diff --git a/j3270/src/main/java/haus/nightmare/j3270/J3270App.java b/j3270/src/main/java/haus/nightmare/j3270/J3270App.java index 916ebda..ddf946f 100644 --- a/j3270/src/main/java/haus/nightmare/j3270/J3270App.java +++ b/j3270/src/main/java/haus/nightmare/j3270/J3270App.java @@ -1070,8 +1070,47 @@ public class J3270App extends JFrame implements ConnectionListener, ScreenUpdate } } - // ========== Main ========== + private static void applyProxyUrl(ConnectionConfig config, String proxyUrl) { + if (config == null || proxyUrl == null || proxyUrl.trim().isEmpty()) return; + try { + java.net.URI uri = new java.net.URI(proxyUrl.trim()); + String scheme = uri.getScheme() != null ? uri.getScheme().toLowerCase() : "http"; + ConnectionConfig.ProxyType pType; + int pPort; + if (scheme.equals("http") || scheme.equals("https")) { + pType = ConnectionConfig.ProxyType.HTTP; + pPort = uri.getPort() > 0 ? uri.getPort() : 8080; + } else if (scheme.equals("socks4") || scheme.equals("socks4a")) { + pType = ConnectionConfig.ProxyType.SOCKS4; + pPort = uri.getPort() > 0 ? uri.getPort() : 1080; + } else if (scheme.equals("socks5") || scheme.equals("socks")) { + pType = ConnectionConfig.ProxyType.SOCKS5; + pPort = uri.getPort() > 0 ? uri.getPort() : 1080; + } else { + return; + } + String pHost = uri.getHost(); + String pUser = null; + String pPass = null; + String userInfo = uri.getUserInfo(); + if (userInfo != null) { + int colon = userInfo.indexOf(':'); + if (colon >= 0) { + pUser = userInfo.substring(0, colon); + pPass = userInfo.substring(colon + 1); + } else { + pUser = userInfo; + } + } + if (pHost != null && !pHost.isEmpty()) { + config.setProxy(pType, pHost, pPort, pUser, pPass); + } + } catch (Exception e) { + log.warning("Failed to parse CLI proxy URL: " + proxyUrl); + } + } + // ========== Main ========== public static void main(String[] args) { boolean debug = false; @@ -1081,6 +1120,8 @@ public class J3270App extends JFrame implements ConnectionListener, ScreenUpdate Boolean cliAutoSysUnlock = null; GraphicsMode cliGraphicsMode = null; String configFile = null; + String cliProxyUrl = null; + String cliProfileName = null; java.util.List remainingArgs = new java.util.ArrayList<>(); for (int i = 0; i < args.length; i++) { @@ -1107,6 +1148,14 @@ public class J3270App extends JFrame implements ConnectionListener, ScreenUpdate cliGraphicsMode = GraphicsMode.NONE; } else if (("-c".equals(arg) || "--config".equals(arg)) && i + 1 < args.length) { configFile = args[++i]; + } else if (arg.startsWith("--proxy=") || arg.startsWith("-proxy=")) { + cliProxyUrl = arg.substring(arg.indexOf('=') + 1).trim(); + } else if (("--proxy".equals(arg) || "-proxy".equals(arg)) && i + 1 < args.length && !args[i + 1].startsWith("-")) { + cliProxyUrl = args[++i].trim(); + } else if (arg.startsWith("--profile=")) { + cliProfileName = arg.substring(arg.indexOf('=') + 1).trim(); + } else if ("--profile".equals(arg) && i + 1 < args.length && !args[i + 1].startsWith("-")) { + cliProfileName = args[++i].trim(); } else if (arg.startsWith("-")) { System.err.println("Unknown option: " + arg); } else { @@ -1143,11 +1192,33 @@ public class J3270App extends JFrame implements ConnectionListener, ScreenUpdate final Boolean finalTn3270e = cliTn3270e; final Boolean finalAutoSysUnlock = cliAutoSysUnlock; final GraphicsMode finalGraphicsMode = cliGraphicsMode; + final String finalProxyUrl = cliProxyUrl; + final String finalProfileName = cliProfileName; SwingUtilities.invokeLater(() -> { J3270App app = new J3270App(); app.setVisible(true); + if (finalProfileName != null) { + haus.nightmare.j3270.storage.SavedHost profile = haus.nightmare.j3270.storage.HostStorage.findByName(finalProfileName); + if (profile == null) { + profile = haus.nightmare.j3270.storage.HostStorage.findById(finalProfileName); + } + if (profile != null) { + ConnectionConfig config = profile.toConnectionConfig(); + if (finalTls) config.setUseTls(true); + if (finalNoVerify) config.setTlsVerifyCert(false); + if (finalTn3270e != null) config.setTn3270eEnabled(finalTn3270e); + if (finalAutoSysUnlock != null) config.setAutoSysUnlock(finalAutoSysUnlock); + if (finalGraphicsMode != null) config.setGraphicsMode(finalGraphicsMode); + if (finalProxyUrl != null) applyProxyUrl(config, finalProxyUrl); + app.connect(config); + return; + } else { + System.err.println("Profile not found: " + finalProfileName); + } + } + if (!remainingArgs.isEmpty()) { String hostArg = remainingArgs.get(0); int port = finalTls ? 992 : 23; @@ -1188,6 +1259,9 @@ public class J3270App extends JFrame implements ConnectionListener, ScreenUpdate } else { config.setGraphicsMode(haus.nightmare.j3270.config.Settings.getGraphicsMode()); } + if (finalProxyUrl != null) { + applyProxyUrl(config, finalProxyUrl); + } app.connect(config); } else { haus.nightmare.j3270.config.Settings.StartupBehavior behavior = haus.nightmare.j3270.config.Settings @@ -1197,26 +1271,41 @@ public class J3270App extends JFrame implements ConnectionListener, ScreenUpdate SwingUtilities.invokeLater(app::showConnectDialog); break; case AUTO_CONNECT: - String host = haus.nightmare.j3270.config.Settings.getAutoConnectHost(); - int port = haus.nightmare.j3270.config.Settings.getAutoConnectPort(); - boolean tls = haus.nightmare.j3270.config.Settings.getAutoConnectTls(); - boolean verify = haus.nightmare.j3270.config.Settings.getAutoConnectVerifyCert(); - boolean tn3270e = haus.nightmare.j3270.config.Settings.getAutoConnectTn3270e(); - if (host != null && !host.isEmpty()) { - ConnectionConfig config = new ConnectionConfig(host, port, TerminalModel.IBM_3279_4, tls); - config.setTlsVerifyCert(verify); - config.setTn3270eEnabled(finalTn3270e != null ? finalTn3270e : tn3270e); - config.setAutoReconnect(haus.nightmare.j3270.config.Settings.getAutoConnectAutoReconnect()); - config.setReconnectMaxRetries(haus.nightmare.j3270.config.Settings.getAutoConnectReconnectMaxRetries()); - config.setAutoSysUnlock(finalAutoSysUnlock != null ? finalAutoSysUnlock : haus.nightmare.j3270.config.Settings.getAutoSysUnlock()); - if (finalGraphicsMode != null) { - config.setGraphicsMode(finalGraphicsMode); - } else { - config.setGraphicsMode(haus.nightmare.j3270.config.Settings.getGraphicsMode()); - } + haus.nightmare.j3270.storage.SavedHost autoHost = haus.nightmare.j3270.storage.HostStorage.getAutoConnectHost(); + if (autoHost != null) { + ConnectionConfig config = autoHost.toConnectionConfig(); + if (finalTls) config.setUseTls(true); + if (finalNoVerify) config.setTlsVerifyCert(false); + if (finalTn3270e != null) config.setTn3270eEnabled(finalTn3270e); + if (finalAutoSysUnlock != null) config.setAutoSysUnlock(finalAutoSysUnlock); + if (finalGraphicsMode != null) config.setGraphicsMode(finalGraphicsMode); + if (finalProxyUrl != null) applyProxyUrl(config, finalProxyUrl); app.connect(config); } else { - SwingUtilities.invokeLater(app::showConnectDialog); + String host = haus.nightmare.j3270.config.Settings.getAutoConnectHost(); + int port = haus.nightmare.j3270.config.Settings.getAutoConnectPort(); + boolean tls = haus.nightmare.j3270.config.Settings.getAutoConnectTls(); + boolean verify = haus.nightmare.j3270.config.Settings.getAutoConnectVerifyCert(); + boolean tn3270e = haus.nightmare.j3270.config.Settings.getAutoConnectTn3270e(); + if (host != null && !host.isEmpty()) { + ConnectionConfig config = new ConnectionConfig(host, port, TerminalModel.IBM_3279_4, tls); + config.setTlsVerifyCert(verify); + config.setTn3270eEnabled(finalTn3270e != null ? finalTn3270e : tn3270e); + config.setAutoReconnect(haus.nightmare.j3270.config.Settings.getAutoConnectAutoReconnect()); + config.setReconnectMaxRetries(haus.nightmare.j3270.config.Settings.getAutoConnectReconnectMaxRetries()); + config.setAutoSysUnlock(finalAutoSysUnlock != null ? finalAutoSysUnlock : haus.nightmare.j3270.config.Settings.getAutoSysUnlock()); + if (finalGraphicsMode != null) { + config.setGraphicsMode(finalGraphicsMode); + } else { + config.setGraphicsMode(haus.nightmare.j3270.config.Settings.getGraphicsMode()); + } + if (finalProxyUrl != null) { + applyProxyUrl(config, finalProxyUrl); + } + app.connect(config); + } else { + SwingUtilities.invokeLater(app::showConnectDialog); + } } break; case DO_NOTHING: diff --git a/j3270/src/main/java/haus/nightmare/j3270/storage/HostStorage.java b/j3270/src/main/java/haus/nightmare/j3270/storage/HostStorage.java new file mode 100644 index 0000000..3417ae1 --- /dev/null +++ b/j3270/src/main/java/haus/nightmare/j3270/storage/HostStorage.java @@ -0,0 +1,434 @@ +package haus.nightmare.j3270.storage; + +import java.util.*; +import java.util.logging.Level; +import java.util.logging.Logger; +import java.util.prefs.Preferences; + +/** + * Storage manager for saved host profiles, maintaining 1:1 format compatibility with a3270. + * Persists profiles in Java user preferences with JSON serialization. + */ +public class HostStorage { + + private static final Logger log = Logger.getLogger(HostStorage.class.getName()); + private static final Preferences prefs = Preferences.userNodeForPackage(HostStorage.class); + public static final String KEY_SAVED_HOSTS = "saved_hosts"; + + public static synchronized List getSavedHosts() { + String jsonStr = prefs.get(KEY_SAVED_HOSTS, null); + if (jsonStr == null || jsonStr.trim().isEmpty()) { + return new ArrayList<>(); + } + try { + return parseHostsJson(jsonStr); + } catch (Exception e) { + log.log(Level.WARNING, "Failed to parse saved hosts JSON", e); + return new ArrayList<>(); + } + } + + public static synchronized void saveHost(SavedHost host) { + if (host == null) return; + List current = new ArrayList<>(getSavedHosts()); + int index = -1; + for (int i = 0; i < current.size(); i++) { + if (Objects.equals(current.get(i).getId(), host.getId())) { + index = i; + break; + } + } + + // If this host is set to autoConnect, clear autoConnect on all others + SavedHost hostToSave = host.copy(); + if (hostToSave.isAutoConnect()) { + for (int i = 0; i < current.size(); i++) { + current.get(i).setAutoConnect(false); + } + } + + if (index >= 0) { + current.set(index, hostToSave); + } else { + current.add(hostToSave); + } + saveAll(current); + } + + public static synchronized void deleteHost(String hostId) { + if (hostId == null) return; + List current = new ArrayList<>(getSavedHosts()); + current.removeIf(h -> Objects.equals(h.getId(), hostId)); + saveAll(current); + } + + public static synchronized SavedHost getAutoConnectHost() { + for (SavedHost h : getSavedHosts()) { + if (h.isAutoConnect()) { + return h; + } + } + return null; + } + + public static synchronized SavedHost findById(String id) { + if (id == null) return null; + for (SavedHost h : getSavedHosts()) { + if (id.equals(h.getId())) { + return h; + } + } + return null; + } + + public static synchronized SavedHost findByName(String name) { + if (name == null) return null; + for (SavedHost h : getSavedHosts()) { + if (name.equalsIgnoreCase(h.getName())) { + return h; + } + } + return null; + } + + public static synchronized void saveAll(List hosts) { + if (hosts == null) return; + String json = serializeHostsJson(hosts); + prefs.put(KEY_SAVED_HOSTS, json); + try { + prefs.flush(); + } catch (Exception ignored) { + } + } + + public static synchronized void clearAll() { + prefs.remove(KEY_SAVED_HOSTS); + try { + prefs.flush(); + } catch (Exception ignored) { + } + } + + // ================= JSON Serialization ================= + + public static String serializeHostsJson(List hosts) { + StringBuilder sb = new StringBuilder(); + sb.append("[\n"); + for (int i = 0; i < hosts.size(); i++) { + SavedHost h = hosts.get(i); + sb.append(" ").append(serializeHostJson(h)); + if (i < hosts.size() - 1) { + sb.append(","); + } + sb.append("\n"); + } + sb.append("]"); + return sb.toString(); + } + + public static String serializeHostJson(SavedHost h) { + StringBuilder sb = new StringBuilder(); + sb.append("{"); + appendField(sb, "id", h.getId(), true); + appendField(sb, "name", h.getName(), true); + appendField(sb, "host", h.getHost(), true); + appendField(sb, "port", h.getPort(), true); + appendField(sb, "model", h.getModel(), true); + appendField(sb, "dynamicRows", h.getDynamicRows(), true); + appendField(sb, "dynamicCols", h.getDynamicCols(), true); + appendField(sb, "luName", h.getLuName(), true); + appendField(sb, "autoConnect", h.isAutoConnect(), true); + appendField(sb, "hostType", h.getHostType(), true); + appendField(sb, "useTls", h.isUseTls(), true); + appendField(sb, "tlsVerifyCert", h.isTlsVerifyCert(), true); + appendField(sb, "tn3270e", h.isTn3270e(), true); + appendField(sb, "graphicsMode", h.getGraphicsMode(), true); + appendField(sb, "codePage", h.getCodePage(), true); + appendField(sb, "proxyType", h.getProxyType(), true); + appendField(sb, "proxyHost", h.getProxyHost(), true); + appendField(sb, "proxyPort", h.getProxyPort(), true); + appendField(sb, "proxyUsername", h.getProxyUsername(), true); + appendField(sb, "proxyPassword", h.getProxyPassword(), false); + sb.append("}"); + return sb.toString(); + } + + private static void appendField(StringBuilder sb, String key, String val, boolean comma) { + sb.append("\"").append(key).append("\":\"").append(escapeJson(val != null ? val : "")).append("\""); + if (comma) sb.append(","); + } + + private static void appendField(StringBuilder sb, String key, int val, boolean comma) { + sb.append("\"").append(key).append("\":").append(val); + if (comma) sb.append(","); + } + + private static void appendField(StringBuilder sb, String key, boolean val, boolean comma) { + sb.append("\"").append(key).append("\":").append(val); + if (comma) sb.append(","); + } + + private static String escapeJson(String s) { + if (s == null) return ""; + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < s.length(); i++) { + char c = s.charAt(i); + switch (c) { + case '"': sb.append("\\\""); break; + case '\\': sb.append("\\\\"); break; + case '\b': sb.append("\\b"); break; + case '\f': sb.append("\\f"); break; + case '\n': sb.append("\\n"); break; + case '\r': sb.append("\\r"); break; + case '\t': sb.append("\\t"); break; + default: + if (c < 32) { + sb.append(String.format("\\u%04x", (int) c)); + } else { + sb.append(c); + } + break; + } + } + return sb.toString(); + } + + // ================= JSON Deserialization ================= + + public static List parseHostsJson(String json) { + List list = new ArrayList<>(); + if (json == null || json.trim().isEmpty()) return list; + + SimpleJsonParser parser = new SimpleJsonParser(json); + Object root = parser.parseValue(); + if (root instanceof List) { + for (Object item : (List) root) { + if (item instanceof Map) { + @SuppressWarnings("unchecked") + Map map = (Map) item; + list.add(fromMap(map)); + } + } + } else if (root instanceof Map) { + @SuppressWarnings("unchecked") + Map map = (Map) root; + list.add(fromMap(map)); + } + return list; + } + + public static SavedHost fromMap(Map map) { + SavedHost h = new SavedHost(); + if (map.containsKey("id")) h.setId(String.valueOf(map.get("id"))); + if (map.containsKey("name")) h.setName(String.valueOf(map.get("name"))); + if (map.containsKey("host")) h.setHost(String.valueOf(map.get("host"))); + if (map.containsKey("port")) h.setPort(parseInt(map.get("port"), 23)); + if (map.containsKey("model")) h.setModel(parseInt(map.get("model"), 4)); + if (map.containsKey("dynamicRows")) h.setDynamicRows(parseInt(map.get("dynamicRows"), 62)); + if (map.containsKey("dynamicCols")) h.setDynamicCols(parseInt(map.get("dynamicCols"), 160)); + if (map.containsKey("luName")) h.setLuName(String.valueOf(map.get("luName"))); + if (map.containsKey("autoConnect")) h.setAutoConnect(parseBool(map.get("autoConnect"), false)); + if (map.containsKey("hostType")) h.setHostType(String.valueOf(map.get("hostType"))); + if (map.containsKey("useTls")) h.setUseTls(parseBool(map.get("useTls"), false)); + if (map.containsKey("tlsVerifyCert")) h.setTlsVerifyCert(parseBool(map.get("tlsVerifyCert"), true)); + if (map.containsKey("tn3270e")) h.setTn3270e(parseBool(map.get("tn3270e"), true)); + if (map.containsKey("graphicsMode")) { + String gm = String.valueOf(map.get("graphicsMode")); + if ("PROGRAMMED_SYMBOLS".equalsIgnoreCase(gm)) gm = "BOTH"; + h.setGraphicsMode(gm); + } + if (map.containsKey("codePage")) h.setCodePage(String.valueOf(map.get("codePage"))); + if (map.containsKey("proxyType")) h.setProxyType(String.valueOf(map.get("proxyType"))); + if (map.containsKey("proxyHost")) h.setProxyHost(String.valueOf(map.get("proxyHost"))); + if (map.containsKey("proxyPort")) h.setProxyPort(parseInt(map.get("proxyPort"), 0)); + if (map.containsKey("proxyUsername")) h.setProxyUsername(String.valueOf(map.get("proxyUsername"))); + if (map.containsKey("proxyPassword")) h.setProxyPassword(String.valueOf(map.get("proxyPassword"))); + return h; + } + + private static int parseInt(Object obj, int def) { + if (obj == null) return def; + if (obj instanceof Number) return ((Number) obj).intValue(); + try { + return Integer.parseInt(String.valueOf(obj).trim()); + } catch (Exception e) { + return def; + } + } + + private static boolean parseBool(Object obj, boolean def) { + if (obj == null) return def; + if (obj instanceof Boolean) return (Boolean) obj; + return Boolean.parseBoolean(String.valueOf(obj).trim()); + } + + /** + * Minimal pure-Java JSON parser for nested Maps, Lists, Strings, Numbers, and Booleans. + */ + static class SimpleJsonParser { + private final String src; + private int pos = 0; + + SimpleJsonParser(String src) { + this.src = src; + } + + Object parseValue() { + skipWhitespace(); + if (pos >= src.length()) return null; + char c = src.charAt(pos); + if (c == '{') return parseObject(); + if (c == '[') return parseArray(); + if (c == '"') return parseString(); + if (c == 't' || c == 'f') return parseBoolean(); + if (c == 'n') return parseNull(); + if (c == '-' || Character.isDigit(c)) return parseNumber(); + throw new IllegalArgumentException("Unexpected char '" + c + "' at position " + pos); + } + + private Map parseObject() { + match('{'); + Map map = new LinkedHashMap<>(); + skipWhitespace(); + if (peek() == '}') { + pos++; + return map; + } + while (true) { + skipWhitespace(); + String key = parseString(); + skipWhitespace(); + match(':'); + Object val = parseValue(); + map.put(key, val); + skipWhitespace(); + char c = peek(); + if (c == ',') { + pos++; + } else if (c == '}') { + pos++; + break; + } else { + break; + } + } + return map; + } + + private List parseArray() { + match('['); + List list = new ArrayList<>(); + skipWhitespace(); + if (peek() == ']') { + pos++; + return list; + } + while (true) { + Object val = parseValue(); + list.add(val); + skipWhitespace(); + char c = peek(); + if (c == ',') { + pos++; + } else if (c == ']') { + pos++; + break; + } else { + break; + } + } + return list; + } + + private String parseString() { + match('"'); + StringBuilder sb = new StringBuilder(); + while (pos < src.length()) { + char c = src.charAt(pos++); + if (c == '"') { + return sb.toString(); + } else if (c == '\\') { + if (pos >= src.length()) break; + char esc = src.charAt(pos++); + switch (esc) { + case '"': sb.append('"'); break; + case '\\': sb.append('\\'); break; + case '/': sb.append('/'); break; + case 'b': sb.append('\b'); break; + case 'f': sb.append('\f'); break; + case 'n': sb.append('\n'); break; + case 'r': sb.append('\r'); break; + case 't': sb.append('\t'); break; + case 'u': + if (pos + 4 <= src.length()) { + String hex = src.substring(pos, pos + 4); + pos += 4; + try { + sb.append((char) Integer.parseInt(hex, 16)); + } catch (NumberFormatException e) { + sb.append("?"); + } + } + break; + default: + sb.append(esc); + break; + } + } else { + sb.append(c); + } + } + return sb.toString(); + } + + private Boolean parseBoolean() { + if (src.startsWith("true", pos)) { + pos += 4; + return true; + } else if (src.startsWith("false", pos)) { + pos += 5; + return false; + } + throw new IllegalArgumentException("Expected boolean at " + pos); + } + + private Object parseNull() { + if (src.startsWith("null", pos)) { + pos += 4; + return null; + } + throw new IllegalArgumentException("Expected null at " + pos); + } + + private Number parseNumber() { + int start = pos; + if (src.charAt(pos) == '-') pos++; + while (pos < src.length() && (Character.isDigit(src.charAt(pos)) || src.charAt(pos) == '.' || src.charAt(pos) == 'e' || src.charAt(pos) == 'E' || src.charAt(pos) == '+' || src.charAt(pos) == '-')) { + pos++; + } + String numStr = src.substring(start, pos); + if (numStr.contains(".")) { + return Double.parseDouble(numStr); + } + return Long.parseLong(numStr); + } + + private void skipWhitespace() { + while (pos < src.length() && Character.isWhitespace(src.charAt(pos))) { + pos++; + } + } + + private char peek() { + return pos < src.length() ? src.charAt(pos) : '\0'; + } + + private void match(char expected) { + skipWhitespace(); + if (pos >= src.length() || src.charAt(pos) != expected) { + throw new IllegalArgumentException("Expected '" + expected + "' at " + pos); + } + pos++; + } + } +} diff --git a/j3270/src/main/java/haus/nightmare/j3270/storage/SavedHost.java b/j3270/src/main/java/haus/nightmare/j3270/storage/SavedHost.java new file mode 100644 index 0000000..28607d2 --- /dev/null +++ b/j3270/src/main/java/haus/nightmare/j3270/storage/SavedHost.java @@ -0,0 +1,222 @@ +package haus.nightmare.j3270.storage; + +import haus.nightmare.lib3270j.ConnectionConfig; +import haus.nightmare.lib3270j.TerminalModel; +import haus.nightmare.lib3270j.graphics.GraphicsMode; + +import java.util.Objects; +import java.util.UUID; + +/** + * Data model for a saved 3270 connection profile, matching the schema of a3270 SavedHost. + * Includes per-connection host, model, protocol, and proxy configuration. + */ +public class SavedHost { + + private String id; + private String name; + private String host; + private int port; + private int model; + private int dynamicRows; + private int dynamicCols; + private String luName; + private boolean autoConnect; + private String hostType; + private boolean useTls; + private boolean tlsVerifyCert; + private boolean tn3270e; + private String graphicsMode; + private String codePage; + + // Per-connection proxy configuration + private String proxyType; + private String proxyHost; + private int proxyPort; + private String proxyUsername; + private String proxyPassword; + + public SavedHost() { + this.id = UUID.randomUUID().toString(); + this.name = "Mainframe"; + this.host = "127.0.0.1"; + this.port = 23; + this.model = 4; + this.dynamicRows = 62; + this.dynamicCols = 160; + this.luName = ""; + this.autoConnect = false; + this.hostType = "TSO"; + this.useTls = false; + this.tlsVerifyCert = true; + this.tn3270e = true; + this.graphicsMode = "BOTH"; + this.codePage = "037"; + this.proxyType = "NONE"; + this.proxyHost = ""; + this.proxyPort = 0; + this.proxyUsername = ""; + this.proxyPassword = ""; + } + + public SavedHost(String id, String name, String host, int port, int model, + int dynamicRows, int dynamicCols, String luName, boolean autoConnect, + String hostType, boolean useTls, boolean tlsVerifyCert, boolean tn3270e, + String graphicsMode, String codePage, String proxyType, String proxyHost, + int proxyPort, String proxyUsername, String proxyPassword) { + this.id = (id != null && !id.trim().isEmpty()) ? id : UUID.randomUUID().toString(); + this.name = name != null ? name : ""; + this.host = host != null ? host : "127.0.0.1"; + this.port = port > 0 ? port : 23; + this.model = model; + this.dynamicRows = dynamicRows > 0 ? dynamicRows : 62; + this.dynamicCols = dynamicCols > 0 ? dynamicCols : 160; + this.luName = luName != null ? luName : ""; + this.autoConnect = autoConnect; + this.hostType = hostType != null ? hostType : "TSO"; + this.useTls = useTls; + this.tlsVerifyCert = tlsVerifyCert; + this.tn3270e = tn3270e; + this.graphicsMode = graphicsMode != null ? graphicsMode : "BOTH"; + this.codePage = codePage != null ? codePage : "037"; + this.proxyType = (proxyType != null && !proxyType.trim().isEmpty()) ? proxyType.toUpperCase() : "NONE"; + this.proxyHost = proxyHost != null ? proxyHost : ""; + this.proxyPort = proxyPort; + this.proxyUsername = proxyUsername != null ? proxyUsername : ""; + this.proxyPassword = proxyPassword != null ? proxyPassword : ""; + } + + public SavedHost copy() { + return new SavedHost(id, name, host, port, model, dynamicRows, dynamicCols, luName, + autoConnect, hostType, useTls, tlsVerifyCert, tn3270e, graphicsMode, codePage, + proxyType, proxyHost, proxyPort, proxyUsername, proxyPassword); + } + + /** + * Converts this saved host profile into an active ConnectionConfig. + */ + public ConnectionConfig toConnectionConfig() { + TerminalModel tm; + if (model == 0) { + tm = TerminalModel.IBM_DYNAMIC; + } else { + try { + tm = TerminalModel.forModel(model, true); + } catch (Exception e) { + tm = TerminalModel.IBM_3279_4; + } + } + + ConnectionConfig config = new ConnectionConfig(host, port, tm, useTls); + if (model == 0 || tm.isDynamic()) { + config.setDynamicDimensions(dynamicRows, dynamicCols); + } + if (luName != null && !luName.trim().isEmpty()) { + config.setLuName(luName.trim()); + } + config.setTlsVerifyCert(tlsVerifyCert); + config.setTn3270eEnabled(tn3270e); + if (graphicsMode != null && !graphicsMode.isEmpty()) { + config.setGraphicsMode(GraphicsMode.fromString(graphicsMode)); + } + if (codePage != null && !codePage.isEmpty()) { + config.setCodePage(codePage); + } + + // Apply per-connection proxy if configured + if (proxyType != null && !proxyType.equalsIgnoreCase("NONE") && proxyHost != null && !proxyHost.trim().isEmpty()) { + try { + ConnectionConfig.ProxyType pt = ConnectionConfig.ProxyType.valueOf(proxyType.toUpperCase()); + int pPort = proxyPort > 0 ? proxyPort : (pt == ConnectionConfig.ProxyType.HTTP ? 8080 : 1080); + config.setProxy(pt, proxyHost.trim(), pPort, + (proxyUsername != null && !proxyUsername.trim().isEmpty()) ? proxyUsername.trim() : null, + (proxyPassword != null && !proxyPassword.isEmpty()) ? proxyPassword : null); + } catch (IllegalArgumentException ignored) { + } + } + return config; + } + + // Getters and Setters + + public String getId() { return id; } + public void setId(String id) { this.id = id; } + + public String getName() { return name; } + public void setName(String name) { this.name = name; } + + public String getHost() { return host; } + public void setHost(String host) { this.host = host; } + + public int getPort() { return port; } + public void setPort(int port) { this.port = port; } + + public int getModel() { return model; } + public void setModel(int model) { this.model = model; } + + public int getDynamicRows() { return dynamicRows; } + public void setDynamicRows(int dynamicRows) { this.dynamicRows = dynamicRows; } + + public int getDynamicCols() { return dynamicCols; } + public void setDynamicCols(int dynamicCols) { this.dynamicCols = dynamicCols; } + + public String getLuName() { return luName; } + public void setLuName(String luName) { this.luName = luName; } + + public boolean isAutoConnect() { return autoConnect; } + public void setAutoConnect(boolean autoConnect) { this.autoConnect = autoConnect; } + + public String getHostType() { return hostType; } + public void setHostType(String hostType) { this.hostType = hostType; } + + public boolean isUseTls() { return useTls; } + public void setUseTls(boolean useTls) { this.useTls = useTls; } + + public boolean isTlsVerifyCert() { return tlsVerifyCert; } + public void setTlsVerifyCert(boolean tlsVerifyCert) { this.tlsVerifyCert = tlsVerifyCert; } + + public boolean isTn3270e() { return tn3270e; } + public void setTn3270e(boolean tn3270e) { this.tn3270e = tn3270e; } + + public String getGraphicsMode() { return graphicsMode; } + public void setGraphicsMode(String graphicsMode) { this.graphicsMode = graphicsMode; } + + public String getCodePage() { return codePage; } + public void setCodePage(String codePage) { this.codePage = codePage; } + + public String getProxyType() { return proxyType != null ? proxyType : "NONE"; } + public void setProxyType(String proxyType) { this.proxyType = (proxyType != null) ? proxyType.toUpperCase() : "NONE"; } + + public String getProxyHost() { return proxyHost != null ? proxyHost : ""; } + public void setProxyHost(String proxyHost) { this.proxyHost = proxyHost; } + + public int getProxyPort() { return proxyPort; } + public void setProxyPort(int proxyPort) { this.proxyPort = proxyPort; } + + public String getProxyUsername() { return proxyUsername != null ? proxyUsername : ""; } + public void setProxyUsername(String proxyUsername) { this.proxyUsername = proxyUsername; } + + public String getProxyPassword() { return proxyPassword != null ? proxyPassword : ""; } + public void setProxyPassword(String proxyPassword) { this.proxyPassword = proxyPassword; } + + @Override + public String toString() { + if (name != null && !name.trim().isEmpty()) { + return name; + } + return host + (port > 0 ? ":" + port : ""); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + SavedHost savedHost = (SavedHost) o; + return Objects.equals(id, savedHost.id); + } + + @Override + public int hashCode() { + return Objects.hash(id); + } +} diff --git a/j3270/src/main/java/haus/nightmare/j3270/ui/ConnectDialog.java b/j3270/src/main/java/haus/nightmare/j3270/ui/ConnectDialog.java index e6e6e4d..1b14a08 100644 --- a/j3270/src/main/java/haus/nightmare/j3270/ui/ConnectDialog.java +++ b/j3270/src/main/java/haus/nightmare/j3270/ui/ConnectDialog.java @@ -1,16 +1,30 @@ package haus.nightmare.j3270.ui; +import haus.nightmare.j3270.storage.HostStorage; +import haus.nightmare.j3270.storage.SavedHost; import haus.nightmare.lib3270j.ConnectionConfig; import haus.nightmare.lib3270j.TerminalModel; import javax.swing.*; +import javax.swing.border.TitledBorder; import java.awt.*; +import java.util.List; /** - * Connection dialog for entering host, port, model, and LU name. + * Connection dialog for entering host, port, model, LU name, and per-connection proxy configuration. + * Supports loading and saving host profiles matching the a3270 schema. */ public class ConnectDialog extends JDialog { + private static final String NEW_PROFILE_LABEL = ""; + + // Profile selector + private JComboBox profileCombo; + private JButton saveProfileBtn; + private JButton deleteProfileBtn; + private String selectedProfileId = null; + + // Connection parameters private JTextField hostField; private JTextField portField; private JComboBox modelCombo; @@ -26,6 +40,14 @@ public class ConnectDialog extends JDialog { private JCheckBox tn3270eCheckBox; private JCheckBox keepAliveCheckBox; private JCheckBox autoReconnectCheckBox; + + // Per-connection proxy configuration + private JComboBox proxyTypeCombo; + private JTextField proxyHostField; + private JTextField proxyPortField; + private JTextField proxyUserField; + private JPasswordField proxyPassField; + private boolean confirmed; private ConnectionConfig result; @@ -35,64 +57,114 @@ public class ConnectDialog extends JDialog { buildUI(); pack(); setLocationRelativeTo(parent); - setResizable(false); + setResizable(true); } private void buildUI() { - JPanel mainPanel = new JPanel(new GridBagLayout()); - mainPanel.setBorder(BorderFactory.createEmptyBorder(16, 16, 16, 16)); + JPanel contentPanel = new JPanel(new GridBagLayout()); + contentPanel.setBorder(BorderFactory.createEmptyBorder(12, 14, 12, 14)); GridBagConstraints gbc = new GridBagConstraints(); - gbc.insets = new Insets(5, 6, 5, 6); + gbc.insets = new Insets(4, 5, 4, 5); gbc.fill = GridBagConstraints.HORIZONTAL; Font labelFont = ThemeManager.getUiFont(); - // Host + int gridy = 0; + + // 0. Profile Row gbc.gridx = 0; - gbc.gridy = 0; + gbc.gridy = gridy; + gbc.weightx = 0; + JLabel profileLabel = new JLabel("Profile:"); + profileLabel.setFont(labelFont); + contentPanel.add(profileLabel, gbc); + + gbc.gridx = 1; + gbc.weightx = 1.0; + JPanel profileBar = new JPanel(new BorderLayout(6, 0)); + profileBar.setOpaque(false); + + profileCombo = new JComboBox<>(); + profileCombo.setFont(ThemeManager.getUiFont()); + ThemeManager.styleComboBox(profileCombo); + refreshProfileCombo(); + profileCombo.addActionListener(e -> onProfileSelectionChanged()); + profileBar.add(profileCombo, BorderLayout.CENTER); + + JPanel profileBtnBar = new JPanel(new FlowLayout(FlowLayout.RIGHT, 4, 0)); + profileBtnBar.setOpaque(false); + + saveProfileBtn = new JButton("Save Profile..."); + ThemeManager.styleButton(saveProfileBtn, ThemeManager.ButtonVariant.DEFAULT); + saveProfileBtn.setFont(labelFont); + saveProfileBtn.addActionListener(e -> onSaveProfile()); + + deleteProfileBtn = new JButton("Delete"); + ThemeManager.styleButton(deleteProfileBtn, ThemeManager.ButtonVariant.CANCEL); + deleteProfileBtn.setFont(labelFont); + deleteProfileBtn.setEnabled(false); + deleteProfileBtn.addActionListener(e -> onDeleteProfile()); + + profileBtnBar.add(saveProfileBtn); + profileBtnBar.add(deleteProfileBtn); + profileBar.add(profileBtnBar, BorderLayout.EAST); + contentPanel.add(profileBar, gbc); + + gridy++; + + // 1. Host + gbc.gridx = 0; + gbc.gridy = gridy; + gbc.weightx = 0; JLabel hostLabel = new JLabel("Host:"); hostLabel.setFont(labelFont); - mainPanel.add(hostLabel, gbc); + contentPanel.add(hostLabel, gbc); gbc.gridx = 1; gbc.weightx = 1.0; hostField = createField(20); - mainPanel.add(hostField, gbc); + contentPanel.add(hostField, gbc); - // Port + gridy++; + + // 2. Port gbc.gridx = 0; - gbc.gridy = 1; + gbc.gridy = gridy; gbc.weightx = 0; JLabel portLabel = new JLabel("Port:"); portLabel.setFont(labelFont); - mainPanel.add(portLabel, gbc); + contentPanel.add(portLabel, gbc); gbc.gridx = 1; gbc.weightx = 1.0; portField = createField(6); portField.setText("23"); - mainPanel.add(portField, gbc); + contentPanel.add(portField, gbc); - // Model + gridy++; + + // 3. Model gbc.gridx = 0; - gbc.gridy = 2; + gbc.gridy = gridy; gbc.weightx = 0; JLabel modelLabel = new JLabel("Model:"); modelLabel.setFont(labelFont); - mainPanel.add(modelLabel, gbc); + contentPanel.add(modelLabel, gbc); gbc.gridx = 1; gbc.weightx = 1.0; modelCombo = new JComboBox<>(TerminalModel.values()); modelCombo.setSelectedItem(TerminalModel.IBM_3279_4); modelCombo.setFont(ThemeManager.getMonospacedUiFont()); ThemeManager.styleComboBox(modelCombo); - mainPanel.add(modelCombo, gbc); + contentPanel.add(modelCombo, gbc); - // Dynamic Dimensions (shown when IBM-DYNAMIC is selected) + gridy++; + + // 4. Dynamic Dimensions gbc.gridx = 0; - gbc.gridy = 3; + gbc.gridy = gridy; gbc.weightx = 0; dynamicDimLabel = new JLabel("Screen Size:"); dynamicDimLabel.setFont(labelFont); - mainPanel.add(dynamicDimLabel, gbc); + contentPanel.add(dynamicDimLabel, gbc); gbc.gridx = 1; gbc.weightx = 1.0; @@ -113,7 +185,7 @@ public class ConnectDialog extends JDialog { dynamicDimPanel.add(dynamicRowsSpinner); dynamicDimPanel.add(colsLabel); dynamicDimPanel.add(dynamicColsSpinner); - mainPanel.add(dynamicDimPanel, gbc); + contentPanel.add(dynamicDimPanel, gbc); Runnable updateDynamicVisibility = () -> { TerminalModel m = (TerminalModel) modelCombo.getSelectedItem(); @@ -125,40 +197,46 @@ public class ConnectDialog extends JDialog { modelCombo.addActionListener(e -> updateDynamicVisibility.run()); updateDynamicVisibility.run(); - // LU Name + gridy++; + + // 5. LU Name gbc.gridx = 0; - gbc.gridy = 4; + gbc.gridy = gridy; gbc.weightx = 0; JLabel luLabel = new JLabel("LU Name:"); luLabel.setFont(labelFont); - mainPanel.add(luLabel, gbc); + contentPanel.add(luLabel, gbc); gbc.gridx = 1; gbc.weightx = 1.0; luField = createField(12); - mainPanel.add(luField, gbc); + contentPanel.add(luField, gbc); - // Graphics Mode + gridy++; + + // 6. Graphics Mode gbc.gridx = 0; - gbc.gridy = 5; + gbc.gridy = gridy; gbc.weightx = 0; JLabel graphicsLabel = new JLabel("Graphics:"); graphicsLabel.setFont(labelFont); - mainPanel.add(graphicsLabel, gbc); + contentPanel.add(graphicsLabel, gbc); gbc.gridx = 1; gbc.weightx = 1.0; graphicsCombo = new JComboBox<>(haus.nightmare.lib3270j.graphics.GraphicsMode.values()); graphicsCombo.setSelectedItem(haus.nightmare.j3270.config.Settings.getGraphicsMode()); graphicsCombo.setFont(ThemeManager.getUiFont()); ThemeManager.styleComboBox(graphicsCombo); - mainPanel.add(graphicsCombo, gbc); + contentPanel.add(graphicsCombo, gbc); - // Code Page + gridy++; + + // 7. Code Page gbc.gridx = 0; - gbc.gridy = 6; + gbc.gridy = gridy; gbc.weightx = 0; JLabel cpLabel = new JLabel("Code Page:"); cpLabel.setFont(labelFont); - mainPanel.add(cpLabel, gbc); + contentPanel.add(cpLabel, gbc); gbc.gridx = 1; gbc.weightx = 1.0; String[] commonCodePages = { @@ -179,43 +257,44 @@ public class ConnectDialog extends JDialog { "1155 - Turkey (Turkish Latin-5 Euro \u20AC)", "905 - Turkey (Turkish Latin-3)", "420 - Arabic Bilingual", - "424 - Hebrew (with Lowercase)", - "803 - Hebrew Old / Standard", - "838 - Thai", - "1160 - Thai (Euro \u20AC)", + "424 - Hebrew (with Latin)", + "803 - Hebrew Character Set", + "838 - Thai Extended", + "1160 - Thai Euro \u20AC", "1025 - Cyrillic Multilingual", "1123 - Cyrillic Ukraine", - "1154 - Cyrillic Multilingual (Euro \u20AC)", - "880 - Cyrillic Russian", + "1154 - Cyrillic Euro \u20AC", + "880 - Cyrillic (Russian)", "1140 - US / Canada (Euro \u20AC)", "1141 - Germany / Austria (Euro \u20AC)", + "1142 - Denmark / Norway (Euro \u20AC)", + "1143 - Sweden / Finland (Euro \u20AC)", + "1144 - Italy (Euro \u20AC)", + "1145 - Spain / Latin America (Euro \u20AC)", + "1146 - United Kingdom (Euro \u20AC)", + "1147 - France (Euro \u20AC)", "1148 - International (Euro \u20AC)", + "1149 - Iceland (Euro \u20AC)", "930 - Japanese Katakana Mixed DBCS", "939 - Japanese Latin Mixed DBCS", "935 - Simplified Chinese Mixed DBCS", - "1388 - Simplified Chinese Extended Mixed DBCS", "937 - Traditional Chinese Mixed DBCS", - "1371 - Traditional Chinese Extended Mixed DBCS", + "1388 - Simplified Chinese Extended DBCS", + "1371 - Traditional Chinese Extended DBCS", "933 - Korean Mixed DBCS" }; codePageCombo = new JComboBox<>(commonCodePages); - codePageCombo.setEditable(true); - String currentCp = haus.nightmare.j3270.config.Settings.getCodePage(); - for (String item : commonCodePages) { - if (item.startsWith(currentCp + " ") || item.equals(currentCp)) { - codePageCombo.setSelectedItem(item); - break; - } - } - codePageCombo.setFont(ThemeManager.getUiFont()); + codePageCombo.setFont(ThemeManager.getMonospacedUiFont()); ThemeManager.styleComboBox(codePageCombo); - mainPanel.add(codePageCombo, gbc); + setInitialCodePage(haus.nightmare.j3270.config.Settings.getCodePage()); + contentPanel.add(codePageCombo, gbc); - // TLS / SSL Checkbox + gridy++; + + // 8. TLS Checkbox gbc.gridx = 1; - gbc.gridy = 7; - gbc.weightx = 1.0; - tlsCheckBox = new JCheckBox("Enable TLS/SSL"); + gbc.gridy = gridy; + tlsCheckBox = new JCheckBox("Use TLS / SSL Encryption"); ThemeManager.styleCheckBox(tlsCheckBox); tlsCheckBox.setFont(labelFont); tlsCheckBox.addActionListener(e -> { @@ -228,46 +307,169 @@ public class ConnectDialog extends JDialog { portField.setText("23"); } }); - mainPanel.add(tlsCheckBox, gbc); + contentPanel.add(tlsCheckBox, gbc); - // Verify Certificate Checkbox + gridy++; + + // 9. Verify Cert Checkbox gbc.gridx = 1; - gbc.gridy = 8; + gbc.gridy = gridy; verifyCertCheckBox = new JCheckBox("Verify Server Certificate"); ThemeManager.styleCheckBox(verifyCertCheckBox); verifyCertCheckBox.setFont(ThemeManager.getUiFont()); verifyCertCheckBox.setSelected(true); verifyCertCheckBox.setEnabled(false); - mainPanel.add(verifyCertCheckBox, gbc); + contentPanel.add(verifyCertCheckBox, gbc); - // TN3270E Checkbox + gridy++; + + // 10. TN3270E Checkbox gbc.gridx = 1; - gbc.gridy = 9; + gbc.gridy = gridy; tn3270eCheckBox = new JCheckBox("Enable TN3270E (Extended 3270)"); ThemeManager.styleCheckBox(tn3270eCheckBox); tn3270eCheckBox.setFont(ThemeManager.getUiFont()); tn3270eCheckBox.setSelected(haus.nightmare.j3270.config.Settings.getAutoConnectTn3270e()); - mainPanel.add(tn3270eCheckBox, gbc); + contentPanel.add(tn3270eCheckBox, gbc); - // Keep-Alive Checkbox + gridy++; + + // 11. Keep-Alive Checkbox gbc.gridx = 1; - gbc.gridy = 10; + gbc.gridy = gridy; keepAliveCheckBox = new JCheckBox("Enable Keep-Alive Heartbeat (NOP)"); ThemeManager.styleCheckBox(keepAliveCheckBox); keepAliveCheckBox.setFont(ThemeManager.getUiFont()); keepAliveCheckBox.setSelected(haus.nightmare.j3270.config.Settings.getAutoConnectKeepAlive()); - mainPanel.add(keepAliveCheckBox, gbc); + contentPanel.add(keepAliveCheckBox, gbc); - // Auto-Reconnect Checkbox + gridy++; + + // 12. Auto-Reconnect Checkbox gbc.gridx = 1; - gbc.gridy = 11; + gbc.gridy = gridy; autoReconnectCheckBox = new JCheckBox("Auto-Reconnect on Disconnect"); ThemeManager.styleCheckBox(autoReconnectCheckBox); autoReconnectCheckBox.setFont(ThemeManager.getUiFont()); autoReconnectCheckBox.setSelected(haus.nightmare.j3270.config.Settings.getAutoConnectAutoReconnect()); - mainPanel.add(autoReconnectCheckBox, gbc); + contentPanel.add(autoReconnectCheckBox, gbc); + + gridy++; + + // 13. PER-CONNECTION PROXY CONFIGURATION PANEL + gbc.gridx = 0; + gbc.gridy = gridy; + gbc.gridwidth = 2; + gbc.weightx = 1.0; + + JPanel proxyGroup = new JPanel(new GridBagLayout()); + proxyGroup.setBorder(BorderFactory.createTitledBorder( + BorderFactory.createEtchedBorder(), + "Proxy Configuration (This Connection)", + TitledBorder.LEFT, + TitledBorder.TOP, + labelFont + )); + GridBagConstraints pgbc = new GridBagConstraints(); + pgbc.insets = new Insets(3, 6, 3, 6); + pgbc.fill = GridBagConstraints.HORIZONTAL; + + // Proxy Type + pgbc.gridx = 0; + pgbc.gridy = 0; + pgbc.weightx = 0; + JLabel ptLabel = new JLabel("Proxy Type:"); + ptLabel.setFont(labelFont); + proxyGroup.add(ptLabel, pgbc); + + pgbc.gridx = 1; + pgbc.gridwidth = 3; + pgbc.weightx = 1.0; + proxyTypeCombo = new JComboBox<>(ConnectionConfig.ProxyType.values()); + proxyTypeCombo.setSelectedItem(ConnectionConfig.ProxyType.NONE); + proxyTypeCombo.setFont(ThemeManager.getUiFont()); + ThemeManager.styleComboBox(proxyTypeCombo); + proxyGroup.add(proxyTypeCombo, pgbc); + + // Proxy Host & Port + pgbc.gridx = 0; + pgbc.gridy = 1; + pgbc.gridwidth = 1; + pgbc.weightx = 0; + JLabel phLabel = new JLabel("Proxy Host:"); + phLabel.setFont(labelFont); + proxyGroup.add(phLabel, pgbc); + + pgbc.gridx = 1; + pgbc.weightx = 1.0; + proxyHostField = createField(14); + proxyGroup.add(proxyHostField, pgbc); + + pgbc.gridx = 2; + pgbc.weightx = 0; + JLabel ppLabel = new JLabel("Port:"); + ppLabel.setFont(labelFont); + proxyGroup.add(ppLabel, pgbc); + + pgbc.gridx = 3; + pgbc.weightx = 0.4; + proxyPortField = createField(5); + proxyGroup.add(proxyPortField, pgbc); + + // Proxy User & Password + pgbc.gridx = 0; + pgbc.gridy = 2; + pgbc.gridwidth = 1; + pgbc.weightx = 0; + JLabel puLabel = new JLabel("Username:"); + puLabel.setFont(labelFont); + proxyGroup.add(puLabel, pgbc); + + pgbc.gridx = 1; + pgbc.weightx = 1.0; + proxyUserField = createField(12); + proxyGroup.add(proxyUserField, pgbc); + + pgbc.gridx = 2; + pgbc.weightx = 0; + JLabel pwLabel = new JLabel("Password:"); + pwLabel.setFont(labelFont); + proxyGroup.add(pwLabel, pgbc); + + pgbc.gridx = 3; + pgbc.weightx = 0.4; + proxyPassField = createPasswordField(10); + proxyGroup.add(proxyPassField, pgbc); + + // Dynamic enable/disable based on Proxy Type + Runnable updateProxyFields = () -> { + ConnectionConfig.ProxyType pt = (ConnectionConfig.ProxyType) proxyTypeCombo.getSelectedItem(); + boolean active = (pt != null && pt != ConnectionConfig.ProxyType.NONE); + proxyHostField.setEnabled(active); + proxyPortField.setEnabled(active); + proxyUserField.setEnabled(active); + proxyPassField.setEnabled(active && pt != ConnectionConfig.ProxyType.SOCKS4); + + if (active) { + String currentPPort = proxyPortField.getText().trim(); + if (currentPPort.isEmpty() || "0".equals(currentPPort)) { + proxyPortField.setText(pt == ConnectionConfig.ProxyType.HTTP ? "8080" : "1080"); + } + } + }; + proxyTypeCombo.addActionListener(e -> updateProxyFields.run()); + updateProxyFields.run(); + + contentPanel.add(proxyGroup, gbc); + + gridy++; + + // 14. Action Buttons + gbc.gridx = 0; + gbc.gridy = gridy; + gbc.gridwidth = 2; + gbc.weightx = 1.0; - // Buttons JPanel buttonPanel = new JPanel(new FlowLayout(FlowLayout.RIGHT, 8, 4)); JButton connectBtn = new JButton("Connect"); @@ -285,13 +487,14 @@ public class ConnectDialog extends JDialog { buttonPanel.add(cancelBtn); buttonPanel.add(connectBtn); + contentPanel.add(buttonPanel, gbc); - gbc.gridx = 0; - gbc.gridy = 12; - gbc.gridwidth = 2; - mainPanel.add(buttonPanel, gbc); + JScrollPane scrollPane = new JScrollPane(contentPanel); + scrollPane.setBorder(BorderFactory.createEmptyBorder()); + scrollPane.setHorizontalScrollBarPolicy(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER); + scrollPane.getVerticalScrollBar().setUnitIncrement(14); + setContentPane(scrollPane); - setContentPane(mainPanel); ThemeManager.applyThemeToWindow(this); // Enter key triggers connect @@ -305,6 +508,189 @@ public class ConnectDialog extends JDialog { return field; } + private JPasswordField createPasswordField(int cols) { + JPasswordField field = new JPasswordField(cols); + field.setFont(ThemeManager.getMonospacedUiFont()); + ThemeManager.styleTextField(field); + return field; + } + + // ================= Profile Management ================= + + private void refreshProfileCombo() { + profileCombo.removeAllItems(); + profileCombo.addItem(NEW_PROFILE_LABEL); + List saved = HostStorage.getSavedHosts(); + for (SavedHost h : saved) { + profileCombo.addItem(h); + } + if (selectedProfileId != null) { + for (int i = 0; i < profileCombo.getItemCount(); i++) { + Object item = profileCombo.getItemAt(i); + if (item instanceof SavedHost && ((SavedHost) item).getId().equals(selectedProfileId)) { + profileCombo.setSelectedIndex(i); + return; + } + } + } + profileCombo.setSelectedIndex(0); + } + + private void onProfileSelectionChanged() { + Object sel = profileCombo.getSelectedItem(); + if (sel instanceof SavedHost) { + SavedHost h = (SavedHost) sel; + selectedProfileId = h.getId(); + deleteProfileBtn.setEnabled(true); + loadProfileFields(h); + } else { + selectedProfileId = null; + deleteProfileBtn.setEnabled(false); + } + } + + public void loadProfile(SavedHost h) { + if (h == null) return; + selectedProfileId = h.getId(); + refreshProfileCombo(); + loadProfileFields(h); + } + + private void loadProfileFields(SavedHost h) { + if (h == null) return; + hostField.setText(h.getHost()); + portField.setText(String.valueOf(h.getPort())); + if (h.getModel() == 0) { + modelCombo.setSelectedItem(TerminalModel.IBM_DYNAMIC); + } else { + try { + modelCombo.setSelectedItem(TerminalModel.forModel(h.getModel(), true)); + } catch (Exception ignored) { + } + } + dynamicRowsSpinner.setValue(h.getDynamicRows() > 0 ? h.getDynamicRows() : 62); + dynamicColsSpinner.setValue(h.getDynamicCols() > 0 ? h.getDynamicCols() : 160); + luField.setText(h.getLuName() != null ? h.getLuName() : ""); + tlsCheckBox.setSelected(h.isUseTls()); + verifyCertCheckBox.setEnabled(h.isUseTls()); + verifyCertCheckBox.setSelected(h.isTlsVerifyCert()); + tn3270eCheckBox.setSelected(h.isTn3270e()); + setInitialCodePage(h.getCodePage()); + + if (h.getGraphicsMode() != null) { + graphicsCombo.setSelectedItem(haus.nightmare.lib3270j.graphics.GraphicsMode.fromString(h.getGraphicsMode())); + } + + // Per-connection proxy parameters + String ptStr = h.getProxyType(); + try { + ConnectionConfig.ProxyType pt = (ptStr != null && !ptStr.trim().isEmpty()) + ? ConnectionConfig.ProxyType.valueOf(ptStr.toUpperCase()) + : ConnectionConfig.ProxyType.NONE; + proxyTypeCombo.setSelectedItem(pt); + } catch (IllegalArgumentException e) { + proxyTypeCombo.setSelectedItem(ConnectionConfig.ProxyType.NONE); + } + proxyHostField.setText(h.getProxyHost() != null ? h.getProxyHost() : ""); + proxyPortField.setText(h.getProxyPort() > 0 ? String.valueOf(h.getProxyPort()) : ""); + proxyUserField.setText(h.getProxyUsername() != null ? h.getProxyUsername() : ""); + proxyPassField.setText(h.getProxyPassword() != null ? h.getProxyPassword() : ""); + } + + private void onSaveProfile() { + String hStr = hostField.getText().trim(); + if (hStr.isEmpty()) { + hostField.requestFocus(); + JOptionPane.showMessageDialog(this, "Please specify a host before saving profile.", "Missing Host", JOptionPane.WARNING_MESSAGE); + return; + } + + String defaultName = hStr; + Object currentSel = profileCombo.getSelectedItem(); + if (currentSel instanceof SavedHost) { + defaultName = ((SavedHost) currentSel).getName(); + } + + String profileName = (String) JOptionPane.showInputDialog( + this, + "Enter Host Profile Name:", + "Save Profile", + JOptionPane.PLAIN_MESSAGE, + null, + null, + defaultName + ); + + if (profileName == null || profileName.trim().isEmpty()) { + return; + } + profileName = profileName.trim(); + + SavedHost hostToSave = buildCurrentSavedHost(profileName); + HostStorage.saveHost(hostToSave); + selectedProfileId = hostToSave.getId(); + refreshProfileCombo(); + } + + private void onDeleteProfile() { + if (selectedProfileId == null) return; + int opt = JOptionPane.showConfirmDialog(this, + "Are you sure you want to delete this host profile?", + "Delete Profile", + JOptionPane.YES_NO_OPTION); + if (opt == JOptionPane.YES_OPTION) { + HostStorage.deleteHost(selectedProfileId); + selectedProfileId = null; + refreshProfileCombo(); + } + } + + private SavedHost buildCurrentSavedHost(String profileName) { + String id = selectedProfileId != null ? selectedProfileId : java.util.UUID.randomUUID().toString(); + String h = hostField.getText().trim(); + int port = 23; + try { + port = Integer.parseInt(portField.getText().trim()); + } catch (NumberFormatException ignored) {} + + TerminalModel m = (TerminalModel) modelCombo.getSelectedItem(); + int mNum = (m != null) ? m.getModelNumber() : 4; + int dRows = (Integer) dynamicRowsSpinner.getValue(); + int dCols = (Integer) dynamicColsSpinner.getValue(); + String lu = luField.getText().trim(); + boolean tls = tlsCheckBox.isSelected(); + boolean verify = verifyCertCheckBox.isSelected(); + boolean tn3270e = tn3270eCheckBox.isSelected(); + + String gfx = "BOTH"; + if (graphicsCombo.getSelectedItem() != null) { + gfx = graphicsCombo.getSelectedItem().toString(); + } + + String cp = "037"; + Object cpSel = codePageCombo.getSelectedItem(); + if (cpSel != null) { + String s = cpSel.toString().trim(); + int dash = s.indexOf(" -"); + cp = (dash > 0) ? s.substring(0, dash).trim() : s; + } + + ConnectionConfig.ProxyType pt = (ConnectionConfig.ProxyType) proxyTypeCombo.getSelectedItem(); + String ptStr = (pt != null) ? pt.name() : "NONE"; + String pHost = proxyHostField.getText().trim(); + int pPort = 0; + try { + pPort = Integer.parseInt(proxyPortField.getText().trim()); + } catch (NumberFormatException ignored) {} + String pUser = proxyUserField.getText().trim(); + String pPass = new String(proxyPassField.getPassword()); + + return new SavedHost(id, profileName, h, port, mNum, dRows, dCols, lu, false, + "TSO", tls, verify, tn3270e, gfx, cp, ptStr, pHost, pPort, pUser, pPass); + } + + // ================= Connect Action ================= + private void onConnect() { String host = hostField.getText().trim(); if (host.isEmpty()) { @@ -321,20 +707,33 @@ public class ConnectDialog extends JDialog { } TerminalModel selectedModel = (TerminalModel) modelCombo.getSelectedItem(); - result = new ConnectionConfig(host, port, selectedModel); - if (selectedModel != null && selectedModel.isDynamic()) { + if (selectedModel == null) { + selectedModel = TerminalModel.IBM_3279_4; + } + + // Support full host string parsing if the user pasted flags or prefixes into host field + if (host.contains(" ") || host.startsWith("L:") || host.startsWith("P:") || host.startsWith("--proxy=") || host.startsWith("-proxy=")) { + result = ConnectionConfig.parseHostString(host, port, selectedModel); + } else { + result = new ConnectionConfig(host, port, selectedModel); + } + + if (selectedModel.isDynamic()) { int dRows = (Integer) dynamicRowsSpinner.getValue(); int dCols = (Integer) dynamicColsSpinner.getValue(); result.setDynamicDimensions(dRows, dCols); haus.nightmare.j3270.config.Settings.setDynamicRows(dRows); haus.nightmare.j3270.config.Settings.setDynamicCols(dCols); } + String lu = luField.getText().trim(); if (!lu.isEmpty()) { result.setLuName(lu); } - result.setGraphicsMode((haus.nightmare.lib3270j.graphics.GraphicsMode) graphicsCombo.getSelectedItem()); - + if (graphicsCombo.getSelectedItem() != null) { + result.setGraphicsMode((haus.nightmare.lib3270j.graphics.GraphicsMode) graphicsCombo.getSelectedItem()); + } + Object cpSelection = codePageCombo.getSelectedItem(); if (cpSelection != null) { String cpStr = cpSelection.toString().trim(); @@ -361,6 +760,22 @@ public class ConnectDialog extends JDialog { result.setReconnectMaxRetries(haus.nightmare.j3270.config.Settings.getAutoConnectReconnectMaxRetries()); haus.nightmare.j3270.config.Settings.setAutoConnectAutoReconnect(ar); + // Apply per-connection proxy parameters strictly to this connection + ConnectionConfig.ProxyType pType = (ConnectionConfig.ProxyType) proxyTypeCombo.getSelectedItem(); + String pHost = proxyHostField.getText().trim(); + if (pType != null && pType != ConnectionConfig.ProxyType.NONE && !pHost.isEmpty()) { + int pPort = 0; + try { + pPort = Integer.parseInt(proxyPortField.getText().trim()); + } catch (NumberFormatException ignored) {} + if (pPort <= 0) { + pPort = (pType == ConnectionConfig.ProxyType.HTTP) ? 8080 : 1080; + } + String pUser = proxyUserField.getText().trim(); + String pPass = new String(proxyPassField.getPassword()); + result.setProxy(pType, pHost, pPort, pUser.isEmpty() ? null : pUser, pPass.isEmpty() ? null : pPass); + } + confirmed = true; dispose(); } @@ -407,4 +822,49 @@ public class ConnectDialog extends JDialog { codePageCombo.setSelectedItem(cp); } } + + /** + * Set initial proxy settings for this connection. + */ + public void setInitialProxy(ConnectionConfig.ProxyType type, String host, int port, String user, String pass) { + if (proxyTypeCombo != null) { + proxyTypeCombo.setSelectedItem(type != null ? type : ConnectionConfig.ProxyType.NONE); + } + if (proxyHostField != null) { + proxyHostField.setText(host != null ? host : ""); + } + if (proxyPortField != null) { + proxyPortField.setText(port > 0 ? String.valueOf(port) : ""); + } + if (proxyUserField != null) { + proxyUserField.setText(user != null ? user : ""); + } + if (proxyPassField != null) { + proxyPassField.setText(pass != null ? pass : ""); + } + } + + public ConnectionConfig.ProxyType getSelectedProxyType() { + return (ConnectionConfig.ProxyType) proxyTypeCombo.getSelectedItem(); + } + + public String getProxyHost() { + return proxyHostField.getText().trim(); + } + + public int getProxyPort() { + try { + return Integer.parseInt(proxyPortField.getText().trim()); + } catch (Exception e) { + return 0; + } + } + + public String getProxyUsername() { + return proxyUserField.getText().trim(); + } + + public String getProxyPassword() { + return new String(proxyPassField.getPassword()); + } } diff --git a/j3270/src/test/java/haus/nightmare/j3270/storage/HostStorageAndProxyTest.java b/j3270/src/test/java/haus/nightmare/j3270/storage/HostStorageAndProxyTest.java new file mode 100644 index 0000000..06b6777 --- /dev/null +++ b/j3270/src/test/java/haus/nightmare/j3270/storage/HostStorageAndProxyTest.java @@ -0,0 +1,307 @@ +package haus.nightmare.j3270.storage; + +import haus.nightmare.j3270.ui.ConnectDialog; +import haus.nightmare.lib3270j.ConnectionConfig; +import haus.nightmare.lib3270j.TerminalModel; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.awt.Frame; +import java.awt.GraphicsEnvironment; +import java.util.List; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; + +public class HostStorageAndProxyTest { + + private List originalHosts; + + @BeforeEach + public void setUp() { + originalHosts = HostStorage.getSavedHosts(); + HostStorage.clearAll(); + } + + @AfterEach + public void tearDown() { + HostStorage.clearAll(); + HostStorage.saveAll(originalHosts); + } + + @Test + @DisplayName("SavedHost correctly models per-connection proxy and converts to ConnectionConfig") + public void testSavedHostProxyToConnectionConfig() { + // 1. Host with SOCKS5 proxy and auth + SavedHost host1 = new SavedHost( + UUID.randomUUID().toString(), + "Prod Mainframe", + "mvs.corp.local", + 2323, + 4, + 43, + 80, + "TSO01", + false, + "TSO", + true, + true, + true, + "BOTH", + "037", + "SOCKS5", + "proxy.internal.corp", + 1080, + "admin", + "secret123" + ); + + ConnectionConfig cfg1 = host1.toConnectionConfig(); + assertEquals("mvs.corp.local", cfg1.getHost()); + assertEquals(2323, cfg1.getPort()); + assertTrue(cfg1.isUseTls()); + assertEquals("TSO01", cfg1.getLuName()); + assertEquals(ConnectionConfig.ProxyType.SOCKS5, cfg1.getProxyType()); + assertEquals("proxy.internal.corp", cfg1.getProxyHost()); + assertEquals(1080, cfg1.getProxyPort()); + assertEquals("admin", cfg1.getProxyUsername()); + assertEquals("secret123", cfg1.getProxyPassword()); + + // 2. Host with direct connection (NONE proxy) + SavedHost host2 = new SavedHost( + UUID.randomUUID().toString(), + "Local VM370", + "127.0.0.1", + 23, + 2, + 24, + 80, + "", + false, + "CMS", + false, + false, + false, + "BOTH", + "037", + "NONE", + "", + 0, + "", + "" + ); + + ConnectionConfig cfg2 = host2.toConnectionConfig(); + assertEquals("127.0.0.1", cfg2.getHost()); + assertEquals(23, cfg2.getPort()); + assertFalse(cfg2.isUseTls()); + assertEquals(ConnectionConfig.ProxyType.NONE, cfg2.getProxyType()); + assertNull(cfg2.getProxyHost()); + } + + @Test + @DisplayName("HostStorage persists and restores multiple host profiles with isolated per-connection proxies") + public void testHostStoragePersistenceAndIsolation() { + SavedHost h1 = new SavedHost( + "id-1", + "System A (SOCKS5)", + "sysa.corp", + 23, + 4, + 62, + 160, + "", + false, + "TSO", + false, + true, + true, + "BOTH", + "037", + "SOCKS5", + "socks.gateway.net", + 1080, + "alice", + "passA" + ); + + SavedHost h2 = new SavedHost( + "id-2", + "System B (HTTP CONNECT)", + "sysb.dmz", + 992, + 5, + 27, + 132, + "LU99", + false, + "TSO", + true, + true, + true, + "BOTH", + "1047", + "HTTP", + "squid.proxy.org", + 3128, + "bob", + "passB" + ); + + SavedHost h3 = new SavedHost( + "id-3", + "System C (Direct)", + "sysc.lan", + 23, + 2, + 24, + 80, + "", + true, // autoConnect + "CMS", + false, + true, + true, + "BOTH", + "500", + "NONE", + "", + 0, + "", + "" + ); + + HostStorage.saveHost(h1); + HostStorage.saveHost(h2); + HostStorage.saveHost(h3); + + List loaded = HostStorage.getSavedHosts(); + assertEquals(3, loaded.size()); + + SavedHost loadedA = HostStorage.findById("id-1"); + assertNotNull(loadedA); + assertEquals("System A (SOCKS5)", loadedA.getName()); + assertEquals("SOCKS5", loadedA.getProxyType()); + assertEquals("socks.gateway.net", loadedA.getProxyHost()); + assertEquals(1080, loadedA.getProxyPort()); + assertEquals("alice", loadedA.getProxyUsername()); + assertEquals("passA", loadedA.getProxyPassword()); + + SavedHost loadedB = HostStorage.findById("id-2"); + assertNotNull(loadedB); + assertEquals("HTTP", loadedB.getProxyType()); + assertEquals("squid.proxy.org", loadedB.getProxyHost()); + assertEquals(3128, loadedB.getProxyPort()); + assertEquals("bob", loadedB.getProxyUsername()); + assertEquals("passB", loadedB.getProxyPassword()); + + SavedHost loadedC = HostStorage.findById("id-3"); + assertNotNull(loadedC); + assertEquals("NONE", loadedC.getProxyType()); + assertEquals("", loadedC.getProxyHost()); + assertTrue(loadedC.isAutoConnect()); + + // Verify getAutoConnectHost returns System C with its own connection settings + SavedHost auto = HostStorage.getAutoConnectHost(); + assertNotNull(auto); + assertEquals("id-3", auto.getId()); + assertEquals(ConnectionConfig.ProxyType.NONE, auto.toConnectionConfig().getProxyType()); + + // Verify deleteHost + HostStorage.deleteHost("id-2"); + assertEquals(2, HostStorage.getSavedHosts().size()); + assertNull(HostStorage.findById("id-2")); + } + + @Test + @DisplayName("HostStorage JSON matches a3270 format and parses a3270 JSON strings") + public void testA3270JsonFormatCompatibility() { + // String representative of a3270 saved_hosts preference JSON + String a3270Json = "[\n" + + " {\n" + + " \"id\": \"android-uuid-1\",\n" + + " \"name\": \"Dallas Mainframe\",\n" + + " \"host\": \"dallas.ibm.com\",\n" + + " \"port\": 2323,\n" + + " \"model\": 4,\n" + + " \"dynamicRows\": 62,\n" + + " \"dynamicCols\": 160,\n" + + " \"luName\": \"DAL01\",\n" + + " \"autoConnect\": false,\n" + + " \"hostType\": \"TSO\",\n" + + " \"useTls\": true,\n" + + " \"tlsVerifyCert\": false,\n" + + " \"tn3270e\": true,\n" + + " \"graphicsMode\": \"BOTH\",\n" + + " \"codePage\": \"037\",\n" + + " \"proxyType\": \"SOCKS5\",\n" + + " \"proxyHost\": \"10.20.30.40\",\n" + + " \"proxyPort\": 9050,\n" + + " \"proxyUsername\": \"dallasuser\",\n" + + " \"proxyPassword\": \"dallaspass\"\n" + + " }\n" + + "]"; + + List parsed = HostStorage.parseHostsJson(a3270Json); + assertEquals(1, parsed.size()); + SavedHost h = parsed.get(0); + assertEquals("android-uuid-1", h.getId()); + assertEquals("Dallas Mainframe", h.getName()); + assertEquals("dallas.ibm.com", h.getHost()); + assertEquals(2323, h.getPort()); + assertEquals(4, h.getModel()); + assertTrue(h.isUseTls()); + assertFalse(h.isTlsVerifyCert()); + assertEquals("SOCKS5", h.getProxyType()); + assertEquals("10.20.30.40", h.getProxyHost()); + assertEquals(9050, h.getProxyPort()); + assertEquals("dallasuser", h.getProxyUsername()); + assertEquals("dallaspass", h.getProxyPassword()); + + // Roundtrip serialization + String serialized = HostStorage.serializeHostsJson(parsed); + List roundtripped = HostStorage.parseHostsJson(serialized); + assertEquals(1, roundtripped.size()); + SavedHost r = roundtripped.get(0); + assertEquals(h.getId(), r.getId()); + assertEquals(h.getProxyType(), r.getProxyType()); + assertEquals(h.getProxyHost(), r.getProxyHost()); + assertEquals(h.getProxyPort(), r.getProxyPort()); + assertEquals(h.getProxyUsername(), r.getProxyUsername()); + assertEquals(h.getProxyPassword(), r.getProxyPassword()); + } + + @Test + @DisplayName("ConnectDialog loads profile proxy and applies per-connection proxy to ConnectionConfig") + public void testConnectDialogProxyHandling() { + if (!GraphicsEnvironment.isHeadless()) { + ConnectDialog dialog = new ConnectDialog((Frame) null); + dialog.setInitialHost("test.mainframe.com"); + dialog.setInitialPort(23); + dialog.setInitialProxy(ConnectionConfig.ProxyType.SOCKS5, "127.0.0.1", 1080, "testuser", "testpass"); + + assertEquals(ConnectionConfig.ProxyType.SOCKS5, dialog.getSelectedProxyType()); + assertEquals("127.0.0.1", dialog.getProxyHost()); + assertEquals(1080, dialog.getProxyPort()); + assertEquals("testuser", dialog.getProxyUsername()); + assertEquals("testpass", dialog.getProxyPassword()); + + // Loading a profile updates dialog proxy fields + SavedHost profile = new SavedHost( + "p1", "Profile 1", "prod.ibm.net", 992, 4, 43, 80, "", false, + "TSO", true, true, true, "BOTH", "037", + "HTTP", "proxy.corp.net", 8080, "corpuser", "corppass" + ); + dialog.loadProfile(profile); + + assertEquals(ConnectionConfig.ProxyType.HTTP, dialog.getSelectedProxyType()); + assertEquals("proxy.corp.net", dialog.getProxyHost()); + assertEquals(8080, dialog.getProxyPort()); + assertEquals("corpuser", dialog.getProxyUsername()); + assertEquals("corppass", dialog.getProxyPassword()); + dialog.dispose(); + } + } +}