diff --git a/pom.xml b/pom.xml index cbf2852..408b0ec 100644 --- a/pom.xml +++ b/pom.xml @@ -53,46 +53,6 @@ Open Autonomous Public License (OAPL) https://open-autonomous-connection.org/license.html - - GNU General Public License v3.0 - https://www.gnu.org/licenses/gpl-3.0.html - - Default license: Applies to all users and projects unless an explicit alternative license has been - granted. - - - - LPGL 3 - https://www.gnu.org/licenses/lgpl-3.0.html#license-text - - - LPGL 2.1 - https://www.gnu.org/licenses/old-licenses/lgpl-2.1.html.en#SEC1 - - - WTPL License - https://github.com/ronmamo/reflections/tree/master?webTab=WTFPL-1-ov-file - - - Apache License 2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt - - - MIT License - https://opensource.org/license/mit - - - javassist - https://github.com/jboss-javassist/javassist/blob/master/License.html - - - projectlombok - https://github.com/projectlombok/lombok?webTab=License-1-ov-file - - - Eclipse Public License v2.0 - https://www.eclipse.org/legal/epl-2.0/ - @@ -109,7 +69,17 @@ org.openautonomousconnection OACSwing - 1.0.0-BETA.1.0 + 1.0.0-BETA.1.1 + + + org.openautonomousconnection + LuaScript + 1.0.0-BETA.1.1 + + + org.openautonomousconnection + InfoNameLib + 1.0.0-BETA.1.3 org.projectlombok @@ -117,16 +87,6 @@ 1.18.38 provided - - org.openautonomousconnection - LuaScript - 1.0.0-BETA.1.0 - - - org.openautonomousconnection - InfoNameLib - 1.0.0-BETA.1.0 - org.openjfx javafx-base diff --git a/src/main/java/org/openautonomousconnection/webclient/ClientImpl.java b/src/main/java/org/openautonomousconnection/webclient/ClientImpl.java index 35c4753..dba0639 100644 --- a/src/main/java/org/openautonomousconnection/webclient/ClientImpl.java +++ b/src/main/java/org/openautonomousconnection/webclient/ClientImpl.java @@ -1,19 +1,36 @@ package org.openautonomousconnection.webclient; import dev.unlegitdqrk.unlegitlibrary.event.Listener; +import org.openautonomousconnection.infonamelib.LibClientImpl; import org.openautonomousconnection.infonamelib.OacWebUrlInstaller; import org.openautonomousconnection.oacswing.component.OACOptionPane; import org.openautonomousconnection.protocol.side.client.ProtocolClient; import org.openautonomousconnection.protocol.side.client.events.ConnectedToProtocolINSServerEvent; -import javax.swing.*; +import java.awt.*; +import java.util.Objects; +import java.util.concurrent.atomic.AtomicBoolean; +/** + * Protocol client implementation for the WebClient. + */ public class ClientImpl extends ProtocolClient { + + private final LibImpl libImpl = new LibImpl(); + private final AtomicBoolean connectedInitialized = new AtomicBoolean(false); + private final Component dialogParent; + private final Runnable onServerReady; + + public ClientImpl(Component dialogParent, Runnable onServerReady) { + this.dialogParent = dialogParent; + this.onServerReady = Objects.requireNonNull(onServerReady, "onServerReady"); + } + @Override public boolean trustINS(String caFingerprint) { Object[] options = {"Continue", "Cancel"}; int result = OACOptionPane.showOptionDialog( - Main.getUi(), + dialogParent, "You never connected to this INS before!\n" + "Fingerprint: " + caFingerprint + "\nDo you want to connect?", "INS Connection", @@ -21,7 +38,7 @@ public class ClientImpl extends ProtocolClient { OACOptionPane.INFORMATION_MESSAGE, null, options, - options[0] // default button: Continue + options[0] ); return result == 0; @@ -31,13 +48,8 @@ public class ClientImpl extends ProtocolClient { public boolean trustNewINSFingerprint(String oldCAFingerprint, String newCAFingerprint) { Object[] options = {"Continue", "Cancel"}; - String table = String.format(""" - Saved Fingerprint\tNew Fingerprint - %s\t%s - """, oldCAFingerprint, newCAFingerprint); - int result = OACOptionPane.showOptionDialog( - Main.getUi(), + dialogParent, "The fingerprint does not match with the saved fingerprint!\n" + "Saved Fingerprint: " + oldCAFingerprint + "\n" + "New Fingerprint: " + newCAFingerprint + "\n" + @@ -47,7 +59,7 @@ public class ClientImpl extends ProtocolClient { OACOptionPane.INFORMATION_MESSAGE, null, options, - options[0] // default button: Continue + options[0] ); return result == 0; @@ -57,15 +69,31 @@ public class ClientImpl extends ProtocolClient { public void onConnected(ConnectedToProtocolINSServerEvent event) { try { buildServerConnection(null, getProtocolBridge().getProtocolValues().ssl); - - //ProtocolHandlerPackages.installPackage("org.openautonomousconnection.infonamelib"); - OacWebUrlInstaller.installOnce(getProtocolBridge().getProtocolValues().eventManager, this); - - SwingUtilities.invokeLater(() -> Main.getUi().openNewTab("web://info.oac/")); + OacWebUrlInstaller.installOnce(getProtocolBridge().getProtocolValues().eventManager, this, libImpl); + if (connectedInitialized.compareAndSet(false, true)) { + onServerReady.run(); + } } catch (Exception e) { - Main.getClient().getProtocolBridge().getLogger().exception("Failed to build Server connection", e); - OACOptionPane.showMessageDialog(Main.getUi(), "Failed to connect to build Server connection:\n" + e.getMessage(), - "Server Connection", OACOptionPane.ERROR_MESSAGE); + getProtocolBridge().getLogger().exception("Failed to build Server connection", e); + OACOptionPane.showMessageDialog( + dialogParent, + "Failed to to build Server connection:\n" + e.getMessage(), + "Server Connection", + OACOptionPane.ERROR_MESSAGE + ); + } + } + + private class LibImpl extends LibClientImpl { + @Override + public void serverConnectionFailed(Exception exception) { + getProtocolBridge().getLogger().exception("Failed to connect to server", exception); + OACOptionPane.showMessageDialog( + dialogParent, + "Failed to connect to Server:\n" + exception.getMessage(), + "Server Connection", + OACOptionPane.ERROR_MESSAGE + ); } } } diff --git a/src/main/java/org/openautonomousconnection/webclient/ui/FxBootstrap.java b/src/main/java/org/openautonomousconnection/webclient/FxBootstrap.java similarity index 86% rename from src/main/java/org/openautonomousconnection/webclient/ui/FxBootstrap.java rename to src/main/java/org/openautonomousconnection/webclient/FxBootstrap.java index cacddcd..a0a54d2 100644 --- a/src/main/java/org/openautonomousconnection/webclient/ui/FxBootstrap.java +++ b/src/main/java/org/openautonomousconnection/webclient/FxBootstrap.java @@ -1,4 +1,4 @@ -package org.openautonomousconnection.webclient.ui; +package org.openautonomousconnection.webclient; import javafx.application.Platform; import javafx.embed.swing.JFXPanel; @@ -28,7 +28,7 @@ public final class FxBootstrap { // Creating a JFXPanel initializes the JavaFX toolkit in Swing apps. new JFXPanel(); - // Optional: keep JavaFX runtime alive even if last window closes. + // Keep JavaFX runtime alive even if last window closes. Platform.setImplicitExit(false); } } \ No newline at end of file diff --git a/src/main/java/org/openautonomousconnection/webclient/Main.java b/src/main/java/org/openautonomousconnection/webclient/Main.java index 480e0a1..a80427f 100644 --- a/src/main/java/org/openautonomousconnection/webclient/Main.java +++ b/src/main/java/org/openautonomousconnection/webclient/Main.java @@ -1,53 +1,35 @@ package org.openautonomousconnection.webclient; -import dev.unlegitdqrk.unlegitlibrary.event.EventManager; -import dev.unlegitdqrk.unlegitlibrary.network.system.packets.PacketHandler; -import lombok.Getter; +import dev.unlegitdqrk.unlegitlibrary.addon.AddonLoader; import org.openautonomousconnection.oacswing.component.design.Design; import org.openautonomousconnection.oacswing.component.design.DesignManager; -import org.openautonomousconnection.protocol.ProtocolBridge; -import org.openautonomousconnection.protocol.ProtocolValues; -import org.openautonomousconnection.protocol.versions.ProtocolVersion; -import org.openautonomousconnection.webclient.settings.INSList; +import org.openautonomousconnection.webclient.settings.AppSettings; +import org.openautonomousconnection.webclient.settings.SettingsManager; import org.openautonomousconnection.webclient.ui.BrowserUI; -import org.openautonomousconnection.webclient.ui.FxBootstrap; import javax.swing.*; -import java.io.File; +import java.io.IOException; import java.net.CookieHandler; import java.net.CookieManager; import java.net.CookiePolicy; +/** + * Application entry point. + */ public class Main { - @Getter - private static ClientImpl client; - - private static ProtocolBridge bridge; - - @Getter private static BrowserUI ui; - private static void initProtocol() { - ProtocolValues values = new ProtocolValues(); - values.packetHandler = new PacketHandler(); - values.eventManager = new EventManager(); - values.ssl = true; + private static AppSettings settings; - client = new ClientImpl(); + private static AddonLoader addonLoader; - try { - bridge = new ProtocolBridge( - client, - values, - ProtocolVersion.PV_1_0_0_BETA, - new File("logs") - ); + public static BrowserUI getUi() { + return ui; + } - client.buildINSConnection(); - } catch (Exception e) { - throw new RuntimeException(e); - } + public static AppSettings getSettings() { + return settings; } private static void installDefaultCookieManager() { @@ -58,24 +40,19 @@ public class Main { CookieHandler.setDefault(cm); } - public static void main(String[] args) { - initProtocol(); + public static void main(String[] args) throws IOException { + settings = SettingsManager.load(); + FxBootstrap.ensureInitialized(); installDefaultCookieManager(); DesignManager.setGlobalDesign(Design.DARK); SwingUtilities.invokeLater(() -> { - ui = new BrowserUI(); + ui = new BrowserUI(settings); ui.setSize(1200, 800); ui.setLocationRelativeTo(null); ui.setVisible(true); - - try { - bridge.getProtocolValues().eventManager.registerListener(client); - client.getClientINSConnection().connect(INSList.DEFAULT_INS, INSList.DEFAULT_PORT); - } catch (Exception exception) { - exception.printStackTrace(System.out); - } + ui.openNewTab(settings.getStartPageUrl()); }); } -} \ No newline at end of file +} diff --git a/src/main/java/org/openautonomousconnection/webclient/lua/WebLogger.java b/src/main/java/org/openautonomousconnection/webclient/lua/WebLogger.java index 509e8b9..c676666 100644 --- a/src/main/java/org/openautonomousconnection/webclient/lua/WebLogger.java +++ b/src/main/java/org/openautonomousconnection/webclient/lua/WebLogger.java @@ -1,36 +1,40 @@ package org.openautonomousconnection.webclient.lua; -import org.openautonomousconnection.webclient.Main; +import org.openautonomousconnection.webclient.ClientImpl; + +import java.util.Objects; public class WebLogger { private final String host; + private final ClientImpl client; - public WebLogger(String host) { + public WebLogger(String host, ClientImpl client) { this.host = host; + this.client = Objects.requireNonNull(client, "client"); } public void log(String string) { - Main.getClient().getProtocolBridge().getLogger().log(host + ": " + string); + client.getProtocolBridge().getLogger().log(host + ": " + string); } public void info(String info) { - Main.getClient().getProtocolBridge().getLogger().info(host + ": " + info); + client.getProtocolBridge().getLogger().info(host + ": " + info); } public void warn(String warn) { - Main.getClient().getProtocolBridge().getLogger().warn(host + ": " + warn); + client.getProtocolBridge().getLogger().warn(host + ": " + warn); } public void error(String error) { - Main.getClient().getProtocolBridge().getLogger().error(host + ": " + error); + client.getProtocolBridge().getLogger().error(host + ": " + error); } public void exception(String infoLine, Exception exception) { - Main.getClient().getProtocolBridge().getLogger().exception(host + ": " + infoLine, exception); + client.getProtocolBridge().getLogger().exception(host + ": " + infoLine, exception); } public void debug(String debug) { - Main.getClient().getProtocolBridge().getLogger().debug(host + ": " + debug); + client.getProtocolBridge().getLogger().debug(host + ": " + debug); } } diff --git a/src/main/java/org/openautonomousconnection/webclient/lua/hosts/FxFormNavigationBridge.java b/src/main/java/org/openautonomousconnection/webclient/lua/hosts/FxFormNavigationBridge.java deleted file mode 100644 index 53aa17e..0000000 --- a/src/main/java/org/openautonomousconnection/webclient/lua/hosts/FxFormNavigationBridge.java +++ /dev/null @@ -1,4 +0,0 @@ -package org.openautonomousconnection.webclient.lua.hosts; - -public class FxFormNavigationBridge { -} diff --git a/src/main/java/org/openautonomousconnection/webclient/settings/AppSettings.java b/src/main/java/org/openautonomousconnection/webclient/settings/AppSettings.java new file mode 100644 index 0000000..0bf6bf2 --- /dev/null +++ b/src/main/java/org/openautonomousconnection/webclient/settings/AppSettings.java @@ -0,0 +1,192 @@ +package org.openautonomousconnection.webclient.settings; + +import org.openautonomousconnection.luascript.security.LuaExecutionPolicy; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Objects; + +/** + * In-memory settings model. + * + *

Persisted by {@link SettingsManager}.

