diff --git a/.gitignore b/.gitignore index 52a0cca..43b0b22 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ build/* *.jar build .gradle +*.txt diff --git a/README.md b/README.md index bf63ccb..cf19210 100644 --- a/README.md +++ b/README.md @@ -132,14 +132,43 @@ java -jar j3270.jar -d mainframe.example.com ## 🛠️ Building from Source -### Standalone Build (No Gradle Required) -The project provides self-contained, portable build scripts requiring only a JDK: +### Quick Setup & Build for End Users + +Self-contained setup scripts automatically locate an installed Java 11+ JDK (searching `JAVA_HOME`, system `PATH`, macOS `java_home`, Homebrew, Linux JVM paths, Windows Registry, standard installation directories, and package managers), compile `lib3270j` and `j3270`, and package the standalone executable JAR (`build/j3270.jar`): + +**Linux / macOS (POSIX Shell):** +```bash +# Setup and build standalone executable JAR: +./setup.sh + +# Build and immediately launch: +./setup.sh --run + +# Clean build and connect to host: +./setup.sh --clean --run -- mainframe.example.com 23 4 +``` + +**Windows (PowerShell):** +```powershell +# Setup and build standalone executable JAR: +.\setup.ps1 + +# Build and immediately launch: +.\setup.ps1 -Run + +# Clean build and connect to host: +.\setup.ps1 -Clean -Run -- mainframe.example.com 23 4 +``` +*(Also available as `.\setup.ps`)* + +### Standalone Build (Advanced / CI) +The project also provides low-level scripts and optional Gradle support: ```bash -# Build standalone executable JAR (build/j3270.jar): +# Direct compile and package without diagnostics: sh ./build_all.sh -# Run all 539 automated unit tests across 88 test containers (~7s): +# Run all 539+ automated unit tests (~7s): sh ./test_all.sh ``` diff --git a/build.gradle b/build.gradle index 8330c48..0b6bdc6 100644 --- a/build.gradle +++ b/build.gradle @@ -17,6 +17,7 @@ subprojects { test { useJUnitPlatform() + jvmArgs '-Djava.awt.headless=true' testLogging { events "passed", "skipped", "failed" } @@ -24,5 +25,6 @@ subprojects { dependencies { testImplementation 'org.junit.jupiter:junit-jupiter:5.10.2' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' } } diff --git a/build_all.sh b/build_all.sh index 061e89a..72be89e 100755 --- a/build_all.sh +++ b/build_all.sh @@ -14,16 +14,32 @@ if [ -n "$JAVA_HOME" ] && [ -x "$JAVA_HOME/bin/javac" ] && [ -x "$JAVA_HOME/bin/ JAVAC_BIN="$JAVA_HOME/bin/javac" JAR_BIN="$JAVA_HOME/bin/jar" elif command -v javac >/dev/null 2>&1 && command -v jar >/dev/null 2>&1; then - JAVAC_BIN="$(command -v javac)" - JAR_BIN="$(command -v jar)" -else - # Check common SDKMAN / macOS / Linux JDK locations - for h in "$HOME/.sdkman/candidates/java/current" \ + if javac -version >/dev/null 2>&1; then + JAVAC_BIN="$(command -v javac)" + JAR_BIN="$(command -v jar)" + fi +fi + +if [ -z "$JAVAC_BIN" ] || [ -z "$JAR_BIN" ]; then + if [ -x /usr/libexec/java_home ]; then + MAC_JAVA_HOME="$(/usr/libexec/java_home 2>/dev/null || true)" + if [ -n "$MAC_JAVA_HOME" ] && [ -x "$MAC_JAVA_HOME/bin/javac" ] && [ -x "$MAC_JAVA_HOME/bin/jar" ]; then + export JAVA_HOME="$MAC_JAVA_HOME" + JAVAC_BIN="$JAVA_HOME/bin/javac" + JAR_BIN="$JAVA_HOME/bin/jar" + fi + fi +fi + +if [ -z "$JAVAC_BIN" ] || [ -z "$JAR_BIN" ]; then + # Check standard system JDK installation locations + for h in /usr/lib/jvm/default-java \ /usr/lib/jvm/java-21-openjdk* \ /usr/lib/jvm/java-17-openjdk* \ /usr/lib/jvm/java-11-openjdk* \ - /usr/lib/jvm/default-java \ - /Library/Java/JavaVirtualMachines/*/Contents/Home; do + /Library/Java/JavaVirtualMachines/*/Contents/Home \ + /opt/homebrew/opt/openjdk/libexec/openjdk.jdk/Contents/Home \ + /opt/homebrew/opt/openjdk; do if [ -d "$h" ] && [ -x "$h/bin/javac" ] && [ -x "$h/bin/jar" ]; then export JAVA_HOME="$h" JAVAC_BIN="$JAVA_HOME/bin/javac" diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 1af9e09..37f78a6 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/gradlew b/gradlew index 49bef16..1aa94a4 100755 --- a/gradlew +++ b/gradlew @@ -1,2 +1,249 @@ #!/bin/sh -exec "$(dirname "$0")/build_all.sh" "$@" + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..6689b85 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,92 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/lib3270j/src/test/java/haus/nightmare/lib3270j/integration/LiveHostHeadlessTest.java b/lib3270j/src/test/java/haus/nightmare/lib3270j/integration/LiveHostHeadlessTest.java index 1bd58ef..49e59ec 100644 --- a/lib3270j/src/test/java/haus/nightmare/lib3270j/integration/LiveHostHeadlessTest.java +++ b/lib3270j/src/test/java/haus/nightmare/lib3270j/integration/LiveHostHeadlessTest.java @@ -112,4 +112,48 @@ public class LiveHostHeadlessTest { client.disconnect(); } } + + @Test + @DisplayName("Logon to live MVS host with guest account 'headless'") + public void testLiveMvsGuestLogin() throws Exception { + boolean reachable = false; + try (Socket probe = new Socket()) { + probe.connect(new InetSocketAddress(HOST, PORT), 3000); + reachable = true; + } catch (IOException e) { + logger.warning("Live host " + HOST + ":" + PORT + " unreachable: " + e.getMessage()); + } + + Assumptions.assumeTrue(reachable, "Skipping live test: " + HOST + ":" + PORT + " is not reachable"); + + ConnectionConfig config = new ConnectionConfig(HOST, PORT, TerminalModel.IBM_3279_2); + config.setConnectTimeoutMs(5000); + config.setSoTimeoutMs(10000); + + Telnet3270Client client = new Telnet3270Client(config); + try { + boolean connected = client.connect(10000); + Assumptions.assumeTrue(connected, "Host connection established"); + + // Wait for initial screen buffer to populate + Thread.sleep(1500); + ScreenBuffer sb = client.getScreenBuffer(); + String initialText = sb != null ? sb.getText().trim() : ""; + logger.info("Initial screen before logon: " + + initialText.substring(0, Math.min(150, initialText.length())).replaceAll("\\s+", " ")); + + // Send guest account 'headless' + client.sendKeys("headless[enter]"); + Thread.sleep(2000); + + String responseText = sb != null ? sb.getText().trim() : ""; + logger.info("Screen after typing 'headless': " + + responseText.substring(0, Math.min(150, responseText.length())).replaceAll("\\s+", " ")); + + assertNotNull(responseText, "Screen response should not be null"); + assertFalse(responseText.isEmpty(), "Screen response should not be empty"); + } finally { + client.disconnect(); + } + } } diff --git a/lib3270j/src/test/java/haus/nightmare/lib3270j/testutil/Mock3270Server.java b/lib3270j/src/test/java/haus/nightmare/lib3270j/testutil/Mock3270Server.java new file mode 100644 index 0000000..51af59d --- /dev/null +++ b/lib3270j/src/test/java/haus/nightmare/lib3270j/testutil/Mock3270Server.java @@ -0,0 +1,570 @@ +package haus.nightmare.lib3270j.testutil; + +import haus.nightmare.lib3270j.charset.EbcdicTranslator; +import haus.nightmare.lib3270j.protocol.DS3270Constants; +import haus.nightmare.lib3270j.protocol.TN3270EConstants; +import haus.nightmare.lib3270j.protocol.TelnetConstants; + +import java.io.*; +import java.net.*; +import java.util.*; +import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.logging.Level; +import java.util.logging.Logger; + +import static haus.nightmare.lib3270j.protocol.DS3270Constants.*; +import static haus.nightmare.lib3270j.protocol.TN3270EConstants.*; +import static haus.nightmare.lib3270j.protocol.TelnetConstants.*; + +/** + * Lightweight, in-memory mock TN3270E server running on loopback (127.0.0.1). + * + * Implements RFC 2355 Telnet negotiation, Device-Type subnegotiation, + * BIND image dispatch, formatted 3270 data stream framing (Write / Erase-Write), + * Contention Resolution (SDI / KRI), Keep-Alive tracking, and disconnect simulation. + */ +public class Mock3270Server implements AutoCloseable { + + private static final Logger log = Logger.getLogger(Mock3270Server.class.getName()); + + private final ServerSocket serverSocket; + private final int port; + private final EbcdicTranslator translator; + private final ExecutorService executor = Executors.newCachedThreadPool(r -> { + Thread t = new Thread(r, "Mock3270Server-Worker"); + t.setDaemon(true); + return t; + }); + + private volatile boolean running = true; + private volatile Socket currentSocket; + private volatile OutputStream clientOut; + private volatile InputStream clientIn; + + private boolean negotiateBindImage = true; + private boolean negotiateContentionResolution = true; + private boolean autoSendBind = true; + private String assignedDeviceType = "IBM-3279-2-E"; + private String assignedLu = "MOCKLU01"; + private int primaryRows = 24; + private int primaryCols = 80; + private int altRows = 24; + private int altCols = 80; + private int sequenceNumber = 0; + + private final List receivedRecords = new CopyOnWriteArrayList<>(); + private final AtomicInteger receivedNops = new AtomicInteger(0); + private final AtomicInteger receivedTimingMarks = new AtomicInteger(0); + + private volatile CountDownLatch connectionLatch = new CountDownLatch(1); + private volatile CountDownLatch handshakeLatch = new CountDownLatch(1); + private volatile CountDownLatch dataReceivedLatch = new CountDownLatch(1); + + public Mock3270Server() throws IOException { + this(0); + } + + public Mock3270Server(int requestedPort) throws IOException { + this.serverSocket = new ServerSocket(requestedPort, 10, InetAddress.getByName("127.0.0.1")); + this.port = serverSocket.getLocalPort(); + this.translator = new EbcdicTranslator(); + executor.submit(this::acceptLoop); + log.info("Mock3270Server started on 127.0.0.1:" + port); + } + + public int getPort() { + return port; + } + + public void setNegotiateBindImage(boolean negotiate) { + this.negotiateBindImage = negotiate; + } + + public void setNegotiateContentionResolution(boolean cr) { + this.negotiateContentionResolution = cr; + } + + public void setAutoSendBind(boolean autoSendBind) { + this.autoSendBind = autoSendBind; + } + + public void setAssignedDeviceType(String assignedDeviceType) { + this.assignedDeviceType = assignedDeviceType; + } + + public void setAssignedLu(String assignedLu) { + this.assignedLu = assignedLu; + } + + public void setDimensions(int pRows, int pCols, int aRows, int aCols) { + this.primaryRows = pRows; + this.primaryCols = pCols; + this.altRows = aRows; + this.altCols = aCols; + } + + public boolean waitForConnection(long timeout, TimeUnit unit) throws InterruptedException { + if (currentSocket != null && currentSocket.isConnected() && !currentSocket.isClosed()) { + return true; + } + return connectionLatch.await(timeout, unit); + } + + public boolean waitForHandshake(long timeout, TimeUnit unit) throws InterruptedException { + return handshakeLatch.await(timeout, unit); + } + + public boolean waitForClientData(long timeout, TimeUnit unit) throws InterruptedException { + if (!receivedRecords.isEmpty()) { + return true; + } + return dataReceivedLatch.await(timeout, unit); + } + + public List getReceivedRecords() { + return Collections.unmodifiableList(receivedRecords); + } + + public byte[] getLastReceivedRecord() { + if (receivedRecords.isEmpty()) return null; + return receivedRecords.get(receivedRecords.size() - 1); + } + + public int getReceivedNops() { + return receivedNops.get(); + } + + public int getReceivedTimingMarks() { + return receivedTimingMarks.get(); + } + + public void resetDataLatch() { + dataReceivedLatch = new CountDownLatch(1); + } + + private void acceptLoop() { + while (running) { + try { + Socket s = serverSocket.accept(); + s.setTcpNoDelay(true); + log.info("Mock3270Server: accepted client connection from " + s.getRemoteSocketAddress()); + synchronized (this) { + this.currentSocket = s; + this.clientIn = new BufferedInputStream(s.getInputStream()); + this.clientOut = new BufferedOutputStream(s.getOutputStream()); + } + connectionLatch.countDown(); + executor.submit(() -> handleClient(s)); + } catch (SocketException e) { + if (!running) break; + } catch (Exception e) { + if (running) { + log.log(Level.WARNING, "Error in acceptLoop", e); + } + } + } + } + + private void handleClient(Socket s) { + try { + // 1. Initial Telnet option offer + sendInitialNegotiation(); + + // 2. Read incoming telnet options and data loop + ByteArrayOutputStream recordBuf = new ByteArrayOutputStream(); + ByteArrayOutputStream sbBuf = new ByteArrayOutputStream(); + int b; + boolean inIAC = false; + int iacCommand = -1; + boolean inSB = false; + + while (running && !s.isClosed() && (b = clientIn.read()) != -1) { + b &= 0xFF; + + if (inSB) { + if (inIAC) { + if (b == SE) { + inSB = false; + inIAC = false; + handleSubnegotiation(sbBuf.toByteArray()); + sbBuf.reset(); + } else if (b == IAC) { + sbBuf.write(IAC); + inIAC = false; + } else { + inIAC = false; + } + } else if (b == IAC) { + inIAC = true; + } else { + sbBuf.write(b); + } + continue; + } + + if (inIAC) { + if (iacCommand == -1) { + iacCommand = b; + switch (b) { + case IAC: // Escaped 0xFF in data stream + recordBuf.write(IAC); + inIAC = false; + iacCommand = -1; + break; + case SB: + inSB = true; + inIAC = false; + iacCommand = -1; + sbBuf.reset(); + break; + case EOR: + // End of record framing + byte[] fullRecord = recordBuf.toByteArray(); + recordBuf.reset(); + inIAC = false; + iacCommand = -1; + onRecordReceived(fullRecord); + break; + case NOP: + receivedNops.incrementAndGet(); + log.fine("Mock3270Server: RCVD IAC NOP"); + inIAC = false; + iacCommand = -1; + break; + case DO: + case DONT: + case WILL: + case WONT: + // Option follows next byte + break; + default: + inIAC = false; + iacCommand = -1; + break; + } + } else { + // 3-byte command: IAC + handleOptionCommand(iacCommand, b); + inIAC = false; + iacCommand = -1; + } + } else if (b == IAC) { + inIAC = true; + iacCommand = -1; + } else { + recordBuf.write(b); + } + } + } catch (SocketException e) { + log.fine("Client socket closed: " + e.getMessage()); + } catch (Exception e) { + if (running) { + log.log(Level.WARNING, "Error in handleClient", e); + } + } finally { + cleanupClientSocket(s); + } + } + + private synchronized void sendInitialNegotiation() throws IOException { + if (clientOut == null) return; + ByteArrayOutputStream out = new ByteArrayOutputStream(); + // Server offers DO TN3270E, DO TRANSMIT-BINARY, WILL TRANSMIT-BINARY, DO EOR, WILL EOR + out.write(new byte[] { + (byte) IAC, (byte) DO, (byte) TELOPT_TN3270E, + (byte) IAC, (byte) DO, (byte) TELOPT_BINARY, + (byte) IAC, (byte) WILL, (byte) TELOPT_BINARY, + (byte) IAC, (byte) DO, (byte) TELOPT_EOR, + (byte) IAC, (byte) WILL, (byte) TELOPT_EOR + }); + clientOut.write(out.toByteArray()); + clientOut.flush(); + log.info("Mock3270Server: sent initial negotiation"); + } + + private void handleOptionCommand(int cmd, int opt) throws IOException { + log.fine("Mock3270Server: RCVD IAC " + cmd + " opt=" + opt); + if (cmd == WILL && opt == TELOPT_TN3270E) { + // Client accepted TN3270E -> Send SB TN3270E SEND DEVICE-TYPE IAC SE + sendDeviceTypeSend(); + } else if (cmd == DO && opt == TELOPT_TM) { + // Client requested Timing Mark keep-alive + receivedTimingMarks.incrementAndGet(); + log.fine("Mock3270Server: RCVD IAC DO TIMING-MARK"); + synchronized (this) { + if (clientOut != null) { + clientOut.write(new byte[] { (byte) IAC, (byte) WILL, (byte) TELOPT_TM }); + clientOut.flush(); + } + } + } + } + + private synchronized void sendDeviceTypeSend() throws IOException { + if (clientOut == null) return; + byte[] devTypeSend = new byte[] { + (byte) IAC, (byte) SB, (byte) TELOPT_TN3270E, + (byte) OP_SEND, (byte) OP_DEVICE_TYPE, + (byte) IAC, (byte) SE + }; + clientOut.write(devTypeSend); + clientOut.flush(); + log.info("Mock3270Server: sent SB TN3270E SEND DEVICE-TYPE SE"); + } + + private void handleSubnegotiation(byte[] sb) throws IOException { + if (sb.length < 2 || (sb[0] & 0xFF) != TELOPT_TN3270E) { + return; + } + int op = sb[1] & 0xFF; + + if (op == OP_DEVICE_TYPE) { + // Client sent DEVICE-TYPE REQUEST [CONNECT ] + int pos = 2; + if (pos < sb.length && (sb[pos] & 0xFF) == OP_REQUEST) pos++; + StringBuilder reqType = new StringBuilder(); + while (pos < sb.length && (sb[pos] & 0xFF) != OP_CONNECT) { + reqType.append((char) (sb[pos] & 0xFF)); + pos++; + } + log.info("Mock3270Server: client requested device-type: " + reqType); + + // Respond with SB TN3270E DEVICE-TYPE IS CONNECT IAC SE + sendDeviceTypeIs(assignedDeviceType, assignedLu); + + } else if (op == OP_FUNCTIONS) { + // Client sent FUNCTIONS REQUEST + List funcs = new ArrayList<>(); + for (int i = 3; i < sb.length; i++) { + funcs.add(sb[i] & 0xFF); + } + log.info("Mock3270Server: client requested functions: " + funcs); + + // Respond with FUNCTIONS IS + sendFunctionsIs(); + + if (autoSendBind && negotiateBindImage) { + sendBindImage(); + } + + handshakeLatch.countDown(); + } + } + + private synchronized void sendDeviceTypeIs(String type, String lu) throws IOException { + if (clientOut == null) return; + ByteArrayOutputStream out = new ByteArrayOutputStream(); + out.write(new byte[] { (byte) IAC, (byte) SB, (byte) TELOPT_TN3270E, (byte) OP_DEVICE_TYPE, (byte) OP_IS }); + out.write(type.getBytes(java.nio.charset.StandardCharsets.US_ASCII)); + if (lu != null && !lu.isEmpty()) { + out.write(OP_CONNECT); + out.write(lu.getBytes(java.nio.charset.StandardCharsets.US_ASCII)); + } + out.write(new byte[] { (byte) IAC, (byte) SE }); + clientOut.write(out.toByteArray()); + clientOut.flush(); + log.info("Mock3270Server: sent SB TN3270E DEVICE-TYPE IS " + type + " CONNECT " + lu + " SE"); + } + + private synchronized void sendFunctionsIs() throws IOException { + if (clientOut == null) return; + ByteArrayOutputStream out = new ByteArrayOutputStream(); + out.write(new byte[] { (byte) IAC, (byte) SB, (byte) TELOPT_TN3270E, (byte) OP_FUNCTIONS, (byte) OP_IS }); + if (negotiateBindImage) { + out.write(FUNC_BIND_IMAGE); + } + out.write(FUNC_RESPONSES); + if (negotiateContentionResolution) { + out.write(FUNC_CONTENTION_RESOLUTION); + } + out.write(FUNC_SYSREQ); + out.write(new byte[] { (byte) IAC, (byte) SE }); + clientOut.write(out.toByteArray()); + clientOut.flush(); + log.info("Mock3270Server: sent SB TN3270E FUNCTIONS IS SE"); + } + + public synchronized void sendBindImage() throws IOException { + if (clientOut == null) return; + // BIND payload: 5-byte header + standard 26-byte BIND image + byte[] bindPayload = new byte[26]; + bindPayload[20] = (byte) primaryRows; + bindPayload[21] = (byte) primaryCols; + bindPayload[22] = (byte) altRows; + bindPayload[23] = (byte) altCols; + bindPayload[24] = (byte) ((altRows == primaryRows && altCols == primaryCols) ? 0x02 : 0x7F); + + sendRecord(DT_BIND_IMAGE, 0, RSF_NO_RESPONSE, bindPayload); + log.info("Mock3270Server: sent BIND image (" + primaryRows + "x" + primaryCols + " / " + altRows + "x" + altCols + ")"); + } + + public synchronized void sendUnbind(int reason) throws IOException { + sendRecord(DT_UNBIND, 0, RSF_NO_RESPONSE, new byte[] { (byte) reason }); + log.info("Mock3270Server: sent UNBIND reason=" + reason); + } + + public synchronized void sendRecord(int dataType, int requestFlag, int responseFlag, byte[] payload) throws IOException { + if (clientOut == null) throw new IOException("No client connected"); + sequenceNumber = (sequenceNumber + 1) & 0xFFFF; + + ByteArrayOutputStream out = new ByteArrayOutputStream(payload.length + 16); + // 5-byte TN3270E header + out.write(dataType); + out.write(requestFlag); + out.write(responseFlag); + out.write((sequenceNumber >> 8) & 0xFF); + out.write(sequenceNumber & 0xFF); + + // Escape payload IAC (0xFF -> 0xFF 0xFF) + for (byte b : payload) { + int val = b & 0xFF; + out.write(val); + if (val == IAC) { + out.write(IAC); + } + } + + // End of Record framing + out.write(IAC); + out.write(EOR); + + clientOut.write(out.toByteArray()); + clientOut.flush(); + } + + public synchronized void sendEraseWrite(int wcc, byte[] data, boolean sdi, boolean kri) throws IOException { + ByteArrayOutputStream payload = new ByteArrayOutputStream(data.length + 2); + payload.write(CMD_ERASE_WRITE); // 0xF5 + payload.write(wcc); + payload.write(data); + int rqf = (sdi ? RQF_SEND_DATA : 0) | (kri ? RQF_KEYBOARD_RESTORE : 0); + sendRecord(DT_3270_DATA, rqf, RSF_NO_RESPONSE, payload.toByteArray()); + } + + public synchronized void sendWrite(int wcc, byte[] data, boolean sdi, boolean kri) throws IOException { + ByteArrayOutputStream payload = new ByteArrayOutputStream(data.length + 2); + payload.write(CMD_WRITE); // 0xF1 + payload.write(wcc); + payload.write(data); + int rqf = (sdi ? RQF_SEND_DATA : 0) | (kri ? RQF_KEYBOARD_RESTORE : 0); + sendRecord(DT_3270_DATA, rqf, RSF_NO_RESPONSE, payload.toByteArray()); + } + + public synchronized void sendFormattedScreen(String text, int row, int col, boolean unlockKeyboard) throws IOException { + ByteArrayOutputStream data = new ByteArrayOutputStream(); + int addr = row * primaryCols + col; + byte[] sba = DS3270Constants.encodeAddress(addr, primaryRows, primaryCols); + + // SBA to target position + data.write(ORDER_SBA); + data.write(sba[0] & 0xFF); + data.write(sba[1] & 0xFF); + + // Start Field (unprotected: 0x40 or 0x41) + data.write(ORDER_SF); + data.write(0x40); + + // Write EBCDIC text + for (char c : text.toCharArray()) { + data.write(translator.unicodeToEbcdicSafe(c)); + } + + // Insert Cursor + data.write(ORDER_IC); + + // WCC: 0xC3 (sound alarm + unlock keyboard + reset MDT) or 0xC0 (locked) + int wcc = unlockKeyboard ? 0xC3 : 0xC0; + sendEraseWrite(wcc, data.toByteArray(), true, unlockKeyboard); + } + + public synchronized void sendFragmented(byte[] rawData, int chunkSize, long delayMs) throws IOException, InterruptedException { + if (clientOut == null) throw new IOException("No client connected"); + int offset = 0; + while (offset < rawData.length) { + int len = Math.min(chunkSize, rawData.length - offset); + clientOut.write(rawData, offset, len); + clientOut.flush(); + offset += len; + if (delayMs > 0 && offset < rawData.length) { + Thread.sleep(delayMs); + } + } + } + + public synchronized void sendRawBytes(byte[] raw) throws IOException { + if (clientOut == null) throw new IOException("No client connected"); + clientOut.write(raw); + clientOut.flush(); + } + + public synchronized void disconnectClient() { + if (currentSocket != null) { + try { + log.info("Mock3270Server: intentionally disconnecting client"); + currentSocket.close(); + } catch (Exception ignored) {} + cleanupClientSocket(currentSocket); + } + } + + private void onRecordReceived(byte[] record) { + log.info("Mock3270Server: received record length=" + record.length); + receivedRecords.add(record); + dataReceivedLatch.countDown(); + } + + public int getLastReceivedAID() { + byte[] last = getLastReceivedRecord(); + if (last == null || last.length <= EH_SIZE) return -1; + return last[EH_SIZE] & 0xFF; + } + + public int getLastReceivedCursorAddress() { + byte[] last = getLastReceivedRecord(); + if (last == null || last.length < EH_SIZE + 3) return -1; + int b1 = last[EH_SIZE + 1] & 0xFF; + int b2 = last[EH_SIZE + 2] & 0xFF; + return DS3270Constants.decodeAddress(b1, b2); + } + + public String getLastReceivedFieldText() { + byte[] last = getLastReceivedRecord(); + if (last == null || last.length <= EH_SIZE + 3) return ""; + StringBuilder sb = new StringBuilder(); + // Skip header + AID (1) + cursor address (2) + int i = EH_SIZE + 3; + while (i < last.length) { + int b = last[i] & 0xFF; + if (b == ORDER_SBA) { + i += 3; + continue; + } + sb.append(translator.ebcdicToUnicode(b)); + i++; + } + return sb.toString(); + } + + private synchronized void cleanupClientSocket(Socket s) { + if (currentSocket == s) { + currentSocket = null; + clientIn = null; + clientOut = null; + } + try { + s.close(); + } catch (Exception ignored) {} + } + + @Override + public void close() { + running = false; + try { + serverSocket.close(); + } catch (Exception ignored) {} + disconnectClient(); + executor.shutdownNow(); + log.info("Mock3270Server stopped"); + } +} diff --git a/lib3270j/src/test/java/haus/nightmare/lib3270j/testutil/Mock3270ServerIntegrationTest.java b/lib3270j/src/test/java/haus/nightmare/lib3270j/testutil/Mock3270ServerIntegrationTest.java new file mode 100644 index 0000000..7d9e14b --- /dev/null +++ b/lib3270j/src/test/java/haus/nightmare/lib3270j/testutil/Mock3270ServerIntegrationTest.java @@ -0,0 +1,293 @@ +package haus.nightmare.lib3270j.testutil; + +import haus.nightmare.lib3270j.ConnectionConfig; +import haus.nightmare.lib3270j.ConnectionState; +import haus.nightmare.lib3270j.Telnet3270Client; +import haus.nightmare.lib3270j.TerminalModel; +import haus.nightmare.lib3270j.ecl.ECLConstants; +import haus.nightmare.lib3270j.ecl.ECLPS; +import haus.nightmare.lib3270j.ecl.ECLScreenDesc; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicBoolean; + +import static haus.nightmare.lib3270j.protocol.DS3270Constants.*; +import static haus.nightmare.lib3270j.protocol.TN3270EConstants.*; +import static haus.nightmare.lib3270j.protocol.TelnetConstants.*; +import static org.junit.jupiter.api.Assertions.*; + +@Timeout(value = 30, unit = TimeUnit.SECONDS) +public class Mock3270ServerIntegrationTest { + + private Mock3270Server server; + private Telnet3270Client client; + + @BeforeEach + public void setup() throws IOException { + server = new Mock3270Server(); + } + + @AfterEach + public void teardown() { + if (client != null) { + client.disconnect(); + client = null; + } + if (server != null) { + server.close(); + server = null; + } + } + + private void waitForState(Telnet3270Client cl, ConnectionState targetState, long timeoutMs) throws Exception { + long deadline = System.currentTimeMillis() + timeoutMs; + while (System.currentTimeMillis() < deadline) { + if (cl.getConnectionState() == targetState) { + return; + } + Thread.sleep(25); + } + assertEquals(targetState, cl.getConnectionState()); + } + + @Test + public void testSuccessfulConnectionAndHandshake() throws Exception { + ConnectionConfig config = new ConnectionConfig("127.0.0.1", server.getPort(), TerminalModel.IBM_3279_2); + config.setConnectTimeoutMs(5000); + client = new Telnet3270Client(config); + + boolean connected = client.connect(8000); + assertTrue(connected, "Client should establish connected session"); + + boolean handshakeDone = server.waitForHandshake(5, TimeUnit.SECONDS); + assertTrue(handshakeDone, "Mock server should complete TN3270E handshake"); + + waitForState(client, ConnectionState.CONNECTED_TN3270E, 5000); + assertEquals("MOCKLU01", client.getTelnetFSM().getConnectedLu()); + } + + @Test + public void testSendScreenAndVerifyPresentationSpace() throws Exception { + ConnectionConfig config = new ConnectionConfig("127.0.0.1", server.getPort(), TerminalModel.IBM_3279_2); + client = new Telnet3270Client(config); + assertTrue(client.connect(8000)); + assertTrue(server.waitForHandshake(5, TimeUnit.SECONDS)); + waitForState(client, ConnectionState.CONNECTED_TN3270E, 5000); + + // Server sends formatted greeting screen at row 2, col 10 + String banner = "WELCOME TO TN3270E SERVER"; + server.sendFormattedScreen(banner, 2, 10, true); + + // Wait up to 3s for screen update + ECLPS ps = client.getPS(); + boolean matched = false; + long deadline = System.currentTimeMillis() + 3000; + while (System.currentTimeMillis() < deadline) { + String text = ps.getString(2, 11, banner.length()); + if (banner.equals(text)) { + matched = true; + break; + } + Thread.sleep(20); + } + + assertTrue(matched, "Presentation space should contain banner string"); + assertFalse(client.getInputProcessor().isKeyboardLocked(), "Keyboard should be unlocked after WCC restore"); + } + + @Test + public void testClientUserInputAndAidTransmission() throws Exception { + ConnectionConfig config = new ConnectionConfig("127.0.0.1", server.getPort(), TerminalModel.IBM_3279_2); + client = new Telnet3270Client(config); + assertTrue(client.connect(8000)); + assertTrue(server.waitForHandshake(5, TimeUnit.SECONDS)); + waitForState(client, ConnectionState.CONNECTED_TN3270E, 5000); + + // Send screen with unprotected field at row 4, col 10 + server.sendFormattedScreen("ENTER DATA: ", 4, 10, true); + Thread.sleep(100); + + // Client positions cursor inside unprotected field (col 23) and enters text + client.getPS().setCursorPos(4, 23); + client.sendKeys("TESTDATA[enter]"); + + // Verify mock server receives AID and field data + assertTrue(server.waitForClientData(5, TimeUnit.SECONDS), "Server must receive client AID submission"); + assertEquals(0x7D, server.getLastReceivedAID(), "AID should be ENTER (0x7D)"); + assertTrue(server.getLastReceivedFieldText().contains("TESTDATA"), + "Field text should contain submitted characters 'TESTDATA'"); + } + + @Test + public void testContentionResolutionAndKeyboardLock() throws Exception { + server.setNegotiateContentionResolution(true); + ConnectionConfig config = new ConnectionConfig("127.0.0.1", server.getPort(), TerminalModel.IBM_3279_2); + client = new Telnet3270Client(config); + client.getTelnetFSM().setNegotiateContentionResolution(true); + assertTrue(client.connect(8000)); + assertTrue(server.waitForHandshake(5, TimeUnit.SECONDS)); + waitForState(client, ConnectionState.CONNECTED_TN3270E, 5000); + + // 1. Lock keyboard explicitly (as happens when user presses AID) + client.getInputProcessor().setKeyboardLocked(true); + assertTrue(client.getInputProcessor().isKeyboardLocked()); + + // Send write with WCC locked (0xC0) and sdi = false + byte[] dummyData = new byte[] { (byte) ORDER_SBA, 0x40, 0x40, (byte) ORDER_SF, 0x60 }; + server.sendWrite(0xC0, dummyData, false, false); + Thread.sleep(100); + + assertTrue(client.getInputProcessor().isKeyboardLocked(), + "Keyboard must remain locked when SDI is not signaled under contention resolution"); + + // 2. Unlock via Contention Resolution: send write with SDI flag = true (RQF_SEND_DATA) + server.sendWrite(0xC0, dummyData, true, false); + Thread.sleep(100); + + assertFalse(client.getInputProcessor().isKeyboardLocked(), + "Keyboard must unlock upon receiving SDI flag on EOR"); + } + + @Test + public void testAutoSysUnlockOnEor() throws Exception { + server.setNegotiateContentionResolution(false); + ConnectionConfig config = new ConnectionConfig("127.0.0.1", server.getPort(), TerminalModel.IBM_3279_2); + config.setAutoSysUnlock(true); + client = new Telnet3270Client(config); + client.getTelnetFSM().setNegotiateContentionResolution(false); + assertTrue(client.connect(8000)); + assertTrue(server.waitForHandshake(5, TimeUnit.SECONDS)); + waitForState(client, ConnectionState.CONNECTED_TN3270E, 5000); + + // Lock keyboard initially + client.getInputProcessor().setKeyboardLocked(true); + client.getOIA().setInputInhibited(ECLConstants.INHIBIT_SYSTEM_LOCK); + assertTrue(client.getInputProcessor().isKeyboardLocked()); + + // Host sends a write record without WCC restore, but autoSysUnlock is active on EOR + byte[] dummyData = new byte[] { (byte) ORDER_SBA, 0x40, 0x40, (byte) ORDER_SF, 0x60 }; + server.sendWrite(0xC0, dummyData, false, false); + Thread.sleep(100); + + assertFalse(client.getInputProcessor().isKeyboardLocked(), + "autoSysUnlock must unlock keyboard on EOR when not in Read command"); + assertFalse(client.getOIA().isXSystem(), "OIA X SYSTEM indicator must be cleared"); + } + + @Test + public void testAutomatedWaitMethods() throws Exception { + ConnectionConfig config = new ConnectionConfig("127.0.0.1", server.getPort(), TerminalModel.IBM_3279_2); + client = new Telnet3270Client(config); + assertTrue(client.connect(8000)); + assertTrue(server.waitForHandshake(5, TimeUnit.SECONDS)); + waitForState(client, ConnectionState.CONNECTED_TN3270E, 5000); + + ECLScreenDesc desc = new ECLScreenDesc(); + desc.addString("LOGIN_READY"); + + CountDownLatch waitLatch = new CountDownLatch(1); + AtomicBoolean waitResult = new AtomicBoolean(false); + + // Run waitForScreen in background thread + CompletableFuture.runAsync(() -> { + boolean res = client.getPS().waitForScreen(desc, 5000); + waitResult.set(res); + waitLatch.countDown(); + }); + + // Delay 80ms then send matching screen from mock server + Thread.sleep(80); + server.sendFormattedScreen("LOGIN_READY", 1, 9, true); + + boolean awakened = waitLatch.await(2000, TimeUnit.MILLISECONDS); + assertTrue(awakened, "waitForScreen must wake reactively upon screen update"); + assertTrue(waitResult.get(), "waitForScreen must return true when screen matches descriptor"); + } + + @Test + public void testKeepAliveHeartbeatEngine() throws Exception { + ConnectionConfig config = new ConnectionConfig("127.0.0.1", server.getPort(), TerminalModel.IBM_3279_2); + config.setKeepAliveEnabled(true); + config.setKeepAliveIntervalSeconds(1); + config.setKeepAliveType("NOP"); + + client = new Telnet3270Client(config); + assertTrue(client.connect(8000)); + assertTrue(server.waitForHandshake(5, TimeUnit.SECONDS)); + waitForState(client, ConnectionState.CONNECTED_TN3270E, 5000); + + // Idle for 2.5 seconds to allow keepalive heartbeat to fire + Thread.sleep(2500); + + assertTrue(server.getReceivedNops() >= 1, + "Server must have received at least 1 IAC NOP heartbeat, received: " + server.getReceivedNops()); + } + + @Test + public void testAutoReconnectOnSocketDisconnect() throws Exception { + ConnectionConfig config = new ConnectionConfig("127.0.0.1", server.getPort(), TerminalModel.IBM_3279_2); + config.setAutoReconnect(true); + config.setReconnectMaxRetries(3); + + client = new Telnet3270Client(config); + assertTrue(client.connect(8000)); + assertTrue(server.waitForHandshake(5, TimeUnit.SECONDS)); + waitForState(client, ConnectionState.CONNECTED_TN3270E, 5000); + + // Disconnect active client connection abruptly + server.disconnectClient(); + + // Wait for client to detect disconnect and reconnect automatically + waitForState(client, ConnectionState.CONNECTED_TN3270E, 8000); + assertEquals(ConnectionState.CONNECTED_TN3270E, client.getConnectionState()); + } + + @Test + public void testPacketFragmentation() throws Exception { + ConnectionConfig config = new ConnectionConfig("127.0.0.1", server.getPort(), TerminalModel.IBM_3279_2); + client = new Telnet3270Client(config); + assertTrue(client.connect(8000)); + assertTrue(server.waitForHandshake(5, TimeUnit.SECONDS)); + waitForState(client, ConnectionState.CONNECTED_TN3270E, 5000); + + // Build raw 3270 Erase/Write packet with 5-byte header + command + SBA + SF + data + IAC EOR + ByteArrayOutputStream buf = new ByteArrayOutputStream(); + // 5-byte header + buf.write(new byte[] { (byte) DT_3270_DATA, 0x00, 0x00, 0x00, 0x01 }); + buf.write(CMD_ERASE_WRITE); // 0xF5 + buf.write(0xC3); // WCC + buf.write(ORDER_SBA); + buf.write(0x40); buf.write(0x40); // row 0, col 0 + buf.write(ORDER_SF); + buf.write(0x40); // unprotected + String text = "FRAGMENTED_TEST"; + for (char c : text.toCharArray()) { + buf.write(client.getScreenBuffer().getTranslator().unicodeToEbcdicSafe(c)); + } + buf.write((byte) IAC); + buf.write((byte) EOR); + + // Send packet in 2-byte fragments with 5ms delays + server.sendFragmented(buf.toByteArray(), 2, 5); + + // Verify presentation space renders the entire reconstructed string + boolean matched = false; + long deadline = System.currentTimeMillis() + 3000; + while (System.currentTimeMillis() < deadline) { + String read = client.getPS().getString(0, 1, text.length()); + if (text.equals(read)) { + matched = true; + break; + } + Thread.sleep(20); + } + + assertTrue(matched, "Fragmented stream must be reassembled and displayed correctly"); + } +} diff --git a/setup.ps b/setup.ps new file mode 100644 index 0000000..2b8864c --- /dev/null +++ b/setup.ps @@ -0,0 +1,31 @@ +<# +.SYNOPSIS + j3270 Terminal Emulator - Setup and Build Script (PowerShell Entrypoint) + Forwards execution directly to setup.ps1 with all supplied parameters. + +.DESCRIPTION + See setup.ps1 for full implementation, parameter definitions, and documentation. +#> + +[CmdletBinding()] +param ( + [switch]$Run, + [switch]$Test, + [switch]$Clean, + [switch]$Help, + [Parameter(ValueFromRemainingArguments = $true)] + [string[]]$RunArgs +) + +$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +if (-not $scriptDir) { + $scriptDir = Get-Location +} + +$ps1Script = Join-Path $scriptDir "setup.ps1" +if (Test-Path $ps1Script) { + & $ps1Script @PSBoundParameters @RunArgs +} else { + Write-Error "setup.ps1 not found in $scriptDir" + exit 1 +} diff --git a/setup.ps1 b/setup.ps1 new file mode 100644 index 0000000..53d0df9 --- /dev/null +++ b/setup.ps1 @@ -0,0 +1,468 @@ +<# +.SYNOPSIS + j3270 Terminal Emulator - Setup and Build Script (PowerShell) + Detects Java 11+ JDK and builds j3270 standalone executable JAR. + +.DESCRIPTION + Searches for a compatible Java Development Kit (JDK 11+ / LTS 11, 17, 21), + verifies compiler (javac) and archiver (jar) toolchains, compiles the lib3270j + protocol core and j3270 Swing desktop application, and packages the standalone + executable JAR file (build/j3270.jar). + +.PARAMETER Run + Launch j3270 immediately after successful build. + +.PARAMETER Test + Run unit test suite after successful build. + +.PARAMETER Clean + Remove previous build artifacts before compiling. + +.PARAMETER Help + Display usage help message. + +.EXAMPLE + .\setup.ps1 + Builds the standalone j3270.jar. + +.EXAMPLE + .\setup.ps1 -Clean -Run + Cleans previous build, builds j3270, and immediately launches the application. + +.EXAMPLE + .\setup.ps1 -Run -- mainframe.example.com 23 4 + Builds and connects to specified host, port, and terminal model. +#> + +[CmdletBinding()] +param ( + [switch]$Run, + [switch]$Test, + [switch]$Clean, + [switch]$Help, + [Parameter(ValueFromRemainingArguments = $true)] + [string[]]$RunArgs +) + +$ErrorActionPreference = "Stop" + +# Resolve script root directory +$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +if (-not $ScriptDir) { + $ScriptDir = Get-Location +} +Set-Location $ScriptDir + +# Helper functions for colored terminal output +function Write-LogInfo($msg) { Write-Host "[INFO] $msg" -ForegroundColor Cyan } +function Write-LogSuccess($msg) { Write-Host "[OK] $msg" -ForegroundColor Green } +function Write-LogWarn($msg) { Write-Host "[WARN] $msg" -ForegroundColor Yellow } +function Write-LogError($msg) { Write-Host "[ERROR] $msg" -ForegroundColor Red } + +if ($Help) { + Write-Host @" +j3270 Terminal Emulator - Setup & Build Script (PowerShell) + +Usage: + .\setup.ps1 [-Run] [-Test] [-Clean] [-Help] [-- ] + +Options: + -Run Build and immediately launch j3270 + -Test Run automated unit tests after build + -Clean Remove previous build artifacts before compiling + -Help Show this help message + +Examples: + .\setup.ps1 + .\setup.ps1 -Clean + .\setup.ps1 -Run + .\setup.ps1 -Run -- mainframe.example.com 23 4 + .\setup.ps1 -Test +"@ + exit 0 +} + +Write-Host "`n=== j3270 Setup & Build (PowerShell) ===`n" -ForegroundColor White + +# Determine platform +$isWindows = if (Test-Path variable:IsWindows) { $IsWindows } else { $env:OS -eq "Windows_NT" } +$exeSuffix = if ($isWindows) { ".exe" } else { "" } + +# ------------------------------------------------------------------------------ +# 1. Helper: Parse Major Java Version +# ------------------------------------------------------------------------------ +function Get-JavaMajorVersion([string]$verStr) { + if (-not $verStr) { return 0 } + if ($verStr -match 'javac\s+([0-9][0-9._]*)' -or + $verStr -match 'version\s+"?([0-9][0-9._]*)"?' -or + $verStr -match '([0-9]+(\.[0-9]+)*)') { + $token = $matches[1] + if ($token -match '^1\.(\d+)') { + return [int]$matches[1] + } + if ($token -match '^(\d+)') { + return [int]$matches[1] + } + } + return 0 +} + +# ------------------------------------------------------------------------------ +# 2. Helper: Test JDK Candidate Directory +# ------------------------------------------------------------------------------ +function Test-JdkCandidate([string]$candHome) { + if (-not $candHome -or -not (Test-Path $candHome)) { + return $null + } + + $javacPath = Join-Path $candHome "bin\javac$exeSuffix" + $jarPath = Join-Path $candHome "bin\jar$exeSuffix" + $javaPath = Join-Path $candHome "bin\java$exeSuffix" + + if (-not (Test-Path $javacPath) -or -not (Test-Path $jarPath) -or -not (Test-Path $javaPath)) { + return $null + } + + try { + $pInfo = New-Object System.Diagnostics.ProcessStartInfo + $pInfo.FileName = $javacPath + $pInfo.Arguments = "-version" + $pInfo.RedirectStandardOutput = $true + $pInfo.RedirectStandardError = $true + $pInfo.UseShellExecute = $false + $pInfo.CreateNoWindow = $true + + $proc = [System.Diagnostics.Process]::Start($pInfo) + $stdout = $proc.StandardOutput.ReadToEnd() + $stderr = $proc.StandardError.ReadToEnd() + $proc.WaitForExit() + + if ($proc.ExitCode -eq 0) { + $verOutput = ("$stdout $stderr").Trim() + $majorVer = Get-JavaMajorVersion $verOutput + + if ($majorVer -ge 11) { + return [PSCustomObject]@{ + Home = $candHome + Javac = $javacPath + Jar = $jarPath + Java = $javaPath + Version = $verOutput + Major = $majorVer + } + } + } + } catch { + # Process execution failed or access denied + } + + return $null +} + +# ------------------------------------------------------------------------------ +# 3. Discover JDK 11+ +# ------------------------------------------------------------------------------ +Write-LogInfo "Searching for a compatible Java Development Kit (JDK 11+)..." + +$detectedJdk = $null + +# Strategy A: Check JAVA_HOME environment variable +if ($env:JAVA_HOME) { + $detectedJdk = Test-JdkCandidate $env:JAVA_HOME + if ($detectedJdk) { + Write-LogInfo "Found valid JDK in `$env:JAVA_HOME: $($detectedJdk.Home)" + } else { + Write-LogWarn "`$env:JAVA_HOME is set ($($env:JAVA_HOME)) but is not a valid JDK 11+." + } +} + +# Strategy B: Check current system PATH +if (-not $detectedJdk) { + $pathJavac = Get-Command "javac$exeSuffix" -ErrorAction SilentlyContinue + if ($pathJavac) { + try { + $realPath = $pathJavac.Source + $candBin = Split-Path -Parent $realPath + $candHome = Split-Path -Parent $candBin + $detectedJdk = Test-JdkCandidate $candHome + if ($detectedJdk) { + Write-LogInfo "Found valid JDK on PATH: $($detectedJdk.Home)" + } + } catch {} + } +} + +# Strategy C: Check Windows Registry (if running on Windows) +if (-not $detectedJdk -and $isWindows) { + $regPaths = @( + "HKLM:\SOFTWARE\JavaSoft\JDK", + "HKLM:\SOFTWARE\JavaSoft\Java Development Kit", + "HKLM:\SOFTWARE\Eclipse Adoptium\JDK", + "HKLM:\SOFTWARE\AdoptOpenJDK\JDK", + "HKLM:\SOFTWARE\Microsoft\JDK", + "HKLM:\SOFTWARE\BellSoft\Liberica JDK", + "HKLM:\SOFTWARE\Zulu\zulu-jdk", + "HKLM:\SOFTWARE\WOW6432Node\JavaSoft\JDK", + "HKLM:\SOFTWARE\WOW6432Node\JavaSoft\Java Development Kit", + "HKLM:\SOFTWARE\WOW6432Node\Eclipse Adoptium\JDK", + "HKLM:\SOFTWARE\WOW6432Node\Microsoft\JDK" + ) + + foreach ($regBase in $regPaths) { + if (Test-Path $regBase) { + $subKeys = Get-ChildItem -Path $regBase -ErrorAction SilentlyContinue + foreach ($key in $subKeys) { + $item = Get-ItemProperty -Path $key.PSPath -ErrorAction SilentlyContinue + if ($item.JavaHome) { + $detectedJdk = Test-JdkCandidate $item.JavaHome + if ($detectedJdk) { break } + } + if ($item.Path) { + $detectedJdk = Test-JdkCandidate $item.Path + if ($detectedJdk) { break } + } + # Check hotspot subkey used by Adoptium/Microsoft MSI installers + $hotspotKey = Join-Path $key.PSPath "hotspot\MSI" + if (Test-Path $hotspotKey) { + $hs = Get-ItemProperty -Path $hotspotKey -ErrorAction SilentlyContinue + if ($hs.Path) { + $detectedJdk = Test-JdkCandidate $hs.Path + if ($detectedJdk) { break } + } + } + } + if ($detectedJdk) { + Write-LogInfo "Found valid JDK via Windows Registry: $($detectedJdk.Home)" + break + } + } + } +} + +# Strategy D: Check Standard Filesystem Locations +if (-not $detectedJdk) { + $fsCandidates = @() + + if ($isWindows) { + $searchRoots = @( + "$env:ProgramFiles\Eclipse Adoptium\*", + "$env:ProgramFiles\Java\*", + "$env:ProgramFiles\Microsoft\*", + "$env:ProgramFiles\Amazon Corretto\*", + "$env:ProgramFiles\Zulu\*", + "$env:ProgramFiles\BellSoft\*", + "$env:ProgramFiles\Semeru\*", + "$env:ProgramFiles\RedHat\*", + "${env:ProgramFiles(x86)}\Java\*", + "$env:LOCALAPPDATA\Programs\Eclipse Adoptium\*", + "$env:LOCALAPPDATA\Programs\Common\Oracle\Java\*", + "$env:USERPROFILE\.jdks\*", + "$env:USERPROFILE\.sdkman\candidates\java\*", + "$env:USERPROFILE\scoop\apps\openjdk\current", + "$env:USERPROFILE\scoop\apps\oraclejdk\current", + "$env:USERPROFILE\scoop\apps\temurin\current", + "$env:USERPROFILE\scoop\apps\zulu\current", + "C:\tools\jdk*", + "C:\Java\*" + ) + foreach ($pattern in $searchRoots) { + Resolve-Path -Path $pattern -ErrorAction SilentlyContinue | ForEach-Object { + $fsCandidates += $_.Path + } + } + } else { + # POSIX / macOS search roots for pwsh + $searchRoots = @( + "/opt/homebrew/opt/openjdk/libexec/openjdk.jdk/Contents/Home", + "/opt/homebrew/opt/openjdk@21/libexec/openjdk.jdk/Contents/Home", + "/opt/homebrew/opt/openjdk@17/libexec/openjdk.jdk/Contents/Home", + "/opt/homebrew/opt/openjdk@11/libexec/openjdk.jdk/Contents/Home", + "/opt/homebrew/opt/openjdk*", + "/usr/local/opt/openjdk/libexec/openjdk.jdk/Contents/Home", + "/Library/Java/JavaVirtualMachines/*/Contents/Home", + "/usr/lib/jvm/default-java", + "/usr/lib/jvm/java-21-openjdk*", + "/usr/lib/jvm/java-17-openjdk*", + "/usr/lib/jvm/java-11-openjdk*", + "/usr/lib/jvm/*", + "$env:HOME/.sdkman/candidates/java/current", + "$env:HOME/.sdkman/candidates/java/*", + "$env:HOME/.asdf/installs/java/*" + ) + foreach ($pattern in $searchRoots) { + Resolve-Path -Path $pattern -ErrorAction SilentlyContinue | ForEach-Object { + $fsCandidates += $_.Path + } + } + } + + foreach ($cand in $fsCandidates) { + $detectedJdk = Test-JdkCandidate $cand + if ($detectedJdk) { + Write-LogInfo "Found valid JDK at: $($detectedJdk.Home)" + break + } + } +} + +# If no compatible JDK is found, report error with helpful instructions +if (-not $detectedJdk) { + Write-LogError "No compatible Java Development Kit (JDK 11+) was found on your system." + Write-Host @" + +j3270 requires a Java Development Kit (JDK) version 11 or higher (LTS 11, 17, or 21). +Note: A JRE (runtime only) is not sufficient; the compiler (javac) and archiver (jar) are required. + +To install JDK 17 on Windows: + • Using Windows Package Manager (winget): + winget install EclipseAdoptium.Temurin.17.JDK + + • Using Chocolatey: + choco install openjdk17 + + • Using Scoop: + scoop install openjdk17 + + • Or download Eclipse Temurin (Adoptium MSI installer): + https://adoptium.net + +After installing, restart PowerShell and re-run .\setup.ps1 +"@ -ForegroundColor Yellow + exit 1 +} + +$env:JAVA_HOME = $detectedJdk.Home +Write-LogSuccess "JDK verified: $($detectedJdk.Version)" +Write-LogInfo "Java compiler: $($detectedJdk.Javac)" +Write-LogInfo "JAR packager: $($detectedJdk.Jar)" +Write-LogInfo "Java runtime: $($detectedJdk.Java)" +Write-LogInfo "JAVA_HOME: $($detectedJdk.Home)" +Write-Host "" + +# ------------------------------------------------------------------------------ +# 4. Preparation & Clean +# ------------------------------------------------------------------------------ +$BuildDir = Join-Path $ScriptDir "build" +$LibBuildDir = Join-Path $BuildDir "lib3270j" +$AppBuildDir = Join-Path $BuildDir "j3270" + +if ($Clean) { + Write-LogInfo "Cleaning build directory ($BuildDir)..." + Remove-Item -Path $LibBuildDir, $AppBuildDir -Recurse -Force -ErrorAction SilentlyContinue + Remove-Item -Path (Join-Path $BuildDir "MANIFEST.MF") -Force -ErrorAction SilentlyContinue + Remove-Item -Path (Join-Path $BuildDir "j3270.jar") -Force -ErrorAction SilentlyContinue +} + +$null = New-Item -ItemType Directory -Path $LibBuildDir -Force +$null = New-Item -ItemType Directory -Path $AppBuildDir -Force + +# ------------------------------------------------------------------------------ +# 5. Compile Core Protocol Engine (lib3270j) +# ------------------------------------------------------------------------------ +Write-LogInfo "Compiling lib3270j core engine..." +$libSrcDir = Join-Path $ScriptDir "lib3270j\src\main\java" +$libSources = Get-ChildItem -Path $libSrcDir -Filter *.java -Recurse | Select-Object -ExpandProperty FullName + +if (-not $libSources -or $libSources.Count -eq 0) { + Write-LogError "No source files found in $libSrcDir" + exit 1 +} + +$libSourcesFile = Join-Path $BuildDir "lib_sources.txt" +$libSources | ForEach-Object { "`"$_`"" } | Set-Content -Path $libSourcesFile -Encoding UTF8 + +& $detectedJdk.Javac -d $LibBuildDir "@$libSourcesFile" +if ($LASTEXITCODE -ne 0) { + Write-LogError "Compilation of lib3270j failed with exit code $LASTEXITCODE." + exit $LASTEXITCODE +} +Remove-Item -Path $libSourcesFile -Force -ErrorAction SilentlyContinue +Write-LogSuccess "lib3270j compiled successfully." + +# ------------------------------------------------------------------------------ +# 6. Compile Desktop Application (j3270) +# ------------------------------------------------------------------------------ +Write-LogInfo "Compiling j3270 terminal emulator..." +$appSrcDir = Join-Path $ScriptDir "j3270\src\main\java" +$appSources = Get-ChildItem -Path $appSrcDir -Filter *.java -Recurse | Select-Object -ExpandProperty FullName + +if (-not $appSources -or $appSources.Count -eq 0) { + Write-LogError "No source files found in $appSrcDir" + exit 1 +} + +$appSourcesFile = Join-Path $BuildDir "app_sources.txt" +$appSources | ForEach-Object { "`"$_`"" } | Set-Content -Path $appSourcesFile -Encoding UTF8 + +& $detectedJdk.Javac -cp $LibBuildDir -d $AppBuildDir "@$appSourcesFile" +if ($LASTEXITCODE -ne 0) { + Write-LogError "Compilation of j3270 failed with exit code $LASTEXITCODE." + exit $LASTEXITCODE +} +Remove-Item -Path $appSourcesFile -Force -ErrorAction SilentlyContinue +Write-LogSuccess "j3270 application compiled successfully." + +# Copy any resources if present +$appResDir = Join-Path $ScriptDir "j3270\src\main\resources" +if (Test-Path $appResDir) { + Copy-Item -Path (Join-Path $appResDir "*") -Destination $AppBuildDir -Recurse -Force -ErrorAction SilentlyContinue +} +$libResDir = Join-Path $ScriptDir "lib3270j\src\main\resources" +if (Test-Path $libResDir) { + Copy-Item -Path (Join-Path $libResDir "*") -Destination $LibBuildDir -Recurse -Force -ErrorAction SilentlyContinue +} + +# ------------------------------------------------------------------------------ +# 7. Package Standalone Executable JAR +# ------------------------------------------------------------------------------ +Write-LogInfo "Packaging standalone executable JAR..." +$manifestFile = Join-Path $BuildDir "MANIFEST.MF" +$manifestText = "Manifest-Version: 1.0`r`nMain-Class: haus.nightmare.j3270.J3270App`r`nImplementation-Title: j3270`r`nImplementation-Version: 0.1.0`r`nCreated-By: j3270 setup.ps1`r`n`r`n" +[System.IO.File]::WriteAllText($manifestFile, $manifestText) + +$jarFile = Join-Path $BuildDir "j3270.jar" +& $detectedJdk.Jar cvfm $jarFile $manifestFile -C $LibBuildDir . -C $AppBuildDir . | Out-Null + +if (-not (Test-Path $jarFile)) { + Write-LogError "Failed to create executable JAR at $jarFile" + exit 1 +} + +$jarItem = Get-Item $jarFile +$jarSizeKB = [math]::Round($jarItem.Length / 1KB, 1) + +Write-Host "`n=== Build Successful ===`n" -ForegroundColor Green +Write-LogSuccess "Executable JAR created: $jarFile (${jarSizeKB} KB)" + +Write-Host @" + +To run j3270: + java -jar "$jarFile" + # Or with arguments: + java -jar "$jarFile" mainframe.example.com 23 4 +"@ -ForegroundColor White + +# ------------------------------------------------------------------------------ +# 8. Run Tests (if requested) +# ------------------------------------------------------------------------------ +if ($Test) { + Write-Host "`n=== Running Unit Tests ===`n" -ForegroundColor White + $testScript = Join-Path $ScriptDir "test_all.sh" + if (Test-Path $testScript) { + & sh $testScript + } else { + Write-LogWarn "No test runner script found at $testScript" + } +} + +# ------------------------------------------------------------------------------ +# 9. Launch Application (if requested) +# ------------------------------------------------------------------------------ +if ($Run) { + Write-LogInfo "Launching j3270..." + if ($isWindows) { + Start-Process -FilePath $detectedJdk.Java -ArgumentList (@("-jar", "`"$jarFile`"") + $RunArgs) + } else { + & $detectedJdk.Java -jar $jarFile @RunArgs + } +} diff --git a/setup.sh b/setup.sh new file mode 100755 index 0000000..32e3f9c --- /dev/null +++ b/setup.sh @@ -0,0 +1,428 @@ +#!/bin/sh +# ============================================================================== +# j3270 Setup Script (POSIX Shell) +# Detects Java 11+ JDK and builds j3270 standalone executable JAR. +# +# Usage: +# ./setup.sh [options] [-- run-arguments] +# +# Options: +# -r, --run Launch j3270 immediately after successful build +# -t, --test Run test suite after build +# -c, --clean Clean build directory before compiling +# -h, --help Show this help message +# ============================================================================== +set -e + +# Resolve directory of this script +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +cd "$SCRIPT_DIR" + +# ANSI color codes (if output is to an interactive terminal) +if [ -t 1 ]; then + BOLD="\033[1m" + GREEN="\033[1;32m" + YELLOW="\033[1;33m" + CYAN="\033[1;36m" + RED="\033[1;31m" + RESET="\033[0m" +else + BOLD="" + GREEN="" + YELLOW="" + CYAN="" + RED="" + RESET="" +fi + +log_info() { printf "%b[INFO]%b %s\n" "$CYAN" "$RESET" "$*"; } +log_success() { printf "%b[OK]%b %s\n" "$GREEN" "$RESET" "$*"; } +log_warn() { printf "%b[WARN]%b %s\n" "$YELLOW" "$RESET" "$*"; } +log_error() { printf "%b[ERROR]%b %s\n" "$RED" "$RESET" "$*"; } + +show_help() { + cat << EOF +j3270 Terminal Emulator - Setup and Build Script + +Usage: + ./setup.sh [options] [-- run-arguments] + +Options: + -r, --run Build and immediately launch j3270 + -t, --test Run automated unit tests after build + -c, --clean Remove previous build artifacts before building + -h, --help Show this help message + +Examples: + ./setup.sh + ./setup.sh --clean + ./setup.sh --run + ./setup.sh --run -- mainframe.example.com 23 4 + ./setup.sh --test +EOF +} + +# Parse command line flags +DO_RUN=0 +DO_TEST=0 +DO_CLEAN=0 +RUN_ARGS="" + +while [ $# -gt 0 ]; do + case "$1" in + -r|--run) + DO_RUN=1 + shift + ;; + -t|--test) + DO_TEST=1 + shift + ;; + -c|--clean) + DO_CLEAN=1 + shift + ;; + -h|--help) + show_help + exit 0 + ;; + --) + shift + RUN_ARGS="$*" + break + ;; + *) + RUN_ARGS="$*" + break + ;; + esac +done + +printf "\n%b=== j3270 Setup & Build ===%b\n\n" "$BOLD" "$RESET" + +# ------------------------------------------------------------------------------ +# 1. Java Version & JDK Toolchain Detection +# ------------------------------------------------------------------------------ + +# Extracts the integer major version from a javac or java version string +# Examples: "javac 11.0.19" -> 11, "javac 17.0.2" -> 17, "javac 1.8.0_292" -> 8 +parse_major_version() { + raw_str="$1" + ver_token=$(echo "$raw_str" | sed -n \ + -e 's/.*javac *\([0-9][0-9._]*\).*/\1/p' \ + -e 's/.*version *"\([0-9][0-9._]*\)".*/\1/p' \ + -e 's/.*version *\([0-9][0-9._]*\).*/\1/p' | head -n 1) + + if [ -n "$ver_token" ]; then + echo "$ver_token" | sed -e 's/^1\.//' -e 's/[^0-9].*//' + fi +} + +# Checks if a given directory contains a valid JDK >= 11 +# Sets JAVAC_BIN, JAR_BIN, JAVA_BIN, JAVA_VERSION if valid +test_jdk_candidate() { + cand_home="$1" + cand_javac="" + cand_jar="" + cand_java="" + + if [ -n "$cand_home" ] && [ -d "$cand_home" ]; then + if [ -x "$cand_home/bin/javac" ]; then cand_javac="$cand_home/bin/javac"; fi + if [ -x "$cand_home/bin/jar" ]; then cand_jar="$cand_home/bin/jar"; fi + if [ -x "$cand_home/bin/java" ]; then cand_java="$cand_home/bin/java"; fi + fi + + if [ -z "$cand_javac" ] || [ -z "$cand_jar" ] || [ -z "$cand_java" ]; then + return 1 + fi + + # Verify that javac actually executes successfully + if ! "$cand_javac" -version >/dev/null 2>&1; then + return 1 + fi + + raw_ver=$("$cand_javac" -version 2>&1) + major_num=$(parse_major_version "$raw_ver") + + if [ -n "$major_num" ] && [ "$major_num" -ge 11 ] 2>/dev/null; then + JAVAC_BIN="$cand_javac" + JAR_BIN="$cand_jar" + JAVA_BIN="$cand_java" + JAVA_VERSION="$raw_ver" + DETECTED_JAVA_HOME="$cand_home" + return 0 + fi + + return 1 +} + +JAVAC_BIN="" +JAR_BIN="" +JAVA_BIN="" +JAVA_VERSION="" +DETECTED_JAVA_HOME="" + +log_info "Searching for a compatible Java Development Kit (JDK 11+)..." + +# Strategy A: Check user-specified JAVA_HOME +if [ -n "$JAVA_HOME" ]; then + if test_jdk_candidate "$JAVA_HOME"; then + log_info "Found valid JDK in \$JAVA_HOME" + fi +fi + +# Strategy B: Check current PATH commands (handling potential macOS /usr/bin stubs) +if [ -z "$JAVAC_BIN" ] && command -v javac >/dev/null 2>&1 && command -v jar >/dev/null 2>&1 && command -v java >/dev/null 2>&1; then + if javac -version >/dev/null 2>&1; then + raw_ver=$(javac -version 2>&1) + major_num=$(parse_major_version "$raw_ver") + if [ -n "$major_num" ] && [ "$major_num" -ge 11 ] 2>/dev/null; then + JAVAC_BIN="$(command -v javac)" + JAR_BIN="$(command -v jar)" + JAVA_BIN="$(command -v java)" + JAVA_VERSION="$raw_ver" + log_info "Found valid JDK on system PATH" + fi + fi +fi + +# Strategy C: macOS java_home utility +if [ -z "$JAVAC_BIN" ] && [ -x /usr/libexec/java_home ]; then + for v in 11 17 21 24 ""; do + if [ -n "$v" ]; then + mhome="$(/usr/libexec/java_home -v "$v+" 2>/dev/null || true)" + else + mhome="$(/usr/libexec/java_home 2>/dev/null || true)" + fi + if [ -n "$mhome" ] && test_jdk_candidate "$mhome"; then + log_info "Found macOS JDK via /usr/libexec/java_home: $mhome" + break + fi + done +fi + +# Strategy D: Check standard package manager & OS installation directories +if [ -z "$JAVAC_BIN" ]; then + candidate_paths=" + /opt/homebrew/opt/openjdk/libexec/openjdk.jdk/Contents/Home + /opt/homebrew/opt/openjdk@21/libexec/openjdk.jdk/Contents/Home + /opt/homebrew/opt/openjdk@17/libexec/openjdk.jdk/Contents/Home + /opt/homebrew/opt/openjdk@11/libexec/openjdk.jdk/Contents/Home + /opt/homebrew/opt/openjdk + /opt/homebrew/opt/openjdk@21 + /opt/homebrew/opt/openjdk@17 + /opt/homebrew/opt/openjdk@11 + /usr/local/opt/openjdk/libexec/openjdk.jdk/Contents/Home + /usr/local/opt/openjdk@21/libexec/openjdk.jdk/Contents/Home + /usr/local/opt/openjdk@17/libexec/openjdk.jdk/Contents/Home + /usr/local/opt/openjdk@11/libexec/openjdk.jdk/Contents/Home + /usr/local/opt/openjdk + /Library/Java/JavaVirtualMachines/*/Contents/Home + $HOME/Library/Java/JavaVirtualMachines/*/Contents/Home + /usr/lib/jvm/default-java + /usr/lib/jvm/java-21-openjdk* + /usr/lib/jvm/java-17-openjdk* + /usr/lib/jvm/java-11-openjdk* + /usr/lib/jvm/temurin-21* + /usr/lib/jvm/temurin-17* + /usr/lib/jvm/temurin-11* + /usr/lib/jvm/semeru-21* + /usr/lib/jvm/semeru-17* + /usr/lib/jvm/semeru-11* + /usr/lib/jvm/zulu-21* + /usr/lib/jvm/zulu-17* + /usr/lib/jvm/zulu-11* + /usr/lib/jvm/* + /usr/java/* + /usr/local/openjdk21 + /usr/local/openjdk17 + /usr/local/openjdk11 + /usr/local/openjdk* + $HOME/.sdkman/candidates/java/current + $HOME/.sdkman/candidates/java/* + $HOME/.asdf/installs/java/* + $HOME/.jenv/versions/* + " + + for cand in $candidate_paths; do + if [ -d "$cand" ] && test_jdk_candidate "$cand"; then + log_info "Found compatible JDK at: $cand" + break + fi + done +fi + +# If no compatible JDK is found, provide OS-tailored installation help +if [ -z "$JAVAC_BIN" ] || [ -z "$JAR_BIN" ] || [ -z "$JAVA_BIN" ]; then + log_error "No compatible Java Development Kit (JDK 11+) was found on your system." + printf "\n" + printf "j3270 requires a Java Development Kit (JDK) version 11 or higher (LTS 11, 17, or 21).\n" + printf "Note: A JRE (runtime only) is not sufficient; the compiler (javac) and archiver (jar) are required.\n\n" + printf "%bTo install JDK 11+ on your system:%b\n\n" "$BOLD" "$RESET" + + UNAME_S="$(uname -s 2>/dev/null || echo "Unknown")" + case "$UNAME_S" in + Darwin) + printf " • Using Homebrew:\n" + printf " brew install openjdk@17\n" + printf " sudo ln -sfn /opt/homebrew/opt/openjdk@17/libexec/openjdk.jdk /Library/Java/JavaVirtualMachines/openjdk-17.jdk\n\n" + printf " • Or download Eclipse Temurin (Adoptium PKG installer):\n" + printf " https://adoptium.net\n\n" + ;; + Linux) + if command -v apt-get >/dev/null 2>&1; then + printf " • Debian / Ubuntu / Linux Mint:\n" + printf " sudo apt update && sudo apt install -y openjdk-17-jdk\n\n" + elif command -v dnf >/dev/null 2>&1; then + printf " • Fedora / RHEL / CentOS / Rocky / AlmaLinux:\n" + printf " sudo dnf install -y java-17-openjdk-devel\n\n" + elif command -v pacman >/dev/null 2>&1; then + printf " • Arch Linux / Manjaro:\n" + printf " sudo pacman -S jdk17-openjdk\n\n" + elif command -v apk >/dev/null 2>&1; then + printf " • Alpine Linux:\n" + printf " sudo apk add openjdk17\n\n" + elif command -v zypper >/dev/null 2>&1; then + printf " • openSUSE:\n" + printf " sudo zypper install -y java-17-openjdk-devel\n\n" + else + printf " • Install JDK 17 via your system package manager or SDKMAN:\n" + printf " curl -s \"https://get.sdkman.io\" | bash\n" + printf " sdk install java 17.0.10-tem\n\n" + fi + ;; + FreeBSD|OpenBSD|NetBSD) + printf " • BSD package manager:\n" + printf " pkg install openjdk17\n\n" + ;; + *) + printf " • Download Eclipse Temurin JDK:\n" + printf " https://adoptium.net\n\n" + ;; + esac + + printf "After installing, re-run ./setup.sh\n\n" + exit 1 +fi + +if [ -n "$DETECTED_JAVA_HOME" ]; then + export JAVA_HOME="$DETECTED_JAVA_HOME" +fi + +log_success "JDK verified: $JAVA_VERSION" +log_info "Java compiler: $JAVAC_BIN" +log_info "JAR packager: $JAR_BIN" +log_info "Java runtime: $JAVA_BIN" +if [ -n "$JAVA_HOME" ]; then + log_info "JAVA_HOME: $JAVA_HOME" +fi +printf "\n" + +# ------------------------------------------------------------------------------ +# 2. Preparation & Clean +# ------------------------------------------------------------------------------ +BUILD_DIR="$SCRIPT_DIR/build" + +if [ "$DO_CLEAN" -eq 1 ]; then + log_info "Cleaning build directory ($BUILD_DIR)..." + rm -rf "$BUILD_DIR/lib3270j" "$BUILD_DIR/j3270" "$BUILD_DIR/MANIFEST.MF" "$BUILD_DIR/j3270.jar" +fi + +mkdir -p "$BUILD_DIR/lib3270j" "$BUILD_DIR/j3270" + +# ------------------------------------------------------------------------------ +# 3. Compile Core Protocol Engine (lib3270j) +# ------------------------------------------------------------------------------ +log_info "Compiling lib3270j core engine..." +find "$SCRIPT_DIR/lib3270j/src/main/java" -name "*.java" | sed 's/^/"/;s/$/"/' > "$BUILD_DIR/lib_sources.txt" + +if [ ! -s "$BUILD_DIR/lib_sources.txt" ]; then + log_error "No source files found in lib3270j/src/main/java" + exit 1 +fi + +"$JAVAC_BIN" -d "$BUILD_DIR/lib3270j" @"$BUILD_DIR/lib_sources.txt" +rm -f "$BUILD_DIR/lib_sources.txt" +log_success "lib3270j compiled successfully." + +# ------------------------------------------------------------------------------ +# 4. Compile Desktop Application (j3270) +# ------------------------------------------------------------------------------ +log_info "Compiling j3270 terminal emulator..." +find "$SCRIPT_DIR/j3270/src/main/java" -name "*.java" | sed 's/^/"/;s/$/"/' > "$BUILD_DIR/app_sources.txt" + +if [ ! -s "$BUILD_DIR/app_sources.txt" ]; then + log_error "No source files found in j3270/src/main/java" + exit 1 +fi + +"$JAVAC_BIN" -cp "$BUILD_DIR/lib3270j" -d "$BUILD_DIR/j3270" @"$BUILD_DIR/app_sources.txt" +rm -f "$BUILD_DIR/app_sources.txt" +log_success "j3270 application compiled successfully." + +# Copy any resources if present +if [ -d "$SCRIPT_DIR/j3270/src/main/resources" ]; then + cp -r "$SCRIPT_DIR/j3270/src/main/resources/"* "$BUILD_DIR/j3270/" 2>/dev/null || true +fi +if [ -d "$SCRIPT_DIR/lib3270j/src/main/resources" ]; then + cp -r "$SCRIPT_DIR/lib3270j/src/main/resources/"* "$BUILD_DIR/lib3270j/" 2>/dev/null || true +fi + +# ------------------------------------------------------------------------------ +# 5. Package Standalone Executable JAR +# ------------------------------------------------------------------------------ +log_info "Packaging standalone executable JAR..." + +cat << 'EOF' > "$BUILD_DIR/MANIFEST.MF" +Manifest-Version: 1.0 +Main-Class: haus.nightmare.j3270.J3270App +Implementation-Title: j3270 +Implementation-Version: 0.1.0 +Created-By: j3270 setup.sh + +EOF + +"$JAR_BIN" cvfm "$BUILD_DIR/j3270.jar" "$BUILD_DIR/MANIFEST.MF" \ + -C "$BUILD_DIR/lib3270j" . \ + -C "$BUILD_DIR/j3270" . > /dev/null + +if [ ! -f "$BUILD_DIR/j3270.jar" ]; then + log_error "Failed to create $BUILD_DIR/j3270.jar" + exit 1 +fi + +JAR_SIZE=$(ls -lh "$BUILD_DIR/j3270.jar" | awk '{print $5}') +printf "\n%b=== Build Successful ===%b\n" "$BOLD" "$RESET" +log_success "Executable JAR: $BUILD_DIR/j3270.jar ($JAR_SIZE)" + +printf "\n%bTo run j3270:%b\n" "$BOLD" "$RESET" +printf " ./run.sh\n" +printf " # Or directly:\n" +printf " %s -jar \"%s/build/j3270.jar\"\n\n" "$JAVA_BIN" "$SCRIPT_DIR" + +# ------------------------------------------------------------------------------ +# 6. Run Tests (if requested) +# ------------------------------------------------------------------------------ +if [ "$DO_TEST" -eq 1 ]; then + printf "%b=== Running Unit Tests ===%b\n" "$BOLD" "$RESET" + if [ -x "$SCRIPT_DIR/test_all.sh" ]; then + sh "$SCRIPT_DIR/test_all.sh" + elif [ -x "$SCRIPT_DIR/build_all.sh" ]; then + sh "$SCRIPT_DIR/build_all.sh" test + fi +fi + +# ------------------------------------------------------------------------------ +# 7. Launch Application (if requested) +# ------------------------------------------------------------------------------ +if [ "$DO_RUN" -eq 1 ]; then + log_info "Launching j3270..." + if [ "$(uname -s)" = "Darwin" ]; then + # On macOS, enable native application menu bar + exec "$JAVA_BIN" -Dapple.laf.useScreenMenuBar=true \ + -Dapple.awt.application.name=j3270 \ + -jar "$BUILD_DIR/j3270.jar" $RUN_ARGS + else + exec "$JAVA_BIN" -jar "$BUILD_DIR/j3270.jar" $RUN_ARGS + fi +fi diff --git a/test_all.sh b/test_all.sh index 860eab3..9316909 100755 --- a/test_all.sh +++ b/test_all.sh @@ -17,15 +17,31 @@ if [ -n "$JAVA_HOME" ] && [ -x "$JAVA_HOME/bin/javac" ] && [ -x "$JAVA_HOME/bin/ JAVAC_BIN="$JAVA_HOME/bin/javac" JAVA_BIN="$JAVA_HOME/bin/java" elif command -v javac >/dev/null 2>&1 && command -v java >/dev/null 2>&1; then - JAVAC_BIN="$(command -v javac)" - JAVA_BIN="$(command -v java)" -else - for h in "$HOME/.sdkman/candidates/java/current" \ + if javac -version >/dev/null 2>&1; then + JAVAC_BIN="$(command -v javac)" + JAVA_BIN="$(command -v java)" + fi +fi + +if [ -z "$JAVAC_BIN" ] || [ -z "$JAVA_BIN" ]; then + if [ -x /usr/libexec/java_home ]; then + MAC_JAVA_HOME="$(/usr/libexec/java_home 2>/dev/null || true)" + if [ -n "$MAC_JAVA_HOME" ] && [ -x "$MAC_JAVA_HOME/bin/javac" ] && [ -x "$MAC_JAVA_HOME/bin/java" ]; then + export JAVA_HOME="$MAC_JAVA_HOME" + JAVAC_BIN="$JAVA_HOME/bin/javac" + JAVA_BIN="$JAVA_HOME/bin/java" + fi + fi +fi + +if [ -z "$JAVAC_BIN" ] || [ -z "$JAVA_BIN" ]; then + for h in /usr/lib/jvm/default-java \ /usr/lib/jvm/java-21-openjdk* \ /usr/lib/jvm/java-17-openjdk* \ /usr/lib/jvm/java-11-openjdk* \ - /usr/lib/jvm/default-java \ - /Library/Java/JavaVirtualMachines/*/Contents/Home; do + /Library/Java/JavaVirtualMachines/*/Contents/Home \ + /opt/homebrew/opt/openjdk/libexec/openjdk.jdk/Contents/Home \ + /opt/homebrew/opt/openjdk; do if [ -d "$h" ] && [ -x "$h/bin/javac" ] && [ -x "$h/bin/java" ]; then export JAVA_HOME="$h" JAVAC_BIN="$JAVA_HOME/bin/javac"