Merge pull request #3384 from vitorpamplona/claude/nsite-napplet-icons-okz5y6

Show nSite/nApplet icons from bundled blobs in favorites
This commit is contained in:
Vitor Pamplona
2026-06-26 15:16:43 -04:00
committed by GitHub
9 changed files with 428 additions and 6 deletions
@@ -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 (`<title>Napplet</title>`, 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 `<link rel="icon">` 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.
@@ -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<String>,
)
/**
* 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<String?>(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
}
@@ -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)
}
@@ -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(
@@ -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 {
@@ -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 `<link rel="icon">` (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<String, Int> = 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>): 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 == '/' }
}
@@ -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
@@ -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())
@@ -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)
}
}