Require Bitcoin version and pruning selection from the App Store

This commit is contained in:
archipelago
2026-09-29 15:37:05 -04:00
parent d25ed492c9
commit 8d9fad1749
3 changed files with 121 additions and 7 deletions
+43 -6
View File
@@ -137,7 +137,7 @@
:tier-label="getAppTier(app.id)"
:install-blocked-reason="installBlockedReason(app.id)"
@view="viewAppDetails"
@install="app.source === 'local' ? installApp(app) : installCommunityApp(app)"
@install="handleInstall(app)"
@launch="launchInstalledApp"
/>
</div>
@@ -153,7 +153,13 @@
</div>
</div>
<!-- End Scrollable Apps Section -->
<InstallVersionModal
:show="showInstallModal"
:app-id="installModalApp?.id || ''"
:app="installModalApp"
@close="showInstallModal = false; installModalApp = null"
@confirm="onInstallModalConfirm"
/>
</div>
</template>
@@ -175,11 +181,13 @@ import { useCollapsingHeaderTabs } from '@/composables/useCollapsingHeaderTabs'
import { useContainersScanTimeout } from '@/composables/useContainersScanTimeout'
import { useCachedResource } from '@/composables/useCachedResource'
import RefreshIndicator from '@/components/RefreshIndicator.vue'
import InstallVersionModal from '@/components/InstallVersionModal.vue'
import { APP_STORE_CATEGORIES, APP_STORE_SECTIONS } from './appStoreCategories'
import MarketplaceAppCard from './marketplace/MarketplaceAppCard.vue'
import {
type MarketplaceApp,
INSTALLED_ALIASES,
MULTI_VERSION_APP_IDS,
getAppTier,
categorizeCommunityApp,
getCuratedAppList,
@@ -206,6 +214,8 @@ const appStoreSections = computed(() => APP_STORE_SECTIONS)
// Installation state — uses global store so it persists across navigation
const installingApps = server.installingApps
const showInstallModal = ref(false)
const installModalApp = ref<MarketplaceApp | null>(null)
const electrumxArchiveWarning = 'You need a full archival bitcoin node before downloading ElectrumX'
function installToast(app: MarketplaceApp) {
@@ -518,7 +528,34 @@ function failInstall(app: MarketplaceApp, err: unknown) {
trackTimeout(() => { server.clearInstallProgress(app.id) }, 5000)
}
async function installApp(app: MarketplaceApp) {
function handleInstall(app: MarketplaceApp) {
if (installingApps.has(app.id) || isInstalled(app.id)) return
const blocked = installBlockedReason(app.id)
if (blocked) {
toast.error(blocked)
return
}
if (MULTI_VERSION_APP_IDS.has(app.id)) {
installModalApp.value = app
showInstallModal.value = true
return
}
startInstall(app)
}
function startInstall(app: MarketplaceApp, version?: string, prune?: boolean) {
if (app.source === 'local') void installApp(app, version, prune)
else void installCommunityApp(app, version, prune)
}
function onInstallModalConfirm(version: string, prune?: boolean) {
const app = installModalApp.value
showInstallModal.value = false
installModalApp.value = null
if (app) startInstall(app, version, prune)
}
async function installApp(app: MarketplaceApp, versionOverride?: string, prune?: boolean) {
if (installingApps.has(app.id) || isInstalled(app.id)) return
const blocked = installBlockedReason(app.id)
if (blocked) {
@@ -536,7 +573,7 @@ async function installApp(app: MarketplaceApp) {
const installUrl = app.url || app.manifestUrl || app.s9pkUrl
await rpcClient.call({
method: 'package.install',
params: { id: app.id, url: installUrl, version: app.version },
params: { id: app.id, url: installUrl, version: versionOverride || app.version, ...(prune === undefined ? {} : { prune }) },
timeout: 600000,
})
} catch (err) {
@@ -545,7 +582,7 @@ async function installApp(app: MarketplaceApp) {
}
}
async function installCommunityApp(app: MarketplaceApp) {
async function installCommunityApp(app: MarketplaceApp, versionOverride?: string, prune?: boolean) {
if (installingApps.has(app.id) || isInstalled(app.id) || !app.dockerImage) return
const blocked = installBlockedReason(app.id)
if (blocked) {
@@ -558,7 +595,7 @@ async function installCommunityApp(app: MarketplaceApp) {
installToast(app)
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, ...(prune === undefined ? {} : { prune }) }
if (app.containerConfig) installParams.containerConfig = app.containerConfig
await rpcClient.call({
method: 'package.install',
@@ -2,6 +2,9 @@ import { flushPromises, mount } from '@vue/test-utils'
import { createPinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import Marketplace from '../Marketplace.vue'
import { rpcClient } from '@/api/rpc-client'
import InstallVersionModal from '@/components/InstallVersionModal.vue'
import MarketplaceAppCard from '../marketplace/MarketplaceAppCard.vue'
// Mirrors the CloudPeersRefresh.test.ts pattern (in-repo convention for
// mounting a view directly with its heavier deps mocked at the module
@@ -44,22 +47,37 @@ vi.mock('@/composables/useMarketplaceApp', () => ({
}))
vi.mock('@/composables/useToast', () => ({
useToast: () => ({ success: vi.fn(), error: toastErrorMock, info: toastInfoMock }),
useToast: () => ({ success: vi.fn(), error: toastErrorMock, info: toastInfoMock, action: vi.fn() }),
}))
vi.mock('@/api/rpc-client', () => ({
rpcClient: {
call: vi.fn(),
marketplaceDiscover: vi.fn().mockResolvedValue({ apps: [] }),
getPackageVersions: vi.fn(),
},
}))
vi.mock('../discover/curatedApps', () => ({
fetchAppCatalog: vi.fn().mockResolvedValue({
apps: ['bitcoin-core', 'bitcoin-knots'].map(id => ({
id, title: id, version: '29.0', description: 'Bitcoin node',
dockerImage: `registry.example/${id}:29.0`, source: 'community',
})),
}),
}))
describe('Marketplace tracer tab: background refresh failure (D-07)', () => {
beforeEach(() => {
vi.stubGlobal('ResizeObserver', vi.fn(() => ({ observe: vi.fn(), disconnect: vi.fn() })))
routerPushMock.mockClear()
toastErrorMock.mockClear()
toastInfoMock.mockClear()
vi.mocked(rpcClient.call).mockReset()
vi.mocked(rpcClient.getPackageVersions).mockResolvedValue({
supportsVersions: true, default: '29.0', bitcoinPrune: false,
versions: [{ version: '29.0', default: true, deprecated: false, eol: null }],
} as Awaited<ReturnType<typeof rpcClient.getPackageVersions>>)
})
afterEach(() => {
@@ -89,4 +107,38 @@ describe('Marketplace tracer tab: background refresh failure (D-07)', () => {
wrapper.unmount()
})
it.each(['bitcoin-core', 'bitcoin-knots'])('requires the version modal before installing %s and forwards pruning', async (id) => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => ({ blockchain_info: { pruned: false } }) }))
const wrapper = mount(Marketplace, { global: { plugins: [createPinia()], stubs: { Teleport: true } } })
await flushPromises()
const card = wrapper.findAllComponents(MarketplaceAppCard).find(c => c.props('app').id === id)!
expect(card.exists()).toBe(true)
card.vm.$emit('install', card.props('app'))
await flushPromises()
const installs = () => vi.mocked(rpcClient.call).mock.calls.filter(([request]) => request.method === 'package.install')
expect(installs()).toHaveLength(0)
const modal = wrapper.findComponent(InstallVersionModal)
expect(modal.props('show')).toBe(true)
await modal.get('input[type="checkbox"]').setValue(true)
await modal.get('button.glass-button-warning').trigger('click')
await flushPromises()
expect(installs()).toHaveLength(1)
expect(installs()[0][0].params).toMatchObject({ id, version: '29.0', prune: true })
expect(modal.props('show')).toBe(false)
wrapper.unmount()
})
it('cancels Bitcoin selection without sending an installation request', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => ({ blockchain_info: { pruned: false } }) }))
const wrapper = mount(Marketplace, { global: { plugins: [createPinia()], stubs: { Teleport: true } } })
await flushPromises()
const card = wrapper.findAllComponents(MarketplaceAppCard).find(c => c.props('app').id === 'bitcoin-knots')!
card.vm.$emit('install', card.props('app'))
await flushPromises()
wrapper.findComponent(InstallVersionModal).vm.$emit('close')
await flushPromises()
expect(vi.mocked(rpcClient.call).mock.calls.filter(([request]) => request.method === 'package.install')).toHaveLength(0)
wrapper.unmount()
})
})