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; /** * Logical browser tab: stable UI key + embedded JavaFX WebView. * *

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

*/ public final class BrowserTab extends OACPanel { private final String key; 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 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 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(); } /** * Returns the stable tab key. * * @return key */ 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)); }); } /** * Loads a URL. * * @param url URL */ public void loadUrl(String 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() { 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() { if (peekHistoryTarget(+1) == null) return; Platform.runLater(() -> { WebEngine e = engine; if (e == null) return; WebHistory h = e.getHistory(); if (h.getCurrentIndex() < h.getEntries().size() - 1) h.go(1); }); } /** * Returns current location if known. * * @return URL or null */ 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() { 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(); } private void fireLocationChanged(String location) { if (location == null) return; String s = location.trim(); if (s.isEmpty()) return; try { onLocationChanged.accept(s); } catch (Exception ignored) { // Must not break FX thread } } 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. } } }