Files
amethyst/desktopApp/plans/2026-06-21-napplet-desktop-host.md

15 KiB

Desktop napplet / nsite host — implementation plan

Status: napplets (NIP-5D) implemented 2026-10-29 in desktopApp/.../desktop/napplet/; nSites (NIP-5A) not yet. See "Built" below for what exists and what is still open.

Date: 2026-06-21. Goal: a desktop NIP-5A (nsite) + NIP-5D (napplet) host that reuses the shared core the Android host already runs on, so the two stay wire- and policy-identical. This doc is the map: what already works for free, what desktop must build, and the decisions to make.

What is already shared (reuse verbatim)

quartz (commonMain) — protocol, no work needed:

  • NIP-5A/5D events, NappletManifest, StaticSiteResolver (path → hash resolution + per-blob sha256 verify), StaticSitePathLookup (sniffContentType, SPA index.html normalization), SiteAggregateHash.

commons (commonMain / jvmAndroid) — the security brain + wire, already platform-agnostic:

  • NappletBroker (the trust boundary: declaration gate → ledger → consent → execute), NappletCapability, NappletIdentity, NappletRequest/NappletResponse, the permissions Ledger/Store/GrantState, and the gateway interfaces (NappletRelayGateway, NappletStorage, NappletWalletGateway, NappletResourceGateway, NappletUploadGateway, NappletIdentityGateway, NappletConsentPrompt).
  • NappletProtocolJson (commons/commonMain) — the wire codec. Desktop's host marshals through the same object, so request/result/push shapes can never drift between platforms.

The web contract (commons/commonMain/composeResources/files/napplet/ + NappletWebContract) — shell.html (the trusted shell page that hosts the applet in an opaque-origin sandbox="allow-scripts" iframe and bridges object↔string), shim.js (the injected window.napplet.*), and NappletWebContract (the origin/URLs + both CSP strings + shell/shim loaders). Reused byte-for-byte — they embody the envelope/push contract. Desktop reads the same Res.readBytes(...) and serves the same CSP headers.

What desktop must build (platform-specific)

Concern Android (today) Desktop (to build)
Web engine Android WebView (Chromium) KCEF/JCEF (Chromium Embedded for the JVM). Gives the same CSP, opaque-origin iframe, and custom-scheme interception we rely on. Compose's experimental WebView is too limited.
Isolation separate :napplet OS process (no keys) The CEF renderer is already sandboxed; combine with the same CSP (connect-src 'none') + no-allow-same-origin iframe. For parity with Android's process model, evaluate running CEF in a child JVM process; at minimum rely on the renderer sandbox + CSP.
Resource serving WebViewClient.shouldInterceptRequest → shell + verified blobs a CEF custom scheme handler that serves https://napplet.local/__shell__ + /app/* from StaticSiteResolver with the identical CSP headers.
Transport Messenger across processes in-process: a direct bridge (CEF JS-query ↔ broker). If child-process: stdio/socket carrying the same JSON envelopes. Either way, the payloads are NappletProtocolJson.
Live subscriptions INostrClient.subscribe + push over Messenger same INostrClient.subscribe; push relay.event/eose/closed over the desktop transport.
Gateways impls bound to Account/BlossomUploader/DataStore/NWC in NappletBrokerService implement the same interfaces against the desktop account + relay client + uploader (the back end is shared).
Entry point Activity + bottom-nav; feed card a desktop window/pane + sidebar; the same inert feed card.

