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) + } +}