7 Commits

Author SHA1 Message Date
UnlegitDqrk
35cd895510 Implemented missing Lua features 2026-02-27 23:56:53 +01:00
UnlegitDqrk
d3662a0773 Implemented missing Lua features 2026-02-27 23:56:35 +01:00
UnlegitDqrk
ff2a76e7da Implemented missing Lua features 2026-02-27 23:56:20 +01:00
UnlegitDqrk
4c1cf09f34 Updated to latest Protocol Version 2026-02-27 21:05:07 +01:00
UnlegitDqrk
b3457ee133 Updated to latest Protocol Version 2026-02-27 20:56:16 +01:00
UnlegitDqrk
b6f7110d16 Updated to latest Protocol Version 2026-02-22 18:21:18 +01:00
UnlegitDqrk
4376fe6daa Updated to latest Protocol Version 2026-02-22 18:20:57 +01:00
14 changed files with 581 additions and 112 deletions

View File

@@ -1 +1,2 @@
Please read the license here: https://open-autonomous-connection.org/license.html
Download all third parties licenses here: https://open-autonomous-connection.org/assets/licenses.zip

View File

@@ -3,3 +3,16 @@
This is the Protocol for our Open Autonomous Connection project.<br />
Feel free to join our Discord.
<br />
## License Notice
This project (OAC) is licensed under
the [Open Autonomous Public License (OAPL)](https://open-autonomous-connection.org/license.html).
**Third-party components:**
<br />
Download all license here: https://open-autonomous-connection.org/assets/licenses.zip
- *UnlegitLibrary* is authored by the same copyright holder and is used here under a special agreement:
While [UnlegitLibrary](https://repo.unlegitdqrk.dev/UnlegitDqrk/unlegitlibrary/) is generally distributed under
the [GNU GPLv3](https://repo.unlegitdqrk.dev/UnlegitDqrk/unlegitlibrary/src/branch/master/LICENSE),
it is additionally licensed under OAPL **exclusively for the OAC project**.
Therefore, within OAC, the OAPL terms apply to UnlegitLibrary as well.

View File

@@ -3,7 +3,7 @@
<modelVersion>4.0.0</modelVersion>
<groupId>org.openautonomousconnection</groupId>
<artifactId>WebClient</artifactId>
<version>1.0.0-BETA.1.0</version>
<version>1.0.1-BETA.0.3</version>
<description>The default WebClient</description>
<url>https://open-autonomous-connection.org/</url>
<issueManagement>
@@ -67,6 +67,13 @@
<artifactId>maven-compiler-plugin</artifactId>
<version>3.13.0</version>
<configuration>
<annotationProcessorPaths>
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.42</version>
</path>
</annotationProcessorPaths>
<compilerArgs>
<arg>--add-exports</arg>
<arg>java.base/sun.security.x509=ALL-UNNAMED</arg>
@@ -109,13 +116,13 @@
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.38</version>
<version>1.18.42</version>
<scope>provided</scope>
</dependency>
</dependencies>
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<maven.compiler.target>23</maven.compiler.target>
<maven.compiler.source>23</maven.compiler.source>
<maven.compiler.target>25</maven.compiler.target>
<maven.compiler.source>25</maven.compiler.source>
</properties>
</project>

24
pom.xml
View File

@@ -6,7 +6,7 @@
<groupId>org.openautonomousconnection</groupId>
<artifactId>WebClient</artifactId>
<version>1.0.0-BETA.1.0</version>
<version>1.0.1-BETA.0.3</version>
<organization>
<name>Open Autonomous Connection</name>
<url>https://open-autonomous-connection.org/</url>
@@ -15,8 +15,8 @@
<description>The default WebClient</description>
<properties>
<maven.compiler.source>23</maven.compiler.source>
<maven.compiler.target>23</maven.compiler.target>
<maven.compiler.source>25</maven.compiler.source>
<maven.compiler.target>25</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
@@ -69,22 +69,22 @@
<dependency>
<groupId>org.openautonomousconnection</groupId>
<artifactId>OACSwing</artifactId>
<version>1.0.0-BETA.1.1</version>
<version>0.0.0-STABLE.1.3</version>
</dependency>
<dependency>
<groupId>org.openautonomousconnection</groupId>
<artifactId>LuaScript</artifactId>
<version>1.0.0-BETA.1.1</version>
<version>0.0.0-STABLE.1.3</version>
</dependency>
<dependency>
<groupId>org.openautonomousconnection</groupId>
<artifactId>InfoNameLib</artifactId>
<version>1.0.0-BETA.1.3</version>
<artifactId>Protocol</artifactId>
<version>1.0.1-BETA.0.5</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.38</version>
<version>1.18.42</version>
<scope>provided</scope>
</dependency>
<dependency>
@@ -135,6 +135,14 @@
<artifactId>maven-compiler-plugin</artifactId>
<version>3.13.0</version>
<configuration>
<annotationProcessorPaths>
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.42</version>
</path>
</annotationProcessorPaths>
<compilerArgs>
<arg>--add-exports</arg>
<arg>java.base/sun.security.x509=ALL-UNNAMED</arg>

View File

@@ -1,93 +1,171 @@
package org.openautonomousconnection.webclient;
import dev.unlegitdqrk.unlegitlibrary.event.Listener;
import org.openautonomousconnection.infonamelib.LibClientImpl;
import org.openautonomousconnection.infonamelib.OacWebUrlInstaller;
import dev.unlegitdqrk.unlegitlibrary.network.system.client.events.packets.C_PacketReadEvent;
import lombok.Getter;
import org.openautonomousconnection.oacswing.component.OACOptionPane;
import org.openautonomousconnection.protocol.packets.v1_0_1.beta.web.WebPacket;
import org.openautonomousconnection.protocol.packets.v1_0_1.beta.web.impl.navigate.WebNavigateAckPacket;
import org.openautonomousconnection.protocol.packets.v1_0_1.beta.web.impl.resource.WebResourceResponsePacket;
import org.openautonomousconnection.protocol.side.client.ProtocolClient;
import org.openautonomousconnection.protocol.side.client.ProtocolWebClient;
import org.openautonomousconnection.protocol.side.client.events.ConnectedToProtocolINSServerEvent;
import org.openautonomousconnection.protocol.urlhandler.v1_0_1.beta.LibClientImpl_v1_0_1_B;
import org.openautonomousconnection.protocol.urlhandler.v1_0_1.beta.web.WebFlagInspector;
import org.openautonomousconnection.protocol.urlhandler.v1_0_1.beta.web.WebRequestContextProvider;
import org.openautonomousconnection.protocol.versions.v1_0_1.beta.WebPacketHeader;
import org.openautonomousconnection.webclient.ui.BrowserTab;
import java.awt.*;
import java.io.ByteArrayOutputStream;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicBoolean;
/**
* Protocol client implementation for the WebClient.
* WebClient Protocol implementation (v1.0.1).
*
* <p>Implements full stream assembly with strict correlation via:
* requestId + tabId + pageId + frameId.</p>
*/
public class ClientImpl extends ProtocolClient {
public final class ClientImpl extends ProtocolWebClient {
private static final long MAX_STREAM_BYTES = 64L * 1024L * 1024L; // 64MB safety cap
private static final int MAX_CONCURRENT_STREAMS = 256;
@Getter
private final LibImpl libImpl = new LibImpl();
private final AtomicBoolean connectedInitialized = new AtomicBoolean(false);
private final AtomicBoolean serverConnectionInitialized = 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");
this.onServerReady = Objects.requireNonNull(onServerReady);
}
@Override
public boolean trustINS(String caFingerprint) {
Object[] options = {"Continue", "Cancel"};
int result = OACOptionPane.showOptionDialog(
return OACOptionPane.showOptionDialog(
dialogParent,
"You never connected to this INS before!\n" +
"Fingerprint: " + caFingerprint + "\nDo you want to connect?",
"Fingerprint: " + caFingerprint + "\nContinue?",
"INS Connection",
OACOptionPane.YES_NO_OPTION,
OACOptionPane.INFORMATION_MESSAGE,
null,
options,
options[0]
);
return result == 0;
) == 0;
}
@Override
public boolean trustNewINSFingerprint(String oldCAFingerprint, String newCAFingerprint) {
Object[] options = {"Continue", "Cancel"};
int result = OACOptionPane.showOptionDialog(
return OACOptionPane.showOptionDialog(
dialogParent,
"The fingerprint does not match with the saved fingerprint!\n" +
"Saved Fingerprint: " + oldCAFingerprint + "\n" +
"New Fingerprint: " + newCAFingerprint + "\n" +
"Do you want to connect?",
"Saved: " + oldCAFingerprint + "\nNew: " + newCAFingerprint + "\nContinue?",
"INS Connection",
OACOptionPane.YES_NO_OPTION,
OACOptionPane.INFORMATION_MESSAGE,
null,
options,
options[0]
);
return result == 0;
) == 0;
}
@Listener
public void onConnected(ConnectedToProtocolINSServerEvent event) {
try {
if (serverConnectionInitialized.compareAndSet(false, true)) {
buildServerConnection(null, getProtocolBridge().getProtocolValues().ssl);
OacWebUrlInstaller.installOnce(getProtocolBridge().getProtocolValues().eventManager, this, libImpl);
if (connectedInitialized.compareAndSet(false, true)) {
onServerReady.run();
}
} catch (Exception e) {
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
);
serverConnectionInitialized.set(false);
throw new RuntimeException(e);
}
}
private class LibImpl extends LibClientImpl {
private record StreamKey(long requestId, long tabId, long pageId, long frameId) {
StreamKey(WebPacketHeader h) {
this(h.getRequestId(), h.getTabId(), h.getPageId(), h.getFrameId());
}
}
private static final class StreamState {
private final int statusCode;
private final String contentType;
private final Map<String, String> headers;
private final long declaredLength;
private final ByteArrayOutputStream buffer = new ByteArrayOutputStream();
private int expectedSeq = 0;
private long written = 0;
private boolean ended = false;
private boolean ok = true;
StreamState(int statusCode,
String contentType,
Map<String, String> headers,
long declaredLength) {
this.statusCode = statusCode;
this.contentType = contentType == null ? "application/octet-stream" : contentType;
this.headers = headers == null ? Map.of() : Map.copyOf(headers);
this.declaredLength = declaredLength;
}
void append(int seq, byte[] data) {
if (ended) throw new IllegalStateException("Chunk after end");
if (seq != expectedSeq) throw new IllegalStateException("Out-of-order chunk");
expectedSeq++;
if (data == null || data.length == 0) return;
written += data.length;
if (written > MAX_STREAM_BYTES)
throw new IllegalStateException("Stream exceeds limit");
buffer.writeBytes(data);
}
void markEnd(boolean ok, String error) {
this.ended = true;
this.ok = ok;
}
byte[] finish() {
if (!ok) return new byte[0];
byte[] data = buffer.toByteArray();
if (declaredLength > 0 && data.length != declaredLength) {
// tolerated but can log if needed
}
return data;
}
}
public final class LibImpl extends LibClientImpl_v1_0_1_B {
private final WebRequestContextProvider provider = new WebRequestContextProvider.Default();
private final WebFlagInspector inspector = new WebFlagInspector.Default();
private final ConcurrentHashMap<StreamKey, StreamState> streams = new ConcurrentHashMap<>();
private BrowserTab currentTab;
@Override
public void serverConnectionFailed(Exception exception) {
getProtocolBridge().getLogger().exception("Failed to connect to server", exception);
getProtocolBridge().getProtocolValues().logger.exception("Failed to connect to server", exception);
OACOptionPane.showMessageDialog(
dialogParent,
"Failed to connect to Server:\n" + exception.getMessage(),
@@ -95,5 +173,77 @@ public class ClientImpl extends ProtocolClient {
OACOptionPane.ERROR_MESSAGE
);
}
public void bindTab(BrowserTab tab) {
this.currentTab = tab;
}
@Override
public boolean isStream(WebPacketHeader header) {
return inspector.isStream(header);
}
@Override
public WebRequestContext contextFor(URL url) {
return provider.contextFor(url);
}
@Override
public void streamStart(WebPacketHeader header,
int statusCode,
String contentType,
Map<String, String> headers,
long totalLength) {
if (streams.size() >= MAX_CONCURRENT_STREAMS) {
throw new IllegalStateException("Too many concurrent streams");
}
StreamKey key = new StreamKey(header);
streams.put(key, new StreamState(statusCode, contentType, headers, totalLength));
}
@Override
public void streamChunk(WebPacketHeader header, int seq, byte[] data) {
StreamState state = streams.get(new StreamKey(header));
if (state == null) {
throw new IllegalStateException("Chunk without streamStart");
}
state.append(seq, data);
}
@Override
public void streamEnd(WebPacketHeader header, boolean ok, String error) {
StreamState state = streams.get(new StreamKey(header));
if (state != null) {
state.markEnd(ok, error);
}
}
@Override
public void streamFinish(WebPacketHeader header, byte[] ignored) {
StreamKey key = new StreamKey(header);
StreamState state = streams.remove(key);
if (state == null) return;
byte[] content = state.finish();
if (currentTab != null) {
currentTab.handleStreamFinished(
header.getRequestId(),
header.getTabId(),
header.getPageId(),
header.getFrameId(),
state.statusCode,
state.contentType,
state.headers,
content
);
}
}
}
}

View File

@@ -1,6 +1,8 @@
package org.openautonomousconnection.webclient;
import dev.unlegitdqrk.unlegitlibrary.addon.AddonLoader;
import dev.unlegitdqrk.unlegitlibrary.event.EventManager;
import dev.unlegitdqrk.unlegitlibrary.utils.Logger;
import org.openautonomousconnection.oacswing.component.design.Design;
import org.openautonomousconnection.oacswing.component.design.DesignManager;
import org.openautonomousconnection.webclient.settings.AppSettings;
@@ -8,6 +10,7 @@ import org.openautonomousconnection.webclient.settings.SettingsManager;
import org.openautonomousconnection.webclient.ui.BrowserUI;
import javax.swing.*;
import java.io.File;
import java.io.IOException;
import java.net.CookieHandler;
import java.net.CookieManager;
@@ -23,6 +26,8 @@ public class Main {
private static AppSettings settings;
private static AddonLoader addonLoader;
private static Logger logger;
private static EventManager eventManager;
public static BrowserUI getUi() {
return ui;
@@ -32,6 +37,14 @@ public class Main {
return settings;
}
public static AddonLoader getAddonLoader() {
return addonLoader;
}
public static Logger getLogger() {
return logger;
}
private static void installDefaultCookieManager() {
if (CookieHandler.getDefault() != null) return;
@@ -40,7 +53,13 @@ public class Main {
CookieHandler.setDefault(cm);
}
public static void main(String[] args) throws IOException {
static void main(String[] args) throws IOException, NoSuchFieldException, IllegalAccessException {
File logsFolder = new File("logs");
if (!logsFolder.exists()) logsFolder.mkdir();
eventManager = new EventManager();
logger = new Logger(logsFolder, false, true);
addonLoader = new AddonLoader(eventManager, logger);
settings = SettingsManager.load();
FxBootstrap.ensureInitialized();

View File

@@ -15,26 +15,26 @@ public class WebLogger {
}
public void log(String string) {
client.getProtocolBridge().getLogger().log(host + ": " + string);
client.getProtocolBridge().getProtocolValues().logger.log(host + ": " + string);
}
public void info(String info) {
client.getProtocolBridge().getLogger().info(host + ": " + info);
client.getProtocolBridge().getProtocolValues().logger.info(host + ": " + info);
}
public void warn(String warn) {
client.getProtocolBridge().getLogger().warn(host + ": " + warn);
client.getProtocolBridge().getProtocolValues().logger.warn(host + ": " + warn);
}
public void error(String error) {
client.getProtocolBridge().getLogger().error(host + ": " + error);
client.getProtocolBridge().getProtocolValues().logger.error(host + ": " + error);
}
public void exception(String infoLine, Exception exception) {
client.getProtocolBridge().getLogger().exception(host + ": " + infoLine, exception);
client.getProtocolBridge().getProtocolValues().logger.exception(host + ": " + infoLine, exception);
}
public void debug(String debug) {
client.getProtocolBridge().getLogger().debug(host + ": " + debug);
client.getProtocolBridge().getProtocolValues().logger.debug(host + ": " + debug);
}
}

View File

@@ -5,8 +5,11 @@ import javafx.scene.web.WebView;
import org.openautonomousconnection.luascript.fx.FxDomHost;
import org.openautonomousconnection.luascript.fx.FxThreadBridge;
import org.openautonomousconnection.luascript.hosts.UiHost;
import org.openautonomousconnection.oacswing.component.OACOptionPane;
import org.openautonomousconnection.webclient.Main;
import org.w3c.dom.Element;
import javax.swing.*;
import java.util.Objects;
/**
@@ -66,23 +69,17 @@ public final class UiHostImpl implements UiHost {
@Override
public void alert(String message) {
// No JS: use simple JavaFX dialog-less fallback (log-style). You can replace with real Dialogs later.
// Keeping it deterministic and non-blocking for now.
System.out.println("[ui.alert] " + (message == null ? "" : message));
OACOptionPane.showMessageDialog(Main.getUi(), message, "Alert", JOptionPane.INFORMATION_MESSAGE);
}
@Override
public boolean confirm(String message) {
// No JS: deterministic default (false). Replace with JavaFX dialogs if you want UI interaction.
System.out.println("[ui.confirm] " + (message == null ? "" : message));
return false;
return OACOptionPane.showConfirmDialog(Main.getUi(), message, "Alert", OACOptionPane.YES_NO_OPTION) == OACOptionPane.YES_OPTION;
}
@Override
public String prompt(String message, String defaultValue) {
// No JS: deterministic default.
System.out.println("[ui.prompt] " + (message == null ? "" : message));
return defaultValue;
return (String) OACOptionPane.showInputDialog(Main.getUi(), message, "Prompt", JOptionPane.QUESTION_MESSAGE, null, null, defaultValue);
}
@Override

View File

@@ -2,7 +2,6 @@ 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;
@@ -187,6 +186,6 @@ public final class AppSettings {
* Resets the Lua policy back to ui default.
*/
public void resetLuaPolicyToUiDefault() {
this.luaPolicy = new LuaExecutionPolicy(Duration.ofMillis(50L), 200_000L, 5_000);
this.luaPolicy = LuaExecutionPolicy.uiDefault();
}
}

View File

@@ -4,6 +4,7 @@ import javafx.concurrent.Worker;
import javafx.scene.web.WebEngine;
import javafx.scene.web.WebView;
import org.luaj.vm2.Globals;
import org.luaj.vm2.LuaError;
import org.openautonomousconnection.luascript.fx.FxDomHost;
import org.openautonomousconnection.luascript.fx.FxEventHost;
import org.openautonomousconnection.luascript.fx.FxWebViewResourceHost;
@@ -11,6 +12,8 @@ import org.openautonomousconnection.luascript.hosts.HostServices;
import org.openautonomousconnection.luascript.runtime.LuaRuntime;
import org.openautonomousconnection.luascript.security.LuaExecutionPolicy;
import org.openautonomousconnection.luascript.utils.LuaGlobalsFactory;
import org.openautonomousconnection.oacswing.component.OACOptionPane;
import org.openautonomousconnection.webclient.Main;
import org.openautonomousconnection.webclient.lua.WebLogger;
import org.openautonomousconnection.webclient.lua.hosts.ConsoleHostImpl;
import org.openautonomousconnection.webclient.lua.hosts.UiHostImpl;
@@ -95,17 +98,19 @@ public final class FxEngine implements AutoCloseable {
ConsoleHostImpl console = new ConsoleHostImpl(logger);
UiHostImpl uiHost = new UiHostImpl(engine, webView, dom);
FxWebViewResourceHost resourceHost = new FxWebViewResourceHost(engine);
LuaRuntime rt = new LuaRuntime(globals, new HostServices.Default(uiHost, dom, null, resourceHost, console), policy);
FxEventHost eventHost = new FxEventHost(dom, rt.eventRouter());
FxEventHost eventHost = new FxEventHost(dom);
HostServices services = new HostServices.Default(uiHost, dom, eventHost, resourceHost, console);
rt.close();
rt = new LuaRuntime(globals, services, policy);
LuaRuntime rt = new LuaRuntime(globals, services, policy);
eventHost.setRouter(rt.eventRouter());
rt.installStdTables(true);
try {
rt.bootstrapFromDom();
} catch (LuaError error) {
Main.getLogger().exception("Failed to start script", error);
OACOptionPane.showMessageDialog(Main.getUi(), error.getMessage(), "Script", OACOptionPane.ERROR_MESSAGE);
}
this.runtime = rt;
}

View File

@@ -48,8 +48,8 @@ public record InsEndpoint(String host, int port) {
@Override
public boolean equals(Object o) {
if (!(o instanceof InsEndpoint other)) return false;
return host.equalsIgnoreCase(other.host) && port == other.port;
if (!(o instanceof InsEndpoint(String host1, int port1))) return false;
return host.equalsIgnoreCase(host1) && port == port1;
}
@Override

View File

@@ -87,9 +87,10 @@ public final class SettingsManager {
}
// 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);
LuaExecutionPolicy defaultPolicy = LuaExecutionPolicy.uiDefault();
long timeoutMs = parseLong(p.getProperty("lua.timeoutMs"), defaultPolicy.timeout().toMillis());
long instr = parseLong(p.getProperty("lua.instructionLimit"), defaultPolicy.instructionLimit());
int hook = parseInt(p.getProperty("lua.hookStep"), defaultPolicy.hookStep());
try {
s.setLuaPolicy(new LuaExecutionPolicy(Duration.ofMillis(timeoutMs), instr, hook));
} catch (Exception ignored) {

View File

@@ -26,7 +26,16 @@ import javax.xml.transform.TransformerFactory;
import javax.xml.transform.dom.DOMSource;
import javax.xml.transform.stream.StreamResult;
import java.awt.*;
import java.io.File;
import java.io.IOException;
import java.io.StringWriter;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.time.Instant;
import java.util.Base64;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.atomic.AtomicBoolean;
@@ -64,8 +73,14 @@ public final class BrowserTab extends OACPanel {
* @param onLocationChange callback invoked on URL changes
* @param luaEnabled whether Lua is enabled for this tab
* @param luaPolicy execution policy for Lua
* @param protocolClient protocol client used for OAC network requests
*/
public BrowserTab(String key, String initialUrl, Consumer<String> onLocationChange, boolean luaEnabled, LuaExecutionPolicy luaPolicy, ClientImpl protocolClient) {
public BrowserTab(String key,
String initialUrl,
Consumer<String> onLocationChange,
boolean luaEnabled,
LuaExecutionPolicy luaPolicy,
ClientImpl protocolClient) {
super();
this.key = Objects.requireNonNull(key, "key");
this.onLocationChanged = Objects.requireNonNull(onLocationChange, "onLocationChange");
@@ -89,6 +104,108 @@ public final class BrowserTab extends OACPanel {
return sw.toString();
}
private static String normalizeMime(String contentType) {
String ct = (contentType == null || contentType.isBlank()) ? "application/octet-stream" : contentType.trim();
int semi = ct.indexOf(';');
String base = (semi >= 0 ? ct.substring(0, semi) : ct).trim();
return base.isEmpty() ? "application/octet-stream" : base;
}
private static boolean isHtml(String contentType) {
String ct = normalizeMime(contentType).toLowerCase(Locale.ROOT);
return ct.equals("text/html") || ct.equals("application/xhtml+xml");
}
private static boolean isText(String contentType) {
String ct = normalizeMime(contentType).toLowerCase(Locale.ROOT);
return ct.startsWith("text/") || ct.equals("application/json") || ct.equals("application/xml") || ct.endsWith("+json") || ct.endsWith("+xml");
}
private static boolean isImage(String contentType) {
String ct = normalizeMime(contentType).toLowerCase(Locale.ROOT);
return ct.startsWith("image/");
}
private static boolean isPdf(String contentType) {
String ct = normalizeMime(contentType).toLowerCase(Locale.ROOT);
return ct.equals("application/pdf");
}
private static Charset charsetFromContentType(String contentType, Charset def) {
if (contentType == null) return def;
String[] parts = contentType.split(";");
for (String p : parts) {
String s = p.trim();
if (s.toLowerCase(Locale.ROOT).startsWith("charset=")) {
String name = s.substring("charset=".length()).trim();
try {
return Charset.forName(name);
} catch (Exception ignored) {
return def;
}
}
}
return def;
}
private static String extractFilenameFromContentDisposition(Map<String, String> headers) {
if (headers == null || headers.isEmpty()) return null;
String cd = null;
for (Map.Entry<String, String> e : headers.entrySet()) {
if (e.getKey() != null && e.getKey().equalsIgnoreCase("content-disposition")) {
cd = e.getValue();
break;
}
}
if (cd == null || cd.isBlank()) return null;
String lower = cd.toLowerCase(Locale.ROOT);
int fn = lower.indexOf("filename=");
if (fn < 0) return null;
String v = cd.substring(fn + "filename=".length()).trim();
if (v.startsWith("\"")) {
int end = v.indexOf('"', 1);
if (end > 1) return v.substring(1, end);
return null;
}
int semi = v.indexOf(';');
if (semi >= 0) v = v.substring(0, semi).trim();
return v.isBlank() ? null : v;
}
private static String sanitizeFilename(String name) {
String s = name.replace('\\', '_').replace('/', '_');
s = s.replace("..", "_");
s = s.replace(':', '_').replace('*', '_').replace('?', '_').replace('"', '_')
.replace('<', '_').replace('>', '_').replace('|', '_');
return s.isBlank() ? "download.bin" : s;
}
private static String extensionFromContentType(String contentType) {
String ct = normalizeMime(contentType).toLowerCase(Locale.ROOT);
if (ct.equals("application/pdf")) return ".pdf";
if (ct.equals("application/zip")) return ".zip";
if (ct.equals("application/x-7z-compressed")) return ".7z";
if (ct.equals("application/x-rar-compressed")) return ".rar";
if (ct.equals("application/gzip")) return ".gz";
if (ct.equals("application/json")) return ".json";
if (ct.equals("application/xml") || ct.endsWith("+xml")) return ".xml";
if (ct.startsWith("image/")) {
int slash = ct.indexOf('/');
if (slash > 0 && slash < ct.length() - 1) {
String ext = ct.substring(slash + 1).trim();
if (!ext.isEmpty()) return "." + ext;
}
}
if (ct.startsWith("text/")) return ".txt";
return ".bin";
}
/**
* Returns the stable tab key.
*
@@ -98,6 +215,11 @@ public final class BrowserTab extends OACPanel {
return key;
}
/**
* Returns the protocol client used by this tab.
*
* @return protocol client
*/
public ClientImpl getProtocolClient() {
return protocolClient;
}
@@ -214,6 +336,8 @@ public final class BrowserTab extends OACPanel {
return getEngineLocation();
}
// -------------------- Stream render/save helpers --------------------
/**
* Returns current engine location.
*
@@ -270,6 +394,78 @@ public final class BrowserTab extends OACPanel {
}
}
/**
* Receives a fully assembled stream payload from the protocol layer and renders or saves it.
*
* <p>Rules:
* <ul>
* <li>Renderable: HTML/text/images/pdf are rendered directly (no wrapper pages for non-html).</li>
* <li>Not renderable: opens "Save As" dialog.</li>
* <li>If user cancels "Save As": content is shown raw in the browser via data: URL.</li>
* </ul>
*
* @param requestId request correlation id
* @param tabId protocol tab id
* @param pageId protocol page id
* @param frameId protocol frame id
* @param statusCode http-like status code
* @param contentType mime type
* @param headers response headers
* @param content full payload bytes
*/
public void handleStreamFinished(long requestId,
long tabId,
long pageId,
long frameId,
int statusCode,
String contentType,
Map<String, String> headers,
byte[] content) {
String ct = (contentType == null || contentType.isBlank())
? "application/octet-stream"
: contentType.trim();
byte[] data = (content == null) ? new byte[0] : content;
// ---- Renderable types -> render without extra wrapper pages ----
if (isHtml(ct)) {
Charset cs = charsetFromContentType(ct, StandardCharsets.UTF_8);
String html = new String(data, cs);
Platform.runLater(() -> {
WebEngine e = engine;
if (e != null) e.loadContent(html, "text/html");
});
return;
}
if (isText(ct)) {
Charset cs = charsetFromContentType(ct, StandardCharsets.UTF_8);
String text = new String(data, cs);
Platform.runLater(() -> {
WebEngine e = engine;
if (e != null) e.loadContent(text, "text/plain");
});
return;
}
if (isImage(ct) || isPdf(ct)) {
renderRawDataUrl(ct, data);
return;
}
// ---- Not renderable -> Save As; if cancelled -> raw data: URL ----
String suggested = extractFilenameFromContentDisposition(headers);
if (suggested == null || suggested.isBlank()) {
String ext = extensionFromContentType(ct);
suggested = "download_" + requestId + "_" + Instant.now().toEpochMilli() + ext;
} else {
suggested = sanitizeFilename(suggested);
}
showSaveAsDialogAndWriteBytes(suggested, ct, data);
}
/**
* Releases resources.
*/
@@ -377,4 +573,75 @@ public final class BrowserTab extends OACPanel {
// Best-effort shutdown.
}
}
private void showSaveAsDialogAndWriteBytes(String suggestedFilename, String contentType, byte[] data) {
SwingUtilities.invokeLater(() -> {
Window parent = SwingUtilities.getWindowAncestor(this);
JFileChooser chooser = new JFileChooser();
chooser.setDialogTitle("Save As");
chooser.setSelectedFile(new File(suggestedFilename));
int result = chooser.showSaveDialog(parent);
if (result != JFileChooser.APPROVE_OPTION) {
// Cancel -> show raw in browser
renderRawDataUrl(contentType, data);
return;
}
File file = chooser.getSelectedFile();
if (file == null) {
renderRawDataUrl(contentType, data);
return;
}
if (file.isDirectory()) {
JOptionPane.showMessageDialog(parent, "Please choose a file, not a directory.", "Save As", JOptionPane.WARNING_MESSAGE);
renderRawDataUrl(contentType, data);
return;
}
if (file.exists()) {
int overwrite = JOptionPane.showConfirmDialog(
parent,
"File already exists. Overwrite?\n" + file.getAbsolutePath(),
"Confirm Overwrite",
JOptionPane.YES_NO_OPTION,
JOptionPane.WARNING_MESSAGE
);
if (overwrite != JOptionPane.YES_OPTION) {
renderRawDataUrl(contentType, data);
return;
}
}
try {
Files.write(file.toPath(), data);
} catch (IOException ex) {
JOptionPane.showMessageDialog(
parent,
"Failed to save file:\n" + ex.getMessage(),
"Save As",
JOptionPane.ERROR_MESSAGE
);
// On failure, still show raw so user can at least see bytes
renderRawDataUrl(contentType, data);
}
});
}
private void renderRawDataUrl(String contentType, byte[] data) {
String mime = normalizeMime(contentType);
String b64 = Base64.getEncoder().encodeToString(data == null ? new byte[0] : data);
String dataUrl = "data:" + mime + ";base64," + b64;
Platform.runLater(() -> {
WebEngine e = engine;
if (e != null) e.load(dataUrl);
});
}
public void bindProtocolClient() {
protocolClient.getLibImpl().bindTab(this);
}
}

View File

@@ -3,13 +3,13 @@ package org.openautonomousconnection.webclient.ui;
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.Main;
import org.openautonomousconnection.webclient.settings.AppSettings;
import org.openautonomousconnection.webclient.settings.HistoryStore;
import org.openautonomousconnection.webclient.settings.InsEndpoint;
@@ -231,6 +231,8 @@ public class BrowserUI extends OACFrame {
readyTab.loadUrl(normalized);
}));
connectTabClient(key, client);
BrowserTab tab = new BrowserTab(
key,
normalized,
@@ -247,6 +249,9 @@ public class BrowserUI extends OACFrame {
client
);
// Bind stream callbacks to this tab (v1.0.1 stream assembly -> tab render).
tab.bindProtocolClient();
tabRef.set(tab);
tab.setOpenInNewTabCallback(() -> openNewTab(
tab.getEngineLocation() == null ? settings.getStartPageUrl() : tab.getEngineLocation()
@@ -272,41 +277,37 @@ public class BrowserUI extends OACFrame {
cardLayout.show(pageHost, key);
browser.addressField().setText(normalized);
connectTabClient(key, client);
}
private void connectTabClient(String key, ClientImpl client) {
try {
File logsFolder = new File("logs");
if (!logsFolder.exists()) logsFolder.mkdir();
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));
values.logger = Main.getLogger();
values.addonLoader = Main.getAddonLoader();
values.protocolVersion = ProtocolVersion.PV_1_0_1_BETA;
AddonLoader addonLoader = values.addonLoader;
ProtocolBridge bridge = new ProtocolBridge(
client,
values,
ProtocolVersion.PV_1_0_0_BETA,
new File(logsFolder, "client")
client.getLibImpl(),
values
);
protocolByKey.put(key, bridge);
addonLoaderByKey.put(key, addonLoader);
File addonsFolder = new File("addons");
if (!addonsFolder.exists()) addonsFolder.mkdir();
addonLoader.loadAddonsFromDirectory(addonsFolder);
client.buildINSConnection();
bridge.getProtocolValues().eventManager.registerListener(client);
InsEndpoint ep = Objects.requireNonNull(settings.getSelectedIns(), "selectedIns");
client.getClientINSConnection().connect(ep.host(), ep.port());
File addonsFolder = new File("addons");
if (!addonsFolder.exists()) addonsFolder.mkdir();
addonLoader.loadAddonsFromDirectory(addonsFolder);
} catch (Exception e) {
OACOptionPane.showMessageDialog(
this,
@@ -351,12 +352,6 @@ public class BrowserUI extends OACFrame {
}
}
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;
@@ -417,12 +412,9 @@ public class BrowserUI extends OACFrame {
if (idx < 0) return;
if (plusTabSupport.isPlusTab(idx)) {
if (suppressPlusAutoOpen) {
return;
}
if (tabsByKey.isEmpty()) {
return;
}
if (suppressPlusAutoOpen) return;
if (tabsByKey.isEmpty()) return;
handlingTabSwitch = true;
try {
int fallback = lastSelectedRealTab >= 0
@@ -452,12 +444,22 @@ public class BrowserUI extends OACFrame {
}
}
/**
* Returns the currently active tab (excluding the plus-tab).
*
* @return current tab or null
*/
public BrowserTab getCurrentTab() {
String key = getSelectedTabKey();
if (key == null) return null;
return tabsByKey.get(key);
}
/**
* Returns the addon loader associated with the current tab.
*
* @return addon loader or null
*/
public AddonLoader getCurrentAddonLoader() {
String key = getSelectedTabKey();
if (key == null) return null;