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
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
wasmJstarget toquartz,commonsandcommonsUI. - Add a thin
:webAppshim 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
nonJvmMainsource 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 awindow.nostrshim 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
postMessagecrosses, and only to sites that already listen for it. The web app cannot injectwindow.nostr. - No framing at all for many sites. Those that send
X-Frame-Optionsor a CSPframe-ancestorsrule 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
postMessagebridge (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 reusesconnectedApps.
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:
- accept napplets/nSites + NIP-46 as the web app's browser story, and continue with W0;
- scope an Amethyst browser extension instead (or as well);
- 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-ui1.1.2,lifecycle-viewmodel-navigation32.11.0,navigationevent-compose1.1.0lifecycle-viewmodel2.11.0,collection1.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-core3.6.0datastore-preferences-core1.2.1androidx.sqlite2.7.1 +sqlite-web2.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-jsengine. - 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?
-
Add
wasmJs { browser() }toquartzonly.- Move the three dependencies with no web build out of
commonMaininto a newnonWebMain(=jvmAndroid+nativeMain): ACINQ secp256k1,sqlite-bundled, negentropy. - Move the code that uses them with them:
Secp256k1Instancebecomes anexpect, and the SQLite store and negentropy sync go tononWebMain. - Stub the remaining
quartzexpects for wasm.
- Move the three dependencies with no web build out of
-
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).
- Build a minimal page in a test source set that connects to a relay through the browser
-
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
-
The IO dispatcher (one PR across all modules).
- Kotlin/Wasm has no
Dispatchers.IOand norunBlocking. In kotlinx-coroutines 1.11 both are declared inconcurrentMain(JVM + Native), checked in the sources jar. commonMainimportskotlinx.coroutines.IOin 303 files.- The fix:
- Declare
expect val Dispatchers.IO: CoroutineDispatcherinquartz(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.
- Declare
- On JVM,
Dispatchers.IOis a member ofDispatchers, and members beat extensions, so behaviour is unchanged there. - Verify that resolution in W0 before the bulk rewrite. Fallback: a plain
IODispatchervalue and a call-site rewrite. - The
runBlockinguses incommonsUI/commonMain(MultiUserErrorMessageDialog,ErrorList,ThreadFeedViewpreviews) move tojvmAndroidor becomeLaunchedEffect.
- Kotlin/Wasm has no
-
Source sets.
- Add
wasmJsMainundernonJvmMainin all three modules. - Add
wasmJsMaintocommonsUI'sskikoMain, since Compose for Web is Skia too. It then gets the shared back handler and pixel helpers that desktop and iOS use.
- Add
-
Synchronous crypto.
- WebCrypto is asynchronous only, and
quartz'sDigestInstance,MacInstance,AESGCMandAESCBCare 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 insha2, HMAC inmac, constant-time AES-GCM/CBC inaes, RIPEMD-160 indigest. Adding awasmJstarget to crypto-kmp, reusing itsnativeMainKotlin code, is the step that unblocks quartz here. - Test against the existing NIP-04, NIP-44 and NIP-17 vectors in
commonTest.
- WebCrypto is asynchronous only, and
-
Browser-specific
quartzactuals.expectwasm actual SecureRandomcrypto.getRandomValuesPlatformLogconsoleUnicodeNormalizerString.prototype.normalizeUriParserURLcurrentTimeMillis/currentTimeSecondsDate.now()PlatformLockno-op (single thread) Deflate,GZippure-Kotlin inflate/deflate ( CompressionStreamis async)BigDecimalpure Kotlin verifyCertificateSignaturesame as nativeMain(pure Kotlin); only the experimental zapstore identity proof uses itEverything else comes from
nonJvmMain. -
commonsactuals (38) andcommonsUIactuals (51).- Formatting uses
Intl(DateTimeFormat,NumberFormat,DisplayNames); languages come fromnavigator.languages. KmpLock/ConcurrentSetare trivial.WeakReferenceuses the JSWeakRef.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.
- okio has no browser file system. Use an in-memory
- DataStore:
datastore-preferences-corewith aStorageover IndexedDB (async, so it suits DataStore's suspend API). SecureKeyStorage: see W3.- The
commonsUIUI actuals are mostly small. Use the iOS ones (32 files) as the template. The real work is pickers, the clipboard and media (W4).
- Formatting uses
-
Purity gate. Add
verifyWebPuritybesideverifyKmpPurity. It forbids, incommonMainandnonJvmMain:import kotlinx.coroutines.IO;runBlocking;kotlin.native.*.
Wire it into the lint job.
W2. The :webApp shim
- Module. A new KMP module
:webAppwithwasmJs { browser { binaries.executable() } }.src/wasmJsMainmirrorsdesktopApp/…/desktop/app/:WebAppModules,WebAppRoot,WebAppServices,WebAppPlatform,WebAccountViewModelHost,WebLocalCacheHost,WebNotePlatform,WebRichTextPlatform.main()callsComposeViewport(document.body) { AmethystApp(…) }.
- What
WebAppModulesleaves out, compared withDesktopAppModules: Tor, foreground services, battery and connectivity ledgers, push, the PoW queue's background restore, and the OS keyring. - URLs and history.
Routeis already@Serializable. Map theNavBackStackstop to the URL (for example/#/p/npub…,/#/e/nevent…,/#/t/tag) through a smallRouteUrlCodecincommonsUI/commonMain, so Android deep links can reuse it.- Drive browser back/forward from
navigationevent-compose(it has a wasm variant) andhistory.pushState. - Deep-link entry reuses the intent router logic moved to
commonsin 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
NostrSignerNip07toquartz: the signer logic incommonMain, the JS bridge inwasmJsMain. - Show it on the login screen through an
AppPlatformslot, like the NIP-55 button on Android.
- Add a
- NIP-46 bunker. Runs over WebSockets with no changes after A2. Set
supportsRemoteSignerLogin = true. - nsec. Stored only as NIP-49
ncryptsecin 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-ktor3usesfetch(). 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.clipboardandnavigator.sharewhere available. - Uploads.
<input type=file>for pickers. Image re-encode throughOffscreenCanvasbehind the A3MediaTranscoderport. 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.socialto 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. postMessagereplaces 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
LocalCachemore 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-srcopen towss:andhttps:(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.
- Build
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
- The in-app browser gate: the maintainer's decision, above.
- W0 spike. Gate.
- 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. - W2 → W3 → W4. First public preview: read, post, zap and DM, with NIP-07/NIP-46.
- 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?