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:
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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'
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user