refactor: update dependencies and remove unused code

- Added new dependencies: `adler2`, `crc32fast`, `flate2`, `miniz_oxide`, and `libredox`.
- Updated existing dependencies: `tokio-rustls` to version 0.26.4 and `filetime` to version 0.2.27.
- Removed the `backup.rs` file as it is no longer needed.
- Introduced tests for configuration and credential management.
- Enhanced the `identity` module to generate W3C compliant DID documents.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-12 00:19:30 +00:00
parent fd2a837bea
commit f07ce10b1a
347 changed files with 18703 additions and 46785 deletions

View File

@@ -784,7 +784,14 @@ function launchApp() {
'uptime-kuma': { dev: 'http://localhost:3001', prod: 'http://localhost:3001' },
'tailscale': { dev: 'http://localhost:8240', prod: 'http://localhost:8240' },
'lnd': { dev: 'http://localhost:8081', prod: 'http://localhost:8081' },
'bitcoin-knots': { dev: 'http://localhost:8334', prod: 'http://localhost:8334' }
'bitcoin-knots': { dev: 'http://localhost:8334', prod: 'http://localhost:8334' },
'botfights': { dev: 'https://botfights.net', prod: 'https://botfights.net' },
'nwnn': { dev: 'https://nwnn.l484.com', prod: 'https://nwnn.l484.com' },
'484-kitchen': { dev: 'https://484.kitchen', prod: 'https://484.kitchen' },
'call-the-operator': { dev: 'https://cta.tx1138.com', prod: 'https://cta.tx1138.com' },
'arch-presentation': { dev: 'https://present.l484.com', prod: 'https://present.l484.com' },
'syntropy-institute': { dev: 'https://syntropy.institute', prod: 'https://syntropy.institute' },
't-zero': { dev: 'https://teeminuszero.net', prod: 'https://teeminuszero.net' }
}
if (appUrls[id]) {

View File

@@ -69,8 +69,9 @@
@click="goToApp(id as string)"
@keydown.enter="goToApp(id as string)"
>
<!-- Uninstall Icon -->
<!-- Uninstall Icon (not for web-only apps) -->
<button
v-if="!isWebOnlyApp(id as string)"
@click.stop="showUninstallModal(id as string, pkg)"
class="absolute top-4 right-4 p-2 rounded-lg text-white/60 hover:text-red-400 hover:bg-red-500/20 transition-colors z-10"
:aria-label="`${t('common.uninstall')} ${pkg.manifest?.title || id}`"
@@ -120,7 +121,7 @@
{{ t('common.launch') }}
</button>
<button
v-if="pkg.state === 'stopped' || pkg.state === 'exited'"
v-if="!isWebOnlyApp(id as string) && (pkg.state === 'stopped' || pkg.state === 'exited')"
@click.stop="startApp(id as string)"
:disabled="loadingActions[id as string]"
class="flex-1 px-4 py-2 bg-green-500/20 border border-green-500/40 rounded-lg text-green-200 text-sm font-medium hover:bg-green-500/30 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
@@ -139,7 +140,7 @@
<span>{{ loadingActions[id as string] ? t('common.starting') : t('common.start') }}</span>
</button>
<button
v-if="pkg.state === 'running' || pkg.state === 'starting'"
v-if="!isWebOnlyApp(id as string) && (pkg.state === 'running' || pkg.state === 'starting')"
@click.stop="stopApp(id as string)"
:disabled="loadingActions[id as string]"
class="flex-1 px-4 py-2 bg-yellow-500/20 border border-yellow-500/40 rounded-lg text-yellow-200 text-sm font-medium hover:bg-yellow-500/30 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
@@ -251,19 +252,81 @@ function showActionError(msg: string) {
errorTimer = setTimeout(() => { actionError.value = '' }, 5000)
}
// Use real packages from store - no more dummy apps
// Web-only app IDs and their URLs
const WEB_ONLY_APP_URLS: Record<string, string> = {
'indeedhub': 'https://archipelago.indeehub.studio',
'botfights': 'https://botfights.net',
'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',
}
function isWebOnlyApp(id: string): boolean {
return id in WEB_ONLY_APP_URLS
}
// Web-only apps (no container) — always show as installed bookmarks
const WEB_ONLY_APPS: Record<string, PackageDataEntry> = {
'indeedhub': {
state: 'running' as PackageState,
manifest: { id: 'indeedhub', title: 'Indeehub', version: '0.1.0', description: { short: 'Bitcoin documentary streaming 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/indeedhub.png' },
},
'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' },
},
'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' },
},
}
// Merge real packages from store with web-only app bookmarks
const packages = computed(() => {
const realPackages = store.packages
if (import.meta.env.DEV) console.log('[Apps] Real packages from store:', Object.keys(realPackages || {}).length, 'apps')
return realPackages || {}
const realPackages = store.packages || {}
return { ...WEB_ONLY_APPS, ...realPackages }
})
// Sorted by manifest title, case-insensitive; order stable regardless of running/stopped
// Web-only apps first (alphabetically), then all other apps (alphabetically)
const sortedPackageEntries = computed(() => {
const entries = Object.entries(packages.value)
return entries.sort(([, a], [, b]) =>
(a.manifest?.title ?? '').localeCompare(b.manifest?.title ?? '', undefined, { sensitivity: 'base' })
)
return entries.sort(([idA, a], [idB, b]) => {
const aWeb = isWebOnlyApp(idA) ? 0 : 1
const bWeb = isWebOnlyApp(idB) ? 0 : 1
if (aWeb !== bWeb) return aWeb - bWeb
return (a.manifest?.title ?? '').localeCompare(b.manifest?.title ?? '', undefined, { sensitivity: 'base' })
})
})
const filteredPackageEntries = computed(() => {
@@ -295,10 +358,10 @@ useModalKeyboard(
)
function canLaunch(pkg: PackageDataEntry): boolean {
// For dummy apps, allow launch if running (they have interface addresses)
// Web-only apps are always launchable
if (isWebOnlyApp(pkg.manifest.id)) return true
// For real apps, check for UI interface
const hasUI = pkg.manifest.interfaces?.main?.ui || pkg.installed?.['interface-addresses']?.main
// Allow launch when running or starting (so buttons show even while backend reports "starting")
const canLaunchState = pkg.state === 'running' || pkg.state === 'starting'
return !!hasUI && canLaunchState
}
@@ -306,7 +369,14 @@ function canLaunch(pkg: PackageDataEntry): boolean {
function launchApp(id: string) {
const isDev = import.meta.env.DEV
const pkg = packages.value[id]
// Web-only apps — use their external URL directly
const webOnlyUrl = WEB_ONLY_APP_URLS[id]
if (webOnlyUrl) {
useAppLauncherStore().open({ url: webOnlyUrl, title: pkg?.manifest?.title || id })
return
}
// Explicit URLs for apps that need them (checked first to avoid package data issues)
const appUrls: Record<string, { dev: string, prod: string }> = {
'lorabell': {
@@ -321,10 +391,6 @@ function launchApp(id: string) {
dev: 'http://localhost:8103',
prod: 'http://localhost:8103' // Self-hosted splash screen
},
'indeedhub': {
dev: 'https://archipelago.indeehub.studio',
prod: 'https://archipelago.indeehub.studio'
}
}
if (appUrls[id]) {

View File

@@ -22,12 +22,12 @@
</svg>
</button>
<!-- Loading indicator while iframe loads -->
<!-- Loading indicator while checking availability or iframe loads -->
<Transition name="fade">
<div v-if="aiuiUrl && !aiuiConnected" class="chat-loading" role="status" aria-live="polite">
<div v-if="aiuiAvailable === null || (aiuiUrl && !aiuiConnected)" class="chat-loading" role="status" aria-live="polite">
<div class="glass-card p-8 flex flex-col items-center gap-4">
<div class="chat-loading-spinner" aria-hidden="true" />
<p class="text-sm text-white/60">{{ t('chat.loadingAssistant') }}</p>
<p class="text-sm text-white/60">{{ aiuiAvailable === null ? t('chat.loadingAssistant') : t('chat.loadingAssistant') }}</p>
</div>
</div>
</Transition>
@@ -78,13 +78,35 @@ const aiuiFrame = ref<HTMLIFrameElement | null>(null)
const aiuiConnected = ref(false)
let broker: ContextBroker | null = null
const aiuiAvailable = ref<boolean | null>(null) // null = checking, true/false = result
const aiuiUrl = computed(() => {
const envUrl = import.meta.env.VITE_AIUI_URL
if (envUrl) return `${envUrl}?embedded=true`
if (import.meta.env.PROD) return '/aiui/?embedded=true'
// In production, only return the URL if we've confirmed AIUI files exist
if (import.meta.env.PROD && aiuiAvailable.value === true) return '/aiui/?embedded=true'
return ''
})
/** Check if AIUI is actually deployed by fetching its index.html */
async function checkAiuiAvailable() {
if (import.meta.env.VITE_AIUI_URL) {
aiuiAvailable.value = true
return
}
if (!import.meta.env.PROD) {
aiuiAvailable.value = false
return
}
try {
const res = await fetch('/aiui/', { method: 'HEAD' })
// If we get HTML back (200), AIUI is deployed. If 404/403, it's not.
aiuiAvailable.value = res.ok
} catch {
aiuiAvailable.value = false
}
}
function closeChat() {
if (window.history.length > 1) {
router.back()
@@ -106,8 +128,9 @@ function onAiuiMessage(event: MessageEvent) {
}
}
onMounted(() => {
onMounted(async () => {
window.addEventListener('message', onAiuiMessage)
await checkAiuiAvailable()
if (aiuiUrl.value) {
broker = new ContextBroker(aiuiFrame, aiuiUrl.value)
broker.start()

View File

@@ -110,7 +110,7 @@
<svg class="w-5 h-5" aria-hidden="true" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path v-for="(path, index) in getIconPath('chat')" :key="index" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" :d="path" />
</svg>
<span>Chat</span>
<span>AIUI</span>
</button>
<!-- Logout - styled as nav item, below Settings -->
@@ -336,7 +336,7 @@
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path v-for="(path, index) in getIconPath('chat')" :key="index" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" :d="path" />
</svg>
<span class="text-[10px] leading-tight">Chat</span>
<span class="text-[10px] leading-tight">AIUI</span>
</button>
</div>
</nav>

View File

@@ -0,0 +1,284 @@
<template>
<div class="kiosk-root" tabindex="0" ref="kioskRoot">
<!-- Kiosk launcher grid -->
<div class="kiosk-launcher">
<!-- Header -->
<div class="kiosk-header">
<div class="flex items-center gap-4">
<img :src="FALLBACK_ICON" alt="Archipelago" class="w-10 h-10" />
<div>
<h1 class="text-2xl font-bold text-white font-archipelago">Archipelago</h1>
<p class="text-sm text-white/50">{{ currentTime }}</p>
</div>
</div>
<div class="flex items-center gap-3">
<div class="kiosk-status-pill" :class="isConnected ? 'bg-green-500/20 text-green-400' : 'bg-red-500/20 text-red-400'">
<div class="w-2 h-2 rounded-full" :class="isConnected ? 'bg-green-400' : 'bg-red-400'"></div>
{{ isConnected ? t('kiosk.online') : t('kiosk.offline') }}
</div>
</div>
</div>
<!-- App grid -->
<div class="kiosk-grid">
<button
v-for="app in launchableApps"
:key="app.id"
class="kiosk-app-tile"
@click="openApp(app)"
:data-controller-focusable="true"
>
<div class="kiosk-app-icon-wrap">
<img
:src="app.icon"
:alt="app.title"
class="kiosk-app-icon"
@error="($event.target as HTMLImageElement).src = FALLBACK_ICON"
/>
<div
class="kiosk-app-status"
:class="app.running ? 'bg-green-400' : 'bg-white/30'"
/>
</div>
<span class="kiosk-app-label">{{ app.title }}</span>
</button>
</div>
<!-- Footer -->
<div class="kiosk-footer">
<span class="text-white/30 text-sm">{{ t('kiosk.navHint') }}</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app'
import { useAppLauncherStore } from '@/stores/appLauncher'
const { t } = useI18n()
const store = useAppStore()
const appLauncher = useAppLauncherStore()
const kioskRoot = ref<HTMLElement | null>(null)
interface KioskApp {
id: string
title: string
icon: string
url: string
running: boolean
}
// Public asset path — construct with BASE_URL to avoid Vite resolving it as a module import
const FALLBACK_ICON = `${import.meta.env.BASE_URL}assets/img/favico.png`
const currentTime = ref('')
const isConnected = computed(() => store.isConnected)
// Build list of launchable apps from the store's package data
const launchableApps = computed<KioskApp[]>(() => {
const pkgs = store.data?.['package-data'] || {}
const apps: KioskApp[] = []
// App URL mappings — use nginx proxy paths for local apps
const urlMap: Record<string, string> = {
'bitcoin-knots': '/app/bitcoin-ui/',
'lnd': '/app/lnd/',
'mempool': '/app/mempool/',
'btcpay-server': '/app/btcpay/',
'homeassistant': '/app/homeassistant/',
'grafana': '/app/grafana/',
'jellyfin': '/app/jellyfin/',
'nextcloud': '/app/nextcloud/',
'immich': '/app/immich/',
'photoprism': '/app/photoprism/',
'vaultwarden': '/app/vaultwarden/',
'filebrowser': '/app/filebrowser/',
'searxng': '/app/searxng/',
'ollama': '/app/ollama/',
'penpot': '/app/penpot/',
'onlyoffice': '/app/onlyoffice/',
'portainer': '/app/portainer/',
'uptime-kuma': '/app/uptime-kuma/',
'nginx-proxy-manager': '/app/nginx-proxy-manager/',
'tailscale': '/app/tailscale/',
'fedimint': '/app/fedimint/',
'fedimint-gateway': '/app/fedimint-gateway/',
'dwn': '/app/dwn/',
'nostr-rs-relay': '/app/nostr-rs-relay/',
'indeedhub': 'https://archipelago.indeehub.studio',
'botfights': 'https://botfights.net',
'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',
}
for (const [id, pkg] of Object.entries(pkgs)) {
const url = urlMap[id]
if (!url) continue
const isRunning = pkg.state === 'running' ||
pkg.installed?.status === 'running'
apps.push({
id,
title: pkg.manifest?.title || id,
icon: pkg['static-files']?.icon || FALLBACK_ICON,
url,
running: isRunning,
})
}
// Sort: running apps first, then alphabetical
return apps.sort((a, b) => {
if (a.running !== b.running) return a.running ? -1 : 1
return a.title.localeCompare(b.title)
})
})
function openApp(app: KioskApp) {
// Delegate to the app launcher — handles iframe overlay vs new-tab
appLauncher.open({ url: app.url, title: app.title })
}
// Clock updater
let clockInterval: ReturnType<typeof setInterval> | undefined
function updateClock() {
const now = new Date()
currentTime.value = now.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
}
onMounted(() => {
updateClock()
clockInterval = setInterval(updateClock, 30000)
kioskRoot.value?.focus()
// Connect WebSocket if not already
if (!store.isConnected) {
store.connectWebSocket().catch(() => {})
}
})
onUnmounted(() => {
if (clockInterval) clearInterval(clockInterval)
})
</script>
<style scoped>
.kiosk-root {
position: fixed;
inset: 0;
background: #000;
outline: none;
overflow: hidden;
z-index: 9999;
}
.kiosk-launcher {
height: 100vh;
display: flex;
flex-direction: column;
padding: 2rem 3rem;
background: linear-gradient(180deg, #0a0a12 0%, #000 100%);
}
.kiosk-header {
display: flex;
justify-content: space-between;
align-items: center;
padding-bottom: 2rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
margin-bottom: 2rem;
}
.kiosk-status-pill {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.375rem 0.75rem;
border-radius: 9999px;
font-size: 0.875rem;
font-weight: 500;
}
.kiosk-grid {
flex: 1;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 1.5rem;
align-content: start;
overflow-y: auto;
padding: 0.5rem;
}
.kiosk-app-tile {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.75rem;
padding: 1.25rem 0.75rem;
border-radius: 1rem;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.06);
transition: all 0.25s ease;
cursor: pointer;
}
.kiosk-app-tile:hover,
.kiosk-app-tile:focus-visible {
background: rgba(255, 255, 255, 0.1);
border-color: rgba(251, 146, 60, 0.4);
transform: scale(1.05);
box-shadow: 0 0 30px rgba(251, 146, 60, 0.15);
outline: none;
}
.kiosk-app-icon-wrap {
position: relative;
width: 64px;
height: 64px;
}
.kiosk-app-icon {
width: 64px;
height: 64px;
border-radius: 16px;
object-fit: cover;
background: rgba(255, 255, 255, 0.05);
}
.kiosk-app-status {
position: absolute;
bottom: -2px;
right: -2px;
width: 14px;
height: 14px;
border-radius: 50%;
border: 3px solid #000;
}
.kiosk-app-label {
font-size: 0.8125rem;
font-weight: 500;
color: rgba(255, 255, 255, 0.85);
text-align: center;
line-height: 1.2;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.kiosk-footer {
padding-top: 1.5rem;
text-align: center;
border-top: 1px solid rgba(255, 255, 255, 0.06);
margin-top: 1.5rem;
}
</style>

View File

@@ -397,6 +397,7 @@ const categories = computed(() => [
{ id: 'home', name: t('marketplace.homeCategory') },
{ id: 'car', name: t('marketplace.auto') },
{ id: 'networking', name: t('marketplace.networking') },
{ id: 'l484', name: 'L484' },
{ id: 'other', name: t('marketplace.other') }
])
@@ -522,11 +523,14 @@ const installedPackages = computed(() => {
// Function to categorize community apps based on their ID and description
function categorizeCommunityApp(app: MarketplaceApp): string {
// If app already has a category set, use it
if (app.category) return app.category
const id = app.id.toLowerCase()
const title = app.title?.toLowerCase() || ''
const description = (typeof app.description === 'string' ? app.description : app.description?.short ?? '').toLowerCase()
const combined = `${id} ${title} ${description}`
// Money category
if (id.includes('bitcoin') || id.includes('btc') || id.includes('lightning') ||
id.includes('lnd') || id.includes('cln') || id.includes('electr') ||
@@ -949,6 +953,96 @@ function getCuratedAppList() {
dockerImage: 'docker.io/scsiblade/nostr-rs-relay:0.9.0',
manifestUrl: undefined,
repoUrl: 'https://sr.ht/~gheartsfield/nostr-rs-relay/'
},
{
id: 'botfights',
title: 'BotFights',
version: '1.0.0',
description: 'AI bot arena — build, train, and battle autonomous agents. Compete in strategy tournaments with your own coded bots.',
icon: '/assets/img/app-icons/botfights.svg',
author: 'BotFights',
dockerImage: '',
manifestUrl: undefined,
repoUrl: 'https://botfights.net',
webUrl: 'https://botfights.net'
},
{
id: 'nwnn',
title: 'Next Web News Network',
version: '1.0.0',
category: 'l484',
description: 'Decentralized news and link aggregator, synchronized from Telegram. Community-curated content on Bitcoin, sovereignty, and decentralized tech.',
icon: '/assets/img/app-icons/nwnn.png',
author: 'L484',
dockerImage: '',
manifestUrl: undefined,
repoUrl: 'https://nwnn.l484.com',
webUrl: 'https://nwnn.l484.com'
},
{
id: '484-kitchen',
title: '484 Kitchen',
version: '1.0.0',
category: 'l484',
description: 'K484 application platform — an internal tool for the L484 network.',
icon: '/assets/img/app-icons/484-kitchen.png',
author: 'L484',
dockerImage: '',
manifestUrl: undefined,
repoUrl: 'https://484.kitchen',
webUrl: 'https://484.kitchen'
},
{
id: 'call-the-operator',
title: 'Call the Operator',
version: '1.0.0',
category: 'l484',
description: 'Escape the Matrix — a portal for exploring decentralized alternatives and reclaiming digital sovereignty.',
icon: '/assets/img/app-icons/call-the-operator.png',
author: 'TX1138',
dockerImage: '',
manifestUrl: undefined,
repoUrl: 'https://cta.tx1138.com',
webUrl: 'https://cta.tx1138.com'
},
{
id: 'arch-presentation',
title: 'Arch Presentation',
version: '1.0.0',
category: 'l484',
description: 'Archipelago: The Future of Decentralized Infrastructure — an interactive presentation about the Archipelago project vision.',
icon: '/assets/img/app-icons/arch-presentation.png',
author: 'L484',
dockerImage: '',
manifestUrl: undefined,
repoUrl: 'https://present.l484.com',
webUrl: 'https://present.l484.com'
},
{
id: 'syntropy-institute',
title: 'Syntropy Institute',
version: '1.0.0',
category: 'l484',
description: 'Medicine Reimagined — Manual Kinetics, Syntropy Frequency analysis-therapy, digital homeopathy, and concierge protocols.',
icon: '/assets/img/app-icons/syntropy-institute.png',
author: 'Syntropy Institute',
dockerImage: '',
manifestUrl: undefined,
repoUrl: 'https://syntropy.institute',
webUrl: 'https://syntropy.institute'
},
{
id: 't-zero',
title: 'T-0',
version: '1.0.0',
category: 'l484',
description: 'Documentary series exploring decentralization, Bitcoin, and the mavericks building the ungovernable future. Conversations with the builders, powered by Nostr.',
icon: '/assets/img/app-icons/t-zero.png',
author: 'T-0',
dockerImage: '',
manifestUrl: undefined,
repoUrl: 'https://teeminuszero.net',
webUrl: 'https://teeminuszero.net'
}
]
}

View File

@@ -374,6 +374,7 @@ import { useAppStore } from '../stores/app'
import { rpcClient } from '../api/rpc-client'
import { useMarketplaceApp, type MarketplaceAppInfo } from '../composables/useMarketplaceApp'
import { useMobileBackButton } from '../composables/useMobileBackButton'
import { useAppLauncherStore } from '../stores/appLauncher'
const { t } = useI18n()
const { bottomPosition } = useMobileBackButton()
@@ -391,8 +392,14 @@ const loading = ref(true)
const appId = computed(() => route.params.id as string)
// Web-only apps (no container, just a URL) — always treated as "installed"
const isWebOnly = computed(() => {
return !!(app.value?.webUrl && !app.value?.dockerImage)
})
// Check if app is already installed
const isInstalled = computed(() => {
if (isWebOnly.value) return true
return !!store.packages[appId.value]
})
@@ -503,6 +510,14 @@ function goBack() {
}
function goToInstalledApp() {
// Web-only apps: launch directly via appLauncher
if (isWebOnly.value && app.value?.webUrl) {
useAppLauncherStore().open({
url: app.value.webUrl,
title: app.value.title || appId.value,
})
return
}
router.push({
path: `/dashboard/apps/${appId.value}`,
query: { from: 'marketplace' }

View File

@@ -0,0 +1,317 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { shallowMount, VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { defineComponent, h } from 'vue'
// Mock rpc-client before importing anything that uses it
vi.mock('@/api/rpc-client', () => ({
rpcClient: {
call: vi.fn().mockResolvedValue({ backups: [] }),
login: vi.fn(),
logout: vi.fn(),
changePassword: vi.fn(),
totpStatus: vi.fn().mockResolvedValue({ enabled: false }),
totpSetupBegin: vi.fn(),
totpSetupConfirm: vi.fn(),
totpDisable: vi.fn(),
getTorAddress: vi.fn().mockResolvedValue({ tor_address: null }),
},
}))
// Mock websocket module
vi.mock('@/api/websocket', () => ({
wsClient: {
connect: vi.fn().mockResolvedValue(undefined),
disconnect: vi.fn(),
subscribe: vi.fn(),
isConnected: vi.fn().mockReturnValue(false),
onConnectionStateChange: vi.fn(),
},
applyDataPatch: vi.fn(),
}))
// Stub the ControllerIndicator component
vi.mock('@/components/ControllerIndicator.vue', () => ({
default: defineComponent({ name: 'ControllerIndicator', render: () => h('div') }),
}))
// Mock useModalKeyboard composable
vi.mock('@/composables/useModalKeyboard', () => ({
useModalKeyboard: vi.fn(),
}))
// Stub vue-router
const pushMock = vi.fn()
vi.mock('vue-router', () => ({
useRouter: () => ({
push: pushMock,
}),
RouterLink: defineComponent({
name: 'RouterLink',
props: { to: { type: String, default: '' } },
setup(_, { slots }) {
return () => h('a', {}, slots.default?.())
},
}),
}))
// Stub global fetch for the Claude status check in onMounted
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('not available')))
import { createI18n } from 'vue-i18n'
import en from '@/locales/en.json'
import Settings from '../Settings.vue'
import { rpcClient } from '@/api/rpc-client'
import { useAppStore } from '@/stores/app'
const i18n = createI18n({ legacy: false, locale: 'en', messages: { en } })
const mockedRpc = vi.mocked(rpcClient)
function mountSettings(storeOverrides?: Partial<ReturnType<typeof useAppStore>>): VueWrapper {
const pinia = createPinia()
setActivePinia(pinia)
const store = useAppStore()
// Set default store state for tests
store.isAuthenticated = true
store.$patch({
data: {
'server-info': {
id: 'test-node',
version: '0.1.0',
name: 'Test Node',
pubkey: 'test-pubkey',
'status-info': { restarting: false, 'shutting-down': false, updated: false, 'backup-progress': null, 'update-progress': null },
'lan-address': '192.168.1.100',
'tor-address': null,
unread: 0,
'wifi-ssids': [],
'zram-enabled': false,
},
'package-data': {},
ui: { name: null, 'ack-welcome': '', marketplace: { 'selected-hosts': [], 'known-hosts': {} }, theme: 'dark' },
},
})
if (storeOverrides) {
store.$patch(storeOverrides as Record<string, unknown>)
}
return shallowMount(Settings, {
global: {
plugins: [pinia, i18n],
stubs: {
Teleport: true,
RouterLink: defineComponent({
name: 'RouterLink',
props: { to: { type: String, default: '' } },
setup(_, { slots }) {
return () => h('a', {}, slots.default?.())
},
}),
},
},
})
}
describe('Settings View', () => {
beforeEach(() => {
vi.clearAllMocks()
localStorage.clear()
mockedRpc.totpStatus.mockResolvedValue({ enabled: false })
mockedRpc.call.mockResolvedValue({ backups: [] })
mockedRpc.getTorAddress.mockResolvedValue({ tor_address: null })
pushMock.mockResolvedValue(undefined)
})
it('renders without errors', () => {
const wrapper = mountSettings()
expect(wrapper.exists()).toBe(true)
})
it('displays the Settings heading', () => {
const wrapper = mountSettings()
const heading = wrapper.find('h1')
expect(heading.exists()).toBe(true)
expect(heading.text()).toBe('Settings')
})
it('displays the Account section with server name and version', () => {
const wrapper = mountSettings()
const html = wrapper.html()
// Account section heading
const sectionHeadings = wrapper.findAll('h2')
const accountHeading = sectionHeadings.find((h) => h.text() === 'Account')
expect(accountHeading).toBeDefined()
// Server name rendered
expect(html).toContain('Test Node')
// Version rendered
expect(html).toContain('0.1.0')
})
it('displays the version from server info', () => {
const wrapper = mountSettings()
const html = wrapper.html()
expect(html).toContain('0.1.0')
expect(html).toContain('Version')
})
it('displays the Interface Mode section', () => {
const wrapper = mountSettings()
const sectionHeadings = wrapper.findAll('h2')
const modeHeading = sectionHeadings.find((h) => h.text() === 'Interface Mode')
expect(modeHeading).toBeDefined()
})
it('displays the Claude Authentication section', () => {
const wrapper = mountSettings()
const sectionHeadings = wrapper.findAll('h2')
const claudeHeading = sectionHeadings.find((h) => h.text() === 'Claude Authentication')
expect(claudeHeading).toBeDefined()
})
it('displays the AI Data Access section', () => {
const wrapper = mountSettings()
const sectionHeadings = wrapper.findAll('h2')
const aiHeading = sectionHeadings.find((h) => h.text() === 'AI Data Access')
expect(aiHeading).toBeDefined()
})
it('displays the System Updates section', () => {
const wrapper = mountSettings()
const sectionHeadings = wrapper.findAll('h2')
const updatesHeading = sectionHeadings.find((h) => h.text() === 'System Updates')
expect(updatesHeading).toBeDefined()
})
it('displays the Backup & Restore section', () => {
const wrapper = mountSettings()
const sectionHeadings = wrapper.findAll('h2')
const backupHeading = sectionHeadings.find((h) => h.text().includes('Backup'))
expect(backupHeading).toBeDefined()
})
it('displays the Network section', () => {
const wrapper = mountSettings()
const sectionHeadings = wrapper.findAll('h2')
const networkHeading = sectionHeadings.find((h) => h.text() === 'Network')
expect(networkHeading).toBeDefined()
})
it('displays a Logout button', () => {
const wrapper = mountSettings()
const buttons = wrapper.findAll('button')
const logoutButton = buttons.find((b) => b.text().includes('Logout'))
expect(logoutButton).toBeDefined()
expect(logoutButton!.exists()).toBe(true)
})
it('logout button triggers store logout and navigates to login', async () => {
const wrapper = mountSettings()
const store = useAppStore()
const logoutSpy = vi.spyOn(store, 'logout').mockResolvedValue()
const buttons = wrapper.findAll('button')
const logoutButton = buttons.find((b) => b.text().includes('Logout'))
expect(logoutButton).toBeDefined()
await logoutButton!.trigger('click')
// Allow async handlers to settle
await vi.dynamicImportSettled()
expect(logoutSpy).toHaveBeenCalled()
expect(pushMock).toHaveBeenCalledWith('/login')
})
it('displays a Change Password button', () => {
const wrapper = mountSettings()
const buttons = wrapper.findAll('button')
const changePasswordButton = buttons.find((b) => b.text().includes('Change Password'))
expect(changePasswordButton).toBeDefined()
expect(changePasswordButton!.exists()).toBe(true)
})
it('displays Two-Factor Authentication section with status', () => {
const wrapper = mountSettings()
const html = wrapper.html()
expect(html).toContain('Two-Factor Authentication')
})
it('shows Enable 2FA button when TOTP is not enabled', () => {
const wrapper = mountSettings()
const buttons = wrapper.findAll('button')
const enable2faButton = buttons.find((b) => b.text().includes('Enable 2FA'))
expect(enable2faButton).toBeDefined()
})
it('displays session status as currently logged in', () => {
const wrapper = mountSettings()
expect(wrapper.html()).toContain('Currently logged in')
})
it('shows server name from the store', () => {
const wrapper = mountSettings()
expect(wrapper.html()).toContain('Server Name')
expect(wrapper.html()).toContain('Test Node')
})
it('defaults version to 0.0.0 when server info has no version', () => {
const pinia = createPinia()
setActivePinia(pinia)
const store = useAppStore()
store.$patch({
isAuthenticated: true,
data: {
'server-info': {
id: 'test',
version: '',
name: null,
pubkey: '',
'status-info': { restarting: false, 'shutting-down': false, updated: false, 'backup-progress': null, 'update-progress': null },
'lan-address': null,
'tor-address': null,
unread: 0,
'wifi-ssids': [],
},
'package-data': {},
ui: { name: null, 'ack-welcome': '', marketplace: { 'selected-hosts': [], 'known-hosts': {} }, theme: 'dark' },
},
})
const wrapper = shallowMount(Settings, {
global: {
plugins: [pinia, i18n],
stubs: {
Teleport: true,
RouterLink: defineComponent({
name: 'RouterLink',
props: { to: { type: String, default: '' } },
setup(_, { slots }) {
return () => h('a', {}, slots.default?.())
},
}),
},
},
})
// When version is empty string, computed returns '0.0.0' from the fallback
const html = wrapper.html()
expect(html).toContain('0.0.0')
})
it('calls totpStatus on mount to check 2FA state', async () => {
mountSettings()
// onMounted calls loadTotpStatus which calls rpcClient.totpStatus
expect(mockedRpc.totpStatus).toHaveBeenCalled()
})
it('calls backup.list on mount to load backups', async () => {
mountSettings()
// onMounted calls loadBackups which calls rpcClient.call with backup.list
expect(mockedRpc.call).toHaveBeenCalledWith({ method: 'backup.list' })
})
})