/** 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', 'penpot-postgres', 'penpot-valkey', 'penpot-backend', 'penpot-exporter', 'mysql-mempool', 'mempool-api', 'archy-mempool-web', 'archy-bitcoin-ui', 'archy-lnd-ui', 'archy-electrs-ui', 'indeedhub-postgres', 'indeedhub-redis', 'indeedhub-minio', 'archy-nostr-vpn-ui', 'archy-fips-ui', '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 'botfights', '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 } // 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', 'nostr-rs-relay': 'nostr', 'nostrudel': 'nostr', 'nostr-vpn': 'networking', 'fips': 'networking', 'routstr': 'community', 'tailscale': 'networking', 'nginx-proxy-manager': 'networking', 'portainer': 'networking', 'uptime-kuma': 'networking', 'dwn': 'data', 'botfights': 'l484', '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' } // Web-only app IDs and their URLs export const WEB_ONLY_APP_URLS: Record = { 'botfights': 'https://botfights.net', '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 = { 'botfights': { state: 'running' as PackageState, manifest: { id: 'botfights', title: 'BotFights', version: '1.0.0', description: { short: 'AI bot arena — build, train, and battle autonomous agents', long: '' }, 'release-notes': '', license: '', 'wrapper-repo': '', 'upstream-repo': '', 'support-site': '', 'marketing-site': '', 'donation-url': null }, 'static-files': { license: '', instructions: '', icon: '/assets/img/app-icons/botfights.svg' }, }, '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', 'cryptpad', 'nginx-proxy-manager', 'tailscale', 'routstr', ]) export function opensInTab(id: string): boolean { return TAB_LAUNCH_APPS.has(id) } 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 const canLaunchState = pkg.state === 'running' || pkg.state === 'starting' return !!hasUI && canLaunchState } 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]) => !isServiceContainer(id)) 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 const placeholderSvg = `data:image/svg+xml,${encodeURIComponent(` `)}` if (!currentSrc.includes('data:image')) { target.src = placeholderSvg } }