Fixes from real fresh-install feedback (Framework node .81) + its log bundle:
Backend:
- websocket: subscribe before initial snapshot — broadcasts in the gap were
silently lost, stranding clients on stale state until a hard refresh
(the "everything needs ctrl-r" bug: My Apps stuck Loading, App Store
stuck Checking, containers-scanned never arriving)
- crash recovery: check the crash marker BEFORE writing our own PID —
recovery had never run on any node (always saw its own PID and skipped);
PID-reuse guard via /proc cmdline
- boot status: pending-boot-starts registry (recovery, stack recovery,
reconciler, adoption) — scanner overlays queued-but-down apps as
Restarting instead of Stopped after a reboot; scanner-authored
Restarting resolves immediately on a settled scan (no transitional wedge)
- install deps: bounded wait (36x5s) when a dependency is installed but
still starting ("Waiting for Bitcoin to start…") instead of instant
rejection; dependency-gate rejections remove the optimistic entry (no
phantom Stopped tile) and surface as a notification
- seed backup: auth.setup persists the onboarding mnemonic as the
encrypted seed backup (reveal previously failed on EVERY node — nothing
ever wrote master_seed.enc); seed.restore stashes too; error sanitizer
lets seed/2FA errors through instead of "Check server logs"
- lnd: bitcoind.rpchost resolved from the running Bitcoin variant
(hardcoded bitcoin-knots broke Core nodes); manifest uses derived_env
- bitcoin status: clean human message for connection-reset/startup; raw
URLs + os-error chains no longer reach the app card
- fedimint-clientd: chown /var/lib/archipelago/fmcd to 1000:1000 (root-
created dir crash-looped the rootless container, EACCES) — first-boot
script + pre-start self-heal
- log volume (>1GB/day on a day-old node): journald caps drop-in (ISO +
bootstrap self-heal), bitcoind -printtoconsole=0 everywhere (90% of the
journal was IBD UpdateTip spam), tracing default debug→info
Frontend:
- Login: Enter advances to confirm field then submits; submit always
clickable with inline errors (was silently disabled on mismatch);
Restart Onboarding needs a confirming second click (the mismatch →
"onboarding restarted" trap)
- sync store: 30s state reconciliation + refetch on re-entrant connect;
20s containers-scanned escape hatch so Checking can never show forever;
fresh empty node reaches the real "no apps yet" state
- intro video: CRF20 re-encode (SSIM 0.988) + faststart — moov was at EOF
so playback needed the full 15MB first (the intro lag)
- backgrounds: 10 heaviest JPEGs → WebP q90 (9.4MB→6.6MB); 7 stayed JPEG
(WebP larger on noisy sources)
- Web5ConnectedNodes: drop unused template ref that failed vue-tsc -b
ISO/kiosk:
- nginx: /assets/ 404s no longer cached immutable for a year; HTTPS block
gained the missing /assets/ location (served index.html as images)
- kiosk: launcher/service spliced from configs/ at ISO build (stale
heredoc force-disabled GPU); MemoryHigh/Max 1200/1500→2200/2800M (kiosk
rode the reclaim throttle = the lag); firmware-intel-graphics +
firmware-amd-graphics (trixie split DMC blobs out of misc-nonfree)
Verified: cargo test 898/898 green, npm run build green with dist
contents confirmed (webp refs, lnd.png, faststart video, new strings).
Handover for ISO build + deploy: docs/HANDOVER-2026-07-02-iso-feedback.md
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
620 lines
23 KiB
Vue
620 lines
23 KiB
Vue
<template>
|
|
<div class="discover-container">
|
|
<!-- Navigation Bar (always at top) -->
|
|
<div>
|
|
<!-- Desktop: tabs + categories + search -->
|
|
<div ref="discoverHeaderRef" class="app-header-desktop mb-6 items-center gap-4 relative">
|
|
<div ref="discoverPrimaryRef" class="flex-shrink-0">
|
|
<div class="mode-switcher hidden md:inline-flex">
|
|
<RouterLink to="/dashboard/apps" class="mode-switcher-btn">My Apps</RouterLink>
|
|
<RouterLink to="/dashboard/discover" class="mode-switcher-btn mode-switcher-btn-active">App Store</RouterLink>
|
|
<RouterLink to="/dashboard/apps?tab=services" class="mode-switcher-btn">Services</RouterLink>
|
|
</div>
|
|
</div>
|
|
<div v-show="!collapseCategories" class="mode-switcher category-tabs-wide hidden md:inline-flex">
|
|
<button
|
|
v-for="section in appStoreSections"
|
|
:key="section.id"
|
|
@click="selectDiscoverCategory(section.id)"
|
|
class="mode-switcher-btn"
|
|
:class="{ 'mode-switcher-btn-active': section.id === 'discover' }"
|
|
>
|
|
{{ section.name }}
|
|
</button>
|
|
</div>
|
|
<div v-show="collapseCategories" class="segmented-select flex-shrink-0">
|
|
<label class="sr-only" for="discover-category-select">App Store category</label>
|
|
<select
|
|
id="discover-category-select"
|
|
class="segmented-select-control"
|
|
value="discover"
|
|
@change="selectDiscoverCategory(($event.target as HTMLSelectElement).value)"
|
|
>
|
|
<option
|
|
v-for="section in appStoreSections"
|
|
:key="section.id"
|
|
:value="section.id"
|
|
>
|
|
{{ section.name }}
|
|
</option>
|
|
</select>
|
|
</div>
|
|
<div ref="discoverCategoryProbeRef" class="mode-switcher category-tabs-probe" aria-hidden="true">
|
|
<button
|
|
v-for="section in appStoreSections"
|
|
:key="section.id"
|
|
class="mode-switcher-btn"
|
|
type="button"
|
|
>
|
|
{{ section.name }}
|
|
</button>
|
|
</div>
|
|
<input
|
|
v-model="searchQuery"
|
|
type="text"
|
|
placeholder="Search apps..."
|
|
aria-label="Search apps"
|
|
data-controller-no-submit
|
|
class="app-header-search text-white placeholder-white/50 focus:outline-none transition-colors"
|
|
/>
|
|
</div>
|
|
|
|
<!-- Mobile: categories + search -->
|
|
<div class="app-header-mobile mb-4">
|
|
<div class="app-header-inline-tabs mode-switcher mode-switcher-full mb-3">
|
|
<RouterLink to="/dashboard/apps" class="mode-switcher-btn">My Apps</RouterLink>
|
|
<RouterLink to="/dashboard/discover" class="mode-switcher-btn mode-switcher-btn-active">App Store</RouterLink>
|
|
<RouterLink to="/dashboard/apps?tab=services" class="mode-switcher-btn">Services</RouterLink>
|
|
</div>
|
|
<div class="flex items-center gap-2 mb-3">
|
|
<span class="discover-terminal-tag">discover</span>
|
|
<h1 class="text-lg font-bold text-white">App Store</h1>
|
|
</div>
|
|
<div class="mobile-category-strip mb-3" aria-label="App Store categories">
|
|
<button
|
|
v-for="section in appStoreSections"
|
|
:key="section.id"
|
|
@click="selectDiscoverCategory(section.id)"
|
|
class="mobile-category-pill"
|
|
:class="{ 'mobile-category-pill-active': section.id === 'discover' }"
|
|
type="button"
|
|
>{{ section.name }}</button>
|
|
</div>
|
|
<input
|
|
v-model="searchQuery"
|
|
type="text"
|
|
placeholder="Search apps..."
|
|
aria-label="Search apps"
|
|
data-controller-no-submit
|
|
class="app-header-search w-full text-white placeholder-white/50 focus:outline-none transition-colors"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Hero + Featured + Banner (only when no search) -->
|
|
<template v-if="!searchQuery">
|
|
<DiscoverHero
|
|
:total-apps="allApps.length"
|
|
:installed-count="installedCount"
|
|
/>
|
|
|
|
<FeaturedApps
|
|
:featured-apps="featuredApps"
|
|
:show-stagger="showStagger"
|
|
:containers-scanned="containersScanned"
|
|
:installing-apps="installingApps"
|
|
:is-installed="isInstalled"
|
|
:is-starting-up="isStartingUp"
|
|
:get-app-tier="getAppTier"
|
|
@view-details="viewAppDetails"
|
|
@launch="launchInstalledApp"
|
|
@install="handleInstall"
|
|
/>
|
|
|
|
<!-- Featured App Banner (from catalog or hardcoded) -->
|
|
<div
|
|
v-if="featuredBanner"
|
|
class="featured-banner glass-card mb-8 relative overflow-hidden cursor-pointer"
|
|
@click="featuredBannerApp && viewAppDetails(featuredBannerApp)"
|
|
>
|
|
<img
|
|
:src="featuredBanner.banner"
|
|
:alt="featuredBanner.headline"
|
|
class="featured-banner-img"
|
|
@error="(e: Event) => (e.target as HTMLImageElement).style.display = 'none'"
|
|
/>
|
|
<div class="featured-banner-overlay">
|
|
<div class="flex items-center gap-3 mb-2">
|
|
<span class="discover-terminal-tag">featured</span>
|
|
<span class="text-white/50 text-sm font-mono">{{ featuredBanner.tag }}</span>
|
|
</div>
|
|
<h2 class="text-3xl md:text-4xl font-extrabold text-white mb-2 tracking-tight">{{ featuredBanner.headline }}</h2>
|
|
<p class="text-white/80 text-base md:text-lg max-w-2xl leading-relaxed mb-4">{{ featuredBanner.description }}</p>
|
|
<div class="flex items-center gap-3">
|
|
<button
|
|
v-if="featuredBannerApp && isInstalled(featuredBannerApp.id) && !isStartingUp(featuredBannerApp.id)"
|
|
@click.stop="launchInstalledApp(featuredBannerApp)"
|
|
class="glass-button rounded-lg px-6 py-2.5 text-sm font-medium"
|
|
>Launch</button>
|
|
<button
|
|
v-else-if="featuredBannerApp && !isInstalled(featuredBannerApp.id) && featuredBannerApp.dockerImage"
|
|
@click.stop="handleInstall(featuredBannerApp)"
|
|
:disabled="installingApps.has(featuredBannerApp.id)"
|
|
class="glass-button rounded-lg px-6 py-2.5 text-sm font-medium disabled:opacity-50"
|
|
>
|
|
<span v-if="installingApps.has(featuredBannerApp.id)">Installing...</span>
|
|
<span v-else>Install</span>
|
|
</button>
|
|
<span class="text-white/40 text-sm">{{ featuredBannerApp?.title }} {{ $ver(featuredBannerApp?.version) }}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Category Section Divider -->
|
|
<div class="flex items-center gap-3 mb-5">
|
|
<span class="discover-terminal-tag">all</span>
|
|
<h2 class="text-xl font-bold text-white">Available to Install</h2>
|
|
<div class="flex-1 h-px bg-white/10"></div>
|
|
<span class="text-white/30 text-sm">{{ filteredApps.length }} apps</span>
|
|
</div>
|
|
</template>
|
|
|
|
<!-- Search results header -->
|
|
<div v-else class="flex items-center gap-3 mb-5">
|
|
<span class="discover-terminal-tag">search</span>
|
|
<h2 class="text-xl font-bold text-white">Search Results</h2>
|
|
<div class="flex-1 h-px bg-white/10"></div>
|
|
<span class="text-white/30 text-sm">{{ filteredApps.length }} apps</span>
|
|
</div>
|
|
|
|
<!-- Community Load Error -->
|
|
<div v-if="communityError" class="alert-error mb-4">
|
|
{{ communityError }}
|
|
<button @click="loadCommunityMarketplace()" class="ml-2 underline hover:no-underline">Retry</button>
|
|
</div>
|
|
|
|
<AppGrid
|
|
:filtered-apps="filteredApps"
|
|
:show-stagger="showStagger"
|
|
:stagger-offset="selectedCategory === 'all' && !searchQuery ? 4 : 0"
|
|
:containers-scanned="containersScanned"
|
|
:installing-apps="installingApps"
|
|
:is-installed="isInstalled"
|
|
:is-starting-up="isStartingUp"
|
|
:get-installed-state="getInstalledState"
|
|
:get-app-tier="getAppTier"
|
|
:is-loading="loadingCommunity || nostrLoading"
|
|
:loading-message="nostrLoading ? 'Querying Nostr relays...' : 'Loading...'"
|
|
:nostr-error="nostrError"
|
|
:is-nostr-category="selectedCategory === 'nostr'"
|
|
:search-query="searchQuery"
|
|
@view-details="viewAppDetails"
|
|
@launch="launchInstalledApp"
|
|
@install="handleInstall"
|
|
@retry-nostr="retryNostr"
|
|
/>
|
|
|
|
<!-- Manifesto Footer (only when no search) -->
|
|
<div v-if="!searchQuery && filteredApps.length > 0" class="discover-manifesto glass-card p-8 mt-4 mb-8">
|
|
<div class="flex items-center gap-3 mb-4">
|
|
<span class="discover-terminal-tag text-orange-400/80">manifesto</span>
|
|
<div class="flex-1 h-px bg-white/10"></div>
|
|
</div>
|
|
<blockquote class="text-white/80 text-xl leading-relaxed italic max-w-3xl">
|
|
"Privacy is not about having something to hide. Privacy is about having the right to choose
|
|
what to reveal. In a world of surveillance capitalism, self-hosting is an act of resistance.
|
|
Every service you run on your own hardware is a vote for a future where individuals — not
|
|
corporations — control their digital lives."
|
|
</blockquote>
|
|
<p class="text-white/60 text-xl mt-4 font-mono">// Cypherpunks write code. We run nodes.</p>
|
|
</div>
|
|
|
|
<!-- First-install version chooser (Bitcoin Knots / Core) -->
|
|
<InstallVersionModal
|
|
:show="showInstallModal"
|
|
:app-id="installModalApp?.id || ''"
|
|
:app="installModalApp"
|
|
@close="showInstallModal = false; installModalApp = null"
|
|
@confirm="onInstallModalConfirm"
|
|
/>
|
|
|
|
</div>
|
|
</template>
|
|
|
|
<script lang="ts">
|
|
let discoverAnimationDone = false
|
|
</script>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
|
|
import { useRouter, RouterLink } from 'vue-router'
|
|
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'
|
|
import { useToast } from '@/composables/useToast'
|
|
import { useCollapsingHeaderTabs } from '@/composables/useCollapsingHeaderTabs'
|
|
import { useContainersScanTimeout } from '@/composables/useContainersScanTimeout'
|
|
import { APP_STORE_SECTIONS } from './appStoreCategories'
|
|
import DiscoverHero from './discover/DiscoverHero.vue'
|
|
import FeaturedApps from './discover/FeaturedApps.vue'
|
|
import AppGrid from './discover/AppGrid.vue'
|
|
import InstallVersionModal from '@/components/InstallVersionModal.vue'
|
|
import type { MarketplaceApp, FeaturedApp } from './discover/types'
|
|
import { getCuratedAppList, INSTALLED_ALIASES, FEATURED_DEFINITIONS, categorizeCommunityApp, fetchAppCatalog, type CatalogFeatured } from './discover/curatedApps'
|
|
|
|
const router = useRouter()
|
|
const store = useAppStore()
|
|
const serverStore = useServerStore()
|
|
|
|
const showStagger = !discoverAnimationDone
|
|
const { setCurrentApp } = useMarketplaceApp()
|
|
const appLauncher = useAppLauncherStore()
|
|
|
|
const selectedCategory = ref('all')
|
|
const searchQuery = ref('')
|
|
const bitcoinPruned = ref(false)
|
|
const electrumxArchiveWarning = 'You need a full archival bitcoin node before downloading ElectrumX'
|
|
const discoverHeaderRef = ref<HTMLElement | null>(null)
|
|
const discoverPrimaryRef = ref<HTMLElement | null>(null)
|
|
const discoverCategoryProbeRef = ref<HTMLElement | null>(null)
|
|
const { collapsed: collapseCategories } = useCollapsingHeaderTabs(
|
|
discoverHeaderRef,
|
|
discoverPrimaryRef,
|
|
discoverCategoryProbeRef,
|
|
144
|
|
)
|
|
|
|
const appStoreSections = computed(() => APP_STORE_SECTIONS)
|
|
|
|
// Installation state — uses global store so it persists across navigation.
|
|
// The store's watcher (stores/server.ts) handles install-progress updates
|
|
// globally, so this view doesn't need its own watcher. Previously had a
|
|
// local watcher that duplicated logic using byte counters only — it has
|
|
// been removed in favour of the store's phase-aware mapping.
|
|
const installingApps = serverStore.installingApps
|
|
|
|
// First-install version-choice modal (multi-version apps: Bitcoin Knots / Core)
|
|
const showInstallModal = ref(false)
|
|
const installModalApp = ref<MarketplaceApp | null>(null)
|
|
|
|
function navigateToMarketplace(categoryId: string) {
|
|
router.push({ name: 'marketplace', query: { category: categoryId } })
|
|
}
|
|
|
|
function selectDiscoverCategory(categoryId: string) {
|
|
if (categoryId === 'discover') {
|
|
router.push('/dashboard/discover')
|
|
return
|
|
}
|
|
navigateToMarketplace(categoryId)
|
|
}
|
|
|
|
// Community & Nostr marketplace state
|
|
const loadingCommunity = ref(false)
|
|
const communityError = ref('')
|
|
const communityApps = ref<MarketplaceApp[]>([])
|
|
const nostrApps = ref<MarketplaceApp[]>([])
|
|
const nostrLoading = ref(false)
|
|
const nostrError = ref('')
|
|
|
|
async function loadNostrMarketplace() {
|
|
if (nostrApps.value.length > 0 || nostrLoading.value) return
|
|
nostrLoading.value = true
|
|
nostrError.value = ''
|
|
try {
|
|
const res = await rpcClient.marketplaceDiscover()
|
|
nostrApps.value = res.apps.map(app => ({
|
|
id: app.manifest.app_id,
|
|
title: app.manifest.name,
|
|
version: app.manifest.version,
|
|
description: typeof app.manifest.description === 'string'
|
|
? app.manifest.description
|
|
: app.manifest.description,
|
|
icon: app.manifest.icon_url || '',
|
|
author: app.manifest.author.name,
|
|
dockerImage: app.manifest.container.image,
|
|
repoUrl: app.manifest.repo_url,
|
|
category: app.manifest.category,
|
|
source: 'nostr',
|
|
trustScore: app.trust_score,
|
|
trustTier: app.trust_tier,
|
|
relayCount: app.relay_count,
|
|
}))
|
|
} catch (e) {
|
|
nostrError.value = e instanceof Error ? e.message : 'Discovery failed'
|
|
if (import.meta.env.DEV) console.warn('Nostr marketplace discovery failed:', e)
|
|
} finally {
|
|
nostrLoading.value = false
|
|
}
|
|
}
|
|
|
|
function retryNostr() {
|
|
nostrApps.value = []
|
|
loadNostrMarketplace()
|
|
}
|
|
|
|
const installedPackages = computed(() => store.data?.['package-data'] || {})
|
|
const containersScannedRaw = computed(() => store.data?.['server-info']?.['status-info']?.['containers-scanned'] ?? false)
|
|
// Escape hatch: never leave app cards on "Checking..." forever — after a
|
|
// timeout, treat the scan as done so cards render their normal install state.
|
|
const { effectiveContainersScanned: containersScanned } = useContainersScanTimeout(
|
|
containersScannedRaw,
|
|
computed(() => store.hasLoadedInitialData),
|
|
)
|
|
|
|
|
|
const allApps = computed(() => {
|
|
const local: (MarketplaceApp & { category: string; source: string })[] = []
|
|
const community = communityApps.value.map(app => ({
|
|
...app,
|
|
category: categorizeCommunityApp(app),
|
|
source: 'community'
|
|
}))
|
|
const base = [...local, ...community]
|
|
|
|
if (nostrApps.value.length > 0) {
|
|
const existingIds = new Set(base.map(a => a.id))
|
|
const nostrMerged = nostrApps.value
|
|
.filter(app => !existingIds.has(app.id))
|
|
.map(app => ({ ...app, category: app.category || categorizeCommunityApp(app), source: 'nostr' }))
|
|
return [...base, ...nostrMerged]
|
|
}
|
|
return base
|
|
})
|
|
|
|
const filteredApps = computed(() => {
|
|
let apps = allApps.value
|
|
if (selectedCategory.value && selectedCategory.value !== 'all' && !searchQuery.value) {
|
|
apps = apps.filter(app => app.category === selectedCategory.value)
|
|
}
|
|
if (searchQuery.value) {
|
|
const query = searchQuery.value.toLowerCase()
|
|
apps = apps.filter(app =>
|
|
app.title?.toLowerCase().includes(query) ||
|
|
(typeof app.description === 'string' && app.description.toLowerCase().includes(query)) ||
|
|
(typeof app.description === 'object' && app.description?.short?.toLowerCase().includes(query)) ||
|
|
app.id?.toLowerCase().includes(query) ||
|
|
app.author?.toLowerCase().includes(query)
|
|
)
|
|
}
|
|
// Hide installed apps and web-only links (no dockerImage = not installable)
|
|
apps = apps.filter(app => !isInstalled(app.id) && app.dockerImage)
|
|
return apps
|
|
})
|
|
|
|
const installedCount = computed(() => {
|
|
return allApps.value.filter(app => isInstalled(app.id)).length
|
|
})
|
|
|
|
// Featured banner — from catalog.json or first FEATURED_DEFINITIONS entry with banner
|
|
const featuredBanner = computed(() => {
|
|
if (catalogFeatured.value) return catalogFeatured.value
|
|
const first = FEATURED_DEFINITIONS.find(f => f.banner)
|
|
if (!first) return null
|
|
const app = allApps.value.find(a => a.id === first.id)
|
|
if (!app) return null
|
|
return { id: first.id, banner: first.banner!, headline: app.title ?? first.id, description: first.desc, tag: first.tag }
|
|
})
|
|
|
|
const featuredBannerApp = computed(() => {
|
|
if (!featuredBanner.value) return null
|
|
return allApps.value.find(a => a.id === featuredBanner.value!.id) ?? null
|
|
})
|
|
|
|
const featuredApps = computed<FeaturedApp[]>(() => {
|
|
return FEATURED_DEFINITIONS
|
|
.map(f => {
|
|
const app = allApps.value.find(a => a.id === f.id)
|
|
if (!app) return null
|
|
return { ...app, featuredDescription: f.desc, privacyTag: f.tag } as FeaturedApp
|
|
})
|
|
.filter((a): a is FeaturedApp => a !== null)
|
|
})
|
|
|
|
|
|
function isInstalled(appId: string): boolean {
|
|
if (appId in installedPackages.value) return true
|
|
const aliases = INSTALLED_ALIASES[appId]
|
|
return aliases ? aliases.some((a) => a in installedPackages.value) : false
|
|
}
|
|
|
|
function getInstalledState(appId: string): string | null {
|
|
const pkg = installedPackages.value[appId]
|
|
if (pkg) return pkg.state
|
|
const aliases = INSTALLED_ALIASES[appId]
|
|
if (aliases) {
|
|
for (const a of aliases) {
|
|
const aliasPkg = installedPackages.value[a]
|
|
if (aliasPkg) return aliasPkg.state
|
|
}
|
|
}
|
|
return null
|
|
}
|
|
|
|
function isStartingUp(appId: string): boolean {
|
|
const state = getInstalledState(appId)
|
|
return state !== null && state !== 'running' && state !== 'stopped' && state !== 'exited'
|
|
}
|
|
|
|
function getAppTier(appId: string): string {
|
|
const core = ['bitcoin-knots', 'bitcoin', 'lnd', 'mempool', 'btcpay-server', 'filebrowser']
|
|
const recommended = ['fedimint', 'vaultwarden', 'uptime-kuma', 'grafana', 'searxng', 'tailscale', 'netbird', 'portainer']
|
|
if (core.includes(appId)) return 'core'
|
|
if (recommended.includes(appId)) return 'recommended'
|
|
return 'optional'
|
|
}
|
|
|
|
function launchInstalledApp(app: MarketplaceApp) {
|
|
appLauncher.openSession(app.id)
|
|
}
|
|
|
|
async function handleInstall(app: MarketplaceApp) {
|
|
const blocked = installBlockedReason(app.id)
|
|
if (blocked) {
|
|
toast.error(blocked)
|
|
return
|
|
}
|
|
if (installingApps.has(app.id) || isInstalled(app.id)) return
|
|
// Multi-version apps (Bitcoin Knots / Core): let the runner pick a version up
|
|
// front via a full-screen modal (latest pre-selected) instead of silently
|
|
// installing the default. Best-effort — if the lookup fails we install directly.
|
|
try {
|
|
const info = await rpcClient.getPackageVersions(app.id)
|
|
if (info.supportsVersions && info.versions.length > 1) {
|
|
installModalApp.value = app
|
|
showInstallModal.value = true
|
|
return
|
|
}
|
|
} catch { /* no catalog versions — fall through to direct install */ }
|
|
startInstall(app)
|
|
}
|
|
|
|
function startInstall(app: MarketplaceApp, versionOverride?: string) {
|
|
if (app.source === 'local') {
|
|
installApp(app, versionOverride)
|
|
} else {
|
|
installCommunityApp(app, versionOverride)
|
|
}
|
|
}
|
|
|
|
function onInstallModalConfirm(version: string) {
|
|
const app = installModalApp.value
|
|
showInstallModal.value = false
|
|
installModalApp.value = null
|
|
if (app) startInstall(app, version)
|
|
}
|
|
|
|
function viewAppDetails(app: MarketplaceApp) {
|
|
try {
|
|
if (isInstalled(app.id)) {
|
|
router.push({ name: 'app-details', params: { id: app.id }, query: { from: 'discover' } })
|
|
} else {
|
|
setCurrentApp(app)
|
|
router.push({ name: 'marketplace-app-detail', params: { id: app.id }, query: { from: 'discover' } })
|
|
}
|
|
} catch (e) {
|
|
if (import.meta.env.DEV) console.error('[Discover] Navigation error:', e)
|
|
}
|
|
}
|
|
|
|
// Timer management
|
|
const activeTimers: ReturnType<typeof setTimeout>[] = []
|
|
|
|
function trackTimeout(fn: () => void, ms: number) {
|
|
const id = setTimeout(() => {
|
|
const idx = activeTimers.indexOf(id)
|
|
if (idx !== -1) activeTimers.splice(idx, 1)
|
|
fn()
|
|
}, ms)
|
|
activeTimers.push(id)
|
|
return id
|
|
}
|
|
|
|
onBeforeUnmount(() => {
|
|
for (const t of activeTimers) clearTimeout(t)
|
|
activeTimers.length = 0
|
|
})
|
|
|
|
const toast = useToast()
|
|
|
|
async function loadBitcoinPruneStatus() {
|
|
try {
|
|
const res = await fetch('/bitcoin-status', { credentials: 'include', signal: AbortSignal.timeout(8000) })
|
|
if (!res.ok) return
|
|
const status = await res.json()
|
|
bitcoinPruned.value = status?.blockchain_info?.pruned === true
|
|
} catch (e) {
|
|
if (import.meta.env.DEV) console.warn('[Discover] Bitcoin prune status unavailable:', e)
|
|
}
|
|
}
|
|
|
|
function installBlockedReason(appId: string): string | undefined {
|
|
if (!bitcoinPruned.value) return undefined
|
|
if (appId !== 'electrumx' && appId !== 'electrs' && appId !== 'mempool-electrs') return undefined
|
|
return electrumxArchiveWarning
|
|
}
|
|
|
|
function queueInstall(app: MarketplaceApp) {
|
|
serverStore.setInstallProgress(app.id, {
|
|
id: app.id,
|
|
title: app.title ?? app.id,
|
|
status: 'downloading',
|
|
progress: 2,
|
|
message: 'Queued…',
|
|
attempt: 0,
|
|
})
|
|
}
|
|
|
|
function failInstall(app: MarketplaceApp, err: unknown) {
|
|
const message = "Failed: " + (err instanceof Error ? err.message : String(err))
|
|
serverStore.setInstallProgress(app.id, {
|
|
id: app.id,
|
|
title: app.title ?? app.id,
|
|
status: 'error',
|
|
progress: 0,
|
|
message,
|
|
attempt: 0,
|
|
})
|
|
trackTimeout(() => { serverStore.clearInstallProgress(app.id) }, 5000)
|
|
}
|
|
|
|
async function installApp(app: MarketplaceApp, versionOverride?: string) {
|
|
if (installingApps.has(app.id) || isInstalled(app.id)) return
|
|
queueInstall(app)
|
|
toast.info("Installing " + (app.title ?? app.id) + " - check My Apps")
|
|
router.push('/dashboard/apps').catch(() => {})
|
|
try {
|
|
const installUrl = app.url || app.manifestUrl || app.s9pkUrl
|
|
await rpcClient.call({ method: 'package.install', params: { id: app.id, url: installUrl, version: versionOverride || app.version }, timeout: 600000 })
|
|
} catch (err) {
|
|
if (import.meta.env.DEV) console.error('Installation failed:', err)
|
|
failInstall(app, err)
|
|
}
|
|
}
|
|
|
|
async function installCommunityApp(app: MarketplaceApp, versionOverride?: string) {
|
|
if (installingApps.has(app.id) || isInstalled(app.id) || !app.dockerImage) return
|
|
queueInstall(app)
|
|
toast.info("Installing " + (app.title ?? app.id) + " - check My Apps")
|
|
router.push('/dashboard/apps').catch(() => {})
|
|
try {
|
|
const installParams: Record<string, unknown> = { id: app.id, dockerImage: app.dockerImage, version: versionOverride || app.version }
|
|
if ((app as Record<string, unknown>).containerConfig) {
|
|
installParams.containerConfig = (app as Record<string, unknown>).containerConfig
|
|
}
|
|
await rpcClient.call({ method: 'package.install', params: installParams, timeout: 600000 })
|
|
} catch (err) {
|
|
if (import.meta.env.DEV) console.error('[Discover] Installation failed:', err)
|
|
failInstall(app, err)
|
|
}
|
|
}
|
|
|
|
onMounted(() => {
|
|
discoverAnimationDone = true
|
|
if (communityApps.value.length === 0 && !loadingCommunity.value) {
|
|
loadCommunityMarketplace()
|
|
}
|
|
loadBitcoinPruneStatus()
|
|
})
|
|
|
|
const catalogFeatured = ref<CatalogFeatured | null>(null)
|
|
|
|
async function loadCommunityMarketplace() {
|
|
loadingCommunity.value = true
|
|
communityError.value = ''
|
|
// Try dynamic catalog first, fall back to hardcoded
|
|
const catalog = await fetchAppCatalog()
|
|
if (catalog) {
|
|
communityApps.value = catalog.apps
|
|
catalogFeatured.value = catalog.featured
|
|
if (import.meta.env.DEV) console.log('Loaded app catalog from registry:', catalog.apps.length, 'apps')
|
|
} else {
|
|
communityApps.value = getCuratedAppList()
|
|
if (import.meta.env.DEV) console.log('Using hardcoded app list (catalog.json unavailable)')
|
|
}
|
|
loadingCommunity.value = false
|
|
}
|
|
</script>
|