bug fixing and deploy and build diagnostics

This commit is contained in:
Dorian
2026-03-22 03:30:21 +00:00
parent 1f8287c4c3
commit e4e0ef4f11
198 changed files with 21703 additions and 19587 deletions
+176
View File
@@ -0,0 +1,176 @@
<template>
<div
data-controller-container
:data-controller-launch="canLaunch(pkg) ? '' : undefined"
tabindex="0"
role="link"
class="glass-card p-6 transition-all hover:-translate-y-1 cursor-pointer relative min-w-0 overflow-hidden"
:class="{ 'card-stagger': showStagger }"
:style="{ '--stagger-index': index }"
@click="$emit('goToApp', id)"
@keydown.enter="$emit('goToApp', id)"
>
<!-- Uninstalling overlay -->
<div
v-if="isUninstalling"
class="absolute inset-0 z-20 flex items-center justify-center bg-black/70 backdrop-blur-sm rounded-xl"
>
<div class="flex items-center gap-3 text-white/90">
<svg class="animate-spin h-5 w-5" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<span class="text-sm font-medium">{{ t('common.uninstalling') }}...</span>
</div>
</div>
<!-- Uninstall Icon (not for web-only apps) -->
<button
v-if="!isWebOnly && !isUninstalling"
@click.stop="$emit('showUninstall', id, 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}`"
:title="t('common.uninstall')"
>
<svg class="w-5 h-5" aria-hidden="true" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
<div class="flex items-start gap-4">
<img
:src="pkg['static-files']?.icon || `/assets/img/app-icons/${id}.png`"
:alt="pkg.manifest?.title || String(id)"
class="w-16 h-16 rounded-lg object-cover bg-white/10"
@error="handleImageError"
/>
<div class="flex-1 min-w-0 overflow-hidden">
<h3 class="text-lg font-semibold text-white mb-1 truncate" :title="pkg.manifest.title">
{{ pkg.manifest.title }}
</h3>
<p class="text-sm text-white/70 mb-2 truncate">
{{ pkg.manifest?.description?.short || '' }}
</p>
<div class="flex items-center gap-2">
<span
class="inline-flex items-center gap-1.5 px-2 py-1 rounded text-xs font-medium"
:class="getStatusClass(pkg.state, pkg.health)"
>
<svg
v-if="isTransitioning"
class="animate-spin h-3 w-3"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<span v-if="pkg.state === 'running' && pkg.health === 'unhealthy'" class="w-1.5 h-1.5 rounded-full bg-orange-400 animate-pulse"></span>
{{ getStatusLabel(pkg.state, pkg.health) }}
</span>
<span class="text-xs text-white/50">
v{{ pkg.manifest.version }}
</span>
</div>
</div>
</div>
<!-- Quick Actions -->
<div v-if="!isUninstalling" class="mt-4 flex gap-2">
<button
v-if="canLaunch(pkg)"
data-controller-launch-btn
@click.stop="$emit('launch', id)"
class="flex-1 px-4 py-2 glass-button glass-button-sm rounded-lg text-sm font-medium flex items-center justify-center gap-1.5"
>
{{ t('common.launch') }}
<svg v-if="opensInTab(id)" class="w-3.5 h-3.5 opacity-60" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" /></svg>
</button>
<button
v-if="!isWebOnly && !isLoading && (pkg.state === 'stopped' || pkg.state === 'exited')"
@click.stop="$emit('start', id)"
class="flex-1 px-4 py-2 glass-button glass-button-success rounded-lg text-sm font-medium flex items-center justify-center gap-2"
>
<span>{{ pkg.state === 'exited' ? 'Restart' : t('common.start') }}</span>
</button>
<button
v-if="!isWebOnly && isLoading && (pkg.state === 'stopped' || pkg.state === 'exited' || pkg.state === 'starting')"
disabled
class="flex-1 px-4 py-2 glass-button glass-button-success rounded-lg text-sm font-medium opacity-50 cursor-not-allowed flex items-center justify-center gap-2"
>
<svg class="animate-spin h-4 w-4" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<span>{{ t('common.starting') }}</span>
</button>
<button
v-if="!isWebOnly && !isLoading && (pkg.state === 'running' || pkg.state === 'starting')"
@click.stop="$emit('stop', id)"
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 flex items-center justify-center gap-2"
>
<span>{{ t('common.stop') }}</span>
</button>
<button
v-if="!isWebOnly && !isLoading && (pkg.state === 'running' || pkg.state === 'starting')"
@click.stop="$emit('restart', id)"
class="px-2.5 py-2 glass-button glass-button-sm rounded-lg flex items-center justify-center"
:title="t('common.restart')"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
</button>
<button
v-if="!isWebOnly && isLoading && (pkg.state === 'running' || pkg.state === 'starting' || pkg.state === 'stopping')"
disabled
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 opacity-50 cursor-not-allowed flex items-center justify-center gap-2"
>
<svg class="animate-spin h-4 w-4" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<span>{{ t('common.stopping') }}</span>
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type { PackageDataEntry } from '@/types/api'
import {
isWebOnlyApp, opensInTab, canLaunch,
getStatusClass, getStatusLabel, handleImageError,
} from './appsConfig'
const { t } = useI18n()
const props = defineProps<{
id: string
pkg: PackageDataEntry
index: number
showStagger: boolean
isLoading: boolean
isUninstalling: boolean
}>()
defineEmits<{
goToApp: [id: string]
launch: [id: string]
start: [id: string]
stop: [id: string]
restart: [id: string]
showUninstall: [id: string, pkg: PackageDataEntry]
}>()
const isWebOnly = computed(() => isWebOnlyApp(props.id))
const isTransitioning = computed(() => {
const s = props.pkg.state
const h = props.pkg.health
return s === 'starting' || s === 'installing' || s === 'stopping' || s === 'restarting' || (s === 'running' && h === 'starting')
})
</script>
@@ -0,0 +1,90 @@
<template>
<Teleport to="body">
<Transition name="modal">
<div
v-if="show"
class="fixed inset-0 z-[3000] flex items-center justify-center p-4"
@click="$emit('close')"
>
<div class="absolute inset-0 bg-black/60 backdrop-blur-md"></div>
<div
ref="modalRef"
@click.stop
role="dialog"
aria-modal="true"
aria-labelledby="uninstall-dialog-title"
class="glass-card p-6 max-w-2xl w-full relative z-10"
>
<div class="flex items-start gap-4 mb-4">
<div class="p-3 bg-red-500/20 rounded-lg">
<svg class="w-6 h-6 text-red-400" aria-hidden="true" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
</div>
<div class="flex-1">
<h3 id="uninstall-dialog-title" class="text-xl font-semibold text-white mb-2">{{ t('apps.uninstallTitle') }}</h3>
<p class="text-white/70">
{{ t('apps.uninstallConfirm', { name: appTitle }) }}
</p>
</div>
</div>
<div class="flex gap-3 justify-end">
<button
@click="$emit('close')"
class="px-4 py-2 glass-button rounded-lg text-sm font-medium"
>
{{ t('common.cancel') }}
</button>
<button
@click="$emit('confirm')"
:disabled="uninstalling"
class="px-4 py-2 glass-button glass-button-danger rounded-lg text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
<svg
v-if="uninstalling"
class="animate-spin h-4 w-4"
aria-hidden="true"
fill="none"
viewBox="0 0 24 24"
>
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<span>{{ uninstalling ? t('common.uninstalling') : t('common.uninstall') }}</span>
</button>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useModalKeyboard } from '@/composables/useModalKeyboard'
const { t } = useI18n()
const props = defineProps<{
show: boolean
appTitle: string
uninstalling: boolean
}>()
const emit = defineEmits<{
close: []
confirm: []
}>()
const modalRef = ref<HTMLElement | null>(null)
const restoreFocusRef = ref<HTMLElement | null>(null)
useModalKeyboard(
modalRef,
computed(() => props.show),
() => emit('close'),
{ restoreFocusRef },
)
</script>
+187
View File
@@ -0,0 +1,187 @@
/** 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',
'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',
])
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<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', 'onlyoffice': 'data',
'homeassistant': 'home', 'lorabell': 'home', 'endurain': 'home',
'searxng': 'community', 'ollama': 'community', 'grafana': 'data',
'nostr-rs-relay': 'nostr', 'nostrudel': 'nostr',
'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<string, unknown>)?.category as string | undefined
return cat || 'other'
}
// Web-only app IDs and their URLs
export const WEB_ONLY_APP_URLS: Record<string, string> = {
'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<string, PackageDataEntry> = {
'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',
'onlyoffice', 'nginx-proxy-manager', 'tailscale',
])
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): 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:
return 'bg-red-500/20 text-red-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'
default:
return 'bg-gray-500/20 text-gray-200'
}
}
export function getStatusLabel(state: PackageState, health?: string | 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.Exited) return 'crashed'
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]) => !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(`
<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
}
}
+104
View File
@@ -0,0 +1,104 @@
/** Composable for app start/stop/restart/uninstall actions */
import { ref, onBeforeUnmount } from 'vue'
import { useAppStore } from '@/stores/app'
export function useAppsActions() {
const store = useAppStore()
const loadingActions = ref<Record<string, boolean>>({})
const actionError = ref('')
let errorTimer: ReturnType<typeof setTimeout> | undefined
const actionTimers = new Map<string, ReturnType<typeof setTimeout>>()
const uninstalling = ref(false)
const uninstallingApps = ref<Set<string>>(new Set())
function showActionError(msg: string) {
actionError.value = msg
if (errorTimer) clearTimeout(errorTimer)
errorTimer = setTimeout(() => { actionError.value = '' }, 5000)
}
async function startApp(id: string) {
loadingActions.value[id] = true
try {
await store.startPackage(id)
if (actionTimers.has(id)) clearTimeout(actionTimers.get(id)!)
actionTimers.set(id, setTimeout(() => {
loadingActions.value[id] = false
actionTimers.delete(id)
}, 5000))
} catch (err) {
if (import.meta.env.DEV) console.error('Failed to start app:', err)
showActionError(`Failed to start app: ${err instanceof Error ? err.message : 'Unknown error'}`)
loadingActions.value[id] = false
}
}
async function stopApp(id: string) {
loadingActions.value[id] = true
try {
await store.stopPackage(id)
if (actionTimers.has(id)) clearTimeout(actionTimers.get(id)!)
actionTimers.set(id, setTimeout(() => {
loadingActions.value[id] = false
actionTimers.delete(id)
}, 5000))
} catch (err) {
if (import.meta.env.DEV) console.error('Failed to stop app:', err)
showActionError(`Failed to stop app: ${err instanceof Error ? err.message : 'Unknown error'}`)
loadingActions.value[id] = false
}
}
async function restartApp(id: string) {
loadingActions.value[id] = true
try {
await store.restartPackage(id)
if (actionTimers.has(id)) clearTimeout(actionTimers.get(id)!)
actionTimers.set(id, setTimeout(() => {
loadingActions.value[id] = false
actionTimers.delete(id)
}, 8000))
} catch (err) {
if (import.meta.env.DEV) console.error('Failed to restart app:', err)
showActionError(`Failed to restart app: ${err instanceof Error ? err.message : 'Unknown error'}`)
loadingActions.value[id] = false
}
}
async function confirmUninstall(appId: string) {
uninstalling.value = true
try {
uninstallingApps.value.add(appId)
await store.uninstallPackage(appId)
if (store.packages && store.packages[appId]) {
delete store.packages[appId]
}
} catch (err) {
if (import.meta.env.DEV) console.error('Failed to uninstall app:', err)
showActionError(`Failed to uninstall app: ${err instanceof Error ? err.message : 'Unknown error'}`)
} finally {
uninstallingApps.value.delete(appId)
uninstalling.value = false
}
}
onBeforeUnmount(() => {
for (const t of actionTimers.values()) clearTimeout(t)
actionTimers.clear()
if (errorTimer) clearTimeout(errorTimer)
})
return {
loadingActions,
actionError,
uninstalling,
uninstallingApps,
showActionError,
startApp,
stopApp,
restartApp,
confirmUninstall,
}
}