diff --git a/amethyst/plans/2026-09-26-browser-pwa-parity.md b/amethyst/plans/2026-09-26-browser-pwa-parity.md
new file mode 100644
index 0000000000..8c01bd3c6d
--- /dev/null
+++ b/amethyst/plans/2026-09-26-browser-pwa-parity.md
@@ -0,0 +1,361 @@
+# Browser surfaces → PWA parity review
+
+Status: **implemented** (phases 0–5, see §7), decisions recorded in §6. Not yet verified on a device. Scope: every surface that renders a
+plain web client — the **embedded** bottom-bar tab and the **external** full-screen browser —
+plus their shared top pull-down "pill" and bottom console "pill". The nsite/napplet hosts
+reuse the same chrome and are covered where the change is shared.
+
+Benchmark: an installed PWA in Chrome for Android (WebAPK, `display: standalone` /
+`minimal-ui`), including the Custom-Tab-style bar Chrome shows when a PWA navigates out of
+its scope.
+
+## 1. Surfaces today
+
+| Surface | Code | Process / rendering | Chrome |
+|---|---|---|---|
+| **Embedded web tab** (bottom-bar favorite) | `WebAppScreen` → `EmbeddedWebAppController` → `NappletBrowserService` | `:napplet` WebView streamed via SurfaceControlViewHost (API 30+) | Compose `TopControlSheet` + `BottomConsoleSheet`, drawn by `EmbeddedTabLayer` |
+| **External browser** (Browser tab launcher, "Open full") | `BrowserScreen` → `FavoriteAppLauncher.launchUrl` → `NappletBrowserActivity` | `:napplet` WebView hosted directly; own task (`documentLaunchMode=intoExisting`) | native-View `NappletControlSheet` + `NappletConsolePanel` |
+| Embedded / full nsite + napplet | `NostrAppScreen` / `NappletHostService` / `NappletHostActivity` | same split, verified-blob shell | same two chrome implementations, `isSandbox = true` |
+
+So there are **two implementations of the same chrome** (Compose + plain Views), and they have
+drifted.
+
+### Top pill — current contents
+
+| Row | Embedded (Compose) | External (Views) |
+|---|---|---|
+| Header | icon + host title | emoji 🌐/🛡 + **launch-time** title (never updates) |
+| Address | — | **editable** URL field + 🔒/🧅/🌐 glyph |
+| Tor | switch, `Lock` icon, `favorite_app_network_*` strings | switch, `ic_tor` icon, `napplet_net_*` strings |
+| Reload | ✓ | ✓ (glyph `↻`) |
+| Access info | sandbox only | sandbox only (glyph `ⓘ`) |
+| Manage permissions | ✓ (navigates in-app) | ✓ (glyph `⚙`, IPC → broker) |
+| Open full screen | ✓ | — (no way back to the tab) |
+| Favorite | ✓, reflects registry live | ✓, but **resets to "Add" on every navigation** |
+| Console | switch | switch (glyph `>`) |
+
+### Bottom pill
+
+Console only: hidden until the top-pill Console switch turns it on, then a grabber + log panel
+(max 40 % height in Compose, a fixed 220 dp in Views). Neither surface has a bottom
+navigation/actions pill.
+
+## 2. What a Chrome PWA gives the user
+
+**Window / OS integration**
+- Its own launcher icon (Add to Home screen / Install), its own task in Recents with the
+ **app's name, icon and `theme_color`**, and a splash screen built from the manifest.
+- Status bar (and nav bar) tinted with `theme_color` / ``, updated
+ live when the page changes it.
+- `display_override` / `display` modes: `standalone` (no UI), `minimal-ui` (back + reload),
+ `fullscreen`.
+- Launch handling: `start_url`, `scope`, `launch_handler`; deep links into the scope open the
+ PWA; app shortcuts (manifest `shortcuts`, shown on launcher long-press); `share_target`
+ (appears in the Android share sheet).
+
+**Navigation**
+- System back = history back; at the root, back leaves the app.
+- **Out-of-scope** navigation (e.g. an OAuth provider) opens a Custom-Tab-like bar showing the
+ **origin + security state + ✕ close**, and returns to the app when closed.
+- `target=_blank` / `window.open()` open a new Chrome tab (or a popup window for OAuth) and
+ `window.opener`/`postMessage` works.
+- Non-web schemes (`mailto:`, `tel:`, `intent:`, `geo:`, `nostr:`…) hand off to apps.
+
+**App menu** (minimal-ui ⋮, or the out-of-scope bar ⋮) — top icon row
+`Forward · Reload/Stop`, then: Share…, Copy link, Open in Chrome, Find in page, Desktop site,
+Zoom/text size, Page info (connection, certificate, cookies, site settings / permissions),
+Clear site data, App info.
+
+**Web platform APIs that "just work"**
+- JS dialogs (`alert`/`confirm`/`prompt`, `beforeunload`), labelled with the origin.
+- Permission prompts: camera/mic (`getUserMedia`), geolocation, notifications + Push,
+ clipboard read, MIDI, protected media — with a per-origin site-settings page.
+- Downloads (``, `Content-Disposition`, blob: URLs) to the Downloads folder.
+- HTML fullscreen (`requestFullscreen`, the video player's fullscreen button), screen
+ orientation lock, Wake Lock.
+- Web Share (`navigator.share`) and Share Target, Badging API, Contact Picker, File System
+ Access (open/save pickers), ``.
+- Long-press context menu on links/images (open in new tab, copy link, share, download
+ image), text selection toolbar with Share/Translate/Search.
+- Pull-to-refresh (unless the page sets `overscroll-behavior`).
+- Service worker offline support, a proper offline/error page with Retry.
+
+## 3. Gap analysis
+
+Legend: ✅ parity · ⚠️ partial · ❌ missing · 🔒 intentionally blocked (sandbox/Tor/keyless — keep it or gate it behind consent).
+
+| Capability | Embedded | External | Notes |
+|---|---|---|---|
+| Own task/Recents entry | n/a (it's a tab) | ⚠️ | Own task exists, but Recents shows the Amethyst label/icon — no `setTaskDescription(title, favicon, themeColor)`. |
+| Add to Home screen / install | ❌ | ❌ | No `ShortcutManagerCompat.requestPinShortcut`. The launcher intent must route through the main process (the `:napplet` activity isn't exported). |
+| Theme-color system bars | ❌ | ❌ | External pads the window with the app's `colorBackground`; `theme-color` is never read. |
+| Page title in chrome | ⚠️ host only | ❌ fixed at launch | Neither listens to `onReceivedTitle`. |
+| System back = history back | ✅ | ✅ | |
+| Forward | ❌ | ❌ | Chrome's menu icon row starts with Forward. |
+| Stop loading | ❌ | ❌ | Reload stays Reload while loading. |
+| Out-of-scope bar (origin + ✕ close) | ❌ | ❌ | Navigating off-site silently replaces the app. There's no concept of the app's "scope" or "home". |
+| `target=_blank` / `window.open` | ❌ | ❌ | `setSupportMultipleWindows(false)` + `javaScriptCanOpenWindowsAutomatically=false`: `_blank` loads in place, `window.open()` returns `null`, so OAuth popups break. |
+| Non-http schemes | ⚠️ | ⚠️ | Handed to `ACTION_VIEW` on a gesture, but `intent:` URIs aren't parsed (`Intent.parseUri` + `browser_fallback_url`), so they fail. |
+| JS dialogs | ❌ | ❌ | **Confirmed from source:** the framework `JsDialogHelper` only shows a dialog when `webView.context is Activity`. The embed runs on a Service context, and the external browser's WebView uses `nightThemedContext()` (a `ContextThemeWrapper` over `createConfigurationContext`, not an Activity) whenever the theme is DARK/LIGHT, which `FavoriteAppLauncher` always resolves it to. So `confirm()` returns `false` and `prompt()` returns `null` in both. |
+| Permission prompts (camera/mic) | ❌ | ❌ | `onPermissionRequest` isn't overridden, so the default denies. Video calls, QR scanners and voice notes on the web all fail. |
+| Geolocation | ❌ | ❌ | `setGeolocationEnabled(false)`. To be offered per origin behind consent, on Tor and open web alike (§6). |
+| Notifications / Push | ❌ | ❌ | Not available in WebView at all. Only a bridge polyfill could provide it; out of scope for v1. |
+| Downloads | ❌ | ❌ | No `DownloadListener`, so download links do nothing. |
+| HTML fullscreen video | ❌ | ❌ | `onShowCustomView` isn't implemented, so the fullscreen button is dead. |
+| Web Share (`navigator.share`) | ❌ | ❌ | Not in WebView. Can be polyfilled through the existing document-start shim to an `ACTION_SEND` chooser. |
+| Share page / Copy link | ❌ | ❌ | Not in either top pill. |
+| Open in external browser | ❌ | ❌ | Useful escape hatch (Google sign-in blocks WebView user agents). |
+| Find in page | ❌ | ❌ | `WebView.findAllAsync` / `findNext`. |
+| Desktop site | ❌ | ❌ | UA override + `useWideViewPort`. |
+| Text zoom | ❌ | ❌ | `settings.textZoom`. |
+| Page info / site settings | ⚠️ | ⚠️ | "Manage permissions" covers NIP-07 grants only. No connection/cert state, no clear-site-data. |
+| Long-press link/image menu | ❌ | ❌ | External can use `hitTestResult` in `onCreateContextMenu`. For the embed, the tap forwarder would need a long-press path. |
+| Pull-to-refresh | ❌ | ❌ | Optional. Should respect the page's overscroll (only fire at `scrollY == 0`). |
+| Error / offline page | ✅ overlay + Retry | ⚠️ | External only logs to the console and shows the raw WebView error page. |
+| Renderer crash recovery | ❌ | ❌ | No `onRenderProcessGone`. A renderer crash in `:napplet` kills every WebView in that process, including every warm tab. |
+| File input | ✅ | ✅ | Recently added. |
+| NIP-07 `window.nostr` | ✅ | ✅ | Amethyst-only advantage. Keep it. |
+| Tor per host | ✅ | ✅ | Amethyst-only advantage. |
+
+### Bugs found along the way (fix regardless of the redesign)
+
+1. **"Open full screen" opens the launch URL, not the current page.** In `WebAppScreen` it
+ calls `FavoriteAppLauncher.launchUrl(context, url)` with the original `url`, not
+ `currentUrl`.
+2. **External favorite state is wrong after any navigation.** `NappletControlSheet.updateUrl`
+ forces `isFavorite = false`, so an already-pinned site shows "Add to favorites" and tapping
+ it sends a toggle that *removes* the pin. The broker knows the truth: it should push the
+ state back (or the toggle should be an explicit add/remove, not a blind flip).
+3. **External header title never updates.** It shows the launch host even after navigating to
+ another site. That's misleading when combined with NIP-07 prompts.
+4. **The two sheets disagree.** They use different icons (emoji vs Material symbols, `Lock` vs
+ `ic_tor`), different strings for the same row, a different row order, and only one side has
+ "Open full" and the address field. There's also a doc contradiction: `BrowserScreen` says "a
+ running app never carries an editable address bar", but `NappletControlSheet` renders one
+ in the external browser.
+
+## 4. Proposal
+
+### 4.1 One chrome spec, two renderers
+
+Promote `EmbeddedTabChrome` into a surface-neutral **`WebChromeSpec`** in `commons`: title,
+origin, security state (https / http / onion-via-Tor / sandbox), canGoBack/Forward,
+isLoading, isFavorite, theme color, and a list of typed actions. Both renderers draw from
+it with the same order, icons (Material Symbols font; the Views side can draw the same glyphs
+from `material_symbols_outlined.ttf` with a `Typeface`) and strings. Add a unit test that pins
+the row order and visibility for each surface type (web / nsite / napplet × embed / full).
+
+### 4.2 Top pill (Chrome-style app menu)
+
+Keep the top-center grabber (it stays out of the site's avatar corner and can't be spoofed
+by the page). Expanded:
+
+```
+┌──────────────────────────────────────────────┐
+│ [favicon] Page title [✕] │ ✕ only in external = close task
+│ 🔒 example.com · via Tor │ read-only origin chip; tap → Page info
+├──────────────────────────────────────────────┤
+│ ← → ↻/✕ ★ ⇪ │ back · forward · reload/stop · favorite · share
+├──────────────────────────────────────────────┤
+│ ⧉ Copy link │
+│ ✎ Edit address │ rare: reveals the editable URL field
+│ ⬈ Open in browser app │ external browser escape hatch
+│ ⛶ Open full screen / ⤓ Return to tab │ embed ↔ external
+│ ⌂ Add to Home screen │
+│ 🔍 Find in page │
+│ 🖥 Desktop site [ ] │
+│ 🧅 Route over Tor [●] │
+│ ⚙ Site settings & permissions │ NIP-07 grants + camera/mic/location + clear data
+│ >_ Console (N) [ ] │ under a "Developer" divider
+└──────────────────────────────────────────────┘
+```
+
+- **Hide the editable address field by default** (decision 1). A PWA never shows one. The
+ origin chip is read-only (tap = page info, long-press = copy). An **Edit address** row, in
+ both the embed and the external browser, swaps the chip for the editable field with
+ the URL pre-selected; Go navigates and collapses it back to the chip. Expected to be used
+ rarely; the Browser tab launcher stays the main place to type a URL.
+- Header title comes from `onReceivedTitle`, falling back to the host.
+- The icon row mirrors Chrome's top row. Reload turns into Stop while `isLoading`.
+- A **scope indicator**: when the current origin differs from the app's start origin, tint the
+ origin chip and show a "Back to " action (the in-app version of Chrome's out-of-scope
+ bar).
+
+### 4.3 Bottom pill
+
+- Keep it **developer-only** (Console), but move the toggle under a *Developer* divider in the
+ top pill, and let the grabber show an error-count badge so the user knows why to open it.
+- Unify height: 40 % of the surface in both renderers (Views currently hard-code 220 dp).
+- **Find-in-page** reuses the bottom slot: a bar with the query field, `n/m`, ↑ ↓ and ✕ docked
+ where the console grabber sits (Chrome puts find-in-page at the top, but the top edge here
+ belongs to the grabber, and the bottom avoids covering the page's own header). Only one
+ bottom panel is shown at a time.
+- Out-of-scope navigation can also surface a slim bottom "← Back to " pill. Pick either
+ this or the chip in 4.2 after trying both on a device.
+
+### 4.4 Behaviours (WebView plumbing)
+
+Fix these in `NappletBrowserActivity`, `NappletBrowserService`, and where applicable the
+nsite/napplet hosts (sandbox profile permitting).
+
+1. **JS dialogs:** implement `onJsAlert`/`onJsConfirm`/`onJsPrompt`/`onJsBeforeUnload` ourselves.
+ - External: show an Activity-owned dialog titled "*origin* says" (the framework helper
+ can't, because the WebView's themed context isn't an Activity).
+ - Embed: IPC to the main process and show a Compose dialog over the tab.
+ - Sandboxed napplets keep today's auto-cancel.
+2. **Popups / `_blank` — follow Chrome** (decision 2): enable `setSupportMultipleWindows(true)`
+ and handle `onCreateWindow`, from both the embed and the external browser.
+ - Every new window (a `_blank` link or a user-gesture `window.open`) opens as a **new
+ full-screen Amethyst browser window** (a new `NappletBrowserActivity` task), the way
+ Chrome opens a new tab.
+ - `opener` must survive for OAuth popups. The child WebView is created in
+ `onCreateWindow` (same `:napplet` process as both parents) and handed to the new
+ activity through an in-process registry keyed by a one-shot token passed in the
+ intent. `window.close()` from the child (`onCloseWindow`) finishes that task and
+ returns the user to the opener.
+ - Keep auto-open without a user gesture blocked (Chrome's popup blocker).
+3. **`intent:` URIs:** parse them with `Intent.parseUri(..., URI_INTENT_SCHEME)`, strip the
+ component/selector, and fall back to `browser_fallback_url`. Keep the gesture requirement.
+4. **Downloads:** add a `DownloadListener`. Hand off to the main process, which runs
+ `DownloadManager` (through the Tor proxy when the host is on Tor, or else refuses rather
+ than leaking the download). Handle `blob:`/`data:` via the shim.
+5. **Permissions:** handle `onPermissionRequest` (camera/mic) and
+ `onGeolocationPermissionsShowPrompt` (enable geolocation). The consent prompt runs in the
+ main process (same pattern as NIP-07 consent), is stored per origin in the existing
+ Connected Apps ledger, and requests the Android runtime permission from the main
+ activity. Works the same on **Tor and the open web** (decision 3); nothing is
+ auto-denied because of routing.
+6. **Fullscreen:** implement `onShowCustomView`/`onHideCustomView`.
+ - External: swap in the custom view with immersive system bars.
+ - Embed: open the external browser in fullscreen, since a streamed surface can't take over
+ the window.
+7. **Web Share polyfill:** have the document-start shim define `navigator.share`/`canShare`
+ routed over the existing bridge to an `ACTION_SEND` chooser (text/url, files later).
+ Require a user activation.
+8. **Theme color:** a tiny shim observer reports `` (and changes to
+ it) over the bridge. External tints the status/nav bar padding and uses it in
+ `setTaskDescription`. Embed tints the top grabber.
+9. **Task description (external):** call `setTaskDescription(title, favicon, themeColor)` on
+ title, icon and theme changes, so Recents looks like an installed app.
+10. **Add to Home screen** (decision 4): `ShortcutManagerCompat.requestPinShortcut`, with the
+ favicon (from `BrowserIconRegistry`) as the icon. The shortcut always opens the page
+ **full screen in Amethyst's own browser** (`NappletBrowserActivity`), never the system
+ browser, so the NIP-07 signer is there. Its intent targets an exported **main-process**
+ trampoline activity (the `:napplet` activities stay unexported). The trampoline
+ brings up the broker, the Tor port and the account's WebView profile, then calls
+ `FavoriteAppLauncher.launchUrl` and finishes. Also add dynamic shortcuts for the top
+ favorites on launcher long-press, with the same target.
+11. **Renderer crash:** handle `onRenderProcessGone`. Destroy that WebView, return `true`, and
+ show the error overlay with Retry (for the embed, rebuild the session on Retry). All
+ WebViews in `:napplet` share one renderer, and the process is still killed if **any** of
+ them leaves the callback unhandled. So `NappletBrowserActivity`, `NappletBrowserService`,
+ `NappletHostActivity` and `NappletHostService` must all ship the handler in one change.
+12. **Context menu (external first):** on long-press over a link or image, offer Open in new
+ window, Copy link, Share link, Download image.
+13. **Desktop site / text zoom:** per-host settings persisted next to `WebAppNetworkRegistry`.
+14. **Pull-to-refresh (optional):** only when the page is at `scrollY == 0` and hasn't claimed
+ overscroll.
+
+### 4.5 Deliberately different from Chrome (keep)
+
+- Keyless `:napplet` process, per-origin NIP-07 consent, per-account WebView profile, Tor
+ routing per host. These are Amethyst's reason to exist and none of the above weakens them.
+ Every new capability goes through the broker with the same consent + ledger pattern.
+- Notifications/Push: not feasible in WebView without a service-worker bridge. Out of scope.
+- Service-worker offline: WebView already supports SW. Nothing to do beyond not clearing the
+ profile.
+
+## 5. Phasing
+
+| Phase | Contents | Size |
+|---|---|---|
+| **0: bugs** ✅ | §3 bugs 1–3; live page titles (`onReceivedTitle`) in both surfaces; JS dialogs in the external browser (origin-labelled, with "Block dialogs from this page") | S |
+| **1: chrome unification** | `WebChromeSpec` in commons; both renderers; icon row (back/forward/reload-stop/star/share); Copy link; Open in browser app; Return to tab; address field hidden behind "Edit address"; row-order test | M |
+| **2: dead web APIs** | `onRenderProcessGone` (all four WebView owners at once — see 4.4 #11), JS dialogs in the embed, `_blank`/`window.open` → new browser window, `intent:` URIs, downloads, fullscreen video, Web Share polyfill | M–L |
+| **3: OS integration** | theme-color bars, `setTaskDescription`, Add to Home screen + trampoline, dynamic shortcuts | M |
+| **4: permissions & page info** | camera/mic/geo consent via the broker, site settings page (grants + clear data + connection state), Find in page, Desktop site, text zoom | L |
+| **5: polish** | long-press context menu, pull-to-refresh, out-of-scope indicator | M |
+
+## 6. Decisions (2026-09-26)
+
+1. **Address bar:** hidden by default in running apps; an "Edit address" row reveals the
+ editable field. Rarely used.
+2. **New windows (`_blank`, `window.open`):** follow Chrome. They open a new full-screen
+ Amethyst browser window, keeping `opener` for OAuth popups.
+3. **Camera / mic / location:** consent-gated per origin, and they work on **both Tor and
+ the open web**. Routing never auto-denies them.
+4. **Add to Home screen:** the shortcut opens the page full screen in **Amethyst's browser**
+ (not the system browser), so the signer is present. It launches through a main-process
+ trampoline.
+
+## 7. Implementation (2026-09-26)
+
+What shipped, where it lives, and what was deliberately left out.
+
+> The two renderers described below (`TopControlSheet`, `NappletControlSheet` and their find, console and
+> dialog views) were later replaced by one set of Compose components. See
+> `2026-09-26-browser-ui-review.md` §5.
+
+**Shared layout.** `commons/…/browser/BrowserChrome.kt` decides which actions the top pill shows, and in
+what order, for every surface (web / nsite / napplet × embedded / full screen), plus the security badge,
+the scope check, text-zoom steps, the desktop user agent and the theme-colour parser. It is covered by
+`BrowserChromeTest`. `nappletHost/…/BrowserChromeLabels.kt` maps each action to one Material Symbol and
+one label (shared strings in `commons` Android resources). Both renderers draw from these two:
+
+- `TopControlSheet` (Compose, embedded tabs).
+- `NappletControlSheet` (plain Views, full screen). It loads the same Material Symbols font from the
+ `commonsUI` assets, so the icons match. Three glyphs were added to the subset font: `FormatSize`,
+ `DesktopWindows` and `AddToHomeScreen`.
+
+**Top pill.** Header: security icon, page title, and `host · connection`. Tapping it opens page info;
+long-pressing copies the link. The full-screen header also has ✕. Below it:
+- The icon row: back · forward · reload/stop · star (filled when pinned) · share.
+- Menu rows: back to app, copy link, edit address, find in page, text size, desktop site, add to Home
+ screen, open in another browser, open full screen.
+- Privacy: Tor, what it can access, site settings.
+- Developer: console.
+
+**Bottom pill.** Console as before; find in page docks in the same slot, one panel at a time.
+
+**Web platform** (`BrowserWebTools`, `BrowserDownloads`, `BrowserPopups`, `BrowserExtrasScript`,
+`BrowserJsDialogs`; used by both `NappletBrowserActivity` and `NappletBrowserService`):
+- JS dialogs: native in full screen; relayed to Compose for the embed.
+- `_blank` / `window.open` open as a new full-screen window, with `opener` kept through a parked popup
+ WebView.
+- `intent:` URIs are parsed, hardened, and fall back to `browser_fallback_url`.
+- Downloads follow the page's route (Tor through SOCKS, remote DNS), carry the page's cookies, and land
+ in Downloads. `blob:` and `data:` downloads come through the page script.
+- HTML fullscreen: the whole window in full screen; inside the surface for the embed.
+- `navigator.share` polyfill (text/url).
+- Renderer-crash recovery in all four WebView owners.
+
+**OS integration.**
+- `theme-color` tints the full-screen window's system-bar areas.
+- `setTaskDescription` sets the title, favicon and colour in Recents.
+- Add to Home screen (`WebShortcuts` + `WebShortcutActivity`, main process, waits for Tor).
+- Dynamic launcher shortcuts for the first four web favorites.
+
+**Permissions and page info.**
+- Camera, microphone and location go through a per-origin prompt. Answers are kept in
+ `WebSitePermissionRegistry` (main process, same on Tor and open web), then Android's runtime permission
+ is requested.
+- The Connected Apps detail screen lists and resets those answers.
+- Page info shows the connection, Tor and certificate, with Clear site data (this profile only).
+
+**Left out, and why.**
+- **Pull-to-refresh:** WebView exposes no overscroll signal, so on pages that scroll an inner element
+ (most SPAs) `scrollY == 0` is always true. A pull there would reload mid-scroll.
+- **Long-press menu in the embedded tab:** the SurfaceControlViewHost surface doesn't deliver long-press
+ context menus. It is available in the full-screen browser.
+- **Theme colour in the embedded tab:** the tab owns no system bars. The script's message is ignored
+ there.
+- **Find / text size for embedded nsites and napplets:** that host has no IPC for them yet. The rows are
+ hidden via `BrowserChrome.State.hasFind` / `hasTextSize`. The full-screen nsite/napplet host has both.
+- **Notifications / Push, file sharing through Web Share:** unchanged, see §4.5.
+
+**Needs on-device verification.**
+- Popup `opener` handoff across activities.
+- Renderer-crash recovery. Trigger it with `chrome://crash` in a debug build, or by killing the renderer.
+- Downloads over Tor.
+- The pinned-shortcut cold start with Tor enabled.
+
diff --git a/amethyst/plans/2026-09-26-browser-ui-review.md b/amethyst/plans/2026-09-26-browser-ui-review.md
new file mode 100644
index 0000000000..2797131d3d
--- /dev/null
+++ b/amethyst/plans/2026-09-26-browser-ui-review.md
@@ -0,0 +1,261 @@
+# Browser surfaces: UI review and redesign
+
+Status: **shipped** (see §5). The components live in
+`commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/`. They are rendered
+offscreen by `BrowserPillRenderTest` (commonsUI jvmTest), which writes PNGs to
+`commonsUI/build/browser-pill/`.
+
+Follows `2026-09-26-browser-pwa-parity.md`. That plan settled *what* the browser does. This one is about
+how it looks and feels.
+
+## 1. What's wrong with what shipped
+
+Reviewed against the code as merged: `TopControlSheet`, `NappletControlSheet`, `BottomConsoleSheet`,
+`NappletConsolePanel`, `BrowserFindBar`, `EmbeddedFindBar`, `EmbeddedPageDialogs`, `BrowserJsDialogs`
+and the full-screen permission / page-info `AlertDialog`s.
+
+**Top pill**
+1. **It's a settings list, not a menu.** Up to 15 rows of equal weight in one scrolling column. Share,
+ copy and find (used daily) look exactly like desktop site and open-in-another-browser (used rarely).
+ Chrome solves this with an icon strip plus a short list; we copied the list and made it longer.
+2. **No visual hierarchy.** Section labels are 12sp caps you don't notice. Every row is the same 44dp
+ icon + label. Nothing groups.
+3. **Security state is a word, not a signal.** "Not secure" and "Onion-routed over Tor" are the same
+ grey 13sp text. Plain HTTP isn't warned about, and Tor, the thing Amethyst does that Chrome doesn't,
+ isn't celebrated.
+4. **Three rows are just switches** (Tor, desktop site, console). Each is a full-width row whose only
+ content is the switch. They say what they are, not what they mean ("Loads over Tor" vs "the site
+ can't see your IP").
+5. **The text-size stepper** is squeezed into a list row: two 24dp buttons and a percentage, with no
+ preview and no way back to 100%.
+6. **The collapsed grabber is mute.** It never shows loading, an insecure page, or console errors, so
+ there's nothing to invite the pull.
+
+**Inputs**
+
+7. **Edit address is a bare text field** dropped into the header. It has no clear button, no go button,
+ no suggestions (the launcher's omnibox has them), and no paste-and-go.
+8. **Find in page** is a full-width sheet with a default `TextField`. The count is loose text, and "no
+ matches" has no state of its own. The native version draws a different bar.
+9. **The JS prompt field** has no label. "Block dialogs from this page" is a third button squeezed in
+ beside Cancel/OK. Chrome uses a checkbox, because it's an option, not an answer.
+
+**Dialogs and sheets**
+
+10. **The permission prompt** is a stock `AlertDialog` with bullet text and only Allow / Block. There's
+ no "just this time", no icon for what's being asked, and nothing about the connection.
+11. **Page info** is a paragraph in an `AlertDialog` with three crowded buttons. The site's permissions
+ aren't there, and Clear site data runs immediately, with no confirmation.
+12. **The console** is a flat list with no filters (errors are buried in logs), no copy, and a count
+ that only shows inside the menu.
+
+**Parity**
+
+13. **Two renderers, two looks.** The full-screen pill is plain Views: framework `Switch`, framework
+ ripple, hand-rolled type sizes. Sharing the layout spec stopped the two drifting in content, but
+ they still don't look alike. `:nappletHost` already depends on `:commonsUI` (Compose), so the
+ full-screen window can host the same Compose pill in a `ComposeView`.
+
+## 2. Principles
+
+- **One tap for daily things, two for rare ones.** Navigation, star and share sit in a capsule.
+ Page actions are a grid of tiles. Privacy and developer settings are grouped cards below.
+- **State is visible, even collapsed.** The security colour (error for HTTP, Tor accent for onion),
+ loading progress and console errors show on the grabber itself.
+- **Inputs are first-class.** Every text input has:
+ - a real container and a leading icon that says what it is;
+ - a placeholder or label;
+ - clear and submit buttons;
+ - the right keyboard and IME action;
+ - an empty or error state.
+
+ Choices are segmented buttons or switches that state their consequence, never a bare "on".
+- **Explain consequences in plain words.** Say "The site can't see your IP address", not "Loads over
+ Tor". Say "Allow while visiting / Only this time / Don't allow", not "Allow / Block".
+- **One implementation.** Stateless composables in `commonsUI`, driven by `BrowserChrome` (already
+ shared). The embedded tab, the full-screen window (through `ComposeView`) and a future desktop browser
+ all draw the same pixels.
+
+## 3. The redesign
+
+### 3.1 Collapsed handle
+
+A 48×24dp capsule at the top centre holding a 32dp bar.
+- A 2dp progress line runs under the bar while the page loads.
+- The bar takes the error colour on HTTP and the Tor accent when onion-routed.
+- A 6dp error dot appears at the right edge when the console has errors.
+- It keeps its current gestures: pull or tap to open.
+
+### 3.2 Expanded pill
+
+```
+╭────────────────────────────────────────────╮
+│ [P] Primal ( ✕ ) │ monogram tile · title · close (full screen)
+│ ╭────────────────────────────────────────╮ │
+│ │ 🧅 primal.net ✎ │ │ origin field: tap edits the address
+│ ╰────────────────────────────────────────╯ │
+│ ⓘ You left primal.net [ Back to app ] │ only when out of scope
+│ ╭────────────────────────────────────────╮ │
+│ │ ← → ↻ ★ ⇪ │ │ navigation capsule
+│ ╰────────────────────────────────────────╯ │
+│ ╭────╮ ╭────╮ ╭────╮ ╭────╮ │
+│ │ ⧉ │ │ 🔍 │ │ Aa │ │ ⌂+ │ │ page actions (tiles, 4 per row)
+│ │Copy│ │Find│ │Text│ │Home│ │
+│ ╰────╯ ╰────╯ ╰────╯ ╰────╯ │
+│ ╭────╮ ╭────╮ ╭────╮ │ toggle tiles fill when on (Desktop)
+│ │ 🖥 │ │ ⬈ │ │ ⛶ │ │
+│ ╰────╯ ╰────╯ ╰────╯ │
+│ A ━━━━━━●━━━━━━━ A 115% Reset │ appears when "Text" is on
+│ ╭ Privacy ───────────────────────────────╮ │
+│ │ (🧅) Onion routing [ ● ] │ │
+│ │ The site can't see your IP │ │
+│ │ (⚙) Site settings › │ │
+│ │ Camera allowed · Location blocked │ │
+│ ╰────────────────────────────────────────╯ │
+│ >_ Console (3 errors) [ ] │
+╰────────────────────────────────────────────╯
+```
+
+- **Origin field.** It looks like an input on purpose (the address is one tap away without an address
+ bar taking space):
+ - a pill-shaped container on `surfaceContainerHighest`;
+ - the security icon in its colour;
+ - the host in `titleSmall`;
+ - a trailing pencil.
+
+ Tapping it opens the address editor (§3.3). Long-pressing copies the link.
+- **Navigation capsule.** Five 48dp icon buttons on `surfaceContainerHigh`, `CircleShape`. Back and
+ forward dim when they can't be used. The star fills in `primary` when the page is pinned. Reload
+ becomes Stop, with a progress ring, while loading.
+- **Tiles.** 72dp-tall rounded (16dp) cards on `surfaceContainer`, each with an icon and a two-line
+ `labelMedium` label. Toggle tiles (desktop site, text size open) switch to `secondaryContainer` with
+ a check badge.
+- **Privacy card.** Grouped rows on one rounded container:
+ - each row has a leading icon in a 36dp tinted circle, a title, and a supporting line that states the
+ consequence;
+ - a trailing switch, or a chevron for navigation;
+ - site settings summarise camera / mic / location decisions inline.
+- **Developer row.** The console with an error badge (`errorContainer` pill) and a switch. It's the
+ only developer item, so it gets no heading.
+- **Sandboxed apps** use the same frame:
+ - the origin field reads "Sandboxed app" with a shield and doesn't edit;
+ - the capsule is reload + star;
+ - the tiles are find and text size;
+ - "What it can access" joins the privacy card.
+
+### 3.3 Address editor (first-class input)
+
+Replaces the origin field inside the pill:
+- A 56dp pill field with the security/search icon leading, the URL selected, and trailing clear (✕)
+ and Go (a filled `primary` circle with an arrow). The keyboard is URI type with IME Go.
+- Below it:
+ - a **Paste and go** chip when the clipboard holds a URL;
+ - then up to five suggestions (favorites first, then history, from `OmniboxSuggestions`), each with a
+ monogram, the title and the URL;
+ - each suggestion has a trailing ↖ that fills its URL into the field without going.
+- Back or tapping outside collapses it to the origin field.
+
+### 3.4 Bottom: find in page and console
+
+- **Find pill.** A floating 56dp capsule, 12dp above the bottom edge, with shadow. It holds:
+ - a search icon;
+ - the field with placeholder "Find in page";
+ - a match chip ("3 / 12", tonal; "No matches" in `errorContainer`);
+ - up/down, a divider, then close.
+
+ The IME Search action jumps to the next match.
+- **Console sheet.** A drag handle, then a title with filter chips "All 42 · Errors 3 · Warnings 5",
+ then Copy and Clear. Log rows have:
+ - a 3dp level-coloured stripe;
+ - the message in monospace;
+ - `file:line` muted on the right;
+ - long-press to copy.
+
+ Errors show first when the Errors chip is on.
+
+### 3.5 Permission prompt
+
+A card sheet:
+- the origin field (read-only) on top;
+- 48dp tinted icon circles for each thing asked (camera / mic / location);
+- a title in plain words ("Use your camera and microphone?") and one body line;
+- when the site is on Tor and asks for camera or mic, a muted note: "Calls can reveal your IP address
+ even over Tor".
+
+Three stacked full-width buttons: **Allow while visiting** (filled, remembered), **Only this time**
+(tonal, not remembered), **Don't allow** (text, remembered).
+
+### 3.6 JS dialogs
+
+A card with:
+- the origin field as its header (so a page can't spoof Amethyst UI);
+- the message in `bodyLarge`, scrolling past 40% of the screen;
+- for `prompt`, an `OutlinedTextField` with a label, a clear button, autofocus, and IME Done that
+ submits;
+- a **"Don't let this page show more dialogs"** checkbox (second dialog onward);
+- right-aligned Cancel / OK. "Leave site?" uses a destructive-tinted Leave.
+
+### 3.7 Page info
+
+A sheet with:
+- **Header:** monogram, host, and the full origin.
+- **Connection:** rows with icons: encryption state (a warning tone for HTTP), then the route (Tor or
+ open web, with the same consequence line as the pill), then the certificate: issued to, issued by,
+ and expiry.
+- **Permissions:** one row per camera / mic / location with a segmented button **Ask · Allow ·
+ Block**, editable in place.
+- **Cookies and site data:** an outlined destructive button, "Clear site data". The first tap turns it
+ into an inline confirmation ("Sign out of this site and delete its data? · Cancel · Clear") instead
+ of acting at once.
+
+## 4. Tokens
+
+| Use | Token |
+|---|---|
+| Pill surface | `surface`, 0 tonal elevation, 6dp shadow, 24dp bottom corners |
+| Inputs, origin field | `surfaceContainerHighest`, `CircleShape` |
+| Navigation capsule | `surfaceContainerHigh`, `CircleShape` |
+| Tiles, grouped cards | `surfaceContainer`, 16dp |
+| Toggle "on" | `secondaryContainer` / `onSecondaryContainer` |
+| Insecure | `error` / `errorContainer` |
+| Tor accent | `tertiary` (no new colour, readable in both themes) |
+| Spacing | 4dp grid, 16dp sheet padding, 8dp between tiles |
+| Type | title `titleMedium`, origin `titleSmall`, tiles `labelMedium`, supporting `bodySmall` |
+
+## 5. What shipped
+
+Every browser surface now draws the components in `commonsUI/…/browser/ui/pill/`; the hand-built chrome
+is gone.
+
+- **Embedded tabs** (`EmbeddedTabLayer`): `EmbeddedTabChrome` now carries a `BrowserPillUi` and one
+ `onEvent(BrowserPillEvent)` callback. The layer draws `BrowserPill`, `FindInPagePill` and
+ `ConsoleSheet`, and handles find and the console itself. The address editor gets suggestions from
+ favorites and history (`OmniboxSuggestions`), and "Paste and go" checks only the clip's type.
+- **Embedded page dialogs** (`WebAppScreen`): `PageDialogCard`, `PermissionPromptCard` and
+ `PageInfoSheet` in Compose `Dialog`s. `MSG_PAGE_INFO` now sends the certificate fields
+ (`KEY_CERT_ISSUED_TO` / `_BY` / `_VALID_UNTIL`) instead of a paragraph of text. Permissions in page
+ info are edited in place, straight into `WebSitePermissionRegistry`.
+- **Full-screen windows** (`NappletBrowserActivity`, `NappletHostActivity`): `BrowserChromeHost` hosts
+ the same composables in two `ComposeView`s over the page. The top view grows to fill the window only
+ while the pill is open, so it can catch taps outside the pill; the bottom view holds find or the
+ console. Dialogs, the permission prompt and page info are Compose `Dialog`s. The browser asks the
+ broker for the site's decisions before showing page info. `:nappletHost` now applies the Compose
+ compiler and links the same JetBrains Compose libraries (Apache-2.0) the app already ships.
+- **Permissions:** Allow while visiting (remembered), Only this time (granted, not remembered), and
+ Don't allow (remembered). Dismissing the prompt denies the request once and remembers nothing.
+- **Clear site data** asks for confirmation inline, in both surfaces.
+- **Removed:** `NappletControlSheet`, `BrowserFindBar`, `NappletConsolePanel`, `BrowserJsDialogs`,
+ `BrowserChromeLabels`, `TopControlSheet`, `EmbeddedFindBar`, `BottomConsoleSheet`,
+ `EmbeddedPageDialogs`, `ConsoleLogEntry` and `BrowserWebTools.pageInfo`, plus the Android strings only
+ they used.
+
+**Sandboxed apps, embedded and full screen alike:**
+- An embedded nSite is labelled as an nSite, not "Sandboxed app", and gets the Tor row. Switching the
+ route saves it in `NappletNetworkRegistry` and rebuilds the session, as the full-screen host relaunches.
+- "What it can access" is `AccessInfoSheet`: the launch capabilities, the keys-stay-in-Amethyst row, the
+ route (nSites), and Manage permissions. It replaces both the platform `AlertDialog` and the embedded
+ tab's `AccessDialog`.
+- The embedded tab has find, text size and the console. `NappletEmbedContract` gained `MSG_FIND`,
+ `MSG_FIND_NEXT`, `MSG_FIND_RESULT`, `MSG_SET_TEXT_ZOOM` and `MSG_CONSOLE_LOG`, which
+ `NappletHostService` serves the same way `NappletBrowserService` does. Load and HTTP errors show up as
+ console errors, as they do full screen.
diff --git a/amethyst/src/main/AndroidManifest.xml b/amethyst/src/main/AndroidManifest.xml
index c4304d3485..d2b9dabc65 100644
--- a/amethyst/src/main/AndroidManifest.xml
+++ b/amethyst/src/main/AndroidManifest.xml
@@ -17,6 +17,17 @@
+
+
+
+
+
+
+
+
+
+
+
+
apps.filterIsInstance().map { it.url to it.label } }
+ .distinctUntilChanged()
+ .collect { WebShortcuts.publishFavorites(this@Amethyst, instance.favoriteApps.favorites.value) }
+ }
+
+ // Hydrate the per-site camera/microphone/location answers the browser asks about.
+ WebSitePermissionRegistry.init(this)
+
// Hydrate the device-local browser visit history (main process only; feeds the omnibox suggestions).
instance.browserHistory.init()
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/favorites/WebShortcutActivity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/favorites/WebShortcutActivity.kt
new file mode 100644
index 0000000000..5650f36c6d
--- /dev/null
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/favorites/WebShortcutActivity.kt
@@ -0,0 +1,80 @@
+/*
+ * Copyright (c) 2025 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * 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.favorites
+
+import android.content.Context
+import android.content.Intent
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.lifecycle.lifecycleScope
+import com.vitorpamplona.amethyst.Amethyst
+import com.vitorpamplona.amethyst.commons.tor.TorType
+import com.vitorpamplona.amethyst.ui.MainActivity
+import com.vitorpamplona.amethyst.ui.tor.TorServiceStatus
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withTimeoutOrNull
+
+/**
+ * The target of every web-app launcher shortcut ([WebShortcuts]). Runs in the **main** process, so the app
+ * (account, broker, Tor) is up before the page opens, then hands off to the full-screen browser through
+ * [FavoriteAppLauncher.launchUrl] — the same path the Browser tab uses — and finishes without drawing.
+ *
+ * On a cold start from the launcher Tor is usually still bootstrapping. Opening straight away would load a
+ * Tor-routed site over the open web, so when Tor is configured this waits for it; if it doesn't come up in
+ * time, the user lands in Amethyst (where Tor's state is visible) rather than on the open web.
+ */
+class WebShortcutActivity : ComponentActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ val url = intent.getStringExtra(EXTRA_URL)?.takeIf { it.startsWith("https://") || it.startsWith("http://") }
+ if (url == null) {
+ finish()
+ return
+ }
+ lifecycleScope.launch {
+ val app = Amethyst.instance
+ val torWanted = app.torPrefs.torType.value != TorType.OFF && app.torManager.activePortOrNull.value == null
+ val torReady =
+ !torWanted ||
+ withTimeoutOrNull(TOR_WAIT_MS) { app.torManager.status.first { it is TorServiceStatus.Active } } != null
+ if (torReady) {
+ FavoriteAppLauncher.launchUrl(this@WebShortcutActivity, url)
+ } else {
+ startActivity(Intent(this@WebShortcutActivity, MainActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
+ }
+ finish()
+ }
+ }
+
+ companion object {
+ private const val EXTRA_URL = "url"
+ private const val TOR_WAIT_MS = 30_000L
+
+ fun intent(
+ context: Context,
+ url: String,
+ ): Intent =
+ Intent(context, WebShortcutActivity::class.java)
+ .setAction(Intent.ACTION_VIEW)
+ .putExtra(EXTRA_URL, url)
+ }
+}
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/favorites/WebShortcuts.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/favorites/WebShortcuts.kt
new file mode 100644
index 0000000000..991c66dc63
--- /dev/null
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/favorites/WebShortcuts.kt
@@ -0,0 +1,123 @@
+/*
+ * Copyright (c) 2025 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * 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.favorites
+
+import android.content.Context
+import android.graphics.BitmapFactory
+import android.widget.Toast
+import androidx.core.content.pm.ShortcutInfoCompat
+import androidx.core.content.pm.ShortcutManagerCompat
+import androidx.core.graphics.drawable.IconCompat
+import androidx.core.graphics.scale
+import com.vitorpamplona.amethyst.Amethyst
+import com.vitorpamplona.amethyst.R
+import com.vitorpamplona.amethyst.commons.browser.BrowserChrome
+import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp
+import com.vitorpamplona.quartz.utils.Log
+import com.vitorpamplona.quartz.utils.sha256.sha256
+import java.io.File
+import com.vitorpamplona.amethyst.commons.R as CommonsR
+
+/**
+ * Launcher shortcuts for web apps — the PWA "Add to Home screen" (pinned) and the long-press list on
+ * Amethyst's own icon (dynamic, from the favorites). Every shortcut opens the page **full screen in
+ * Amethyst's own browser** through [WebShortcutActivity], never the system browser, so the user's NIP-07
+ * signer is there.
+ *
+ * Main process only: the icon comes from [BrowserIconRegistry]'s captured favicon when it is a raster
+ * image, else Amethyst's launcher icon.
+ */
+object WebShortcuts {
+ private const val TAG = "WebShortcuts"
+ private const val MAX_DYNAMIC = 4
+ private const val DYNAMIC_PREFIX = "fav:"
+ private const val ICON_PX = 192
+
+ /** Asks the launcher to pin a shortcut to [url] labelled [title]. */
+ fun requestPin(
+ context: Context,
+ url: String,
+ title: String,
+ ) {
+ if (!ShortcutManagerCompat.isRequestPinShortcutSupported(context)) {
+ Toast.makeText(context, CommonsR.string.browser_home_shortcut_unsupported, Toast.LENGTH_SHORT).show()
+ return
+ }
+ val info = build(context, "web:" + idOf(url), url, title)
+ runCatching { ShortcutManagerCompat.requestPinShortcut(context, info, null) }
+ .onFailure { Log.w(TAG, "Pin shortcut request failed", it) }
+ }
+
+ /** Mirrors the first few web-app favorites into Amethyst's long-press shortcut list. */
+ fun publishFavorites(
+ context: Context,
+ favorites: List,
+ ) {
+ val shortcuts =
+ favorites
+ .filterIsInstance()
+ .take(MAX_DYNAMIC)
+ .map { build(context, DYNAMIC_PREFIX + idOf(it.url), it.url, it.label) }
+ runCatching {
+ val stale =
+ ShortcutManagerCompat
+ .getDynamicShortcuts(context)
+ .map { it.id }
+ .filter { it.startsWith(DYNAMIC_PREFIX) && it !in shortcuts.map { s -> s.id } }
+ if (stale.isNotEmpty()) ShortcutManagerCompat.removeDynamicShortcuts(context, stale)
+ shortcuts.forEach { ShortcutManagerCompat.pushDynamicShortcut(context, it) }
+ }.onFailure { Log.w(TAG, "Could not publish favorite shortcuts", it) }
+ }
+
+ private fun build(
+ context: Context,
+ id: String,
+ url: String,
+ title: String,
+ ): ShortcutInfoCompat {
+ val label = title.ifBlank { BrowserChrome.displayHost(url) }
+ return ShortcutInfoCompat
+ .Builder(context, id)
+ .setShortLabel(label.take(24))
+ .setLongLabel(label.take(48))
+ .setIcon(iconFor(context, url))
+ .setIntent(WebShortcutActivity.intent(context, url))
+ .build()
+ }
+
+ private fun iconFor(
+ context: Context,
+ url: String,
+ ): IconCompat {
+ val bitmap =
+ Amethyst.instance.browserIcons
+ .iconModelFor(BrowserChrome.displayHost(url))
+ ?.removePrefix("file://")
+ ?.let { path -> runCatching { BitmapFactory.decodeFile(File(path).absolutePath) }.getOrNull() }
+ return if (bitmap != null) {
+ IconCompat.createWithBitmap(if (bitmap.width < ICON_PX) bitmap.scale(ICON_PX, ICON_PX, filter = false) else bitmap)
+ } else {
+ IconCompat.createWithResource(context, R.mipmap.ic_launcher)
+ }
+ }
+
+ private fun idOf(url: String): String = sha256(url.encodeToByteArray()).take(12).joinToString("") { "%02x".format(it) }
+}
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt
index 3340367115..30007d4ec8 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt
@@ -33,6 +33,7 @@ import android.os.RemoteException
import android.os.SystemClock
import androidx.core.net.toUri
import com.vitorpamplona.amethyst.Amethyst
+import com.vitorpamplona.amethyst.commons.browser.BrowserSitePermission
import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerPermissionLedger
import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp
import com.vitorpamplona.amethyst.commons.napplet.NappletBroker
@@ -42,6 +43,7 @@ import com.vitorpamplona.amethyst.commons.napplet.NappletIdentityWatch
import com.vitorpamplona.amethyst.commons.napplet.NappletRequestRouter
import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletProtocolJson
import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletResponse
+import com.vitorpamplona.amethyst.favorites.WebShortcuts
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.napplet.gateways.AccountNappletGateways
import com.vitorpamplona.amethyst.napplethost.NappletIpc
@@ -217,7 +219,7 @@ class NappletBrokerService : Service() {
return true
}
- // The direct-WebView browser requests a favorite toggle for the current URL (main process only).
+ // The direct-WebView browser pins/unpins the page it shows (main process only).
if (msg.what == NappletIpc.MSG_TOGGLE_WEB_FAVORITE) {
val data = msg.data ?: return true
val url = data.getString(NappletIpc.KEY_FAVORITE_URL)?.takeIf { it.isNotBlank() } ?: return true
@@ -225,11 +227,69 @@ class NappletBrokerService : Service() {
val favorites = Amethyst.instance.favoriteApps
favorites.init()
val id = "url:$url"
- if (favorites.isFavorite(id)) {
- favorites.remove(id)
- } else {
+ // The star sends the state it wants (it flips what it shows); an older client sends none: toggle.
+ val target =
+ if (data.containsKey(NappletIpc.KEY_FAVORITE_IS_FAVORITE)) {
+ data.getBoolean(NappletIpc.KEY_FAVORITE_IS_FAVORITE)
+ } else {
+ !favorites.isFavorite(id)
+ }
+ if (target) {
favorites.add(FavoriteApp.WebApp(url, label, System.currentTimeMillis()))
+ } else {
+ favorites.remove(id)
}
+ msg.replyTo?.let { replyWebFavoriteState(it, url) }
+ return true
+ }
+
+ // The direct-WebView browser asks whether the page it now shows is pinned, so its star is right.
+ if (msg.what == NappletIpc.MSG_QUERY_WEB_FAVORITE) {
+ val replyTo = msg.replyTo ?: return true
+ val url = msg.data?.getString(NappletIpc.KEY_FAVORITE_URL)?.takeIf { it.isNotBlank() } ?: return true
+ Amethyst.instance.favoriteApps.init()
+ replyWebFavoriteState(replyTo, url)
+ return true
+ }
+
+ // The browser asks what the user already answered for a site's camera/microphone/location.
+ if (msg.what == NappletIpc.MSG_QUERY_SITE_PERMISSIONS) {
+ val replyTo = msg.replyTo ?: return true
+ val data = msg.data ?: return true
+ val origin = data.getString(NappletIpc.KEY_BROWSER_ORIGIN)?.takeIf { it.isNotBlank() } ?: return true
+ WebSitePermissionRegistry.init(applicationContext)
+ val reply =
+ Message.obtain(null, NappletIpc.MSG_SITE_PERMISSIONS).apply {
+ this.data =
+ Bundle().apply {
+ putLong(NappletIpc.KEY_REQUEST_ID, data.getLong(NappletIpc.KEY_REQUEST_ID))
+ putString(NappletIpc.KEY_BROWSER_ORIGIN, origin)
+ BrowserSitePermission.entries.forEach { permission ->
+ putString(NappletIpc.KEY_SITE_PERMISSION_PREFIX + permission.key, WebSitePermissionRegistry.decision(origin, permission).name)
+ }
+ }
+ }
+ runCatching { replyTo.send(reply) }
+ return true
+ }
+
+ // The browser relays the user's answer to a site's permission prompt; remember it per origin.
+ if (msg.what == NappletIpc.MSG_SET_SITE_PERMISSION) {
+ val data = msg.data ?: return true
+ val origin = data.getString(NappletIpc.KEY_BROWSER_ORIGIN)?.takeIf { it.isNotBlank() } ?: return true
+ val permission = BrowserSitePermission.fromKey(data.getString(NappletIpc.KEY_SITE_PERMISSION)) ?: return true
+ val decision = runCatching { BrowserSitePermission.Decision.valueOf(data.getString(NappletIpc.KEY_SITE_DECISION).orEmpty()) }.getOrNull() ?: return true
+ WebSitePermissionRegistry.init(applicationContext)
+ WebSitePermissionRegistry.set(origin, permission, decision)
+ return true
+ }
+
+ // The full-screen browser's "Add to Home screen": pin a shortcut that reopens it in Amethyst.
+ if (msg.what == NappletIpc.MSG_ADD_TO_HOME_SCREEN) {
+ val data = msg.data ?: return true
+ val url = data.getString(NappletIpc.KEY_FAVORITE_URL)?.takeIf { it.startsWith("https://") || it.startsWith("http://") } ?: return true
+ Amethyst.instance.browserIcons.init()
+ WebShortcuts.requestPin(applicationContext, url, data.getString(NappletIpc.KEY_FAVORITE_LABEL).orEmpty())
return true
}
@@ -460,6 +520,26 @@ class NappletBrokerService : Service() {
}
}
+ /** Tells a browser surface whether [url] is currently pinned, so its star shows the registry's truth. */
+ private fun replyWebFavoriteState(
+ replyTo: Messenger,
+ url: String,
+ ) {
+ val message =
+ Message.obtain(null, NappletIpc.MSG_WEB_FAVORITE_STATE).apply {
+ data =
+ Bundle().apply {
+ putString(NappletIpc.KEY_FAVORITE_URL, url)
+ putBoolean(NappletIpc.KEY_FAVORITE_IS_FAVORITE, Amethyst.instance.favoriteApps.isFavorite("url:$url"))
+ }
+ }
+ try {
+ replyTo.send(message)
+ } catch (e: RemoteException) {
+ Log.w("NappletBrokerService", "Browser went away before the favorite state could be delivered", e)
+ }
+ }
+
/** Sends an unsolicited push (a `relay.event`/`relay.eose` envelope) for the host to forward verbatim. */
private fun push(
replyTo: Messenger,
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/WebSitePermissionRegistry.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/WebSitePermissionRegistry.kt
new file mode 100644
index 0000000000..efd41df623
--- /dev/null
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/WebSitePermissionRegistry.kt
@@ -0,0 +1,115 @@
+/*
+ * Copyright (c) 2025 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * 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
+
+import android.content.Context
+import androidx.datastore.core.DataStore
+import androidx.datastore.preferences.core.Preferences
+import androidx.datastore.preferences.core.edit
+import androidx.datastore.preferences.core.stringPreferencesKey
+import com.vitorpamplona.amethyst.Amethyst
+import com.vitorpamplona.amethyst.commons.browser.BrowserSitePermission
+import com.vitorpamplona.amethyst.commons.browser.BrowserSitePermission.Decision
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+
+/**
+ * The per-site permission file, on the app-wide holder rather than a `Context` delegate (the same
+ * `filesDir/datastore/web_site_permissions.preferences_pb` path the delegate used).
+ *
+ * Main process only: [Amethyst.instance] is deliberately unset in the `:napplet` sandbox, which reaches
+ * these answers through the broker.
+ */
+private val webSitePermissionDataStore: DataStore
+ get() = Amethyst.instance.appStores.getDataStore("web_site_permissions")
+
+/**
+ * The user's answers to web sites' camera / microphone / location requests, per **origin**
+ * (`https://example.com`), the way Chrome's site settings keep them. Absent = [Decision.ASK]: the browser
+ * prompts the next time the site asks. The same answer holds on Tor and the open web — routing never
+ * changes it.
+ *
+ * Lives only in the **main process**. The keyless browser reads and writes it through the broker
+ * ([com.vitorpamplona.amethyst.napplethost.NappletIpc.MSG_QUERY_SITE_PERMISSIONS] /
+ * [com.vitorpamplona.amethyst.napplethost.NappletIpc.MSG_SET_SITE_PERMISSION]); the Connected Apps detail
+ * screen shows and resets it. In-memory state is authoritative for the session, written through to a
+ * DataStore stored under `"|"` keys.
+ */
+object WebSitePermissionRegistry {
+ private val _decisions = MutableStateFlow