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 ic_tor vector painter rendered at its intrinsic size because the Icon had
no size modifier, so it was oversized in the embedded web tab and the
full-screen browser. Pin it to the standard 24dp to match the reload icon.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgMpRcWj6y82LxLiwcuzmN
Two bottom-nav polish items:
- No label on favorite tabs — they now match the built-in items, which are
icon-only.
- Use the app's own icon. FavoriteApp gains an optional iconUrl (the
nsite/napplet manifest icon, captured when you favorite from its card and
persisted). A shared FavoriteAppIcon renders that icon, falling back to a
type glyph (the napplet/nsite mark, or the globe for a plain URL) when
there's no icon or it fails to load. Used in both the bottom bar and the
Favorite Apps grid.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgMpRcWj6y82LxLiwcuzmN
Per review, drop the shield-as-Tor (and the security sheet that hosted it):
Tor is now the app's standard ic_tor onion (TorToggleButton, lit when on Tor,
dimmed on the open web), matching how Tor appears on relays and in settings.
The shield is reserved for the nsite/napplet tab, where it genuinely means
"what it can access".
- New shared TorToggleButton (onion).
- EmbeddedTabTopBar takes a `leading` slot: the web tab puts the Tor onion
there, the napplet/nsite tab puts its access shield.
- BrowserHostActivity (full-screen) uses the same onion toggle instead of the
shield/sheet. WebAppSecurityDialog and its strings are removed.
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 BrowserHostActivity still used the shield (Security icon) as a
bare Tor toggle (pink when on Tor, plain when on the open web) — the same
icon-overload we removed from the embedded tabs. Switch it to the shared
"Security & privacy" pattern: the shield opens the WebAppSecurityDialog with
the Tor toggle inside, matching FavoriteWebAppScreen. The page keeps its
back/close + reload.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgMpRcWj6y82LxLiwcuzmN
The launcher's address field is a plain Row in the Scaffold topBar slot, so
(unlike a Material3 TopAppBar) it didn't apply the status-bar inset and drew
under the status bar. Add statusBarsPadding() to the omnibox row.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgMpRcWj6y82LxLiwcuzmN
The web-app tab and the nsite/napplet tab had different bars, and both used
the same MaterialSymbols.Security "shield" for different things — Tor on the
web tab, sandbox access on the napplet tab — which read as the same icon
meaning two things. (Tor's real mark is the ic_tor onion used elsewhere.)
- Add a shared EmbeddedTabTopBar (sandbox shield · title · reload · pop-out)
used by both tabs, so they're visually identical.
- The shield now consistently means "security & privacy": it opens a sheet.
Tor moves into that sheet (a live toggle on the web-app tab; the napplet
sheet keeps its capability list + network line), so the shield is never
confused with a Tor toggle and there's no standalone Tor icon to mistake
for it.
(The full-screen BrowserHostActivity pop-out still uses its own bar; can
harmonize that next if wanted.)
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
Replaces the parallel bottomBarItems (List<NavBarItem>) + bottomBarFavoriteIds
with a single ordered List<BottomBarEntry>, so built-in destinations and
favorite apps live in one list and can be pinned and drag-reordered together.
- BottomBarEntry = BuiltIn(NavBarItem) | Favorite(favoriteId). The favorite id
already encodes the route's parameters (the url / addressable coordinate), so
each entry maps deterministically to its Route — BuiltIn via NavBarCatalog,
Favorite via Route.FavoriteWebApp/FavoriteNostrApp. (Storing the raw Route
isn't an option: the sealed Route parent isn't @Serializable, so a List<Route>
can't be persisted without annotating the whole ~100-subtype hierarchy.)
- UiSettings/UiSettingsFlow carry bottomBarItems: List<BottomBarEntry>;
UISharedPreferences serializes it as JSON, with a legacy comma-separated
NavBarItem fallback so existing configs still load.
- BottomBarSettingsScreen now shows one reorderable list mixing built-ins and
favorites; the separate favorites section is gone.
- AppBottomBar renders entries in saved order; warm-keep membership derives
from the list's favorite entries.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgMpRcWj6y82LxLiwcuzmN
isMarkdown() treated the second newline of a blank line as a non-space
character, flipping the line-start tracker off. ATX headings, blockquotes,
and list markers that follow the standard blank-line spacing went
undetected, so NIP-23 long-form articles made of prose plus section
headings rendered as raw text. Exclude newline/carriage-return from the
line-start guard so a blank line stays at line start.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Implements keep-warm (approach A) so a bottom-row embedded tab keeps its
full state across tab swaps, scoped to bottom-row apps per review.
Why a persistent layer: androidx.privacysandbox.ui's SandboxedSdkView
closes its session in onDetachedFromWindow, so a session torn down the
moment a tab leaves composition is unavoidable if the surface lives inside
the per-screen composable. Instead, a single app-shell overlay
(EmbeddedTabLayer) holds every warm session's SandboxedSdkView attached the
whole time — the active one positioned over the current tab's reserved
content area, the rest parked off-screen but alive. No provider changes
needed: the WebView never detaches, so its JS state survives.
- EmbeddedTabHost: process-level holder of warm sessions (keyed by
FavoriteApp.id), the active id, and the active content bounds.
- EmbeddedSurfaceController unifies the browser + napplet controllers so the
layer can attach/park/teardown either; the napplet controller pauses its
applet while parked (onHidden) and on app-background.
- FavoriteWebAppScreen / FavoriteNappletScreen no longer host the surface;
they reserve the content area (reporting window bounds) and drive the warm
controller. The trusted napplet chrome stays in the main process.
Scope (per review): only bottom-bar favorites stay warm — a tab whose app
isn't a bottom-bar favorite is evicted (restarted) when it leaves
(EmbeddedTabHost.retainOnly, driven by the settings list). Genuine memory
pressure (onTrimMemory) drops all warm sessions; mere backgrounding does not.
Needs on-device verification of the surface overlay (alignment, touch
pass-through to the bars, warm re-show) — validated here by build + assemble.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgMpRcWj6y82LxLiwcuzmN
Reworks how favorites get into the bottom bar, per review: instead of a
grid Pin/Unpin that auto-appended to the bar, favorites are now activated
as a dedicated "Favorite apps" section in the bottom-bar settings page —
kept separate from the built-in destinations because favorites are dynamic
data, not the fixed NavBarItem enum.
- UiSettings/UiSettingsFlow/UISharedPreferences gain bottomBarFavoriteIds
(a device-local list of FavoriteApp ids), persisted alongside the
existing bottomBarItems.
- BottomBarSettingsScreen gets a "Favorite apps" section: one toggle per
favorite to activate/deactivate it as a bottom-bar tab.
- AppBottomBar renders the favorite tabs from that settings list instead of
a registry-side pinned set.
- FavoriteAppsRegistry drops the pinned-set machinery; the grid drops its
Pin/Unpin item. The "All apps" grid remains a built-in destination.
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
Builds on the favorites system so a favorite can live in the bottom row
as a real tab instead of only launching a full-screen activity.
- FavoriteAppsRegistry gains a pinned-ids set (persisted device-locally,
alongside the favorites list). Only WebUrl favorites are pinnable today
— they're the ones that embed in-process — so a pinned tab always swaps
in place and never launches an activity from the bottom row. Removing a
favorite unpins it.
- Route.FavoriteWebApp(url) + FavoriteWebAppScreen render the embedded
:napplet browser surface as an in-app tab: the app bottom bar stays, so
switching to/from it is an ordinary tab swap. A pop-out action hands the
same URL to the full-screen BrowserHostActivity for users who want it as
its own window.
- AppBottomBar appends pinned favorites as tabs after the built-in items,
navigating via navBottomBar (marked a tab root, so the bar stays).
- The Favorite Apps grid gains a Pin/Unpin action for WebUrl favorites.
Known follow-ups (intentionally out of this commit): NostrApp favorites
can't embed as tabs yet (they need an embedded nsite/napplet surface in
nappletHost), and embedded tabs rebuild on return rather than staying warm.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgMpRcWj6y82LxLiwcuzmN
Introduces a device-local "favorite apps" system that unifies nsites,
napplets, and arbitrary web clients behind one model and three
presentation shells, while removing the editable address bar from any
running app (it now lives only in the browser launcher).
Core spine:
- FavoriteApp (commons): a sealed model with two cases — NostrApp
(nsite/napplet, keyed by addressable coordinate so it survives code
updates) and WebUrl. napplet-vs-nsite is recomputed from the live
event at launch, never stored.
- FavoriteAppsRegistry (amethyst): device-local, DataStore-backed,
StateFlow source of truth; main process only, hydrated at app start.
- FavoriteAppLauncher: dispatches a favorite to its one launch path —
full-screen BrowserHostActivity for a URL, sandboxed NappletLauncher
(re-resolved from LocalCache) for an nsite/napplet.
Presentation:
- BrowserHostActivity: full-screen, single-app host in the main process
that embeds the keyless :napplet browser surface. Its own task/recents
entry (documentLaunchMode=intoExisting), no editable URL — locked to
the app it opened with, keeping one NIP-07 trust context per instance.
- BrowserScreen is now a launcher: an omnibox that opens each URL in its
own host activity, plus the shared favorites grid. The address bar is
gone from the content surface.
- FavoriteAppsScreen + FAVORITE_APPS bottom-bar item: a grid of big
launch buttons, reused inside the browser launcher.
- StaticWebsiteCard gains a header-actions slot; a star toggle on each
nsite/napplet card pins it (strings/store stay in the app layer).
EmbeddedBrowserSurface extracts the chrome-free surface + controller
helper so the tab, the launcher, and the host activity share one piece
of cross-process glue.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgMpRcWj6y82LxLiwcuzmN
Replace the self-contained AddressableAuthorRelayLoaderSubAssembler
(which duplicated UserOutboxFinderSubAssembler) with a thin bridge that
injects UserFinderQueryState entries directly into the existing
UserFinderFilterAssembler.
When EventFinderFilterAssembler detects an AddressableNote stub whose
author relay list is unknown, it subscribes that author to userFinder.
UserOutboxFinderSubAssembler already handles the kind-0/10002 fetch and
relay resolution — no logic is duplicated. Subscriptions are cleaned up
when the note loads or the EventFinder key is removed.
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
When a kind-16 repost (or any event) has an a-tag pointing to an
addressable event by an unknown author (no NIP-65 relay list loaded),
potentialRelaysToFindAddress() returned an empty set and the event was
never found.
AddressableAuthorRelayLoaderSubAssembler mirrors
UserOutboxFinderSubAssembler but is driven by EventFinderQueryState.
It detects AddressableNote stubs whose author relay list is missing and
emits kind-0/10002 filters to indexer/search relays. On EOSE it calls
invalidateFilters(), causing NoteEventLoaderSubAssembler to re-run with
the now-populated outbox relays and fetch the addressable event.
The new plain-AGP :nappletHost library only declares debug/release build
types (unlike the KMP :commons/:quartz libs, which match any build type).
The :amethyst app's `benchmark` build type had no fallback, so Gradle could
not resolve a matching :nappletHost variant for fdroidBenchmark/playBenchmark,
breaking Test/Build CI on main.
Add `matchingFallbacks += "release"` to the benchmark build type so it falls
back to a library's release variant when no benchmark variant exists.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014tzXcqVUcAPhyDDFak4SQ8
Adds the `inc` pub/sub bus so napplets that declare it boot and can exchange
topic events: `inc.subscribe {topic}` / `inc.unsubscribe {topic}` register
interest, `inc.emit {topic, payload}` fans out an `inc.event {topic, payload,
sender}` to OTHER subscribed napplet sessions (never echoing the sender) — the
kehto runtime's inc contract.
- Router edge ops (gated on the INC declaration alone, like identity.watch —
no per-call consent): SubscribeInc/UnsubscribeInc/EmitInc outcomes.
- Protocol: readTopic/readPayloadRaw + encodeIncEvent.
- NappletIncBus in the broker service routes across the live napplet sessions
(the one service every sandbox binds), keyed by reply Messenger.
- Tests for inc routing + declaration gating; updated capability/router tests
that asserted the old "inc/theme/notify are unknown" behavior.
NOTE: napplets run foreground-only/one-at-a-time, so cross-napplet delivery is
usually a no-op in practice; the bus is correct if sessions ever overlap. It is
app-wide (not author-scoped) — a future refinement could namespace topics by
author. Unblocks feed/profile-viewer/chat/bot. See the plan doc.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
Napplets can now create/list/dismiss user-facing notifications:
`notify.create { title, body }` → `notify.created { id }` (the bespoke
past-tense reply the client listens for, not the generic `.result`),
`notify.list` → `notify.listed { notifications }`, `notify.dismiss` fire-and-forget.
- NotifyCreate/NotifyList/NotifyDismiss requests, NotifyCreated/NotifyListed
responses, NappletNotifyGateway + NappletNotification; broker executes them
(consent-gated, ask-once).
- Protocol encodes the past-tense reply types.
- Android: NappletNotificationStore (per-coordinate, main-process, survives
broker rebuilds — a napplet only ever sees/dismisses its own) + a best-effort
system-tray notification.
- Consent summary + string for notify.
Unblocks the toaster demo. See the plan doc.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
Napplets can now read the host's current theme via `theme.get` →
`theme.get.result { theme: { colors: { background, text, primary } } }`,
mapping it to their CSS variables. This is the universal boot gate for
real-world napplets (e.g. kehto/web's demos all `requires: theme` and abort
if shell.supports('theme') is false).
- NappletCapability gains THEME (+ NOTIFY/INC, wired in following commits);
adds requiresConsent (false for SHELL/THEME — cosmetic/negotiation never prompt).
- ThemeGet request, Theme response, NappletThemeGateway; broker executes it
with no consent prompt.
- Android gateway returns Amethyst's brand purple with a dark/light bg+text pair.
- Capability label/description/icon + strings for theme/notify/inc.
See amethyst/plans/2026-06-23-napplet-nap-theme-notify-inc.md.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
- 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 disclosure used AnimatedVisibility's default transition and the inner servers
Column didn't fill width, so the section faded/expanded while the server list also
grew in horizontally from the left — an inconsistent, weird effect. Make the
transition explicit (fade + expandVertically/shrinkVertically anchored at Top, so
it opens/closes straight down like the card) and fill width on the servers column
so nothing slides in sideways.
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