diff --git a/amethyst/plans/2026-06-26-nsite-napplet-favorite-icons.md b/amethyst/plans/2026-06-26-nsite-napplet-favorite-icons.md
new file mode 100644
index 0000000000..1e7bf91f53
--- /dev/null
+++ b/amethyst/plans/2026-06-26-nsite-napplet-favorite-icons.md
@@ -0,0 +1,77 @@
+# nSite / nApplet favorite icons
+
+**Date:** 2026-06-26
+**Status:** implemented (pending on-device verification of the blob image-load path)
+
+## Problem
+
+When a user favorites a plain web app and pins it to the bottom nav, the generic globe
+icon is replaced by the **site's favicon**. Favorited nSites (NIP-5A) and nApplets
+(NIP-5D) did not get the same treatment — they fell back to the generic grid glyph.
+
+## Why the webapp trick doesn't carry over
+
+The webapp favicon is **captured live** from the WebView that loads the page
+(`NappletBrowserActivity.onReceivedIcon` → IPC `MSG_RECORD_ICON` →
+`BrowserIconRegistry`, keyed by host). That works because, for a plain webapp, the site
+**is** the WebView's main frame.
+
+nSites/nApplets render differently: they always load inside a **cross-origin sandboxed
+iframe** under a trusted shell document (`commons/.../composeResources/files/napplet/shell.html`,
+`iframe.src = '__APP_ORIGIN__/'`). `WebChromeClient.onReceivedIcon` only reports the
+**main frame's** favicon — i.e. the shell (`
Napplet`, no icon), never the
+applet's iframe. So the live-capture approach is structurally blind to the app's own
+favicon here, and mirroring it would silently show nothing.
+
+## Approach: derive the icon from the manifest's own bundled blobs
+
+An nSite/nApplet ships its files as `path → sha256` (`path` tags). Its icon is almost
+always one of those blobs (a conventional `/favicon.png`, `/icon.png`,
+`/apple-touch-icon.png`, …). We already download + sha256-verify every manifest blob into
+a shared, content-addressed cache (`NappletBlobCache` / `NappletBlobPrefetcher`,
+Tor-routed). So the icon can be resolved from the manifest itself — no WebView, no iframe
+problem, content-addressed and verifiable, on the same private network path as everything
+else.
+
+### Resolution priority (per favorite)
+
+1. **Captured/bundled blob** (`iconModel`) — the conventional icon path picked from the
+ manifest's blobs, loaded from the verified cache as a `file://` model.
+2. **Manifest `icon` tag** (`FavoriteApp.iconUrl`) — the publisher-declared icon URL
+ (already wired before this change).
+3. **Type glyph** — grid (nostr app) / globe (web), already the fallback in
+ `FavoriteAppIcon`.
+
+Blob beats the `icon` URL deliberately: the blob is verified and rides the site's
+Tor-routed path, whereas a remote `icon` URL would be a clearnet fetch by Coil. Both still
+beat the glyph.
+
+## Changes
+
+- **quartz** `nip5aStaticWebsites/NappletIconPath.kt` (new) — pure, unit-tested heuristic
+ that picks the best icon `PathTag` from a manifest's `path` tags (priority list of
+ conventional names + a loose raster fallback; prefers shallower paths; raster formats
+ over `.ico`/`.svg`). Tests in `NappletIconPathTest.kt`.
+- **quartz** — `NappletManifest.iconBlob()` (covers nApplet kinds) and
+ `RootSiteEvent.iconBlob()` / `NamedSiteEvent.iconBlob()` (nSite kinds) delegate to it.
+- **amethyst** `favorites/NappletFavoriteIcon.kt` (new) — `rememberNappletIconModel(coordinate)`:
+ re-resolves the live event from `LocalCache`, picks its icon blob, ensures it's in the
+ shared cache (prefetching on demand, off the composition thread), and returns a `file://`
+ Coil model. Returns null until the blob is on disk (icon appears on next recomposition).
+- **amethyst** — `AppBottomBar` and `FavoriteAppsScreen` now resolve that model for
+ `FavoriteApp.NostrApp` and pass it as `iconModel`, exactly as they already did with the
+ captured favicon for `FavoriteApp.WebApp`.
+
+`FavoriteApp` is unchanged (no persistence migration): the icon is resolved from the live
+manifest at render time, consistent with the existing rule that a `NostrApp` favorite is
+only usable while its event is resolvable in `LocalCache`.
+
+## Follow-ups / not done
+
+- **On-device verification** of the blob → Coil image load (the heuristic + wiring are
+ verified by unit tests + compilation; the actual image render needs a device).
+- **HTML `` parsing.** The heuristic matches by conventional file name.
+ A future pass could fetch + parse the index blob to honor a non-conventional icon path.
+- **`.svg` / `.ico` decoding.** Listed as low-priority candidates; if Coil can't decode
+ them the `FavoriteAppIcon` error fallback shows the glyph, so it's harmless but not
+ guaranteed to render.
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/favorites/NappletFavoriteIcon.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/favorites/NappletFavoriteIcon.kt
new file mode 100644
index 0000000000..d0937b37b2
--- /dev/null
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/favorites/NappletFavoriteIcon.kt
@@ -0,0 +1,109 @@
+/*
+ * Copyright (c) 2025 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.vitorpamplona.amethyst.favorites
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.platform.LocalContext
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.vitorpamplona.amethyst.Amethyst
+import com.vitorpamplona.amethyst.model.LocalCache
+import com.vitorpamplona.amethyst.napplethost.NappletBlobCache
+import com.vitorpamplona.amethyst.napplethost.NappletBlobPrefetcher
+import com.vitorpamplona.quartz.nip01Core.core.Event
+import com.vitorpamplona.quartz.nip5aStaticWebsites.NamedSiteEvent
+import com.vitorpamplona.quartz.nip5aStaticWebsites.RootSiteEvent
+import com.vitorpamplona.quartz.nip5aStaticWebsites.tags.PathTag
+import com.vitorpamplona.quartz.nip5dNapplets.NamedNappletEvent
+import com.vitorpamplona.quartz.nip5dNapplets.RootNappletEvent
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import java.io.File
+
+/** The chosen icon blob and the servers that hold it — resolved from the live manifest. */
+private class IconBlob(
+ val path: PathTag,
+ val servers: List,
+)
+
+/**
+ * A Coil model (`file://…`) for the app icon an nsite/napplet bundles in its own content — the verified,
+ * content-addressed blob the [NappletIconPath][com.vitorpamplona.quartz.nip5aStaticWebsites.NappletIconPath]
+ * heuristic picks from the manifest's `path` tags — or null when the manifest declares no such icon (or it
+ * hasn't downloaded yet). Used to decorate an nsite/napplet favorite the same way a captured favicon
+ * decorates a web favorite, except this rides the same Tor-routed, sha256-verified blob path as the rest of
+ * the site instead of a clearnet favicon fetch.
+ *
+ * Unlike a webapp's favicon, this can't be captured live: the applet runs in a cross-origin sandboxed
+ * iframe under the trusted shell, so `WebChromeClient.onReceivedIcon` only ever reports the shell's icon,
+ * never the applet's. Deriving it from the bundled blobs is the only path that sees the real icon.
+ *
+ * Observes the addressable note, so the icon resolves whenever the manifest arrives or updates in
+ * LocalCache — not only if it already happened to be cached at first composition (on a cold start the
+ * event streams in from relays a moment later). Returns null until the blob is on disk; the icon then
+ * appears on the next recomposition. All disk + network work runs off the composition thread. The blob is
+ * usually already cached (the browse/feed card prefetches every manifest blob, this one included); the
+ * on-demand fetch here just covers favorites whose card isn't currently on screen.
+ */
+@Composable
+fun rememberNappletIconModel(coordinate: String): String? {
+ val context = LocalContext.current
+
+ // checkGetOrCreate returns null only for a malformed coordinate, so this early return is stable for a
+ // given coordinate (it never flips across recompositions, which would break composition structure).
+ val note = remember(coordinate) { LocalCache.checkGetOrCreateAddressableNote(coordinate) } ?: return null
+ val noteState by note
+ .flow()
+ .metadata.stateFlow
+ .collectAsStateWithLifecycle()
+
+ // Key on the event itself, not the NoteState wrapper: re-resolve only when the manifest actually
+ // changes, not on every unrelated metadata bump (a reaction/zap tracked on the note).
+ val event = noteState.note.event
+ val icon = remember(event) { resolveIconBlob(event) } ?: return null
+
+ var model by remember(icon.path.hash) { mutableStateOf(null) }
+ LaunchedEffect(icon.path.hash) {
+ withContext(Dispatchers.IO) {
+ val file = File(NappletBlobCache.dirFor(context.cacheDir), icon.path.hash.lowercase())
+ if (!file.isFile) {
+ val torPort = Amethyst.instance.torManager.activePortOrNull.value ?: -1
+ runCatching { NappletBlobPrefetcher.prefetch(listOf(icon.path), icon.servers, context.cacheDir, torPort) }
+ }
+ if (file.isFile) model = "file://" + file.absolutePath
+ }
+ }
+ return model
+}
+
+/** Asks each nsite/napplet event type for its bundled icon blob + the servers that hold it. */
+private fun resolveIconBlob(event: Event?): IconBlob? =
+ when (event) {
+ is RootNappletEvent -> event.iconBlob()?.let { IconBlob(it, event.servers()) }
+ is NamedNappletEvent -> event.iconBlob()?.let { IconBlob(it, event.servers()) }
+ is RootSiteEvent -> event.iconBlob()?.let { IconBlob(it, event.servers()) }
+ is NamedSiteEvent -> event.iconBlob()?.let { IconBlob(it, event.servers()) }
+ else -> null
+ }
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/AppBottomBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/AppBottomBar.kt
index d6b853ced4..145f145807 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/AppBottomBar.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/AppBottomBar.kt
@@ -48,6 +48,7 @@ import com.vitorpamplona.amethyst.commons.favorites.FavoriteAppIcon
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.favorites.BrowserIconRegistry
import com.vitorpamplona.amethyst.favorites.FavoriteAppsRegistry
+import com.vitorpamplona.amethyst.favorites.rememberNappletIconModel
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -146,9 +147,15 @@ private fun RenderBottomMenu(
is FavoriteApp.WebApp -> Route.WebApp(fav.url)
is FavoriteApp.NostrApp -> Route.NostrApp(fav.coordinate)
}
+ // A web favorite uses its captured favicon; an nsite/napplet uses the verified
+ // icon blob bundled in its own content (the iframe sandbox rules out live capture).
val iconModel =
- remember(fav, iconKeys) {
- (fav as? FavoriteApp.WebApp)?.let { OmniboxInput.hostOf(it.url)?.let(BrowserIconRegistry::iconModelFor) }
+ when (fav) {
+ is FavoriteApp.WebApp ->
+ remember(fav, iconKeys) {
+ OmniboxInput.hostOf(fav.url)?.let(BrowserIconRegistry::iconModelFor)
+ }
+ is FavoriteApp.NostrApp -> rememberNappletIconModel(fav.coordinate)
}
FavoriteNavItem(destination == selectedRoute, fav, iconModel, destination, nav)
}
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/favorites/FavoriteAppsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/favorites/FavoriteAppsScreen.kt
index baa33c9861..4ecbed9bd1 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/favorites/FavoriteAppsScreen.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/favorites/FavoriteAppsScreen.kt
@@ -69,6 +69,7 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.favorites.BrowserIconRegistry
import com.vitorpamplona.amethyst.favorites.FavoriteAppLauncher
import com.vitorpamplona.amethyst.favorites.FavoriteAppsRegistry
+import com.vitorpamplona.amethyst.favorites.rememberNappletIconModel
import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
@@ -175,12 +176,17 @@ internal fun FavoriteAppCell(
) {
var menuOpen by remember { mutableStateOf(false) }
- // For a plain web favorite, prefer the favicon captured when its site was opened; nsites/napplets keep
- // their manifest icon. Observing the key set recomputes the model as an icon arrives.
+ // For a plain web favorite, prefer the favicon captured when its site was opened; an nsite/napplet uses
+ // the verified icon blob bundled in its own content. Observing the key set recomputes the model as a
+ // captured favicon arrives.
val iconKeys by BrowserIconRegistry.keys.collectAsStateWithLifecycle()
val faviconModel =
- remember(app, iconKeys) {
- (app as? FavoriteApp.WebApp)?.let { OmniboxInput.hostOf(it.url)?.let(BrowserIconRegistry::iconModelFor) }
+ when (app) {
+ is FavoriteApp.WebApp ->
+ remember(app, iconKeys) {
+ OmniboxInput.hostOf(app.url)?.let(BrowserIconRegistry::iconModelFor)
+ }
+ is FavoriteApp.NostrApp -> rememberNappletIconModel(app.coordinate)
}
Column(
diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip5aStaticWebsites/NamedSiteEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip5aStaticWebsites/NamedSiteEvent.kt
index a61407388f..cc033634ce 100644
--- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip5aStaticWebsites/NamedSiteEvent.kt
+++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip5aStaticWebsites/NamedSiteEvent.kt
@@ -54,6 +54,9 @@ class NamedSiteEvent(
fun icon() = tags.siteIcon()
+ /** The bundled blob that best looks like this site's app icon, or null. See [NappletIconPath]. */
+ fun iconBlob() = NappletIconPath.choose(paths())
+
fun identifier() = dTag()
companion object {
diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip5aStaticWebsites/NappletIconPath.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip5aStaticWebsites/NappletIconPath.kt
new file mode 100644
index 0000000000..19e0faeb05
--- /dev/null
+++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip5aStaticWebsites/NappletIconPath.kt
@@ -0,0 +1,114 @@
+/*
+ * Copyright (c) 2025 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.vitorpamplona.quartz.nip5aStaticWebsites
+
+import com.vitorpamplona.quartz.nip5aStaticWebsites.tags.PathTag
+
+/**
+ * Picks the [PathTag] most likely to be a site's / napplet's own app icon, from the `path` tags it
+ * already publishes — so a launcher (favorite tab, grid, card) can show the app's real icon even when
+ * the manifest carries no explicit `icon` URL tag. The chosen blob is content-addressed (sha256), so it
+ * loads from the same verified, Tor-routed blob cache as the rest of the site — never a clearnet favicon
+ * fetch that would leak the visit.
+ *
+ * Selection is by file name, not by reading the index HTML's `` (which would need the
+ * index blob fetched + parsed first). The conventional locations below cover the common cases; a future
+ * pass could add HTML parsing for the rest.
+ */
+object NappletIconPath {
+ // Preferred exact file names, best first. Raster web formats Android/Coil decodes natively rank above
+ // .ico (flaky on Android) and .svg (needs an extra Coil decoder), which sit last as best-effort — a
+ // failed decode just falls back to the type glyph, so listing them costs nothing.
+ private val PRIORITY =
+ listOf(
+ "apple-touch-icon.png",
+ "apple-touch-icon-precomposed.png",
+ "icon.png",
+ "icon-512.png",
+ "icon-512x512.png",
+ "icon-256.png",
+ "icon-192.png",
+ "icon-192x192.png",
+ "favicon.png",
+ "logo.png",
+ "icon.webp",
+ "favicon.webp",
+ "logo.webp",
+ "icon.jpg",
+ "icon.jpeg",
+ "favicon.ico",
+ "icon.svg",
+ "favicon.svg",
+ "logo.svg",
+ )
+
+ // Decode-friendly raster extensions for the loose fallback (no .ico / .svg here — only names we're
+ // confident render, since the fallback has no curated-name signal to justify a best-effort decode).
+ private val RASTER = listOf(".png", ".webp", ".jpg", ".jpeg", ".gif", ".bmp")
+
+ // Loose-fallback name stems: a raster file whose name looks like an icon, when no exact name matched.
+ private val STEMS = listOf("apple-touch-icon", "favicon", "icon", "logo")
+
+ // PRIORITY as a name -> rank lookup, so a path's preference is an O(1) map hit instead of a scan of
+ // the whole list per name. Built once.
+ private val PRIORITY_RANK: Map = PRIORITY.withIndex().associate { (i, name) -> name to i }
+
+ /**
+ * The best icon blob in [paths], or null if none looks like an icon. A lower-ranked conventional name
+ * wins over a higher one; among equally-named candidates the shallowest path wins (a root
+ * `/favicon.png` over a nested `/assets/x/favicon.png`); a loose icon-ish raster is the last resort.
+ *
+ * Single pass: [basename] is computed once per path (vs. once per path *per* priority name), and no
+ * intermediate lists are allocated — this runs over a whole site's `path` set, which can be large.
+ */
+ fun choose(paths: List): PathTag? {
+ var best: PathTag? = null
+ var bestRank = Int.MAX_VALUE
+ var bestDepth = Int.MAX_VALUE
+ var fallback: PathTag? = null
+ var fallbackDepth = Int.MAX_VALUE
+
+ for (p in paths) {
+ val b = basename(p.path)
+ val rank = PRIORITY_RANK[b]
+ if (rank != null) {
+ val d = depth(p.path)
+ if (rank < bestRank || (rank == bestRank && d < bestDepth)) {
+ best = p
+ bestRank = rank
+ bestDepth = d
+ }
+ } else if (best == null && RASTER.any(b::endsWith) && STEMS.any(b::contains)) {
+ val d = depth(p.path)
+ if (d < fallbackDepth) {
+ fallback = p
+ fallbackDepth = d
+ }
+ }
+ }
+
+ return best ?: fallback
+ }
+
+ private fun basename(path: String) = path.substringAfterLast('/').lowercase()
+
+ private fun depth(path: String) = path.count { it == '/' }
+}
diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip5aStaticWebsites/RootSiteEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip5aStaticWebsites/RootSiteEvent.kt
index 647a4ce17a..955420ce50 100644
--- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip5aStaticWebsites/RootSiteEvent.kt
+++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip5aStaticWebsites/RootSiteEvent.kt
@@ -53,6 +53,9 @@ class RootSiteEvent(
fun icon() = tags.siteIcon()
+ /** The bundled blob that best looks like this site's app icon, or null. See [NappletIconPath]. */
+ fun iconBlob() = NappletIconPath.choose(paths())
+
companion object {
const val KIND = 15128
diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip5dNapplets/NappletManifest.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip5dNapplets/NappletManifest.kt
index 728e9f1323..fb98aa0fc8 100644
--- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip5dNapplets/NappletManifest.kt
+++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip5dNapplets/NappletManifest.kt
@@ -22,6 +22,7 @@ package com.vitorpamplona.quartz.nip5dNapplets
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArray
+import com.vitorpamplona.quartz.nip5aStaticWebsites.NappletIconPath
import com.vitorpamplona.quartz.nip5aStaticWebsites.SiteAggregateHash
import com.vitorpamplona.quartz.nip5aStaticWebsites.siteAggregateHash
import com.vitorpamplona.quartz.nip5aStaticWebsites.siteDescription
@@ -63,6 +64,13 @@ interface NappletManifest {
/** `icon` tag: URL to the napplet's square app icon, when the publisher supplied one. */
fun icon(): String? = tags.siteIcon()
+ /**
+ * The bundled blob that best looks like this napplet's own app icon (a conventional `favicon`/`icon`
+ * path among [paths]), or null when none is present. Lets a launcher show the real icon from verified,
+ * Tor-routed content even without an explicit `icon` URL tag. See [NappletIconPath].
+ */
+ fun iconBlob(): PathTag? = NappletIconPath.choose(paths())
+
/** The NIP-5A aggregate hash recomputed from this manifest's [paths]. */
fun computeAggregateHash(): HexKey = SiteAggregateHash.compute(paths())
diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip5aStaticWebsites/NappletIconPathTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip5aStaticWebsites/NappletIconPathTest.kt
new file mode 100644
index 0000000000..08d40a5595
--- /dev/null
+++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip5aStaticWebsites/NappletIconPathTest.kt
@@ -0,0 +1,95 @@
+/*
+ * Copyright (c) 2025 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.vitorpamplona.quartz.nip5aStaticWebsites
+
+import com.vitorpamplona.quartz.nip5aStaticWebsites.tags.PathTag
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertNull
+
+class NappletIconPathTest {
+ private fun paths(vararg p: String) = p.mapIndexed { i, path -> PathTag(path, "hash$i") }
+
+ @Test
+ fun emptyReturnsNull() {
+ assertNull(NappletIconPath.choose(emptyList()))
+ }
+
+ @Test
+ fun noIconLikeFileReturnsNull() {
+ assertNull(NappletIconPath.choose(paths("/index.html", "/app.js", "/style.css")))
+ }
+
+ @Test
+ fun picksFaviconPng() {
+ val chosen = NappletIconPath.choose(paths("/index.html", "/favicon.png"))
+ assertEquals("/favicon.png", chosen?.path)
+ }
+
+ @Test
+ fun appleTouchIconBeatsFavicon() {
+ val chosen = NappletIconPath.choose(paths("/favicon.png", "/apple-touch-icon.png"))
+ assertEquals("/apple-touch-icon.png", chosen?.path)
+ }
+
+ @Test
+ fun rasterPngBeatsIcoAndSvg() {
+ val chosen = NappletIconPath.choose(paths("/favicon.ico", "/favicon.svg", "/icon.png"))
+ assertEquals("/icon.png", chosen?.path)
+ }
+
+ @Test
+ fun matchIsCaseInsensitive() {
+ val chosen = NappletIconPath.choose(paths("/Favicon.PNG"))
+ assertEquals("/Favicon.PNG", chosen?.path)
+ }
+
+ @Test
+ fun nestedIconMatchesByBasename() {
+ val chosen = NappletIconPath.choose(paths("/index.html", "/assets/icon.png"))
+ assertEquals("/assets/icon.png", chosen?.path)
+ }
+
+ @Test
+ fun rootIconPreferredOverNested() {
+ val chosen = NappletIconPath.choose(paths("/assets/deep/icon.png", "/icon.png"))
+ assertEquals("/icon.png", chosen?.path)
+ }
+
+ @Test
+ fun looseFallbackMatchesIconLikeRaster() {
+ // No exact conventional name, but a raster file whose name contains an icon stem.
+ val chosen = NappletIconPath.choose(paths("/index.html", "/my-app-logo.webp"))
+ assertEquals("/my-app-logo.webp", chosen?.path)
+ }
+
+ @Test
+ fun looseFallbackIgnoresNonRasterIconNames() {
+ // icon.css is an icon-named file but not a decodable raster image.
+ assertNull(NappletIconPath.choose(paths("/index.html", "/icon-fonts.css")))
+ }
+
+ @Test
+ fun icoChosenWhenItIsTheOnlyOption() {
+ val chosen = NappletIconPath.choose(paths("/index.html", "/favicon.ico"))
+ assertEquals("/favicon.ico", chosen?.path)
+ }
+}