Compare commits

..
Author SHA1 Message Date
archipelago 0fac51b9c5 chore: preserve signed release catalog 2026-09-12 16:00:16 -04:00
archipelago 4f0d123f27 feat: open GitWorkshop at Archipelago repository
Demo images / Build & push demo images (push) Successful in 3m33s
2026-09-12 15:57:57 -04:00
archipelago 13b1329c21 test: keep Cuprate stack as one app entry
Demo images / Build & push demo images (push) Successful in 4m0s
2026-09-12 15:33:05 -04:00
archipelago c4aa72dccc fix: route installs to apps or services
Demo images / Build & push demo images (push) Successful in 3m39s
2026-09-12 15:07:33 -04:00
archipelago d35474f774 fix: defensively hide legacy node identity
Demo images / Build & push demo images (push) Successful in 3m31s
2026-09-12 10:24:01 -04:00
archipelago a03f340bd1 fix: keep node key out of profile signer picker
Demo images / Build & push demo images (push) Successful in 3m26s
2026-09-12 10:06:41 -04:00
archipelago caaa2e729e fix: gate app launches on health readiness
Demo images / Build & push demo images (push) Successful in 3m47s
2026-09-12 09:35:25 -04:00
14 changed files with 121 additions and 24 deletions
+1 -1
View File
@@ -26,7 +26,7 @@
"headline": "Your node. Your source.", "headline": "Your node. Your source.",
"description": "Install GitWorkshop to browse Archipelago's code from your own node, clone it with ngit, and contribute issues, patches, and reviews over Nostr.", "description": "Install GitWorkshop to browse Archipelago's code from your own node, clone it with ngit, and contribute issues, patches, and reviews over Nostr.",
"tag": "NGIT // NOSTR // NO SILO", "tag": "NGIT // NOSTR // NO SILO",
"path": "/npub1w3sqdkrhn0gyuvsex32effzgnfpyde6qrrc4u467flg5e9txh4wsfn5vjg/archy", "path": "/npub1w3sqdkrhn0gyuvsex32effzgnfpyde6qrrc4u467flg5e9txh4wsfn5vjg/relay.ngit.dev/archy",
"launchLabel": "Open GitWorkshop", "launchLabel": "Open GitWorkshop",
"installLabel": "Install GitWorkshop", "installLabel": "Install GitWorkshop",
"detailsLabel": "How contribution works →" "detailsLabel": "How contribution works →"
@@ -55,6 +55,10 @@ impl RpcHandler {
"did": id.did, "did": id.did,
"created_at": id.created_at, "created_at": id.created_at,
"is_default": is_default, "is_default": is_default,
// The node's operational Nostr key is intentionally
// distinguishable from user profile identities. Clients
// must never offer it in app sign-in pickers.
"is_node": is_node,
"nostr_pubkey": nostr_pubkey, "nostr_pubkey": nostr_pubkey,
"nostr_npub": nostr_npub, "nostr_npub": nostr_npub,
"profile": id.profile, "profile": id.profile,
+1 -1
View File
@@ -26,7 +26,7 @@
"headline": "Your node. Your source.", "headline": "Your node. Your source.",
"description": "Install GitWorkshop to browse Archipelago's code from your own node, clone it with ngit, and contribute issues, patches, and reviews over Nostr.", "description": "Install GitWorkshop to browse Archipelago's code from your own node, clone it with ngit, and contribute issues, patches, and reviews over Nostr.",
"tag": "NGIT // NOSTR // NO SILO", "tag": "NGIT // NOSTR // NO SILO",
"path": "/npub1w3sqdkrhn0gyuvsex32effzgnfpyde6qrrc4u467flg5e9txh4wsfn5vjg/archy", "path": "/npub1w3sqdkrhn0gyuvsex32effzgnfpyde6qrrc4u467flg5e9txh4wsfn5vjg/relay.ngit.dev/archy",
"launchLabel": "Open GitWorkshop", "launchLabel": "Open GitWorkshop",
"installLabel": "Install GitWorkshop", "installLabel": "Install GitWorkshop",
"detailsLabel": "How contribution works →" "detailsLabel": "How contribution works →"
@@ -45,13 +45,13 @@
</button> </button>
</div> </div>
<div v-else-if="identities.length === 0" class="text-center py-8"> <div v-else-if="userIdentities.length === 0" class="text-center py-8">
<p class="text-white/50 text-sm">No identities found.</p> <p class="text-white/50 text-sm">No identities found.</p>
<p class="text-white/30 text-xs mt-1">Create one in Settings &rarr; Credentials</p> <p class="text-white/30 text-xs mt-1">Create one in Settings &rarr; Credentials</p>
</div> </div>
<button <button
v-for="identity in identities" v-for="identity in userIdentities"
:key="identity.id" :key="identity.id"
type="button" type="button"
role="radio" role="radio"
@@ -130,6 +130,7 @@ interface Identity {
is_default: boolean is_default: boolean
nostr_pubkey?: string nostr_pubkey?: string
nostr_npub?: string nostr_npub?: string
is_node?: boolean
} }
const props = defineProps<{ const props = defineProps<{
@@ -148,10 +149,23 @@ const selectedId = ref<string | null>(null)
const loading = ref(false) const loading = ref(false)
const loadError = ref<string | null>(null) const loadError = ref<string | null>(null)
// The node key authenticates the appliance itself (mesh/discovery and other
// platform operations), not the person's public profile. The API marks it
// explicitly; keep a defensive name/purpose fallback for older nodes that do
// not send is_node yet.
const userIdentities = computed(() => identities.value.filter(identity =>
// `node-<pubkey>` is the deterministic id used by older node APIs before
// the explicit is_node marker was added. Keep this fallback so an older
// backend can never expose the appliance key as a profile choice.
!identity.is_node
&& !identity.id.trim().toLowerCase().startsWith('node-')
&& identity.name.trim().toLowerCase() !== 'node'
))
useModalKeyboard(modalRef, computed(() => props.show), () => emit('cancel')) useModalKeyboard(modalRef, computed(() => props.show), () => emit('cancel'))
const hasNostrKey = computed(() => { const hasNostrKey = computed(() => {
const selected = identities.value.find(i => i.id === selectedId.value) const selected = userIdentities.value.find(i => i.id === selectedId.value)
return selected?.nostr_pubkey != null return selected?.nostr_pubkey != null
}) })
@@ -169,8 +183,8 @@ async function loadIdentities() {
try { try {
const res = await rpcClient.call<{ identities: Identity[] }>({ method: 'identity.list' }) const res = await rpcClient.call<{ identities: Identity[] }>({ method: 'identity.list' })
identities.value = res.identities || [] identities.value = res.identities || []
const defaultId = identities.value.find(i => i.is_default && i.nostr_pubkey) const defaultId = userIdentities.value.find(i => i.is_default && i.nostr_pubkey)
|| identities.value.find(i => i.nostr_pubkey) || userIdentities.value.find(i => i.nostr_pubkey)
if (defaultId) selectedId.value = defaultId.id if (defaultId) selectedId.value = defaultId.id
} catch (error) { } catch (error) {
identities.value = [] identities.value = []
@@ -183,7 +197,7 @@ async function loadIdentities() {
} }
function confirm() { function confirm() {
const selected = identities.value.find(i => i.id === selectedId.value) const selected = userIdentities.value.find(i => i.id === selectedId.value)
if (selected) emit('select', selected) if (selected) emit('select', selected)
} }
+10 -1
View File
@@ -7,7 +7,8 @@ import { openInAppOrNewTab, isCompanionApp, type InAppLaunchMeta } from '@/utils
import { directAppUrl, HOST_FRAME_APPS, HTTPS_APP_IDS, resolveAppUrl } from '@/views/appSession/appSessionConfig' import { directAppUrl, HOST_FRAME_APPS, HTTPS_APP_IDS, resolveAppUrl } from '@/views/appSession/appSessionConfig'
import { appPortIsGateFronted } from '@/views/appSession/appSessionConfig' import { appPortIsGateFronted } from '@/views/appSession/appSessionConfig'
import { useAppStore } from '@/stores/app' import { useAppStore } from '@/stores/app'
import { resolveAppIcon } from '@/views/apps/appsConfig' import { resolveAppIcon, isAppReadyForLaunch } from '@/views/apps/appsConfig'
import { useToast } from '@/composables/useToast'
import { IS_DEMO, isDemoApp, isDemoExternal, demoAppUrl } from '@/composables/useDemoIntro' import { IS_DEMO, isDemoApp, isDemoExternal, demoAppUrl } from '@/composables/useDemoIntro'
import type { AppCredential, AppCredentialsResponse } from '@/types/api' import type { AppCredential, AppCredentialsResponse } from '@/types/api'
import { resolveAppCredentials } from '@/views/apps/appCredentials' import { resolveAppCredentials } from '@/views/apps/appCredentials'
@@ -290,6 +291,14 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
* Previously each Apps view owned a private modal, so Home skipped the * Previously each Apps view owned a private modal, so Home skipped the
* Portainer first-run token entirely. */ * Portainer first-run token entirely. */
function openSession(appId: string, opts: LaunchOptions = {}) { function openSession(appId: string, opts: LaunchOptions = {}) {
// Home/goal/deep-link launchers do not pass through AppCard.canLaunch.
// Apply the same readiness gate here so a container that has just entered
// `running` cannot race nginx and show a transient 502 to the user.
const pkg = useAppStore().data?.['package-data']?.[appId]
if (pkg && pkg.state === 'running' && !isAppReadyForLaunch(pkg)) {
useToast().info(`${pkg.manifest?.title || appId} is still starting — try again in a moment`)
return
}
if (!opts.skipCredentialPrompt && CREDENTIAL_INTERSTITIAL_APPS.has(appId)) { if (!opts.skipCredentialPrompt && CREDENTIAL_INTERSTITIAL_APPS.has(appId)) {
void prepareCredentialLaunch(appId, opts.path) void prepareCredentialLaunch(appId, opts.path)
return return
+13 -4
View File
@@ -365,6 +365,7 @@ import AppGrid from './discover/AppGrid.vue'
import InstallVersionModal from '@/components/InstallVersionModal.vue' import InstallVersionModal from '@/components/InstallVersionModal.vue'
import type { MarketplaceApp, FeaturedApp } from './discover/types' import type { MarketplaceApp, FeaturedApp } from './discover/types'
import { getCuratedAppList, INSTALLED_ALIASES, FEATURED_DEFINITIONS, categorizeCommunityApp, fetchAppCatalog, type CatalogFeatured, type CatalogStorefront } from './discover/curatedApps' import { getCuratedAppList, INSTALLED_ALIASES, FEATURED_DEFINITIONS, categorizeCommunityApp, fetchAppCatalog, type CatalogFeatured, type CatalogStorefront } from './discover/curatedApps'
import { isServiceContainer } from './apps/serviceNames'
const router = useRouter() const router = useRouter()
const store = useAppStore() const store = useAppStore()
@@ -733,6 +734,16 @@ onBeforeUnmount(() => {
const toast = useToast() const toast = useToast()
function installToast(app: MarketplaceApp) {
const service = isServiceContainer(app.id)
const destination = service ? 'Services' : 'My Apps'
toast.action(
`Installing ${app.title ?? app.id} — it will appear in ${destination}`,
{ label: `View ${destination}`, onClick: () => router.push({ path: '/dashboard/apps', query: service ? { tab: 'services' } : {} }) },
{ variant: 'info', duration: 15000 },
)
}
function installBlockedReason(appId: string): string | undefined { function installBlockedReason(appId: string): string | undefined {
if (!bitcoinPruned.value) return undefined if (!bitcoinPruned.value) return undefined
if (appId !== 'electrumx' && appId !== 'electrs' && appId !== 'mempool-electrs') return undefined if (appId !== 'electrumx' && appId !== 'electrs' && appId !== 'mempool-electrs') return undefined
@@ -766,8 +777,7 @@ function failInstall(app: MarketplaceApp, err: unknown) {
async function installApp(app: MarketplaceApp, versionOverride?: string) { async function installApp(app: MarketplaceApp, versionOverride?: string) {
if (installingApps.has(app.id) || isInstalled(app.id)) return if (installingApps.has(app.id) || isInstalled(app.id)) return
queueInstall(app) queueInstall(app)
toast.info("Installing " + (app.title ?? app.id) + " - check My Apps") installToast(app)
router.push('/dashboard/apps').catch(() => {})
try { try {
const installUrl = app.url || app.manifestUrl || app.s9pkUrl const installUrl = app.url || app.manifestUrl || app.s9pkUrl
await rpcClient.call({ method: 'package.install', params: { id: app.id, url: installUrl, version: versionOverride || app.version }, timeout: 600000 }) await rpcClient.call({ method: 'package.install', params: { id: app.id, url: installUrl, version: versionOverride || app.version }, timeout: 600000 })
@@ -780,8 +790,7 @@ async function installApp(app: MarketplaceApp, versionOverride?: string) {
async function installCommunityApp(app: MarketplaceApp, versionOverride?: string) { async function installCommunityApp(app: MarketplaceApp, versionOverride?: string) {
if (installingApps.has(app.id) || isInstalled(app.id) || !app.dockerImage) return if (installingApps.has(app.id) || isInstalled(app.id) || !app.dockerImage) return
queueInstall(app) queueInstall(app)
toast.info("Installing " + (app.title ?? app.id) + " - check My Apps") installToast(app)
router.push('/dashboard/apps').catch(() => {})
try { try {
const installParams: Record<string, unknown> = { id: app.id, dockerImage: app.dockerImage, version: versionOverride || app.version } const installParams: Record<string, unknown> = { id: app.id, dockerImage: app.dockerImage, version: versionOverride || app.version }
if ((app as Record<string, unknown>).containerConfig) { if ((app as Record<string, unknown>).containerConfig) {
+14 -2
View File
@@ -185,6 +185,7 @@ import {
getCuratedAppList, getCuratedAppList,
} from './marketplace/marketplaceData' } from './marketplace/marketplaceData'
import { fetchAppCatalog } from './discover/curatedApps' import { fetchAppCatalog } from './discover/curatedApps'
import { isServiceContainer } from './apps/serviceNames'
const router = useRouter() const router = useRouter()
const route = useRoute() const route = useRoute()
@@ -207,6 +208,17 @@ const appStoreSections = computed(() => APP_STORE_SECTIONS)
const installingApps = server.installingApps const installingApps = server.installingApps
const electrumxArchiveWarning = 'You need a full archival bitcoin node before downloading ElectrumX' const electrumxArchiveWarning = 'You need a full archival bitcoin node before downloading ElectrumX'
function installToast(app: MarketplaceApp) {
const service = isServiceContainer(app.id)
const tab = service ? 'services' : 'apps'
const destination = service ? 'Services' : 'My Apps'
toast.action(
`Installing ${app.title ?? app.id} — it will appear in ${destination}`,
{ label: `View ${destination}`, onClick: () => router.push({ path: '/dashboard/apps', query: service ? { tab } : {} }) },
{ variant: 'info', duration: 15000 },
)
}
// Install progress tracking is now in serverStore (global watcher on WebSocket data) // Install progress tracking is now in serverStore (global watcher on WebSocket data)
// so it works regardless of which page is active // so it works regardless of which page is active
@@ -518,7 +530,7 @@ async function installApp(app: MarketplaceApp) {
// Stay on the store page: the tile itself shows install progress via the // Stay on the store page: the tile itself shows install progress via the
// global watcher, and a forced jump to My Apps yanked the user out of the // global watcher, and a forced jump to My Apps yanked the user out of the
// page they were deliberately browsing. // page they were deliberately browsing.
toast.info("Installing " + (app.title ?? app.id) + " — it will appear in My Apps") installToast(app)
try { try {
const installUrl = app.url || app.manifestUrl || app.s9pkUrl const installUrl = app.url || app.manifestUrl || app.s9pkUrl
@@ -543,7 +555,7 @@ async function installCommunityApp(app: MarketplaceApp) {
queueInstall(app) queueInstall(app)
// Stay on the store page (see installApp). // Stay on the store page (see installApp).
toast.info("Installing " + (app.title ?? app.id) + " — it will appear in My Apps") installToast(app)
try { 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: app.version }
@@ -1,5 +1,5 @@
import { describe, expect, it, beforeEach } from 'vitest' import { describe, expect, it, beforeEach } from 'vitest'
import { HOST_FRAME_APPS, NEW_TAB_APPS, directAppUrl, resolveAppUrl } from '../appSessionConfig' import { DEFAULT_GITWORKSHOP_REPO_PATH, HOST_FRAME_APPS, NEW_TAB_APPS, directAppUrl, resolveAppUrl } from '../appSessionConfig'
import { GENERATED_HOST_FRAME_APPS, GENERATED_NEW_TAB_APPS } from '../generatedAppSessionConfig' import { GENERATED_HOST_FRAME_APPS, GENERATED_NEW_TAB_APPS } from '../generatedAppSessionConfig'
import { __setSignedCatalogForTests } from '../../discover/curatedApps' import { __setSignedCatalogForTests } from '../../discover/curatedApps'
@@ -159,9 +159,9 @@ describe('appSessionConfig', () => {
// Source is intentionally absent from SIGNED until owner UAT passes. It // Source is intentionally absent from SIGNED until owner UAT passes. It
// must follow the already-working dashboard ingress instead of assuming // must follow the already-working dashboard ingress instead of assuming
// that the same address also exposes a dedicated high port. // that the same address also exposes a dedicated high port.
expect(resolveAppUrl('archipelago-source')).toBe('/app/archipelago-source/') expect(resolveAppUrl('archipelago-source')).toBe(`/app/archipelago-source${DEFAULT_GITWORKSHOP_REPO_PATH}`)
expect(resolveAppUrl('archipelago-source', undefined, 'http://localhost:8337')) expect(resolveAppUrl('archipelago-source', undefined, 'http://localhost:8337'))
.toBe('/app/archipelago-source/') .toBe(`/app/archipelago-source${DEFAULT_GITWORKSHOP_REPO_PATH}`)
expect(resolveAppUrl('archipelago-source', '/search')) expect(resolveAppUrl('archipelago-source', '/search'))
.toBe('/app/archipelago-source/search') .toBe('/app/archipelago-source/search')
}) })
@@ -61,6 +61,10 @@ export const PROXY_APPS: Record<string, string> = {
'uptime-kuma': '/app/uptime-kuma/', 'uptime-kuma': '/app/uptime-kuma/',
} }
/** The repository shown when GitWorkshop is opened from the app launcher. */
export const DEFAULT_GITWORKSHOP_REPO_PATH =
'/npub1w3sqdkrhn0gyuvsex32effzgnfpyde6qrrc4u467flg5e9txh4wsfn5vjg/relay.ngit.dev/archy'
/** App launches use direct ports. Do not route through /app/... path proxies. */ /** App launches use direct ports. Do not route through /app/... path proxies. */
export const HTTPS_PROXY_PATHS: Record<string, string> = { export const HTTPS_PROXY_PATHS: Record<string, string> = {
} }
@@ -137,8 +141,8 @@ export function resolveAppUrl(id: string, routeQueryPath?: string, runtimeUrl?:
// high port is reachable through the same address. // high port is reachable through the same address.
if (id === 'archipelago-source') { if (id === 'archipelago-source') {
const base = PROXY_APPS['archipelago-source']! const base = PROXY_APPS['archipelago-source']!
if (!routeQueryPath) return base const path = routeQueryPath || DEFAULT_GITWORKSHOP_REPO_PATH
return base.replace(/\/+$/, '') + (routeQueryPath.startsWith('/') ? routeQueryPath : `/${routeQueryPath}`) return base.replace(/\/+$/, '') + (path.startsWith('/') ? path : `/${path}`)
} }
// Bitcoin UI is a host-network companion on :8334. Do not launch it via // Bitcoin UI is a host-network companion on :8334. Do not launch it via
@@ -1,7 +1,7 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { ref } from 'vue' import { ref } from 'vue'
import { PackageState, type PackageDataEntry } from '@/types/api' import { PackageState, type PackageDataEntry } from '@/types/api'
import { APP_CATEGORY_MAP, canLaunch, filterEntriesForTab, hasFrontendUi, isServiceContainer, isServicePackage, isWebsitePackage, launchBlockedReason, resolveAppIcon, useCategoriesWithApps, DEFAULT_APP_ICON } from '../appsConfig' import { APP_CATEGORY_MAP, canLaunch, filterEntriesForTab, hasFrontendUi, isServiceContainer, isServicePackage, isWebsitePackage, isAppReadyForLaunch, launchBlockedReason, resolveAppIcon, useCategoriesWithApps, DEFAULT_APP_ICON } from '../appsConfig'
function makePkg(id: string, title: string, category: string): PackageDataEntry { function makePkg(id: string, title: string, category: string): PackageDataEntry {
return { return {
@@ -76,6 +76,16 @@ describe('appsConfig service filtering', () => {
expect(services.map(([id]) => id)).toEqual(['core-lnd-ui']) expect(services.map(([id]) => id)).toEqual(['core-lnd-ui'])
}) })
it('shows Cuprate as one My Apps entry while hiding its daemon dependency', () => {
const entries: Array<[string, PackageDataEntry]> = [
['cuprate-ui', makePkg('cuprate-ui', 'Cuprate UI', 'money')],
['cuprate', makePkg('cuprate', 'Cuprate daemon', 'money')],
]
;(entries[0][1].manifest as unknown as Record<string, unknown>).interfaces = { main: { ui: 'http://localhost:18091' } }
expect(filterEntriesForTab(entries, 'apps', 'all').map(([id]) => id)).toEqual(['cuprate-ui'])
expect(filterEntriesForTab(entries, 'services', 'all').map(([id]) => id)).toEqual(['cuprate'])
})
it('falls back to packaged app icon when static icon token is not a path', () => { it('falls back to packaged app icon when static icon token is not a path', () => {
const pkg = makePkg('gitea', 'Gitea', 'dev') const pkg = makePkg('gitea', 'Gitea', 'dev')
pkg['static-files']!.icon = 'git-branch' pkg['static-files']!.icon = 'git-branch'
@@ -141,6 +151,19 @@ describe('appsConfig service filtering', () => {
expect(canLaunch(confirmedUi)).toBe(true) expect(canLaunch(confirmedUi)).toBe(true)
}) })
it('does not launch a health-checked app during the running-before-ready race', () => {
const pkg = makePkg('archipelago-source', 'GitWorkshop', 'development')
;(pkg.manifest as unknown as Record<string, unknown>).interfaces = { main: { ui: 'true' } }
;(pkg.manifest as unknown as Record<string, unknown>).health_check = { path: '/healthz' }
pkg.installed = { 'interface-addresses': { main: { 'lan-address': 'http://localhost:8337' } }, status: 'running' } as unknown as PackageDataEntry['installed']
pkg.health = null
expect(isAppReadyForLaunch(pkg)).toBe(false)
expect(canLaunch(pkg)).toBe(false)
expect(launchBlockedReason(pkg.manifest.id, pkg)).toContain('Starting up')
pkg.health = 'healthy'
expect(canLaunch(pkg)).toBe(true)
})
it('never offers Launch for curated service containers even with a UI flag', () => { it('never offers Launch for curated service containers even with a UI flag', () => {
const service = makePkg('indeedhub-api', 'IndeeHub API', 'media') const service = makePkg('indeedhub-api', 'IndeeHub API', 'media')
;(service.manifest as unknown as Record<string, unknown>).interfaces = { main: { ui: 'true' } } ;(service.manifest as unknown as Record<string, unknown>).interfaces = { main: { ui: 'true' } }
+21 -3
View File
@@ -37,7 +37,7 @@ export function isServicePackage(id: string, pkg?: PackageDataEntry): boolean {
// Known app -> category mappings (matches App Store categorisation) // Known app -> category mappings (matches App Store categorisation)
export const APP_CATEGORY_MAP: Record<string, string> = { export const APP_CATEGORY_MAP: Record<string, string> = {
'bitcoin-core': 'money', 'bitcoin-knots': 'money', 'bitcoin-ui': 'money', 'electrumx': 'money', 'electrs': 'money', 'bitcoin-core': 'money', 'bitcoin-knots': 'money', 'bitcoin-ui': 'money', 'cuprate-ui': 'money', 'electrumx': 'money', 'electrs': 'money',
'lnd': 'money', 'mempool': 'money', 'mempool-web': 'money', 'btcpay-server': 'commerce', 'lnd': 'money', 'mempool': 'money', 'mempool-web': 'money', 'btcpay-server': 'commerce',
'fedimint': 'money', 'fedimint-gateway': 'money', 'fedimint': 'money', 'fedimint-gateway': 'money',
'indeedhub': 'media', 'jellyfin': 'media', 'photoprism': 'media', 'immich': 'media', 'indeedhub': 'media', 'jellyfin': 'media', 'photoprism': 'media', 'immich': 'media',
@@ -259,11 +259,26 @@ export function canLaunch(pkg: PackageDataEntry): boolean {
// the tile stays launchable while the backend is still 'starting' (ElectrumX // the tile stays launchable while the backend is still 'starting' (ElectrumX
// indexes for 10m+ on first run). A genuinely 'unhealthy' backend still // indexes for 10m+ on first run). A genuinely 'unhealthy' backend still
// blocks. Apps that rely on a runtime interface-address keep the strict gate. // blocks. Apps that rely on a runtime interface-address keep the strict gate.
const blockedByHealth = const blockedByHealth = !isAppReadyForLaunch(pkg) ||
pkg.health === 'unhealthy' || (pkg.health === 'starting' && !hasKnownLaunchUrl) (pkg.health === 'starting' && !hasKnownLaunchUrl)
return !!hasUI && pkg.state === 'running' && !blockedByHealth return !!hasUI && pkg.state === 'running' && !blockedByHealth
} }
/**
* A published port is not the same thing as a usable app. During the short
* interval between the container entering `running` and its HTTP health check
* passing, nginx quite correctly returns 502 because the upstream has not
* bound its socket yet. Keep every app with a declared health check out of
* the launch path until the platform has observed readiness. Apps without a
* health check retain the legacy state/port behaviour.
*/
export function isAppReadyForLaunch(pkg: PackageDataEntry): boolean {
const manifest = pkg.manifest as unknown as Record<string, unknown>
const hasHealthCheck = Boolean(manifest.health_check || manifest['health-check'])
if (!hasHealthCheck) return pkg.health !== 'unhealthy'
return pkg.health === 'healthy'
}
export function launchBlockedReason(id: string, pkg?: PackageDataEntry | null): string { export function launchBlockedReason(id: string, pkg?: PackageDataEntry | null): string {
const appId = pkg?.manifest?.id || id const appId = pkg?.manifest?.id || id
if ( if (
@@ -272,6 +287,9 @@ export function launchBlockedReason(id: string, pkg?: PackageDataEntry | null):
) { ) {
return 'Guardian opens a wait page until Bitcoin finishes initial sync.' return 'Guardian opens a wait page until Bitcoin finishes initial sync.'
} }
if (pkg && pkg.state === PackageState.Running && !isAppReadyForLaunch(pkg)) {
return 'Starting up — Launch will appear when the app is ready.'
}
return '' return ''
} }
+2
View File
@@ -14,6 +14,8 @@
// SERVICE_NAMES set that used to live in appsConfig.ts verbatim. // SERVICE_NAMES set that used to live in appsConfig.ts verbatim.
export const SERVICE_NAMES = new Set([ export const SERVICE_NAMES = new Set([
'dwn', 'archy-mempool-db', 'archy-btcpay-db', 'archy-nbxplorer', 'archy-tor', 'dwn', 'archy-mempool-db', 'archy-btcpay-db', 'archy-nbxplorer', 'archy-tor',
// Cuprate's daemon is the backend dependency of the Cuprate UI app.
'cuprate',
// Headless backends with no user-facing UI: the Fedimint ecash client daemon, // Headless backends with no user-facing UI: the Fedimint ecash client daemon,
// the Nostr relay, and the Meshtastic LoRa daemon (its chat UI lives in the // the Nostr relay, and the Meshtastic LoRa daemon (its chat UI lives in the
// built-in Mesh tab) belong in Services, not My Apps. // built-in Mesh tab) belong in Services, not My Apps.
@@ -308,6 +308,7 @@ export function getCuratedAppList(): MarketplaceApp[] {
// Supporting containers (DBs, caches, workers) do NOT — having only a DB // Supporting containers (DBs, caches, workers) do NOT — having only a DB
// without the main app should not mark the app as installed in the UI. // without the main app should not mark the app as installed in the UI.
export const INSTALLED_ALIASES: Record<string, string[]> = { export const INSTALLED_ALIASES: Record<string, string[]> = {
'cuprate-ui': ['cuprate-ui', 'cuprate'],
mempool: ['mempool', 'mempool-web', 'archy-mempool-web'], mempool: ['mempool', 'mempool-web', 'archy-mempool-web'],
bitcoin: ['bitcoin-knots'], bitcoin: ['bitcoin-knots'],
btcpay: ['btcpay-server'], btcpay: ['btcpay-server'],
@@ -68,6 +68,7 @@ const REGISTRY = 'source.archipelago-foundation.org/lfg2025'
/** Marketplace app ID -> backend package keys (for "Already Installed" when first-boot/deploy created them) */ /** Marketplace app ID -> backend package keys (for "Already Installed" when first-boot/deploy created them) */
export const INSTALLED_ALIASES: Record<string, string[]> = { export const INSTALLED_ALIASES: Record<string, string[]> = {
'cuprate-ui': ['cuprate-ui', 'cuprate'],
mempool: ['mempool-web', 'mempool-api', 'archy-mempool-web', 'archy-mempool-db'], mempool: ['mempool-web', 'mempool-api', 'archy-mempool-web', 'archy-mempool-db'],
bitcoin: ['bitcoin-knots'], bitcoin: ['bitcoin-knots'],
btcpay: ['btcpay-server', 'archy-btcpay-db', 'archy-nbxplorer'], btcpay: ['btcpay-server', 'archy-btcpay-db', 'archy-nbxplorer'],