refactor(napplet): move wire codec to commons for desktop reuse + desktop host plan

Set the desktopApp up to host napplets/nsites by maximizing the shared
core and documenting the edge it must build.

- Moved NappletProtocolJson (the wire codec) from amethyst to
  commons/jvmAndroid (package ...commons.napplet.protocol), next to the
  NappletRequest/Response types it marshals. It depends only on quartz +
  kotlinx.serialization + java.util.Base64 (Android 26+/JVM), so a future
  desktop host marshals through the identical object — request/result/push
  shapes can't drift between platforms. amethyst host/service/tests updated
  to import it; tests stay in amethyst and still exercise it.
- Added desktopApp/plans/2026-06-21-napplet-desktop-host.md: what's already
  shared (broker, protocol, codec, resolver, the shell.html/shim.js web
  contract), what desktop must build (KCEF/JCEF engine, custom-scheme
  serving, isolation, transport, gateways, UI), the decisions to make, a
  security-parity checklist, and recommended further extractions
  (NappletRequestRouter, shared web assets, the inert feed card).

commons:jvmTest and the amethyst napplet suite pass.

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:12:28 +00:00
parent dfc237d27b
commit 0b76518ef2
8 changed files with 99 additions and 13 deletions
@@ -44,12 +44,11 @@ All compile; `commons:jvmTest` + the amethyst napplet suite stay green.
`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.)
- **DONE: `NappletProtocolJson``commons/jvmAndroid`.** Moved to
`commons/.../napplet/protocol/` (next to the types it marshals) so the future **desktop** host
reuses the exact wire codec. Its tests stay in `amethyst` (JUnit4) for now and still exercise it
via the commons dependency; converting them to `kotlin.test` and moving to commons `jvmTest` is a
small follow-up. See `desktopApp/plans/2026-06-21-napplet-desktop-host.md`.
- **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
@@ -47,6 +47,7 @@ import com.vitorpamplona.amethyst.commons.napplet.NappletUploadGateway
import com.vitorpamplona.amethyst.commons.napplet.NappletUploadResult
import com.vitorpamplona.amethyst.commons.napplet.NappletWalletGateway
import com.vitorpamplona.amethyst.commons.napplet.permissions.NappletPermissionLedger
import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletProtocolJson
import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletRequest
import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletResponse
import com.vitorpamplona.amethyst.model.Account
@@ -43,6 +43,7 @@ import androidx.webkit.WebMessageCompat
import androidx.webkit.WebViewCompat
import androidx.webkit.WebViewFeature
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletProtocolJson
import com.vitorpamplona.amethyst.commons.napplet.resolveRequiredCapabilities
import com.vitorpamplona.quartz.nip5aStaticWebsites.resolver.BlobFetcher
import com.vitorpamplona.quartz.nip5aStaticWebsites.resolver.StaticSiteResolution
@@ -24,7 +24,7 @@ package com.vitorpamplona.amethyst.napplet
* The Messenger wire contract between the untrusted `:napplet` process (the WebView host) and
* the main-process [NappletBrokerService]. Kept tiny and string-only on purpose: nothing the
* applet controls is ever interpreted as a Binder object, and the only payloads are JSON
* strings ([NappletProtocolJson]) plus the applet's identity coordinate.
* strings (`NappletProtocolJson`, in commons) plus the applet's identity coordinate.
*/
object NappletIpc {
/** Host → broker: a capability request. Carries [KEY_REQUEST_ID], the identity keys, and [KEY_PAYLOAD]. */
@@ -21,6 +21,7 @@
package com.vitorpamplona.amethyst.napplet
import com.vitorpamplona.amethyst.commons.napplet.NappletCapability
import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletProtocolJson
import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletRequest
import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletResponse
import com.vitorpamplona.quartz.nip01Core.core.Event
@@ -21,6 +21,7 @@
package com.vitorpamplona.amethyst.napplet
import com.vitorpamplona.amethyst.commons.napplet.NappletCapability
import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletProtocolJson
import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletRequest
import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletResponse
import com.vitorpamplona.quartz.nip01Core.core.Event
@@ -18,10 +18,8 @@
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.napplet
package com.vitorpamplona.amethyst.commons.napplet.protocol
import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletRequest
import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletResponse
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import kotlinx.serialization.json.Json
@@ -42,12 +40,17 @@ import java.util.Base64
/**
* Marshals the KMP-pure [NappletRequest] / [NappletResponse] types to and from the wire the
* applet exchanges over `window.napplet.*`. The envelope matches the upstream napplet SDK
* (`@napplet/web`): requests are `{ "type": "<domain>.<action>", "id", ...fields }` and replies
* are `{ "type": "<domain>.<action>.result", "id", "ok", ...fields }`.
* (`@napplet/shim` / `@napplet/nap`): requests are `{ "type": "<domain>.<action>", "id", ...fields }`
* and replies are `{ "type": "<domain>.<action>.result", "id", ...fields }`.
*
* Lives in `commons/jvmAndroid` (not in any single front end) because the wire contract is identical
* across hosts: the Android `:napplet` WebView host and a future desktop host both marshal through
* here. It depends only on `quartz` (Event/Filter), kotlinx.serialization, and `java.util.Base64`
* (available on Android API 26+ and the JVM) no platform-UI or process APIs.
*
* This is the only place the boundary parses untrusted applet input, so it is deliberately
* strict: an unrecognized `type` decodes to `null` (the broker denies it) and a malformed/short
* body throws (the broker wraps it into a `Failed`). Uses kotlinx.serialization so it is
* body throws (the host wraps it into a `Failed`). Uses kotlinx.serialization so it is
* unit-testable off-device.
*/
object NappletProtocolJson {
@@ -0,0 +1,80 @@
# Desktop napplet / nsite host — implementation plan
**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/jvmAndroid`) — the wire codec. Desktop's host marshals through
the same object, so request/result/push shapes can never drift between platforms.
**The web contract (`amethyst/assets/napplet/`)** — `shell.html` (the trusted shell page that hosts
the applet in an opaque-origin `sandbox="allow-scripts"` iframe and bridges object↔string) and
`shim.js` (the injected `window.napplet.*`). **These must be reused byte-for-byte** — they embody
the envelope/push contract. See "Shared web assets" below for how to share them.
## 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.
## Recommended shared extractions (do as part of, or just before, desktop work)
These reduce desktop reimplementation and prevent drift. None are done yet:
- **`NappletRequestRouter` (commons/jvmAndroid).** Today the orchestration (readType → `relay.close`
/ `resource.cancel` short-circuit → decode → `broker.handle` → encode reply / detect
`Subscribed`) lives in Android's `NappletBrokerService.handleMessage`. Extract it into a pure
router returning a small `Outcome` (`Reply(payload)` / `OpenSubscription(subId, filters)` /
`CloseSubscription(subId)`), so both hosts share the brain and only supply transport + relay
client. It's unit-testable in commons.
- **Shared web assets.** Move `shell.html` + `shim.js` into `commons/commonMain/composeResources/files/napplet/`
and read them via the generated `Res.readBytes("files/napplet/...")` on both platforms (the
Material Symbols font already lives in commons composeResources). This makes the web contract
single-sourced. (Android's host then reads them via `Res` instead of `assets.open`.)
- **The inert feed card.** `RenderStaticWebsite` (+ the napplet/nsite branches) in
`amethyst/.../note/types/StaticWebsite.kt` is plain Compose; move it to `commons` so the desktop
feed renders the identical card and launches the desktop host.
## 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.
## Status
Shared core (broker, protocol, codec, resolver) is ready and unit-tested. The codec now lives in
`commons/jvmAndroid` specifically so this desktop host can consume it. The remaining work is the
desktop **edge** (engine + scheme handler + transport + gateways + UI) plus the three recommended
extractions above — and on-device/desktop verification of the whole round-trip.