feat(napplet): broker the NAP theme domain

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
This commit is contained in:
Claude
2026-06-23 00:23:26 +00:00
parent 20b42d3443
commit 4b8b2ac513
12 changed files with 160 additions and 5 deletions
@@ -0,0 +1,52 @@
# Napplet NAP domains: theme, notify, inc
Date: 2026-06-23
Status: in progress
## Problem
Real-world demo napplets (e.g. kehto/web's `apps/playground/napplets/*`) hard-gate
their own boot: each reads `window.napplet.shell.supports(<domain>)` for every
domain in its manifest `requires` and aborts ("unavailable") if any is missing.
**Every** kehto demo `requires: theme`; most also need `inc`; toaster needs
`notify`. Amethyst's `fromNapDomain` returns `null` for `theme/notify/inc/cvm`,
so `shell.supports()` is false for them and all demos fail at boot.
These are real NAP service domains (kehto ships reference handlers). We add the
three the demos need (theme, notify, inc); `cvm` is deferred (its own design).
## Wire contracts (verified against kehto reference services + demos)
- **theme** — `theme.get``theme.get.result { theme: { colors: { background, text, primary } } }`.
Optional host push `theme.changed { theme }` (we skip the push for v1; the
app theme rarely changes while a napplet is foreground). Read-only, **no consent**.
- **notify** — `notify.create { title, body }``notify.created { id }` (past-tense,
**not** the generic `.result`), `notify.list``notify.listed { notifications }`,
`notify.dismiss { notificationId }` fire-and-forget. Consent-gated (ask once).
Host shows a system notification + tracks a per-coordinate store for list/dismiss.
- **inc** — a topic pub/sub bus. `inc.emit { topic, args, payload }` (fire-and-forget)
delivers `inc.event { topic, payload }` to **other** subscribed napplet sessions
(no echo to the sender). `inc.subscribe`/`inc.unsubscribe` register interest.
Gated on the INC declaration at the router edge (like `identity.watch`), no
per-call consent. NOTE: Amethyst runs napplets **foreground-only, one at a time**,
so cross-napplet delivery is usually a no-op in practice — but the bus is correct
if/when multiple sessions overlap, and it lets the demos boot + emit without error.
## Capability mapping & consent
`NappletCapability` gains `THEME`, `NOTIFY`, `INC`; `fromNapDomain` maps the bare
domains. `requiresConsent` is false for `SHELL` and `THEME` (negotiation/cosmetic),
true otherwise. INC is authorized at the router (declared-only) and never reaches
the broker consent path.
## Touch points
- commons: `NappletCapability`, `NappletRequest`, `NappletResponse`,
`NappletBrokerCollaborators` (new gateways), `NappletBroker`,
`protocol/NappletProtocolJson` (decode + custom reply types + inc/theme pushes),
`NappletRequestRouter` (inc edge ops).
- amethyst: `gateways/AccountNappletGateways` (+ theme/notify gateways), an
app-wide `NappletIncBus`, and `NappletBrokerService` wiring (notify store + inc
push transport, like `NappletLiveSubscriptions`/`NappletIdentityWatch`).
Staged commits: (1) theme, (2) notify, (3) inc.
@@ -36,6 +36,9 @@ fun NappletCapability.labelRes(): Int =
NappletCapability.VALUE -> R.string.napplet_cap_value
NappletCapability.RESOURCE -> R.string.napplet_cap_resource
NappletCapability.UPLOAD -> R.string.napplet_cap_upload
NappletCapability.THEME -> R.string.napplet_cap_theme
NappletCapability.NOTIFY -> R.string.napplet_cap_notify
NappletCapability.INC -> R.string.napplet_cap_inc
}
/** Localized one-line description of what a capability lets a napplet do. */
@@ -50,4 +53,7 @@ fun NappletCapability.descriptionRes(): Int =
NappletCapability.VALUE -> R.string.napplet_cap_value_desc
NappletCapability.RESOURCE -> R.string.napplet_cap_resource_desc
NappletCapability.UPLOAD -> R.string.napplet_cap_upload_desc
NappletCapability.THEME -> R.string.napplet_cap_theme_desc
NappletCapability.NOTIFY -> R.string.napplet_cap_notify_desc
NappletCapability.INC -> R.string.napplet_cap_inc_desc
}
@@ -85,7 +85,7 @@ class NappletConsentSummary(
}
is NappletRequest.ResourceBytes -> context.getString(R.string.napplet_consent_resource)
is NappletRequest.UploadBlob -> context.getString(R.string.napplet_consent_upload)
// Resolved in the broker before consent (negotiation / shell-mediated); never shown.
is NappletRequest.ShellSupports, is NappletRequest.RegisterAction, is NappletRequest.UnregisterAction -> ""
// Resolved in the broker before consent (negotiation / shell-mediated / cosmetic); never shown.
is NappletRequest.ShellSupports, is NappletRequest.RegisterAction, is NappletRequest.UnregisterAction, is NappletRequest.ThemeGet -> ""
}
}
@@ -21,6 +21,7 @@
package com.vitorpamplona.amethyst.napplet.gateways
import android.content.Context
import android.content.res.Configuration
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.napplet.NappletBroker
import com.vitorpamplona.amethyst.commons.napplet.NappletConsentPrompt
@@ -28,6 +29,8 @@ import com.vitorpamplona.amethyst.commons.napplet.NappletIdentityGateway
import com.vitorpamplona.amethyst.commons.napplet.NappletRelayGateway
import com.vitorpamplona.amethyst.commons.napplet.NappletResourceGateway
import com.vitorpamplona.amethyst.commons.napplet.NappletStorage
import com.vitorpamplona.amethyst.commons.napplet.NappletThemeColors
import com.vitorpamplona.amethyst.commons.napplet.NappletThemeGateway
import com.vitorpamplona.amethyst.commons.napplet.NappletUploadGateway
import com.vitorpamplona.amethyst.commons.napplet.NappletUploadResult
import com.vitorpamplona.amethyst.commons.napplet.NappletWalletGateway
@@ -89,8 +92,23 @@ class AccountNappletGateways(
val resource = NappletResourceGateway { url -> resourceFetcher.fetch(url) }
val identityReads = NappletIdentityGateway { method, argument -> identityReader.read(method, argument) }
val upload = NappletUploadGateway { bytes, contentType, filename -> uploadBlob(bytes, contentType, filename) }
val theme = NappletThemeGateway { currentThemeColors() }
return NappletBroker(account.signer, ledger, consent, relay, storage, wallet, resource, upload = upload, identityReads = identityReads)
return NappletBroker(account.signer, ledger, consent, relay, storage, wallet, resource, upload = upload, identityReads = identityReads, theme = theme)
}
/**
* The host theme colors a napplet maps to its CSS variables (`theme.get`): Amethyst's brand
* purple plus a background/text pair chosen from the current dark/light mode. Read-only and
* cheap; the napplet falls back to its own defaults if this is ever unavailable.
*/
private fun currentThemeColors(): NappletThemeColors {
val night = (context.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES
return if (night) {
NappletThemeColors(background = "#0E0E10", text = "#E6E6E6", primary = AMETHYST_PURPLE)
} else {
NappletThemeColors(background = "#FFFFFF", text = "#1A1A1A", primary = AMETHYST_PURPLE)
}
}
/**
@@ -176,5 +194,8 @@ class AccountNappletGateways(
companion object {
private const val QUERY_TIMEOUT_MS = 8_000L
private const val WALLET_TIMEOUT_MS = 60_000L
/** Amethyst's brand purple (`AmethystPurple`, commons Colors.kt), exposed as the theme primary. */
private const val AMETHYST_PURPLE = "#9A82DB"
}
}
@@ -340,4 +340,7 @@ private fun NappletCapability.symbol(): MaterialSymbol =
NappletCapability.VALUE -> MaterialSymbols.Bolt
NappletCapability.RESOURCE -> MaterialSymbols.Language
NappletCapability.UPLOAD -> MaterialSymbols.Upload
NappletCapability.THEME -> MaterialSymbols.Image
NappletCapability.NOTIFY -> MaterialSymbols.Notifications
NappletCapability.INC -> MaterialSymbols.SwapHoriz
}
+6
View File
@@ -694,6 +694,12 @@
<string name="napplet_cap_value_desc">Pay Lightning invoices</string>
<string name="napplet_cap_resource_desc">Fetch web and Blossom resources</string>
<string name="napplet_cap_upload_desc">Upload files to your media server</string>
<string name="napplet_cap_theme">Theme</string>
<string name="napplet_cap_notify">Notifications</string>
<string name="napplet_cap_inc">Messaging</string>
<string name="napplet_cap_theme_desc">Match your app\'s colors</string>
<string name="napplet_cap_notify_desc">Show you notifications</string>
<string name="napplet_cap_inc_desc">Exchange messages with other napplets</string>
<!-- Napplet consent dialog -->
<string name="napplet_consent_capability">Capability: %1$s</string>
<string name="napplet_consent_allow_always">Always allow</string>
@@ -69,6 +69,7 @@ class NappletBroker(
private val resource: NappletResourceGateway? = null,
private val upload: NappletUploadGateway? = null,
private val identityReads: NappletIdentityGateway? = null,
private val theme: NappletThemeGateway? = null,
) {
// Serializes the consent-prompt path so concurrent requests queue into one dialog at a time
// (see [authorizeWithConsent]). Only the prompt is held here; non-prompting paths and execute()
@@ -108,6 +109,8 @@ class NappletBroker(
// Keyboard/command action registration is a shell-mediated UI affordance, not key
// access — declared is enough; it never prompts.
request is NappletRequest.RegisterAction || request is NappletRequest.UnregisterAction -> true
// Cosmetic/negotiation capabilities (theme) never prompt.
!capability.requiresConsent -> true
// Remote/external signers run their own per-request consent UI — defer to them.
signerSelfGates(request) -> true
// A standing allow short-circuits, except for per-use capabilities (e.g. payments).
@@ -178,6 +181,12 @@ class NappletBroker(
is NappletRequest.GetPublicKey -> NappletResponse.PublicKey(signer.pubKey)
is NappletRequest.ThemeGet -> {
val gateway = theme ?: return NappletResponse.Unsupported("theme.get")
val colors = gateway.current()
NappletResponse.Theme(colors.background, colors.text, colors.primary)
}
is NappletRequest.IdentityRead -> {
val gateway = identityReads ?: return NappletResponse.Unsupported("identity.${request.method}")
val raw = gateway.read(request.method, request.argument) ?: return NappletResponse.Unsupported("identity.${request.method}")
@@ -105,6 +105,21 @@ fun interface NappletWalletGateway {
suspend fun payInvoice(invoice: String): String?
}
/** The host's current theme, as hex color strings the applet maps to CSS variables. */
class NappletThemeColors(
val background: String,
val text: String,
val primary: String,
)
/**
* Bridges the broker to the host's current theme for [NappletCapability.THEME] (`theme.get`). A
* `null` gateway answers `Unsupported`. Read-only and never prompts it exposes only cosmetic colors.
*/
fun interface NappletThemeGateway {
suspend fun current(): NappletThemeColors
}
/** A fetched resource: its [bytes] and best-effort [contentType]. */
class NappletResource(
val bytes: ByteArray,
@@ -27,8 +27,7 @@ package com.vitorpamplona.amethyst.commons.napplet
*
* The mapping is **default-deny**: an unrecognized NAP domain maps to `null` and the shell must
* surface it as unknown rather than silently granting it. Domains we don't yet broker
* (`inc`, `intent`, `theme`, `notify`, `media`, `config`, `outbox`, `ifc`, `cvm`) therefore
* resolve to `null` for now.
* (`intent`, `media`, `config`, `outbox`, `ifc`, `cvm`) therefore resolve to `null` for now.
*/
enum class NappletCapability {
/** `shell` — capability negotiation (`shell.supports`). Always available; needs no consent. */
@@ -59,8 +58,26 @@ enum class NappletCapability {
/** `upload` — shell-mediated blob upload (Blossom). */
UPLOAD,
/** `theme` — read the host's current theme colors (`theme.get`). Cosmetic, read-only, no consent. */
THEME,
/** `notify` — create/list/dismiss user-facing notifications (`notify.*`). */
NOTIFY,
/** `inc` — a topic pub/sub bus between napplets/services (`inc.emit`/`inc.event`). */
INC,
;
/**
* Whether using this capability requires user consent. Negotiation ([SHELL]) and the cosmetic,
* read-only theme read ([THEME]) never prompt; everything else does (subject to the broker's
* signer-self-gating and standing-grant rules). [INC] is authorized at the router edge on its
* declaration alone, so it never reaches the consent path regardless of this flag.
*/
val requiresConsent: Boolean
get() = this != SHELL && this != THEME
/**
* Whether the user must confirm **every single use** no standing auto-approval. True for
* [VALUE]: a payment always prompts with the amount shown, so a napplet can never silently
@@ -93,6 +110,9 @@ enum class NappletCapability {
"value" -> VALUE
"resource" -> RESOURCE
"upload" -> UPLOAD
"theme" -> THEME
"notify" -> NOTIFY
"inc" -> INC
else -> null
}
}
@@ -63,6 +63,11 @@ sealed interface NappletRequest {
override val capability get() = NappletCapability.IDENTITY
}
/** `theme.get` — read the host's current theme colors. Cosmetic, read-only, never prompts. */
data object ThemeGet : NappletRequest {
override val capability get() = NappletCapability.THEME
}
/** `shell.supports(domain, protocol?)` — capability negotiation; always answerable, no consent. */
data class ShellSupports(
val domain: String,
@@ -109,6 +109,13 @@ sealed interface NappletResponse {
val preimage: String?,
) : NappletResponse
/** Result of `theme.get`: the host's current theme colors (hex strings). */
data class Theme(
val background: String,
val text: String,
val primary: String,
) : NappletResponse
/** A successful operation with no return value (e.g. a storage write/remove). */
data object Done : NappletResponse
@@ -131,6 +131,7 @@ object NappletProtocolJson {
val o = json.parseToJsonElement(envelopeJson).jsonObject
return when (o.str("type")) {
"shell.supports" -> NappletRequest.ShellSupports(o.req("domain"), o.str("protocol"))
"theme.get" -> NappletRequest.ThemeGet
"identity.getPublicKey" -> NappletRequest.GetPublicKey
"relay.publish" -> {
val t = o.eventTemplate()
@@ -259,6 +260,16 @@ object NappletProtocolJson {
put("ok", true)
put("preimage", response.preimage)
}
is NappletResponse.Theme -> {
put("ok", true)
putJsonObject("theme") {
putJsonObject("colors") {
put("background", response.background)
put("text", response.text)
put("primary", response.primary)
}
}
}
is NappletResponse.Done -> {
put("ok", true)
}