fix(bitcoin): bulletproof multi-version switching (Knots & Core)
Three stacked bugs made "switch version" silently fail / crash-loop, and
the data-access mismatch corrupted a node's index during recovery attempts.
Backend renderer:
- sync_quadlet_unit ignored the per-app pinned version and re-rendered the
quadlet with the manifest's :latest every reconcile tick, reverting any
switch. Factor the install-time catalog/pin resolution into a shared
resolve_catalog_image() and call it in BOTH install_fresh and
sync_quadlet_unit.
- The renderer folded manifest `entrypoint: ["sh","-lc"]` into Exec=, which
only worked when the image entrypoint was a passthrough shell wrapper. The
versioned images use ENTRYPOINT ["bitcoind"], so Exec=sh -lc ... became
`bitcoind sh -lc ...` and crash-looped. Emit a real Entrypoint= override;
exec_changed now also compares Entrypoint=.
Images:
- Build all bitcoin images (Core + Knots, every version) as container-root
(USER removed) like the legacy :latest image. Chain data is owned by the
data_uid (container uid 102); root reads it via CAP_DAC_OVERRIDE (granted in
the manifest). A non-root USER (the previous uid 1000) can't read existing
chain data → "Error initializing block database". Still fully rootless:
container-root maps to the unprivileged host service user.
Catalog:
- bitcoin-knots versions[]: 29.3.knots20260508/20260507/20260210 +
29.2.knots20251110, "latest" tracking newest.
- bitcoin-core versions[]: add 29.2 + a "latest" entry. All images rebuilt
root and published to the mirror.
Frontend:
- AppSidebar version dropdown: rename the latest option to "Always use the
latest version" (no v prefix), fix right padding, and guarantee the current
selection matches a real option (was rendering blank).
- New InstallVersionModal: full-screen version chooser shown from the App
Store / Discover install button for multi-version apps (Bitcoin Knots/Core),
app icon + "Install <name>", latest pre-selected.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 05:46:04 -04:00
|
|
|
<template>
|
|
|
|
|
<BaseModal :show="show" title="" max-width="max-w-lg" @close="emit('close')">
|
|
|
|
|
<!-- Header: app icon + "Install Bitcoin Knots/Core" -->
|
|
|
|
|
<div class="flex items-center gap-4 mb-5 -mt-2">
|
|
|
|
|
<img
|
|
|
|
|
v-if="app?.icon"
|
|
|
|
|
:src="app.icon"
|
|
|
|
|
:alt="app?.title || ''"
|
|
|
|
|
class="w-14 h-14 rounded-xl shadow-lg shrink-0"
|
|
|
|
|
/>
|
|
|
|
|
<div v-else class="w-14 h-14 rounded-xl bg-white/10 flex items-center justify-center shrink-0">
|
|
|
|
|
<svg class="w-7 h-7 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>
|
|
|
|
|
<h3 class="text-xl font-semibold text-white leading-snug">
|
|
|
|
|
{{ t('marketplace.installModalTitle', { name: app?.title || appId }) }}
|
|
|
|
|
</h3>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div v-if="loading" class="py-6 text-center text-white/60 text-sm">{{ t('common.loading') }}</div>
|
|
|
|
|
|
|
|
|
|
<div v-else class="space-y-2">
|
|
|
|
|
<label class="block text-white/60 text-sm">{{ t('appDetails.selectVersion') }}</label>
|
|
|
|
|
<select
|
|
|
|
|
v-model="selected"
|
|
|
|
|
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 versions" :key="v.version" :value="v.version">{{ optionLabel(v) }}</option>
|
|
|
|
|
</select>
|
|
|
|
|
<p class="text-white/40 text-xs">{{ t('marketplace.installModalHint') }}</p>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<template #footer>
|
|
|
|
|
<div class="flex gap-2 mt-6">
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
2026-07-14 06:36:51 -04:00
|
|
|
class="flex-1 glass-button glass-button-warning rounded-lg disabled:opacity-50 text-sm font-semibold py-2.5"
|
fix(bitcoin): bulletproof multi-version switching (Knots & Core)
Three stacked bugs made "switch version" silently fail / crash-loop, and
the data-access mismatch corrupted a node's index during recovery attempts.
Backend renderer:
- sync_quadlet_unit ignored the per-app pinned version and re-rendered the
quadlet with the manifest's :latest every reconcile tick, reverting any
switch. Factor the install-time catalog/pin resolution into a shared
resolve_catalog_image() and call it in BOTH install_fresh and
sync_quadlet_unit.
- The renderer folded manifest `entrypoint: ["sh","-lc"]` into Exec=, which
only worked when the image entrypoint was a passthrough shell wrapper. The
versioned images use ENTRYPOINT ["bitcoind"], so Exec=sh -lc ... became
`bitcoind sh -lc ...` and crash-looped. Emit a real Entrypoint= override;
exec_changed now also compares Entrypoint=.
Images:
- Build all bitcoin images (Core + Knots, every version) as container-root
(USER removed) like the legacy :latest image. Chain data is owned by the
data_uid (container uid 102); root reads it via CAP_DAC_OVERRIDE (granted in
the manifest). A non-root USER (the previous uid 1000) can't read existing
chain data → "Error initializing block database". Still fully rootless:
container-root maps to the unprivileged host service user.
Catalog:
- bitcoin-knots versions[]: 29.3.knots20260508/20260507/20260210 +
29.2.knots20251110, "latest" tracking newest.
- bitcoin-core versions[]: add 29.2 + a "latest" entry. All images rebuilt
root and published to the mirror.
Frontend:
- AppSidebar version dropdown: rename the latest option to "Always use the
latest version" (no v prefix), fix right padding, and guarantee the current
selection matches a real option (was rendering blank).
- New InstallVersionModal: full-screen version chooser shown from the App
Store / Discover install button for multi-version apps (Bitcoin Knots/Core),
app icon + "Install <name>", latest pre-selected.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 05:46:04 -04:00
|
|
|
:disabled="loading || !selected"
|
|
|
|
|
@click="confirm"
|
|
|
|
|
>
|
|
|
|
|
{{ t('common.install') }}
|
|
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
class="rounded-lg bg-white/10 hover:bg-white/20 text-white text-sm font-medium px-5 py-2.5 transition-colors"
|
|
|
|
|
@click="emit('close')"
|
|
|
|
|
>
|
|
|
|
|
{{ t('common.cancel') }}
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</template>
|
|
|
|
|
</BaseModal>
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
<script setup lang="ts">
|
|
|
|
|
import { ref, watch } from 'vue'
|
|
|
|
|
import { useI18n } from 'vue-i18n'
|
|
|
|
|
import BaseModal from './BaseModal.vue'
|
|
|
|
|
import { rpcClient, type CatalogVersionInfo } from '../api/rpc-client'
|
|
|
|
|
import { displayVersion } from '@/utils/version'
|
|
|
|
|
|
|
|
|
|
const props = defineProps<{
|
|
|
|
|
show: boolean
|
|
|
|
|
appId: string
|
|
|
|
|
app: { id: string; title?: string; icon?: string } | null
|
|
|
|
|
}>()
|
|
|
|
|
|
|
|
|
|
const emit = defineEmits<{
|
|
|
|
|
close: []
|
|
|
|
|
// Emits the version string the runner chose (e.g. "latest" or "29.3.knots20260508").
|
|
|
|
|
confirm: [version: string]
|
|
|
|
|
}>()
|
|
|
|
|
|
|
|
|
|
const { t } = useI18n()
|
|
|
|
|
const loading = ref(false)
|
|
|
|
|
const versions = ref<CatalogVersionInfo[]>([])
|
|
|
|
|
const selected = ref('')
|
|
|
|
|
|
|
|
|
|
// Latest reads as a sentence (no "v" prefix); concrete versions are normalized.
|
|
|
|
|
function optionLabel(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
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function load() {
|
|
|
|
|
loading.value = true
|
|
|
|
|
versions.value = []
|
|
|
|
|
selected.value = ''
|
|
|
|
|
try {
|
|
|
|
|
const info = await rpcClient.getPackageVersions(props.appId)
|
|
|
|
|
// catalog_versions() returns the list default(=latest)-first, so versions[0]
|
|
|
|
|
// is the latest — pre-select it.
|
|
|
|
|
versions.value = info.versions || []
|
|
|
|
|
selected.value = info.default || versions.value.find((v) => v.default)?.version || versions.value[0]?.version || 'latest'
|
|
|
|
|
} catch (err) {
|
|
|
|
|
if (import.meta.env.DEV) console.warn('[InstallVersionModal] getPackageVersions failed:', err)
|
|
|
|
|
// Fall back to the floating "latest" so the install can still proceed.
|
|
|
|
|
selected.value = 'latest'
|
|
|
|
|
} finally {
|
|
|
|
|
loading.value = false
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function confirm() {
|
|
|
|
|
if (!selected.value) return
|
|
|
|
|
emit('confirm', selected.value)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
watch(
|
|
|
|
|
() => props.show,
|
|
|
|
|
(open) => {
|
|
|
|
|
if (open) void load()
|
|
|
|
|
},
|
|
|
|
|
{ immediate: true },
|
|
|
|
|
)
|
|
|
|
|
</script>
|