244 lines
11 KiB
TypeScript
244 lines
11 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'
|
|
|
|
// 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<string, string> = {
|
|
'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<string, unknown>)?.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<string, string> = {
|
|
'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<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' },
|
|
},
|
|
'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<string, string> = {
|
|
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 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
|
|
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<Record<string, PackageDataEntry>>,
|
|
allCategories: Ref<Array<{ id: string; name: string }>>,
|
|
) {
|
|
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(`
|
|
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
<rect width="64" height="64" rx="12" fill="rgba(255,255,255,0.1)"/>
|
|
<path d="M32 20L40 28H36V40H28V28H24L32 20Z" fill="rgba(255,255,255,0.6)"/>
|
|
<path d="M20 44H44V48H20V44Z" fill="rgba(255,255,255,0.4)"/>
|
|
</svg>
|
|
`)}`
|
|
if (!currentSrc.includes("data:image")) {
|
|
target.src = placeholderSvg
|
|
}
|
|
}
|