archy/neode-ui/src/views/MarketplaceAppDetails.vue
archipelago 2cffa79d9d feat(demo): app launch UIs, "No demo" gating, onboarding skip, 12 nodes
App launching (DEMO):
- resolveAppUrl routes every app to its demo target: mock UIs for Bitcoin Core,
  ElectrumX, Fedimint (served by the backend), IndeeHub → iframe indee.tx1138.com,
  Mempool → mempool.space/testnet (new tab); all others → a generic "Demo preview"
  notice page.
- Non-demoable apps show a disabled "No demo" install button (marketplace details,
  app grid, featured apps).

Onboarding:
- Demo treats the visitor as fully set up so the onboarding WIZARD (seed/identity)
  is never forced; the welcome intro still replays per day. Intro CTA goes straight
  to login; wizard entry points + login restart-onboarding link hidden in demo.

Network:
- federation.list-nodes now returns 12 trusted/federated nodes (9 trusted, 3
  observer); transport.peers already at 5.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 10:26:35 -04:00

650 lines
30 KiB
Vue

<template>
<div class="app-details-container pb-16 md:pb-16">
<BackButton :label="backButtonLabel" desktop-margin="mb-6" @click="goBack" />
<Transition name="content-fade" mode="out-in">
<!-- Loading State -->
<div v-if="loading" key="loading" class="glass-card p-12 text-center">
<svg class="animate-spin h-12 w-12 text-blue-400 mx-auto mb-4" 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>
<p class="text-white/70">{{ t('marketplaceDetails.loadingDetails') }}</p>
</div>
<!-- App Details -->
<div v-else-if="app" key="content">
<!-- Compact Hero Section -->
<div class="glass-card p-6 mb-6">
<!-- Desktop: Single Row Layout -->
<div class="hidden md:flex items-center gap-6">
<!-- App Icon -->
<img
v-if="app.icon"
:src="app.icon"
:alt="app.title"
class="w-20 h-20 rounded-xl shadow-xl flex-shrink-0"
@error="handleImageError"
/>
<div v-else class="w-20 h-20 rounded-xl bg-white/10 flex items-center justify-center flex-shrink-0">
<svg class="w-10 h-10 text-white/40" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
</svg>
</div>
<!-- App Info (grows to fill space) -->
<div class="flex-1 min-w-0">
<h1 class="text-2xl font-bold text-white mb-1">{{ app.title }}</h1>
<p class="text-white/70 text-sm mb-2">{{ shortDescription }}</p>
<div class="flex items-center gap-2">
<span
v-if="isInstalled"
class="inline-flex items-center px-2.5 py-1 rounded-lg text-xs font-medium bg-green-500/20 text-green-200 border border-green-500/30"
>
<span class="w-1.5 h-1.5 rounded-full bg-green-400 mr-1.5"></span>
{{ t('marketplaceDetails.installed') }}
</span>
<span class="text-white/50 text-xs">{{ app.version ? $ver(app.version) : 'latest' }}</span>
</div>
</div>
<!-- Action Buttons -->
<div class="flex items-center gap-2 flex-shrink-0">
<button
v-if="isInstalled"
@click="goToInstalledApp"
class="glass-button glass-button-sm px-6 py-2.5 rounded-lg text-sm font-semibold flex items-center gap-2"
>
<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="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
{{ t('marketplaceDetails.open') }}
</button>
<button
v-else
@click="installApp"
:disabled="demoNoInstall || installing || (!installBlockedReason && !app.manifestUrl && !app.dockerImage)"
:title="demoNoInstall ? 'Not available in the demo' : (installBlockedReason || undefined)"
class="glass-button glass-button-sm px-6 py-2.5 rounded-lg text-sm font-semibold flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
>
<svg v-if="installing" class="animate-spin h-4 w-4" 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>
<svg v-else 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 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
{{ demoNoInstall ? 'No demo' : installBlockedReason ? 'Bitcoin Pruned' : installing ? t('common.installing') : t('common.install') }}
</button>
</div>
</div>
<!-- Mobile: Two Column Grid Layout -->
<div class="md:hidden">
<!-- Top: Icon + Info -->
<div class="grid grid-cols-[80px_1fr] gap-4 mb-4">
<!-- App Icon -->
<img
v-if="app.icon"
:src="app.icon"
:alt="app.title"
class="w-20 h-20 rounded-xl shadow-xl"
@error="handleImageError"
/>
<div v-else class="w-20 h-20 rounded-xl bg-white/10 flex items-center justify-center">
<svg class="w-10 h-10 text-white/40" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
</svg>
</div>
<!-- App Info -->
<div class="min-w-0">
<h1 class="text-xl font-bold text-white mb-1">{{ app.title }}</h1>
<p class="text-white/70 text-xs mb-2 line-clamp-2">{{ shortDescription }}</p>
<div class="flex flex-wrap items-center gap-2">
<span
v-if="isInstalled"
class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-500/20 text-green-200 border border-green-500/30"
>
<span class="w-1.5 h-1.5 rounded-full bg-green-400 mr-1"></span>
{{ t('marketplaceDetails.installed') }}
</span>
<span class="text-white/50 text-xs">{{ app.version ? $ver(app.version) : 'latest' }}</span>
</div>
</div>
</div>
<!-- Bottom: Action Buttons -->
<div class="grid grid-cols-2 gap-2">
<button
v-if="isInstalled"
@click="goToInstalledApp"
class="glass-button glass-button-sm px-4 py-2.5 rounded-lg text-sm font-semibold flex items-center justify-center gap-2"
>
<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="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
{{ t('marketplaceDetails.open') }}
</button>
<button
v-else
@click="installApp"
:disabled="demoNoInstall || installing || (!installBlockedReason && !app.manifestUrl && !app.dockerImage)"
:title="demoNoInstall ? 'Not available in the demo' : (installBlockedReason || undefined)"
class="glass-button glass-button-sm px-4 py-2.5 rounded-lg text-sm font-semibold flex items-center justify-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed col-span-2"
>
<svg v-if="installing" class="animate-spin h-4 w-4" 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>
<svg v-else 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 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
{{ demoNoInstall ? 'No demo' : installBlockedReason ? 'Bitcoin Pruned' : installing ? t('common.installing') : t('common.install') }}
</button>
</div>
<!-- Installation Error Banner (Mobile) -->
<div v-if="installError" class="mt-4 p-3 bg-red-500/20 border border-red-500/40 rounded-lg">
<div class="flex items-start gap-2">
<svg class="w-4 h-4 text-red-400 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div class="flex-1">
<p class="text-red-200 font-medium text-sm">{{ t('marketplaceDetails.installFailed') }}</p>
<p class="text-red-300 text-xs mt-1">{{ installError }}</p>
</div>
</div>
</div>
</div>
<!-- Installation Error Banner (Desktop) -->
<div v-if="installError" class="hidden md:block mt-4 p-4 bg-red-500/20 border border-red-500/40 rounded-lg">
<div class="flex items-start gap-3">
<svg class="w-5 h-5 text-red-400 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div class="flex-1">
<p class="text-red-200 font-medium">{{ t('marketplaceDetails.installFailed') }}</p>
<p class="text-red-300 text-sm mt-1">{{ installError }}</p>
</div>
</div>
</div>
<div v-if="installBlockedReason" class="hidden md:block mt-4 p-4 bg-yellow-500/15 border border-yellow-500/30 rounded-lg">
<p class="text-yellow-100 font-medium">Bitcoin is in pruned mode</p>
<p class="text-yellow-200/80 text-sm mt-1">{{ installBlockedReason }}</p>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<!-- Main Content -->
<div class="lg:col-span-2 space-y-6">
<!-- Screenshots Gallery -->
<div v-if="screenshots.length > 0" class="glass-card p-6">
<h2 class="text-2xl font-bold text-white mb-4">{{ t('marketplaceDetails.screenshots') }}</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<img
v-for="screenshot in screenshots"
:key="screenshot.src"
:src="screenshot.src"
:alt="screenshot.alt"
class="aspect-video w-full rounded-xl border border-white/10 object-cover"
loading="lazy"
/>
</div>
</div>
<!-- Description -->
<div class="glass-card p-6">
<h2 class="text-2xl font-bold text-white mb-4">{{ t('marketplaceDetails.about', { name: app.title }) }}</h2>
<p class="text-white/80 leading-relaxed whitespace-pre-line">
{{ longDescription }}
</p>
</div>
<!-- Features -->
<div class="glass-card p-6">
<h2 class="text-2xl font-bold text-white mb-4">{{ t('marketplaceDetails.features') }}</h2>
<ul class="space-y-3">
<li
v-for="(feature, index) in features"
:key="index"
class="flex items-start gap-3 text-white/80"
>
<svg class="w-6 h-6 text-green-400 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span>{{ feature }}</span>
</li>
</ul>
</div>
</div>
<!-- Sidebar -->
<div class="space-y-6">
<!-- App Info Card -->
<div class="glass-card p-6">
<h3 class="text-lg font-bold text-white mb-4">{{ t('marketplaceDetails.information') }}</h3>
<div class="space-y-3">
<div class="flex items-center justify-between py-2 border-b border-white/10">
<span class="text-white/60 text-sm">{{ t('common.version') }}</span>
<span class="text-white font-medium">{{ app.version || 'latest' }}</span>
</div>
<div v-if="app.author" class="flex items-center justify-between py-2 border-b border-white/10">
<span class="text-white/60 text-sm">{{ t('common.developer') }}</span>
<span class="text-white font-medium">{{ app.author }}</span>
</div>
<div class="flex items-center justify-between py-2 border-b border-white/10">
<span class="text-white/60 text-sm">{{ t('common.status') }}</span>
<span class="text-white font-medium">{{ isInstalled ? t('marketplaceDetails.installed') : t('marketplaceDetails.notInstalled') }}</span>
</div>
<div class="flex items-center justify-between py-2 border-b border-white/10">
<span class="text-white/60 text-sm">{{ t('common.category') }}</span>
<span class="text-white font-medium capitalize">{{ app.category || 'App' }}</span>
</div>
<div v-if="app.manifestUrl" class="flex items-center justify-between py-2">
<span class="text-white/60 text-sm">Package</span>
<span class="text-white font-medium text-xs">.s9pk</span>
</div>
</div>
</div>
<!-- Requirements Card -->
<div class="glass-card p-6">
<h3 class="text-lg font-bold text-white mb-4">{{ t('marketplaceDetails.requirements') }}</h3>
<div class="space-y-3">
<!-- App Dependencies -->
<div v-if="dependencies.length > 0" class="space-y-2 mb-4">
<div
v-for="dep in dependencies"
:key="dep.id"
class="flex items-center gap-3 py-2 border-b border-white/10"
>
<!-- Status indicator -->
<svg v-if="dep.status === 'running'" class="w-5 h-5 text-green-400 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<svg v-else-if="dep.status === 'stopped'" class="w-5 h-5 text-yellow-400 flex-shrink-0" 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>
<svg v-else class="w-5 h-5 text-red-400 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div class="flex-1">
<p class="text-white/80 font-medium text-sm">{{ dep.title }}</p>
<p class="text-white/50 text-xs">
{{ dep.status === 'running' ? t('marketplaceDetails.depRunning') : dep.status === 'stopped' ? t('marketplaceDetails.depStopped') : t('marketplaceDetails.depNotInstalled') }}
</p>
</div>
</div>
<!-- Install missing dependencies button -->
<button
v-if="dependencies.some(d => d.status === 'missing')"
@click="installDependencies"
:disabled="installingDeps"
class="glass-button w-full mt-3 px-4 py-2 rounded-lg text-sm font-medium flex items-center justify-center gap-2"
>
<svg v-if="installingDeps" class="animate-spin h-4 w-4" 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>
{{ installingDeps ? t('common.installing') : t('marketplaceDetails.installRequirements') }}
</button>
</div>
<div v-else class="py-2 border-b border-white/10">
<p class="text-white/60 text-sm">{{ t('marketplaceDetails.noRequirements') }}</p>
</div>
<div class="flex items-start gap-3">
<svg class="w-5 h-5 text-blue-400 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z" />
</svg>
<div class="flex-1">
<p class="text-white/80 font-medium">{{ t('appDetails.ram') }}</p>
<p class="text-white/60 text-sm">{{ t('appDetails.ramDesc') }}</p>
</div>
</div>
<div class="flex items-start gap-3">
<svg class="w-5 h-5 text-purple-400 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4" />
</svg>
<div class="flex-1">
<p class="text-white/80 font-medium">{{ t('appDetails.storage') }}</p>
<p class="text-white/60 text-sm">{{ t('appDetails.storageDesc') }}</p>
</div>
</div>
</div>
</div>
<!-- Links Card (no GitHub - repo link removed per product) -->
<div v-if="app.manifestUrl" class="glass-card p-6">
<h3 class="text-lg font-bold text-white mb-4">{{ t('marketplaceDetails.links') }}</h3>
<div class="space-y-2">
<a
:href="app.manifestUrl"
target="_blank"
rel="noopener noreferrer"
class="flex items-center gap-2 text-blue-400 hover:text-blue-300 transition-colors"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
{{ t('marketplaceDetails.downloadPackage') }}
</a>
</div>
</div>
</div>
</div>
</div>
<!-- App Not Found -->
<div v-else key="not-found" class="glass-card p-12 text-center">
<svg class="w-24 h-24 text-white/20 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.172 16.172a4 4 0 015.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<h3 class="text-2xl font-semibold text-white mb-2">{{ t('marketplaceDetails.notFoundTitle') }}</h3>
<p class="text-white/70">{{ t('marketplaceDetails.notFoundMessage') }}</p>
</div>
</Transition>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
import { IS_DEMO, isDemoApp } from '@/composables/useDemoIntro'
import { useRouter, useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '../stores/app'
import { rpcClient } from '../api/rpc-client'
import { useMarketplaceApp, type MarketplaceAppInfo } from '../composables/useMarketplaceApp'
import { useAppLauncherStore } from '../stores/appLauncher'
import BackButton from '@/components/BackButton.vue'
import { useToast } from '../composables/useToast'
import { handleImageError } from './apps/appsConfig'
const { t } = useI18n()
const toast = useToast()
const router = useRouter()
const route = useRoute()
const store = useAppStore()
const { getCurrentApp } = useMarketplaceApp()
const app = ref<MarketplaceAppInfo | null>(null)
const installing = ref(false)
const installingDeps = ref(false)
const installError = ref<string | null>(null)
const loading = ref(true)
const bitcoinPruned = ref(false)
const backButtonLabel = computed(() => route.query.from === 'home' ? t('marketplaceDetails.backToHome') : t('marketplaceDetails.backToStore'))
const electrumxArchiveWarning = 'You need a full archival bitcoin node before downloading ElectrumX'
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]
})
// Extract descriptions with safety checks
const shortDescription = computed(() => {
try {
if (!app.value) return ''
const desc = app.value.description
if (typeof desc === 'object' && desc) {
return desc.short || desc.long || ''
}
return desc || ''
} catch (e) {
if (import.meta.env.DEV) console.error('[MarketplaceAppDetails] Error in shortDescription:', e)
return ''
}
})
const longDescription = computed(() => {
try {
if (!app.value) return ''
const desc = app.value.description
if (typeof desc === 'object' && desc) {
return desc.long || desc.short || ''
}
return desc || ''
} catch (e) {
if (import.meta.env.DEV) console.error('[MarketplaceAppDetails] Error in longDescription:', e)
return ''
}
})
const screenshots = computed(() => normalizeScreenshots(app.value?.screenshots))
function normalizeScreenshots(items: MarketplaceAppInfo['screenshots'] | undefined) {
if (!Array.isArray(items)) return []
return items
.map((item, index) => {
if (typeof item === 'string') {
const src = item.trim()
return src ? { src, alt: `${app.value?.title || 'App'} screenshot ${index + 1}` } : null
}
const src = item.src?.trim()
if (!src) return null
return {
src,
alt: item.alt?.trim() || `${app.value?.title || 'App'} screenshot ${index + 1}`,
}
})
.filter((item): item is { src: string; alt: string } => item !== null)
}
// Placeholder features
const features = computed(() => {
return [
'Self-hosted and privacy-focused',
'Easy installation and updates',
'Automatic backups',
'Secure by default',
'Open source'
]
})
/** App dependency definitions */
const R = '146.59.87.168:3000/lfg2025'
const APP_DEPENDENCIES: Record<string, { id: string; title: string; dockerImage: string }[]> = {
'electrumx': [{ id: 'bitcoin-knots', title: 'Bitcoin Knots', dockerImage: `${R}/bitcoin-knots:latest` }],
'lnd': [{ id: 'bitcoin-knots', title: 'Bitcoin Knots', dockerImage: `${R}/bitcoin-knots:latest` }],
'btcpay-server': [{ id: 'bitcoin-knots', title: 'Bitcoin Knots', dockerImage: `${R}/bitcoin-knots:latest` }],
'mempool': [
{ id: 'bitcoin-knots', title: 'Bitcoin Knots', dockerImage: `${R}/bitcoin-knots:latest` },
{ id: 'electrumx', title: 'ElectrumX', dockerImage: `${R}/electrumx:v1.18.0` },
],
'fedimint': [{ id: 'bitcoin-knots', title: 'Bitcoin Knots', dockerImage: `${R}/bitcoin-knots:latest` }],
}
/** Check dependency status against installed packages */
const dependencies = computed(() => {
if (!app.value) return []
const deps = APP_DEPENDENCIES[app.value.id]
if (!deps) return []
return deps.map(dep => {
const pkg = store.packages[dep.id]
let status: 'running' | 'stopped' | 'missing' = 'missing'
if (pkg) {
status = pkg.state === 'running' ? 'running' : 'stopped'
}
return { ...dep, status }
})
})
const installBlockedReason = computed(() => {
const id = app.value?.id
if (!bitcoinPruned.value || !id) return ''
if (id !== 'electrumx' && id !== 'electrs' && id !== 'mempool-electrs') return ''
return electrumxArchiveWarning
})
// Demo: only demoable apps can be installed; the rest show "No demo".
const demoNoInstall = computed(() => IS_DEMO && !!app.value?.id && !isDemoApp(app.value.id))
let pendingRedirect: ReturnType<typeof setTimeout> | null = null
onMounted(() => {
if (import.meta.env.DEV) console.log('[MarketplaceAppDetails] Loading app ID:', appId.value)
try {
const loadedApp = getCurrentApp()
if (loadedApp && loadedApp.id === appId.value) {
app.value = loadedApp
loading.value = false
} else {
loading.value = false
pendingRedirect = setTimeout(() => {
router.push('/dashboard/marketplace').catch(() => {})
}, 500)
}
} catch (e) {
if (import.meta.env.DEV) console.error('[MarketplaceAppDetails] Error loading app data:', e)
loading.value = false
pendingRedirect = setTimeout(() => {
router.push('/dashboard/marketplace').catch(() => {})
}, 500)
}
loadBitcoinPruneStatus()
})
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('[MarketplaceAppDetails] Bitcoin prune status unavailable:', e)
}
}
onBeforeUnmount(() => {
if (pendingRedirect) { clearTimeout(pendingRedirect); pendingRedirect = null }
})
function goBack() {
if (route.query.from === 'home') {
router.push('/dashboard').catch(() => {})
} else if (route.query.from === 'discover') {
router.push('/dashboard/discover').catch(() => {})
} else {
router.push('/dashboard/marketplace').catch(() => {})
}
}
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' }
}).catch(() => {})
}
async function installDependencies() {
if (installingDeps.value) return
const missingDeps = dependencies.value.filter(d => d.status === 'missing')
if (!missingDeps.length) return
if (bitcoinPruned.value && missingDeps.some(d => d.id === 'electrumx' || d.id === 'electrs' || d.id === 'mempool-electrs')) {
installError.value = electrumxArchiveWarning
toast.error(electrumxArchiveWarning)
return
}
installingDeps.value = true
installError.value = null
try {
// Install dependencies sequentially (order matters: bitcoin before electrumx)
for (const dep of missingDeps) {
await rpcClient.call({
method: 'package.install',
params: {
id: dep.id,
dockerImage: dep.dockerImage,
},
timeout: 15000,
})
// Wait for package to register before installing next
await new Promise(resolve => setTimeout(resolve, 2000))
}
} catch (err: unknown) {
installError.value = err instanceof Error ? err.message : t('marketplaceDetails.installFailed')
if (import.meta.env.DEV) console.error('[MarketplaceAppDetails] Failed to install dependencies:', err)
} finally {
installingDeps.value = false
}
}
async function installApp() {
if (installing.value || !app.value) return
if (installBlockedReason.value) {
installError.value = installBlockedReason.value
toast.error(installBlockedReason.value)
return
}
if (!app.value.manifestUrl && !app.value.dockerImage) {
if (import.meta.env.DEV) console.warn('[MarketplaceAppDetails] Cannot install - no manifestUrl or dockerImage:', app.value)
return
}
installing.value = true
installError.value = null
try {
if (app.value.dockerImage) {
// Docker-based app installation
const installParams: Record<string, unknown> = {
id: app.value.id,
dockerImage: app.value.dockerImage,
version: app.value.version,
}
if (app.value.containerConfig) installParams.containerConfig = app.value.containerConfig
await rpcClient.call({
method: 'package.install',
params: installParams,
timeout: 600000,
})
} else {
// Package-based installation
const installUrl = app.value.url || app.value.manifestUrl
await rpcClient.call({
method: 'package.install',
params: {
id: app.value.id,
url: installUrl,
version: app.value.version,
},
timeout: 600000,
})
}
// Wait a moment for the package to be registered
await new Promise(resolve => setTimeout(resolve, 1000))
router.push(`/dashboard/apps/${appId.value}`).catch(() => {})
} catch (err: unknown) {
installError.value = err instanceof Error ? err.message : t('marketplaceDetails.installFailed')
if (import.meta.env.DEV) console.error('[MarketplaceAppDetails] Failed to install app:', err)
} finally {
installing.value = false
}
}
</script>