archy/neode-ui/src/views/apps/appsConfig.ts
archipelago a7c7c44843 feat(neode-ui): mobile app-launch UX — store-driven panel, loader, ElectrumX icon
- Mobile launches use the store-driven panel (no route push) so the background
  tab no longer changes and closing returns to where you launched from.
- Tab-only apps open directly (in-app WebView on companion / new tab on PWA) —
  no "this app opens in a tab" interstitial.
- Shared AppLoadingScreen (app icon + progress bar) on the app session and the
  legacy iframe overlay instead of a black screen.
- Pin the dashboard to 100dvh on mobile so the mesh chat/tools panes stop sliding
  under the bottom tab bar in mobile browsers (no-op in the companion WebView).
- ElectrumX/electrs/electrs-ui ids now resolve to the real ElectrumX icon in My Apps.
- isMobile made reactive so overlay/footer/teleport decisions track the viewport.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 03:48:57 -04:00

424 lines
20 KiB
TypeScript

/** Static configuration for the Apps view */
import type { Ref } from 'vue'
import { computed } from 'vue'
import { PackageState, type PackageDataEntry } from '@/types/api'
import { resolveAppUrl } from '../appSession/appSessionConfig'
export type AppsTab = 'apps' | 'websites' | 'services'
// 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',
// 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',
'immich_postgres', 'immich_redis',
// 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',
'mysql-mempool', 'mempool-api', 'archy-mempool-web',
'archy-bitcoin-ui', 'archy-lnd-ui', 'archy-electrs-ui',
'bitcoin-ui', 'lnd-ui', 'electrs-ui',
'indeedhub-postgres', 'indeedhub-redis', 'indeedhub-minio',
'indeedhub-api', 'indeedhub-ffmpeg',
'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',
// L484 web-only apps — parked in Services for now
'nwnn', '484-kitchen', 'call-the-operator',
'syntropy-institute', 't-zero', 'arch-presentation',
])
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')
}
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
// 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
return false
}
export function isServicePackage(id: string, pkg?: PackageDataEntry): boolean {
if (isServiceContainer(id)) return true
const manifestId = pkg?.manifest?.id
return !!manifestId && isServiceContainer(manifestId)
}
// Known app -> category mappings (matches App Store categorisation)
export const APP_CATEGORY_MAP: Record<string, string> = {
'bitcoin-core': 'money', 'bitcoin-knots': 'money', 'bitcoin-ui': 'money', 'electrumx': 'money', 'electrs': 'money',
'lnd': 'money', 'mempool': 'money', 'mempool-web': 'money', 'btcpay-server': 'commerce',
'fedimint': 'money', 'fedimint-gateway': 'money',
'indeedhub': 'media', 'jellyfin': 'media', 'photoprism': 'media', 'immich': 'media',
'nextcloud': 'data', 'vaultwarden': 'data', 'filebrowser': 'data', 'cryptpad': 'data',
'homeassistant': 'home', 'lorabell': 'home', 'endurain': 'home',
'searxng': 'community', 'ollama': 'community', 'grafana': 'data', 'gitea': 'data',
'nostrudel': 'nostr',
'tailscale': 'networking', 'netbird': 'networking', 'nginx-proxy-manager': 'networking', 'portainer': 'networking',
'uptime-kuma': 'networking',
'botfights': 'community', 'nwnn': 'l484', '484-kitchen': 'l484',
'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'
}
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))
}
// True when the package's manifest declares a front-end UI interface. This is
// 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.
export function hasFrontendUi(pkg?: PackageDataEntry): boolean {
return !!pkg?.manifest?.interfaces?.main?.ui
}
export function isWebsitePackage(id: string, pkg?: PackageDataEntry): boolean {
if (isInternalToolingPackage(id, pkg)) return false
// Headless infra (databases/backends/companions) keyed by container name are
// services regardless of any stray UI string.
if (isServicePackage(id, pkg)) return true
// 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.
if (isKnownApp(id, pkg)) return false
// 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
}
export function filterEntriesForTab(
entries: Array<[string, PackageDataEntry]>,
activeTab: AppsTab,
selectedCategory: string,
): Array<[string, PackageDataEntry]> {
return entries.filter(([id, pkg]) => {
if (isInternalToolingPackage(id, pkg)) return false
const wantsWebsites = activeTab === 'websites' || activeTab === 'services'
const isWebsite = isWebsitePackage(id, pkg)
if (wantsWebsites ? !isWebsite : isWebsite) return false
if (activeTab === 'apps' && selectedCategory !== 'all') {
return getAppCategory(id, pkg) === selectedCategory
}
if (activeTab === 'services' && selectedCategory !== 'all') {
return getServiceCategory(id, pkg) === selectedCategory
}
return true
})
}
// 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' },
]
}
// 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',
'arch-presentation': 'https://present.l484.com',
'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' },
},
'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' },
},
'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',
'vaultwarden', 'nextcloud', 'uptime-kuma', 'portainer', 'gitea',
'cryptpad', 'nginx-proxy-manager', 'tailscale',
// netbird's dashboard needs HTTPS (secure context) so it opens in a new tab
'netbird',
])
export function opensInTab(id: string): boolean {
return TAB_LAUNCH_APPS.has(id)
}
// 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).
const APP_ICON_FALLBACKS: Record<string, string> = {
gitea: '/assets/img/app-icons/gitea.svg',
'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
}
export const DEFAULT_APP_ICON = '/assets/icon/favico-black-v2.svg'
export function resolveAppIcon(id: string, pkg: PackageDataEntry, curatedIcon?: string): string {
const rawIcon = (pkg["static-files"]?.icon || "").trim()
const icon = rawIcon === '/assets/img/favico.png' ? '' : rawIcon
if (
icon.startsWith("/") ||
icon.startsWith("http://") ||
icon.startsWith("https://") ||
icon.startsWith("data:image")
) {
return icon
}
return (
curatedIcon ||
APP_ICON_FALLBACKS[id] ||
serviceParentIcon(id) ||
`/assets/img/app-icons/${id}.png`
)
}
export function canLaunch(pkg: PackageDataEntry): boolean {
if (isWebOnlyApp(pkg.manifest.id)) return true
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
if ((pkg.manifest.id === 'fedimint' || pkg.manifest.id === 'fedimintd') && hasUI) {
return pkg.state === PackageState.Running || pkg.state === PackageState.Starting
}
// 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
}
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 ''
}
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}`)
}
export function getStatusClass(state: PackageState, health?: string | null, exitCode?: number | null): string {
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:
// 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'
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'
case PackageState.Updating:
return 'bg-orange-500/20 text-orange-200'
default:
return 'bg-gray-500/20 text-gray-200'
}
}
export function getStatusLabel(state: PackageState, health?: string | null, exitCode?: number | null): string {
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'
if (state === PackageState.Updating) return 'updating...'
if (state === PackageState.Running) return 'running'
if (state === PackageState.Exited || state === PackageState.Stopped) {
if (exitCode === 137) return 'killed (SIGKILL)'
if (exitCode != null && exitCode !== 0) return 'crashed'
return 'stopped'
}
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(() => {
const entries = Object.entries(packages.value).filter(([id, pkg]) => !isWebsitePackage(id, pkg) && !isInternalToolingPackage(id, pkg))
return allCategories.value.filter(cat => {
if (cat.id === 'all') return true
return entries.some(([id, pkg]) => getAppCategory(id, pkg) === cat.id)
})
})
}
// 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)
})
})
}
export function handleImageError(e: Event) {
const target = e.target as HTMLImageElement
const currentSrc = target.src
if (target.dataset.fallbackTried !== "1" && currentSrc.endsWith(".png")) {
target.dataset.fallbackTried = "1"
target.src = currentSrc.replace(/\.png($|\?)/, ".svg$1")
return
}
if (!currentSrc.includes(DEFAULT_APP_ICON)) {
target.src = DEFAULT_APP_ICON
target.dataset.defaultIcon = "1"
}
}