mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-09 08:04:45 +00:00
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:
@@ -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 -> ""
|
||||
}
|
||||
}
|
||||
|
||||
+22
-1
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
+3
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
+9
@@ -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}")
|
||||
|
||||
+15
@@ -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,
|
||||
|
||||
+22
-2
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+5
@@ -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,
|
||||
|
||||
+7
@@ -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
|
||||
|
||||
|
||||
+11
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user