Mirrors the web-app load-recovery fix to the napplet/nsite path and fixes a
layer bug that kept the overlay from ever showing.
The embedded surface (SandboxedSdkView) is drawn by EmbeddedTabLayer, which
sits *above* the nav screens in the shell. So a loading/error overlay placed in
the favorite screen was covered by the surface's opaque pre-first-frame
background — the black void persisted. Move the overlay into EmbeddedTabLayer,
drawn over the active tab's bounds (where the chrome sheet already lives), so it
actually covers the surface. Also fixes the overlay sizing (fillMaxSize, not
matchParentSize, which collapsed to zero inside the reserved Box).
- Promote load state to the EmbeddedSurfaceController interface (loadStatus /
onLoadStatusChanged / retry), so EmbeddedTabLayer renders one overlay for both
the browser and napplet controllers. Shared EmbeddedLoadStatus +
EmbeddedLoadOverlay.
- NappletHostService now reports main-frame load state (start/finish/error) over
a new MSG_LOAD_STATE; EmbeddedNappletController relays it and exposes retry()
(= reload the verified content).
- The web-app path keeps its about:blank → canonical-URL self-heal; the napplet
path has no client-supplied URL to drop, so retry = reload.
Verified on device: with the network cut, the brainstorm tab shows
"Couldn't load this app." + Retry; restoring the network and tapping Retry loads
the page.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A favorite web app pinned to the bottom bar at runtime could come up on a
blank surface (black, then white after a manual reload) and never recover.
Its warm browser session settled on about:blank — its real URL was dropped on
the way in — and the chrome Reload button calls WebView.reload(), which just
re-loads about:blank instead of the favorite's page.
Fixes:
- The provider now reports main-frame load state (start/finish/error) over a
new MSG_LOAD_STATE. When a favorite session settles on about:blank while it
has a real URL, the controller re-navigates to the canonical URL once.
Gated on a real startUrl, so the generic browser's intentional about:blank
new-tab page is left alone. Adds controller.retry() (navigate-to-canonical,
not reload) for the chrome retry path.
- FavoriteWebAppScreen now draws a loading spinner until a real page paints,
and an error + Retry overlay when the main frame fails or the load stalls
(12s) — so a slow, blank, or failed load is no longer a silent black/white
void.
Scoped to the browser/WebUrl path; the napplet/nsite path is unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Builds on the omnibox work to modernize the launcher list now that favorites
and visit history both exist:
- Idle browser home (BrowserHome): pinned favorites on top under a "Favorites"
header, then a "Recent" section from the visit history — all in one grid so
they scroll together. Long-press a recent to drop it.
- Typed suggestions are grouped: a highlighted "Favorites" group first (subtle
primary-container tint + medium weight), then "Recent". Favorites still rank
first via the existing frecency boost.
- Real favicons: captured from the WebView that already loaded the page in the
keyless :napplet browser host (so they ride the page's own Tor-routed network
path — the main app never fetches host/favicon.ico itself), scaled and
relayed as PNG bytes over a new MSG_RECORD_ICON IPC, and stored per-host by
BrowserIconRegistry (main process, filesDir). Favorite cards, suggestion
rows, and recent rows all show them, falling back to a glyph.
FavoriteAppIcon gains an optional iconModel; FavoriteAppCell is reusable via a
new LazyGridScope.favoriteAppItems extension so the browser home and the
Favorite Apps tab share one cell. Thumbnails deferred to a follow-up.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017LyxWy2k3AT1LiZSvMsiDx
Refines the browser URL-bar experience across the launcher and the in-page
browser chrome:
- Shared URL normalization (commons OmniboxInput): dedupes the logic that was
copied between BrowserScreen and NappletBrowserService, recognizes bare
domains/localhost/IPs, falls back to a (configurable) DuckDuckGo search, and
flags .onion as Tor-only so the launcher forces Tor for it.
- Omnibox suggestions (commons OmniboxSuggestions): ranks favorites + visit
history by prefix/substring match, favorite boost, and frecency; deduped by
host. The launcher body turns into a suggestion list as you type.
- Inline ghost-text completion in the address field (TextFieldValue selection),
completing a typed host fragment to the top-ranked host.
- Visit history (BrowserHistoryRegistry, main process): a device-local,
bounded, DataStore-backed store. Pages are recorded ONLY on a clean
main-frame load — relayed from the keyless :napplet browser host over a new
MSG_RECORD_HISTORY IPC — so misspelled/unresolved addresses never enter it.
- In-page editable address bar (websites only) in NappletControlSheet, showing
the live URL + a security glyph (Tor/https/plain) and loading what the user
types. nsite/napplet hosts pass no navigate callback, so they never get one.
Pure logic is covered by unit tests in commons.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017LyxWy2k3AT1LiZSvMsiDx
The embedded surfaces' Compose TopControlSheet and the full-screen activities'
native NappletControlSheet are deliberate twins in different processes/modules
(Compose in the main app vs hand-built Views in the Compose-free :napplet
sandbox host), so they can't share a composable — but they should render
identically. Bring them in line:
- Uniform row rhythm: every action/Tor row now uses the same 10dp vertical
padding in both. The Compose switch row was 6dp while items were 12dp; both
are now 10dp, matching the native rows.
- Native Tor row now uses a real framework Switch as the state indicator (like
the embedded row) instead of an icon whose tint/alpha encoded on/off; the icon
is a steady muted tint and the whole row is the toggle target.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgMpRcWj6y82LxLiwcuzmN
Sandbox host services (per-session correctness + leaks):
- NappletHostService/NappletBrowserService: guard broker-reply delivery against
a stale tab (drop replies whose session was replaced) and wrap postMessage in
runCatching so a torn-down WebView can't crash the relay; tear down each tab's
content server + WebView on session close and in onDestroy; refuse to build an
orphan WebView for an unknown session.
- NappletContentServer.close(): shut the OkHttp dispatcher + evict the pool off
the hot path so a closed tab doesn't leak connections/threads.
- NappletBlobHttp: bound a blob fetch end-to-end with a callTimeout so a stalled
Tor exit can't pin the WebView worker thread indefinitely.
- UiAdapter close(): hop to the main thread before destroying the WebView.
Embedded IME (shim.js + RemoteImeView):
- Surrogate-pair-safe diff so an edited astral char (emoji, CJK-supplement) is
never split into a lone surrogate in the synthesized InputEvent data.
- Real contenteditable support: map char offsets through Ranges and replace in
place instead of overwriting textContent (which destroyed structure + caret).
- Dedup selectionchange against the last applied selection so our own setSel
doesn't echo back to the host as a fresh edit.
- RemoteImeView flushes synchronously at the outermost batch close, preserving
the composing region across a compose+commit in the same frame.
Embedded layer + preloader:
- Resize the cross-process surface to the snapped imeAnimationTarget instead of
the animated ime inset, so it doesn't reconfigure every keyboard-slide frame.
- yield() between favorites in the startup sweep so building WebViews doesn't
monopolize the frame.
- Per-site Tor/open-web registries expose awaitReady(); the preloader awaits
hydration before its first routing decision so a cold start can't route a
site the user pinned to the open web through Tor.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgMpRcWj6y82LxLiwcuzmN
Mirror the browser host's per-session refactor for the napplet/nsite embed
provider. A single NappletHostService instance is shared by every embedded tab
(same bound Intent), but it kept one set of fields (client messenger, config,
content server, WebView, broker bridge), so with >1 napplet tab open the
controls (reload/back/pause/resume), navigation state, "allow always" notices,
NIP-07 traffic, and IME all routed to whichever tab was created last.
Collect all per-surface state into a NappletTab keyed by a client-stamped
session id (KEY_SESSION_ID on MSG_CREATE_SESSION and every control message):
- Controls/pause/resume resolve the target tab by id and act on its own WebView.
- Content server, shell handshake (declaredDomains), launch token, and page
state/notices are per tab; onShellMessage resolves the tab by its WebView.
- Each tab gets its OWN reply Messenger, so broker responses AND unsolicited
relay pushes come back tagged to the right tab — no id rewriting, per-tab
origin/fire-seq state.
- onSessionClosed drops the tab and destroys only its own WebView; the broker is
bound once for the whole service.
EmbeddedNappletController generates a unique id and stamps it on all messages.
Both embed hosts (browser + napplet) are now fully per-tab correct, including the
keyboard for multiple simultaneous tabs.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgMpRcWj6y82LxLiwcuzmN
Extend the embedded keyboard to napplet/nsite surfaces. The shim's IME agent now
installs on any embedded surface (gated by __nappletImeProxy), reaching native
over whichever transport it has — the direct bridge for the browser, the trusted
shell relay for napplets — both through send().
- NappletContentServer gains an imeProxy flag that injects __nappletImeProxy
before the shim; NappletHostService sets it (embedded), the full-screen
NappletHostActivity leaves it off (native keyboard).
- NappletHostService relays ime.* between the applet (via the shell bridge) and
the client (MSG_IME_EVENT / MSG_IME_OP) — the shell already forwards all
message types, so no change to the trusted shell page.
- EmbeddedNappletController implements EmbeddedImeBridge, so EmbeddedTabLayer's
RemoteImeView drives it exactly like the browser.
Correct for a single nsite/napplet tab. Multiple simultaneous napplet tabs share
the host service's single client/bridge pointer (same limitation as reload/back/
NIP-07 there) — making that per-tab needs the session-scoping the browser host
already got; tracked as a follow-up.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgMpRcWj6y82LxLiwcuzmN
The embedded browser renders cross-process through SurfaceControlViewHost, which
forwards touch but not the soft keyboard (the embedded window can't be an IME
target, and androidx.privacysandbox.ui never wires IME). So focusing a field in
an embedded page did nothing.
Bridge the keyboard instead: host it in the main app window and relay editing to
the page.
- Shim IME agent (embedded browser only, gated by __nappletImeProxy): tracks the
focused editable, reports focus/blur/external-change, and applies host ops
(commit / compose / delete / key / editor-action) with real input & composition
events. Scrolls the field into view on focus.
- NappletBrowserService relays ime.* envelopes between the page bridge and the
client (MSG_IME_EVENT / MSG_IME_OP), per tab.
- EmbeddedBrowserController implements EmbeddedImeBridge (parses events, sends ops).
- RemoteImeView: an invisible EditText in the main window that takes the keyboard
for the active tab. Keeps a real local Editable (so the platform handles
composing/suggestions/selection) while an InputConnection wrapper forwards every
op to the page. Maps web input types / enterKeyHint to inputType/IME action.
- EmbeddedTabLayer hosts the proxy bound to the active tab and shrinks the active
surface by the IME height so the page can scroll the field clear of the keyboard.
Covers <input>/<textarea> fully and contenteditable best-effort (plain text).
Napplet/nsite embeds still need their own wiring (the shell path); this is the
browser surface.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgMpRcWj6y82LxLiwcuzmN
A single NappletBrowserService instance is shared by every embedded browser tab
(they bind the same Intent), but it kept one set of fields (webView, client
messenger, NIP-07 bridge state, reply messenger), so with >1 browser tab open:
controls (reload/Tor/back/navigate) hit whichever tab opened last, URL updates
went to the wrong address bar, and NIP-07 responses/pushes could land in the
wrong page.
Collect all per-surface state into a BrowserTab keyed by a client-stamped
session id (KEY_SESSION_ID on MSG_CREATE_SESSION and every control message):
- Controls resolve the target tab by session id and act on its own WebView.
- pushUrl delivers to that tab's own client messenger.
- Each tab gets its OWN reply Messenger, so broker responses AND unsolicited
relay pushes come back already tagged to the right tab (the broker just echoes
replyTo) — no id rewriting, and per-tab originTokens/mint state.
- onSessionClosed drops the tab and destroys only its own WebView.
EmbeddedBrowserController generates a unique session id and stamps it on all
messages. Tor note: the WebView proxy override is process-global (Android has no
per-WebView proxy), so toggling Tor still affects every tab; only the toggled
tab is reloaded. The napplet host service has the analogous shared-instance
shape (black-out already fixed) but isn't session-scoped yet.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgMpRcWj6y82LxLiwcuzmN
A single NappletBrowserService / NappletHostService instance is shared by every
embedded tab (they bind the same Intent), but each tab opens its own session
with its own WebView; the service's `webView` field is only a "latest" pointer.
The audit-batch "destroy stale WebView before rebuild" in createXWebView was
therefore destroying a *sibling* tab's live WebView whenever another browser/
napplet tab opened a session — exactly the repro: open A, switch to B (B's
create destroys A's WebView), back to A → black, B (created last) stays fine.
- Drop the destroy-on-create entirely; each session's WebView lives until that
session closes.
- onSessionClosed now destroys the closing session's OWN WebView (passed in)
and clears the shared pointer only if it still referenced it, so evicting one
tab can't tear down another either.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgMpRcWj6y82LxLiwcuzmN
A vertical LinearLayout defaults its children to MATCH_PARENT width, so the
collapsed grabber chip's rounded background spanned the entire screen. Give the
grabber explicit WRAP_CONTENT layout params (centered) so only the chip shows.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgMpRcWj6y82LxLiwcuzmN
The full-screen browser/napplet-host activities still showed the old corner
pill/globe — which sits exactly where a site puts its own login avatar. Add a
native-View NappletControlSheet (the twin of the embedded tabs' Compose
TopControlSheet): a small grabber at the top edge that pulls down to the page's
controls, and wire both :napplet full-screen activities to it.
- NappletControlSheet: title row (shield/globe), optional Tor row, reload, and
optional "what it can access". Tor supports an inline toggle (browser) or a
tap-through to a confirm dialog (nSite host, where switching rebuilds the
session). Tap or vertical drag to expand/collapse.
- NappletBrowserActivity / NappletHostActivity: drop buildFloatingChip/chipGlyph
for buildControlSheet(); attach at Gravity.TOP, full width.
- Add short napplet_net_tor_label / napplet_net_open_label strings.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgMpRcWj6y82LxLiwcuzmN
Durability & correctness:
- FavoriteAppsRegistry: tombstone removals made before async hydration so a
just-deleted favorite can't be resurrected by the disk merge.
- BottomBarEntry: stable @SerialName discriminators so persisted bottom-bar
configs survive class renames/moves.
- EmbeddedTabHost: guard reportBounds/setActiveChrome by active id so a
cross-fading outgoing screen can't clobber the incoming tab's bounds/chrome.
- EmbeddedNappletController: replay a parked-before-bound pause after session
create so a never-shown applet doesn't come up running.
- NappletBrowserService: bind the broker once (no leaked binding on re-create),
destroy a stale WebView before rebuilding, and reload only after the async
proxy override actually applies. NappletHostService: same WebView-reuse guard.
- NappletBrokerService: cap concurrent foreground leases so a misbehaving
sandbox can't pin Tor/relays with unbounded arbitrary keys.
Perf:
- Screens publish a remembered EmbeddedTabChrome; host short-circuits identical
publishes so the tab layer isn't recomposed every frame.
- AppBottomBar resolves favorites via an id-indexed map, not a per-entry scan.
- TopControlSheet keyed on the active tab so its expand state resets per tab.
Cleanup:
- Delete dead BrowserHostActivity + EmbeddedBrowserSurface + AppControlPuck and
their manifest entry; drop the unused `ready` surface state; null controller
refs on unbind; refresh stale z-order/host docs.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgMpRcWj6y82LxLiwcuzmN
- Remove the shipped scroll/zoom diagnostics from NappletBrowserService (per-touch
MotionEvent log and per-page zoom log).
- NappletBrowserActivity now renews its foreground lease on a 30s heartbeat like
NappletHostActivity, so the broker's 90s watchdog can't reap it (tearing down
Tor/relays) while the browser is genuinely foreground.
- Persist the per-host Tor choice against the host actually displayed (webView.url),
not the start URL, so an in-page navigation doesn't save the choice to the wrong site.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgMpRcWj6y82LxLiwcuzmN
The embedded surface forwarding taps but cancelling drags was a known bug in
androidx.privacysandbox.ui alpha10: with the provider surface z-ordered above,
"the gesture is exclusively received by the provider window and not transferred
to the client window" (alpha13 release notes). alpha15 then "set the default
Z-ordering to below" and "added support for the UI provider to receive
MotionEvents in this mode after being received by the client window" — i.e. the
drag-input path we needed.
Bump alpha10 → alpha17 and adapt the changed API: openSession takes SessionData
instead of a windowInputToken IBinder, Session adds notifySessionRendered, and
the session-state listener became setEventListener(SandboxedSdkViewEventListener)
(ready now flips on onUiDisplayed). The direct-WebView browser is unaffected
(it doesn't use this library).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgMpRcWj6y82LxLiwcuzmN
Confirmed on a real device: the streamed SurfaceControlViewHost surface forwards
taps but drops scroll/zoom/keyboard gestures — a hard limitation of
androidx.privacysandbox.ui on current Android. No tweak fixes it.
Add NappletBrowserActivity: a full-screen browser that hosts the WebView
*directly* in its own window in the keyless :napplet process, so scrolling,
pinch-zoom, and the soft keyboard (windowSoftInputMode=adjustResize) all work
natively. It carries over NappletBrowserService's per-origin NIP-07 bridge and
Tor proxy, plus NappletHostActivity's trusted chip, loading screen, and
foreground hold — so it stays just as keyless (page JS runs in :napplet, every
window.nostr call is brokered + consent-gated per origin in the main process).
Web favorites and URL launches now open this activity instead of the streamed
BrowserHostActivity. Per-host Tor choice persists via a new MSG_SET_WEB_TOR
broker message (the :napplet process relays it to WebUrlNetworkRegistry).
The embedded bottom-row web tab still uses the streamed surface (it must, to
live inside MainActivity) and so still can't scroll — that's a follow-up.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgMpRcWj6y82LxLiwcuzmN
Replace the full-width top bar on every running app surface (embedded web/nsite/
napplet tabs and the full-screen browser + napplet activities) with a small
floating control puck. Apps already title themselves, so instead of repeating
the name we keep one always-visible trusted marker — the sandbox shield for
napplets/nsites (the anti-phishing affordance the page can't draw over), a globe
for the plain browser — that expands on tap to reveal the actions (Tor, reload,
pop-out, access sheet, close).
- New shared AppControlPuck composable backs the three Compose surfaces;
NappletHostActivity gets the native-View equivalent.
- Embedded tabs inset their reserved surface bounds by the puck height
(AppControlPuckReserve) so the warm surface, drawn over those bounds above the
nav tree, doesn't cover the puck. Full-screen activities float it on top.
- EmbeddedTabTopBar is removed (no longer used).
Also remember the Tor on/off choice per web client: some sites' servers reject
Tor exits, so a user opting one out must have it stick. New WebUrlNetworkRegistry
(main process, keyed by host, device-local) mirrors NappletNetworkRegistry; the
browser reads it for the initial route and writes on toggle, in both the embedded
tab and the full-screen browser.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgMpRcWj6y82LxLiwcuzmN
Logs the WebView's current scale, density, and pixel widths on page finish, to
confirm whether the 400% zoom is a density/viewport mismatch from streaming the
WebView through SurfaceControlViewHost. Temporary, alongside the touch/session
diagnostics.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgMpRcWj6y82LxLiwcuzmN
Adds two temporary logs to pin down why the embedded browser doesn't scroll:
- provider side (NappletBrowserService): logs each MotionEvent that reaches the
remote WebView, so we can see if touch crosses the SurfaceControlViewHost
boundary at all (returns false, never consumes).
- client side (EmbeddedBrowserController): logs the SandboxedSdkView session
state transitions (Idle/Loading/Active/Error).
To be reverted once the cause is confirmed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgMpRcWj6y82LxLiwcuzmN
The embedded browser / nsite / napplet WebView runs in the keyless :napplet
process, which has no access to the main app's Compose theme, so before a page
painted it showed the WebView default white — jarring against Amethyst's (often
dark) background.
Pass the theme background color (MaterialTheme.colorScheme.background) across the
process boundary and apply it to the WebView, and paint the SandboxedSdkView
placeholder with it too so there's no white flash before the first frame. The
full-screen NappletHostActivity resolves the color locally from its themed
context. Covers the embedded browser tab, the full-screen browser activity, and
the embedded nsite/napplet tab.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgMpRcWj6y82LxLiwcuzmN
The cross-process foreground hold relied on the `:napplet` host delivering its
onPause "false" to release. If that process dies while foreground (a crash, an
OS kill) it never sends it, and the broker would hold the main process resumed —
Tor/relays/AUTH up — forever.
Turn the hold into a renewing lease. A resumed host re-reports foreground on a
30s heartbeat; the broker stamps each launch token's last-seen time and a
watchdog reaps any lease older than a 90s TTL, releasing its hold. A live app
keeps renewing so it's never wrongly dropped; a dead one stops renewing and is
reaped within the TTL, bounding any leak to one window instead of forever.
Only the cross-process napplet/nSite path needs this — BrowserHostActivity runs
in the main process, so if it dies the hold and every connection die with it.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgMpRcWj6y82LxLiwcuzmN
Opening a full-screen napplet/nSite (`:napplet` process) or browser host
(main process) backgrounds MainActivity, stopping its ManageRelayServices /
ManageWebOkHttp collectors. The underlying WhileSubscribed flows then scale
everything down on their timers — Tor's port (~2s), the relay pool (~30s),
dropping the relay AUTH sessions with it — even though the user is still on a
Nostr surface that brokers NIP-07 + relays back through that very process.
Add a ref-counted SandboxForegroundHold (main process): while held it
subscribes to exactly the flows the resumed UI subscribes to, keeping Tor, the
relay pool, and AUTH up; it releases when the last surface leaves so normal
background scaling resumes. Main-process activities call it directly; the
`:napplet` host can't touch that lifecycle, so it signals foreground over a new
MSG_SET_FOREGROUND IPC and the broker holds on its behalf (token-set keyed,
released on unbind).
Switching between several open surfaces (old.onPause -> new.onResume, plus the
async IPC hop) would dip the count to 0 and back to 1. A short release linger
keeps the same collectors running straight through that dip, so swapping
between open napplets/nSites/browser apps holds the network perfectly steady.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgMpRcWj6y82LxLiwcuzmN
The surface adapters set the WebView's layoutParams to a plain
ViewGroup.LayoutParams, but the SurfaceControlViewHost container measures its
children with measureChildWithMargins, which casts to MarginLayoutParams —
crashing the :napplet process with a ClassCastException on first layout.
Use FrameLayout.LayoutParams (a MarginLayoutParams) in both
NappletHostUiAdapter and NappletBrowserUiAdapter (openSession + notifyResized).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgMpRcWj6y82LxLiwcuzmN
The full-screen NappletHostActivity already consumes system-bar + cutout
insets at its root so its WebView doesn't pad the page for the status/nav
bars a second time (targetSdk 35+ auto-applies received insets to web
content). The embedded surfaces — the browser tab and the new embedded
nsite/napplet tabs — have no such root: the WebView is the surface view, so
it received and re-applied those insets, leaving an empty band under the
status and navigation bars even though the host already places the surface
in the inset-free content area.
Add WebView.dropSystemBarInsets() (zeroes system-bar + display-cutout
insets, keeps IME for keyboard resize) and apply it in both
NappletBrowserService and NappletHostService when the session WebView is
built.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgMpRcWj6y82LxLiwcuzmN
Completes the favorites system: a favorited nsite/napplet can now be
pinned to the bottom bar and render as an embedded, swap-in-place tab —
no longer only a full-screen activity launch.
New sandbox surface (:napplet, keyless), mirroring the browser embed:
- NappletHostService hosts the verified-blob WebView (same content server,
shell bridge, and single launch-token broker path as NappletHostActivity)
and ships it as a SandboxedUiAdapter surface, so applet JS still runs only
in the keyless process — never where the keys live.
- NappletHostUiAdapter / NappletEmbedContract are the SurfaceControlViewHost
adapter and the Messenger contract; the create-session bundle reuses
NappletHostContract's EXTRA_* keys, so the embedded and full-screen host
paths launch from identical, main-process-minted parameters.
Main process:
- NappletLauncher.buildLaunchParams extracts the verified param/token minting
so both the activity intent and the embedded session share it.
- EmbeddedNappletController binds the service and attaches the surface
(mirror of EmbeddedBrowserController).
- FavoriteNappletScreen draws the TRUSTED CHROME (sandbox shield, app name,
"what it can access") in the main process around the surface — the sandbox
must never draw chrome the user is meant to trust — plus a pop-out to the
full-screen host. Capability consent still flows through the existing
main-process broker + consent activity, unchanged and host-agnostic.
Security parity with the full-screen host:
- The applet's JS + timers are paused while the app is backgrounded
(lifecycle ON_STOP/ON_START → MSG_PAUSE/MSG_RESUME), so an "allow always"
napplet can't act on the user's behalf when they aren't looking.
- Granted sensitive ops (publish/upload/pay) surface a notice toast.
Both favorite kinds are now pinnable; the bottom bar routes WebUrl →
FavoriteWebApp and NostrApp → FavoriteNostrApp, each embedding in place.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgMpRcWj6y82LxLiwcuzmN
The full-screen NappletHostActivity browser-mode and NappletLauncher.launchBrowser
were added alongside the embedded browser but never wired into any UI. The embedded
surface (NappletBrowserService rendered via SurfaceControlViewHost) supersedes them
and draws the address bar in the trusted main process rather than inside the sandbox,
so this removes the weaker, unused surface.
Removed:
- NappletHostActivity browser-mode (setupBrowser, address bar, per-origin bridge) —
reverted the host activity to its pre-browser state.
- NappletLauncher.launchBrowser and the EXTRA_BROWSER_MODE/EXTRA_BROWSER_URL extras.
- The now-unused nappletHost browser strings.
Kept (used by the embedded path): the broker per-origin token mint
(MSG_MINT_BROWSER_TOKEN / MSG_BROWSER_TOKEN / KEY_BROWSER_ORIGIN) in NappletBrokerService
and NappletIpc, so NIP-07 consent stays scoped per visited origin.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgMpRcWj6y82LxLiwcuzmN
Adds a "Browser" navigation destination (drawer + pinnable bottom-nav item,
API 30+) that opens any URL. The page renders in the sandboxed, keyless
`:napplet` process and is streamed into the main activity as a cross-process
surface via androidx.privacysandbox.ui (SurfaceControlViewHost) — only pixels
and input cross the boundary, never the WebView's JS context or the NIP-07
bridge. The trusted address bar is drawn by the main process around the
embedded surface, so the sandbox can never spoof the URL.
NIP-07 `window.nostr` is injected the same way nSite website mode does it, but
scoped per visited origin: each origin gets its own broker-minted launch token
(keyed by the trusted source origin), so a grant to one site never leaks to
another.
- NappletBrowserService (`:napplet`): hosts the live-URL WebView, exposes it as
a SandboxedUiAdapter, and relays the per-origin NIP-07 bridge to the broker.
- NappletBrowserUiAdapter: wraps the WebView session for privacysandbox.ui.
- NappletBrokerService: mints a per-origin synthetic identity so NIP-07 consent
is scoped per host.
- EmbeddedBrowserController + BrowserScreen: bind the service, render the
SandboxedSdkView, and drive the trusted address bar (navigate/reload/back/Tor).
- shim.js: a direct-bridge transport so the injected shim works in a top-level
page that has no trusted shell parent.
- Browser nav item hidden below API 30 (SurfaceControlViewHost requirement).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgMpRcWj6y82LxLiwcuzmN
The new plain-AGP :nappletHost library only declared debug/release build
types (unlike the KMP :commons/:quartz/:nestsClient libs, which match any
build type). The :amethyst app's `benchmark` build type therefore had no
matching :nappletHost variant, breaking fdroidBenchmark/playBenchmark
resolution and Test/Build CI on main.
Declare a matching `benchmark` build type (initWith release) in :nappletHost
so the app resolves a real variant. Library AARs aren't signed, so no
signing config is needed here. :nappletHost is the only plain-AGP library
:amethyst depends on, so no consumer-side matchingFallbacks is required.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014tzXcqVUcAPhyDDFak4SQ8
- StrictMode DiskReadViolation: PrefetchManifestBlobs read context.cacheDir
(ensurePrivateCacheDirExists touches disk) on the composition dispatcher.
Move it into withContext(Dispatchers.IO); the prefetch was already IO-bound.
- Sandbox top-bar network indicator now uses the app's real Tor logo
(ic_tor, copied into :nappletHost since it can't depend on :amethyst) via an
ImageView, instead of the onion/globe emoji. Lit when routing through Tor,
dimmed when the site loads over the open web.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
nSites now load over Tor by default when Tor is active — closing the gap where
only blob fetches were Tor-routed while the site's own web traffic (fetch/img/
script) went out the system network and could leak the user's IP. Users can opt
a specific site out to the open web (e.g. a site that breaks or is slow over
Tor); the choice is remembered per site and makes everything for that site
direct (web + blobs).
- NappletHostActivity sets a process-wide WebView SOCKS proxy override
(socks5://127.0.0.1:<torPort>) for website-mode nSites, or clears it for open
web. Best-effort + on-device-verifiable: SOCKS-over-WebView support varies by
WebView version, so it's isolated to applyWebViewProxy() and never breaks the
site if unsupported.
- Top-bar onion (🧅 Tor / 🌐 open web) shows the routing and toggles it; the
dialog explains the IP-privacy trade. Shown only for nSites when Tor is active.
- NappletNetworkRegistry: main-process per-site preference (coordinate-keyed,
DataStore-backed, Tor-default). The launcher reads it; the broker persists the
sandbox's toggle resolved through the launch token. The key-free sandbox never
touches it.
- Toggling persists via a new MSG_SET_NETWORK_MODE IPC, then relaunches the host
so the proxy + content server rebuild cleanly for the new mode.
- "Open web" also routes blob fetches direct (effective proxy -1); locked
napplets always keep Tor for blobs (no toggle exposed).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
Two edge-to-edge issues on the napplet/nsite host, both surfacing as a white strip below
the applet on a light page:
- Double bottom inset. The root inset listener turned the system-bar/cutout insets into
padding but returned them un-consumed, so the child WebView (which on targetSdk 35+
auto-applies any insets it receives to its web content) padded the bottom a SECOND time.
Zero those types before they reach the WebView, keeping IME flowing so keyboard resize
still works.
- Over-scroll reveal. Forcing a scroll past the content edge stretched the view and
exposed the shell document's background behind the applet iframe. WebView.overScrollMode
only governs the outer frame, so also inject `overscroll-behavior: none` into the applet
document (alongside the shim) to kill the stretch at the source, theme-independently.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
nSites now open in "website mode": a normal web app with normal network
access plus a NIP-07 window.nostr provider, so standard Nostr web apps can
"log in with Amethyst" and sign as the active user. Napplets are unchanged
(locked, declared-only sandbox).
- window.nostr (shim.js) installs only when the host sets __nappletNip07
(website mode). getPublicKey/getRelays reuse the existing consent-gated
identity reads; signEvent is a new sign-only op honoring the app-supplied
created_at (no publish — the web app sends to relays itself).
- NappletRequest.SignEvent + nostr.signEvent decode; broker signs as the
user and returns the signed event without publishing. pubkey is still
fixed by the signer, so the app can never sign as another identity.
- Website mode: content server defers off-origin requests to the WebView
and drops the app CSP (normal network); locked napplets keep connect-src
'none' and 404 off-origin.
- Launcher grants IDENTITY + RELAY (consent-gated) for website mode,
independent of the nSite's empty manifest requires.
- Consent dialog shows the kind + content preview for a sign request.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
The sandbox host now routes the back gesture into the applet/site's own history
first: an OnBackPressedCallback calls webView.goBack() while there's history to
pop (in-page links, iframe navigations, and history.pushState all count), and
only disables itself — letting system back return to Amethyst — once the WebView
is at its first page. The callback's enabled state tracks canGoBack() via
doUpdateVisitedHistory / onPageFinished. Works with predictive back.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
Static-site / SPA nApplets & nSites (Vite/nsyte/CRA/webpack output) rendered as a
blank page, then — once that was fixed — as a fast reload-loop blink. Three layered
causes, each found on-device via logcat:
1. Sub-path serving. The applet loaded under https://napplet.local/app/, but bundlers
emit absolute asset URLs (/assets/app.js, /fonts/…) that resolve against the origin
ROOT, so every script/style/font 404'd. nSites are defined to be hosted at the domain
root; serve there.
2. Opaque-origin storage. The applet ran in an `allow-scripts`-only iframe, so its origin
was opaque ("null"): module scripts + asset fetches were CORS-blocked, and reading
localStorage/IndexedDB/serviceWorker threw SecurityError — which crash-loops every SPA
(gruuv: "cache version 0 < 23 → reset → reload", forever, because IndexedDB never
worked so the version never persisted).
Fix: give each applet its OWN real, persistent, isolated origin — a per-applet subdomain
https://<id>.napplet.local (id = sha256(author:identifier)), framed by the shell with
`allow-scripts allow-same-origin`. A real origin restores localStorage/IndexedDB/SW and
makes the applet's own assets same-origin (no CORS). Isolation is preserved because the
origin is DISTINCT from the shell's: the native bridge stays origin-restricted to the
shell (napplet.local), so the cross-origin applet still can't reach it or read the shell
DOM, and per-applet subdomains keep applets' storage isolated from each other. The shell
HTML's iframe src + CSP frame-src are bound to the specific applet origin at serve time.
Also add an in-memory localStorage/sessionStorage polyfill to the injected shim as
belt-and-suspenders for any context where DOM storage is still unavailable.
By-design sandbox enforcement is unchanged and correctly blocks the rest (external CDN
scripts, direct relay WebSockets via connect-src 'none', external images) — apps must go
through the napplet SDK for those.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Three load-time wins on the nApplet/nSite open path:
1. Overlap WebView/Chromium init with the index probe. The host now creates +
warms the WebView (and binds the broker) in onCreate, so its slow first-in-
process init runs concurrently with the IO availability probe instead of
serially after it; the probe just attaches the ready WebView.
2. Prefetch in parallel, index-first. NappletBlobPrefetcher downloads a
manifest's blobs with bounded concurrency (5) and fetches index.html first,
instead of strictly sequentially — faster first paint on cold opens.
3. Serve CAS hits without re-hashing. The content server short-circuits a
content-addressed cache hit (verified on write, addressed by sha256) straight
to the response, skipping the resolver's per-serve sha256 over the whole blob
— the dominant CPU cost for large bundles. Network (cache-miss) path still
fully verifies.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
Faster opens: while an nApplet/nSite card is on screen, eagerly download +
sha256-verify all of its blobs (Tor-routed) into a shared content-addressed
cache, so tapping Open serves from disk. Prefetch is de-duplicated (in-flight +
on-disk) and cancellation-aware (stops when the card scrolls away).
- New :nappletHost pieces: NappletBlobCache (content-addressed, multi-process-safe
atomic store, replacing the single-process OkHttp DiskLruCache), NappletBlobHttp
(shared Tor OkHttp + size-capped download), NappletBlobPrefetcher.
- NappletContentServer now reads the shared cache first, falling back to a Tor
download that refills it; still re-verifies every blob on serve.
- Cards trigger prefetch via a LaunchedEffect in the StaticWebsite render fns.
Nicer launch: NappletHostActivity probes the index (resolving + caching it)
behind a loading screen (monogram + title + spinner), then shows the app, or a
clear "couldn't load — try again" screen if the publisher's servers are offline.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
The `:napplet` sandbox runtime (NappletHostActivity, NappletContentServer,
NappletIpc, NappletKeyActions) now lives in a new :nappletHost Android library
that depends only on :commons + :quartz — NEVER :amethyst. So the sandbox code
is compile-time incapable of importing Amethyst.instance / LocalCache / Account,
turning the "two-process, no secrets in the sandbox" rule from a convention into
a build-graph guarantee.
- New module + NappletHostContract (Intent-extra keys + broker service FQN), so
the launcher (amethyst) and activity (module) share the launch contract with no
dependency cycle. The activity binds the broker by class name.
- Capability labels for the "what it can access" sheet are resolved by the
launcher (which has app resources) and passed in, so the module needs no
capability string resources. Host-only strings moved into the module.
- amethyst depends on :nappletHost; the broker-side (NappletBrokerService,
gateways, NappletLaunchRegistry) stays in :amethyst. Manifest declares the
activity by FQN (keeps @style/Theme.Amethyst resolvable).
- Docs updated (CLAUDE.md + security plan).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde