Files
amethyst/commons/plans/2026-10-09-web-app.md
Claude 9ca2259f76 refactor!: take crypto primitives from crypto-kmp
Quartz's SHA, HMAC, HKDF, AES, ChaCha20/Poly1305, X25519, Ed25519,
digest and secure-random code moved to vitorpamplona/crypto-kmp, which
quic-kmp now uses too, so quic no longer pulls in all of Quartz.
Quartz depends on com.vitorpamplona.crypto:crypto 1.0.0 as api; every
import moves to com.vitorpamplona.crypto.* with no shims. secp256k1
stays in Quartz.

What stayed in Quartz or its callers:
- NIP-44's 76-byte HKDF expansion (Hkdf.fastExpand, now MessageKey in
  nip44Encryption/crypto/Nip44MessageKey.kt).
- AESGCM.copyUsingUTF8Nonce, the Blossom workaround, as a private
  extension in EncryptedBlobInterceptor.
- NostrCipher is now crypto-kmp's SymmetricCipher.

quic-kmp 2.0.0 drops its Quartz dependency and logs through QuicLog, so
QuicLogBridge (commons jvmAndroid) routes those lines into Quartz's Log,
installed next to Log.minLevel on Android, desktop and amy. Lines below
Log.minLevel are skipped before their message is built.

-PcryptoKmpDir=../crypto-kmp builds a local crypto-kmp checkout, like
-PquicKmpDir does for quic.

Needs crypto-kmp 1.0.0 and quic-kmp 2.0.0 on Maven Central before CI
can resolve them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017FiX3CsiEsvjJ7zHNHD3Kk
2026-10-20 23:01:13 +00:00

18 KiB

title, type, status, date, owner, consumers, depends-on
title type status date owner consumers depends-on
Web app: the shared UI in the browser through Compose Multiplatform for Web (wasmJs) feature on-hold (blocked by the in-app browser gate below) 2026-10-09 commons quartz, commons, commonsUI, new webApp commons/plans/2026-10-09-ios-app.md (Part A)

Web app

Goal

Run the same AmethystApp from commonsUI in a browser:

  • Add a wasmJs target to quartz, commons and commonsUI.
  • Add a thin :webApp shim that fills the same ports the desktop and iOS shims fill.
  • No web-only screens, and no HTML or JS rewrite of the UI.

Scope is deliberately smaller than Android. A browser tab:

  • cannot run in the background;
  • cannot open UDP sockets or use Tor;
  • is subject to CORS;
  • runs all Kotlin/Wasm code on one thread.

The feature table at the end says what ships, what comes later and what cannot be done.

Reuse. The web app is mostly what the iOS plan's Part A already builds:

  • the nonJvmMain source set;
  • the Ktor transports;
  • the JVM-free account graph;
  • the shared screens.

This plan adds only what is specific to the browser.

Gate: the in-app browser must give NIP-07 to websites (maintainer, 2026-10-09)

The maintainer's condition: build the web app only if its in-app browser can answer NIP-07 calls, as the Android one does.

How Android does it. nappletHost/…/NappletBrowserActivity and NappletBrowserService run each page in a WebView the app owns:

  • WebViewCompat.addDocumentStartJavaScript(…, setOf("*")) injects a window.nostr shim into every page and frame;
  • the shim talks to the signer broker through addWebMessageListener.

iOS can do the same with WKUserScript (iOS plan B9).

What a web page can do (checked 2026-10-09). A web app has no WebView of its own. It can only show another site in an <iframe>, and then:

  • No injection. The same-origin policy stops the parent page from running script inside a cross-origin frame. Only postMessage crosses, and only to sites that already listen for it. The web app cannot inject window.nostr.
  • No framing at all for many sites. Those that send X-Frame-Options or a CSP frame-ancestors rule refuse to load in a frame.
  • Proxying is ruled out. Serving other sites through our own origin so the shim can be injected would run arbitrary third-party script in the origin that holds the user's keys.