Decisions to make first

  1. Web engine: KCEF (Kotlin wrapper over JCEF) vs raw JCEF. KCEF is the lighter integration for Compose Desktop. Confirm license (JCEF/CEF is BSD — permissive, OK) before adding the dependency.
  2. Isolation model: renderer-sandbox-only (simpler) vs CEF-in-child-process (closer to Android's keyless-process guarantee). Start with renderer + CSP; treat child-process as a hardening follow-up.
  3. Transport: in-process bridge (fine if isolation is renderer-only) vs child-process IPC.

These reduce desktop reimplementation and prevent drift:

  • DONE: NappletRequestRouter (commons/jvmAndroid). The orchestration (readType → relay.close / resource.cancel short-circuit → decode → broker.handle → encode reply / detect Subscribed) used to live in Android's NappletBrokerService.handleMessage. It is now a pure router returning a small Outcome (Ignore / Reply(payload) / OpenSubscription(subId, filters) / CloseSubscription(subId) / Push(payloads)), so both hosts share the brain and only supply the broker + transport + live relay subscription. Unit-tested in commons/jvmTest (NappletRequestRouterTest); Android's service consumes it via a when (outcome) dispatch. The desktop host will route the exact same way.
  • DONE: Shared web assets + contract. shell.html + shim.js now live in commons/commonMain/composeResources/files/napplet/ and are read via Res.readBytes("files/napplet/..."). A new commons/commonMain/.../napplet/NappletWebContract single-sources the whole web contract: the shell/shim loaders plus the origin/host/URLs and both CSP strings (SHELL_CSP, APP_CSP). Android's host preloads the bytes in onCreate and reads every origin/CSP constant from NappletWebContract; the desktop scheme handler will serve the same bytes + headers.
  • DONE: The inert feed card. The preview card is now commons/commonMain/.../ui/note/StaticWebsiteCard — self-contained (commons compose-resource strings, LocalUriHandler for links, inlined card chrome), parameterized by isNapplet and an onOpen launch slot. Android's note/types/StaticWebsite.kt is now a thin set of event→card adapters; the desktop feed renders the identical card and supplies its own onOpen.

Security parity checklist (desktop must match Android)

  • Opaque-origin sandbox="allow-scripts" iframe (no allow-same-origin).
  • CSP connect-src 'none' (applet has no direct network) + default-src locked to the internal origin.
  • Serve only the manifest's declared paths, each sha256-verified before serving (reuse StaticSiteResolver).
  • The web engine/renderer holds no keys; all signing/consent stays in the broker.
  • Capability declaration gate + consent + signer-aware deferral (all already in NappletBroker).
  • Foreground-only execution (pause the engine when the pane is hidden) — mirror Android's onPause.
  • Route external links to the system browser only on a user gesture; never navigate the sandbox away.

Built (2026-10-29)

Napplets and nSites open from the feed card's Open button (DesktopNotePlatform → DesktopNappletCard, DesktopSiteCard; the shared nSites and nApplets screens render through it) and from favorites and the browser home (DesktopAppPlatform's launcher), each in its own window.

  • Engine: raw JCEF through jcefmaven (me.friwi:jcefmaven, Apache-2.0; JCEF and CEF BSD-3; Chromium's libcef.so includes FFmpeg, LGPL-2.1). KCEF was dropped: no release since 2025-03. DesktopCefEngine downloads the native bundle into the data dir on first use, with progress in the window, and runs Chromium with background networking off (its startup otherwise waits on Google services, forever behind a TLS-breaking proxy). Chromium's own services (account ListAccounts, search prefetch, network time, DNS over HTTPS) ignore that flag, so every connection Chromium makes, loopback included, goes through DesktopSocksRouter, a SOCKS5 server in the app that refuses all of it unless an open nSite asked for a route (a net log of the test run showed nothing else leaving). The router resolves a name before connecting directly and refuses one that lands on this machine (DNS rebinding, ::ffff:127.0.0.1), and closes every refused connection; its pumps run on virtual threads. WebRTC can't go around it: UDP ignores a SOCKS proxy, so the profile sets webrtc.ip_handling_policy to disable_non_proxied_udp (CEF ignores the command-line switch; preferences can only be set on its UI thread, once the global context starts, and the engine fails closed if one is refused). Third-party cookies are blocked (profile.cookie_controls_mode). Permission prompts (camera, microphone, location) are denied. The profile is jcef-cache/Default: a napplet's opaque srcdoc keeps nothing there, an nSite's localStorage, IndexedDB and cookies persist under its own origin.
  • Isolation: renderer sandbox + the shared CSPs + opaque-origin srcdoc, in process. No child JVM: the trust boundary is the bridge check below.
  • Serving: shared with Android: NappletSiteServer (commons) decides every response and the session only translates it for Chromium. A napplet gets only SHELL_URL (shell CSP header); every other request is cancelled before it leaves (data:, blob: and about:srcdoc stay local). NappletBlobStore fetches each file from the event's servers into the content-addressed NappletBlobCache, writing only bytes that hash to their name, with one download per blob however many requests miss it at once. Files stream from the cache (a cancelled request closes its file); only HTML is read whole, to prepend the prelude, which is built once per launch. The shell base64-encodes the napplet into one presized array: a 20 MB napplet allocates its 28 MB page and nothing more. The cache trims to 256 MB least recently used first, from one job as windows close.
  • Transport: CEF's message router. Chromium injects its query function into every frame, so only a query from the main frame at SHELL_URL is accepted; the shell captures the function and deletes it from its window. Replies go back through executeJavaScript into the shell's __nappletBridge.onmessage, with the request id added as on Android; replies queued together go in one script, in order.
  • Broker: DesktopNappletHost builds one per account with the gateways now shared in commons (AccountNappletRelayGateway, AccountNappletWalletGateway, NappletResourceFetcher, AccountIdentityReader, DataStoreNappletStorage) and the persisted DataStoreNappletPermissionStore ledger. Live subscriptions use the now-generic NappletLiveSubscriptions. Windows close when their launching account is no longer logged in.
  • Consent: NappletConsentCard (commonsUI, now also Android's dialog body) in a separate DialogWindow, so Chromium's heavyweight view can't cover it; text from the shared NappletConsentSummary.
  • Theme: theme.get and theme.changed follow the app's Material colors.
  • nSites (HostProfile.WEBSITE, now in commonsUI and shared with Android): the shell from the shared NappletWebContract.shell, the site at its own napplet.local origin with allow-scripts allow-same-origin. Every file from that origin is a verified blob from the event (a document navigation to an unlisted path falls back to /, a missing script or image is a 404); HTML gets the prelude with NIP-07. The origin includes the account (NappletWebContract.appId's partition), because CEF keeps one storage jar where Android picks a WebView profile per account. Requests to other hosts go out on the site's route. The shell and the site's own frame never navigate away (a clicked link opens the system browser); embeds inside the site may.
  • Network: NappletNetworkChoices (commons, Android's NappletNetworkRegistry now delegates to it) keeps each site's Tor or open-web choice, Tor by default while Tor is on. DesktopNappletRoutes arbitrates the router's one route like Android's process-wide WebView proxy: any site on Tor takes everything through Tor (an open-web site then says so), otherwise any site on the open web goes direct, otherwise nothing leaves. With Tor on and not running, a site, and a napplet's blob fetch, waits up to 30 s and then fails closed with "nothing left this computer". Switching a site reopens its window, as Android relaunches its activity.
  • NIP-07 consent: the broker gets the signer ledger, the connect prompt and the per-operation prompt (120 s, then deny). Their UI is shared now: SignerConnectCard, SignerConsentCard and BatchedConsentCard (commonsUI) with buildConnectInfo/buildSignerConsentInfo, and Nip46ConsentInfoBuilder in commons; Android's activities wrap the same cards.
  • Window chrome: a toolbar above the page, which the page can't draw over: back, forward, reload/stop, the title and posture (opens Android's AccessInfoSheet), find, zoom, the onion switch for a site while Tor is on, and Close. Loading, waiting for Tor and each failure have their own pane with Try again.
  • Tests: DesktopNappletSessionTest: a tampered blob never loads; a Tor site doesn't open while Tor is down and fetches nothing; with AMETHYST_CEF_TEST=1 under a display, a probe napplet's direct call to the bridge from its iframe is refused while the shim's storage round trip works and its fetch never reaches the server, the Doom napplet boots and asks for its saves, and a site runs at its own per-account origin (verified files, 404 for unlisted ones, the client-route fallback, localStorage, window.nostr getPublicKey and signEvent through the connect and signer prompts, and no navigation away). DesktopSocksRouterTest covers the routes, the loopback refusal, Tor carrying the connection, cutting open connections on a switch, closing a refused connection, and the arbitration. With Chromium, a napplet's RTCPeerConnection sends no packet to a STUN server on the machine's LAN address, and a burst of 100 replies reaches its requests whole.
  • Checked in the running app: published nSites (Hallway logged in through NIP-07 and kept its login across a restart) and napplets (Mini Samurai) open from their cards; waiting for Tor, the fail-closed pane, the onion switch, find, zoom, the access window and Close.

Open:

  • "Manage permissions" is hidden on desktop: the per-app permission screen (ConnectedAppDetailScreen) is still Android's.
  • A site's service worker can't register on either platform: neither host serves worker scripts.
  • While any site is on the open web, Chromium's own services go direct too (one process-wide route, as on Android).
  • No developer console panel: console messages and load errors go to the log.
  • Not offered: upload, notify, keys.onAction, the inc bus (not in Amethyst's supported domains).
  • No foreground hold (NappletHeldRequests) or identity.changed push yet; acting requests are still consent-gated.
  • Native Wayland (JBR's WaylandWindow) is unverified: CEF's windowed mode is X11.
  • macOS: the CEF bundle downloads unsigned into the data dir; notarized builds may need it signed or shipped inside the app. Windows is untested.

Status

Built and shipping behind the feed's Open button; the open items above remain. A 2026-10-29 audit fixed: blob downloads read with an API-33 call on Android (minSdk 26), WebRTC leaking around the router, refused SOCKS connections left open, names resolving to loopback, a broker kept for a logged-out account, races between a window's start and close and between route claims, and the performance items folded into Serving and Transport above.