Merge branch 'bitcoin-version-bulletproof' into mesh-multiversion-integration

This commit is contained in:
archipelago
2026-06-29 06:45:50 -04:00
13 changed files with 478 additions and 76 deletions
+44 -7
View File
@@ -207,6 +207,15 @@
<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>
@@ -228,6 +237,7 @@ 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'
@@ -262,6 +272,10 @@ const appStoreSections = computed(() => APP_STORE_SECTIONS)
// 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 } })
}
@@ -427,19 +441,42 @@ function launchInstalledApp(app: MarketplaceApp) {
appLauncher.openSession(app.id)
}
function handleInstall(app: MarketplaceApp) {
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)
installApp(app, versionOverride)
} else {
installCommunityApp(app)
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)) {
@@ -514,27 +551,27 @@ function failInstall(app: MarketplaceApp, err: unknown) {
trackTimeout(() => { serverStore.clearInstallProgress(app.id) }, 5000)
}
async function installApp(app: MarketplaceApp) {
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: app.version }, timeout: 600000 })
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) {
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: app.version }
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
}
+31 -9
View File
@@ -48,11 +48,9 @@
<select
v-model="selectedVersion"
:disabled="versionBusy"
class="w-full rounded-lg bg-white/[0.06] border border-white/10 text-white px-3 py-2 text-sm focus:outline-none focus:border-blue-400/60"
class="w-full rounded-lg bg-white/[0.06] border border-white/10 text-white pl-3 pr-9 py-2 text-sm focus:outline-none focus:border-blue-400/60"
>
<option v-for="v in versionInfo.versions" :key="v.version" :value="v.version">
{{ $ver(v.version) }}{{ v.default ? ' latest' : '' }}{{ v.deprecated ? ' (deprecated)' : '' }}{{ v.eol ? ` · EOL ${v.eol}` : '' }}
</option>
<option v-for="v in versionInfo.versions" :key="v.version" :value="v.version">{{ versionOptionLabel(v) }}</option>
</select>
</div>
@@ -252,7 +250,8 @@
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import type { AppCredentialsResponse } from '@/types/api'
import { rpcClient, type PackageVersionsResponse } from '../../api/rpc-client'
import { rpcClient, type PackageVersionsResponse, type CatalogVersionInfo } from '../../api/rpc-client'
import { displayVersion } from '@/utils/version'
const { t } = useI18n()
const copiedCredential = ref('')
@@ -334,10 +333,33 @@ const isPinned = computed(() => !!versionInfo.value?.pinnedVersion)
const versionDirty = computed(() => {
const info = versionInfo.value
if (!info) return false
const currentSelection = info.pinnedVersion || info.default || info.installedVersion || ''
return selectedVersion.value !== currentSelection || autoUpdate.value !== info.autoUpdate
return selectedVersion.value !== pickSelection(info) || autoUpdate.value !== info.autoUpdate
})
// Option label: the floating "latest" entry reads as a sentence (no "v"
// prefix); every concrete version is normalized via $ver + status suffixes.
function versionOptionLabel(v: CatalogVersionInfo): string {
if (v.version === 'latest') return t('appDetails.alwaysUseLatestVersion')
let label = displayVersion(v.version)
if (v.deprecated) label += ' (deprecated)'
if (v.eol) label += ` · EOL ${v.eol}`
return label
}
// Resolve the dropdown's current value and GUARANTEE it matches a real option,
// otherwise the native <select> renders blank. Prefer the pin, then the catalog
// default, then the running version — but fall back to the default/first option
// when none of those is actually in the list (e.g. a stale installedVersion the
// catalog no longer carries, which is what left the control blank).
function pickSelection(info: PackageVersionsResponse): string {
const options = info.versions.map((v) => v.version)
const preferred = info.pinnedVersion || info.default || info.installedVersion || ''
if (preferred && options.includes(preferred)) return preferred
return info.default && options.includes(info.default)
? info.default
: info.versions[0]?.version || ''
}
async function loadVersions(appId: string) {
versionInfo.value = null
versionError.value = ''
@@ -346,7 +368,7 @@ async function loadVersions(appId: string) {
const info = await rpcClient.getPackageVersions(appId)
if (!info.supportsVersions || !info.versions.length) return
versionInfo.value = info
selectedVersion.value = info.pinnedVersion || info.default || info.installedVersion || info.versions[0]?.version || ''
selectedVersion.value = pickSelection(info)
autoUpdate.value = info.autoUpdate
} catch (err) {
if (import.meta.env.DEV) console.warn('[AppSidebar] getPackageVersions failed:', err)
@@ -381,7 +403,7 @@ function cancelDowngrade() {
downgradeWarning.value = ''
// Reset the dropdown to the current selection.
const info = versionInfo.value
if (info) selectedVersion.value = info.pinnedVersion || info.default || info.installedVersion || ''
if (info) selectedVersion.value = pickSelection(info)
}
watch(