feat: persistent app install state across navigation (#9)

Move installingApps from local refs in Marketplace/Discover to the
global server store. Install progress now persists when navigating
between views. My Apps shows installing overlay with progress bar
for apps being installed from the Marketplace.

Changes:
- server.ts: add installingApps Map + helpers to store
- Marketplace.vue: use store's installingApps instead of local ref
- Discover.vue: same
- Apps.vue: pass isInstalling + installProgress to AppCard
- AppCard.vue: add amber installing overlay with progress bar

522 tests pass, vue-tsc clean.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-29 00:13:39 +00:00
co-authored by Claude Opus 4.6
parent 840ecfaa5f
commit 2e29a41627
5 changed files with 108 additions and 52 deletions
+28 -27
View File
@@ -112,6 +112,7 @@ import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue'
import { useRouter, useRoute, RouterLink } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app'
import { useServerStore } from '@/stores/server'
import { rpcClient } from '@/api/rpc-client'
import { useMarketplaceApp } from '@/composables/useMarketplaceApp'
import { useAppLauncherStore } from '@/stores/appLauncher'
@@ -119,7 +120,6 @@ import MarketplaceAppCard from './marketplace/MarketplaceAppCard.vue'
import MarketplaceFilterModal from './marketplace/MarketplaceFilterModal.vue'
import {
type MarketplaceApp,
type InstallProgress,
INSTALLED_ALIASES,
getAppTier,
categorizeCommunityApp,
@@ -129,6 +129,7 @@ import {
const router = useRouter()
const route = useRoute()
const store = useAppStore()
const server = useServerStore()
const { t } = useI18n()
const showStagger = !marketplaceAnimationDone
@@ -152,8 +153,8 @@ const categories = computed(() => [
{ id: 'other', name: t('marketplace.other') }
])
// Installation state - support multiple concurrent installations
const installingApps = ref<Map<string, InstallProgress>>(new Map())
// Installation state — uses global store so it persists across navigation
const installingApps = server.installingApps
const maxAttempts = ref(60)
// Watch WebSocket data for real install progress from backend
@@ -162,8 +163,8 @@ watch(() => store.packages, (packages) => {
for (const [appId, pkg] of Object.entries(packages)) {
if ((pkg.state as string) === 'installing') {
const progress = pkg['install-progress']
if (!installingApps.value.has(appId)) {
installingApps.value.set(appId, {
if (!installingApps.has(appId)) {
installingApps.set(appId, {
id: appId,
title: pkg.manifest?.title || appId,
status: 'downloading',
@@ -173,19 +174,19 @@ watch(() => store.packages, (packages) => {
})
}
if (progress) {
const current = installingApps.value.get(appId)!
const current = installingApps.get(appId)!
const pct = progress.size > 0 ? Math.round((progress.downloaded / progress.size) * 100) : 0
const downloadedMB = (progress.downloaded / (1024 * 1024)).toFixed(1)
const totalMB = (progress.size / (1024 * 1024)).toFixed(1)
installingApps.value.set(appId, {
installingApps.set(appId, {
...current,
status: 'downloading',
progress: Math.min(pct, 95),
message: progress.size > 0 ? `Downloading: ${downloadedMB} / ${totalMB} MB (${pct}%)` : t('marketplace.downloading'),
})
}
} else if (installingApps.value.has(appId) && (pkg.state as string) !== 'installing') {
installingApps.value.delete(appId)
} else if (installingApps.has(appId) && (pkg.state as string) !== 'installing') {
installingApps.delete(appId)
}
}
}, { deep: true })
@@ -402,11 +403,11 @@ onBeforeUnmount(() => {
function startInstallPolling(appId: string, statusMessage: string) {
const interval = trackInterval(() => {
const current = installingApps.value.get(appId)
const current = installingApps.get(appId)
if (!current) { clearTrackedInterval(interval); return }
const newAttempt = current.attempt + 1
installingApps.value.set(appId, {
installingApps.set(appId, {
...current,
attempt: newAttempt,
progress: Math.min(60 + (newAttempt * 0.5), 95),
@@ -415,49 +416,49 @@ function startInstallPolling(appId: string, statusMessage: string) {
if (isInstalled(appId)) {
clearTrackedInterval(interval)
installingApps.value.set(appId, { ...current, status: 'complete', progress: 100, message: 'Installation complete!' })
trackTimeout(() => { installingApps.value.delete(appId) }, 2000)
installingApps.set(appId, { ...current, status: 'complete', progress: 100, message: 'Installation complete!' })
trackTimeout(() => { installingApps.delete(appId) }, 2000)
} else if (newAttempt >= maxAttempts.value) {
clearTrackedInterval(interval)
installingApps.value.set(appId, { ...current, status: 'error', progress: 0, message: 'Installation timeout' })
trackTimeout(() => { installingApps.value.delete(appId) }, 5000)
installingApps.set(appId, { ...current, status: 'error', progress: 0, message: 'Installation timeout' })
trackTimeout(() => { installingApps.delete(appId) }, 5000)
}
}, 1000)
}
async function installApp(app: MarketplaceApp) {
if (installingApps.value.has(app.id) || isInstalled(app.id)) return
if (installingApps.has(app.id) || isInstalled(app.id)) return
installingApps.value.set(app.id, {
installingApps.set(app.id, {
id: app.id, title: app.title ?? app.id, status: 'downloading', progress: 10, message: 'Preparing installation...', attempt: 0
})
try {
const installUrl = app.url || app.manifestUrl || app.s9pkUrl
installingApps.value.set(app.id, { ...installingApps.value.get(app.id)!, status: 'downloading', progress: 30, message: 'Downloading package...' })
installingApps.set(app.id, { ...installingApps.get(app.id)!, status: 'downloading', progress: 30, message: 'Downloading package...' })
await rpcClient.call({ method: 'package.install', params: { id: app.id, url: installUrl, version: app.version } })
installingApps.value.set(app.id, { ...installingApps.value.get(app.id)!, status: 'installing', progress: 60, message: 'Installing package...' })
installingApps.set(app.id, { ...installingApps.get(app.id)!, status: 'installing', progress: 60, message: 'Installing package...' })
startInstallPolling(app.id, 'Starting application...')
} catch (err) {
if (import.meta.env.DEV) console.error('Installation failed:', err)
installingApps.value.set(app.id, { ...installingApps.value.get(app.id)!, status: 'error', progress: 0, message: `Failed: ${err}` })
trackTimeout(() => { installingApps.value.delete(app.id) }, 5000)
installingApps.set(app.id, { ...installingApps.get(app.id)!, status: 'error', progress: 0, message: `Failed: ${err}` })
trackTimeout(() => { installingApps.delete(app.id) }, 5000)
}
}
async function installCommunityApp(app: MarketplaceApp) {
if (installingApps.value.has(app.id) || isInstalled(app.id) || !app.dockerImage) return
if (installingApps.has(app.id) || isInstalled(app.id) || !app.dockerImage) return
installingApps.value.set(app.id, {
installingApps.set(app.id, {
id: app.id, title: app.title ?? app.id, status: 'downloading', progress: 10, message: 'Pulling Docker image...', attempt: 0
})
try {
installingApps.value.set(app.id, { ...installingApps.value.get(app.id)!, status: 'downloading', progress: 20, message: 'Downloading container image...' })
installingApps.set(app.id, { ...installingApps.get(app.id)!, status: 'downloading', progress: 20, message: 'Downloading container image...' })
await rpcClient.call({
method: 'package.install',
@@ -465,13 +466,13 @@ async function installCommunityApp(app: MarketplaceApp) {
timeout: 180000
})
installingApps.value.set(app.id, { ...installingApps.value.get(app.id)!, status: 'installing', progress: 60, message: 'Starting container...' })
installingApps.set(app.id, { ...installingApps.get(app.id)!, status: 'installing', progress: 60, message: 'Starting container...' })
startInstallPolling(app.id, 'Initializing application...')
} catch (err) {
if (import.meta.env.DEV) console.error('[Marketplace] Installation failed:', err)
installingApps.value.set(app.id, { ...installingApps.value.get(app.id)!, status: 'error', progress: 0, message: `Failed: ${err}` })
trackTimeout(() => { installingApps.value.delete(app.id) }, 5000)
installingApps.set(app.id, { ...installingApps.get(app.id)!, status: 'error', progress: 0, message: `Failed: ${err}` })
trackTimeout(() => { installingApps.delete(app.id) }, 5000)
}
}
</script>