+ */ +public final class AppSettings { + + private final List insEndpoints = new ArrayList<>(); + private final List favorites = new ArrayList<>(); + private String startPageUrl = "web://info.oac/"; + private boolean sslEnabled = true; + private boolean luaEnabled = true; + private boolean historyEnabled = true; + private InsEndpoint selectedIns; + private LuaExecutionPolicy luaPolicy = LuaExecutionPolicy.uiDefault(); + + /** + * Creates settings with defaults. + */ + public AppSettings() { + // Defaults: include the current INSList defaults as initial endpoint. + insEndpoints.add(new InsEndpoint("open-autonomous-connection.org", 1026)); + selectedIns = insEndpoints.get(0); + } + + /** + * Returns the configured start page URL. + * + * @return start page URL + */ + public String getStartPageUrl() { + return startPageUrl; + } + + /** + * Sets the start page URL. + * + * @param startPageUrl URL (non-null, non-blank) + */ + public void setStartPageUrl(String startPageUrl) { + String s = Objects.requireNonNull(startPageUrl, "startPageUrl").trim(); + if (s.isEmpty()) throw new IllegalArgumentException("startPageUrl must not be blank"); + this.startPageUrl = s; + } + + /** + * Returns whether SSL is enabled for protocol connections. + * + * @return true if enabled + */ + public boolean isSslEnabled() { + return sslEnabled; + } + + /** + * Enables/disables SSL. + * + * @param sslEnabled enabled + */ + public void setSslEnabled(boolean sslEnabled) { + this.sslEnabled = sslEnabled; + } + + /** + * Returns whether Lua runtime is enabled in WebView. + * + * @return true if enabled + */ + public boolean isLuaEnabled() { + return luaEnabled; + } + + /** + * Enables/disables Lua. + * + * @param luaEnabled enabled + */ + public void setLuaEnabled(boolean luaEnabled) { + this.luaEnabled = luaEnabled; + } + + /** + * Returns whether history is enabled. + * + * @return true if enabled + */ + public boolean isHistoryEnabled() { + return historyEnabled; + } + + /** + * Enables/disables history tracking. + * + * @param historyEnabled enabled + */ + public void setHistoryEnabled(boolean historyEnabled) { + this.historyEnabled = historyEnabled; + } + + /** + * Returns a mutable INS endpoint list. + * + * @return list (mutable) + */ + public List getInsEndpointsMutable() { + return insEndpoints; + } + + /** + * Returns an immutable view of INS endpoints. + * + * @return endpoints + */ + public List getInsEndpoints() { + return Collections.unmodifiableList(insEndpoints); + } + + /** + * Returns currently selected INS. + * + * @return selected endpoint + */ + public InsEndpoint getSelectedIns() { + return selectedIns; + } + + /** + * Sets selected INS endpoint. + * + * @param selectedIns endpoint (must exist in list or will be added) + */ + public void setSelectedIns(InsEndpoint selectedIns) { + Objects.requireNonNull(selectedIns, "selectedIns"); + if (!insEndpoints.contains(selectedIns)) { + insEndpoints.add(selectedIns); + } + this.selectedIns = selectedIns; + } + + /** + * Returns a mutable favorites list. + * + * @return favorites (mutable) + */ + public List getFavoritesMutable() { + return favorites; + } + + /** + * Returns immutable favorites. + * + * @return favorites + */ + public List getFavorites() { + return Collections.unmodifiableList(favorites); + } + + /** + * Returns Lua execution policy. + * + * @return policy + */ + public LuaExecutionPolicy getLuaPolicy() { + return luaPolicy; + } + + /** + * Sets Lua execution policy. + * + * @param luaPolicy policy (non-null) + */ + public void setLuaPolicy(LuaExecutionPolicy luaPolicy) { + this.luaPolicy = Objects.requireNonNull(luaPolicy, "luaPolicy"); + } + + /** + * Resets the Lua policy back to ui default. + */ + public void resetLuaPolicyToUiDefault() { + this.luaPolicy = new LuaExecutionPolicy(Duration.ofMillis(50L), 200_000L, 5_000); + } +} \ No newline at end of file diff --git a/src/main/java/org/openautonomousconnection/webclient/settings/FxEngine.java b/src/main/java/org/openautonomousconnection/webclient/settings/FxEngine.java index fd6e42d..ab45999 100644 --- a/src/main/java/org/openautonomousconnection/webclient/settings/FxEngine.java +++ b/src/main/java/org/openautonomousconnection/webclient/settings/FxEngine.java @@ -39,6 +39,7 @@ public final class FxEngine implements AutoCloseable { * * @param engine web engine * @param webView web view + * @param logger web logger */ public FxEngine(WebEngine engine, WebView webView, WebLogger logger) { this(engine, webView, LuaExecutionPolicy.uiDefault(), logger); @@ -50,12 +51,13 @@ public final class FxEngine implements AutoCloseable { * @param engine web engine * @param webView web view * @param policy execution policy + * @param logger web logger */ public FxEngine(WebEngine engine, WebView webView, LuaExecutionPolicy policy, WebLogger logger) { this.engine = Objects.requireNonNull(engine, "engine"); - this.webView = webView; + this.webView = Objects.requireNonNull(webView, "webView"); this.policy = Objects.requireNonNull(policy, "policy"); - this.logger = logger; + this.logger = Objects.requireNonNull(logger, "logger"); } /** @@ -81,32 +83,24 @@ public final class FxEngine implements AutoCloseable { closeRuntimeQuietly(); - // DOM host must exist before event/UI tables, and must ensure stable ids. FxDomHost dom = new FxDomHost(engine); dom.ensureAllElementsHaveId(); - // Create per-page globals; harden sandbox in production. Globals globals = LuaGlobalsFactory.create( new LuaGlobalsFactory.Options() .enableDebug(false) .sandbox(true) ); - // Create runtime first (router lives inside it). ConsoleHostImpl console = new ConsoleHostImpl(logger); UiHostImpl uiHost = new UiHostImpl(engine, webView, dom); FxWebViewResourceHost resourceHost = new FxWebViewResourceHost(engine); - // runtime depends on services; events depends on runtime router. - // We'll create eventHost after runtime, then build HostServices with it. LuaRuntime rt = new LuaRuntime(globals, new HostServices.Default(uiHost, dom, null, resourceHost, console), policy); FxEventHost eventHost = new FxEventHost(dom, rt.eventRouter()); - - // Rebuild services including eventHost and reinstall tables. HostServices services = new HostServices.Default(uiHost, dom, eventHost, resourceHost, console); - // Replace runtime with correct services (clean and deterministic). rt.close(); rt = new LuaRuntime(globals, services, policy); diff --git a/src/main/java/org/openautonomousconnection/webclient/settings/HistoryManager.java b/src/main/java/org/openautonomousconnection/webclient/settings/HistoryManager.java new file mode 100644 index 0000000..18c2f31 --- /dev/null +++ b/src/main/java/org/openautonomousconnection/webclient/settings/HistoryManager.java @@ -0,0 +1,82 @@ +package org.openautonomousconnection.webclient.settings; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Objects; + +/** + * In-memory history tracker (URL + timestamp). + * + *

Persistence can be added later; for now it follows settings toggle and supports clearing.

+ */ +public final class HistoryManager { + + private final List entries = new ArrayList<>(); + private volatile boolean enabled = true; + + /** + * Returns whether history is enabled. + * + * @return enabled + */ + public boolean isEnabled() { + return enabled; + } + + /** + * Sets whether history is enabled. + * + * @param enabled enabled + */ + public void setEnabled(boolean enabled) { + this.enabled = enabled; + if (!enabled) { + clear(); + } + } + + /** + * Adds a URL to history if enabled. + * + * @param url url (non-null, non-blank) + */ + public void add(String url) { + if (!enabled) return; + String s = Objects.requireNonNull(url, "url").trim(); + if (s.isEmpty()) return; + + // De-dup consecutive duplicates + if (!entries.isEmpty()) { + Entry last = entries.get(entries.size() - 1); + if (last.url().equals(s)) return; + } + entries.add(new Entry(s, Instant.now())); + } + + /** + * Clears history. + */ + public void clear() { + entries.clear(); + } + + /** + * Returns immutable entries. + * + * @return entries + */ + public List entries() { + return Collections.unmodifiableList(entries); + } + + /** + * Single history entry. + * + * @param url visited URL + * @param visitedAt timestamp + */ + public record Entry(String url, Instant visitedAt) { + } +} \ No newline at end of file diff --git a/src/main/java/org/openautonomousconnection/webclient/settings/HistoryStore.java b/src/main/java/org/openautonomousconnection/webclient/settings/HistoryStore.java new file mode 100644 index 0000000..916f7cf --- /dev/null +++ b/src/main/java/org/openautonomousconnection/webclient/settings/HistoryStore.java @@ -0,0 +1,125 @@ +package org.openautonomousconnection.webclient.settings; + +import java.io.*; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Objects; + +/** + * Persists browsing history to disk. + * + *

Format per line: {@code \t}

+ */ +public final class HistoryStore { + private static final String FILE_NAME = "history.log"; + private final File file; + + /** + * Creates a history store in the default user settings directory. + */ + public HistoryStore() { + this(historyFile()); + } + + /** + * Creates a history store for a specific file. + * + * @param file history file + */ + public HistoryStore(File file) { + this.file = Objects.requireNonNull(file, "file"); + File dir = file.getParentFile(); + if (dir != null && !dir.isDirectory()) { + //noinspection ResultOfMethodCallIgnored + dir.mkdirs(); + } + } + + /** + * Returns the default history file location. + * + * @return file + */ + public static File historyFile() { + return new File(FILE_NAME); + } + + /** + * Loads history entries from disk. + * + * @return entries (immutable) + */ + public List load() { + if (!file.isFile()) return List.of(); + + List out = new ArrayList<>(); + try (BufferedReader br = Files.newBufferedReader(file.toPath(), StandardCharsets.UTF_8)) { + String line; + while ((line = br.readLine()) != null) { + int tab = line.indexOf('\t'); + if (tab <= 0) continue; + String tsS = line.substring(0, tab).trim(); + String url = line.substring(tab + 1).trim(); + if (url.isEmpty()) continue; + + long ms; + try { + ms = Long.parseLong(tsS); + } catch (Exception ignored) { + continue; + } + out.add(new Entry(url, Instant.ofEpochMilli(ms))); + } + } catch (Exception ignored) { + return List.of(); + } + return Collections.unmodifiableList(out); + } + + /** + * Appends a single entry to disk (best-effort). + * + * @param url visited URL + * @param at timestamp + */ + public void append(String url, Instant at) { + Objects.requireNonNull(url, "url"); + Objects.requireNonNull(at, "at"); + String u = url.trim(); + if (u.isEmpty()) return; + + try (Writer w = new BufferedWriter(new OutputStreamWriter( + new FileOutputStream(file, true), StandardCharsets.UTF_8))) { + w.write(Long.toString(at.toEpochMilli())); + w.write('\t'); + w.write(u.replace('\n', ' ').replace('\r', ' ')); + w.write('\n'); + } catch (Exception ignored) { + // Best-effort + } + } + + /** + * Clears history file (best-effort). + */ + public void clear() { + try { + Files.deleteIfExists(file.toPath()); + } catch (Exception ignored) { + // Best-effort + } + } + + /** + * History entry. + * + * @param url visited URL + * @param visitedAt timestamp + */ + public record Entry(String url, Instant visitedAt) { + } +} \ No newline at end of file diff --git a/src/main/java/org/openautonomousconnection/webclient/settings/INSList.java b/src/main/java/org/openautonomousconnection/webclient/settings/INSList.java deleted file mode 100644 index e6cd6be..0000000 --- a/src/main/java/org/openautonomousconnection/webclient/settings/INSList.java +++ /dev/null @@ -1,16 +0,0 @@ -package org.openautonomousconnection.webclient.settings; - -import java.util.HashMap; - -public class INSList { - - public static String DEFAULT_INS = "open-autonomous-connection.org"; - public static int DEFAULT_PORT = 1026; - - private static HashMap insList = new HashMap<>(); - - public static void registerINS(String host, int tcpPort) { - insList.put(host, tcpPort); - } - -} diff --git a/src/main/java/org/openautonomousconnection/webclient/settings/InsEndpoint.java b/src/main/java/org/openautonomousconnection/webclient/settings/InsEndpoint.java new file mode 100644 index 0000000..59caaac --- /dev/null +++ b/src/main/java/org/openautonomousconnection/webclient/settings/InsEndpoint.java @@ -0,0 +1,59 @@ +package org.openautonomousconnection.webclient.settings; + +import java.util.Objects; + +/** + * Represents an INS endpoint (host + TCP port). + */ +public record InsEndpoint(String host, int port) { + + /** + * Creates an INS endpoint. + * + * @param host endpoint host (non-null, non-blank) + * @param port tcp port (1..65535) + */ + public InsEndpoint(String host, int port) { + String h = Objects.requireNonNull(host, "host").trim(); + if (h.isEmpty()) throw new IllegalArgumentException("host must not be blank"); + if (port < 1 || port > 65535) throw new IllegalArgumentException("port out of range: " + port); + this.host = h; + this.port = port; + } + + /** + * Returns the host. + * + * @return host + */ + @Override + public String host() { + return host; + } + + /** + * Returns the tcp port. + * + * @return port + */ + @Override + public int port() { + return port; + } + + @Override + public String toString() { + return host + ":" + port; + } + + @Override + public boolean equals(Object o) { + if (!(o instanceof InsEndpoint other)) return false; + return host.equalsIgnoreCase(other.host) && port == other.port; + } + + @Override + public int hashCode() { + return host.toLowerCase().hashCode() * 31 + port; + } +} \ No newline at end of file diff --git a/src/main/java/org/openautonomousconnection/webclient/settings/SettingsManager.java b/src/main/java/org/openautonomousconnection/webclient/settings/SettingsManager.java new file mode 100644 index 0000000..8da1cfc --- /dev/null +++ b/src/main/java/org/openautonomousconnection/webclient/settings/SettingsManager.java @@ -0,0 +1,190 @@ +package org.openautonomousconnection.webclient.settings; + +import org.openautonomousconnection.luascript.security.LuaExecutionPolicy; + +import java.io.*; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.Properties; + +/** + * Loads/saves {@link AppSettings} to a simple properties file. + * + *

Location: {@code settings.properties}

+ */ +public final class SettingsManager { + + private static final String FILE_NAME = "settings.properties"; + + private SettingsManager() { + // Utility class + } + + /** + * Returns the settings file location. + * + * @return settings file + */ + public static File settingsFile() { + return new File(FILE_NAME); + } + + /** + * Loads settings from disk. If missing, returns defaults. + * + * @return settings + */ + public static AppSettings load() { + AppSettings s = new AppSettings(); + File f = settingsFile(); + if (!f.isFile()) return s; + + Properties p = new Properties(); + try (InputStream in = Files.newInputStream(f.toPath())) { + p.load(new InputStreamReader(in, StandardCharsets.UTF_8)); + } catch (Exception ignored) { + return s; + } + + trySetString(p, "startPageUrl", s::setStartPageUrl); + s.setSslEnabled(parseBool(p.getProperty("sslEnabled"), s.isSslEnabled())); + s.setLuaEnabled(parseBool(p.getProperty("luaEnabled"), s.isLuaEnabled())); + s.setHistoryEnabled(parseBool(p.getProperty("historyEnabled"), s.isHistoryEnabled())); + + // INS endpoints + List endpoints = new ArrayList<>(); + int count = parseInt(p.getProperty("ins.count"), 0); + for (int i = 0; i < count; i++) { + String host = p.getProperty("ins." + i + ".host"); + int port = parseInt(p.getProperty("ins." + i + ".port"), -1); + if (host == null || host.isBlank() || port < 1 || port > 65535) continue; + endpoints.add(new InsEndpoint(host.trim(), port)); + } + if (!endpoints.isEmpty()) { + s.getInsEndpointsMutable().clear(); + s.getInsEndpointsMutable().addAll(endpoints); + } + + String selHost = p.getProperty("ins.selected.host"); + int selPort = parseInt(p.getProperty("ins.selected.port"), -1); + if (selHost != null && !selHost.isBlank() && selPort >= 1 && selPort <= 65535) { + s.setSelectedIns(new InsEndpoint(selHost.trim(), selPort)); + } else { + // Keep default selection, but ensure it exists in list. + s.setSelectedIns(s.getSelectedIns()); + } + + // Favorites + int favCount = parseInt(p.getProperty("favorites.count"), 0); + s.getFavoritesMutable().clear(); + for (int i = 0; i < favCount; i++) { + String url = p.getProperty("favorites." + i); + if (url != null && !url.isBlank()) s.getFavoritesMutable().add(url.trim()); + } + + // Lua policy + long timeoutMs = parseLong(p.getProperty("lua.timeoutMs"), 50L); + long instr = parseLong(p.getProperty("lua.instructionLimit"), 200_000L); + int hook = parseInt(p.getProperty("lua.hookStep"), 5_000); + try { + s.setLuaPolicy(new LuaExecutionPolicy(Duration.ofMillis(timeoutMs), instr, hook)); + } catch (Exception ignored) { + s.resetLuaPolicyToUiDefault(); + } + + return s; + } + + /** + * Saves settings to disk (best-effort). + * + * @param s settings + */ + public static void save(AppSettings s) { + Objects.requireNonNull(s, "s"); + + File f = settingsFile(); + File dir = f.getParentFile(); + if (dir != null && !dir.isDirectory()) { + //noinspection ResultOfMethodCallIgnored + dir.mkdirs(); + } + + Properties p = new Properties(); + p.setProperty("startPageUrl", s.getStartPageUrl()); + p.setProperty("sslEnabled", Boolean.toString(s.isSslEnabled())); + p.setProperty("luaEnabled", Boolean.toString(s.isLuaEnabled())); + p.setProperty("historyEnabled", Boolean.toString(s.isHistoryEnabled())); + + List endpoints = s.getInsEndpoints(); + p.setProperty("ins.count", Integer.toString(endpoints.size())); + for (int i = 0; i < endpoints.size(); i++) { + InsEndpoint ep = endpoints.get(i); + p.setProperty("ins." + i + ".host", ep.host()); + p.setProperty("ins." + i + ".port", Integer.toString(ep.port())); + } + + InsEndpoint sel = s.getSelectedIns(); + if (sel != null) { + p.setProperty("ins.selected.host", sel.host()); + p.setProperty("ins.selected.port", Integer.toString(sel.port())); + } + + List fav = s.getFavorites(); + p.setProperty("favorites.count", Integer.toString(fav.size())); + for (int i = 0; i < fav.size(); i++) { + p.setProperty("favorites." + i, fav.get(i)); + } + + p.setProperty("lua.timeoutMs", Long.toString(s.getLuaPolicy().timeout().toMillis())); + p.setProperty("lua.instructionLimit", Long.toString(s.getLuaPolicy().instructionLimit())); + p.setProperty("lua.hookStep", Integer.toString(s.getLuaPolicy().hookStep())); + + try (OutputStream out = Files.newOutputStream(f.toPath())) { + p.store(new OutputStreamWriter(out, StandardCharsets.UTF_8), "OAC WebClient Settings"); + } catch (Exception ignored) { + // Best-effort persistence + } + } + + private static void trySetString(Properties p, String key, java.util.function.Consumer setter) { + String v = p.getProperty(key); + if (v != null && !v.isBlank()) { + try { + setter.accept(v.trim()); + } catch (Exception ignored) { + // Ignore malformed value + } + } + } + + private static boolean parseBool(String v, boolean def) { + if (v == null) return def; + String s = v.trim().toLowerCase(); + if (s.equals("true")) return true; + if (s.equals("false")) return false; + return def; + } + + private static int parseInt(String v, int def) { + if (v == null) return def; + try { + return Integer.parseInt(v.trim()); + } catch (Exception e) { + return def; + } + } + + private static long parseLong(String v, long def) { + if (v == null) return def; + try { + return Long.parseLong(v.trim()); + } catch (Exception e) { + return def; + } + } +} \ No newline at end of file diff --git a/src/main/java/org/openautonomousconnection/webclient/ui/BrowserDesign.java b/src/main/java/org/openautonomousconnection/webclient/ui/BrowserDesign.java new file mode 100644 index 0000000..ded9926 --- /dev/null +++ b/src/main/java/org/openautonomousconnection/webclient/ui/BrowserDesign.java @@ -0,0 +1,136 @@ +package org.openautonomousconnection.webclient.ui; + +import org.openautonomousconnection.oacswing.component.OACButton; +import org.openautonomousconnection.oacswing.component.OACPanel; +import org.openautonomousconnection.oacswing.component.OACTextField; + +import javax.swing.border.EmptyBorder; +import java.awt.*; + +public final class BrowserDesign extends OACPanel { + + private static final int HEIGHT_NAV = 44; + private static final int RADIUS = 16; + + private final OACButton backButton = iconButton("«"); + private final OACButton forwardButton = iconButton("»"); + private final OACButton reloadButton = iconButton("↻"); + + private final OACTextField addressField = new OACTextField(); + + private final OACButton goButton = pillButton("\uD83D\uDD0D"); + private final OACButton starButton = iconButton("☆"); + private final OACButton menuButton = iconButton("▤"); + + private final FavChipBar favoritesBar = new FavChipBar(); + + public BrowserDesign() { + super(new BorderLayout(0, 0)); + setOpaque(false); + + GlassPanel card = new GlassPanel(new BorderLayout(0, 0), RADIUS, + Color.decode("#131e34"), + Color.decode("#0f172a"), + Color.decode("#2a3756")); + card.setBorder(new EmptyBorder(8, 10, 8, 10)); + + card.add(buildNavRow(), BorderLayout.NORTH); + card.add(favoritesBar, BorderLayout.CENTER); + + add(card, BorderLayout.CENTER); + setBorder(new EmptyBorder(8, 10, 8, 10)); + + addressField.setBorder(new EmptyBorder(6, 10, 6, 10)); + addressField.setPreferredSize(new Dimension(1, 32)); + + backButton.setToolTipText("Back"); + forwardButton.setToolTipText("Forward"); + reloadButton.setToolTipText("Reload"); + goButton.setToolTipText("Open URL"); + starButton.setToolTipText("Add to favorites"); + menuButton.setToolTipText("Menu"); + } + + private static OACButton iconButton(String text) { + OACButton b = new OACButton(text); + b.setMargin(new Insets(3, 10, 3, 10)); + b.setFocusable(false); + b.setPreferredSize(new Dimension(44, 32)); + return b; + } + + private static OACButton pillButton(String text) { + OACButton b = new OACButton(text); + b.setMargin(new Insets(3, 14, 3, 14)); + b.setFocusable(false); + b.setPreferredSize(new Dimension(64, 32)); + return b; + } + + private Component buildNavRow() { + OACPanel row = new OACPanel(new BorderLayout(10, 0)); + row.setOpaque(false); + row.setBorder(new EmptyBorder(2, 2, 8, 2)); + row.setPreferredSize(new Dimension(1, HEIGHT_NAV)); + + OACPanel left = new OACPanel(new FlowLayout(FlowLayout.LEFT, 6, 0)); + left.setOpaque(false); + left.add(backButton); + left.add(forwardButton); + left.add(reloadButton); + + GlassPanel addressPill = new GlassPanel(new BorderLayout(8, 0), 14, + Color.decode("#0f172a"), Color.decode("#0b1220"), Color.decode("#2a3756")); + addressPill.setBorder(new EmptyBorder(2, 8, 2, 8)); + addressPill.add(addressField, BorderLayout.CENTER); + + OACPanel right = new OACPanel(new FlowLayout(FlowLayout.RIGHT, 6, 0)); + right.setOpaque(false); + right.add(goButton); + right.add(starButton); + right.add(menuButton); + + row.add(left, BorderLayout.WEST); + row.add(addressPill, BorderLayout.CENTER); + row.add(right, BorderLayout.EAST); + + return row; + } + + /** + * Returns the favorites bar. + * + * @return favorites bar + */ + public FavChipBar favoritesBar() { + return favoritesBar; + } + + public OACButton backButton() { + return backButton; + } + + public OACButton forwardButton() { + return forwardButton; + } + + public OACButton reloadButton() { + return reloadButton; + } + + public OACTextField addressField() { + return addressField; + } + + public OACButton goButton() { + return goButton; + } + + public OACButton starButton() { + return starButton; + } + + public OACButton menuButton() { + return menuButton; + } +} diff --git a/src/main/java/org/openautonomousconnection/webclient/ui/BrowserTab.java b/src/main/java/org/openautonomousconnection/webclient/ui/BrowserTab.java index 6d46b56..71692c3 100644 --- a/src/main/java/org/openautonomousconnection/webclient/ui/BrowserTab.java +++ b/src/main/java/org/openautonomousconnection/webclient/ui/BrowserTab.java @@ -1,37 +1,152 @@ package org.openautonomousconnection.webclient.ui; +import javafx.application.Platform; +import javafx.concurrent.Worker; +import javafx.embed.swing.JFXPanel; +import javafx.scene.Scene; +import javafx.scene.control.ContextMenu; +import javafx.scene.control.MenuItem; +import javafx.scene.input.Clipboard; +import javafx.scene.input.ClipboardContent; +import javafx.scene.input.ContextMenuEvent; +import javafx.scene.web.WebEngine; +import javafx.scene.web.WebHistory; +import javafx.scene.web.WebView; +import org.openautonomousconnection.luascript.security.LuaExecutionPolicy; +import org.openautonomousconnection.oacswing.component.OACPanel; +import org.openautonomousconnection.webclient.ClientImpl; +import org.openautonomousconnection.webclient.lua.WebLogger; +import org.openautonomousconnection.webclient.settings.FxEngine; +import org.w3c.dom.Document; + +import javax.swing.*; +import javax.xml.transform.OutputKeys; +import javax.xml.transform.Transformer; +import javax.xml.transform.TransformerFactory; +import javax.xml.transform.dom.DOMSource; +import javax.xml.transform.stream.StreamResult; +import java.awt.*; +import java.io.StringWriter; import java.util.Objects; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.function.Consumer; /** - * A logical browser tab consisting of a key (tab id), a TabView (WebView), - * and navigation helpers. + * Logical browser tab: stable UI key + embedded JavaFX WebView. + * + *

This class merges the previous {@code BrowserTab} + {@code TabView} into one component.

*/ -public class BrowserTab { +public final class BrowserTab extends OACPanel { private final String key; - private final TabView view; + + private final AtomicBoolean initialized = new AtomicBoolean(false); + private final Consumer onLocationChanged; + private final WebLogger webLogger; + private final ClientImpl protocolClient; + + private final boolean luaEnabled; + private final LuaExecutionPolicy luaPolicy; + + private volatile Runnable openInNewTab; + + private JFXPanel fxPanel; + private WebView webView; + private WebEngine engine; + private volatile FxEngine luaEngine; /** * Creates a browser tab. * - * @param initialUrl initial URL (used for initial location value) + * @param key stable UI key (must match CardLayout key and titlebar tab title) + * @param initialUrl initial URL (used for logger context) * @param onLocationChange callback invoked on URL changes + * @param luaEnabled whether Lua is enabled for this tab + * @param luaPolicy execution policy for Lua */ - public BrowserTab(String initialUrl, Consumer onLocationChange) { - this.key = Objects.requireNonNull(initialUrl, "initialUrl"); // placeholder key overwritten by BrowserUI - this.view = new TabView(onLocationChange, initialUrl); + public BrowserTab(String key, String initialUrl, Consumer onLocationChange, boolean luaEnabled, LuaExecutionPolicy luaPolicy, ClientImpl protocolClient) { + super(); + this.key = Objects.requireNonNull(key, "key"); + this.onLocationChanged = Objects.requireNonNull(onLocationChange, "onLocationChange"); + this.protocolClient = Objects.requireNonNull(protocolClient, "protocolClient"); + this.webLogger = new WebLogger(Objects.requireNonNull(initialUrl, "initialUrl"), protocolClient); + this.luaEnabled = luaEnabled; + this.luaPolicy = Objects.requireNonNull(luaPolicy, "luaPolicy"); + setLayout(new BorderLayout()); + } + + private static String serializeDom(Document doc) throws Exception { + TransformerFactory tf = TransformerFactory.newInstance(); + Transformer t = tf.newTransformer(); + t.setOutputProperty(OutputKeys.METHOD, "html"); + t.setOutputProperty(OutputKeys.OMIT_XML_DECLARATION, "yes"); + t.setOutputProperty(OutputKeys.ENCODING, "UTF-8"); + t.setOutputProperty(OutputKeys.INDENT, "yes"); + + StringWriter sw = new StringWriter(64 * 1024); + t.transform(new DOMSource(doc), new StreamResult(sw)); + return sw.toString(); } /** - * Sets the key (tab id) used by the UI host. + * Returns the stable tab key. * - * @param key tab key - * @return this + * @return key */ - public BrowserTab withKey(String key) { - // The BrowserUI uses titles as keys; keep logic simple. - return new BrowserTabKeyed(key, view); + public String getKey() { + return key; + } + + public ClientImpl getProtocolClient() { + return protocolClient; + } + + /** + * Sets callback for opening current page in a new tab. + * + * @param callback callback + */ + public void setOpenInNewTabCallback(Runnable callback) { + this.openInNewTab = callback; + } + + @Override + public void addNotify() { + super.addNotify(); + + if (!initialized.compareAndSet(false, true)) { + return; + } + + fxPanel = new JFXPanel(); + add(fxPanel, BorderLayout.CENTER); + + Platform.runLater(() -> { + webView = new WebView(); + webView.setContextMenuEnabled(false); + + engine = webView.getEngine(); + engine.setJavaScriptEnabled(false); + + engine.locationProperty().addListener((obs, oldV, newV) -> fireLocationChanged(newV)); + engine.getLoadWorker().stateProperty().addListener((obs, oldS, newS) -> { + if (newS == Worker.State.RUNNING || newS == Worker.State.SUCCEEDED) { + fireLocationChanged(engine.getLocation()); + } + }); + + installCustomContextMenu(); + + if (luaEnabled) { + luaEngine = new FxEngine(engine, webView, luaPolicy, webLogger); + luaEngine.install(); + } else { + luaEngine = null; + } + + fxPanel.setScene(new Scene(webView)); + }); } /** @@ -40,28 +155,54 @@ public class BrowserTab { * @param url URL */ public void loadUrl(String url) { - view.loadUrl(url); + String target = Objects.requireNonNull(url, "url").trim(); + if (target.isEmpty()) return; + + Platform.runLater(() -> { + WebEngine e = engine; + if (e != null) e.load(target); + }); + } + + /** + * Reloads the current page. + */ + public void reload() { + String current = getEngineLocation(); + if (current == null || current.isBlank()) return; + + Platform.runLater(() -> { + WebEngine e = engine; + if (e != null) e.reload(); + }); } /** * Goes back in history if possible. */ public void goBack() { - view.back(); + if (peekHistoryTarget(-1) == null) return; + + Platform.runLater(() -> { + WebEngine e = engine; + if (e == null) return; + WebHistory h = e.getHistory(); + if (h.getCurrentIndex() > 0) h.go(-1); + }); } /** * Goes forward in history if possible. */ public void goForward() { - view.forward(); - } + if (peekHistoryTarget(+1) == null) return; - /** - * Reloads the page. - */ - public void reload() { - view.reload(); + Platform.runLater(() -> { + WebEngine e = engine; + if (e == null) return; + WebHistory h = e.getHistory(); + if (h.getCurrentIndex() < h.getEntries().size() - 1) h.go(1); + }); } /** @@ -69,88 +210,171 @@ public class BrowserTab { * * @return URL or null */ - public String getLocation() { - return view.getEngineLocation(); + public String getLocationUrl() { + return getEngineLocation(); + } + + /** + * Returns current engine location. + * + * @return url or null + */ + public String getEngineLocation() { + WebEngine e = engine; + return e != null ? e.getLocation() : null; + } + + /** + * Applies HTML live to the current tab (no saving). + * + * @param html html text + */ + public void applyHtml(String html) { + String content = html == null ? "" : html; + + Platform.runLater(() -> { + WebEngine e = engine; + if (e != null) e.loadContent(content, "text/html"); + }); + } + + /** + * Returns the current rendered HTML (DOM serialization). + * + * @return current html (never null) + */ + public String getCurrentHtml() { + CompletableFuture fut = new CompletableFuture<>(); + Platform.runLater(() -> { + try { + WebEngine e = engine; + if (e == null) { + fut.complete(""); + return; + } + Document doc = e.getDocument(); + if (doc == null) { + fut.complete(""); + return; + } + fut.complete(serializeDom(doc)); + } catch (Throwable t) { + fut.completeExceptionally(t); + } + }); + + try { + return fut.join(); + } catch (Throwable t) { + return ""; + } } /** * Releases resources. */ public void dispose() { - view.dispose(); + FxEngine le = luaEngine; + luaEngine = null; + if (le != null) { + try { + le.close(); + } catch (Exception ignored) { + // Intentionally ignored + } + } + + Platform.runLater(() -> { + WebEngine e = engine; + if (e != null) { + try { + e.load(null); + } catch (Exception ignored) { + // Intentionally ignored + } + } + engine = null; + webView = null; + if (fxPanel != null) { + fxPanel.setScene(null); + } + }); + + disconnectProtocolQuietly(); } - /** - * Returns the tab key. - * - * @return key - */ - public String getKey() { - return key; - } + private void fireLocationChanged(String location) { + if (location == null) return; + String s = location.trim(); + if (s.isEmpty()) return; - /** - * Returns the Swing component that renders the web content. - * - * @return tab view - */ - public TabView getView() { - return view; - } - - /** - * Internal keyed wrapper so BrowserUI can store a stable key without re-creating the WebView. - */ - private static final class BrowserTabKeyed extends BrowserTab { - - private final String fixedKey; - private final TabView fixedView; - - private BrowserTabKeyed(String fixedKey, TabView fixedView) { - super("about:blank", s -> { - }); - this.fixedKey = Objects.requireNonNull(fixedKey, "fixedKey"); - this.fixedView = Objects.requireNonNull(fixedView, "fixedView"); - } - - @Override - public String getKey() { - return fixedKey; - } - - @Override - public TabView getView() { - return fixedView; - } - - @Override - public void loadUrl(String url) { - fixedView.loadUrl(url); - } - - @Override - public void goBack() { - fixedView.back(); - } - - @Override - public void goForward() { - fixedView.forward(); - } - - @Override - public void reload() { - fixedView.reload(); - } - - @Override - public String getLocation() { - return fixedView.getEngineLocation(); - } - - @Override - public void dispose() { - fixedView.dispose(); + try { + onLocationChanged.accept(s); + } catch (Exception ignored) { + // Must not break FX thread } } -} \ No newline at end of file + + private void installCustomContextMenu() { + final ContextMenu menu = new ContextMenu(); + + MenuItem back = new MenuItem("Back"); + back.setOnAction(e -> SwingUtilities.invokeLater(this::goBack)); + + MenuItem forward = new MenuItem("Forward"); + forward.setOnAction(e -> SwingUtilities.invokeLater(this::goForward)); + + MenuItem reload = new MenuItem("Reload"); + reload.setOnAction(e -> SwingUtilities.invokeLater(this::reload)); + + MenuItem copyLink = new MenuItem("Copy Link"); + copyLink.setOnAction(e -> { + WebEngine e2 = engine; + String loc = e2 != null ? e2.getLocation() : null; + if (loc == null) return; + ClipboardContent cc = new ClipboardContent(); + cc.putString(loc); + Clipboard.getSystemClipboard().setContent(cc); + }); + + MenuItem openNewTab = new MenuItem("Open in New Tab"); + openNewTab.setOnAction(e -> { + Runnable r = this.openInNewTab; + if (r != null) SwingUtilities.invokeLater(r); + }); + + menu.getItems().addAll(back, forward, reload, copyLink, openNewTab); + + webView.addEventHandler(ContextMenuEvent.CONTEXT_MENU_REQUESTED, ev -> { + menu.hide(); + menu.show(webView, ev.getScreenX(), ev.getScreenY()); + ev.consume(); + }); + } + + private String peekHistoryTarget(int delta) { + WebEngine e = engine; + if (e == null) return null; + WebHistory h = e.getHistory(); + int idx = h.getCurrentIndex() + delta; + if (idx < 0 || idx >= h.getEntries().size()) return null; + return h.getEntries().get(idx).getUrl(); + } + + private void disconnectProtocolQuietly() { + try { + if (protocolClient.getClientServerConnection() != null) { + protocolClient.getClientServerConnection().disconnect(); + } + } catch (Exception ignored) { + // Best-effort shutdown. + } + try { + if (protocolClient.getClientINSConnection() != null) { + protocolClient.getClientINSConnection().disconnect(); + } + } catch (Exception ignored) { + // Best-effort shutdown. + } + } +} diff --git a/src/main/java/org/openautonomousconnection/webclient/ui/BrowserUI.java b/src/main/java/org/openautonomousconnection/webclient/ui/BrowserUI.java index 09c3ad7..01c46be 100644 --- a/src/main/java/org/openautonomousconnection/webclient/ui/BrowserUI.java +++ b/src/main/java/org/openautonomousconnection/webclient/ui/BrowserUI.java @@ -1,126 +1,113 @@ package org.openautonomousconnection.webclient.ui; -import org.openautonomousconnection.oacswing.component.OACButton; -import org.openautonomousconnection.oacswing.component.OACFrame; -import org.openautonomousconnection.oacswing.component.OACPanel; -import org.openautonomousconnection.oacswing.component.OACTextField; +import dev.unlegitdqrk.unlegitlibrary.addon.AddonLoader; +import dev.unlegitdqrk.unlegitlibrary.event.EventManager; +import dev.unlegitdqrk.unlegitlibrary.network.system.packets.PacketHandler; +import dev.unlegitdqrk.unlegitlibrary.utils.Logger; +import org.openautonomousconnection.oacswing.component.*; import org.openautonomousconnection.oacswing.component.design.DesignManager; +import org.openautonomousconnection.protocol.ProtocolBridge; +import org.openautonomousconnection.protocol.ProtocolValues; +import org.openautonomousconnection.protocol.versions.ProtocolVersion; +import org.openautonomousconnection.webclient.ClientImpl; +import org.openautonomousconnection.webclient.settings.AppSettings; +import org.openautonomousconnection.webclient.settings.HistoryStore; +import org.openautonomousconnection.webclient.settings.InsEndpoint; +import org.openautonomousconnection.webclient.settings.SettingsManager; +import org.openautonomousconnection.webclient.ui.menus.AboutDialog; +import org.openautonomousconnection.webclient.ui.menus.AddonsDialog; +import org.openautonomousconnection.webclient.ui.menus.SettingsDialog; import javax.swing.*; import java.awt.*; +import java.awt.event.WindowAdapter; +import java.awt.event.WindowEvent; +import java.io.File; +import java.time.Instant; import java.util.LinkedHashMap; import java.util.Map; import java.util.Objects; +import java.util.concurrent.atomic.AtomicReference; /** - * A simple multi-tab browser UI: - * - Tab headers live in the custom title bar (OACTitleBar / OACTabbedPane). - * - Tab content (JavaFX WebView) lives in the frame content area. - * - Address bar controls the currently selected tab. + * Multi-tab browser UI. */ public class BrowserUI extends OACFrame { private static final int TITLE_BAR_HEIGHT = 42; - private final OACTextField addressField; - private final OACButton goButton; - private final OACButton newTabButton; - private final OACButton closeTabButton; - private final OACButton backButton; - private final OACButton forwardButton; - private final OACButton reloadButton; + private final AppSettings settings; + private final HistoryStore historyStore = new HistoryStore(); private final CardLayout cardLayout; private final OACPanel pageHost; private final Map tabsByKey = new LinkedHashMap<>(); + private final Map protocolByKey = new LinkedHashMap<>(); + private final Map addonLoaderByKey = new LinkedHashMap<>(); + private final BrowserDesign browser; + private final PlusTabSupport plusTabSupport; private int tabCounter = 0; + private int lastSelectedRealTab = -1; + private boolean handlingTabSwitch = false; + private boolean suppressPlusAutoOpen = false; - /** - * Creates the browser UI and wires tab selection and address bar to tab content. - */ - public BrowserUI() { + public BrowserUI(AppSettings settings) { super("OAC Browser"); + setBackground(DesignManager.getGlobalDesign().getElements().get(OACFrame.class).background().getColor()); + this.settings = Objects.requireNonNull(settings, "settings"); - // Content pane must be offset because your title bar is an overlay in the layered pane. - JComponent content = (JComponent) getContentPane(); + OACPanel content = (OACPanel) getContentPane(); content.setLayout(new BorderLayout()); content.setBorder(BorderFactory.createEmptyBorder(TITLE_BAR_HEIGHT, 0, 0, 0)); - // Address bar (top of content area) - OACPanel navBar = new OACPanel(new BorderLayout(8, 0)); - navBar.setBorder(BorderFactory.createEmptyBorder(8, 10, 8, 10)); + browser = new BrowserDesign(); + content.add(browser, BorderLayout.NORTH); - OACPanel leftControls = new OACPanel(new FlowLayout(FlowLayout.LEFT, 6, 0)); - backButton = new OACButton("←"); - forwardButton = new OACButton("→"); - reloadButton = new OACButton("⟳"); - leftControls.add(backButton); - leftControls.add(forwardButton); - leftControls.add(reloadButton); - - addressField = new OACTextField(); - goButton = new OACButton("Go"); - - OACPanel rightControls = new OACPanel(new FlowLayout(FlowLayout.RIGHT, 6, 0)); - newTabButton = new OACButton("+"); - closeTabButton = new OACButton("✕"); - rightControls.add(newTabButton); - rightControls.add(closeTabButton); - - navBar.add(leftControls, BorderLayout.WEST); - navBar.add(addressField, BorderLayout.CENTER); - navBar.add(goButton, BorderLayout.EAST); - navBar.add(rightControls, BorderLayout.EAST); - - // Fix: BorderLayout only allows one EAST; wrap Go+RightControls in a single panel - OACPanel east = new OACPanel(new FlowLayout(FlowLayout.RIGHT, 6, 0)); - - east.add(goButton); - east.add(newTabButton); - east.add(closeTabButton); - navBar.add(east, BorderLayout.EAST); - - content.add(navBar, BorderLayout.NORTH); - - // Page host cardLayout = new CardLayout(); pageHost = new OACPanel(cardLayout); content.add(pageHost, BorderLayout.CENTER); - // Wire title bar tab selection to content host + plusTabSupport = new PlusTabSupport(getTitleBar().getTabs(), () -> openNewTab(settings.getStartPageUrl())); + plusTabSupport.ensurePlusTab(); + getTitleBar().getTabs().addChangeListener(e -> onHeaderTabChanged()); - // Wire address bar actions - addressField.addActionListener(e -> navigateCurrent(addressField.getText())); - goButton.addActionListener(e -> navigateCurrent(addressField.getText())); + browser.addressField().addActionListener(e -> navigateCurrent(browser.addressField().getText())); + browser.goButton().addActionListener(e -> navigateCurrent(browser.addressField().getText())); - // Wire navigation buttons - backButton.addActionListener(e -> { + browser.backButton().addActionListener(e -> { BrowserTab tab = getCurrentTab(); if (tab != null) tab.goBack(); }); - forwardButton.addActionListener(e -> { + browser.forwardButton().addActionListener(e -> { BrowserTab tab = getCurrentTab(); if (tab != null) tab.goForward(); }); - reloadButton.addActionListener(e -> { + browser.reloadButton().addActionListener(e -> { BrowserTab tab = getCurrentTab(); if (tab != null) tab.reload(); }); - newTabButton.addActionListener(e -> openNewTab("web://info.oac/")); - closeTabButton.addActionListener(e -> closeCurrentTab()); + browser.starButton().addActionListener(e -> addCurrentToFavorites()); + browser.menuButton().addActionListener(e -> showAppMenu(browser.menuButton())); - // Create first tab - DesignManager.apply(this); + browser.favoritesBar().setOnNavigate(this::navigateCurrent); + browser.favoritesBar().setOnEdit(this::openSettings); + browser.favoritesBar().setFavorites(settings.getFavorites()); + + addWindowListener(new WindowAdapter() { + @Override + public void windowClosing(WindowEvent e) { + SettingsManager.save(settings); + } + }); } private static String normalizeUrl(String input) { String s = input == null ? "" : input.trim(); if (s.isEmpty()) return "web://info.oac/"; if (s.startsWith("web://")) { - // Ensure trailing slash for "host only" URLs String rest = s.substring("web://".length()); if (!rest.contains("/")) return s + "/"; return s; @@ -128,110 +115,359 @@ public class BrowserUI extends OACFrame { return "web://" + s + (s.contains("/") ? "" : "/"); } - /** - * Opens a new tab and navigates to the given URL. - * - * @param url initial URL - */ + private static String displayTitleFromUrl(String location) { + if (location == null) return "New Tab"; + + String s = location.trim(); + if (s.isEmpty()) return "New Tab"; + + if (s.startsWith("web://")) s = s.substring("web://".length()); + if (s.startsWith("https://")) s = s.substring("https://".length()); + if (s.startsWith("http://")) s = s.substring("http://".length()); + + int slash = s.indexOf('/'); + if (slash > 0) s = s.substring(0, slash); + + if (s.isEmpty()) return "New Tab"; + if (s.length() <= 24) return s; + return s.substring(0, 24) + "..."; + } + + private void showAppMenu(Component anchor) { + OACPopupMenu menu = new OACPopupMenu(); + + OACMenuItem settingsItem = new OACMenuItem("Settings"); + settingsItem.addActionListener(e -> openSettings()); + + OACMenuItem addonsItem = new OACMenuItem("Addons"); + addonsItem.addActionListener(e -> new AddonsDialog(this, settings).setVisible(true)); + + OACMenuItem aboutItem = new OACMenuItem("About"); + aboutItem.addActionListener(e -> new AboutDialog(this, settings).setVisible(true)); + + OACMenu history = new OACMenu("History"); + OACCheckBoxMenuItem historyEnabled = new OACCheckBoxMenuItem("Enable History", settings.isHistoryEnabled()); + historyEnabled.addActionListener(e -> { + settings.setHistoryEnabled(historyEnabled.isSelected()); + SettingsManager.save(settings); + if (!settings.isHistoryEnabled()) historyStore.clear(); + }); + + OACMenuItem show = new OACMenuItem("Show History"); + show.addActionListener(e -> showHistoryDialog()); + + OACMenuItem clear = new OACMenuItem("Clear History"); + clear.addActionListener(e -> { + historyStore.clear(); + OACOptionPane.showMessageDialog(this, "History cleared.", "History", OACOptionPane.INFORMATION_MESSAGE); + }); + + history.add(historyEnabled); + history.addSeparator(); + history.add(show); + history.add(clear); + + menu.add(settingsItem); + menu.addSeparator(); + menu.add(history); + menu.addSeparator(); + menu.add(addonsItem); + menu.add(aboutItem); + + menu.show(anchor, 0, anchor.getHeight()); + } + + private void showHistoryDialog() { + java.util.List entries = historyStore.load(); + + OACTextArea area = new OACTextArea(); + area.setEditable(false); + area.setLineWrap(true); + area.setWrapStyleWord(true); + + StringBuilder sb = new StringBuilder(32_768); + for (int i = entries.size() - 1; i >= 0; i--) { + HistoryStore.Entry e = entries.get(i); + sb.append(e.visitedAt()).append(" ").append(e.url()).append('\n'); + } + area.setText(sb.toString()); + + OACScrollPane sp = new OACScrollPane(area); + sp.setPreferredSize(new Dimension(860, 520)); + + OACOptionPane.showOptionDialog(this, sp, "History", OACOptionPane.DEFAULT_OPTION, OACOptionPane.INFORMATION_MESSAGE, + null, null, null); + } + + private void openSettings() { + SettingsDialog dlg = new SettingsDialog(this, settings, () -> { + SettingsManager.save(settings); + browser.favoritesBar().setFavorites(settings.getFavorites()); + if (!settings.isHistoryEnabled()) historyStore.clear(); + }); + dlg.setVisible(true); + } + + private void navigateCurrent(String input) { + BrowserTab tab = getCurrentTab(); + if (tab == null) return; + + String normalized = normalizeUrl(input); + tab.loadUrl(normalized); + browser.addressField().setText(normalized); + updateTabPresentation(tab.getKey(), normalized); + onVisited(normalized); + } + public void openNewTab(String url) { String key = nextTabKey(); - BrowserTab tab = new BrowserTab(url, newLocation -> SwingUtilities.invokeLater(() -> { - BrowserTab current = getCurrentTab(); - if (current != null && Objects.equals(current.getKey(), key)) { - addressField.setText(newLocation); - } + String normalized = normalizeUrl(url); + + AtomicReference tabRef = new AtomicReference<>(); + + ClientImpl client = new ClientImpl(this, () -> SwingUtilities.invokeLater(() -> { + BrowserTab readyTab = tabRef.get(); + if (readyTab == null) return; + readyTab.loadUrl(normalized); })); + BrowserTab tab = new BrowserTab( + key, + normalized, + newLocation -> SwingUtilities.invokeLater(() -> { + String selectedKey = getSelectedTabKey(); + if (Objects.equals(selectedKey, key)) { + browser.addressField().setText(newLocation); + } + updateTabPresentation(key, newLocation); + onVisited(newLocation); + }), + settings.isLuaEnabled(), + settings.getLuaPolicy(), + client + ); + + tabRef.set(tab); + tab.setOpenInNewTabCallback(() -> openNewTab( + tab.getEngineLocation() == null ? settings.getStartPageUrl() : tab.getEngineLocation() + )); + tabsByKey.put(key, tab); + pageHost.add(tab, key); - // Real page content in center host - pageHost.add(tab.getView(), key); + OACTabbedPane tabs = getTitleBar().getTabs(); + suppressPlusAutoOpen = true; + try { + plusTabSupport.ensurePlusTab(); - // Header tab in title bar: DO NOT place the real page here (title bar is only ~42px high). - getTitleBar().addTab(key, new OACPanel()); + int insertIndex = findPlusIndex(tabs); + if (insertIndex < 0) insertIndex = tabs.getTabCount(); - // Select it - int idx = getTitleBar().getTabs().getTabCount() - 1; - getTitleBar().getTabs().setSelectedIndex(idx); + tabs.insertTab(key, null, new OACPanel(), null, insertIndex); + tabs.setTabComponentAt(insertIndex, new TabButton(displayTitleFromUrl(normalized), () -> closeTabByKey(key))); + tabs.setSelectedIndex(insertIndex); + } finally { + suppressPlusAutoOpen = false; + } - // Navigate - tab.loadUrl(url); - - // Show content cardLayout.show(pageHost, key); - pageHost.revalidate(); - pageHost.repaint(); + browser.addressField().setText(normalized); - // Update address field immediately - addressField.setText(url); + connectTabClient(key, client); } - /** - * Navigates the current tab to the given input (URL or host). - * - * @param input user input - */ - public void navigateCurrent(String input) { - BrowserTab tab = getCurrentTab(); - if (tab == null) return; + private void connectTabClient(String key, ClientImpl client) { + try { + File logsFolder = new File("logs"); + if (!logsFolder.exists()) logsFolder.mkdir(); - String url = normalizeUrl(input); - addressField.setText(url); - tab.loadUrl(url); - } + ProtocolValues values = new ProtocolValues(); + values.packetHandler = new PacketHandler(); + values.eventManager = new EventManager(); + values.ssl = settings.isSslEnabled(); + AddonLoader addonLoader = new AddonLoader(values.eventManager, + new Logger(new File(logsFolder, "addons"), false, true)); - /** - * Closes the currently selected tab. - */ - public void closeCurrentTab() { - int idx = getTitleBar().getTabs().getSelectedIndex(); - if (idx < 0) return; + ProtocolBridge bridge = new ProtocolBridge( + client, + values, + ProtocolVersion.PV_1_0_0_BETA, + new File(logsFolder, "client") + ); - String key = getTitleBar().getTabs().getTitleAt(idx); + protocolByKey.put(key, bridge); + addonLoaderByKey.put(key, addonLoader); - BrowserTab removed = tabsByKey.remove(key); - if (removed != null) { - removed.dispose(); - pageHost.remove(removed.getView()); - } + client.buildINSConnection(); + bridge.getProtocolValues().eventManager.registerListener(client); - getTitleBar().getTabs().removeTabAt(idx); + InsEndpoint ep = Objects.requireNonNull(settings.getSelectedIns(), "selectedIns"); + client.getClientINSConnection().connect(ep.host(), ep.port()); - // If no tabs left, open a new one - if (getTitleBar().getTabs().getTabCount() == 0) { - openNewTab("web://info.oac/"); - return; - } - - // Show selected tab content - onHeaderTabChanged(); - } - - private void onHeaderTabChanged() { - BrowserTab tab = getCurrentTab(); - if (tab == null) return; - - cardLayout.show(pageHost, tab.getKey()); - pageHost.revalidate(); - pageHost.repaint(); - - // Sync address bar - String loc = tab.getLocation(); - if (loc != null && !loc.isBlank()) { - addressField.setText(loc); + File addonsFolder = new File("addons"); + if (!addonsFolder.exists()) addonsFolder.mkdir(); + addonLoader.loadAddonsFromDirectory(addonsFolder); + } catch (Exception e) { + OACOptionPane.showMessageDialog( + this, + "Tab connection failed:\n" + e.getMessage(), + "Connection", + OACOptionPane.ERROR_MESSAGE + ); } } - public BrowserTab getCurrentTab() { - int idx = getTitleBar().getTabs().getSelectedIndex(); - if (idx < 0) return null; - - String key = getTitleBar().getTabs().getTitleAt(idx); - return tabsByKey.get(key); + private int findPlusIndex(OACTabbedPane tabs) { + for (int i = 0; i < tabs.getTabCount(); i++) { + if ("+".equals(tabs.getTitleAt(i))) return i; + } + return -1; } private String nextTabKey() { tabCounter++; - return "Tab " + tabCounter; + return "Tab-" + tabCounter; } -} \ No newline at end of file + + private void onVisited(String url) { + if (!settings.isHistoryEnabled()) return; + if (url == null || url.isBlank()) return; + historyStore.append(url.trim(), Instant.now()); + } + + private void addCurrentToFavorites() { + BrowserTab tab = getCurrentTab(); + if (tab == null) return; + + String loc = tab.getEngineLocation(); + if (loc == null || loc.isBlank()) return; + + String u = loc.trim(); + + if (!settings.getFavoritesMutable().contains(u)) { + settings.getFavoritesMutable().add(u); + SettingsManager.save(settings); + browser.favoritesBar().setFavorites(settings.getFavorites()); + } + } + + private void closeCurrentTab() { + String key = getSelectedTabKey(); + if (key == null) return; + closeTabByKey(key); + } + + private void closeTabByKey(String key) { + int idx = findTabIndexByKey(key); + if (idx < 0) return; + + BrowserTab removed = tabsByKey.remove(key); + if (removed != null) { + removed.dispose(); + pageHost.remove(removed); + } + protocolByKey.remove(key); + addonLoaderByKey.remove(key); + + OACTabbedPane tabs = getTitleBar().getTabs(); + suppressPlusAutoOpen = true; + try { + tabs.removeTabAt(idx); + } finally { + suppressPlusAutoOpen = false; + } + + if (tabsByKey.isEmpty()) { + openNewTab(settings.getStartPageUrl()); + return; + } + + plusTabSupport.ensurePlusTab(); + int plusIdx = findPlusIndex(tabs); + if (plusIdx > 0) { + int target = Math.min(Math.max(0, idx), plusIdx - 1); + tabs.setSelectedIndex(target); + } + onHeaderTabChanged(); + } + + private int findTabIndexByKey(String key) { + OACTabbedPane tabs = getTitleBar().getTabs(); + for (int i = 0; i < tabs.getTabCount(); i++) { + if (Objects.equals(tabs.getTitleAt(i), key)) return i; + } + return -1; + } + + private void updateTabPresentation(String key, String location) { + int idx = findTabIndexByKey(key); + if (idx < 0) return; + + Component component = getTitleBar().getTabs().getTabComponentAt(idx); + if (component instanceof TabButton tabButton) { + tabButton.setTitle(displayTitleFromUrl(location)); + } + } + + private void onHeaderTabChanged() { + if (handlingTabSwitch) return; + + OACTabbedPane tabs = getTitleBar().getTabs(); + int idx = tabs.getSelectedIndex(); + if (idx < 0) return; + + if (plusTabSupport.isPlusTab(idx)) { + if (suppressPlusAutoOpen) { + return; + } + if (tabsByKey.isEmpty()) { + return; + } + handlingTabSwitch = true; + try { + int fallback = lastSelectedRealTab >= 0 + ? Math.min(lastSelectedRealTab, Math.max(0, tabs.getTabCount() - 1)) + : Math.max(0, tabs.getTabCount() - 2); + int next = plusTabSupport.handleIfPlusSelected(idx, fallback); + if (next >= 0 && next < tabs.getTabCount()) { + tabs.setSelectedIndex(next); + } + } finally { + handlingTabSwitch = false; + } + return; + } + + lastSelectedRealTab = idx; + + String key = tabs.getTitleAt(idx); + BrowserTab tab = tabsByKey.get(key); + if (tab == null) return; + + cardLayout.show(pageHost, key); + + String loc = tab.getLocationUrl(); + if (loc != null && !loc.isBlank()) { + browser.addressField().setText(loc); + } + } + + public BrowserTab getCurrentTab() { + String key = getSelectedTabKey(); + if (key == null) return null; + return tabsByKey.get(key); + } + + public AddonLoader getCurrentAddonLoader() { + String key = getSelectedTabKey(); + if (key == null) return null; + return addonLoaderByKey.get(key); + } + + private String getSelectedTabKey() { + int idx = getTitleBar().getTabs().getSelectedIndex(); + if (idx < 0) return null; + if (plusTabSupport.isPlusTab(idx)) return null; + return getTitleBar().getTabs().getTitleAt(idx); + } +} diff --git a/src/main/java/org/openautonomousconnection/webclient/ui/FavChipBar.java b/src/main/java/org/openautonomousconnection/webclient/ui/FavChipBar.java new file mode 100644 index 0000000..a06d91b --- /dev/null +++ b/src/main/java/org/openautonomousconnection/webclient/ui/FavChipBar.java @@ -0,0 +1,102 @@ +package org.openautonomousconnection.webclient.ui; + +import org.openautonomousconnection.oacswing.component.OACButton; +import org.openautonomousconnection.oacswing.component.OACPanel; + +import javax.swing.border.EmptyBorder; +import java.awt.*; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.function.Consumer; + +/** + * Favorites bar rendered as compact chips. + */ +public final class FavChipBar extends OACPanel { + + private final List favorites = new ArrayList<>(); + private Consumer onNavigate = u -> { + }; + private Runnable onEdit = () -> { + }; + + /** + * Creates a chip-based favorites bar. + */ + public FavChipBar() { + super(new BorderLayout()); + setOpaque(false); + setBorder(new EmptyBorder(4, 8, 6, 8)); + rebuild(); + } + + private static String compactLabel(String url) { + String s = url; + if (s.startsWith("web://")) s = s.substring("web://".length()); + if (s.endsWith("/")) s = s.substring(0, s.length() - 1); + if (s.isEmpty()) s = url; + + if (s.length() <= 24) return s; + return s.substring(0, 24) + "..."; + } + + /** + * Sets navigation callback. + * + * @param onNavigate callback + */ + public void setOnNavigate(Consumer onNavigate) { + this.onNavigate = Objects.requireNonNull(onNavigate, "onNavigate"); + rebuild(); + } + + /** + * Sets edit callback. + * + * @param onEdit callback + */ + public void setOnEdit(Runnable onEdit) { + this.onEdit = Objects.requireNonNull(onEdit, "onEdit"); + rebuild(); + } + + /** + * Replaces favorites list. + * + * @param items favorites + */ + public void setFavorites(List items) { + favorites.clear(); + if (items != null) favorites.addAll(items); + rebuild(); + } + + private void rebuild() { + removeAll(); + + OACPanel chips = new OACPanel(new FlowLayout(FlowLayout.LEFT, 6, 0)); + chips.setOpaque(false); + + for (String url : favorites) { + if (url == null || url.isBlank()) continue; + String u = url.trim(); + + OACButton chip = new OACButton(compactLabel("★ " + u)); + chip.setToolTipText(u); + chip.setMargin(new Insets(3, 10, 3, 10)); + chip.setFocusable(false); + chip.addActionListener(e -> onNavigate.accept(u)); + chips.add(chip); + } + + OACPanel right = new OACPanel(new FlowLayout(FlowLayout.RIGHT, 6, 0)); + right.setOpaque(false); + + add(chips, BorderLayout.CENTER); + add(right, BorderLayout.EAST); + + revalidate(); + repaint(); + } +} diff --git a/src/main/java/org/openautonomousconnection/webclient/ui/GlassPanel.java b/src/main/java/org/openautonomousconnection/webclient/ui/GlassPanel.java new file mode 100644 index 0000000..18f1562 --- /dev/null +++ b/src/main/java/org/openautonomousconnection/webclient/ui/GlassPanel.java @@ -0,0 +1,56 @@ +package org.openautonomousconnection.webclient.ui; + +import org.openautonomousconnection.oacswing.component.OACPanel; + +import java.awt.*; + +/** + * A modern painted surface with subtle gradient and a hairline border. + */ +public class GlassPanel extends OACPanel { + + private final int radius; + private final Color border; + private final Color top; + private final Color bottom; + + /** + * Creates a glass-like panel. + * + * @param layout layout + * @param radius corner radius + * @param top gradient top color + * @param bottom gradient bottom color + * @param border border color + */ + public GlassPanel(LayoutManager layout, int radius, Color top, Color bottom, Color border) { + super(layout); + this.radius = radius; + this.top = top; + this.bottom = bottom; + this.border = border; + setOpaque(false); + } + + @Override + protected void paintComponent(Graphics g) { + Graphics2D g2 = (Graphics2D) g.create(); + try { + g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); + + int w = getWidth(); + int h = getHeight(); + + GradientPaint gp = new GradientPaint(0, 0, top, 0, h, bottom); + g2.setPaint(gp); + g2.fillRoundRect(0, 0, w - 1, h - 1, radius, radius); + + g2.setColor(border); + g2.drawRoundRect(0, 0, w - 1, h - 1, radius, radius); + } finally { + g2.dispose(); + } + + super.paintComponent(g); + } +} diff --git a/src/main/java/org/openautonomousconnection/webclient/ui/PlusTabSupport.java b/src/main/java/org/openautonomousconnection/webclient/ui/PlusTabSupport.java new file mode 100644 index 0000000..04081ff --- /dev/null +++ b/src/main/java/org/openautonomousconnection/webclient/ui/PlusTabSupport.java @@ -0,0 +1,77 @@ +package org.openautonomousconnection.webclient.ui; + +import org.openautonomousconnection.oacswing.component.OACPanel; +import org.openautonomousconnection.oacswing.component.OACTabbedPane; + +import java.util.Objects; + +/** + * Manages a trailing "+" tab in a tabbed pane component. + */ +public final class PlusTabSupport { + + private final OACTabbedPane tabs; + private final Runnable onNewTab; + + private final OACPanel dummy = new OACPanel(); // never shown as content + + /** + * Creates plus-tab support. + * + * @param tabs tabbed pane + * @param onNewTab callback when "+" is pressed/selected + */ + public PlusTabSupport(OACTabbedPane tabs, Runnable onNewTab) { + this.tabs = Objects.requireNonNull(tabs, "tabs"); + this.onNewTab = Objects.requireNonNull(onNewTab, "onNewTab"); + } + + /** + * Ensures the "+" tab exists as the last tab. + */ + public void ensurePlusTab() { + int plusIdx = findPlusIndex(); + if (plusIdx >= 0) return; + + tabs.addTab("+", dummy); + } + + /** + * Returns whether the index points to the "+" tab. + * + * @param index index + * @return true if plus tab + */ + public boolean isPlusTab(int index) { + int plusIdx = findPlusIndex(); + return plusIdx >= 0 && index == plusIdx; + } + + /** + * Handles selection change; if "+" selected, triggers new tab and returns previous index to reselect. + * + * @param selectedIndex currently selected index + * @param previousIndex previous index (fallback) + * @return index that should be selected after handling + */ + public int handleIfPlusSelected(int selectedIndex, int previousIndex) { + if (!isPlusTab(selectedIndex)) return selectedIndex; + + onNewTab.run(); + + int plusIdx = findPlusIndex(); + if (plusIdx < 0) return 0; + + // Select last "real" tab if exists, else 0 + int lastReal = Math.max(0, plusIdx - 1); + return lastReal; + } + + private int findPlusIndex() { + for (int i = 0; i < tabs.getTabCount(); i++) { + String t = tabs.getTitleAt(i); + if ("+".equals(t)) return i; + } + return -1; + } +} diff --git a/src/main/java/org/openautonomousconnection/webclient/ui/TabButton.java b/src/main/java/org/openautonomousconnection/webclient/ui/TabButton.java new file mode 100644 index 0000000..669ac0f --- /dev/null +++ b/src/main/java/org/openautonomousconnection/webclient/ui/TabButton.java @@ -0,0 +1,77 @@ +package org.openautonomousconnection.webclient.ui; + +import org.openautonomousconnection.oacswing.component.OACButton; +import org.openautonomousconnection.oacswing.component.OACLabel; +import org.openautonomousconnection.oacswing.component.OACPanel; + +import javax.swing.*; +import javax.swing.border.EmptyBorder; +import java.awt.*; +import java.util.Objects; + +/** + * Custom tab header component (favicon + title + close button). + */ +public final class TabButton extends OACPanel { + + private final OACLabel iconLabel = new OACLabel(""); + private final OACLabel titleLabel = new OACLabel(""); + private final OACButton closeButton = new OACButton("x"); + + /** + * Creates a tab button component. + * + * @param initialTitle initial title + * @param onClose close action + */ + public TabButton(String initialTitle, Runnable onClose) { + super(new BorderLayout(6, 0)); + Objects.requireNonNull(onClose, "onClose"); + + setOpaque(false); + setBorder(new EmptyBorder(3, 10, 3, 6)); + + iconLabel.setPreferredSize(new Dimension(16, 16)); + iconLabel.setMinimumSize(new Dimension(16, 16)); + + titleLabel.setText(safeTitle(initialTitle)); + titleLabel.setBorder(new EmptyBorder(0, 2, 0, 2)); + + closeButton.setFocusable(false); + closeButton.setMargin(new Insets(1, 8, 1, 8)); + closeButton.addActionListener(e -> onClose.run()); + + add(iconLabel, BorderLayout.WEST); + add(titleLabel, BorderLayout.CENTER); + add(closeButton, BorderLayout.EAST); + } + + private static String safeTitle(String title) { + String s = title == null ? "" : title.trim(); + if (s.isEmpty()) return "New Tab"; + if (s.length() <= 22) return s; + return s.substring(0, 22) + "..."; + } + + /** + * Sets the tab title. + * + * @param title title + */ + public void setTitle(String title) { + titleLabel.setText(safeTitle(title)); + revalidate(); + repaint(); + } + + /** + * Sets the favicon icon (scaled by caller if needed). + * + * @param icon icon or null + */ + public void setFavicon(Icon icon) { + iconLabel.setIcon(icon); + revalidate(); + repaint(); + } +} diff --git a/src/main/java/org/openautonomousconnection/webclient/ui/TabView.java b/src/main/java/org/openautonomousconnection/webclient/ui/TabView.java deleted file mode 100644 index 0a9fb82..0000000 --- a/src/main/java/org/openautonomousconnection/webclient/ui/TabView.java +++ /dev/null @@ -1,162 +0,0 @@ -package org.openautonomousconnection.webclient.ui; - -import javafx.application.Platform; -import javafx.embed.swing.JFXPanel; -import javafx.scene.Scene; -import javafx.scene.web.WebEngine; -import javafx.scene.web.WebHistory; -import javafx.scene.web.WebView; -import org.openautonomousconnection.oacswing.component.OACPanel; -import org.openautonomousconnection.webclient.lua.WebLogger; -import org.openautonomousconnection.webclient.settings.FxEngine; - -import java.awt.*; -import java.util.Objects; -import java.util.concurrent.atomic.AtomicBoolean; -import java.util.function.Consumer; - -/** - * A Swing panel that embeds a JavaFX WebView and runs LuaScript (no JavaScript). - * - *

Loads "web://" URLs so JavaFX can request subresources (CSS, images, href navigations) - * through URLConnection (mapped to OAC WebRequestPacket).

- */ -public final class TabView extends OACPanel { - - private final AtomicBoolean initialized = new AtomicBoolean(false); - private final Consumer onLocationChanged; - private final WebLogger webLogger; - private JFXPanel fxPanel; - private WebView webView; - private WebEngine engine; - private volatile FxEngine luaEngine; - - /** - * Creates a new tab view. - * - * @param onLocationChanged callback invoked when the WebEngine location changes - * @param url callback invoked on URL changes - */ - public TabView(Consumer onLocationChanged, String url) { - super(); - this.onLocationChanged = Objects.requireNonNull(onLocationChanged, "onLocationChanged"); - this.webLogger = new WebLogger(url); - setLayout(new BorderLayout()); - } - - @Override - public void addNotify() { - super.addNotify(); - - if (!initialized.compareAndSet(false, true)) { - return; - } - - fxPanel = new JFXPanel(); - add(fxPanel, BorderLayout.CENTER); - - Platform.runLater(() -> { - webView = new WebView(); - webView.setContextMenuEnabled(false); - - engine = webView.getEngine(); - engine.setJavaScriptEnabled(false); - - engine.locationProperty().addListener((obs, oldV, newV) -> { - if (newV != null) { - onLocationChanged.accept(newV); - } - }); - - // Proper Lua integration from your library - luaEngine = new FxEngine(engine, webView, webLogger); - luaEngine.install(); - - fxPanel.setScene(new Scene(webView)); - }); - } - - /** - * Loads a normalized URL (expected: web://...). - * - * @param url URL to load - */ - public void loadUrl(String url) { - String u = Objects.requireNonNull(url, "url").trim(); - if (u.isEmpty()) return; - - Platform.runLater(() -> { - if (engine != null) { - engine.load(u); - } - }); - } - - /** - * Reloads the current page. - */ - public void reload() { - Platform.runLater(() -> { - if (engine != null) engine.reload(); - }); - } - - /** - * Navigates one step back in history if possible. - */ - public void back() { - Platform.runLater(() -> { - if (engine == null) return; - WebHistory h = engine.getHistory(); - if (h.getCurrentIndex() > 0) h.go(-1); - }); - } - - /** - * Navigates one step forward in history if possible. - */ - public void forward() { - Platform.runLater(() -> { - if (engine == null) return; - WebHistory h = engine.getHistory(); - if (h.getCurrentIndex() < h.getEntries().size() - 1) h.go(1); - }); - } - - /** - * Returns current engine location. - * - * @return location or null - */ - public String getEngineLocation() { - return engine != null ? engine.getLocation() : null; - } - - /** - * Disposes resources. - */ - public void dispose() { - FxEngine le = luaEngine; - luaEngine = null; - if (le != null) { - try { - le.close(); - } catch (Exception ignored) { - } - } - - Platform.runLater(() -> { - if (engine != null) { - try { - engine.load(null); - } catch (Exception ignored) { - } - } - engine = null; - webView = null; - if (fxPanel != null) { - fxPanel.setScene(null); - } - }); - } -} \ No newline at end of file diff --git a/src/main/java/org/openautonomousconnection/webclient/ui/menus/AboutDialog.java b/src/main/java/org/openautonomousconnection/webclient/ui/menus/AboutDialog.java new file mode 100644 index 0000000..26e1b82 --- /dev/null +++ b/src/main/java/org/openautonomousconnection/webclient/ui/menus/AboutDialog.java @@ -0,0 +1,134 @@ +package org.openautonomousconnection.webclient.ui.menus; + +import org.openautonomousconnection.luascript.security.LuaExecutionPolicy; +import org.openautonomousconnection.oacswing.component.*; +import org.openautonomousconnection.webclient.ClientImpl; +import org.openautonomousconnection.webclient.Main; +import org.openautonomousconnection.webclient.settings.AppSettings; +import org.openautonomousconnection.webclient.settings.InsEndpoint; +import org.openautonomousconnection.webclient.ui.BrowserTab; + +import javax.swing.*; +import java.awt.*; +import java.io.File; +import java.util.Objects; + +/** + * About dialog showing basic client information and quick actions. + */ +public final class AboutDialog extends OACDialog { + + /** + * Creates an about dialog. + * + * @param owner owner frame + * @param settings app settings + */ + public AboutDialog(Frame owner, AppSettings settings) { + super(owner, "About", true); + Objects.requireNonNull(settings, "settings"); + buildUi(settings); + pack(); + setMinimumSize(new Dimension(640, 420)); + setLocationRelativeTo(owner); + } + + private static String safeClientInsId() { + ClientImpl c = currentClient(); + if (c == null) return "N/A"; + if (c.getClientINSConnection() == null) return "N/A"; + return String.valueOf(c.getClientINSConnection().getUniqueID()); + } + + /** + * Tries to resolve an additional "web server" id if available in your client. + * + *

Adjust this method once you confirm where your web-server id is exposed.

+ * + * @return id or N/A + */ + private static String safeClientWebId() { + ClientImpl c = currentClient(); + if (c == null) return "N/A"; + if (c.getClientServerConnection() == null) return "N/A"; + return String.valueOf(c.getClientServerConnection().getUniqueID()); + } + + private static ClientImpl currentClient() { + if (Main.getUi() == null) return null; + BrowserTab tab = Main.getUi().getCurrentTab(); + if (tab == null) return null; + return tab.getProtocolClient(); + } + + private void buildUi(AppSettings settings) { + setLayout(new BorderLayout()); + + OACPanel root = new OACPanel(new BorderLayout(10, 10)); + root.setBorder(BorderFactory.createEmptyBorder(12, 12, 12, 12)); + + OACTextArea info = new OACTextArea(); + info.setEditable(false); + info.setLineWrap(true); + info.setWrapStyleWord(true); + + InsEndpoint selected = settings.getSelectedIns(); + LuaExecutionPolicy pol = settings.getLuaPolicy(); + + String insId = safeClientInsId(); + String webId = safeClientWebId(); + + info.setText( + "OAC WebClient\n\n" + + "This client embeds JavaFX WebView into Swing and executes Lua scripts (optional) instead of JavaScript.\n\n" + + "Selected INS: " + (selected == null ? "N/A" : selected) + "\n\n" + + "Client IDs:\n" + + " - INS Connection ID: " + insId + "\n" + + " - Web Connection ID: " + webId + "\n\n" + + "Lua Execution Policy:\n" + + " - timeoutMs: " + pol.timeout().toMillis() + "\n" + + " - instructionLimit: " + pol.instructionLimit() + "\n" + + " - hookStep: " + pol.hookStep() + "\n" + ); + + root.add(new OACScrollPane(info), BorderLayout.CENTER); + + OACPanel actions = new OACPanel(new FlowLayout(FlowLayout.RIGHT, 8, 0)); + + OACButton openCerts = new OACButton("Open Certificates Folder"); + openCerts.addActionListener(e -> { + ClientImpl c = currentClient(); + if (c == null) { + OACOptionPane.showMessageDialog(this, "No active tab client.", "Certificates", OACOptionPane.WARNING_MESSAGE); + return; + } + openDir(c.getFolderStructure().certificatesFolder.getAbsolutePath(), "Certificates"); + }); + + OACButton openLicenses = new OACButton("Open Licenses Folder"); + openLicenses.addActionListener(e -> openDir(new File("licenses").getAbsolutePath(), "Licenses")); + + OACButton close = new OACButton("Close"); + close.addActionListener(e -> dispose()); + + actions.add(openCerts); + actions.add(openLicenses); + actions.add(close); + + root.add(actions, BorderLayout.SOUTH); + + add(root, BorderLayout.CENTER); + } + + private void openDir(String dir, String title) { + try { + File f = new File(dir); + Desktop.getDesktop().open(f.getAbsoluteFile()); + } catch (Exception ex) { + OACOptionPane.showMessageDialog(this, + "Failed to open " + title + " folder:\n" + ex.getMessage(), + title, + OACOptionPane.ERROR_MESSAGE); + } + } +} diff --git a/src/main/java/org/openautonomousconnection/webclient/ui/menus/AddonsDialog.java b/src/main/java/org/openautonomousconnection/webclient/ui/menus/AddonsDialog.java new file mode 100644 index 0000000..7dca316 --- /dev/null +++ b/src/main/java/org/openautonomousconnection/webclient/ui/menus/AddonsDialog.java @@ -0,0 +1,257 @@ +package org.openautonomousconnection.webclient.ui.menus; + +import dev.unlegitdqrk.unlegitlibrary.addon.AddonLoader; +import dev.unlegitdqrk.unlegitlibrary.addon.events.AddonLoadedEvent; +import dev.unlegitdqrk.unlegitlibrary.addon.impl.Addon; +import dev.unlegitdqrk.unlegitlibrary.event.EventListener; +import dev.unlegitdqrk.unlegitlibrary.event.Listener; +import org.openautonomousconnection.oacswing.component.*; +import org.openautonomousconnection.webclient.settings.AppSettings; +import org.openautonomousconnection.webclient.ui.BrowserUI; + +import javax.swing.*; +import java.awt.*; +import java.io.File; +import java.util.Objects; + +/** + * Addons management dialog for the currently selected tab/client. + */ +public final class AddonsDialog extends OACDialog { + + private final BrowserUI browserUi; + private final DefaultListModel addonModel = new DefaultListModel<>(); + private final OACList addonList = new OACList<>(addonModel); + private final OACTextArea details = new OACTextArea(); + private final AddonListener addonListener = new AddonListener(); + private AddonLoader currentLoader; + + public AddonsDialog(Frame owner, AppSettings settings) { + super(owner, "Addons", true); + Objects.requireNonNull(settings, "settings"); + this.browserUi = owner instanceof BrowserUI bui ? bui : null; + + buildUi(); + bindLoader(); + refreshAddons(); + + pack(); + setMinimumSize(new Dimension(820, 520)); + setLocationRelativeTo(owner); + } + + private static String safe(ValueSupplier s) { + try { + String v = s.get(); + return v == null || v.isBlank() ? "N/A" : v; + } catch (Exception ignored) { + return "N/A"; + } + } + + @Override + public void dispose() { + unbindLoader(); + super.dispose(); + } + + private void buildUi() { + setLayout(new BorderLayout(10, 10)); + + OACPanel root = new OACPanel(new BorderLayout(10, 10)); + root.setBorder(javax.swing.BorderFactory.createEmptyBorder(12, 12, 12, 12)); + + addonList.setSelectionMode(javax.swing.ListSelectionModel.SINGLE_SELECTION); + addonList.addListSelectionListener(e -> { + if (!e.getValueIsAdjusting()) updateDetails(); + }); + + details.setEditable(false); + details.setLineWrap(true); + details.setWrapStyleWord(true); + + OACPanel center = new OACPanel(new GridLayout(1, 2, 10, 0)); + center.add(new OACScrollPane(addonList)); + center.add(new OACScrollPane(details)); + root.add(center, BorderLayout.CENTER); + + OACPanel buttons = new OACPanel(new FlowLayout(FlowLayout.RIGHT, 8, 0)); + + OACButton refresh = new OACButton("Refresh"); + refresh.addActionListener(e -> refreshAddons()); + + OACButton loadDir = new OACButton("Load Dir"); + loadDir.addActionListener(e -> loadFromAddonsDirectory()); + + OACButton loadJar = new OACButton("Load Jar"); + loadJar.addActionListener(e -> loadSingleJar()); + + OACButton enable = new OACButton("Enable"); + enable.addActionListener(e -> setSelectedEnabled(true)); + + OACButton disable = new OACButton("Disable"); + disable.addActionListener(e -> setSelectedEnabled(false)); + + OACButton close = new OACButton("Close"); + close.addActionListener(e -> dispose()); + + buttons.add(new OACLabel("Current tab addons")); + buttons.add(refresh); + buttons.add(loadDir); + buttons.add(loadJar); + buttons.add(enable); + buttons.add(disable); + buttons.add(close); + + root.add(buttons, BorderLayout.SOUTH); + add(root, BorderLayout.CENTER); + } + + private void bindLoader() { + unbindLoader(); + currentLoader = browserUi == null ? null : browserUi.getCurrentAddonLoader(); + if (currentLoader != null) { + currentLoader.getEventManager().registerListener(addonListener); + } + } + + private void unbindLoader() { + if (currentLoader != null) { + try { + currentLoader.getEventManager().unregisterListener(addonListener); + } catch (Exception ignored) { + // best effort + } + } + currentLoader = null; + } + + private void refreshAddons() { + bindLoader(); + addonModel.clear(); + + if (currentLoader == null) { + details.setText("No active tab or no addon loader for current tab."); + return; + } + + for (Addon addon : currentLoader.getLoadedAddons()) { + addonModel.addElement(addon); + } + + if (addonModel.size() > 0) { + addonList.setSelectedIndex(0); + } + updateDetails(); + } + + private void loadFromAddonsDirectory() { + if (currentLoader == null) { + OACOptionPane.showMessageDialog(this, "No addon loader for current tab.", "Addons", OACOptionPane.WARNING_MESSAGE); + return; + } + + try { + File addonsFolder = new File("addons"); + if (!addonsFolder.exists() && !addonsFolder.mkdirs()) { + throw new IllegalStateException("Cannot create addons directory."); + } + currentLoader.loadAddonsFromDirectory(addonsFolder); + refreshAddons(); + } catch (Exception ex) { + OACOptionPane.showMessageDialog(this, "Failed to load from directory:\n" + ex.getMessage(), "Addons", OACOptionPane.ERROR_MESSAGE); + } + } + + private void loadSingleJar() { + if (currentLoader == null) { + OACOptionPane.showMessageDialog(this, "No addon loader for current tab.", "Addons", OACOptionPane.WARNING_MESSAGE); + return; + } + + FileDialog fd = new FileDialog(this, "Select addon jar", FileDialog.LOAD); + fd.setFile("*.jar"); + fd.setVisible(true); + + String file = fd.getFile(); + String dir = fd.getDirectory(); + if (file == null || dir == null) return; + + try { + currentLoader.loadAddonFromJar(new File(dir, file)); + refreshAddons(); + } catch (Exception ex) { + OACOptionPane.showMessageDialog(this, "Failed to load jar:\n" + ex.getMessage(), "Addons", OACOptionPane.ERROR_MESSAGE); + } + } + + private void setSelectedEnabled(boolean enabled) { + if (currentLoader == null) { + OACOptionPane.showMessageDialog(this, "No addon loader for current tab.", "Addons", OACOptionPane.WARNING_MESSAGE); + return; + } + + Addon addon = addonList.getSelectedValue(); + if (addon == null) return; + + try { + if (enabled) { + currentLoader.enableAddon(addon); + } else { + currentLoader.disableAddon(addon); + } + addonList.repaint(); + updateDetails(); + } catch (Exception ex) { + OACOptionPane.showMessageDialog(this, "Operation failed:\n" + ex.getMessage(), "Addons", OACOptionPane.ERROR_MESSAGE); + } + } + + private void updateDetails() { + Addon addon = addonList.getSelectedValue(); + if (addon == null) { + details.setText(addonModel.isEmpty() ? "No addons loaded." : "Select an addon."); + return; + } + + String name = safe(() -> addon.getAddonInfo().name()); + String version = safe(() -> addon.getAddonInfo().version()); + String author = safe(() -> addon.getAddonInfo().author()); + + details.setText( + "Name: " + name + "\n" + + "Version: " + version + "\n" + + "Author: " + author + "\n" + + "Enabled: " + addon.isEnabled() + "\n" + + "Class: " + addon.getClass().getName() + ); + } + + private boolean containsAddon(Addon addon) { + for (int i = 0; i < addonModel.size(); i++) { + if (addonModel.get(i) == addon) return true; + } + return false; + } + + @FunctionalInterface + private interface ValueSupplier { + String get(); + } + + private final class AddonListener extends EventListener { + @Listener + public void onLoaded(AddonLoadedEvent event) { + EventQueue.invokeLater(() -> { + Addon a = event.getAddon(); + if (a == null) return; + if (!containsAddon(a)) addonModel.addElement(a); + addonList.repaint(); + if (addonList.getSelectedValue() == null && addonModel.size() > 0) { + addonList.setSelectedIndex(0); + } + updateDetails(); + }); + } + } +} diff --git a/src/main/java/org/openautonomousconnection/webclient/ui/menus/SettingsDialog.java b/src/main/java/org/openautonomousconnection/webclient/ui/menus/SettingsDialog.java new file mode 100644 index 0000000..4ed78b9 --- /dev/null +++ b/src/main/java/org/openautonomousconnection/webclient/ui/menus/SettingsDialog.java @@ -0,0 +1,417 @@ +package org.openautonomousconnection.webclient.ui.menus; + +import org.openautonomousconnection.luascript.security.LuaExecutionPolicy; +import org.openautonomousconnection.oacswing.component.*; +import org.openautonomousconnection.webclient.settings.AppSettings; +import org.openautonomousconnection.webclient.settings.InsEndpoint; + +import javax.swing.*; +import java.awt.*; +import java.time.Duration; +import java.util.List; +import java.util.Objects; + +/** + * Settings dialog for WebClient. + */ +public final class SettingsDialog extends OACDialog { + + private final AppSettings settings; + private final Runnable onSaved; + + private final OACTextField startPageField = new OACTextField(); + private final OACCheckBox sslEnabled = new OACCheckBox("Enable SSL"); + private final OACCheckBox luaEnabled = new OACCheckBox("Enable Lua"); + private final OACCheckBox historyEnabled = new OACCheckBox("Enable History"); + + private final DefaultListModel insModel = new DefaultListModel<>(); + private final OACList insList = new OACList<>(insModel); + private final OACTextField insHost = new OACTextField(); + private final OACTextField insPort = new OACTextField(); + + private final DefaultListModel favModel = new DefaultListModel<>(); + private final OACList favList = new OACList<>(favModel); + private final OACTextField favUrl = new OACTextField(); + + private final OACTextField luaTimeoutMs = new OACTextField(); + private final OACTextField luaInstructionLimit = new OACTextField(); + private final OACTextField luaHookStep = new OACTextField(); + + /** + * Creates settings dialog. + * + * @param owner owner frame + * @param settings settings (mutable) + * @param onSaved callback invoked after saving + */ + public SettingsDialog(Frame owner, AppSettings settings, Runnable onSaved) { + super(owner, "Settings", true); + this.settings = Objects.requireNonNull(settings, "settings"); + this.onSaved = Objects.requireNonNull(onSaved, "onSaved"); + buildUi(); + loadFromSettings(); + pack(); + setMinimumSize(new Dimension(760, 560)); + setLocationRelativeTo(owner); + } + + private void buildUi() { + setLayout(new BorderLayout()); + + OACTabbedPane tabs = new OACTabbedPane(); + tabs.addTab("General", buildGeneral()); + tabs.addTab("INS", buildIns()); + tabs.addTab("Favorites", buildFavorites()); + tabs.addTab("Lua Policy", buildLuaPolicy()); + add(tabs, BorderLayout.CENTER); + + OACPanel south = new OACPanel(new FlowLayout(FlowLayout.RIGHT, 8, 8)); + OACButton save = new OACButton("Save"); + OACButton cancel = new OACButton("Cancel"); + save.addActionListener(e -> onSave()); + cancel.addActionListener(e -> dispose()); + south.add(cancel); + south.add(save); + add(south, BorderLayout.SOUTH); + } + + private JComponent buildGeneral() { + OACPanel p = new OACPanel(new GridBagLayout()); + p.setBorder(BorderFactory.createEmptyBorder(12, 12, 12, 12)); + + GridBagConstraints c = new GridBagConstraints(); + c.insets = new Insets(6, 6, 6, 6); + c.fill = GridBagConstraints.HORIZONTAL; + c.weightx = 1; + + int y = 0; + + c.gridx = 0; + c.gridy = y; + c.weightx = 0; + p.add(new OACLabel("Start Page"), c); + c.gridx = 1; + c.gridy = y; + c.weightx = 1; + p.add(startPageField, c); + y++; + + c.gridx = 0; + c.gridy = y; + c.gridwidth = 2; + OACPanel checks = new OACPanel(new FlowLayout(FlowLayout.LEFT, 10, 0)); + checks.add(sslEnabled); + checks.add(luaEnabled); + checks.add(historyEnabled); + p.add(checks, c); + + return p; + } + + private JComponent buildIns() { + OACPanel root = new OACPanel(new BorderLayout(10, 10)); + root.setBorder(BorderFactory.createEmptyBorder(12, 12, 12, 12)); + + insList.setSelectionMode(ListSelectionModel.SINGLE_SELECTION); + root.add(new OACScrollPane(insList), BorderLayout.CENTER); + + OACPanel editor = new OACPanel(new GridBagLayout()); + GridBagConstraints c = new GridBagConstraints(); + c.insets = new Insets(6, 6, 6, 6); + c.fill = GridBagConstraints.HORIZONTAL; + c.weightx = 1; + + c.gridx = 0; + c.gridy = 0; + c.weightx = 0; + editor.add(new OACLabel("Host"), c); + c.gridx = 1; + c.gridy = 0; + c.weightx = 1; + editor.add(insHost, c); + + c.gridx = 0; + c.gridy = 1; + c.weightx = 0; + editor.add(new OACLabel("Port"), c); + c.gridx = 1; + c.gridy = 1; + c.weightx = 1; + insPort.setText("1026"); + insPort.setToolTipText("Default: 1026"); + editor.add(insPort, c); + + OACPanel buttons = new OACPanel(new FlowLayout(FlowLayout.RIGHT, 8, 0)); + OACButton add = new OACButton("Add"); + OACButton remove = new OACButton("Remove"); + OACButton select = new OACButton("Select"); + add.addActionListener(e -> onAddIns()); + remove.addActionListener(e -> onRemoveIns()); + select.addActionListener(e -> onSelectIns()); + + buttons.add(add); + buttons.add(remove); + buttons.add(select); + + OACPanel south = new OACPanel(new BorderLayout()); + south.add(editor, BorderLayout.CENTER); + south.add(buttons, BorderLayout.SOUTH); + + root.add(south, BorderLayout.SOUTH); + return root; + } + + private JComponent buildFavorites() { + OACPanel root = new OACPanel(new BorderLayout(10, 10)); + root.setBorder(BorderFactory.createEmptyBorder(12, 12, 12, 12)); + + favList.setSelectionMode(ListSelectionModel.SINGLE_SELECTION); + root.add(new OACScrollPane(favList), BorderLayout.CENTER); + + OACPanel addRow = new OACPanel(new BorderLayout(8, 0)); + addRow.add(new OACLabel("URL"), BorderLayout.WEST); + addRow.add(favUrl, BorderLayout.CENTER); + + OACPanel buttons = new OACPanel(new FlowLayout(FlowLayout.RIGHT, 8, 0)); + OACButton add = new OACButton("Add"); + OACButton remove = new OACButton("Remove"); + add.addActionListener(e -> onAddFavorite()); + remove.addActionListener(e -> onRemoveFavorite()); + buttons.add(add); + buttons.add(remove); + + OACPanel south = new OACPanel(new BorderLayout(8, 8)); + south.add(addRow, BorderLayout.CENTER); + south.add(buttons, BorderLayout.SOUTH); + + root.add(south, BorderLayout.SOUTH); + return root; + } + + private JComponent buildLuaPolicy() { + OACPanel p = new OACPanel(new GridBagLayout()); + p.setBorder(BorderFactory.createEmptyBorder(12, 12, 12, 12)); + + GridBagConstraints c = new GridBagConstraints(); + c.insets = new Insets(6, 6, 6, 6); + c.fill = GridBagConstraints.HORIZONTAL; + c.weightx = 1; + + int y = 0; + + c.gridx = 0; + c.gridy = y; + c.weightx = 0; + p.add(new OACLabel("Timeout (ms)"), c); + c.gridx = 1; + c.gridy = y; + c.weightx = 1; + p.add(luaTimeoutMs, c); + y++; + + c.gridx = 0; + c.gridy = y; + c.weightx = 0; + p.add(new OACLabel("Instruction Limit"), c); + c.gridx = 1; + c.gridy = y; + c.weightx = 1; + p.add(luaInstructionLimit, c); + y++; + + c.gridx = 0; + c.gridy = y; + c.weightx = 0; + p.add(new OACLabel("Hook Step"), c); + c.gridx = 1; + c.gridy = y; + c.weightx = 1; + p.add(luaHookStep, c); + y++; + + OACPanel buttons = new OACPanel(new FlowLayout(FlowLayout.RIGHT, 8, 0)); + OACButton reset = new OACButton("Reset to uiDefault"); + reset.addActionListener(e -> { + settings.resetLuaPolicyToUiDefault(); + loadLuaPolicy(); + }); + buttons.add(reset); + + c.gridx = 0; + c.gridy = y; + c.gridwidth = 2; + p.add(buttons, c); + + return p; + } + + private void loadFromSettings() { + startPageField.setText(settings.getStartPageUrl()); + sslEnabled.setSelected(settings.isSslEnabled()); + luaEnabled.setSelected(settings.isLuaEnabled()); + historyEnabled.setSelected(settings.isHistoryEnabled()); + + insModel.clear(); + for (InsEndpoint ep : settings.getInsEndpoints()) insModel.addElement(ep); + + // Select current INS in list if present + InsEndpoint selected = settings.getSelectedIns(); + if (selected != null) { + for (int i = 0; i < insModel.size(); i++) { + if (selected.equals(insModel.get(i))) { + insList.setSelectedIndex(i); + break; + } + } + } + + favModel.clear(); + for (String u : settings.getFavorites()) favModel.addElement(u); + + loadLuaPolicy(); + } + + private void loadLuaPolicy() { + LuaExecutionPolicy pol = settings.getLuaPolicy(); + luaTimeoutMs.setText(Long.toString(pol.timeout().toMillis())); + luaInstructionLimit.setText(Long.toString(pol.instructionLimit())); + luaHookStep.setText(Integer.toString(pol.hookStep())); + } + + private void onAddIns() { + String host = insHost.getText() == null ? "" : insHost.getText().trim(); + String portS = insPort.getText() == null ? "" : insPort.getText().trim(); + + if (host.isEmpty() || portS.isEmpty()) { + OACOptionPane.showMessageDialog(this, "Host/Port required.", "INS", OACOptionPane.WARNING_MESSAGE); + return; + } + + int port; + try { + port = Integer.parseInt(portS); + } catch (Exception e) { + OACOptionPane.showMessageDialog(this, "Invalid port.", "INS", OACOptionPane.WARNING_MESSAGE); + return; + } + + InsEndpoint ep; + try { + ep = new InsEndpoint(host, port); + } catch (Exception e) { + OACOptionPane.showMessageDialog(this, e.getMessage(), "INS", OACOptionPane.WARNING_MESSAGE); + return; + } + + // Add if not exists + for (int i = 0; i < insModel.size(); i++) { + if (ep.equals(insModel.get(i))) { + insList.setSelectedIndex(i); + return; + } + } + insModel.addElement(ep); + insList.setSelectedIndex(insModel.size() - 1); + } + + private void onRemoveIns() { + int idx = insList.getSelectedIndex(); + if (idx < 0) return; + + InsEndpoint ep = insModel.get(idx); + int confirm = OACOptionPane.showConfirmDialog(this, "Remove " + ep + "?", "INS", OACOptionPane.YES_NO_OPTION); + if (confirm != OACOptionPane.YES_OPTION) return; + + insModel.remove(idx); + if (insModel.isEmpty()) { + // Always keep at least one + insModel.addElement(new InsEndpoint("open-autonomous-connection.org", 1026)); + } + insList.setSelectedIndex(Math.min(idx, insModel.size() - 1)); + } + + private void onSelectIns() { + int idx = insList.getSelectedIndex(); + if (idx < 0) return; + InsEndpoint ep = insModel.get(idx); + OACOptionPane.showMessageDialog(this, + "Selected INS will be used on next (re)connect:\n" + ep, + "INS", OACOptionPane.INFORMATION_MESSAGE); + } + + private void onAddFavorite() { + String url = favUrl.getText() == null ? "" : favUrl.getText().trim(); + if (url.isEmpty()) return; + + for (int i = 0; i < favModel.size(); i++) { + if (url.equals(favModel.get(i))) return; + } + favModel.addElement(url); + favUrl.setText(""); + } + + private void onRemoveFavorite() { + int idx = favList.getSelectedIndex(); + if (idx < 0) return; + favModel.remove(idx); + } + + private void onSave() { + // Validate + apply + String start = startPageField.getText() == null ? "" : startPageField.getText().trim(); + if (start.isEmpty()) { + OACOptionPane.showMessageDialog(this, "Start page required.", "Settings", OACOptionPane.WARNING_MESSAGE); + return; + } + + try { + settings.setStartPageUrl(start); + settings.setSslEnabled(sslEnabled.isSelected()); + settings.setLuaEnabled(luaEnabled.isSelected()); + settings.setHistoryEnabled(historyEnabled.isSelected()); + + // INS list + selection + List list = settings.getInsEndpointsMutable(); + list.clear(); + for (int i = 0; i < insModel.size(); i++) list.add(insModel.get(i)); + + int selIdx = insList.getSelectedIndex(); + if (selIdx >= 0 && selIdx < insModel.size()) { + settings.setSelectedIns(insModel.get(selIdx)); + } else if (!list.isEmpty()) { + settings.setSelectedIns(list.get(0)); + } + + // Favorites + List favs = settings.getFavoritesMutable(); + favs.clear(); + for (int i = 0; i < favModel.size(); i++) { + String u = favModel.get(i); + if (u != null && !u.isBlank()) favs.add(u.trim()); + } + + // Lua policy + long timeoutMs = parseLongOrFail(luaTimeoutMs.getText(), "Timeout (ms)"); + long instr = parseLongOrFail(luaInstructionLimit.getText(), "Instruction Limit"); + int hook = (int) parseLongOrFail(luaHookStep.getText(), "Hook Step"); + + settings.setLuaPolicy(new LuaExecutionPolicy(Duration.ofMillis(timeoutMs), instr, hook)); + } catch (Exception e) { + OACOptionPane.showMessageDialog(this, e.getMessage(), "Settings", OACOptionPane.ERROR_MESSAGE); + return; + } + + onSaved.run(); + dispose(); + } + + private long parseLongOrFail(String text, String field) { + String s = text == null ? "" : text.trim(); + if (s.isEmpty()) throw new IllegalArgumentException(field + " is required."); + try { + return Long.parseLong(s); + } catch (Exception e) { + throw new IllegalArgumentException("Invalid number for " + field + "."); + } + } +}