refactor(napplet): audit fixes — sub lifecycle, broker/http caching, shim asset

Code-audit pass over the napplet subsystem (see
plans/2026-06-21-napplet-code-audit.md):

Correctness:
- Live subscriptions now store the exact INostrClient that opened them, so
  teardown unsubscribes from the right account even after an account
  switch (previously leaked on the original account).
- Multi-relay subscriptions emit a single relay.eose via an eoseSent
  latch, instead of one per relay (the SDK expects one).

Performance:
- The broker is cached per account (reference identity) instead of rebuilt
  on every request.
- The blob OkHttpClient is cached and reused (keyed by Tor port) for
  connection pooling, instead of a new client per fetch.

Refactor / docs:
- Moved the 105-line injected shim from a Kotlin string constant to
  assets/napplet/shim.js (loaded once like shell.html); corrected stale
  onChanged / subscribe comments.

Deferred (documented with rationale): cross-relay event dedup,
background pause/resume of live subs, request ordering, and the
recommended NappletProtocolJson -> commons/jvmAndroid move.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
This commit is contained in:
Claude
2026-06-21 22:01:57 +00:00
parent cd2bae081e
commit dfc237d27b
4 changed files with 233 additions and 136 deletions
@@ -0,0 +1,63 @@
# Napplet subsystem code audit — bugs, performance, refactor, placement
**Date:** 2026-06-21. Scope: the napplet/nsite subsystem (`amethyst/.../napplet/`,
`commons/.../napplet/`, `quartz/.../nip5aStaticWebsites` + `nip5dNapplets`).
## Fixed in this pass
| # | Category | Issue | Fix |
|---|---|---|---|
| 1 | 🐛 correctness | **Live subscription unsubscribed from the wrong client after an account switch**`closeLiveSubscription`/`onDestroy` used the *current* account's client, leaking the sub on the original. | `LiveSub` holder stores the exact `INostrClient` that opened the sub; teardown uses it. |
| 2 | 🐛 correctness | **Multi-relay subscriptions emitted N `relay.eose`** (one per relay) — the SDK expects one. | An `eoseSent` latch (`compareAndSet`) emits a single `relay.eose`. |
| 3 | ⚡ perf | **Broker rebuilt on every request** (new gateways/prompt each call). | `broker()` caches per account (reference identity), rebuilt only on switch. |
| 4 | ⚡ perf | **A fresh `OkHttpClient` per blob fetch** (no connection pooling). | `blobHttpClient()` caches the client keyed by Tor port (`@Synchronized`). |
| 5 | ♻️ refactor | **105-line `SHIM_JS` string constant** in `NappletHostActivity.kt` (no highlighting, hard to edit). | Moved to `assets/napplet/shim.js`, loaded once like `shell.html`. |
| 6 | 📝 docs | Stale shim comments (`onChanged` "follow-up", `subscribe` "snapshot…follow-up"). | Rewritten to match reality (no-op onChanged; live tail). |
All compile; `commons:jvmTest` + the amethyst napplet suite stay green.
## Deferred — with rationale (not silently dropped)
- **Duplicate events across relays** — the same event id can arrive from multiple relays, so
`relay.event` is pushed more than once. Deduping needs a per-subscription seen-id set (unbounded
memory for long subs); napplets already dedupe by id. Left as-is; documented.
- **Background teardown of live subscriptions** — a backgrounded-but-alive napplet keeps its relay
subscription open (the WebView is paused, but the service keeps streaming). It is *not* a
permanent leak: the service is bind-only, so closing the napplet → `unbindService` → service
`onDestroy` → all subs torn down. A proper pause/resume (unsubscribe on background, re-`REQ` on
foreground) is a real optimization but needs an IPC pause/resume signal + device verification.
- **Request ordering** — requests are handled concurrently (`scope.launch` per message), so
`storage.set` then `storage.get` aren't guaranteed in-order. Matches the SDK's async model;
serializing would hurt throughput. Documented, not changed.
- **`runBlocking` in `shouldInterceptRequest`** — this runs on a WebView *background* worker thread
(not the UI thread), so blocking there during a blob fetch is acceptable; WebView fans out
resource loads across workers. Left as-is.
- **Over-flags from the sweep that aren't real:** `pendingRequests` / `bridgeReplyProxy` "races" —
both the `WebMessageListener` callback and the reply `Handler` run on the **main looper**, so
there is no cross-thread access. A null `bridgeReplyProxy` only drops a reply to an
already-gone WebView (the applet is gone too) — harmless.
## Recommended moves to commons / quartz
- **quartz (protocol-only): correct as-is.** NIP-5A/5D events, `NappletManifest`,
`StaticSiteResolver` + `StaticSitePathLookup` (`sniffContentType`), `SiteAggregateHash` are all in
`commonMain`. No app policy leaked in.
- **commons (shared logic): mostly correct.** Broker, capability, identity, request/response,
permissions ledger/store, and gateway interfaces are in `commonMain` — right home.
- **Recommended move: `NappletProtocolJson``commons/jvmAndroid`.** It's pure wire-marshalling
(kotlinx.serialization + `java.util.Base64`) over the commons protocol types; commons already has
a `jvmAndroid` source set and depends on `kotlinx.serialization.json`. Moving it (and its test)
co-locates the codec with the protocol and lets a future **desktop** napplet host reuse it. Modest
churn (the test is JUnit4 → would move to commons `jvmTest`), no functional gain today, so it's a
*recommended*, not urgent, refactor. (Left in `amethyst/` for now.)
- **amethyst (Android-only): correctly platform-bound.** `NappletHostActivity` (WebView/process),
`NappletBrokerService` (Service/Messenger/account), the gateway *implementations* (account,
`BlossomUploader`, DataStore, NWC), `NappletLauncher`, consent UI, `NappletIpc`, and the screens
all belong here.
## Lower-priority refactors (not done)
- Extract a shared Tor-aware OkHttp builder (host `buildHttpClient` vs service `blobHttpClient`
duplicate the proxy logic) into a small util.
- `summaryFor`'s long `when` could become a per-request-type method, and `encodeResponse`'s big
`when` is repetitive — both are readability, not correctness.
+107
View File
@@ -0,0 +1,107 @@
// Injected window.napplet.* client shim. Namespaced to match the @napplet/shim SDK over the
// {type:"domain.action", id} envelope, so an applet built against that SDK runs unchanged here.
// This is loaded as an asset and injected into the applet document by NappletHostActivity.
(function(){
if (window.__nappletShimInstalled) return; window.__nappletShimInstalled = true;
var seq = 0, pending = {}, subs = {}, actions = {};
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 env = { type: type }; if (fields) for (var k in fields) env[k] = fields[k];
var id = send(env);
pending[id] = { resolve: resolve, reject: reject };
});
}
// 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;
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' || msg.type === 'relay.closed') {
var sub = subs[msg.subId]; if (!sub) return;
if (msg.type === 'relay.event') { if (sub.onEvent) sub.onEvent(msg.event); }
else if (msg.type === 'relay.eose') { if (sub.onEose) sub.onEose(); }
else { delete subs[msg.subId]; if (sub.onClosed) sub.onClosed(msg.reason); }
return;
}
// keys.action push: the shell triggers a registered keyboard/command action.
if (msg.type === 'keys.action') { var cb = actions[msg.actionId]; if (cb) cb(); 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 bytesToB64(bytes){ var u = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes); var s=''; for (var i=0;i<u.length;i++) s+=String.fromCharCode(u[i]); return btoa(s); }
var napplet = {
shell: {
// supports() is synchronous in @napplet/shim; we expose a sync proxy backed by an async check.
supports: function(domain, protocol){ return field(call('shell.supports', { domain: domain, protocol: protocol }), 'supported'); },
ready: function(){ return Promise.resolve({}); },
onReady: function(cb){ if (typeof cb === 'function') cb({}); return { close: function(){} }; },
services: []
},
identity: {
getPublicKey: function(){ return field(call('identity.getPublicKey'), 'pubkey'); },
getProfile: function(){ return field(call('identity.getProfile'), 'profile'); },
getRelays: function(){ return field(call('identity.getRelays'), 'relays'); },
getFollows: function(){ return field(call('identity.getFollows'), 'pubkeys'); },
getMutes: function(){ return field(call('identity.getMutes'), 'pubkeys'); },
getBlocked: function(){ return field(call('identity.getBlocked'), 'pubkeys'); },
getList: function(listType){ return field(call('identity.getList', { listType: listType }), 'entries'); },
getZaps: function(){ return field(call('identity.getZaps'), 'zaps'); },
getBadges: function(){ return field(call('identity.getBadges'), 'badges'); },
// onChanged is a no-op subscription: the host does not yet emit identity-change pushes, so
// the handler is never called. Kept for API parity.
onChanged: function(handler){ return { close: function(){} }; }
},
// keys = keyboard / command action binding (NOT signing). The shell acknowledges registration;
// the global-key binding + keys.action push is a follow-up, so onAction won't fire yet.
keys: {
registerAction: function(action){ return call('keys.registerAction', { action: action }).then(function(m){ return { actionId: m.actionId, binding: m.binding }; }); },
unregisterAction: function(actionId){ post('keys.unregisterAction', { actionId: actionId }); },
onAction: function(actionId, cb){ actions[actionId] = cb; return { close: function(){ delete actions[actionId]; } }; }
},
relay: {
// publish takes an UNSIGNED template (carried in the `event` field per @napplet/shim); the
// shell signs it and resolves to the signed event.
publish: function(template, options){ return field(call('relay.publish', { event: template, options: options }), 'event'); },
publishEncrypted: function(template, recipient, encryption){ return field(call('relay.publishEncrypted', { event: template, recipient: recipient, encryption: encryption || 'nip44' }), 'event'); },
query: function(filters){ return field(call('relay.query', normFilters(filters)), 'events'); },
// subscribe is a live tail: the shell streams relay.event (stored + live), one relay.eose, and
// relay.closed keyed by subId until close() sends relay.close.
subscribe: function(filters, onEvent, onEose, options){
var subId = 's' + (seq++);
subs[subId] = { onEvent: onEvent, onEose: onEose };
var env = normFilters(filters); env.subId = subId;
post('relay.subscribe', env);
return { close: function(){ delete subs[subId]; post('relay.close', { subId: subId }); } };
}
},
storage: {
// SDK method names are getItem/setItem/removeItem/keys; the wire types are storage.get/set/remove/keys.
getItem: function(key){ return field(call('storage.get', { key: key }), 'value'); },
setItem: function(key, value){ return call('storage.set', { key: key, value: value }).then(function(){}); },
removeItem: function(key){ return call('storage.remove', { key: key }).then(function(){}); },
keys: function(){ return field(call('storage.keys'), 'keys'); }
},
// value.payInvoice is an Amethyst-specific extension (not part of @napplet/shim).
value: {
payInvoice: function(invoice){ return field(call('value.payInvoice', { invoice: invoice }), 'preimage'); }
},
resource: {
// The shell rebuilds the Blob from the host's base64 before this resolves.
bytes: function(url){ return field(call('resource.bytes', { url: url }), 'blob'); },
bytesAsObjectURL: function(url){ return field(call('resource.bytes', { url: url }), 'blob').then(function(blob){ return URL.createObjectURL(blob); }); }
},
upload: {
// Sends the SDK's upload.upload; we inline the bytes as base64 (shell.html does the same for
// a Blob from a stock napplet). Resolves to the uploaded URL.
blob: function(bytes, contentType){ return field(call('upload.upload', { request: { dataBase64: bytesToB64(bytes), mimeType: contentType } }), 'url'); }
}
};
window.napplet = Object.freeze(napplet);
})();
@@ -55,6 +55,7 @@ import com.vitorpamplona.amethyst.ui.pluralStringRes
import com.vitorpamplona.quartz.lightning.LnInvoiceUtil
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAll
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
@@ -86,6 +87,7 @@ import java.net.InetSocketAddress
import java.net.Proxy
import java.net.URLDecoder
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicInteger
/**
@@ -110,10 +112,25 @@ class NappletBrokerService : Service() {
private val incoming by lazy { Messenger(Handler(Looper.getMainLooper(), ::handleMessage)) }
// Live relay subscriptions: applet subId -> client subId, so relay.close (and teardown) can stop them.
private val liveSubs = ConcurrentHashMap<String, String>()
// The broker for the current account, rebuilt only on account switch (see broker()).
private var cachedBroker: Pair<Account, NappletBroker>? = null
// Reused blob HTTP client, keyed by the active Tor port (see blobHttpClient()).
private var cachedHttp: Pair<Int, OkHttpClient>? = null
// Live relay subscriptions, keyed by the applet's subId. Holds the exact client that opened each
// one so teardown unsubscribes from the right account even after a switch, and an eose latch so
// a multi-relay subscription emits a single relay.eose.
private val liveSubs = ConcurrentHashMap<String, LiveSub>()
private val liveSeq = AtomicInteger(0)
private class LiveSub(
val clientSubId: String,
val client: INostrClient,
) {
val eoseSent = AtomicBoolean(false)
}
override fun onBind(intent: Intent?): IBinder? {
// Defense in depth on top of exported=false: only our own UID may bind.
if (Binder.getCallingUid() != Process.myUid()) return null
@@ -121,11 +138,7 @@ class NappletBrokerService : Service() {
}
override fun onDestroy() {
val client =
Amethyst.instance.sessionManager
.loggedInAccount()
?.client
liveSubs.values.forEach { runCatching { client?.unsubscribe(it) } }
liveSubs.values.forEach { sub -> runCatching { sub.client.unsubscribe(sub.clientSubId) } }
liveSubs.clear()
scope.cancel()
super.onDestroy()
@@ -197,14 +210,23 @@ class NappletBrokerService : Service() {
runCatching { NappletProtocolJson.decodeRequest(payload) }.getOrNull()
?: return NappletResponse.Failed("Malformed or unsupported request.")
val broker = buildBroker() ?: return NappletResponse.Failed("No account is signed in.")
val broker = broker() ?: return NappletResponse.Failed("No account is signed in.")
return broker.handle(identity, request, declared)
}
/** Builds a broker bound to the *currently* signed-in account, so account switches are honored. */
private fun buildBroker(): NappletBroker? {
/**
* The broker for the *currently* signed-in account, cached and rebuilt only when the account
* changes (reference identity). The gateways capture the account and read its flows live, so a
* cached broker stays correct across requests without per-request allocation.
*/
@Synchronized
private fun broker(): NappletBroker? {
val account = Amethyst.instance.sessionManager.loggedInAccount() ?: return null
cachedBroker?.let { (acc, broker) -> if (acc === account) return broker }
return buildBroker(account).also { cachedBroker = account to it }
}
private fun buildBroker(account: Account): NappletBroker {
val relay =
object : NappletRelayGateway {
override suspend fun publish(event: Event): List<String> {
@@ -364,14 +386,22 @@ class NappletBrokerService : Service() {
}
}
/** Tor-routed OkHttp client for host-side blob fetches (the applet has no direct network). */
/**
* Tor-routed OkHttp client for host-side blob fetches (the applet has no direct network).
* Cached and reused for connection pooling; rebuilt only when the Tor proxy port changes.
*/
@Synchronized
private fun blobHttpClient(): OkHttpClient {
val port = Amethyst.instance.torManager.activePortOrNull.value ?: -1
return if (port > 0) {
OkHttpClient.Builder().proxy(Proxy(Proxy.Type.SOCKS, InetSocketAddress("127.0.0.1", port))).build()
} else {
OkHttpClient()
}
cachedHttp?.let { (cachedPort, client) -> if (cachedPort == port) return client }
val client =
if (port > 0) {
OkHttpClient.Builder().proxy(Proxy(Proxy.Type.SOCKS, InetSocketAddress("127.0.0.1", port))).build()
} else {
OkHttpClient()
}
cachedHttp = port to client
return client
}
/**
@@ -482,8 +512,10 @@ class NappletBrokerService : Service() {
}
closeLiveSubscription(nappletSubId)
val clientSubId = "napplet-$nappletSubId-${liveSeq.incrementAndGet()}"
liveSubs[nappletSubId] = clientSubId
// liveSeq guarantees a unique client subId, so a rapid re-open of the same applet subId
// can't collide with the subscription it's replacing.
val sub = LiveSub("napplet-$nappletSubId-${liveSeq.incrementAndGet()}", account.client)
liveSubs[nappletSubId] = sub
val listener =
object : SubscriptionListener {
@@ -494,10 +526,14 @@ class NappletBrokerService : Service() {
forFilters: List<Filter>?,
) = push(replyTo, NappletProtocolJson.encodeRelayEvent(nappletSubId, event))
// A subscription fans out to several relays; collapse their EOSEs into the single
// relay.eose the SDK expects (fired when the first relay finishes its stored events).
override fun onEose(
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) = push(replyTo, NappletProtocolJson.encodeRelayEose(nappletSubId))
) {
if (sub.eoseSent.compareAndSet(false, true)) push(replyTo, NappletProtocolJson.encodeRelayEose(nappletSubId))
}
override fun onClosed(
message: String,
@@ -506,17 +542,14 @@ class NappletBrokerService : Service() {
) = push(replyTo, NappletProtocolJson.encodeRelayClosed(nappletSubId, message))
}
runCatching { account.client.subscribe(clientSubId, relays.associateWith { filters }, listener) }
runCatching { sub.client.subscribe(sub.clientSubId, relays.associateWith { filters }, listener) }
}
/** Stops the live subscription for [nappletSubId], if any. */
/** Stops the live subscription for [nappletSubId], unsubscribing from the client that opened it. */
private fun closeLiveSubscription(nappletSubId: String) {
val clientSubId = liveSubs.remove(nappletSubId) ?: return
val sub = liveSubs.remove(nappletSubId) ?: return
runCatching {
Amethyst.instance.sessionManager
.loggedInAccount()
?.client
?.unsubscribe(clientSubId)
sub.client.unsubscribe(sub.clientSubId)
}
}
@@ -330,10 +330,13 @@ class NappletHostActivity : ComponentActivity() {
)
}
// The injected window.napplet shim, read once from assets (see assets/napplet/shim.js).
private val shimJs by lazy { assets.open("napplet/shim.js").use { it.readBytes() }.decodeToString() }
/** Inserts the `window.napplet` client shim into the applet's HTML document. */
private fun injectShim(html: ByteArray): ByteArray {
val text = html.decodeToString()
val script = "<script>$SHIM_JS</script>"
val script = "<script>$shimJs</script>"
val headIdx = text.indexOf("<head", ignoreCase = true)
val injected =
when {
@@ -488,114 +491,5 @@ class NappletHostActivity : ComponentActivity() {
"font-src 'self' https://napplet.local data:; " +
"media-src 'self' https://napplet.local blob: data:; " +
"connect-src 'none'; frame-src 'none'; object-src 'none'; base-uri 'self'; form-action 'none'"
// Namespaced window.napplet.* matching the upstream @napplet/web SDK, over the
// {type:"domain.action", id} envelope. The applet's own code (built against that SDK)
// therefore runs unchanged on this shell.
private const val SHIM_JS = """
(function(){
if (window.__nappletShimInstalled) return; window.__nappletShimInstalled = true;
var seq = 0, pending = {}, subs = {}, actions = {};
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 env = { type: type }; if (fields) for (var k in fields) env[k] = fields[k];
var id = send(env);
pending[id] = { resolve: resolve, reject: reject };
});
}
// 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;
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' || msg.type === 'relay.closed') {
var sub = subs[msg.subId]; if (!sub) return;
if (msg.type === 'relay.event') { if (sub.onEvent) sub.onEvent(msg.event); }
else if (msg.type === 'relay.eose') { if (sub.onEose) sub.onEose(); }
else { delete subs[msg.subId]; if (sub.onClosed) sub.onClosed(msg.reason); }
return;
}
// keys.action push: the shell triggers a registered keyboard/command action.
if (msg.type === 'keys.action') { var cb = actions[msg.actionId]; if (cb) cb(); 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 bytesToB64(bytes){ var u = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes); var s=''; for (var i=0;i<u.length;i++) s+=String.fromCharCode(u[i]); return btoa(s); }
var napplet = {
shell: {
// supports() is synchronous in @napplet/shim; we expose a sync proxy backed by an async check.
supports: function(domain, protocol){ return field(call('shell.supports', { domain: domain, protocol: protocol }), 'supported'); },
ready: function(){ return Promise.resolve({}); },
onReady: function(cb){ if (typeof cb === 'function') cb({}); return { close: function(){} }; },
services: []
},
identity: {
getPublicKey: function(){ return field(call('identity.getPublicKey'), 'pubkey'); },
getProfile: function(){ return field(call('identity.getProfile'), 'profile'); },
getRelays: function(){ return field(call('identity.getRelays'), 'relays'); },
getFollows: function(){ return field(call('identity.getFollows'), 'pubkeys'); },
getMutes: function(){ return field(call('identity.getMutes'), 'pubkeys'); },
getBlocked: function(){ return field(call('identity.getBlocked'), 'pubkeys'); },
getList: function(listType){ return field(call('identity.getList', { listType: listType }), 'entries'); },
getZaps: function(){ return field(call('identity.getZaps'), 'zaps'); },
getBadges: function(){ return field(call('identity.getBadges'), 'badges'); },
// Live identity-change push is a follow-up; onChanged is a no-op subscription for now.
onChanged: function(handler){ return { close: function(){} }; }
},
// keys = keyboard / command action binding (NOT signing). The shell acknowledges registration;
// the global-key binding + keys.action push is a follow-up, so onAction won't fire yet.
keys: {
registerAction: function(action){ return call('keys.registerAction', { action: action }).then(function(m){ return { actionId: m.actionId, binding: m.binding }; }); },
unregisterAction: function(actionId){ post('keys.unregisterAction', { actionId: actionId }); },
onAction: function(actionId, cb){ actions[actionId] = cb; return { close: function(){ delete actions[actionId]; } }; }
},
relay: {
// publish takes an UNSIGNED template (carried in the `event` field per @napplet/shim); the
// shell signs it and resolves to the signed event.
publish: function(template, options){ return field(call('relay.publish', { event: template, options: options }), 'event'); },
publishEncrypted: function(template, recipient, encryption){ return field(call('relay.publishEncrypted', { event: template, recipient: recipient, encryption: encryption || 'nip44' }), 'event'); },
query: function(filters){ return field(call('relay.query', normFilters(filters)), 'events'); },
// subscribe is push-based: the shell sends relay.event/relay.eose keyed by subId. Today it
// delivers the initial matches then EOSE; a live tail is a follow-up.
subscribe: function(filters, onEvent, onEose, options){
var subId = 's' + (seq++);
subs[subId] = { onEvent: onEvent, onEose: onEose };
var env = normFilters(filters); env.subId = subId;
post('relay.subscribe', env);
return { close: function(){ delete subs[subId]; post('relay.close', { subId: subId }); } };
}
},
storage: {
// SDK method names are getItem/setItem/removeItem/keys; the wire types are storage.get/set/remove/keys.
getItem: function(key){ return field(call('storage.get', { key: key }), 'value'); },
setItem: function(key, value){ return call('storage.set', { key: key, value: value }).then(function(){}); },
removeItem: function(key){ return call('storage.remove', { key: key }).then(function(){}); },
keys: function(){ return field(call('storage.keys'), 'keys'); }
},
// value.payInvoice is an Amethyst-specific extension (not part of @napplet/shim).
value: {
payInvoice: function(invoice){ return field(call('value.payInvoice', { invoice: invoice }), 'preimage'); }
},
resource: {
// The shell rebuilds the Blob from the host's base64 before this resolves.
bytes: function(url){ return field(call('resource.bytes', { url: url }), 'blob'); },
bytesAsObjectURL: function(url){ return field(call('resource.bytes', { url: url }), 'blob').then(function(blob){ return URL.createObjectURL(blob); }); }
},
upload: {
// Sends the SDK's upload.upload; we inline the bytes as base64 (shell.html does the same for
// a Blob from a stock napplet). Resolves to the uploaded URL.
blob: function(bytes, contentType){ return field(call('upload.upload', { request: { dataBase64: bytesToB64(bytes), mimeType: contentType } }), 'url'); }
}
};
window.napplet = Object.freeze(napplet);
})();
"""
}
}