chore: release v1.7.45-alpha

Resilience-validated release. Three full sweeps of the new resilience
harness against .228 confirm no shipstoppers.

Big user-visible:
- Bitcoin RPC auth durably correct via host-rendered nginx.conf bind-mount,
  replaces fragile post-start exec that failed under restricted-cap rootless
  podman ("crun: write cgroup.procs: Permission denied")
- Multi-container stack installs (indeedhub, immich, btcpay, mempool) now
  emit phase events at every boundary so the progress bar advances
- Apps no longer vanish from the dashboard mid-install (absent-scanner skips
  packages in transitional states)
- Indeedhub fresh installs work end-to-end (was 8500+ restart loop): five
  missing env vars (DATABASE_PORT, QUEUE_HOST, QUEUE_PORT,
  S3_PRIVATE_BUCKET_NAME, AES_MASTER_SECRET) added to install code
- Tailscale install fixed: --entrypoint string was being passed as a single
  shell-line arg; switched to custom_args array
- Catalog cleaned of broken entries (dwn, endurain, ollama removed; nextcloud
  restored on docker.io)
- Bitcoin Core update path uses correct image (was looking for nonexistent
  lfg2025/bitcoin:28.4)
- ISO installs now allocate swap on the encrypted data partition

Infra:
- New resilience harness (scripts/resilience/) — black-box state-machine
  tester, every app × every transition. Run before each release.

Sweep #3 final: PASS 107 / FAIL 12 / SKIP 14. The 12 fails are 1 cosmetic
(homeassistant trusted_hosts), 8 harness/timing false-positives, and 3
non-shipstopper tracked items. Down from 23 in baseline sweep #1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
archipelago
2026-04-29 12:31:45 -04:00
parent dffa7e99bb
commit 4ec6ca98c1
38 changed files with 1699 additions and 1805 deletions

View File