What still works:

  • Napplets and nSites. They opt in to the postMessage bridge (W6), so they get signing.
  • NIP-46. Sites that support nostrconnect:// can use the web app as their bunker while its tab is open. This reuses connectedApps.

Full NIP-07 parity in a desktop browser needs a browser extension. Extensions can inject window.nostr into every tab (as nos2x and Alby do). That is a separate product, outside this plan.

Result: the condition is not met for arbitrary websites. The plan is on hold until the maintainer chooses one of:

  1. accept napplets/nSites + NIP-46 as the web app's browser story, and continue with W0;
  2. scope an Amethyst browser extension instead (or as well);
  3. shelve the web app.

Platform status (checked 2026-10-09)

  • Compose Multiplatform for Web (Kotlin/Wasm, Skia drawn on a <canvas>) has been Beta since CMP 1.9. The repo is on CMP 1.12.1.
  • Kotlin/Wasm itself is listed as Alpha on JetBrains' platform-stability page.
  • Browsers. WasmGC is required: Chrome/Edge 119+, Firefox 120+, Safari 18.2+.

Dependencies. Read from each library's published Gradle module metadata.

Publish a wasmJs variant:

  • Compose ui / foundation / material3 / components-resources 1.12.1
  • navigation3-ui 1.1.2, lifecycle-viewmodel-navigation3 2.11.0, navigationevent-compose 1.1.0
  • lifecycle-viewmodel 2.11.0, collection 1.6.0
  • Coil 3.6.3 (coil-compose, coil-network-ktor3), Vico 3.3.1, zoomable 2.16.0, highlights 1.1.0
  • okio 3.18.2
  • kotlinx-serialization json/cbor 1.11.0
  • ktor-client-core 3.6.0
  • datastore-preferences-core 1.2.1
  • androidx.sqlite 2.7.1 + sqlite-web 2.7.1

No wasmJs variant, so these need a swap:

Dependency Replacement on web
fr.acinq.secp256k1:secp256k1-kmp 0.24.0 (common, jvm, native only) quartz/…/utils/Secp256k1InstanceKotlin, the pure-Kotlin implementation that already exists with the same API
androidx.sqlite:sqlite-bundled Not needed by the app on web (LocalCache is in-memory); sqlite-web if a feature needs the event store
datastore-preferences (the okio-file artifact) datastore-preferences-core + a browser Storage
The richtext markdown fork (JitPack) The fork gains a wasmJs target (decided 2026-10-09, iOS plan A4)
com.vitorpamplona.negentropy:kmp-negentropy (JitPack) Could not read its metadata (JitPack 404). The maintainer's own library: add a wasmJs target there

Prerequisites from the iOS plan

Done before W1:

  • A1: nonJvmMain.
  • A2: Ktor transports. Web adds the ktor-client-js engine.
  • A3: the graph moved out of jvmAndroid.
  • A4: the last JVM-only screens.

A5 (the IO dispatcher) is owned here, as W1's first PR.

W0. Spike and go/no-go gate (do this first, before Part A finishes)

