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:
+ *Important:
+ *Protocol limitation: no correlation id -> single-flight (one in-flight request at a time).
+ * + *UDP streaming semantics (best-effort):
+ *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, MapUDP 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, MapCall 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 ConcurrentHashMapJVM lookup rule: it searches {@code
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 MapRange 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 ConcurrentHashMapSafe 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, + MapIf 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