From d715c0758dbf5a646dc271201810b5623fbdb0cd Mon Sep 17 00:00:00 2001 From: UnlegitDqrk Date: Sun, 22 Feb 2026 16:00:57 +0100 Subject: [PATCH] Implemented InfoNameLib --- pom.xml | 2 +- .../protocol/ProtocolBridge.java | 16 +- .../v1_0_0/beta/LibClientImpl_v1_0_0_B.java | 15 + .../urlhandler/v1_0_0/beta/OacSessionJar.java | 29 + .../v1_0_0/beta/OacWebHttpURLConnection.java | 288 +++++++++ .../v1_0_0/beta/OacWebPacketListener.java | 153 +++++ .../v1_0_0/beta/OacWebRequestBroker.java | 529 ++++++++++++++++ .../v1_0_0/beta/OacWebResponse.java | 32 + .../beta/OacWebUrlInstaller_v1_0_0_B.java | 32 + .../v1_0_0/beta/ProtocolHandlerPackages.java | 42 ++ .../urlhandler/v1_0_0/beta/web/Handler.java | 22 + .../v1_0_1/beta/LibClientImpl_v1_0_1_B.java | 55 ++ .../beta/OacUrlHandlerInstaller_v1_0_1_B.java | 50 ++ .../v1_0_1/beta/OacUrlProtocolModule.java | 38 ++ .../v1_0_1/beta/OacUrlProtocolRegistry.java | 52 ++ .../v1_0_1/beta/ProtocolHandlerPackages.java | 44 ++ .../beta/web/DeleteOnCloseInputStream.java | 71 +++ .../urlhandler/v1_0_1/beta/web/Handler.java | 26 + .../v1_0_1/beta/web/OacSessionJar.java | 26 + .../beta/web/OacWebHttpURLConnection.java | 270 ++++++++ .../v1_0_1/beta/web/OacWebPacketListener.java | 71 +++ .../web/OacWebProtocolModule_v1_0_1_B.java | 51 ++ .../v1_0_1/beta/web/OacWebRequestBroker.java | 576 ++++++++++++++++++ .../v1_0_1/beta/web/OacWebResponse.java | 32 + .../v1_0_1/beta/web/WebFlagInspector.java | 30 + .../beta/web/WebRequestContextProvider.java | 37 ++ .../v1_0_1/beta/compat/WebCompatMapper.java | 3 +- 27 files changed, 2589 insertions(+), 3 deletions(-) create mode 100644 src/main/java/org/openautonomousconnection/protocol/urlhandler/v1_0_0/beta/LibClientImpl_v1_0_0_B.java create mode 100644 src/main/java/org/openautonomousconnection/protocol/urlhandler/v1_0_0/beta/OacSessionJar.java create mode 100644 src/main/java/org/openautonomousconnection/protocol/urlhandler/v1_0_0/beta/OacWebHttpURLConnection.java create mode 100644 src/main/java/org/openautonomousconnection/protocol/urlhandler/v1_0_0/beta/OacWebPacketListener.java create mode 100644 src/main/java/org/openautonomousconnection/protocol/urlhandler/v1_0_0/beta/OacWebRequestBroker.java create mode 100644 src/main/java/org/openautonomousconnection/protocol/urlhandler/v1_0_0/beta/OacWebResponse.java create mode 100644 src/main/java/org/openautonomousconnection/protocol/urlhandler/v1_0_0/beta/OacWebUrlInstaller_v1_0_0_B.java create mode 100644 src/main/java/org/openautonomousconnection/protocol/urlhandler/v1_0_0/beta/ProtocolHandlerPackages.java create mode 100644 src/main/java/org/openautonomousconnection/protocol/urlhandler/v1_0_0/beta/web/Handler.java create mode 100644 src/main/java/org/openautonomousconnection/protocol/urlhandler/v1_0_1/beta/LibClientImpl_v1_0_1_B.java create mode 100644 src/main/java/org/openautonomousconnection/protocol/urlhandler/v1_0_1/beta/OacUrlHandlerInstaller_v1_0_1_B.java create mode 100644 src/main/java/org/openautonomousconnection/protocol/urlhandler/v1_0_1/beta/OacUrlProtocolModule.java create mode 100644 src/main/java/org/openautonomousconnection/protocol/urlhandler/v1_0_1/beta/OacUrlProtocolRegistry.java create mode 100644 src/main/java/org/openautonomousconnection/protocol/urlhandler/v1_0_1/beta/ProtocolHandlerPackages.java create mode 100644 src/main/java/org/openautonomousconnection/protocol/urlhandler/v1_0_1/beta/web/DeleteOnCloseInputStream.java create mode 100644 src/main/java/org/openautonomousconnection/protocol/urlhandler/v1_0_1/beta/web/Handler.java create mode 100644 src/main/java/org/openautonomousconnection/protocol/urlhandler/v1_0_1/beta/web/OacSessionJar.java create mode 100644 src/main/java/org/openautonomousconnection/protocol/urlhandler/v1_0_1/beta/web/OacWebHttpURLConnection.java create mode 100644 src/main/java/org/openautonomousconnection/protocol/urlhandler/v1_0_1/beta/web/OacWebPacketListener.java create mode 100644 src/main/java/org/openautonomousconnection/protocol/urlhandler/v1_0_1/beta/web/OacWebProtocolModule_v1_0_1_B.java create mode 100644 src/main/java/org/openautonomousconnection/protocol/urlhandler/v1_0_1/beta/web/OacWebRequestBroker.java create mode 100644 src/main/java/org/openautonomousconnection/protocol/urlhandler/v1_0_1/beta/web/OacWebResponse.java create mode 100644 src/main/java/org/openautonomousconnection/protocol/urlhandler/v1_0_1/beta/web/WebFlagInspector.java create mode 100644 src/main/java/org/openautonomousconnection/protocol/urlhandler/v1_0_1/beta/web/WebRequestContextProvider.java diff --git a/pom.xml b/pom.xml index 25bb5df..4e4144a 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ org.openautonomousconnection Protocol - 1.0.1-BETA.0.2 + 1.0.1-BETA.0.3 Open Autonomous Connection https://open-autonomous-connection.org/ diff --git a/src/main/java/org/openautonomousconnection/protocol/ProtocolBridge.java b/src/main/java/org/openautonomousconnection/protocol/ProtocolBridge.java index 82fd42e..b4a2b0f 100644 --- a/src/main/java/org/openautonomousconnection/protocol/ProtocolBridge.java +++ b/src/main/java/org/openautonomousconnection/protocol/ProtocolBridge.java @@ -32,6 +32,9 @@ import org.openautonomousconnection.protocol.side.client.ProtocolWebClient; import org.openautonomousconnection.protocol.side.ins.ProtocolINSServer; import org.openautonomousconnection.protocol.side.server.ProtocolCustomServer; import org.openautonomousconnection.protocol.side.web.ProtocolWebServer; +import org.openautonomousconnection.protocol.urlhandler.v1_0_0.beta.OacWebUrlInstaller_v1_0_0_B; +import org.openautonomousconnection.protocol.urlhandler.v1_0_1.beta.LibClientImpl_v1_0_1_B; +import org.openautonomousconnection.protocol.urlhandler.v1_0_1.beta.OacUrlHandlerInstaller_v1_0_1_B; import org.openautonomousconnection.protocol.versions.ProtocolVersion; import org.openautonomousconnection.protocol.versions.v1_0_0.beta.ProtocolWebServer_1_0_0_B; @@ -122,7 +125,7 @@ public final class ProtocolBridge { */ @ProtocolInfo(protocolSide = ProtocolVersion.ProtocolSide.CLIENT) public ProtocolBridge(ProtocolClient protocolClient, ProtocolValues protocolValues, ProtocolVersion protocolVersion, - Logger logger, AddonLoader addonLoader) throws Exception { + Logger logger, AddonLoader addonLoader, LibClientImpl_v1_0_1_B libClientImpl) throws Exception { // Assign the parameters to the class fields this.protocolClient = protocolClient; this.protocolValues = protocolValues; @@ -138,6 +141,17 @@ public final class ProtocolBridge { // Register the appropriate listeners and packets registerListeners(); registerPackets(); + installUrl(libClientImpl); + } + + private void installUrl(LibClientImpl_v1_0_1_B libClientImpl) { + if (protocolVersion == ProtocolVersion.PV_1_0_0_BETA) { + OacWebUrlInstaller_v1_0_0_B.installOnce(this, libClientImpl); + } + + if (protocolVersion == ProtocolVersion.PV_1_0_1_BETA) { + OacUrlHandlerInstaller_v1_0_1_B.installOnce(this, libClientImpl, libClientImpl, libClientImpl); + } } private void downloadLicenses() throws IOException { diff --git a/src/main/java/org/openautonomousconnection/protocol/urlhandler/v1_0_0/beta/LibClientImpl_v1_0_0_B.java b/src/main/java/org/openautonomousconnection/protocol/urlhandler/v1_0_0/beta/LibClientImpl_v1_0_0_B.java new file mode 100644 index 0000000..d09ff8c --- /dev/null +++ b/src/main/java/org/openautonomousconnection/protocol/urlhandler/v1_0_0/beta/LibClientImpl_v1_0_0_B.java @@ -0,0 +1,15 @@ +package org.openautonomousconnection.protocol.urlhandler.v1_0_0.beta; +/** + * Installs the "web://" protocol handler using java.protocol.handler.pkgs. + * Recommended to use Version v1.0.0-BETA or newer + */ +@Deprecated(forRemoval = false, since = "1.0.1-BETA.0.3") +public abstract class LibClientImpl_v1_0_0_B { + /** + * Called when connecting to the resolved server endpoint fails. + * + * @param exception the connection failure + */ + public abstract void serverConnectionFailed(Exception exception); + +} diff --git a/src/main/java/org/openautonomousconnection/protocol/urlhandler/v1_0_0/beta/OacSessionJar.java b/src/main/java/org/openautonomousconnection/protocol/urlhandler/v1_0_0/beta/OacSessionJar.java new file mode 100644 index 0000000..5cc2ec6 --- /dev/null +++ b/src/main/java/org/openautonomousconnection/protocol/urlhandler/v1_0_0/beta/OacSessionJar.java @@ -0,0 +1,29 @@ +package org.openautonomousconnection.protocol.urlhandler.v1_0_0.beta; + +/** + * Stores the current session token across multiple HttpURLConnection instances. + * + *

JavaFX WebView creates a new connection per navigation, so headers must be re-injected + * for every request.

