mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-09 08:04:45 +00:00
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:
@@ -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
|
||||
}
|
||||
+9
-2
@@ -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)
|
||||
}
|
||||
|
||||
+10
-4
@@ -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(
|
||||
|
||||
+3
@@ -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 {
|
||||
|
||||
+114
@@ -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 == '/' }
|
||||
}
|
||||
+3
@@ -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())
|
||||
|
||||
|
||||
+95
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user