diff --git a/neode-ui/src/views/apps/__tests__/appsConfig.test.ts b/neode-ui/src/views/apps/__tests__/appsConfig.test.ts index 6cfba900..546557ad 100644 --- a/neode-ui/src/views/apps/__tests__/appsConfig.test.ts +++ b/neode-ui/src/views/apps/__tests__/appsConfig.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest' import { ref } from 'vue' import { PackageState, type PackageDataEntry } from '@/types/api' -import { canLaunch, filterEntriesForTab, isServiceContainer, isServicePackage, launchBlockedReason, resolveAppIcon, useCategoriesWithApps } from '../appsConfig' +import { canLaunch, filterEntriesForTab, hasFrontendUi, isServiceContainer, isServicePackage, isWebsitePackage, launchBlockedReason, resolveAppIcon, useCategoriesWithApps } from '../appsConfig' function makePkg(id: string, title: string, category: string): PackageDataEntry { return { @@ -82,6 +82,22 @@ describe('appsConfig service filtering', () => { expect(resolveAppIcon('gitea', pkg)).toBe('/assets/img/app-icons/gitea.svg') }) + it('classifies an unknown app by whether its manifest declares a UI (#45)', () => { + // Headless: a LAN address but no declared UI → Website. + const headless = makePkg('some-backend', 'Some Backend', 'other') + headless.installed = { 'interface-addresses': { main: { 'lan-address': 'http://localhost:9000' } } } as unknown as PackageDataEntry['installed'] + expect(hasFrontendUi(headless)).toBe(false) + expect(isWebsitePackage('some-backend', headless)).toBe(true) + + // Front-end app: declares interfaces.main.ui → My Apps even when not in the + // curated category map. + const uiApp = makePkg('some-ui-app', 'Some UI App', 'other') + ;(uiApp.manifest as unknown as Record).interfaces = { main: { ui: 'http://localhost:9001' } } + uiApp.installed = { 'interface-addresses': { main: { 'lan-address': 'http://localhost:9001' } } } as unknown as PackageDataEntry['installed'] + expect(hasFrontendUi(uiApp)).toBe(true) + expect(isWebsitePackage('some-ui-app', uiApp)).toBe(false) + }) + it('explains that Fedimint waits for Bitcoin sync before Guardian starts', () => { const pkg = makePkg('fedimint', 'Fedimint', 'money') pkg.state = PackageState.Starting diff --git a/neode-ui/src/views/apps/appsConfig.ts b/neode-ui/src/views/apps/appsConfig.ts index 53cd48e8..1b784f89 100644 --- a/neode-ui/src/views/apps/appsConfig.ts +++ b/neode-ui/src/views/apps/appsConfig.ts @@ -78,10 +78,25 @@ export function isKnownApp(id: string, pkg?: PackageDataEntry): boolean { return !!(APP_CATEGORY_MAP[id] || (manifestId && APP_CATEGORY_MAP[manifestId]) || isWebOnlyApp(id)) } +// True when the package's manifest declares a front-end UI interface. This is +// the authoritative "is this a user-facing app?" signal (#45): apps with a UI +// belong in "My Apps", while headless services (databases, backends, workers) +// declare no UI and belong in "Websites"/"Services". +export function hasFrontendUi(pkg?: PackageDataEntry): boolean { + return !!pkg?.manifest?.interfaces?.main?.ui +} + export function isWebsitePackage(id: string, pkg?: PackageDataEntry): boolean { if (isInternalToolingPackage(id, pkg)) return false + // Headless infra (databases/backends/companions) keyed by container name are + // services regardless of any stray UI string. if (isServicePackage(id, pkg)) return true + // A declared front-end UI is the deciding factor: it's an app, not a website. + if (hasFrontendUi(pkg)) return false + // Curated known apps stay in My Apps even if their manifest predates the UI + // interface field. if (isKnownApp(id, pkg)) return false + // Fallback: reachable on the LAN but declares no UI → treat as a website. return !!pkg && !!runtimeLanAddress(pkg) }