mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-08 23:54:39 +00:00
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:
@@ -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.
|
||||
@@ -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);
|
||||
})();
|
||||
"""
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user