feat(apps): backend-only services classify as services with no Launch button
Demo images / Build & push demo images (push) Has been cancelled

A published port no longer implies a web UI. The package scanner used to
synthesize interfaces.main.ui="true" for any container with a port or
onion address, so headless backends — including self-deployed compose
stacks like podsteadr — showed up as launchable apps. New ui_detection
module decides instead: a manifest interfaces declaration (catalog
overlay first, disk second) is definitive; undeclared apps get a short
HTTP probe of the launch port (HTML page, redirect, or browser auth
wall = UI; JSON APIs, raw TCP, dead ports = service), with cached
verdicts and probes gated on running containers. Frontend canLaunch
now refuses curated services outright and only treats a bare runtime
address as launchable for curated known apps.

Works identically for manifest apps and containers deployed by hand
outside the orchestrator. ui_detection tests 6/6, frontend suite
696/696.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
archipelago
2026-07-29 05:48:38 -04:00
co-authored by Claude Fable 5
parent d7c5d39747
commit da14c135e4
5 changed files with 302 additions and 3 deletions
@@ -98,6 +98,36 @@ describe('appsConfig service filtering', () => {
expect(isWebsitePackage('some-ui-app', uiApp)).toBe(false)
})
it('never offers Launch for an unknown container with a bare exposed port', () => {
// A self-deployed compose stack (e.g. podsteadr) publishes a port, so it
// has a runtime lan-address — but no manifest-declared or probed UI. It
// must classify as a service and must NOT get a Launch button.
const selfDeployed = makePkg('podsteadr', 'podsteadr', 'other')
selfDeployed.installed = { 'interface-addresses': { main: { 'lan-address': 'http://localhost:8095' } }, status: 'running' } as unknown as PackageDataEntry['installed']
expect(isWebsitePackage('podsteadr', selfDeployed)).toBe(true)
expect(canLaunch(selfDeployed)).toBe(false)
})
it('offers Launch for an unknown container once the backend confirms a UI', () => {
const confirmedUi = makePkg('podsteadr', 'podsteadr', 'other')
;(confirmedUi.manifest as unknown as Record<string, unknown>).interfaces = { main: { ui: 'true' } }
confirmedUi.installed = { 'interface-addresses': { main: { 'lan-address': 'http://localhost:8095' } }, status: 'running' } as unknown as PackageDataEntry['installed']
expect(canLaunch(confirmedUi)).toBe(true)
})
it('never offers Launch for curated service containers even with a UI flag', () => {
const service = makePkg('indeedhub-api', 'IndeeHub API', 'media')
;(service.manifest as unknown as Record<string, unknown>).interfaces = { main: { ui: 'true' } }
service.installed = { 'interface-addresses': { main: { 'lan-address': 'http://localhost:9100' } }, status: 'running' } as unknown as PackageDataEntry['installed']
expect(canLaunch(service)).toBe(false)
})
it('keeps Launch for curated apps that rely on a runtime address alone', () => {
const known = makePkg('jellyfin', 'Jellyfin', 'media')
known.installed = { 'interface-addresses': { main: { 'lan-address': 'http://localhost:8096' } }, status: 'running' } as unknown as PackageDataEntry['installed']
expect(canLaunch(known)).toBe(true)
})
it('explains that Fedimint waits for Bitcoin sync before Guardian starts', () => {
const pkg = makePkg('fedimint', 'Fedimint', 'money')
pkg.state = PackageState.Starting
+9 -1
View File
@@ -255,9 +255,17 @@ export function resolveAppIcon(id: string, pkg: PackageDataEntry, curatedIcon?:
export function canLaunch(pkg: PackageDataEntry): boolean {
if (isWebOnlyApp(pkg.manifest.id)) return true
// Headless backends never get a Launch button, even with a published port.
if (isServicePackage(pkg.manifest.id, pkg)) return false
const hasRuntimeAddress = !!pkg.installed?.['interface-addresses']?.main?.['lan-address']
const hasKnownLaunchUrl = typeof window !== 'undefined' && !!resolveAppUrl(pkg.manifest.id)
const hasUI = pkg.manifest.interfaces?.main?.ui || hasRuntimeAddress || hasKnownLaunchUrl
// A bare runtime address is only a launch signal for curated apps: the
// backend now sets interfaces.main.ui strictly for confirmed web UIs
// (manifest declaration or HTTP probe), so an unknown container with an
// exposed non-UI port must not become launchable just for having one.
const hasUI = pkg.manifest.interfaces?.main?.ui
|| hasKnownLaunchUrl
|| (hasRuntimeAddress && isKnownApp(pkg.manifest.id, pkg))
if ((pkg.manifest.id === 'fedimint' || pkg.manifest.id === 'fedimintd') && hasUI) {
return pkg.state === PackageState.Running || pkg.state === PackageState.Starting
}