From 56d836724fd191ce4fa4574ba7402c788da03384 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 21 Jun 2026 15:34:36 +0000 Subject: [PATCH] feat(napplet): structured-clone transport + relay.subscribe push channel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stock napplets (built on @napplet/core) post structured-clone OBJECTS, not JSON strings, and expect object replies — our shell only forwarded strings, so their messages were dropped. Bridge the transport so real napplets interop: - shell.html bridges object<->string both directions: applet->native serializes object envelopes; native->applet parses and posts a structured-clone object (what the SDK reads via e.data.type). - resource.bytes returns a real Blob: the shell rebuilds it from the host's base64 bytes+mime before delivering. - relay.subscribe is push-based: relay.event (per match) then relay.eose, keyed by subId, no .result — matching @napplet/shim. New MSG_PUSH IPC frame carries unsolicited envelopes the host forwards verbatim; relay.close is a fire-and-forget no-op. Delivers the initial snapshot then EOSE (a live tail is a follow-up). Injected shim updated to accept object messages, dispatch subscription pushes by subId, and use the push-based subscribe. Codec gains encodeRelayEvent/encodeRelayEose/readSubId with unit tests. The shell/shim are JS and not covered by the JVM tests — this needs on-device verification with a playground napplet. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde --- .../2026-06-20-napplet-ecosystem-audit.md | 26 +++++-- amethyst/src/main/assets/napplet/shell.html | 25 +++++-- .../amethyst/napplet/NappletBrokerService.kt | 36 +++++++++- .../amethyst/napplet/NappletHostActivity.kt | 67 ++++++++++++------- .../amethyst/napplet/NappletIpc.kt | 7 ++ .../amethyst/napplet/NappletProtocolJson.kt | 22 ++++++ .../napplet/NappletProtocolJsonTest.kt | 25 +++++++ 7 files changed, 171 insertions(+), 37 deletions(-) diff --git a/amethyst/plans/2026-06-20-napplet-ecosystem-audit.md b/amethyst/plans/2026-06-20-napplet-ecosystem-audit.md index c4a237c133..9002c4ef7e 100644 --- a/amethyst/plans/2026-06-20-napplet-ecosystem-audit.md +++ b/amethyst/plans/2026-06-20-napplet-ecosystem-audit.md @@ -151,14 +151,26 @@ than dumping raw content. Corrected in code: identity reads now emit method-spec `storage.keys` returns `keys`; `relay.publish`/`publishEncrypted` read the template from `event`; `getProfile` builds a `ProfileData` object. Locked by `NappletProtocolJsonTest`. -**Transport gap still open (the real blocker for stock napplets).** `@napplet/core` posts +**Transport: structured-clone objects + subscription push — landed.** `@napplet/core` posts **structured-clone objects** (not JSON strings) via `target.postMessage(obj)` and validates -cloneability — that is how `resource.bytes` returns a real `Blob`. Our shell relay -(`shell.html`) only forwards `typeof e.data === 'string'` and posts replies back as strings, so a -stock napplet's object messages are dropped today. Making the shell bridge object↔string (and -converting the `resource.bytes` base64 reply into a real `Blob`), plus the `relay.subscribe` -push channel (`relay.event`/`relay.eose`, no `.result`) and multi-`filters` queries, is the next -focused pass — and it needs on-device verification. +cloneability — that is how `resource.bytes` returns a real `Blob`, and why a stock napplet's +object messages were dropped before (our shell only forwarded strings). Fixed: + +- **`shell.html` bridges object↔string both ways.** applet→native serializes object envelopes to + the string the native bridge carries (requests carry no Blobs); native→applet parses the reply + to an object and posts a **structured-clone object** (what the SDK reads via `e.data.type`), not + a string. The injected shim accepts either form. +- **`resource.bytes` Blob.** The shell rebuilds a real `Blob` from the host's base64 `bytes`+`mime` + before delivering, so both the SDK and our shim resolve to a `Blob`. +- **`relay.subscribe` push channel.** Subscriptions are answered with `relay.event` (one per match) + then `relay.eose`, keyed by `subId` — no `.result`, matching the SDK. A new `MSG_PUSH` IPC frame + lets the broker push unsolicited envelopes the host forwards verbatim; `relay.close` is a + fire-and-forget no-op. Today this delivers the **initial snapshot then EOSE**. + +Still open: a **live subscription tail** (push as events arrive, not just the snapshot) plus +`identity.onChanged`/`inc.on`; multi-`filters` queries (we use the first filter); the Blossom +`upload` gateway; and **on-device verification** — the shell/shim changes are JS and not exercised +by the JVM unit tests. ## Update (2026-06-20, later): verified against `@napplet/shim@0.16.0` and corrected diff --git a/amethyst/src/main/assets/napplet/shell.html b/amethyst/src/main/assets/napplet/shell.html index cabb9a1a1b..753658f83e 100644 --- a/amethyst/src/main/assets/napplet/shell.html +++ b/amethyst/src/main/assets/napplet/shell.html @@ -22,16 +22,33 @@ var bridge = window.__nappletBridge; // applet -> native: only forward messages that came from our applet iframe. + // The @napplet SDK posts structured-clone objects; our injected shim posts strings. The + // native bridge carries only strings, so serialize objects here. (Requests carry no Blobs.) window.addEventListener('message', function (e) { if (e.source !== iframe.contentWindow) return; - if (typeof e.data !== 'string') return; - if (bridge) bridge.postMessage(e.data); + var data = e.data; + if (typeof data !== 'string') { + try { data = JSON.stringify(data); } catch (_) { return; } + } + if (bridge) bridge.postMessage(data); }); - // native -> applet: hand the broker's reply down into the iframe. + // native -> applet: hand the broker's reply (or a subscription push) down into the iframe as + // a structured-clone object, which is what the SDK expects (it reads e.data.type, not a string). if (bridge) { bridge.onmessage = function (e) { - iframe.contentWindow.postMessage(e.data, '*'); + var msg; + try { msg = JSON.parse(e.data); } catch (_) { return; } + // resource.bytes resolves to a real Blob in the SDK; rebuild it from the base64 the host sent. + if (msg && msg.type === 'resource.bytes.result' && typeof msg.bytes === 'string') { + try { + var bin = atob(msg.bytes), u = new Uint8Array(bin.length); + for (var i = 0; i < bin.length; i++) u[i] = bin.charCodeAt(i); + msg.blob = new Blob([u], { type: msg.mime || '' }); + delete msg.bytes; + } catch (_) {} + } + iframe.contentWindow.postMessage(msg, '*'); }; } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt index c4deb36dab..ca71b52bc7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt @@ -127,8 +127,26 @@ class NappletBrokerService : Service() { val requestType = runCatching { NappletProtocolJson.readType(payload) }.getOrNull() ?: "napplet" scope.launch { + // Unsubscribe is fire-and-forget: snapshot subscriptions have no live tail to cancel. + if (requestType == "relay.close") { + reply(replyTo, requestId, NappletProtocolJson.encodeResponse(requestType, NappletResponse.Done)) + return@launch + } + val response = process(identity, declared, payload) - reply(replyTo, requestId, NappletProtocolJson.encodeResponse(requestType, response)) + + // A subscription is answered with relay.event/relay.eose pushes (keyed by subId), not a + // .result — matching @napplet/shim. Today it delivers the initial matches then EOSE; a + // live tail is a follow-up. + val subId = if (requestType == "relay.subscribe") runCatching { NappletProtocolJson.readSubId(payload) }.getOrNull() else null + if (subId != null) { + if (response is NappletResponse.Events) { + response.events.forEach { push(replyTo, NappletProtocolJson.encodeRelayEvent(subId, it)) } + } + push(replyTo, NappletProtocolJson.encodeRelayEose(subId)) + } else { + reply(replyTo, requestId, NappletProtocolJson.encodeResponse(requestType, response)) + } } return true } @@ -416,6 +434,22 @@ class NappletBrokerService : Service() { } } + /** Sends an unsolicited push (a `relay.event`/`relay.eose` envelope) for the host to forward verbatim. */ + private fun push( + replyTo: Messenger, + payload: String, + ) { + val message = + Message.obtain(null, NappletIpc.MSG_PUSH).apply { + data = Bundle().apply { putString(NappletIpc.KEY_PAYLOAD, payload) } + } + try { + replyTo.send(message) + } catch (e: RemoteException) { + Log.w("NappletBrokerService", "Applet host went away before push could be delivered", e) + } + } + companion object { private const val QUERY_TIMEOUT_MS = 8_000L private const val WALLET_TIMEOUT_MS = 60_000L diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletHostActivity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletHostActivity.kt index 3698b523b4..856944e0a6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletHostActivity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletHostActivity.kt @@ -357,15 +357,24 @@ class NappletHostActivity : ComponentActivity() { } private fun onBrokerReply(msg: Message): Boolean { - if (msg.what != NappletIpc.MSG_RESPONSE) return false val data = msg.data ?: return true - val id = data.getString(NappletIpc.KEY_REQUEST_ID) ?: return true - val payload = data.getString(NappletIpc.KEY_PAYLOAD) ?: return true + when (msg.what) { + NappletIpc.MSG_RESPONSE -> { + val id = data.getString(NappletIpc.KEY_REQUEST_ID) ?: return true + val payload = data.getString(NappletIpc.KEY_PAYLOAD) ?: return true - // payload is the broker's {type:"...result", ok, ...}; inject the correlation id for the shim. - val result = runCatching { JSONObject(payload) }.getOrNull() ?: JSONObject() - result.put("id", id) - bridgeReplyProxy?.postMessage(result.toString()) + // payload is the broker's {type:"...result", ok, ...}; inject the correlation id for the shim. + val result = runCatching { JSONObject(payload) }.getOrNull() ?: JSONObject() + result.put("id", id) + bridgeReplyProxy?.postMessage(result.toString()) + } + // A subscription push (relay.event/relay.eose) is keyed by subId, not a request id; forward verbatim. + NappletIpc.MSG_PUSH -> { + val payload = data.getString(NappletIpc.KEY_PAYLOAD) ?: return true + bridgeReplyProxy?.postMessage(payload) + } + else -> return false + } return true } @@ -419,28 +428,35 @@ class NappletHostActivity : ComponentActivity() { private const val SHIM_JS = """ (function(){ if (window.__nappletShimInstalled) return; window.__nappletShimInstalled = true; - var seq = 0, pending = {}; + var seq = 0, pending = {}, subs = {}; + function send(env){ env.id = env.id || ('r' + (seq++)); parent.postMessage(JSON.stringify(env), '*'); return env.id; } function call(type, fields){ return new Promise(function(resolve, reject){ - var id = 'r' + (seq++); + var env = { type: type }; if (fields) for (var k in fields) env[k] = fields[k]; + var id = send(env); pending[id] = { resolve: resolve, reject: reject }; - var env = { type: type, id: id }; - if (fields) for (var k in fields) env[k] = fields[k]; - parent.postMessage(JSON.stringify(env), '*'); }); } + // Fire-and-forget (no .result awaited), used for subscribe/unsubscribe. + function post(type, fields){ var env = { type: type }; if (fields) for (var k in fields) env[k] = fields[k]; send(env); } window.addEventListener('message', function(e){ if (e.source !== parent) return; - if (typeof e.data !== 'string') return; - var msg; try { msg = JSON.parse(e.data); } catch (_) { return; } - if (!msg || !msg.id) return; + var msg; if (typeof e.data === 'string') { try { msg = JSON.parse(e.data); } catch (_) { return; } } else { msg = e.data; } + if (!msg) return; + // Subscription pushes are keyed by subId, not a request id. + if (msg.type === 'relay.event' || msg.type === 'relay.eose') { + var sub = subs[msg.subId]; if (!sub) return; + if (msg.type === 'relay.event') { if (sub.onEvent) sub.onEvent(msg.event); } + else { if (sub.onEose) sub.onEose(); } + return; + } + if (!msg.id) return; var p = pending[msg.id]; if (!p) return; delete pending[msg.id]; if (msg.ok) p.resolve(msg); else { var err = new Error(msg.reason || msg.operation || msg.error || 'napplet error'); err.napplet = msg; p.reject(err); } }); function field(promise, name){ return promise.then(function(m){ return m[name]; }); } function normFilters(filters){ return Array.isArray(filters) ? { filters: filters } : { filter: filters || {} }; } - function b64ToBytes(b64){ var bin = atob(b64); var u = new Uint8Array(bin.length); for (var i=0;i