diff --git a/amethyst/plans/2026-06-23-napplet-nap-theme-notify-inc.md b/amethyst/plans/2026-06-23-napplet-nap-theme-notify-inc.md new file mode 100644 index 0000000000..49f28de3a0 --- /dev/null +++ b/amethyst/plans/2026-06-23-napplet-nap-theme-notify-inc.md @@ -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()` 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. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletCapabilityLabels.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletCapabilityLabels.kt index 6a09fdc23e..adff3b46f6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletCapabilityLabels.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletCapabilityLabels.kt @@ -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 } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConsentSummary.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConsentSummary.kt index 5a50bb3d8a..268b46309d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConsentSummary.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConsentSummary.kt @@ -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 -> "" } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/gateways/AccountNappletGateways.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/gateways/AccountNappletGateways.kt index 378d2f88e3..933106354d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/gateways/AccountNappletGateways.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/gateways/AccountNappletGateways.kt @@ -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" } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/NappletPermissionsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/NappletPermissionsScreen.kt index cfbba00f71..2add599c49 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/NappletPermissionsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/NappletPermissionsScreen.kt @@ -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 } diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 1dbbdb7d09..64eea3501a 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -694,6 +694,12 @@ Pay Lightning invoices Fetch web and Blossom resources Upload files to your media server + Theme + Notifications + Messaging + Match your app\'s colors + Show you notifications + Exchange messages with other napplets Capability: %1$s Always allow diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBroker.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBroker.kt index f873e53eeb..626e538e10 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBroker.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBroker.kt @@ -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}") diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBrokerCollaborators.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBrokerCollaborators.kt index e007cbe355..5d26571fb2 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBrokerCollaborators.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBrokerCollaborators.kt @@ -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, diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletCapability.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletCapability.kt index 1d02203a11..a781dc7bc1 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletCapability.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletCapability.kt @@ -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 } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/protocol/NappletRequest.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/protocol/NappletRequest.kt index 1cc3735249..d0fd506cf2 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/protocol/NappletRequest.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/protocol/NappletRequest.kt @@ -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, diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/protocol/NappletResponse.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/protocol/NappletResponse.kt index e1c537c714..575aac8f74 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/protocol/NappletResponse.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/protocol/NappletResponse.kt @@ -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 diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/napplet/protocol/NappletProtocolJson.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/napplet/protocol/NappletProtocolJson.kt index 560acd9671..3cce5f0c7d 100644 --- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/napplet/protocol/NappletProtocolJson.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/napplet/protocol/NappletProtocolJson.kt @@ -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) }