2026-03-22 03:30:21 +00:00
/** Static configuration for the Apps view */
import type { Ref } from 'vue'
import { computed } from 'vue'
import { PackageState , type PackageDataEntry } from '@/types/api'
2026-05-13 22:59:55 -04:00
import { resolveAppUrl } from '../appSession/appSessionConfig'
2026-03-22 03:30:21 +00:00
2026-05-17 17:30:04 -04:00
export type AppsTab = 'apps' | 'websites' | 'services'
2026-03-22 03:30:21 +00:00
// Service container name patterns (backend/infra, not user-facing)
export const SERVICE_NAMES = new Set ( [
'dwn' , 'archy-mempool-db' , 'archy-btcpay-db' , 'archy-nbxplorer' , 'archy-tor' ,
2026-06-23 03:48:57 -04:00
// Headless backends with no user-facing UI: the Fedimint ecash client daemon,
// the Nostr relay, and the Meshtastic LoRa daemon (its chat UI lives in the
// built-in Mesh tab) belong in Services, not My Apps.
'fedimint-clientd' , 'nostr-rs-relay' , 'meshtastic' ,
2026-03-22 03:30:21 +00:00
'immich_postgres' , 'immich_redis' ,
2026-06-23 03:48:57 -04:00
// immich is now a manifest-driven stack (app_id-named, hyphen). The server is
// the launcher app; postgres/redis are backends → Services.
'immich-postgres' , 'immich-redis' ,
2026-03-22 03:30:21 +00:00
'mysql-mempool' , 'mempool-api' , 'archy-mempool-web' ,
'archy-bitcoin-ui' , 'archy-lnd-ui' , 'archy-electrs-ui' ,
2026-04-28 15:00:58 -04:00
'bitcoin-ui' , 'lnd-ui' , 'electrs-ui' ,
2026-03-22 03:30:21 +00:00
'indeedhub-postgres' , 'indeedhub-redis' , 'indeedhub-minio' ,
2026-03-31 11:06:19 +01:00
'indeedhub-api' , 'indeedhub-ffmpeg' ,
2026-03-22 03:30:21 +00:00
'indeedhub-relay' , 'indeedhub-build_api_1' , 'indeedhub-build_ffmpeg-worker_1' ,
'indeedhub-build_postgres_1' , 'indeedhub-build_redis_1' , 'indeedhub-build_minio_1' ,
'indeedhub-build_minio-init_1' , 'indeedhub-build_relay_1' ,
2026-03-30 13:35:02 +01:00
// L484 web-only apps — parked in Services for now
2026-04-11 19:59:36 +01:00
'nwnn' , '484-kitchen' , 'call-the-operator' ,
2026-03-30 13:35:02 +01:00
'syntropy-institute' , 't-zero' , 'arch-presentation' ,
2026-03-22 03:30:21 +00:00
] )
2026-05-17 17:30:04 -04:00
const INTERNAL_TOOLING_NAMES = new Set ( [
'buildx_buildkit_default' ,
] )
export function isInternalToolingPackage ( id : string , pkg? : PackageDataEntry ) : boolean {
const manifestId = pkg ? . manifest ? . id || ''
return INTERNAL_TOOLING_NAMES . has ( id ) || INTERNAL_TOOLING_NAMES . has ( manifestId ) || id . startsWith ( 'buildx_buildkit' ) || manifestId . startsWith ( 'buildx_buildkit' )
}
2026-03-22 03:30:21 +00:00
export function isServiceContainer ( id : string ) : boolean {
if ( SERVICE_NAMES . has ( id ) ) return true
if ( id . startsWith ( 'indeedhub-build_' ) ) return true
if ( id . startsWith ( 'archy-' ) ) return true
2026-06-23 03:48:57 -04:00
// Backend naming patterns that never carry a user-facing UI: databases and
// caches. Safe to classify by suffix (a database is never a launcher).
if ( /-(db|postgres|postgresql|redis|valkey|mariadb|mysql|cache)$/ . test ( id ) ) return true
if ( id . endsWith ( '_db' ) ) return true
2026-03-22 03:30:21 +00:00
return false
}
2026-04-28 15:00:58 -04:00
export function isServicePackage ( id : string , pkg? : PackageDataEntry ) : boolean {
if ( isServiceContainer ( id ) ) return true
const manifestId = pkg ? . manifest ? . id
return ! ! manifestId && isServiceContainer ( manifestId )
}
2026-03-22 03:30:21 +00:00
// Known app -> category mappings (matches App Store categorisation)
export const APP_CATEGORY_MAP : Record < string , string > = {
fix(apps): classify Bitcoin Core as an app, not a website (#8, #9)
bitcoin-core was missing from APP_CATEGORY_MAP, so isKnownApp() was false and
isWebsitePackage() fell through to 'has a runtime LAN address'. Once the running
container's LAN address (the bitcoind RPC port :8332) showed up ~a minute after
launch, Bitcoin Core was reclassified as a website: it dropped out of the Apps
tab and search, moved under Websites, and launching it opened :8332 (raw RPC)
instead of the :8334 custom UI that Knots opens.
Add 'bitcoin-core': 'money' alongside bitcoin-knots/bitcoin-ui so isKnownApp is
true, isWebsitePackage is false, and launchAppNow routes through openSession ->
resolveAppUrl (:8334 custom UI). Fixes search, category, and the launch URL.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 03:43:29 -04:00
'bitcoin-core' : 'money' , 'bitcoin-knots' : 'money' , 'bitcoin-ui' : 'money' , 'electrumx' : 'money' , 'electrs' : 'money' ,
2026-06-11 00:24:40 -04:00
'lnd' : 'money' , 'mempool' : 'money' , 'mempool-web' : 'money' , 'btcpay-server' : 'commerce' ,
2026-03-22 03:30:21 +00:00
'fedimint' : 'money' , 'fedimint-gateway' : 'money' ,
'indeedhub' : 'media' , 'jellyfin' : 'media' , 'photoprism' : 'media' , 'immich' : 'media' ,
2026-04-02 18:20:52 +01:00
'nextcloud' : 'data' , 'vaultwarden' : 'data' , 'filebrowser' : 'data' , 'cryptpad' : 'data' ,
2026-03-22 03:30:21 +00:00
'homeassistant' : 'home' , 'lorabell' : 'home' , 'endurain' : 'home' ,
2026-05-19 14:29:20 -04:00
'searxng' : 'community' , 'ollama' : 'community' , 'grafana' : 'data' , 'gitea' : 'data' ,
release(v1.7.38-alpha): onboarding auto-heal + silent returning logins + app-store trim
- auth.rs now infers onboarding-complete from setup_complete + password_hash so
nodes stop bouncing users through the intro wizard after browser clear / update
/ reboot; the flag self-heals to disk on next check
- frontend: "backend uncertain" no longer defaults to /onboarding/intro —
useOnboarding returns null + callers poll / retry instead of flashing the wizard
- login sounds (synthwave, welcome voice, pop, whoosh, oomph) gated by
isFirstInstallPhase(); typing sounds unaffected
- removed FIPS app, Nostr Relay, Nostr VPN, Routstr, Penpot from catalog,
frontend config, Rust AppMetadata + install dispatch + install_penpot_stack;
docker/fips-ui + docker/nostr-vpn-ui + apps/penpot dirs and 5 icons deleted;
15 image versions deleted from tx1138, .168, gitea-local registries (.160
Gitea was 502 at release time — follow-up)
- AIUI baked into frontend release tarball via demo/aiui/; deploy-to-target
falls back to demo/aiui/ when the AIUI sibling checkout is missing
- prebuild hook syncs app-catalog/catalog.json → public/catalog.json so the
two copies can no longer drift (was the source of the "apps still visible"
bug — public/ had stale data)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 13:02:24 -04:00
'nostrudel' : 'nostr' ,
2026-05-19 14:29:20 -04:00
'tailscale' : 'networking' , 'netbird' : 'networking' , 'nginx-proxy-manager' : 'networking' , 'portainer' : 'networking' ,
2026-06-12 03:00:15 -04:00
'uptime-kuma' : 'networking' ,
2026-04-11 19:59:36 +01:00
'botfights' : 'community' , 'nwnn' : 'l484' , '484-kitchen' : 'l484' ,
2026-03-22 03:30:21 +00:00
'call-the-operator' : 'l484' , 'syntropy-institute' : 'l484' , 't-zero' : 'l484' ,
}
export function getAppCategory ( id : string , pkg : PackageDataEntry ) : string {
if ( APP_CATEGORY_MAP [ id ] ) return APP_CATEGORY_MAP [ id ]
const cat = ( pkg . manifest as unknown as Record < string , unknown > ) ? . category as string | undefined
return cat || 'other'
}
2026-05-17 17:30:04 -04:00
export function runtimeLanAddress ( pkg : PackageDataEntry ) : string {
return pkg . installed ? . [ 'interface-addresses' ] ? . main ? . [ 'lan-address' ] || ''
}
export function isKnownApp ( id : string , pkg? : PackageDataEntry ) : boolean {
const manifestId = pkg ? . manifest ? . id
return ! ! ( APP_CATEGORY_MAP [ id ] || ( manifestId && APP_CATEGORY_MAP [ manifestId ] ) || isWebOnlyApp ( id ) )
}
2026-06-17 06:09:46 -04:00
// True when the package's manifest declares a front-end UI interface. This is
2026-06-17 16:56:36 -04:00
// the authoritative "is this a user-facing app?" signal (#45/#51): apps with a
// UI belong in "My Apps", while headless services (databases, APIs, backends,
// workers) declare no UI and belong in the "Services" tab.
2026-06-17 06:09:46 -04:00
export function hasFrontendUi ( pkg? : PackageDataEntry ) : boolean {
return ! ! pkg ? . manifest ? . interfaces ? . main ? . ui
}
2026-05-17 17:30:04 -04:00
export function isWebsitePackage ( id : string , pkg? : PackageDataEntry ) : boolean {
if ( isInternalToolingPackage ( id , pkg ) ) return false
2026-06-17 06:09:46 -04:00
// Headless infra (databases/backends/companions) keyed by container name are
// services regardless of any stray UI string.
2026-05-17 17:30:04 -04:00
if ( isServicePackage ( id , pkg ) ) return true
2026-06-17 06:09:46 -04:00
// A declared front-end UI is the deciding factor: it's an app, not a website.
if ( hasFrontendUi ( pkg ) ) return false
// Curated known apps stay in My Apps even if their manifest predates the UI
// interface field.
2026-05-17 17:30:04 -04:00
if ( isKnownApp ( id , pkg ) ) return false
2026-06-23 03:48:57 -04:00
// Anything still here has no declared UI and isn't a known launcher app:
// databases, APIs, backends, workers. They belong in Services (not My Apps),
// whether or not they expose a LAN address. (#10 — "anything that isn't the
// frontend UI launcher".)
return ! ! pkg
2026-05-17 17:30:04 -04:00
}
2026-04-28 15:00:58 -04:00
export function filterEntriesForTab (
entries : Array < [ string , PackageDataEntry ] > ,
2026-05-17 17:30:04 -04:00
activeTab : AppsTab ,
2026-04-28 15:00:58 -04:00
selectedCategory : string ,
) : Array < [ string , PackageDataEntry ] > {
return entries . filter ( ( [ id , pkg ] ) = > {
2026-05-17 17:30:04 -04:00
if ( isInternalToolingPackage ( id , pkg ) ) return false
const wantsWebsites = activeTab === 'websites' || activeTab === 'services'
const isWebsite = isWebsitePackage ( id , pkg )
if ( wantsWebsites ? ! isWebsite : isWebsite ) return false
2026-04-28 15:00:58 -04:00
if ( activeTab === 'apps' && selectedCategory !== 'all' ) {
return getAppCategory ( id , pkg ) === selectedCategory
}
2026-06-23 03:48:57 -04:00
if ( activeTab === 'services' && selectedCategory !== 'all' ) {
return getServiceCategory ( id , pkg ) === selectedCategory
}
2026-04-28 15:00:58 -04:00
return true
} )
}
2026-06-23 03:48:57 -04:00
// Group a (non-launcher) service container by type for the Services tab sub-nav
// (#12). Heuristic over the container id + manifest id.
export function getServiceCategory ( id : string , pkg? : PackageDataEntry ) : string {
const s = ` ${ id } ${ pkg ? . manifest ? . id || '' } ` . toLowerCase ( )
if ( /postgres|mariadb|mysql|(^|[-_])db([-_]|$)/ . test ( s ) ) return 'database'
if ( /redis|valkey|(^|[-_])cache([-_]|$)/ . test ( s ) ) return 'cache'
if ( /(^|[-_])api([-_]|$)/ . test ( s ) ) return 'api'
return 'backend'
}
export function buildServiceCategories ( t : ( key : string ) = > string ) : Array < { id : string ; name : string } > {
return [
{ id : 'all' , name : t ( 'marketplace.all' ) } ,
{ id : 'database' , name : 'Databases' } ,
{ id : 'cache' , name : 'Caches' } ,
{ id : 'api' , name : 'APIs' } ,
{ id : 'backend' , name : 'Backends' } ,
]
}
2026-03-22 03:30:21 +00:00
// Web-only app IDs and their URLs
export const WEB_ONLY_APP_URLS : Record < string , string > = {
'nwnn' : 'https://nwnn.l484.com' ,
'484-kitchen' : 'https://484.kitchen' ,
'call-the-operator' : 'https://cta.tx1138.com' ,
2026-05-17 19:22:18 -04:00
'arch-presentation' : 'https://present.l484.com' ,
2026-03-22 03:30:21 +00:00
'syntropy-institute' : 'https://syntropy.institute' ,
't-zero' : 'https://teeminuszero.net' ,
}
export function isWebOnlyApp ( id : string ) : boolean {
return id in WEB_ONLY_APP_URLS
}
// Web-only apps (no container) -- always show as installed bookmarks
export const WEB_ONLY_APPS : Record < string , PackageDataEntry > = {
'nwnn' : {
state : 'running' as PackageState ,
manifest : { id : 'nwnn' , title : 'Next Web News Network' , version : '1.0.0' , description : { short : 'Decentralized news aggregator, synced from Telegram' , long : '' } , 'release-notes' : '' , license : '' , 'wrapper-repo' : '' , 'upstream-repo' : '' , 'support-site' : '' , 'marketing-site' : '' , 'donation-url' : null } ,
'static-files' : { license : '' , instructions : '' , icon : '/assets/img/app-icons/nwnn.png' } ,
} ,
'484-kitchen' : {
state : 'running' as PackageState ,
manifest : { id : '484-kitchen' , title : '484 Kitchen' , version : '1.0.0' , description : { short : 'K484 application platform' , long : '' } , 'release-notes' : '' , license : '' , 'wrapper-repo' : '' , 'upstream-repo' : '' , 'support-site' : '' , 'marketing-site' : '' , 'donation-url' : null } ,
'static-files' : { license : '' , instructions : '' , icon : '/assets/img/app-icons/484-kitchen.png' } ,
} ,
'call-the-operator' : {
state : 'running' as PackageState ,
manifest : { id : 'call-the-operator' , title : 'Call the Operator' , version : '1.0.0' , description : { short : 'Escape the Matrix — explore decentralized alternatives' , long : '' } , 'release-notes' : '' , license : '' , 'wrapper-repo' : '' , 'upstream-repo' : '' , 'support-site' : '' , 'marketing-site' : '' , 'donation-url' : null } ,
'static-files' : { license : '' , instructions : '' , icon : '/assets/img/app-icons/call-the-operator.png' } ,
} ,
2026-05-17 19:22:18 -04:00
'arch-presentation' : {
state : 'running' as PackageState ,
manifest : { id : 'arch-presentation' , title : 'Arch Presentation' , version : '1.0.0' , description : { short : 'Archipelago: The Future of Decentralized Infrastructure' , long : '' } , 'release-notes' : '' , license : '' , 'wrapper-repo' : '' , 'upstream-repo' : '' , 'support-site' : '' , 'marketing-site' : '' , 'donation-url' : null } ,
'static-files' : { license : '' , instructions : '' , icon : '/assets/img/app-icons/arch-presentation.png' } ,
} ,
2026-03-22 03:30:21 +00:00
'syntropy-institute' : {
state : 'running' as PackageState ,
manifest : { id : 'syntropy-institute' , title : 'Syntropy Institute' , version : '1.0.0' , description : { short : 'Medicine Reimagined — frequency analysis-therapy' , long : '' } , 'release-notes' : '' , license : '' , 'wrapper-repo' : '' , 'upstream-repo' : '' , 'support-site' : '' , 'marketing-site' : '' , 'donation-url' : null } ,
'static-files' : { license : '' , instructions : '' , icon : '/assets/img/app-icons/syntropy-institute.png' } ,
} ,
't-zero' : {
state : 'running' as PackageState ,
manifest : { id : 't-zero' , title : 'T-0' , version : '1.0.0' , description : { short : 'Documentary series on decentralization and Bitcoin' , long : '' } , 'release-notes' : '' , license : '' , 'wrapper-repo' : '' , 'upstream-repo' : '' , 'support-site' : '' , 'marketing-site' : '' , 'donation-url' : null } ,
'static-files' : { license : '' , instructions : '' , icon : '/assets/img/app-icons/t-zero.png' } ,
} ,
}
/** Apps that open in a new browser tab (X-Frame-Options blocks iframe) */
export const TAB_LAUNCH_APPS = new Set ( [
'btcpay-server' , 'grafana' , 'photoprism' , 'homeassistant' ,
2026-04-28 15:00:58 -04:00
'vaultwarden' , 'nextcloud' , 'uptime-kuma' , 'portainer' , 'gitea' ,
release(v1.7.38-alpha): onboarding auto-heal + silent returning logins + app-store trim
- auth.rs now infers onboarding-complete from setup_complete + password_hash so
nodes stop bouncing users through the intro wizard after browser clear / update
/ reboot; the flag self-heals to disk on next check
- frontend: "backend uncertain" no longer defaults to /onboarding/intro —
useOnboarding returns null + callers poll / retry instead of flashing the wizard
- login sounds (synthwave, welcome voice, pop, whoosh, oomph) gated by
isFirstInstallPhase(); typing sounds unaffected
- removed FIPS app, Nostr Relay, Nostr VPN, Routstr, Penpot from catalog,
frontend config, Rust AppMetadata + install dispatch + install_penpot_stack;
docker/fips-ui + docker/nostr-vpn-ui + apps/penpot dirs and 5 icons deleted;
15 image versions deleted from tx1138, .168, gitea-local registries (.160
Gitea was 502 at release time — follow-up)
- AIUI baked into frontend release tarball via demo/aiui/; deploy-to-target
falls back to demo/aiui/ when the AIUI sibling checkout is missing
- prebuild hook syncs app-catalog/catalog.json → public/catalog.json so the
two copies can no longer drift (was the source of the "apps still visible"
bug — public/ had stale data)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 13:02:24 -04:00
'cryptpad' , 'nginx-proxy-manager' , 'tailscale' ,
2026-06-17 19:21:42 -04:00
// netbird's dashboard needs HTTPS (secure context) so it opens in a new tab
'netbird' ,
2026-03-22 03:30:21 +00:00
] )
export function opensInTab ( id : string ) : boolean {
return TAB_LAUNCH_APPS . has ( id )
}
2026-06-23 03:48:57 -04:00
// Backend services that ship no icon of their own reuse their PARENT app's icon
// (#14) so they render the app's logo instead of a 404 → 📦 placeholder. Paths
// are explicit because icon extensions vary (.png / .webp / .svg).
2026-05-05 11:29:18 -04:00
const APP_ICON_FALLBACKS : Record < string , string > = {
gitea : '/assets/img/app-icons/gitea.svg' ,
2026-06-23 03:48:57 -04:00
'fedimint-gateway' : '/assets/img/app-icons/fedimint.png' ,
'fedimint-clientd' : '/assets/img/app-icons/fedimint.png' ,
// immich stack
'immich-postgres' : '/assets/img/app-icons/immich.png' ,
'immich-redis' : '/assets/img/app-icons/immich.png' ,
'immich-server' : '/assets/img/app-icons/immich.png' ,
'immich_postgres' : '/assets/img/app-icons/immich.png' ,
'immich_redis' : '/assets/img/app-icons/immich.png' ,
// btcpay stack
'archy-btcpay-db' : '/assets/img/app-icons/btcpay-server.png' ,
'archy-nbxplorer' : '/assets/img/app-icons/btcpay-server.png' ,
// mempool stack
'archy-mempool-db' : '/assets/img/app-icons/mempool.webp' ,
'mempool-api' : '/assets/img/app-icons/mempool.webp' ,
'archy-mempool-web' : '/assets/img/app-icons/mempool.webp' ,
'mysql-mempool' : '/assets/img/app-icons/mempool.webp' ,
// bitcoin / lightning companion UIs
'archy-bitcoin-ui' : '/assets/img/app-icons/bitcoin-knots.webp' ,
'archy-lnd-ui' : '/assets/img/app-icons/lnd.svg' ,
'archy-electrs-ui' : '/assets/img/app-icons/electrumx.png' ,
// ElectrumX ships under a few historical ids (the backend was renamed
// electrs → electrumx). Without an explicit map, an `electrs`-keyed install
// falls through to the default `/assets/img/app-icons/electrs.png`, which
// doesn't exist → handleImageError swaps .png→.svg and lands on electrs.svg
// (the "Electrs in Rust" logo) instead of the real ElectrumX icon. Pin the
// whole family to the ElectrumX icon so My Apps shows the right logo no
// matter which id the node has it installed under.
'electrs' : '/assets/img/app-icons/electrumx.png' ,
'electrs-ui' : '/assets/img/app-icons/electrumx.png' ,
'electrumx' : '/assets/img/app-icons/electrumx.png' ,
}
// Parent-app icon by prefix, for stack members not listed explicitly above
// (e.g. every indeedhub-* sub-container → indeedhub).
const SERVICE_ICON_PREFIXES : Array < [ string , string ] > = [
[ 'indeedhub-' , '/assets/img/app-icons/indeedhub.png' ] ,
[ 'immich-' , '/assets/img/app-icons/immich.png' ] ,
[ 'immich_' , '/assets/img/app-icons/immich.png' ] ,
]
function serviceParentIcon ( id : string ) : string | undefined {
for ( const [ prefix , icon ] of SERVICE_ICON_PREFIXES ) {
if ( id . startsWith ( prefix ) ) return icon
}
return undefined
2026-05-05 11:29:18 -04:00
}
2026-04-28 15:00:58 -04:00
2026-06-11 00:24:40 -04:00
export const DEFAULT_APP_ICON = '/assets/icon/favico-black-v2.svg'
2026-04-28 15:00:58 -04:00
export function resolveAppIcon ( id : string , pkg : PackageDataEntry , curatedIcon? : string ) : string {
2026-05-19 17:20:32 -04:00
const rawIcon = ( pkg [ "static-files" ] ? . icon || "" ) . trim ( )
const icon = rawIcon === '/assets/img/favico.png' ? '' : rawIcon
2026-04-28 15:00:58 -04:00
if (
icon . startsWith ( "/" ) ||
icon . startsWith ( "http://" ) ||
icon . startsWith ( "https://" ) ||
icon . startsWith ( "data:image" )
) {
return icon
}
2026-06-23 03:48:57 -04:00
return (
curatedIcon ||
APP_ICON_FALLBACKS [ id ] ||
serviceParentIcon ( id ) ||
` /assets/img/app-icons/ ${ id } .png `
)
2026-04-28 15:00:58 -04:00
}
2026-03-22 03:30:21 +00:00
export function canLaunch ( pkg : PackageDataEntry ) : boolean {
if ( isWebOnlyApp ( pkg . manifest . id ) ) return true
2026-05-13 22:59:55 -04:00
const hasRuntimeAddress = ! ! pkg . installed ? . [ 'interface-addresses' ] ? . main ? . [ 'lan-address' ]
const hasKnownLaunchUrl = typeof window !== 'undefined' && ! ! resolveAppUrl ( pkg . manifest . id )
const hasUI = pkg . manifest . interfaces ? . main ? . ui || hasRuntimeAddress || hasKnownLaunchUrl
2026-06-11 00:24:40 -04:00
if ( ( pkg . manifest . id === 'fedimint' || pkg . manifest . id === 'fedimintd' ) && hasUI ) {
return pkg . state === PackageState . Running || pkg . state === PackageState . Starting
}
fix: wallet receive reliability, bitcoin install self-heal, ElectrumX app tile
Fixes three Bitcoin/wallet failures observed across the fleet on v1.7.90-alpha
(all nodes were already on the latest build — these were live bugs, not stale
builds), plus the missing ElectrumX tile, and adds automated coverage so each
can't regress silently.
Receive address (".116 receive fails", ".228 false 'wallet is locked'"):
- LND publishes its REST API on a host port that can drift from the manifest
(a container created when the mapping was 8080 kept publishing 8080 after the
manifest moved to 18080). The in-process client connects to the manifest port,
gets connection-refused, and wallet init fails forever while the container
looks "Up". Add published-port drift detection to the reconciler
(container_ports_drifted / host_port_bindings_drifted) that recreates a
drifted backend even for restart-sensitive apps — a drifted container is
already broken, so leaving it "untouched" only perpetuates the failure.
- Receive errors now carry a stable [CODE] token (REST_UNREACHABLE, WALLET_LOCKED,
WALLET_UNINITIALIZED, SYNCING) and always start with "Bitcoin address" so they
survive the RPC error sanitizer instead of collapsing to the generic
"Operation failed". The UI maps the code instead of guessing wallet state from
substrings — so an unreachable REST endpoint is no longer mislabelled "locked".
Bitcoin install (".198 bitcoin gone / reinstall just stops"):
- bitcoin-knots requires the secret bitcoin-rpc-txrelay-rpcauth, which was only
generated by the tx-relay flow. Nodes that never used tx-relay lacked it, so
secret resolution hard-failed and the whole Bitcoin stack cascaded. Generate
it idempotently before bitcoin starts (ensure_app_secrets, reusing
ensure_txrelay_credentials), and name the missing secret in the error so a
genuine gap is actionable instead of a bare "IO error".
ElectrumX app tile missing on every node with it installed:
- The catalog generator dropped electrumx because the manifest had no
interfaces.main block, so the tile had no launch URL and was hidden. Declare
the companion UI port (50002) in the manifest, regenerate the catalog, and let
an app with a known launch URL stay launchable while its backend is still
"starting" (ElectrumX indexes for 10m+).
Test harness:
- New lifecycle bats suites: bitcoin-receive, port-drift, secret-completeness
(validated live; port-drift catches the real .116 drift).
- Rust unit tests for drift detection, the receive reason-code classifier, and
the named-missing-secret error; vitest for the UI code mapping.
- create-release.sh now runs tests/release/run.sh and aborts the release on
failure — previously it ran no tests at all.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 03:12:56 -04:00
// A static launch URL (e.g. a host-networked companion UI like
// archy-electrs-ui) serves independently of the backend's own sync state, so
// the tile stays launchable while the backend is still 'starting' (ElectrumX
// indexes for 10m+ on first run). A genuinely 'unhealthy' backend still
// blocks. Apps that rely on a runtime interface-address keep the strict gate.
const blockedByHealth =
pkg . health === 'unhealthy' || ( pkg . health === 'starting' && ! hasKnownLaunchUrl )
return ! ! hasUI && pkg . state === 'running' && ! blockedByHealth
2026-03-22 03:30:21 +00:00
}
2026-06-11 00:24:40 -04:00
export function launchBlockedReason ( id : string , pkg? : PackageDataEntry | null ) : string {
const appId = pkg ? . manifest ? . id || id
if (
( appId === 'fedimint' || appId === 'fedimintd' ) &&
( pkg ? . state === PackageState . Starting || ( pkg ? . state === PackageState . Running && pkg ? . health === 'starting' ) )
) {
return 'Guardian opens a wait page until Bitcoin finishes initial sync.'
}
return ''
}
2026-05-17 17:30:04 -04:00
export function resolveRuntimeLaunchUrl ( pkg : PackageDataEntry ) : string {
const addr = runtimeLanAddress ( pkg )
if ( ! addr || typeof window === 'undefined' ) return addr
return addr . replace ( /^http:\/\/(localhost|127\.0\.0\.1)(?=[:/]|$)/ , ` http:// ${ window . location . hostname } ` )
}
fix: overhaul container lifecycle — recovery, health, uninstall, UI state
Container recovery:
- Health monitor: MAX_RESTART_ATTEMPTS 3→10, interval 60s→120s
- Dependency-aware restarts: won't restart services before their deps
- Reset dependent counters when a dependency recovers
- Handle "created" state containers (were invisible to health monitor)
- Added IndeedHub, mempool-api, mysql to tier system
- Crash recovery: podman start timeout 30s→120s with retry
- Podman client: socket timeout 5s→30s, added restart policy
UI state representation:
- Exit code 0 shows "stopped" (gray), not "crashed" (red)
- Exit code 137 shows "killed (OOM)"
- Non-zero exit shows "crashed" (red)
- Added exit_code field to PackageDataEntry
Install/uninstall fixes:
- Install returns error when container doesn't start (was silent success)
- Post-install hooks awaited instead of fire-and-forget tokio::spawn
- Uninstall: graceful rm before force, volume prune, network cleanup
- Uninstall returns error on partial failure (was 200 OK)
Config consistency:
- DB passwords read from /var/lib/archipelago/secrets/ (was hardcoded)
- Bitcoin: added ZMQ ports 28332/28333 for LND block notifications
- IndeedHub port 7777→8190 (was conflicting with strfry)
- Marketplace versions: LND 0.17.4→0.18.4, Mempool 2.5.0→3.0.0
Performance:
- Metrics collector interval 60s→300s (was duplicating health monitor)
- Podman client: proper error propagation instead of unwrap_or_default
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 07:03:57 +01:00
export function getStatusClass ( state : PackageState , health? : string | null , exitCode? : number | null ) : string {
2026-03-22 03:30:21 +00:00
if ( state === PackageState . Running && health === 'starting' ) return 'bg-yellow-500/20 text-yellow-200'
if ( state === PackageState . Running && health === 'unhealthy' ) return 'bg-orange-500/20 text-orange-200'
switch ( state ) {
case PackageState . Running :
return 'bg-green-500/20 text-green-200'
case PackageState . Stopped :
return 'bg-gray-500/20 text-gray-200'
case PackageState . Exited :
fix: overhaul container lifecycle — recovery, health, uninstall, UI state
Container recovery:
- Health monitor: MAX_RESTART_ATTEMPTS 3→10, interval 60s→120s
- Dependency-aware restarts: won't restart services before their deps
- Reset dependent counters when a dependency recovers
- Handle "created" state containers (were invisible to health monitor)
- Added IndeedHub, mempool-api, mysql to tier system
- Crash recovery: podman start timeout 30s→120s with retry
- Podman client: socket timeout 5s→30s, added restart policy
UI state representation:
- Exit code 0 shows "stopped" (gray), not "crashed" (red)
- Exit code 137 shows "killed (OOM)"
- Non-zero exit shows "crashed" (red)
- Added exit_code field to PackageDataEntry
Install/uninstall fixes:
- Install returns error when container doesn't start (was silent success)
- Post-install hooks awaited instead of fire-and-forget tokio::spawn
- Uninstall: graceful rm before force, volume prune, network cleanup
- Uninstall returns error on partial failure (was 200 OK)
Config consistency:
- DB passwords read from /var/lib/archipelago/secrets/ (was hardcoded)
- Bitcoin: added ZMQ ports 28332/28333 for LND block notifications
- IndeedHub port 7777→8190 (was conflicting with strfry)
- Marketplace versions: LND 0.17.4→0.18.4, Mempool 2.5.0→3.0.0
Performance:
- Metrics collector interval 60s→300s (was duplicating health monitor)
- Podman client: proper error propagation instead of unwrap_or_default
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 07:03:57 +01:00
// Exit code 0 = clean shutdown (gray), non-zero = crash (red)
return exitCode != null && exitCode !== 0
? 'bg-red-500/20 text-red-200'
: 'bg-gray-500/20 text-gray-200'
2026-03-22 03:30:21 +00:00
case PackageState . Starting :
case PackageState . Stopping :
case PackageState . Restarting :
return 'bg-yellow-500/20 text-yellow-200'
case PackageState . Installing :
return 'bg-blue-500/20 text-blue-200'
2026-04-09 11:47:35 +02:00
case PackageState . Updating :
return 'bg-orange-500/20 text-orange-200'
2026-03-22 03:30:21 +00:00
default :
return 'bg-gray-500/20 text-gray-200'
}
}
fix: overhaul container lifecycle — recovery, health, uninstall, UI state
Container recovery:
- Health monitor: MAX_RESTART_ATTEMPTS 3→10, interval 60s→120s
- Dependency-aware restarts: won't restart services before their deps
- Reset dependent counters when a dependency recovers
- Handle "created" state containers (were invisible to health monitor)
- Added IndeedHub, mempool-api, mysql to tier system
- Crash recovery: podman start timeout 30s→120s with retry
- Podman client: socket timeout 5s→30s, added restart policy
UI state representation:
- Exit code 0 shows "stopped" (gray), not "crashed" (red)
- Exit code 137 shows "killed (OOM)"
- Non-zero exit shows "crashed" (red)
- Added exit_code field to PackageDataEntry
Install/uninstall fixes:
- Install returns error when container doesn't start (was silent success)
- Post-install hooks awaited instead of fire-and-forget tokio::spawn
- Uninstall: graceful rm before force, volume prune, network cleanup
- Uninstall returns error on partial failure (was 200 OK)
Config consistency:
- DB passwords read from /var/lib/archipelago/secrets/ (was hardcoded)
- Bitcoin: added ZMQ ports 28332/28333 for LND block notifications
- IndeedHub port 7777→8190 (was conflicting with strfry)
- Marketplace versions: LND 0.17.4→0.18.4, Mempool 2.5.0→3.0.0
Performance:
- Metrics collector interval 60s→300s (was duplicating health monitor)
- Podman client: proper error propagation instead of unwrap_or_default
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 07:03:57 +01:00
export function getStatusLabel ( state : PackageState , health? : string | null , exitCode? : number | null ) : string {
2026-03-22 03:30:21 +00:00
if ( state === PackageState . Running && health === 'starting' ) return 'starting up'
if ( state === PackageState . Running && health === 'unhealthy' ) return 'unhealthy'
if ( state === PackageState . Running && health === 'healthy' ) return 'healthy'
2026-04-09 11:47:35 +02:00
if ( state === PackageState . Updating ) return 'updating...'
2026-03-31 23:01:51 +01:00
if ( state === PackageState . Running ) return 'running'
if ( state === PackageState . Exited || state === PackageState . Stopped ) {
2026-05-14 01:15:22 -04:00
if ( exitCode === 137 ) return 'killed (SIGKILL)'
fix: overhaul container lifecycle — recovery, health, uninstall, UI state
Container recovery:
- Health monitor: MAX_RESTART_ATTEMPTS 3→10, interval 60s→120s
- Dependency-aware restarts: won't restart services before their deps
- Reset dependent counters when a dependency recovers
- Handle "created" state containers (were invisible to health monitor)
- Added IndeedHub, mempool-api, mysql to tier system
- Crash recovery: podman start timeout 30s→120s with retry
- Podman client: socket timeout 5s→30s, added restart policy
UI state representation:
- Exit code 0 shows "stopped" (gray), not "crashed" (red)
- Exit code 137 shows "killed (OOM)"
- Non-zero exit shows "crashed" (red)
- Added exit_code field to PackageDataEntry
Install/uninstall fixes:
- Install returns error when container doesn't start (was silent success)
- Post-install hooks awaited instead of fire-and-forget tokio::spawn
- Uninstall: graceful rm before force, volume prune, network cleanup
- Uninstall returns error on partial failure (was 200 OK)
Config consistency:
- DB passwords read from /var/lib/archipelago/secrets/ (was hardcoded)
- Bitcoin: added ZMQ ports 28332/28333 for LND block notifications
- IndeedHub port 7777→8190 (was conflicting with strfry)
- Marketplace versions: LND 0.17.4→0.18.4, Mempool 2.5.0→3.0.0
Performance:
- Metrics collector interval 60s→300s (was duplicating health monitor)
- Podman client: proper error propagation instead of unwrap_or_default
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 07:03:57 +01:00
if ( exitCode != null && exitCode !== 0 ) return 'crashed'
return 'stopped'
}
2026-03-22 03:30:21 +00:00
return state
}
export function buildAllCategories ( t : ( key : string ) = > string ) {
return [
{ id : 'all' , name : t ( 'marketplace.all' ) } ,
{ id : 'community' , name : t ( 'marketplace.community' ) } ,
{ id : 'nostr' , name : 'Nostr' } ,
{ id : 'commerce' , name : t ( 'marketplace.commerce' ) } ,
{ id : 'money' , name : t ( 'marketplace.money' ) } ,
{ id : 'data' , name : t ( 'marketplace.data' ) } ,
{ id : 'media' , name : 'Media' } ,
{ id : 'home' , name : t ( 'marketplace.homeCategory' ) } ,
{ id : 'networking' , name : t ( 'marketplace.networking' ) } ,
{ id : 'l484' , name : 'L484' } ,
{ id : 'other' , name : t ( 'marketplace.other' ) } ,
]
}
export function useCategoriesWithApps (
packages : Ref < Record < string , PackageDataEntry > > ,
allCategories : Ref < Array < { id : string ; name : string } > > ,
) {
return computed ( ( ) = > {
2026-05-17 17:30:04 -04:00
const entries = Object . entries ( packages . value ) . filter ( ( [ id , pkg ] ) = > ! isWebsitePackage ( id , pkg ) && ! isInternalToolingPackage ( id , pkg ) )
2026-03-22 03:30:21 +00:00
return allCategories . value . filter ( cat = > {
if ( cat . id === 'all' ) return true
return entries . some ( ( [ id , pkg ] ) = > getAppCategory ( id , pkg ) === cat . id )
} )
} )
}
2026-06-23 03:48:57 -04:00
// Services-tab equivalent of useCategoriesWithApps: only show a service category
// when at least one installed service belongs to it (#12).
export function useServiceCategories (
packages : Ref < Record < string , PackageDataEntry > > ,
serviceCategories : Ref < Array < { id : string ; name : string } > > ,
) {
return computed ( ( ) = > {
const entries = Object . entries ( packages . value ) . filter ( ( [ id , pkg ] ) = > isWebsitePackage ( id , pkg ) && ! isInternalToolingPackage ( id , pkg ) )
return serviceCategories . value . filter ( cat = > {
if ( cat . id === 'all' ) return true
return entries . some ( ( [ id , pkg ] ) = > getServiceCategory ( id , pkg ) === cat . id )
} )
} )
}
2026-03-22 03:30:21 +00:00
export function handleImageError ( e : Event ) {
const target = e . target as HTMLImageElement
const currentSrc = target . src
2026-04-28 15:00:58 -04:00
if ( target . dataset . fallbackTried !== "1" && currentSrc . endsWith ( ".png" ) ) {
target . dataset . fallbackTried = "1"
target . src = currentSrc . replace ( /\.png($|\?)/ , ".svg$1" )
return
}
2026-06-11 00:24:40 -04:00
if ( ! currentSrc . includes ( DEFAULT_APP_ICON ) ) {
target . src = DEFAULT_APP_ICON
target . dataset . defaultIcon = "1"
2026-03-22 03:30:21 +00:00
}
}