mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-30 15:48:36 +00:00
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
359 lines
18 KiB
Markdown
359 lines
18 KiB
Markdown
---
|
|
title: "Web app: the shared UI in the browser through Compose Multiplatform for Web (wasmJs)"
|
|
type: feature
|
|
status: on-hold (blocked by the in-app browser gate below)
|
|
date: 2026-10-09
|
|
owner: commons
|
|
consumers: quartz, commons, commonsUI, new webApp
|
|
depends-on: 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` `expect`s 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
|
|
|
|
0. **The in-app browser gate**: the maintainer's decision, above.
|
|
1. **W0 spike. Gate.**
|
|
2. **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`.
|
|
3. **W2 → W3 → W4.** First public preview: read, post, zap and DM, with NIP-07/NIP-46.
|
|
4. **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?
|