+ */ +public final class OacSessionJar { + + private volatile String session; + + /** + * Stores a session token (e.g. from response header "session"). + * + * @param session session token + */ + public void store(String session) { + String s = (session == null) ? null : session.trim(); + this.session = (s == null || s.isEmpty()) ? null : s; + } + + /** + * @return stored session token or null + */ + public String get() { + return session; + } +} \ No newline at end of file diff --git a/src/main/java/org/openautonomousconnection/protocol/urlhandler/v1_0_0/beta/OacWebHttpURLConnection.java b/src/main/java/org/openautonomousconnection/protocol/urlhandler/v1_0_0/beta/OacWebHttpURLConnection.java new file mode 100644 index 0000000..b258a24 --- /dev/null +++ b/src/main/java/org/openautonomousconnection/protocol/urlhandler/v1_0_0/beta/OacWebHttpURLConnection.java @@ -0,0 +1,288 @@ +package org.openautonomousconnection.protocol.urlhandler.v1_0_0.beta; + +import org.openautonomousconnection.protocol.versions.v1_0_0.beta.WebRequestMethod; + +import java.io.*; +import java.net.HttpURLConnection; +import java.net.ProtocolException; +import java.net.URL; +import java.util.*; + +/** + * HttpURLConnection implementation that maps "web://" URLs to OAC WebRequestPacket/WebResponsePacket. + * + *

Important semantics for JavaFX WebView:

+ * + */ +public final class OacWebHttpURLConnection extends HttpURLConnection { + + private static final int MAX_REDIRECTS = 8; + + private final OacWebRequestBroker broker; + private final OacSessionJar sessionJar; + + private final Map requestHeaders = new LinkedHashMap<>(); + private final ByteArrayOutputStream requestBody = new ByteArrayOutputStream(1024); + + private boolean connected; + private boolean requestSent; + + private OacWebResponse response; + + /** + * Creates a new OAC HttpURLConnection. + * + * @param url the web:// URL + * @param broker request broker + * @param sessionJar shared session store (must be shared across connections) + */ + public OacWebHttpURLConnection(URL url, OacWebRequestBroker broker, OacSessionJar sessionJar) { + super(url); + this.broker = Objects.requireNonNull(broker, "broker"); + this.sessionJar = Objects.requireNonNull(sessionJar, "sessionJar"); + this.method = "GET"; + setInstanceFollowRedirects(true); + } + + private static String headerValue(Map headers, String nameLower) { + if (headers == null || headers.isEmpty() || nameLower == null) return null; + String needle = nameLower.trim().toLowerCase(Locale.ROOT); + for (Map.Entry e : headers.entrySet()) { + if (e.getKey() == null) continue; + if (e.getKey().trim().toLowerCase(Locale.ROOT).equals(needle)) { + return e.getValue(); + } + } + return null; + } + + @Override + public void setRequestProperty(String key, String value) { + if (key == null) return; + // DO NOT block after connect(): WebView may call connect() early. + // Only block once the request was actually sent. + if (requestSent) throw new IllegalStateException("Request already sent"); + if (value == null) requestHeaders.remove(key); + else requestHeaders.put(key, value); + } + + @Override + public String getRequestProperty(String key) { + if (key == null) return null; + for (Map.Entry e : requestHeaders.entrySet()) { + if (e.getKey() != null && e.getKey().equalsIgnoreCase(key)) return e.getValue(); + } + return null; + } + + @Override + public Map> getRequestProperties() { + Map> out = new LinkedHashMap<>(); + for (Map.Entry e : requestHeaders.entrySet()) { + out.put(e.getKey(), e.getValue() == null ? List.of() : List.of(e.getValue())); + } + return Collections.unmodifiableMap(out); + } + + @Override + public void setRequestMethod(String method) throws ProtocolException { + if (method == null) throw new ProtocolException("method is null"); + if (requestSent) throw new ProtocolException("Request already sent"); + + String m = method.trim().toUpperCase(Locale.ROOT); + if (!m.equals("GET") && !m.equals("POST")) { + throw new ProtocolException("Unsupported method: " + method); + } + this.method = m; + } + + @Override + public OutputStream getOutputStream() throws IOException { + // WebView may call connect() first, so do NOT throw "Already connected". + if (requestSent) throw new IllegalStateException("Request already sent"); + + setDoOutput(true); + return requestBody; + } + + @Override + public void connect() { + // MUST NOT send here. WebView may call connect() before writing the POST body. + connected = true; + } + + private void ensureResponse() throws IOException { + if (requestSent) return; + + URL cur = this.url; + + String methodStr = (this.method == null) ? "GET" : this.method.trim().toUpperCase(Locale.ROOT); + WebRequestMethod reqMethod = "POST".equals(methodStr) ? WebRequestMethod.POST : WebRequestMethod.GET; + + // Snapshot headers/body at send time. + Map carryHeaders = new LinkedHashMap<>(requestHeaders); + + // Each navigation creates a new connection, so we re-add the session for every request. + String session = sessionJar.get(); + if (session != null && !session.isBlank() && headerValue(carryHeaders, "session") == null) { + carryHeaders.put("session", session); + } + + byte[] carryBody = null; + if (getDoOutput()) { + carryBody = requestBody.toByteArray(); + if (reqMethod == WebRequestMethod.POST && carryBody == null) carryBody = new byte[0]; + } + + if (reqMethod == WebRequestMethod.POST && headerValue(carryHeaders, "content-type") == null) { + carryHeaders.put("content-type", "application/x-www-form-urlencoded; charset=utf-8"); + } + + OacWebResponse resp = null; + WebRequestMethod carryMethod = reqMethod; + + for (int i = 0; i <= MAX_REDIRECTS; i++) { + resp = broker.fetch(cur, carryMethod, carryHeaders, carryBody); + + String newSession = headerValue(resp.headers(), "session"); + if (newSession != null && !newSession.isBlank()) { + sessionJar.store(newSession); + // keep it for the next request in this redirect chain too + carryHeaders.put("session", newSession); + } + + int code = resp.statusCode(); + if (!getInstanceFollowRedirects()) break; + + if (code == 301 || code == 302 || code == 303 || code == 307 || code == 308) { + String loc = headerValue(resp.headers(), "location"); + if (loc == null || loc.isBlank()) break; + + try { + cur = new URL(cur, loc); + } catch (Exception ex) { + break; + } + + if (code == 303) { + carryMethod = WebRequestMethod.GET; + carryBody = null; + } else if ((code == 301 || code == 302) && carryMethod == WebRequestMethod.POST) { + carryMethod = WebRequestMethod.GET; + carryBody = null; + } + + continue; + } + + break; + } + + this.response = resp; + this.requestSent = true; + this.connected = true; + } + + @Override + public InputStream getInputStream() throws IOException { + ensureResponse(); + if (response == null) return new ByteArrayInputStream(new byte[0]); + return response.bodyStream(); + } + + @Override + public InputStream getErrorStream() { + try { + ensureResponse(); + if (response == null) return null; + int code = response.statusCode(); + return (code >= 400) ? response.bodyStream() : null; + } catch (IOException e) { + return null; + } + } + + @Override + public int getResponseCode() throws IOException { + ensureResponse(); + return response == null ? -1 : response.statusCode(); + } + + @Override + public String getContentType() { + try { + ensureResponse(); + } catch (IOException e) { + return "application/octet-stream"; + } + String ct = (response == null) ? null : response.contentType(); + return (ct == null || ct.isBlank()) ? "application/octet-stream" : ct; + } + + @Override + public int getContentLength() { + try { + ensureResponse(); + } catch (IOException e) { + return -1; + } + long len = (response == null) ? -1L : response.contentLength(); + return (len <= 0 || len > Integer.MAX_VALUE) ? -1 : (int) len; + } + + @Override + public long getContentLengthLong() { + try { + ensureResponse(); + } catch (IOException e) { + return -1L; + } + return (response == null) ? -1L : response.contentLength(); + } + + @Override + public Map> getHeaderFields() { + try { + ensureResponse(); + } catch (IOException e) { + return Map.of(); + } + if (response == null) return Map.of(); + + Map> out = new LinkedHashMap<>(); + for (Map.Entry e : response.headers().entrySet()) { + String k = e.getKey(); + String v = e.getValue(); + if (k == null) continue; + out.put(k, v == null ? List.of() : List.of(v)); + } + return out; + } + + @Override + public String getHeaderField(String name) { + if (name == null) return null; + try { + ensureResponse(); + } catch (IOException e) { + return null; + } + if (response == null) return null; + return headerValue(response.headers(), name.trim().toLowerCase(Locale.ROOT)); + } + + @Override + public void disconnect() { + // No persistent socket owned by this object. + connected = false; + } + + @Override + public boolean usingProxy() { + return false; + } +} \ No newline at end of file diff --git a/src/main/java/org/openautonomousconnection/protocol/urlhandler/v1_0_0/beta/OacWebPacketListener.java b/src/main/java/org/openautonomousconnection/protocol/urlhandler/v1_0_0/beta/OacWebPacketListener.java new file mode 100644 index 0000000..79cf019 --- /dev/null +++ b/src/main/java/org/openautonomousconnection/protocol/urlhandler/v1_0_0/beta/OacWebPacketListener.java @@ -0,0 +1,153 @@ +package org.openautonomousconnection.protocol.urlhandler.v1_0_0.beta; + +import dev.unlegitdqrk.unlegitlibrary.event.EventListener; +import dev.unlegitdqrk.unlegitlibrary.event.EventPriority; +import dev.unlegitdqrk.unlegitlibrary.event.Listener; +import dev.unlegitdqrk.unlegitlibrary.network.system.client.events.packets.C_PacketReadEvent; +import org.openautonomousconnection.protocol.packets.v1_0_0.beta.INSResponsePacket; +import org.openautonomousconnection.protocol.packets.v1_0_0.beta.web.WebResponsePacket; +import org.openautonomousconnection.protocol.packets.v1_0_0.beta.web.stream.WebStreamChunkPacket_v1_0_0_B; +import org.openautonomousconnection.protocol.packets.v1_0_0.beta.web.stream.WebStreamEndPacket_v1_0_0_B; +import org.openautonomousconnection.protocol.packets.v1_0_0.beta.web.stream.WebStreamStartPacket_v1_0_0_B; +import org.openautonomousconnection.protocol.side.client.ProtocolClient; +import org.openautonomousconnection.protocol.side.client.events.ConnectedToProtocolServerEvent; +import org.openautonomousconnection.protocol.versions.v1_0_0.beta.INSRecord; +import org.openautonomousconnection.protocol.versions.v1_0_0.beta.INSResponseStatus; + +import java.util.List; +import java.util.Objects; + +/** + * Receives incoming OAC web response packets and forwards them into the request broker. + * + *

Important:

+ *
    + *
  • The shown protocol types do not contain any correlation id.
  • + *
  • Therefore, the broker must treat the connection as single-flight (one in-flight request).
  • + *
+ */ +public final class OacWebPacketListener extends EventListener { + + private final OacWebRequestBroker broker; + private final ProtocolClient client; + private final LibClientImpl_v1_0_0_B impl; + + /** + * Creates a new listener bound to the given broker. + * + * @param broker broker instance + * @param client protocol client + */ + public OacWebPacketListener(OacWebRequestBroker broker, ProtocolClient client, LibClientImpl_v1_0_0_B impl) { + this.broker = Objects.requireNonNull(broker, "broker"); + this.client = Objects.requireNonNull(client, "client"); + this.impl = Objects.requireNonNull(impl, "impl"); + } + + /** + * Notifies the broker that the server connection is established. + * + * @param event connected event + */ + @Listener(priority = EventPriority.HIGHEST) + public void onConnected(ConnectedToProtocolServerEvent event) { + broker.notifyServerConnected(); + } + + /** + * Handles packets coming from INS and the web server side. + * + * @param event packet event + */ + @Listener(priority = EventPriority.HIGHEST) + public void onPacketRead(C_PacketReadEvent event) { + Object p = event.getPacket(); + + if (p instanceof INSResponsePacket resp) { + onInsResponse(resp); + return; + } + + if (p instanceof WebResponsePacket resp) { + broker.onWebResponse(resp.getStatusCode(), resp.getContentType(), resp.getHeaders(), resp.getBody()); + return; + } + + if (p instanceof WebStreamStartPacket_v1_0_0_B start) { + broker.onStreamStart(start.getStatusCode(), start.getContentType(), start.getHeaders(), start.getTotalLength()); + return; + } + + if (p instanceof WebStreamChunkPacket_v1_0_0_B chunk) { + broker.onStreamChunk(chunk.getSeq(), chunk.getData()); + return; + } + + if (p instanceof WebStreamEndPacket_v1_0_0_B end) { + broker.onStreamEnd(end.isOk()); + } + } + + private void onInsResponse(INSResponsePacket resp) { + INSResponseStatus status = resp.getStatus(); + List records = resp.getRecords(); + + if (status != INSResponseStatus.OK) { + broker.invalidateCurrentInfoName(); + throw new IllegalStateException("INS resolution failed: " + status); + } + + if (records == null || records.isEmpty() || records.getFirst() == null || records.getFirst().value == null) { + broker.invalidateCurrentInfoName(); + throw new IllegalStateException("INS resolution returned no usable records"); + } + + String host = records.getFirst().value.trim(); + if (host.isEmpty()) { + broker.invalidateCurrentInfoName(); + throw new IllegalStateException("INS record value is empty"); + } + + String hostname; + int port; + + if (!host.contains(":")) { + hostname = host; + + if (records.getFirst().port == 0) port = 1028; + else port = records.getFirst().port; + } else { + String[] split = host.split(":", 2); + hostname = split[0].trim(); + String p1 = split[1].trim(); + if (hostname.isEmpty() || p1.isEmpty()) { + broker.invalidateCurrentInfoName(); + throw new IllegalStateException("Invalid INS host:port value: " + host); + } + try { + port = Integer.parseInt(p1); + } catch (NumberFormatException e) { + broker.invalidateCurrentInfoName(); + throw new IllegalStateException("Invalid port in INS record: " + host, e); + } + } + + Thread t = new Thread(() -> connectServer(hostname, port), "oac-web-server-connect"); + t.setDaemon(true); + t.start(); + } + + private void connectServer(String hostname, int port) { + try { + if (client.getClientServerConnection() != null && client.getClientServerConnection().isConnected()) { + client.getClientServerConnection().disconnect(); + } + + client.buildServerConnection(null, client.getProtocolBridge().getProtocolValues().ssl); + client.getClientServerConnection().connect(hostname, port); + } catch (Exception e) { + broker.invalidateCurrentInfoName(); + impl.serverConnectionFailed(e); + } + } +} \ No newline at end of file diff --git a/src/main/java/org/openautonomousconnection/protocol/urlhandler/v1_0_0/beta/OacWebRequestBroker.java b/src/main/java/org/openautonomousconnection/protocol/urlhandler/v1_0_0/beta/OacWebRequestBroker.java new file mode 100644 index 0000000..d271452 --- /dev/null +++ b/src/main/java/org/openautonomousconnection/protocol/urlhandler/v1_0_0/beta/OacWebRequestBroker.java @@ -0,0 +1,529 @@ +package org.openautonomousconnection.protocol.urlhandler.v1_0_0.beta; + +import dev.unlegitdqrk.unlegitlibrary.network.system.utils.TransportProtocol; +import org.openautonomousconnection.protocol.packets.v1_0_0.beta.INSQueryPacket; +import org.openautonomousconnection.protocol.packets.v1_0_0.beta.web.WebRequestPacket; +import org.openautonomousconnection.protocol.side.client.ProtocolClient; +import org.openautonomousconnection.protocol.versions.v1_0_0.beta.INSRecordType; +import org.openautonomousconnection.protocol.versions.v1_0_0.beta.WebRequestMethod; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.net.URL; +import java.util.Arrays; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +/** + * Central broker that translates {@code web://} URLs into OAC protocol traffic. + * + *

Protocol limitation: no correlation id -> single-flight (one in-flight request at a time).

+ * + *

UDP streaming semantics (best-effort):

+ *
    + *
  • Chunks may arrive out of order or be lost.
  • + *
  • We accept gaps and assemble what we have after {@code WebStreamEndPacket}.
  • + *
  • We wait a short grace window after stream end to allow late UDP packets.
  • + *
+ */ +public final class OacWebRequestBroker { + + private static final OacWebRequestBroker INSTANCE = new OacWebRequestBroker(); + + private static final long CONNECT_TIMEOUT_SECONDS = 10; + private static final long RESPONSE_TIMEOUT_SECONDS = 25; + + /** + * Grace time after receiving WebStreamEndPacket to allow late UDP packets (reordering). + */ + private static final long UDP_END_GRACE_MILLIS = 150; + + private final Object responseLock = new Object(); + + private volatile ProtocolClient client; + private volatile CountDownLatch connectionLatch; + private volatile String currentInfoName; + private volatile ResponseState responseState; + + private OacWebRequestBroker() { + } + + /** + * Returns the singleton broker. + * + * @return broker + */ + public static OacWebRequestBroker get() { + return INSTANCE; + } + + private static String safeContentType(String ct) { + return (ct == null || ct.isBlank()) ? "application/octet-stream" : ct; + } + + private static Map safeHeaders(Map headers) { + return (headers == null || headers.isEmpty()) ? Map.of() : Map.copyOf(headers); + } + + private static String normalizePathWithQuery(String path, String query) { + String p; + if (path == null || path.isBlank() || "/".equals(path)) { + p = "index.html"; + } else { + p = path.startsWith("/") ? path.substring(1) : path; + if (p.isBlank()) p = "index.html"; + } + + if (query != null && !query.isBlank()) { + return p + "?" + query; + } + return p; + } + + /** + * Assembles the response body from received chunks in ascending seq order. + * + *

Best-effort UDP behavior: gaps are ignored.

+ */ + private static void assembleBestEffortLocked(ResponseState st) { + st.body.reset(); + + if (st.chunkBuffer.isEmpty() || st.maxSeqSeen < 0) { + return; + } + + for (int seq = 0; seq <= st.maxSeqSeen; seq++) { + byte[] chunk = st.chunkBuffer.get(seq); + if (chunk == null) continue; // gap accepted + st.body.write(chunk, 0, chunk.length); + } + } + + private static void sleepSilently(long millis) { + try { + Thread.sleep(millis); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + + /** + * Attaches the client used to send INS/Web packets. + * + * @param client protocol client + */ + public void attachClient(ProtocolClient client) { + this.client = Objects.requireNonNull(client, "client"); + } + + /** + * Legacy overload (GET with no body). + * + * @param url web:// URL + * @param headers request headers + * @return response + */ + public OacWebResponse fetch(URL url, Map headers) { + return fetch(url, WebRequestMethod.GET, headers, null); + } + + /** + * Fetches a URL via OAC protocol (used by {@link java.net.URLConnection}). + * + * @param url web:// URL + * @param method request method + * @param headers request headers + * @param body request body (may be null) + * @return response + */ + public OacWebResponse fetch(URL url, WebRequestMethod method, Map headers, byte[] body) { + Objects.requireNonNull(url, "url"); + Objects.requireNonNull(method, "method"); + + ProtocolClient c = this.client; + if (c == null) { + throw new IllegalStateException("ProtocolClient not attached. Call OacWebUrlInstaller.installOnce(..., client) first."); + } + + Response r = openAndAwait(c, url, method, headers, body); + + byte[] respBody = (r.body() == null) ? new byte[0] : r.body(); + long len = respBody.length; + + return new OacWebResponse( + r.statusCode(), + r.contentType(), + OacWebResponse.safeHeaders(r.headers()), + new ByteArrayInputStream(respBody), + len + ); + } + + /** + * Opens a resource and blocks until the current single-flight response completes. + */ + public Response openAndAwait(ProtocolClient client, URL url, WebRequestMethod method, Map headers, byte[] body) { + Objects.requireNonNull(client, "client"); + Objects.requireNonNull(url, "url"); + Objects.requireNonNull(method, "method"); + + open(client, url, method, headers, body); + return awaitResponse(RESPONSE_TIMEOUT_SECONDS, TimeUnit.SECONDS); + } + + /** + * Sends required packets for a {@code web://} URL. + */ + public synchronized void open(ProtocolClient client, URL url, WebRequestMethod method, Map headers, byte[] body) { + Objects.requireNonNull(client, "client"); + Objects.requireNonNull(url, "url"); + Objects.requireNonNull(method, "method"); + + if (!"web".equalsIgnoreCase(url.getProtocol())) { + throw new IllegalArgumentException("Unsupported protocol: " + url.getProtocol()); + } + + String infoName = url.getHost(); + if (infoName == null || infoName.isBlank()) { + throw new IllegalArgumentException("Missing InfoName in URL: " + url); + } + + String path = normalizePathWithQuery(url.getPath(), url.getQuery()); + + beginNewResponse(); + + if (!infoName.equals(currentInfoName)) { + resolveAndConnect(client, infoName); + currentInfoName = infoName; + } else { + awaitConnectionIfPending(); + } + + sendWebRequest(client, path, method, headers, body); + } + + /** + * Called by packet listener when server connection is established. + */ + public void notifyServerConnected() { + CountDownLatch latch = connectionLatch; + if (latch != null) { + latch.countDown(); + } + } + + /** + * Invalidates cached InfoName, forcing a new INS resolution on next request. + */ + public synchronized void invalidateCurrentInfoName() { + currentInfoName = null; + } + + /** + * Handles a non-streamed WebResponsePacket. + * + * @param statusCode status code + * @param contentType content-type + * @param headers headers + * @param body body + */ + public void onWebResponse(int statusCode, String contentType, Map headers, byte[] body) { + ResponseState st = responseState; + if (st == null) return; + + synchronized (responseLock) { + if (st.completed) return; + + st.statusCode = statusCode; + st.contentType = safeContentType(contentType); + st.headers = safeHeaders(headers); + + byte[] b = (body == null) ? new byte[0] : body; + st.body.reset(); + st.body.write(b, 0, b.length); + + st.completed = true; + st.success = true; + st.done.countDown(); + } + } + + /** + * Handles the beginning of a streamed response. + * + * @param statusCode status code + * @param contentType content-type + * @param headers headers + * @param totalLength total length (may be -1) + */ + public void onStreamStart(int statusCode, String contentType, Map headers, long totalLength) { + ResponseState st = responseState; + if (st == null) return; + + synchronized (responseLock) { + if (st.completed) return; + + st.statusCode = statusCode; + st.contentType = safeContentType(contentType); + st.headers = safeHeaders(headers); + st.totalLength = totalLength; + + st.streamStarted = true; + st.maxSeqSeen = -1; + st.endReceived = false; + st.endReceivedAtMillis = 0L; + + // Streaming body will be assembled on end (best-effort UDP) + st.body.reset(); + } + } + + /** + * Handles a streamed chunk. + * + *

UDP best-effort: store by seq and assemble later; accept gaps.

+ * + * @param seq chunk sequence number + * @param data chunk bytes + */ + public void onStreamChunk(int seq, byte[] data) { + ResponseState st = responseState; + if (st == null) return; + if (data == null || data.length == 0) return; + + synchronized (responseLock) { + if (st.completed) return; + + if (!st.streamStarted) { + failLocked(st, "Stream chunk received before stream start"); + return; + } + + if (seq < 0) return; + + st.chunkBuffer.put(seq, Arrays.copyOf(data, data.length)); + if (seq > st.maxSeqSeen) st.maxSeqSeen = seq; + } + } + + /** + * Handles stream end. + * + *

UDP best-effort: do not complete immediately; allow late UDP packets and assemble after grace.

+ * + * @param ok end status + */ + public void onStreamEnd(boolean ok) { + ResponseState st = responseState; + if (st == null) return; + + synchronized (responseLock) { + if (st.completed) return; + + if (!st.streamStarted) { + st.streamStarted = true; + } + + st.success = ok; + st.endReceived = true; + st.endReceivedAtMillis = System.currentTimeMillis(); + // completion + assembly happens in awaitResponse() after grace window + } + } + + /** + * Waits for the current response (single-flight). + * + * @param timeout timeout + * @param unit unit + * @return response + */ + public Response awaitResponse(long timeout, TimeUnit unit) { + Objects.requireNonNull(unit, "unit"); + + ResponseState st = responseState; + if (st == null) { + throw new IllegalStateException("No in-flight request"); + } + + long deadlineNanos = System.nanoTime() + unit.toNanos(timeout); + + for (; ; ) { + synchronized (responseLock) { + if (st.completed) { + break; + } + + // Non-stream response already completed by onWebResponse() + if (!st.streamStarted && st.done.getCount() == 0) { + st.completed = true; + break; + } + + if (st.endReceived) { + long now = System.currentTimeMillis(); + if (now - st.endReceivedAtMillis >= UDP_END_GRACE_MILLIS) { + // Assemble best-effort body from received chunks + assembleBestEffortLocked(st); + + st.completed = true; + + if (!st.success) { + st.errorMessage = (st.errorMessage == null) ? "Streaming failed" : st.errorMessage; + } + + st.done.countDown(); + break; + } + } + } + + if (System.nanoTime() >= deadlineNanos) { + throw new IllegalStateException("Timeout while waiting for Web response"); + } + + sleepSilently(10); + } + + synchronized (responseLock) { + if (!st.success) { + throw new IllegalStateException(st.errorMessage == null ? "Request failed" : st.errorMessage); + } + return new Response( + st.statusCode, + st.contentType, + st.headers, + st.body.toByteArray() + ); + } + } + + private void resolveAndConnect(ProtocolClient client, String infoName) { + if (client.getClientINSConnection() == null || !client.getClientINSConnection().isConnected()) return; + + String[] parts = infoName.split("\\."); + if (parts.length < 2 || parts.length > 3) { + throw new IllegalArgumentException( + "Invalid INS address format: " + infoName + " (expected name.tln or sub.name.tln)" + ); + } + + String tln = parts[parts.length - 1]; + String name = parts[parts.length - 2]; + String sub = (parts.length == 3) ? parts[0] : null; + + connectionLatch = new CountDownLatch(1); + + INSQueryPacket query = new INSQueryPacket( + tln, + name, + sub, + INSRecordType.A, + client.getClientINSConnection().getUniqueID() + ); + + try { + client.getClientINSConnection().sendPacket(query, TransportProtocol.TCP); + } catch (Exception e) { + throw new IllegalStateException("Failed to send INSQueryPacket for " + infoName, e); + } + + awaitConnectionIfPending(); + } + + private void awaitConnectionIfPending() { + CountDownLatch latch = connectionLatch; + if (latch == null || client == null || client.getClientServerConnection() == null || client.getClientINSConnection() == null) { + return; + } + + try { + if (!latch.await(CONNECT_TIMEOUT_SECONDS, TimeUnit.SECONDS)) { + throw new IllegalStateException("Timeout while waiting for ServerConnection"); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("Interrupted while waiting for ServerConnection", e); + } + } + + private void sendWebRequest(ProtocolClient client, String path, WebRequestMethod method, Map headers, byte[] body) { + Objects.requireNonNull(client, "client"); + Objects.requireNonNull(path, "path"); + Objects.requireNonNull(method, "method"); + + if (client.getClientServerConnection() == null || !client.getClientServerConnection().isConnected()) { + awaitConnectionIfPending(); + } + + if (client.getClientServerConnection() == null || !client.getClientServerConnection().isConnected()) { + throw new IllegalStateException("ServerConnection is not connected after waiting"); + } + + WebRequestPacket packet = new WebRequestPacket( + path, + method, + (headers == null ? Map.of() : headers), + body + ); + + try { + client.getClientServerConnection().sendPacket(packet, TransportProtocol.TCP); + } catch (Exception e) { + throw new IllegalStateException("Failed to send WebRequestPacket for path " + path, e); + } + } + + private void beginNewResponse() { + synchronized (responseLock) { + responseState = new ResponseState(); + } + } + + private void failLocked(ResponseState st, String message) { + st.completed = true; + st.success = false; + st.errorMessage = message; + st.done.countDown(); + } + + /** + * Response DTO. + * + * @param statusCode status code + * @param contentType content type + * @param headers headers + * @param body body bytes + */ + public record Response(int statusCode, String contentType, Map headers, byte[] body) { + } + + /** + * In-flight state for a single request (single-flight). + */ + private static final class ResponseState { + private final CountDownLatch done = new CountDownLatch(1); + + private final ByteArrayOutputStream body = new ByteArrayOutputStream(64 * 1024); + private final Map chunkBuffer = new HashMap<>(); + + private int statusCode = 0; + private String contentType = "application/octet-stream"; + private Map headers = Map.of(); + + private boolean streamStarted; + private long totalLength; + + private int maxSeqSeen = -1; + + private boolean endReceived; + private long endReceivedAtMillis; + + private boolean completed; + private boolean success; + private String errorMessage; + } +} \ No newline at end of file diff --git a/src/main/java/org/openautonomousconnection/protocol/urlhandler/v1_0_0/beta/OacWebResponse.java b/src/main/java/org/openautonomousconnection/protocol/urlhandler/v1_0_0/beta/OacWebResponse.java new file mode 100644 index 0000000..e45fe0a --- /dev/null +++ b/src/main/java/org/openautonomousconnection/protocol/urlhandler/v1_0_0/beta/OacWebResponse.java @@ -0,0 +1,32 @@ +package org.openautonomousconnection.protocol.urlhandler.v1_0_0.beta; + +import java.io.InputStream; +import java.util.Collections; +import java.util.Map; +import java.util.Objects; + +/** + * Represents a resolved web response for the JavaFX WebView. + * + * @param statusCode HTTP-like status code + * @param contentType response content-type (as sent by server) + * @param headers response headers + * @param bodyStream body stream (may be streaming) + * @param contentLength content length if known, else -1 + */ +public record OacWebResponse( + int statusCode, + String contentType, + Map headers, + InputStream bodyStream, + long contentLength +) { + public OacWebResponse { + Objects.requireNonNull(headers, "headers"); + Objects.requireNonNull(bodyStream, "bodyStream"); + } + + public static Map safeHeaders(Map h) { + return (h == null) ? Collections.emptyMap() : Collections.unmodifiableMap(h); + } +} \ No newline at end of file diff --git a/src/main/java/org/openautonomousconnection/protocol/urlhandler/v1_0_0/beta/OacWebUrlInstaller_v1_0_0_B.java b/src/main/java/org/openautonomousconnection/protocol/urlhandler/v1_0_0/beta/OacWebUrlInstaller_v1_0_0_B.java new file mode 100644 index 0000000..c89daf7 --- /dev/null +++ b/src/main/java/org/openautonomousconnection/protocol/urlhandler/v1_0_0/beta/OacWebUrlInstaller_v1_0_0_B.java @@ -0,0 +1,32 @@ +package org.openautonomousconnection.protocol.urlhandler.v1_0_0.beta; + +import org.openautonomousconnection.protocol.ProtocolBridge; + +import java.util.Objects; +import java.util.concurrent.atomic.AtomicBoolean; + +/** + * Installs the "web://" protocol handler using java.protocol.handler.pkgs. + * Recommended to use Version v1.0.0-BETA or newer + */ +@Deprecated(forRemoval = false, since = "1.0.1-BETA.0.3") +public final class OacWebUrlInstaller_v1_0_0_B { + + private static final AtomicBoolean INSTALLED = new AtomicBoolean(false); + + private OacWebUrlInstaller_v1_0_0_B() { + } + + public static void installOnce(ProtocolBridge protocolBridge, LibClientImpl_v1_0_0_B impl) { + Objects.requireNonNull(protocolBridge, "protocolBridge"); + Objects.requireNonNull(impl, "impl"); + + if (!INSTALLED.compareAndSet(false, true)) return; + + OacWebRequestBroker.get().attachClient(protocolBridge.getProtocolClient()); + protocolBridge.getProtocolValues().eventManager. + registerListener(new OacWebPacketListener(OacWebRequestBroker.get(), protocolBridge.getProtocolClient(), impl)); + + ProtocolHandlerPackages.installPackage("org.openautonomousconnection.urlhandler"); + } +} \ No newline at end of file diff --git a/src/main/java/org/openautonomousconnection/protocol/urlhandler/v1_0_0/beta/ProtocolHandlerPackages.java b/src/main/java/org/openautonomousconnection/protocol/urlhandler/v1_0_0/beta/ProtocolHandlerPackages.java new file mode 100644 index 0000000..1d99f1a --- /dev/null +++ b/src/main/java/org/openautonomousconnection/protocol/urlhandler/v1_0_0/beta/ProtocolHandlerPackages.java @@ -0,0 +1,42 @@ +package org.openautonomousconnection.protocol.urlhandler.v1_0_0.beta; + +import java.util.LinkedHashSet; +import java.util.Objects; +import java.util.Set; + +/** + * Utility to safely append packages to "java.protocol.handler.pkgs". + */ +public final class ProtocolHandlerPackages { + + private static final String KEY = "java.protocol.handler.pkgs"; + + private ProtocolHandlerPackages() { + } + + /** + * Appends a package prefix to the protocol handler search path. + * + * @param pkg package prefix (e.g. "com.example.protocols") + */ + public static void installPackage(String pkg) { + Objects.requireNonNull(pkg, "pkg"); + String p = pkg.trim(); + if (p.isEmpty()) return; + + String existing = System.getProperty(KEY, ""); + Set parts = new LinkedHashSet<>(); + + if (!existing.isBlank()) { + for (String s : existing.split("\\|")) { + String t = s.trim(); + if (!t.isEmpty()) parts.add(t); + } + } + + parts.add(p); + + String merged = String.join("|", parts); + System.setProperty(KEY, merged); + } +} \ No newline at end of file diff --git a/src/main/java/org/openautonomousconnection/protocol/urlhandler/v1_0_0/beta/web/Handler.java b/src/main/java/org/openautonomousconnection/protocol/urlhandler/v1_0_0/beta/web/Handler.java new file mode 100644 index 0000000..6fca944 --- /dev/null +++ b/src/main/java/org/openautonomousconnection/protocol/urlhandler/v1_0_0/beta/web/Handler.java @@ -0,0 +1,22 @@ +package org.openautonomousconnection.protocol.urlhandler.v1_0_0.beta.web; + +import org.openautonomousconnection.protocol.urlhandler.v1_0_0.beta.OacSessionJar; +import org.openautonomousconnection.protocol.urlhandler.v1_0_0.beta.OacWebHttpURLConnection; +import org.openautonomousconnection.protocol.urlhandler.v1_0_0.beta.OacWebRequestBroker; + +import java.io.IOException; +import java.net.URL; +import java.net.URLConnection; +import java.net.URLStreamHandler; + +/** + * URLStreamHandler for the "web" protocol (loaded via java.protocol.handler.pkgs). + */ +public final class Handler extends URLStreamHandler { + private static final OacSessionJar SESSION_JAR = new OacSessionJar(); + + @Override + protected URLConnection openConnection(URL u) throws IOException { + return new OacWebHttpURLConnection(u, OacWebRequestBroker.get(), SESSION_JAR); + } +} \ No newline at end of file diff --git a/src/main/java/org/openautonomousconnection/protocol/urlhandler/v1_0_1/beta/LibClientImpl_v1_0_1_B.java b/src/main/java/org/openautonomousconnection/protocol/urlhandler/v1_0_1/beta/LibClientImpl_v1_0_1_B.java new file mode 100644 index 0000000..8a1dd34 --- /dev/null +++ b/src/main/java/org/openautonomousconnection/protocol/urlhandler/v1_0_1/beta/LibClientImpl_v1_0_1_B.java @@ -0,0 +1,55 @@ +package org.openautonomousconnection.protocol.urlhandler.v1_0_1.beta; + +import org.openautonomousconnection.protocol.urlhandler.v1_0_0.beta.LibClientImpl_v1_0_0_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 java.util.Map; + +/** + * Callback surface for URL-handler related streaming events (v1.0.1-BETA). + */ +public abstract class LibClientImpl_v1_0_1_B extends LibClientImpl_v1_0_0_B implements WebRequestContextProvider, WebFlagInspector { + + /** + * Called when a streamed response begins. + */ + public void streamStart( + WebPacketHeader header, + int statusCode, + String contentType, + Map headers, + long totalLength + ) { + } + + /** + * Called for each streamed chunk. + */ + public void streamChunk( + WebPacketHeader header, + int seq, + byte[] data + ) { + } + + /** + * Called when the stream ends. + */ + public void streamEnd( + WebPacketHeader header, + boolean ok, + String error + ) { + } + + /** + * Called after full best-effort assembly (only if ok=true). + */ + public void streamFinish( + WebPacketHeader header, + byte[] content + ) { + } +} \ No newline at end of file diff --git a/src/main/java/org/openautonomousconnection/protocol/urlhandler/v1_0_1/beta/OacUrlHandlerInstaller_v1_0_1_B.java b/src/main/java/org/openautonomousconnection/protocol/urlhandler/v1_0_1/beta/OacUrlHandlerInstaller_v1_0_1_B.java new file mode 100644 index 0000000..3616735 --- /dev/null +++ b/src/main/java/org/openautonomousconnection/protocol/urlhandler/v1_0_1/beta/OacUrlHandlerInstaller_v1_0_1_B.java @@ -0,0 +1,50 @@ +package org.openautonomousconnection.protocol.urlhandler.v1_0_1.beta; + +import org.openautonomousconnection.protocol.ProtocolBridge; +import org.openautonomousconnection.protocol.urlhandler.v1_0_1.beta.web.OacWebProtocolModule_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 java.util.Objects; +import java.util.concurrent.atomic.AtomicBoolean; + +/** + * Installs OAC URL protocol modules for v1.0.1-BETA. + * + *

Call once during startup (before creating {@link java.net.URL} instances).

+ */ +public final class OacUrlHandlerInstaller_v1_0_1_B { + + private static final AtomicBoolean INSTALLED = new AtomicBoolean(false); + + private OacUrlHandlerInstaller_v1_0_1_B() { + } + + /** + * Installs the URL handler package path and registers the WEB module. + * + * @param protocolBridge protocol bridge + * @param impl callback implementation + * @param ctxProvider provides tab/page/frame correlation for each request + * @param flagInspector interprets {@code WebPacketHeader.flags} (e.g. stream bit) + */ + public static void installOnce( + ProtocolBridge protocolBridge, + LibClientImpl_v1_0_1_B impl, + WebRequestContextProvider ctxProvider, + WebFlagInspector flagInspector + ) { + Objects.requireNonNull(protocolBridge, "protocolBridge"); + Objects.requireNonNull(impl, "impl"); + Objects.requireNonNull(ctxProvider, "ctxProvider"); + Objects.requireNonNull(flagInspector, "flagInspector"); + + if (!INSTALLED.compareAndSet(false, true)) return; + + ProtocolHandlerPackages.installPackage("org.openautonomousconnection.protocol.urlhandler.v1_0_1.beta"); + + OacWebProtocolModule_v1_0_1_B web = new OacWebProtocolModule_v1_0_1_B(ctxProvider, flagInspector); + OacUrlProtocolRegistry.register(web); + web.install(protocolBridge, impl); + } +} \ No newline at end of file diff --git a/src/main/java/org/openautonomousconnection/protocol/urlhandler/v1_0_1/beta/OacUrlProtocolModule.java b/src/main/java/org/openautonomousconnection/protocol/urlhandler/v1_0_1/beta/OacUrlProtocolModule.java new file mode 100644 index 0000000..e66c124 --- /dev/null +++ b/src/main/java/org/openautonomousconnection/protocol/urlhandler/v1_0_1/beta/OacUrlProtocolModule.java @@ -0,0 +1,38 @@ +package org.openautonomousconnection.protocol.urlhandler.v1_0_1.beta; + +import org.openautonomousconnection.protocol.ProtocolBridge; + +import java.io.IOException; +import java.net.URL; +import java.net.URLConnection; + +/** + * Pluggable module for a URL protocol scheme (e.g. "web", "ftp"). + * + *

Each module is responsible for providing {@link URLConnection} instances for its scheme and + * wiring packet listeners into the {@link ProtocolBridge} runtime.

+ */ +public interface OacUrlProtocolModule { + + /** + * @return the URL scheme handled by this module (e.g. "web", "ftp") + */ + String scheme(); + + /** + * Opens a connection for the given URL. + * + * @param url the URL + * @return the connection + * @throws IOException if opening fails + */ + URLConnection openConnection(URL url) throws IOException; + + /** + * Installs the module into the given protocol bridge (listeners, brokers, etc.). + * + * @param protocolBridge protocol bridge + * @param impl callback implementation + */ + void install(ProtocolBridge protocolBridge, LibClientImpl_v1_0_1_B impl); +} \ No newline at end of file diff --git a/src/main/java/org/openautonomousconnection/protocol/urlhandler/v1_0_1/beta/OacUrlProtocolRegistry.java b/src/main/java/org/openautonomousconnection/protocol/urlhandler/v1_0_1/beta/OacUrlProtocolRegistry.java new file mode 100644 index 0000000..e12eea6 --- /dev/null +++ b/src/main/java/org/openautonomousconnection/protocol/urlhandler/v1_0_1/beta/OacUrlProtocolRegistry.java @@ -0,0 +1,52 @@ +package org.openautonomousconnection.protocol.urlhandler.v1_0_1.beta; + +import java.util.Locale; +import java.util.Objects; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Global registry for URL protocol modules. + */ +public final class OacUrlProtocolRegistry { + + private static final ConcurrentHashMap MODULES = new ConcurrentHashMap<>(); + + private OacUrlProtocolRegistry() { + } + + /** + * Registers a module for its scheme. + * + * @param module the module + * @throws IllegalStateException if a different module is already registered for the scheme + */ + public static void register(OacUrlProtocolModule module) { + Objects.requireNonNull(module, "module"); + + String scheme = normalizeScheme(module.scheme()); + OacUrlProtocolModule prev = MODULES.putIfAbsent(scheme, module); + + if (prev != null && prev != module) { + throw new IllegalStateException( + "Module already registered for scheme '" + scheme + "': " + prev.getClass().getName() + ); + } + } + + /** + * Returns the module for the given scheme. + * + * @param scheme the scheme + * @return module or null if not registered + */ + public static OacUrlProtocolModule get(String scheme) { + if (scheme == null) return null; + return MODULES.get(normalizeScheme(scheme)); + } + + private static String normalizeScheme(String scheme) { + String s = Objects.requireNonNull(scheme, "scheme").trim(); + if (s.isEmpty()) throw new IllegalArgumentException("scheme is empty"); + return s.toLowerCase(Locale.ROOT); + } +} \ No newline at end of file diff --git a/src/main/java/org/openautonomousconnection/protocol/urlhandler/v1_0_1/beta/ProtocolHandlerPackages.java b/src/main/java/org/openautonomousconnection/protocol/urlhandler/v1_0_1/beta/ProtocolHandlerPackages.java new file mode 100644 index 0000000..3c8a9a3 --- /dev/null +++ b/src/main/java/org/openautonomousconnection/protocol/urlhandler/v1_0_1/beta/ProtocolHandlerPackages.java @@ -0,0 +1,44 @@ +package org.openautonomousconnection.protocol.urlhandler.v1_0_1.beta; + +import java.util.LinkedHashSet; +import java.util.Objects; +import java.util.Set; + +/** + * Utility to safely append packages to "java.protocol.handler.pkgs". + * + *

JVM lookup rule: it searches {@code ..Handler}.

+ */ +public final class ProtocolHandlerPackages { + + private static final String KEY = "java.protocol.handler.pkgs"; + + private ProtocolHandlerPackages() { + } + + /** + * Appends a package prefix to the protocol handler search path. + * + * @param pkg package prefix (e.g. "org.openautonomousconnection.protocol.urlhandler.v1_0_1.beta") + */ + public static void installPackage(String pkg) { + Objects.requireNonNull(pkg, "pkg"); + String p = pkg.trim(); + if (p.isEmpty()) return; + + String existing = System.getProperty(KEY, ""); + Set parts = new LinkedHashSet<>(); + + if (!existing.isBlank()) { + for (String s : existing.split("\\|")) { + String t = s.trim(); + if (!t.isEmpty()) parts.add(t); + } + } + + parts.add(p); + + String merged = String.join("|", parts); + System.setProperty(KEY, merged); + } +} \ No newline at end of file diff --git a/src/main/java/org/openautonomousconnection/protocol/urlhandler/v1_0_1/beta/web/DeleteOnCloseInputStream.java b/src/main/java/org/openautonomousconnection/protocol/urlhandler/v1_0_1/beta/web/DeleteOnCloseInputStream.java new file mode 100644 index 0000000..01ebc47 --- /dev/null +++ b/src/main/java/org/openautonomousconnection/protocol/urlhandler/v1_0_1/beta/web/DeleteOnCloseInputStream.java @@ -0,0 +1,71 @@ +package org.openautonomousconnection.protocol.urlhandler.v1_0_1.beta.web; + +import java.io.FilterInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.*; +import java.nio.file.attribute.BasicFileAttributes; +import java.util.Objects; + +/** + * InputStream wrapper that deletes one or more paths (files/directories) on close. + * + *

Useful for temporary downloads/streams that must be cleaned up automatically.

+ */ +final class DeleteOnCloseInputStream extends FilterInputStream { + + private final Path[] deletePaths; + private volatile boolean closed; + + DeleteOnCloseInputStream(InputStream in, Path... deletePaths) { + super(Objects.requireNonNull(in, "in")); + this.deletePaths = (deletePaths == null) ? new Path[0] : deletePaths.clone(); + } + + @Override + public void close() throws IOException { + if (closed) return; + closed = true; + + IOException io = null; + try { + super.close(); + } catch (IOException e) { + io = e; + } + + for (Path p : deletePaths) { + if (p == null) continue; + try { + deleteRecursivelyIfExists(p); + } catch (IOException e) { + if (io == null) io = e; + } + } + + if (io != null) throw io; + } + + private static void deleteRecursivelyIfExists(Path path) throws IOException { + if (!Files.exists(path)) return; + + if (Files.isDirectory(path)) { + Files.walkFileTree(path, new SimpleFileVisitor<>() { + @Override + public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException { + Files.deleteIfExists(file); + return FileVisitResult.CONTINUE; + } + + @Override + public FileVisitResult postVisitDirectory(Path dir, IOException exc) throws IOException { + Files.deleteIfExists(dir); + return FileVisitResult.CONTINUE; + } + }); + return; + } + + Files.deleteIfExists(path); + } +} \ No newline at end of file diff --git a/src/main/java/org/openautonomousconnection/protocol/urlhandler/v1_0_1/beta/web/Handler.java b/src/main/java/org/openautonomousconnection/protocol/urlhandler/v1_0_1/beta/web/Handler.java new file mode 100644 index 0000000..b01cf67 --- /dev/null +++ b/src/main/java/org/openautonomousconnection/protocol/urlhandler/v1_0_1/beta/web/Handler.java @@ -0,0 +1,26 @@ +package org.openautonomousconnection.protocol.urlhandler.v1_0_1.beta.web; + +import org.openautonomousconnection.protocol.urlhandler.v1_0_1.beta.OacUrlProtocolModule; +import org.openautonomousconnection.protocol.urlhandler.v1_0_1.beta.OacUrlProtocolRegistry; + +import java.io.IOException; +import java.net.URL; +import java.net.URLConnection; +import java.net.URLStreamHandler; + +/** + * URLStreamHandler for the "web" protocol. + * + *

Loaded by JVM via {@code java.protocol.handler.pkgs} and delegates to the registered module.

+ */ +public final class Handler extends URLStreamHandler { + + @Override + protected URLConnection openConnection(URL u) throws IOException { + OacUrlProtocolModule module = OacUrlProtocolRegistry.get("web"); + if (module == null) { + throw new IOException("No module registered for scheme 'web'. Did you call OacUrlHandlerInstaller_v1_0_1_B.installOnce(...)?"); + } + return module.openConnection(u); + } +} \ No newline at end of file diff --git a/src/main/java/org/openautonomousconnection/protocol/urlhandler/v1_0_1/beta/web/OacSessionJar.java b/src/main/java/org/openautonomousconnection/protocol/urlhandler/v1_0_1/beta/web/OacSessionJar.java new file mode 100644 index 0000000..ba1deed --- /dev/null +++ b/src/main/java/org/openautonomousconnection/protocol/urlhandler/v1_0_1/beta/web/OacSessionJar.java @@ -0,0 +1,26 @@ +package org.openautonomousconnection.protocol.urlhandler.v1_0_1.beta.web; + +/** + * Stores the current session token across multiple URLConnection instances. + */ +public final class OacSessionJar { + + private volatile String session; + + /** + * Stores a session token (e.g. from response header "session"). + * + * @param session session token + */ + public void store(String session) { + String s = (session == null) ? null : session.trim(); + this.session = (s == null || s.isEmpty()) ? null : s; + } + + /** + * @return stored session token or null + */ + public String get() { + return session; + } +} \ No newline at end of file diff --git a/src/main/java/org/openautonomousconnection/protocol/urlhandler/v1_0_1/beta/web/OacWebHttpURLConnection.java b/src/main/java/org/openautonomousconnection/protocol/urlhandler/v1_0_1/beta/web/OacWebHttpURLConnection.java new file mode 100644 index 0000000..411e39c --- /dev/null +++ b/src/main/java/org/openautonomousconnection/protocol/urlhandler/v1_0_1/beta/web/OacWebHttpURLConnection.java @@ -0,0 +1,270 @@ +package org.openautonomousconnection.protocol.urlhandler.v1_0_1.beta.web; + +import java.io.*; +import java.net.HttpURLConnection; +import java.net.ProtocolException; +import java.net.URL; +import java.util.*; + +/** + * HttpURLConnection implementation that maps "web://" URLs to WEB v1.0.1 protocol requests. + * + *

This implementation is designed for JavaFX WebView semantics.

+ */ +public final class OacWebHttpURLConnection extends HttpURLConnection { + + private static final int MAX_REDIRECTS = 8; + + private final OacWebRequestBroker broker; + private final OacSessionJar sessionJar; + private final WebRequestContextProvider ctxProvider; + + private final Map requestHeaders = new LinkedHashMap<>(); + private final ByteArrayOutputStream requestBody = new ByteArrayOutputStream(1024); + + private boolean requestSent; + private OacWebResponse response; + + public OacWebHttpURLConnection(URL url, OacWebRequestBroker broker, OacSessionJar sessionJar, WebRequestContextProvider ctxProvider) { + super(url); + this.broker = Objects.requireNonNull(broker, "broker"); + this.sessionJar = Objects.requireNonNull(sessionJar, "sessionJar"); + this.ctxProvider = Objects.requireNonNull(ctxProvider, "ctxProvider"); + this.method = "GET"; + setInstanceFollowRedirects(true); + } + + private static String headerValue(Map headers, String nameLower) { + if (headers == null || headers.isEmpty() || nameLower == null) return null; + String needle = nameLower.trim().toLowerCase(Locale.ROOT); + for (Map.Entry e : headers.entrySet()) { + if (e.getKey() == null) continue; + if (e.getKey().trim().toLowerCase(Locale.ROOT).equals(needle)) { + return e.getValue(); + } + } + return null; + } + + @Override + public void setRequestProperty(String key, String value) { + if (key == null) return; + if (requestSent) throw new IllegalStateException("Request already sent"); + if (value == null) requestHeaders.remove(key); + else requestHeaders.put(key, value); + } + + @Override + public String getRequestProperty(String key) { + if (key == null) return null; + for (Map.Entry e : requestHeaders.entrySet()) { + if (e.getKey() != null && e.getKey().equalsIgnoreCase(key)) return e.getValue(); + } + return null; + } + + @Override + public Map> getRequestProperties() { + Map> out = new LinkedHashMap<>(); + for (Map.Entry e : requestHeaders.entrySet()) { + out.put(e.getKey(), e.getValue() == null ? List.of() : List.of(e.getValue())); + } + return Collections.unmodifiableMap(out); + } + + @Override + public void setRequestMethod(String method) throws ProtocolException { + if (method == null) throw new ProtocolException("method is null"); + if (requestSent) throw new ProtocolException("Request already sent"); + + String m = method.trim().toUpperCase(Locale.ROOT); + if (!m.equals("GET") && !m.equals("POST")) { + throw new ProtocolException("Unsupported method: " + method); + } + this.method = m; + } + + @Override + public OutputStream getOutputStream() { + if (requestSent) throw new IllegalStateException("Request already sent"); + setDoOutput(true); + return requestBody; + } + + @Override + public void connect() { + // Intentionally no-op: JavaFX WebView may call connect() before writing POST body. + } + + private void ensureResponse() throws IOException { + if (requestSent) return; + + URL cur = this.url; + String methodStr = (this.method == null) ? "GET" : this.method.trim().toUpperCase(Locale.ROOT); + + Map carryHeaders = new LinkedHashMap<>(requestHeaders); + + String session = sessionJar.get(); + if (session != null && !session.isBlank() && headerValue(carryHeaders, "session") == null) { + carryHeaders.put("session", session); + } + + byte[] carryBody = null; + if (getDoOutput()) { + carryBody = requestBody.toByteArray(); + if ("POST".equals(methodStr) && carryBody == null) carryBody = new byte[0]; + } + + if ("POST".equals(methodStr) && headerValue(carryHeaders, "content-type") == null) { + carryHeaders.put("content-type", "application/x-www-form-urlencoded; charset=utf-8"); + } + + OacWebResponse resp = null; + + for (int i = 0; i <= MAX_REDIRECTS; i++) { + WebRequestContextProvider.WebRequestContext ctx = ctxProvider.contextFor(cur); + + resp = broker.fetch( + cur, + methodStr, + carryHeaders, + carryBody, + ctx.tabId(), + ctx.pageId(), + ctx.frameId() + ); + + String newSession = broker.extractSession(resp.headers()); + if (newSession != null && !newSession.isBlank()) { + sessionJar.store(newSession); + carryHeaders.put("session", newSession); + } + + int code = resp.statusCode(); + if (!getInstanceFollowRedirects()) break; + + if (code == 301 || code == 302 || code == 303 || code == 307 || code == 308) { + String loc = headerValue(resp.headers(), "location"); + if (loc == null || loc.isBlank()) break; + + try { + cur = new URL(cur, loc); + } catch (Exception ex) { + break; + } + + if (code == 303) { + methodStr = "GET"; + carryBody = null; + } else if ((code == 301 || code == 302) && "POST".equals(methodStr)) { + methodStr = "GET"; + carryBody = null; + } + + continue; + } + + break; + } + + this.response = resp; + this.requestSent = true; + } + + @Override + public InputStream getInputStream() throws IOException { + ensureResponse(); + if (response == null) return new ByteArrayInputStream(new byte[0]); + return response.bodyStream(); + } + + @Override + public InputStream getErrorStream() { + try { + ensureResponse(); + if (response == null) return null; + return (response.statusCode() >= 400) ? response.bodyStream() : null; + } catch (IOException e) { + return null; + } + } + + @Override + public int getResponseCode() throws IOException { + ensureResponse(); + return response == null ? -1 : response.statusCode(); + } + + @Override + public String getContentType() { + try { + ensureResponse(); + } catch (IOException e) { + return "application/octet-stream"; + } + String ct = (response == null) ? null : response.contentType(); + return (ct == null || ct.isBlank()) ? "application/octet-stream" : ct; + } + + @Override + public int getContentLength() { + try { + ensureResponse(); + } catch (IOException e) { + return -1; + } + long len = (response == null) ? -1L : response.contentLength(); + return (len <= 0 || len > Integer.MAX_VALUE) ? -1 : (int) len; + } + + @Override + public long getContentLengthLong() { + try { + ensureResponse(); + } catch (IOException e) { + return -1L; + } + return (response == null) ? -1L : response.contentLength(); + } + + @Override + public Map> getHeaderFields() { + try { + ensureResponse(); + } catch (IOException e) { + return Map.of(); + } + if (response == null) return Map.of(); + + Map> out = new LinkedHashMap<>(); + for (Map.Entry e : response.headers().entrySet()) { + String k = e.getKey(); + String v = e.getValue(); + if (k == null) continue; + out.put(k, v == null ? List.of() : List.of(v)); + } + return out; + } + + @Override + public String getHeaderField(String name) { + if (name == null) return null; + try { + ensureResponse(); + } catch (IOException e) { + return null; + } + if (response == null) return null; + return headerValue(response.headers(), name.trim().toLowerCase(Locale.ROOT)); + } + + @Override + public void disconnect() { + // No persistent socket owned by this object. + } + + @Override + public boolean usingProxy() { + return false; + } +} \ No newline at end of file diff --git a/src/main/java/org/openautonomousconnection/protocol/urlhandler/v1_0_1/beta/web/OacWebPacketListener.java b/src/main/java/org/openautonomousconnection/protocol/urlhandler/v1_0_1/beta/web/OacWebPacketListener.java new file mode 100644 index 0000000..1ed2154 --- /dev/null +++ b/src/main/java/org/openautonomousconnection/protocol/urlhandler/v1_0_1/beta/web/OacWebPacketListener.java @@ -0,0 +1,71 @@ +package org.openautonomousconnection.protocol.urlhandler.v1_0_1.beta.web; + +import dev.unlegitdqrk.unlegitlibrary.event.EventListener; +import dev.unlegitdqrk.unlegitlibrary.event.EventPriority; +import dev.unlegitdqrk.unlegitlibrary.event.Listener; +import dev.unlegitdqrk.unlegitlibrary.network.system.client.events.packets.C_PacketReadEvent; +import org.openautonomousconnection.protocol.packets.v1_0_1.beta.web.impl.resource.WebResourceResponsePacket; +import org.openautonomousconnection.protocol.packets.v1_0_1.beta.web.impl.stream.WebStreamChunkPacket_v1_0_1_B; +import org.openautonomousconnection.protocol.packets.v1_0_1.beta.web.impl.stream.WebStreamEndPacket_v1_0_1_B; +import org.openautonomousconnection.protocol.packets.v1_0_1.beta.web.impl.stream.WebStreamStartPacket_v1_0_1_B; +import org.openautonomousconnection.protocol.urlhandler.v1_0_1.beta.LibClientImpl_v1_0_1_B; + +import java.util.Map; +import java.util.Objects; + +/** + * Receives incoming WEB packets and forwards them into the broker and client callbacks. + */ +public final class OacWebPacketListener extends EventListener { + + private final OacWebRequestBroker broker; + private final LibClientImpl_v1_0_1_B impl; + + /** + * Creates a listener bound to the given broker. + * + * @param broker broker instance + * @param impl callback implementation + */ + public OacWebPacketListener(OacWebRequestBroker broker, LibClientImpl_v1_0_1_B impl) { + this.broker = Objects.requireNonNull(broker, "broker"); + this.impl = Objects.requireNonNull(impl, "impl"); + } + + @Listener(priority = EventPriority.HIGHEST) + public void onPacketRead(C_PacketReadEvent event) { + Object p = event.getPacket(); + + if (p instanceof WebResourceResponsePacket resp) { + broker.onResourceResponse(resp); + return; + } + + if (p instanceof WebStreamStartPacket_v1_0_1_B start) { + impl.streamStart( + start.getHeader(), + start.getStatusCode(), + start.getContentType(), + start.getHeaders() == null ? Map.of() : start.getHeaders(), + start.getTotalLength() + ); + broker.onStreamStart(start); + return; + } + + if (p instanceof WebStreamChunkPacket_v1_0_1_B chunk) { + byte[] data = (chunk.getData() == null) ? new byte[0] : chunk.getData(); + impl.streamChunk(chunk.getHeader(), chunk.getSeq(), data); + broker.onStreamChunk(chunk); + return; + } + + if (p instanceof WebStreamEndPacket_v1_0_1_B end) { + impl.streamEnd(end.getHeader(), end.isOk(), end.getError()); + byte[] full = broker.onStreamEndAndAssemble(end); + if (full != null) { + impl.streamFinish(end.getHeader(), full); + } + } + } +} \ No newline at end of file diff --git a/src/main/java/org/openautonomousconnection/protocol/urlhandler/v1_0_1/beta/web/OacWebProtocolModule_v1_0_1_B.java b/src/main/java/org/openautonomousconnection/protocol/urlhandler/v1_0_1/beta/web/OacWebProtocolModule_v1_0_1_B.java new file mode 100644 index 0000000..348c233 --- /dev/null +++ b/src/main/java/org/openautonomousconnection/protocol/urlhandler/v1_0_1/beta/web/OacWebProtocolModule_v1_0_1_B.java @@ -0,0 +1,51 @@ +package org.openautonomousconnection.protocol.urlhandler.v1_0_1.beta.web; + +import org.openautonomousconnection.protocol.ProtocolBridge; +import org.openautonomousconnection.protocol.urlhandler.v1_0_1.beta.LibClientImpl_v1_0_1_B; +import org.openautonomousconnection.protocol.urlhandler.v1_0_1.beta.OacUrlProtocolModule; + +import java.io.IOException; +import java.net.URL; +import java.net.URLConnection; +import java.util.Objects; + +/** + * WEB protocol module implementation for v1.0.1-BETA. + */ +public final class OacWebProtocolModule_v1_0_1_B implements OacUrlProtocolModule { + + private final OacSessionJar sessionJar = new OacSessionJar(); + private final WebRequestContextProvider ctxProvider; + private final WebFlagInspector flagInspector; + + public OacWebProtocolModule_v1_0_1_B(WebRequestContextProvider ctxProvider, WebFlagInspector flagInspector) { + this.ctxProvider = Objects.requireNonNull(ctxProvider, "ctxProvider"); + this.flagInspector = Objects.requireNonNull(flagInspector, "flagInspector"); + } + + @Override + public String scheme() { + return "web"; + } + + @Override + public URLConnection openConnection(URL url) throws IOException { + Objects.requireNonNull(url, "url"); + if (!"web".equalsIgnoreCase(url.getProtocol())) { + throw new IOException("Unsupported scheme for this module: " + url.getProtocol()); + } + return new OacWebHttpURLConnection(url, OacWebRequestBroker.get(), sessionJar, ctxProvider); + } + + @Override + public void install(ProtocolBridge protocolBridge, LibClientImpl_v1_0_1_B impl) { + Objects.requireNonNull(protocolBridge, "protocolBridge"); + Objects.requireNonNull(impl, "impl"); + + OacWebRequestBroker.get().attachRuntime(protocolBridge.getProtocolClient(), flagInspector); + + protocolBridge.getProtocolValues().eventManager.registerListener( + new OacWebPacketListener(OacWebRequestBroker.get(), impl) + ); + } +} \ No newline at end of file diff --git a/src/main/java/org/openautonomousconnection/protocol/urlhandler/v1_0_1/beta/web/OacWebRequestBroker.java b/src/main/java/org/openautonomousconnection/protocol/urlhandler/v1_0_1/beta/web/OacWebRequestBroker.java new file mode 100644 index 0000000..8088a23 --- /dev/null +++ b/src/main/java/org/openautonomousconnection/protocol/urlhandler/v1_0_1/beta/web/OacWebRequestBroker.java @@ -0,0 +1,576 @@ +package org.openautonomousconnection.protocol.urlhandler.v1_0_1.beta.web; + +import dev.unlegitdqrk.unlegitlibrary.network.system.utils.TransportProtocol; +import org.openautonomousconnection.protocol.packets.v1_0_1.beta.web.impl.resource.WebResourceRequestPacket; +import org.openautonomousconnection.protocol.packets.v1_0_1.beta.web.impl.resource.WebResourceResponsePacket; +import org.openautonomousconnection.protocol.packets.v1_0_1.beta.web.impl.stream.WebStreamChunkPacket_v1_0_1_B; +import org.openautonomousconnection.protocol.packets.v1_0_1.beta.web.impl.stream.WebStreamEndPacket_v1_0_1_B; +import org.openautonomousconnection.protocol.packets.v1_0_1.beta.web.impl.stream.WebStreamStartPacket_v1_0_1_B; +import org.openautonomousconnection.protocol.side.client.ProtocolClient; +import org.openautonomousconnection.protocol.versions.v1_0_1.beta.WebPacketFlags; +import org.openautonomousconnection.protocol.versions.v1_0_1.beta.WebPacketHeader; + +import java.io.*; +import java.net.URL; +import java.nio.file.*; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicLong; + +/** + * Multi-flight broker for WEB v1.0.1-BETA with best-effort streaming and temp-file assembly. + * + *

Range support: request headers are passed through unchanged (e.g. "Range").

+ * + *

Streaming is best-effort: missing sequence numbers are ignored.

+ *

Chunks are spooled to temp chunk files and merged into a final temp file on stream end.

+ */ +public final class OacWebRequestBroker { + + private static final OacWebRequestBroker INSTANCE = new OacWebRequestBroker(); + + private static final long RESPONSE_TIMEOUT_SECONDS = 30; + + private final ConcurrentHashMap inFlight = new ConcurrentHashMap<>(); + private final AtomicLong requestCounter = new AtomicLong(1); + + private volatile ProtocolClient client; + private volatile WebFlagInspector flagInspector; + + private OacWebRequestBroker() { + } + + /** + * @return global singleton instance + */ + public static OacWebRequestBroker get() { + return INSTANCE; + } + + /** + * Attaches runtime dependencies required for dispatching requests and interpreting flags. + * + *

Safe to call multiple times with the same instances. Different instances are rejected.

+ * + * @param client protocol client used to send packets + * @param flagInspector inspector for header flags (STREAM bit) + */ + public synchronized void attachRuntime(ProtocolClient client, WebFlagInspector flagInspector) { + Objects.requireNonNull(client, "client"); + Objects.requireNonNull(flagInspector, "flagInspector"); + + if (this.client == null && this.flagInspector == null) { + this.client = client; + this.flagInspector = flagInspector; + return; + } + + if (this.client == client && this.flagInspector == flagInspector) { + return; + } + + throw new IllegalStateException("OacWebRequestBroker runtime already initialized with different instances"); + } + + /** + * Sends a resource request and blocks until completion. + * + * @param url requested URL + * @param method HTTP-like method (GET/POST/...) + * @param headers request headers (passed through unchanged; includes Range) + * @param body request body (nullable) + * @param tabId tab id + * @param pageId page id + * @param frameId frame id + * @return resolved response + */ + public OacWebResponse fetch( + URL url, + String method, + Map headers, + byte[] body, + long tabId, + long pageId, + long frameId + ) throws IOException { + Objects.requireNonNull(url, "url"); + + ProtocolClient c = this.client; + if (c == null) { + throw new IllegalStateException("ProtocolClient not attached. Call OacWebRequestBroker.attachRuntime(...) during install."); + } + + String m = (method == null || method.isBlank()) ? "GET" : method.trim().toUpperCase(Locale.ROOT); + + long requestId = requestCounter.getAndIncrement(); + + int flags = WebPacketFlags.RESOURCE; + + WebPacketHeader header = new WebPacketHeader( + requestId, + tabId, + pageId, + frameId, + flags, + System.currentTimeMillis() + ); + + ResponseState st = new ResponseState(requestId); + inFlight.put(requestId, st); + + Map safeHeaders = (headers == null) ? Map.of() : new LinkedHashMap<>(headers); + byte[] safeBody = (body == null) ? new byte[0] : body; + + WebResourceRequestPacket packet = new WebResourceRequestPacket( + header, + url.toString(), + m, + safeHeaders, + safeBody, + safeHeaders.get("content-type"), + null, + null + ); + + try { + if (c.getClientServerConnection() == null || !c.getClientServerConnection().isConnected()) { + inFlight.remove(requestId); + throw new IOException("ServerConnection is not connected"); + } + + c.getClientServerConnection().sendPacket(packet, TransportProtocol.TCP); + } catch (Exception e) { + inFlight.remove(requestId); + throw new IOException("Failed to send WebResourceRequestPacket", e); + } + + return awaitAndBuildResponse(st); + } + + /** + * Extracts the session token from response headers (case-insensitive). + * + * @param headers headers + * @return session token or null + */ + public String extractSession(Map headers) { + return headerValue(headers, "session"); + } + + /** + * Handles a WebResourceResponsePacket. + * + *

If STREAM flag is set in header, body is expected to be streamed via start/chunk/end packets.

+ * + * @param p packet + */ + public void onResourceResponse(WebResourceResponsePacket p) { + if (p == null || p.getHeader() == null) return; + + ResponseState st = inFlight.get(p.getHeader().getRequestId()); + if (st == null) return; + + synchronized (st.lock) { + if (st.completed) return; + + st.statusCode = p.getStatusCode(); + st.contentType = safeContentType(p.getContentType()); + st.headers = safeHeaders(p.getHeaders()); + + boolean stream = false; + WebFlagInspector inspector = this.flagInspector; + if (inspector != null) { + stream = inspector.isStream(p.getHeader()); + } + + if (!stream) { + byte[] b = (p.getBody() == null) ? new byte[0] : p.getBody(); + st.memoryBody = b; + st.success = true; + st.completed = true; + st.done.countDown(); + return; + } + + st.streamExpected = true; + + // Some servers may already include metadata here; do not complete until stream end. + } + } + + /** + * Handles stream start. + * + * @param p start packet + */ + public void onStreamStart(WebStreamStartPacket_v1_0_1_B p) { + if (p == null || p.getHeader() == null) return; + + ResponseState st = inFlight.get(p.getHeader().getRequestId()); + if (st == null) return; + + synchronized (st.lock) { + if (st.completed) return; + + st.statusCode = p.getStatusCode(); + st.contentType = safeContentType(p.getContentType()); + st.headers = safeHeaders(p.getHeaders()); + st.totalLength = p.getTotalLength(); + + st.streamExpected = true; + + if (st.spooler == null) { + try { + st.spooler = TempChunkSpooler.create(st.requestId); + st.spoolDir = st.spooler.dir(); + } catch (IOException e) { + failLocked(st, "Failed to create stream spooler: " + e.getMessage()); + } + } + } + } + + /** + * Handles stream chunk (best-effort; stores chunk by seq). + * + * @param p chunk packet + */ + public void onStreamChunk(WebStreamChunkPacket_v1_0_1_B p) { + if (p == null || p.getHeader() == null) return; + + ResponseState st = inFlight.get(p.getHeader().getRequestId()); + if (st == null) return; + + int seq = p.getSeq(); + if (seq < 0) return; + + byte[] data = (p.getData() == null) ? new byte[0] : p.getData(); + if (data.length == 0) return; + + synchronized (st.lock) { + if (st.completed) return; + + st.streamExpected = true; + + if (st.spooler == null) { + try { + st.spooler = TempChunkSpooler.create(st.requestId); + st.spoolDir = st.spooler.dir(); + } catch (IOException e) { + failLocked(st, "Failed to create stream spooler: " + e.getMessage()); + return; + } + } + + try { + st.spooler.writeChunk(seq, data); + if (seq > st.maxSeqSeen) st.maxSeqSeen = seq; + } catch (IOException e) { + failLocked(st, "Failed to spool stream chunk: " + e.getMessage()); + } + } + } + + /** + * Handles stream end and assembles best-effort output. + * + *

On success, assembles final temp file and returns full content bytes (optional; may be large).

+ * + * @param p end packet + * @return full content bytes (best-effort) or null on failure + */ + public byte[] onStreamEndAndAssemble(WebStreamEndPacket_v1_0_1_B p) { + if (p == null || p.getHeader() == null) return null; + + ResponseState st = inFlight.get(p.getHeader().getRequestId()); + if (st == null) return null; + + synchronized (st.lock) { + if (st.completed) return null; + + st.success = p.isOk(); + st.errorMessage = p.getError(); + + if (!st.success) { + st.completed = true; + st.done.countDown(); + return null; + } + + if (st.spooler == null) { + // No chunks received; treat as empty success. + st.finalFile = null; + st.completed = true; + st.done.countDown(); + return new byte[0]; + } + + st.spoolDir = st.spooler.dir(); + + try { + st.finalFile = st.spooler.assembleBestEffort(st.maxSeqSeen); + } catch (IOException e) { + failLocked(st, "Failed to assemble stream: " + e.getMessage()); + return null; + } finally { + try { + st.spooler.close(); + } catch (IOException ignored) { + } + } + + st.completed = true; + st.done.countDown(); + + // Optional: deliver full bytes to callback (requested). + // WARNING: This can be large. You explicitly requested it, so we do it. + if (st.finalFile == null) return new byte[0]; + + try (InputStream in = Files.newInputStream(st.finalFile)) { + return in.readAllBytes(); + } catch (IOException e) { + // Assembly is still fine; just no full byte[]. + return null; + } + } + } + + private OacWebResponse awaitAndBuildResponse(ResponseState st) throws IOException { + try { + if (!st.done.await(RESPONSE_TIMEOUT_SECONDS, TimeUnit.SECONDS)) { + removeAndCleanup(st); + throw new IOException("Timeout waiting for web response (requestId=" + st.requestId + ")"); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + removeAndCleanup(st); + throw new IOException("Interrupted while waiting for web response (requestId=" + st.requestId + ")", e); + } + + // Remove from map; response is completed now. + inFlight.remove(st.requestId); + + synchronized (st.lock) { + if (!st.success) { + cleanupLocked(st); + String msg = (st.errorMessage == null || st.errorMessage.isBlank()) ? "Request failed" : st.errorMessage; + throw new IOException(msg); + } + + // Non-stream response + if (!st.streamExpected && st.finalFile == null) { + byte[] b = (st.memoryBody == null) ? new byte[0] : st.memoryBody; + return new OacWebResponse( + st.statusCode, + safeContentType(st.contentType), + OacWebResponse.safeHeaders(st.headers), + new ByteArrayInputStream(b), + b.length + ); + } + + // Stream response -> return InputStream backed by final temp file (delete-on-close) + if (st.finalFile == null) { + // Valid empty stream + return new OacWebResponse( + st.statusCode, + safeContentType(st.contentType), + OacWebResponse.safeHeaders(st.headers), + new ByteArrayInputStream(new byte[0]), + 0L + ); + } + + InputStream fileIn = Files.newInputStream(st.finalFile, StandardOpenOption.READ); + // Delete final file + spool directory when WebView/URLConnection closes the stream. + return new OacWebResponse( + st.statusCode, + safeContentType(st.contentType), + OacWebResponse.safeHeaders(st.headers), + new DeleteOnCloseInputStream(fileIn, st.finalFile, st.spoolDir), + safeFileSize(st.finalFile) + ); + } + } + + private void removeAndCleanup(ResponseState st) { + inFlight.remove(st.requestId); + synchronized (st.lock) { + cleanupLocked(st); + } + } + + private static void cleanupLocked(ResponseState st) { + if (st.spooler != null) { + try { + st.spooler.close(); + } catch (IOException ignored) { + } + st.spooler = null; + } + if (st.finalFile != null) { + try { + Files.deleteIfExists(st.finalFile); + } catch (IOException ignored) { + } + st.finalFile = null; + } + if (st.spoolDir != null) { + try { + // Let DeleteOnCloseInputStream handle recursive deletion in success path, + // but ensure cleanup on failure/timeout. + if (Files.exists(st.spoolDir)) { + // best-effort delete + try (DirectoryStream ds = Files.newDirectoryStream(st.spoolDir)) { + for (Path p : ds) { + try { + Files.deleteIfExists(p); + } catch (IOException ignored2) { + } + } + } + try { + Files.deleteIfExists(st.spoolDir); + } catch (IOException ignored2) { + } + } + } catch (IOException ignored) { + } + st.spoolDir = null; + } + } + + private static void failLocked(ResponseState st, String message) { + st.success = false; + st.errorMessage = message; + st.completed = true; + st.done.countDown(); + } + + private static long safeFileSize(Path p) { + try { + return Files.size(p); + } catch (IOException e) { + return -1L; + } + } + + private static String safeContentType(String ct) { + return (ct == null || ct.isBlank()) ? "application/octet-stream" : ct; + } + + private static Map safeHeaders(Map headers) { + return (headers == null || headers.isEmpty()) ? Map.of() : Map.copyOf(headers); + } + + private static String headerValue(Map headers, String nameLower) { + if (headers == null || headers.isEmpty() || nameLower == null) return null; + String needle = nameLower.trim().toLowerCase(Locale.ROOT); + for (Map.Entry e : headers.entrySet()) { + if (e.getKey() == null) continue; + if (e.getKey().trim().toLowerCase(Locale.ROOT).equals(needle)) { + return e.getValue(); + } + } + return null; + } + + /** + * Per-request state (multi-flight, correlated by requestId). + */ + private static final class ResponseState { + private final long requestId; + private final Object lock = new Object(); + private final CountDownLatch done = new CountDownLatch(1); + + private int statusCode = 0; + private String contentType = "application/octet-stream"; + private Map headers = Map.of(); + + private boolean streamExpected; + private long totalLength = -1L; + + private int maxSeqSeen = -1; + + private boolean completed; + private boolean success; + private String errorMessage; + + private byte[] memoryBody; + + private TempChunkSpooler spooler; + private Path spoolDir; + private Path finalFile; + + private ResponseState(long requestId) { + this.requestId = requestId; + } + } + + /** + * Spools stream chunks to temp files and assembles best-effort output on end. + */ + private static final class TempChunkSpooler implements Closeable { + + private final Path dir; + + private TempChunkSpooler(Path dir) { + this.dir = dir; + } + + static TempChunkSpooler create(long requestId) throws IOException { + Path dir = Files.createTempDirectory("oac-web-stream-" + requestId + "-"); + return new TempChunkSpooler(dir); + } + + void writeChunk(int seq, byte[] data) throws IOException { + // One file per seq -> no offset assumptions needed. + Path p = dir.resolve(seq + ".chunk"); + Files.write(p, data, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING, StandardOpenOption.WRITE); + } + + Path assembleBestEffort(int maxSeqSeen) throws IOException { + Path out = Files.createTempFile("oac-web-stream-final-", ".bin"); + + try (OutputStream os = Files.newOutputStream(out, StandardOpenOption.TRUNCATE_EXISTING, StandardOpenOption.WRITE)) { + for (int seq = 0; seq <= maxSeqSeen; seq++) { + Path p = dir.resolve(seq + ".chunk"); + if (!Files.exists(p)) { + continue; // best-effort: skip gaps + } + try (InputStream in = Files.newInputStream(p, StandardOpenOption.READ)) { + in.transferTo(os); + } + Files.deleteIfExists(p); + } + } + + return out; + } + + Path dir() { + return dir; + } + + @Override + public void close() throws IOException { + // Cleanup remaining chunk files (best-effort). + if (!Files.exists(dir)) return; + + try (DirectoryStream ds = Files.newDirectoryStream(dir)) { + for (Path p : ds) { + try { + Files.deleteIfExists(p); + } catch (IOException ignored) { + } + } + } + try { + Files.deleteIfExists(dir); + } catch (IOException ignored) { + } + } + } +} \ No newline at end of file diff --git a/src/main/java/org/openautonomousconnection/protocol/urlhandler/v1_0_1/beta/web/OacWebResponse.java b/src/main/java/org/openautonomousconnection/protocol/urlhandler/v1_0_1/beta/web/OacWebResponse.java new file mode 100644 index 0000000..0da4ab1 --- /dev/null +++ b/src/main/java/org/openautonomousconnection/protocol/urlhandler/v1_0_1/beta/web/OacWebResponse.java @@ -0,0 +1,32 @@ +package org.openautonomousconnection.protocol.urlhandler.v1_0_1.beta.web; + +import java.io.InputStream; +import java.util.Collections; +import java.util.Map; +import java.util.Objects; + +/** + * Represents a resolved web response for the JavaFX WebView. + * + * @param statusCode HTTP-like status code + * @param contentType response content-type + * @param headers response headers + * @param bodyStream body stream + * @param contentLength content length if known, else -1 + */ +public record OacWebResponse( + int statusCode, + String contentType, + Map headers, + InputStream bodyStream, + long contentLength +) { + public OacWebResponse { + Objects.requireNonNull(headers, "headers"); + Objects.requireNonNull(bodyStream, "bodyStream"); + } + + public static Map safeHeaders(Map h) { + return (h == null) ? Collections.emptyMap() : Collections.unmodifiableMap(h); + } +} \ No newline at end of file diff --git a/src/main/java/org/openautonomousconnection/protocol/urlhandler/v1_0_1/beta/web/WebFlagInspector.java b/src/main/java/org/openautonomousconnection/protocol/urlhandler/v1_0_1/beta/web/WebFlagInspector.java new file mode 100644 index 0000000..5d3bc74 --- /dev/null +++ b/src/main/java/org/openautonomousconnection/protocol/urlhandler/v1_0_1/beta/web/WebFlagInspector.java @@ -0,0 +1,30 @@ +package org.openautonomousconnection.protocol.urlhandler.v1_0_1.beta.web; + +import org.openautonomousconnection.protocol.versions.v1_0_1.beta.WebPacketFlags; +import org.openautonomousconnection.protocol.versions.v1_0_1.beta.WebPacketHeader; + +import java.util.Objects; + +/** + * Interprets {@link WebPacketHeader#getFlags()} into semantic booleans. + */ +public interface WebFlagInspector { + + /** + * @param header web packet header + * @return true if the packet is part of a streamed body transfer + */ + boolean isStream(WebPacketHeader header); + + /** + * Default implementation based on {@link WebPacketFlags#STREAM}. + */ + final class Default implements WebFlagInspector { + + @Override + public boolean isStream(WebPacketHeader header) { + Objects.requireNonNull(header, "header"); + return WebPacketFlags.has(header.getFlags(), WebPacketFlags.STREAM); + } + } +} \ No newline at end of file diff --git a/src/main/java/org/openautonomousconnection/protocol/urlhandler/v1_0_1/beta/web/WebRequestContextProvider.java b/src/main/java/org/openautonomousconnection/protocol/urlhandler/v1_0_1/beta/web/WebRequestContextProvider.java new file mode 100644 index 0000000..443b739 --- /dev/null +++ b/src/main/java/org/openautonomousconnection/protocol/urlhandler/v1_0_1/beta/web/WebRequestContextProvider.java @@ -0,0 +1,37 @@ +package org.openautonomousconnection.protocol.urlhandler.v1_0_1.beta.web; + +import java.net.URL; + +/** + * Provides per-request WEB correlation context (tab/page/frame). + */ +public interface WebRequestContextProvider { + + /** + * Returns the context to use for this URL request. + * + * @param url requested URL + * @return context (never null) + */ + WebRequestContext contextFor(URL url); + + /** + * Immutable context DTO. + * + * @param tabId stable tab id + * @param pageId navigation instance id + * @param frameId frame id (0 = main frame) + */ + record WebRequestContext(long tabId, long pageId, long frameId) { + } + + /** + * Default context provider (tab/page/frame = 0). + */ + final class Default implements WebRequestContextProvider { + @Override + public WebRequestContext contextFor(URL url) { + return new WebRequestContext(0L, 0L, 0L); + } + } +} \ No newline at end of file diff --git a/src/main/java/org/openautonomousconnection/protocol/versions/v1_0_1/beta/compat/WebCompatMapper.java b/src/main/java/org/openautonomousconnection/protocol/versions/v1_0_1/beta/compat/WebCompatMapper.java index 55b1f20..2cacef6 100644 --- a/src/main/java/org/openautonomousconnection/protocol/versions/v1_0_1/beta/compat/WebCompatMapper.java +++ b/src/main/java/org/openautonomousconnection/protocol/versions/v1_0_1/beta/compat/WebCompatMapper.java @@ -262,6 +262,7 @@ public final class WebCompatMapper { return switch (method) { case GET -> "GET"; case POST, EXECUTE, SCRIPT -> "POST"; + default -> "GET"; }; } @@ -274,7 +275,7 @@ public final class WebCompatMapper { public static WebRequestMethod map100BMethod(String method) { if (method == null) return WebRequestMethod.GET; return switch (method.toUpperCase()) { - case "POST" -> WebRequestMethod.POST; + case "POST", "SCRIPT", "EXECUTE" -> WebRequestMethod.POST; default -> WebRequestMethod.GET; }; }