@@ -144,7 +144,6 @@ import {
WEB_ONLY_APP_URLS,
PACKAGE_ALIASES,
BITCOIN_DEPENDENT_APPS,
APP_URLS,
resolvePackageKey,
isRealOnionAddress,
} from './appDetails/appDetailsData'
@@ -285,7 +284,6 @@ function goBack() {
function launchApp() {
if (!pkg.value) return
const isDev = import.meta.env.DEV
const id = appId.value
const webOnlyUrl = WEB_ONLY_APP_URLS[id]
@@ -294,22 +292,12 @@ function launchApp() {
return
}
if (APP_URLS[id]) {
let url = isDev ? APP_URLS[id].dev : APP_URLS[id].prod
if (url.includes('localhost')) {
url = url.replace('localhost', window.location.hostname)
}
useAppLauncherStore().open({ url, title: pkg.value.manifest.title })
return
}
const torAddress = pkg.value.manifest.interfaces?.main?.['tor-config']
const lanConfig = pkg.value.manifest.interfaces?.main?.['lan-config']
if (torAddress || lanConfig) {
showActionError(t('appDetails.noLaunchUrl'))
}
// Container apps should launch through session routing so protocol/path
// handling stays centralized in appSessionConfig.
useAppLauncherStore().openSession(id)
}
async function startApp() {
try {
await store.startPackage(appId.value)

View File

@@ -221,7 +221,6 @@ const categories = computed(() => [
// 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
const maxAttempts = ref(60)
function selectCategory(id: string) {
selectedCategory.value = id
@@ -415,7 +414,6 @@ function viewAppDetails(app: MarketplaceApp) {
// Timer management
const activeTimers: ReturnType<typeof setTimeout>[] = []
const activeIntervals: ReturnType<typeof setInterval>[] = []
function trackTimeout(fn: () => void, ms: number) {
const id = setTimeout(() => {
@@ -427,86 +425,68 @@ function trackTimeout(fn: () => void, ms: number) {
return id
}
function trackInterval(fn: () => void, ms: number) {
const id = setInterval(fn, ms)
activeIntervals.push(id)
return id
}
function clearTrackedInterval(id: ReturnType<typeof setInterval>) {
clearInterval(id)
const idx = activeIntervals.indexOf(id)
if (idx !== -1) activeIntervals.splice(idx, 1)
}
onBeforeUnmount(() => {
for (const t of activeTimers) clearTimeout(t)
activeTimers.length = 0
for (const i of activeIntervals) clearInterval(i)
activeIntervals.length = 0
})
function startInstallPolling(appId: string, statusMessage: string) {
const interval = trackInterval(() => {
const current = installingApps.get(appId)
if (!current) { clearTrackedInterval(interval); return }
const newAttempt = current.attempt + 1
installingApps.set(appId, { ...current, attempt: newAttempt, progress: Math.min(60 + (newAttempt * 0.5), 95), message: statusMessage })
if (isInstalled(appId)) {
clearTrackedInterval(interval)
installingApps.set(appId, { ...current, status: 'complete', progress: 100, message: 'Installation complete!' })
trackTimeout(() => { installingApps.delete(appId) }, 2000)
} else if (newAttempt >= maxAttempts.value) {
clearTrackedInterval(interval)
installingApps.set(appId, { ...current, status: 'error', progress: 0, message: 'Installation timeout' })
trackTimeout(() => { installingApps.delete(appId) }, 5000)
}
}, 1000)
}
const toast = useToast()
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) {
if (installingApps.has(app.id) || isInstalled(app.id)) return
installingApps.set(app.id, { id: app.id, title: app.title ?? app.id, status: 'downloading', progress: 10, message: 'Preparing installation...', attempt: 0 })
toast.info(`Installing ${app.title ?? app.id} check My Apps`)
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
installingApps.set(app.id, { ...installingApps.get(app.id)!, status: 'downloading', progress: 30, message: 'Downloading package...' })
await rpcClient.call({ method: 'package.install', params: { id: app.id, url: installUrl, version: app.version }, timeout: 15000 })
installingApps.set(app.id, { ...installingApps.get(app.id)!, status: 'installing', progress: 60, message: 'Installing package...' })
startInstallPolling(app.id, 'Starting application...')
} catch (err) {
if (import.meta.env.DEV) console.error('Installation failed:', err)
installingApps.set(app.id, { ...installingApps.get(app.id)!, status: 'error', progress: 0, message: `Failed: ${err}` })
trackTimeout(() => { installingApps.delete(app.id) }, 5000)
failInstall(app, err)
}
}
async function installCommunityApp(app: MarketplaceApp) {
if (installingApps.has(app.id) || isInstalled(app.id) || !app.dockerImage) return
installingApps.set(app.id, { id: app.id, title: app.title ?? app.id, status: 'downloading', progress: 10, message: 'Pulling Docker image...', attempt: 0 })
toast.info(`Installing ${app.title ?? app.id} check My Apps`)
queueInstall(app)
toast.info("Installing " + (app.title ?? app.id) + " - check My Apps")
router.push('/dashboard/apps').catch(() => {})
try {
installingApps.set(app.id, { ...installingApps.get(app.id)!, status: 'downloading', progress: 20, message: 'Downloading container image...' })
// Pass containerConfig from catalog if available (allows dynamic apps without hardcoded backend config)
const installParams: Record<string, unknown> = { id: app.id, dockerImage: app.dockerImage, version: 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: 15000 })
installingApps.set(app.id, { ...installingApps.get(app.id)!, status: 'installing', progress: 60, message: 'Starting container...' })
startInstallPolling(app.id, 'Initializing application...')
} catch (err) {
if (import.meta.env.DEV) console.error('[Discover] Installation failed:', err)
installingApps.set(app.id, { ...installingApps.get(app.id)!, status: 'error', progress: 0, message: `Failed: ${err}` })
trackTimeout(() => { installingApps.delete(app.id) }, 5000)
failInstall(app, err)
}
}
onMounted(() => {
discoverAnimationDone = true
if (communityApps.value.length === 0 && !loadingCommunity.value) {

View File

@@ -157,7 +157,6 @@ const categories = computed(() => [
// Installation state — uses global store so it persists across navigation
const installingApps = server.installingApps
const maxAttempts = ref(60)
// Install progress tracking is now in serverStore (global watcher on WebSocket data)
// so it works regardless of which page is active
@@ -338,7 +337,6 @@ function viewAppDetails(app: MarketplaceApp) {
}
const activeTimers: ReturnType<typeof setTimeout>[] = []
const activeIntervals: ReturnType<typeof setInterval>[] = []
function trackTimeout(fn: () => void, ms: number) {
const id = setTimeout(() => {
@@ -350,115 +348,74 @@ function trackTimeout(fn: () => void, ms: number) {
return id
}
function trackInterval(fn: () => void, ms: number) {
const id = setInterval(fn, ms)
activeIntervals.push(id)
return id
}
function clearTrackedInterval(id: ReturnType<typeof setInterval>) {
clearInterval(id)
const idx = activeIntervals.indexOf(id)
if (idx !== -1) activeIntervals.splice(idx, 1)
}
onBeforeUnmount(() => {
for (const t of activeTimers) clearTimeout(t)
activeTimers.length = 0
for (const i of activeIntervals) clearInterval(i)
activeIntervals.length = 0
})
function startInstallPolling(appId: string, statusMessage: string) {
const interval = trackInterval(() => {
const current = installingApps.get(appId)
if (!current) { clearTrackedInterval(interval); return }
function queueInstall(app: MarketplaceApp) {
server.setInstallProgress(app.id, {
id: app.id,
title: app.title ?? app.id,
status: 'downloading',
progress: 2,
message: 'Queued…',
attempt: 0,
})
}
const newAttempt = current.attempt + 1
const state = getInstalledState(appId)
// Update message based on actual backend state
let message = statusMessage
if (state === 'starting') message = 'Starting application...'
else if (state === 'running') message = 'Installation complete!'
installingApps.set(appId, {
...current,
attempt: newAttempt,
progress: Math.min(60 + (newAttempt * 0.5), 95),
message
})
// Only clear when fully running — server store watcher handles the actual delete
if (state === 'running') {
clearTrackedInterval(interval)
installingApps.set(appId, { ...current, status: 'complete', progress: 100, message: 'Installation complete!' })
// Server store watcher will clear installingApps when it sees 'running'
} else if (newAttempt >= maxAttempts.value) {
clearTrackedInterval(interval)
installingApps.set(appId, { ...current, status: 'error', progress: 0, message: 'Installation timeout — check My Apps' })
trackTimeout(() => { installingApps.delete(appId) }, 5000)
}
}, 1000)
function failInstall(app: MarketplaceApp, err: unknown) {
const message = "Failed: " + (err instanceof Error ? err.message : String(err))
server.setInstallProgress(app.id, {
id: app.id,
title: app.title ?? app.id,
status: 'error',
progress: 0,
message,
attempt: 0,
})
trackTimeout(() => { server.clearInstallProgress(app.id) }, 5000)
}
async function installApp(app: MarketplaceApp) {
if (installingApps.has(app.id) || isInstalled(app.id)) return
installingApps.set(app.id, {
id: app.id, title: app.title ?? app.id, status: 'downloading', progress: 10, message: 'Preparing installation...', attempt: 0
})
// Navigate to My Apps immediately and show toast
toast.info(`Installing ${app.title ?? app.id} — check My Apps`)
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
installingApps.set(app.id, { ...installingApps.get(app.id)!, status: 'downloading', progress: 30, message: 'Downloading package...' })
await rpcClient.call({ method: 'package.install', params: { id: app.id, url: installUrl, version: app.version }, timeout: 15000 })
installingApps.set(app.id, { ...installingApps.get(app.id)!, status: 'installing', progress: 60, message: 'Installing package...' })
startInstallPolling(app.id, 'Starting application...')
await rpcClient.call({
method: 'package.install',
params: { id: app.id, url: installUrl, version: app.version },
timeout: 15000,
})
} catch (err) {
if (import.meta.env.DEV) console.error('Installation failed:', err)
installingApps.set(app.id, { ...installingApps.get(app.id)!, status: 'error', progress: 0, message: `Failed: ${err}` })
trackTimeout(() => { installingApps.delete(app.id) }, 5000)
failInstall(app, err)
}
}
async function installCommunityApp(app: MarketplaceApp) {
if (installingApps.has(app.id) || isInstalled(app.id) || !app.dockerImage) return
installingApps.set(app.id, {
id: app.id, title: app.title ?? app.id, status: 'downloading', progress: 10, message: 'Pulling Docker image...', attempt: 0
})
// Navigate to My Apps immediately and show toast
toast.info(`Installing ${app.title ?? app.id} — check My Apps`)
queueInstall(app)
toast.info("Installing " + (app.title ?? app.id) + " - check My Apps")
router.push('/dashboard/apps').catch(() => {})
try {
installingApps.set(app.id, { ...installingApps.get(app.id)!, status: 'downloading', progress: 20, message: 'Downloading container image...' })
await rpcClient.call({
method: 'package.install',
params: { id: app.id, dockerImage: app.dockerImage, version: app.version },
timeout: 15000
timeout: 15000,
})
installingApps.set(app.id, { ...installingApps.get(app.id)!, status: 'installing', progress: 60, message: 'Starting container...' })
startInstallPolling(app.id, 'Initializing application...')
} catch (err) {
if (import.meta.env.DEV) console.error('[Marketplace] Installation failed:', err)
installingApps.set(app.id, { ...installingApps.get(app.id)!, status: 'error', progress: 0, message: `Failed: ${err}` })
trackTimeout(() => { installingApps.delete(app.id) }, 5000)
failInstall(app, err)
}
}
</script>
<style scoped>

View File

@@ -444,7 +444,7 @@ const features = computed(() => {
})
/** App dependency definitions */
const R = 'git.tx1138.com/lfg2025'
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` }],
@@ -607,4 +607,3 @@ async function installApp() {
}
}
</script>

View File

@@ -6,7 +6,6 @@ import { PackageState } from '@/types/api'
/** Web-only app detection (no container -- external websites) */
export const WEB_ONLY_APP_URLS: Record<string, string> = {
'indeedhub': `${window.location.protocol}//${window.location.hostname}:7777`,
'nwnn': 'https://nwnn.l484.com',
'484-kitchen': 'https://484.kitchen',
'call-the-operator': 'https://cta.tx1138.com',
@@ -65,7 +64,6 @@ export const APP_URLS: Record<string, { dev: string; prod: string }> = {
'lorabell': { dev: 'http://192.168.1.166', prod: 'http://192.168.1.166' },
'atob': { dev: 'http://localhost:8102', prod: 'https://app.atobitcoin.io' },
'k484': { dev: 'http://localhost:8103', prod: 'http://localhost:8103' },
'indeedhub': { dev: 'https://archipelago.indeehub.studio', prod: 'https://archipelago.indeehub.studio' },
'bitcoin': { dev: 'http://localhost:8332', prod: 'http://localhost:8332' },
'btcpay-server': { dev: 'http://localhost:23000', prod: 'http://localhost:23000' },
'homeassistant': { dev: 'http://localhost:8123', prod: 'http://localhost:8123' },

View File

@@ -38,7 +38,7 @@ export const APP_PORTS: Record<string, number> = {
'fedimint': 8175,
'fedimintd': 8175,
'fedimint-gateway': 8176,
'indeedhub': 7778,
'indeedhub': 7777,
'botfights': 9100,
'dwn': 3100,
'endurain': 8080,
@@ -138,11 +138,24 @@ export function resolveAppUrl(id: string, routeQueryPath?: string): string {
const ext = EXTERNAL_URLS[id]
if (ext) return ext
// Local apps: always launch by host port
// Bitcoin apps always go through nginx proxy so browser basic-auth prompts never appear.
if (id === 'bitcoin-knots' || id === 'bitcoin-core' || id === 'bitcoin-ui') {
return window.location.protocol + '//' + window.location.hostname + '/app/bitcoin-ui/'
}
// HTTPS pages cannot embed plain HTTP port origins (mixed-content).
if (window.location.protocol === 'https:') {
const proxyPath = HTTPS_PROXY_PATHS[id]
if (proxyPath) {
return window.location.protocol + '//' + window.location.hostname + proxyPath
}
}
// Local apps on HTTP pages launch by host port.
const port = APP_PORTS[id]
if (!port) return ''
let base = `${window.location.protocol}//${window.location.hostname}:${port}`
let base = window.location.protocol + '//' + window.location.hostname + ':' + String(port)
if (routeQueryPath) base += routeQueryPath
return base
}

View File

@@ -1,6 +1,6 @@
import type { MarketplaceApp } from './types'
const R = 'git.tx1138.com/lfg2025'
const R = '146.59.87.168:3000/lfg2025'
// ---------- Dynamic catalog from registry ----------
export interface CatalogFeatured {

View File

@@ -376,7 +376,7 @@ export function getCuratedAppList(): MarketplaceApp[] {
description: 'Bitcoin documentary streaming platform with Nostr identity sign-in. Stream God Bless Bitcoin and other educational content about sovereignty and decentralized technology.',
icon: '/assets/img/app-icons/indeedhub.png',
author: 'Indeehub Team',
dockerImage: 'git.tx1138.com/lfg2025/indeedhub:latest',
dockerImage: '146.59.87.168:3000/lfg2025/indeedhub:latest',
manifestUrl: undefined,
repoUrl: 'https://github.com/indeedhub/indeedhub'
},