Answer the one question that can kill the project: can one browser thread keep up with ingest?

  1. Add wasmJs { browser() } to quartz only.

    • Move the three dependencies with no web build out of commonMain into a new nonWebMain (= jvmAndroid + nativeMain): ACINQ secp256k1, sqlite-bundled, negentropy.
    • Move the code that uses them with them: Secp256k1Instance becomes an expect, and the SQLite store and negentropy sync go to nonWebMain.
    • Stub the remaining quartz expects for wasm.
  2. Browser harness.

    • Build a minimal page in a test source set that connects to a relay through the browser WebSocket.
    • Parse, verify and insert a recorded corpus of 50k events into an in-memory cache.
    • Record events/second, main-thread long tasks over 50 ms, and peak memory.
    • Run in headless Chromium through Playwright (installed in the cloud container).
  3. Decide:

    • Pass (the maintainer sets the bar; Android's ingest rate on a mid-range phone is the reference): continue with W1 on a single thread.
    • Fail: choose between:
      • (a) move ingest and verification into a Web Worker running a second Kotlin/Wasm module. Only verified JSON crosses to the UI thread, so events are parsed twice.
      • (b) stop the web plan.

    Record the numbers in this file.

W1. Make the shared modules compile for wasmJs

  1. The IO dispatcher (one PR across all modules).

    • Kotlin/Wasm has no Dispatchers.IO and no runBlocking. In kotlinx-coroutines 1.11 both are declared in concurrentMain (JVM + Native), checked in the sources jar.
    • commonMain imports kotlinx.coroutines.IO in 303 files.
    • The fix:
      • Declare expect val Dispatchers.IO: CoroutineDispatcher in quartz (com.vitorpamplona.quartz.utils.coroutines).
      • Actuals: on JVM and Native, delegate to kotlinx's; on wasm, return Dispatchers.Default.
      • Rewrite only the import line in the 303 files. Call sites keep the text Dispatchers.IO.
    • On JVM, Dispatchers.IO is a member of Dispatchers, and members beat extensions, so behaviour is unchanged there.
    • Verify that resolution in W0 before the bulk rewrite. Fallback: a plain IODispatcher value and a call-site rewrite.
    • The runBlocking uses in commonsUI/commonMain (MultiUserErrorMessageDialog, ErrorList, ThreadFeedView previews) move to jvmAndroid or become LaunchedEffect.
  2. Source sets.

    • Add wasmJsMain under nonJvmMain in all three modules.
    • Add wasmJsMain to commonsUI's skikoMain, since Compose for Web is Skia too. It then gets the shared back handler and pixel helpers that desktop and iOS use.
  3. Synchronous crypto.

    • WebCrypto is asynchronous only, and quartz's DigestInstance, MacInstance, AESGCM and AESCBC are synchronous, so the wasm actuals must be pure Kotlin.
    • That code lives in crypto-kmp (com.vitorpamplona.crypto.*) and already backs its native actuals: SHA-1/SHA-2 in sha2, HMAC in mac, constant-time AES-GCM/CBC in aes, RIPEMD-160 in digest. Adding a wasmJs target to crypto-kmp, reusing its nativeMain Kotlin code, is the step that unblocks quartz here.
    • Test against the existing NIP-04, NIP-44 and NIP-17 vectors in commonTest.
  4. Browser-specific quartz actuals.

    expect wasm actual
    SecureRandom crypto.getRandomValues
    PlatformLog console
    UnicodeNormalizer String.prototype.normalize
    UriParser URL
    currentTimeMillis / currentTimeSeconds Date.now()
    PlatformLock no-op (single thread)
    Deflate, GZip pure-Kotlin inflate/deflate (CompressionStream is async)
    BigDecimal pure Kotlin
    verifyCertificateSignature same as nativeMain (pure Kotlin); only the experimental zapstore identity proof uses it

    Everything else comes from nonJvmMain.

  5. commons actuals (38) and commonsUI actuals (51).

    • Formatting uses Intl (DateTimeFormat, NumberFormat, DisplayNames); languages come from navigator.languages.
    • KmpLock / ConcurrentSet are trivial.
    • WeakReference uses the JS WeakRef.
    • platformFileSystem:
      • okio has no browser file system. Use an in-memory FakeFileSystem-style implementation for scratch files, and an OPFS-backed one only if a feature needs to persist files.
      • Audit what writes files: account records go to DataStore; the Coil disk cache is replaced by the browser HTTP cache.
    • DataStore: datastore-preferences-core with a Storage over IndexedDB (async, so it suits DataStore's suspend API).
    • SecureKeyStorage: see W3.
    • The commonsUI UI actuals are mostly small. Use the iOS ones (32 files) as the template. The real work is pickers, the clipboard and media (W4).
  6. Purity gate. Add verifyWebPurity beside verifyKmpPurity. It forbids, in commonMain and nonJvmMain:

    • import kotlinx.coroutines.IO;
    • runBlocking;
    • kotlin.native.*.

    Wire it into the lint job.

W2. The :webApp shim

  • Module. A new KMP module :webApp with wasmJs { browser { binaries.executable() } }.
    • src/wasmJsMain mirrors desktopApp/…/desktop/app/: WebAppModules, WebAppRoot, WebAppServices, WebAppPlatform, WebAccountViewModelHost, WebLocalCacheHost, WebNotePlatform, WebRichTextPlatform.
    • main() calls ComposeViewport(document.body) { AmethystApp(…) }.
  • What WebAppModules leaves out, compared with DesktopAppModules: Tor, foreground services, battery and connectivity ledgers, push, the PoW queue's background restore, and the OS keyring.
  • URLs and history.
    • Route is already @Serializable. Map the NavBackStacks top to the URL (for example /#/p/npub…, /#/e/nevent…, /#/t/tag) through a small RouteUrlCodec in commonsUI/commonMain, so Android deep links can reuse it.
    • Drive browser back/forward from navigationevent-compose (it has a wasm variant) and history.pushState.
    • Deep-link entry reuses the intent router logic moved to commons in iOS plan B7.
  • Lifecycle. visibilitychange → the lifecycle owner. Hidden tabs pause the always-on subscriptions and resume on focus.

W3. Login and keys

  • NIP-07 (window.nostr). This is the main way to log in on the web.
    • Add a NostrSignerNip07 to quartz: the signer logic in commonMain, the JS bridge in wasmJsMain.
    • Show it on the login screen through an AppPlatform slot, like the NIP-55 button on Android.
  • NIP-46 bunker. Runs over WebSockets with no changes after A2. Set supportsRemoteSignerLogin = true.
  • nsec. Stored only as NIP-49 ncryptsec in IndexedDB and unlocked with a passphrase each session. Never store it in plaintext. The login screen warns that browser storage is weaker than an OS keychain.
  • Read-only npub login works as-is.

W4. Media, CORS and fonts

  • Images. Coil with coil-network-ktor3 uses fetch(). A response can only be decoded if the host sends CORS headers.
    • Blossom servers (BUD specs), NIP-05 and NIP-11 require CORS, so they work.
    • Arbitrary image hosts may not.
    • Add an opt-in image proxy setting, empty by default, with a privacy note.
    • Without a proxy, a blocked image shows the existing error placeholder plus "open link".
  • Link previews. OpenGraph fetching needs a proxy. Off by default, using the same proxy setting.
  • Video and audio. CMP 1.9+ HTML interop places a real <video> / <audio> element over the canvas (WebNotePlatform.VideoView). HLS uses native HLS where the browser has it, and is otherwise not supported in the first release.
  • PDF. Open in a new tab.
  • Fonts. The canvas does not use system fonts, so emoji and CJK need bundled fallbacks.
    • Lazy-load Noto Color Emoji and CJK subsets as Compose resources on first use.
    • Set a bundle-size budget and report download sizes in CI.
  • Clipboard and share. navigator.clipboard and navigator.share where available.
  • Uploads. <input type=file> for pickers. Image re-encode through OffscreenCanvas behind the A3 MediaTranscoder port. Video uploads as-is, with no transcode.

W5. Notifications and background

  • While the tab is open. The shared in-app notification feed, plus Web Notifications for DMs and zaps.
  • While the tab is closed (later). Web Push through a service worker. Needs push.amethyst.social to speak Web Push (VAPID), and gift-wrap decryption in the worker (requires the key there). Defer until the push server work for iOS lands; the shared extraction from iOS plan B6 serves this too.

W6. Napplets, nSites and the in-app browser

General websites cannot get NIP-07 from a browser tab; see the gate above. The in-app browser on web is limited to napplets and nSites over the bridge, plus NIP-46 for sites that support it.

The browser is the natural host:

  • A sandboxed <iframe> from a separate origin replaces the WebView.
  • postMessage replaces Messenger IPC.
  • The commons/…/napplet/ broker stays shared.

Serving content needs either:

  • a separate origin the app controls (a static helper domain or a service worker), or
  • srcdoc / blob URLs, when the content allows it.

Decide in W6. Security rules from amethyst/plans/archive/2026-06-22-napplet-nsite-security.md apply as written.

W7. Performance work (depends on W0's numbers)

  • Cap synchronous work per frame: ingest and feed filters yield() between chunks.
  • Use verifySchnorrBatch (in the pure-Kotlin secp256k1) for relay bursts.
  • Bound LocalCache more aggressively on web: tabs are memory-capped and reload often.
  • If W0 chose the worker, the relay client, verification and dedup run there. The UI module receives verified events.

W8. Hosting and CI

  • Hosting. Static files: index.html, .wasm, .mjs, resources.
    • Domain to be chosen by the maintainer.
    • Serve a strict CSP with connect-src open to wss: and https: (relays are arbitrary).
    • Cache-busting file names.
    • A service worker for the offline shell.
  • CI.
    • Build :webApp:wasmJsBrowserDistribution.
    • Run a Playwright smoke test in headless Chromium: the page loads, a read-only npub login works, and the home feed renders from a local geode.
    • Track the bundle-size budget.

What ships on the web

Area Web, first release Later Not possible
Feeds, threads, profiles, search, lists, communities, relay settings ✔
Posting, replies, reactions, reposts, polls, highlights ✔
DMs (NIP-17), Marmot/Cordn groups ✔ (after A3)
Zaps: LNURL, NWC; WebLN (window.webln) ✔
NIP-60 Cashu wallet ✔
Login: NIP-07, NIP-46, ncryptsec, npub ✔ NIP-55
Blossom / NIP-96 uploads ✔ images; video without transcode Video transcode (WebCodecs)
Video / audio playback ✔ <video> / <audio> HLS everywhere (JS player via interop)
Napplets / nSites ✔ iframes (W6)
Notifications In-app + Web Notifications while the tab is open Web Push (W5) Always-on sockets
Calls WebRTC through the browser API (JS interop)
Audio rooms (nests) Browser WebTransport API instead of :quic (moq-lite already targets a browser-reachable relay) Raw QUIC/UDP
Camera capture getUserMedia
Tor ✘
Cast, Health, TTS, calendar TTS via speechSynthesis Cast (Chrome only), Health, phone calendar
Scheduled posts Hidden (as on iOS, iOS plan B13) Device-timed publishing
PoW Foreground only
Link previews, arbitrary images without CORS Via the opt-in proxy Direct

Order

  1. The in-app browser gate: the maintainer's decision, above.
  2. W0 spike. Gate.
  3. Part A of the iOS plan, and W1 alongside it: once A1/A2 exist, wasm actuals land in the same PRs as the iOS ones where they share nonJvmMain.
  4. W2 → W3 → W4. First public preview: read, post, zap and DM, with NIP-07/NIP-46.
  5. W5, W6, W7, W8, then the "Later" column as the maintainer prioritises.

Risks

  • Single thread (W0 decides).
  • Beta/Alpha toolchain. Compose for Web and Kotlin/Wasm APIs can change between releases; pin versions and upgrade deliberately.
  • CORS makes some media look broken without a proxy, and running a proxy has privacy and cost implications.
  • Canvas rendering limits. Weaker accessibility, text selection, search-engine indexing and browser find-in-page than an HTML app. Acceptable for an app; not for public note pages. njump-style HTML pages remain the right tool for sharing links.
  • Download size: multi-MB WebAssembly plus fonts on first load.

Open questions

  • Domain and hosting owner.
  • Should the project run an image/preview proxy, or leave it as a user setting only?
  • The in-app browser gate: napplets + NIP-46, an extension, or shelve?