mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-30 07:37:38 +00:00
- iOS: push goes to APNs directly; the richtext fork gains iOS and wasm targets; scheduled posts are hidden on iOS. - iOS: split the App Store payment risks by rule, quoting Apple's text. Zaps fall under 3.2.1(vii) (gifts tied to content). The Cashu and on-chain wallets hold funds and fall under 3.1.5(i) (organization account). LNURL-via-external-wallet and NWC hold nothing. - iOS: the in-app browser injects NIP-07 into every page through WKUserScript, matching Android's addDocumentStartJavaScript. - Web: put the plan on hold. A browser tab cannot inject window.nostr into cross-origin frames, so its in-app browser can't offer NIP-07 to arbitrary sites, which was the maintainer's condition. Lists the options. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019uPKNfzNbAgxKLAPYqbBxH
361 lines
18 KiB
Markdown
361 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
|
|
- `cryptography-provider-optimal` 0.6.0
|
|
- `ktor-client-core` 3.6.0
|
|
- `datastore-preferences-core` 1.2.1
|
|
- `androidx.sqlite` 2.7.1 + `sqlite-web` 2.7.1
|
|
- andreypfau `kotlinx-crypto-sha2` 0.0.4
|
|
|
|
**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.**
|
|
- The `cryptography-kotlin` WebCrypto provider exposes asynchronous operations only.
|
|
- `quartz`'s `DigestInstance`, `MacInstance`, `AESGCM` and `AESCBC` are synchronous, so the
|
|
wasm actuals must be pure Kotlin:
|
|
- SHA-2/HMAC from the andreypfau libraries already in `quartz`;
|
|
- AES-GCM/CBC as a small pure-Kotlin AES in `wasmJsMain`, or `nonJvmMain` if iOS also
|
|
wants to drop the provider.
|
|
- 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?
|