/** Static configuration for the Apps view */ import type { Ref } from 'vue' import { computed } from 'vue' import { PackageState, type PackageDataEntry } from '@/types/api' // 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', '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', ]) 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 if (id.endsWith('_db') || 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 = { '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', 'nostrudel': 'nostr', 'tailscale': 'networking', 'nginx-proxy-manager': 'networking', 'portainer': 'networking', 'uptime-kuma': 'networking', 'dwn': 'data', '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)?.category as string | undefined return cat || 'other' } export function filterEntriesForTab( entries: Array<[string, PackageDataEntry]>, activeTab: 'apps' | 'services', selectedCategory: string, ): Array<[string, PackageDataEntry]> { return entries.filter(([id, pkg]) => { const isSvc = isServicePackage(id, pkg) if (activeTab === 'services' ? !isSvc : isSvc) return false if (activeTab === 'apps' && selectedCategory !== 'all') { return getAppCategory(id, pkg) === selectedCategory } return true }) } // Web-only app IDs and their URLs export const WEB_ONLY_APP_URLS: Record = { 'nwnn': 'https://nwnn.l484.com', '484-kitchen': 'https://484.kitchen', 'call-the-operator': 'https://cta.tx1138.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 = { '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' }, }, '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', ]) export function opensInTab(id: string): boolean { return TAB_LAUNCH_APPS.has(id) } const APP_ICON_FALLBACKS: Record = { gitea: '/assets/img/app-icons/gitea.svg', } export function resolveAppIcon(id: string, pkg: PackageDataEntry, curatedIcon?: string): string { const icon = (pkg["static-files"]?.icon || "").trim() if ( icon.startsWith("/") || icon.startsWith("http://") || icon.startsWith("https://") || icon.startsWith("data:image") ) { return icon } return curatedIcon || APP_ICON_FALLBACKS[id] || `/assets/img/app-icons/${id}.png` } export function canLaunch(pkg: PackageDataEntry): boolean { if (isWebOnlyApp(pkg.manifest.id)) return true const hasUI = pkg.manifest.interfaces?.main?.ui || pkg.installed?.['interface-addresses']?.main return !!hasUI && pkg.state === 'running' && pkg.health !== 'starting' && pkg.health !== 'unhealthy' } 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 (OOM)' 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>, allCategories: Ref>, ) { return computed(() => { const entries = Object.entries(packages.value).filter(([id, pkg]) => !isServicePackage(id, pkg)) return allCategories.value.filter(cat => { if (cat.id === 'all') return true return entries.some(([id, pkg]) => getAppCategory(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 } const placeholderSvg = `data:image/svg+xml,${encodeURIComponent(` `)}` if (!currentSrc.includes("data:image")) { target.src = placeholderSvg } }