From d2d2b9dd68adc082c8c0c331cc4d9940482b0edd Mon Sep 17 00:00:00 2001 From: archipelago Date: Wed, 17 Jun 2026 06:09:46 -0400 Subject: [PATCH] =?UTF-8?q?fix(apps):=20classify=20by=20declared=20UI=20?= =?UTF-8?q?=E2=80=94=20UI=20apps=20to=20My=20Apps,=20headless=20to=20Websi?= =?UTF-8?q?tes=20(#45)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per the rule that only front-end apps with a UI belong in "My Apps" (databases/backends/headless go to Websites), make the manifest's interfaces.main.ui the deciding signal. isWebsitePackage now treats any package that declares a UI as an app even when it isn't in the curated APP_CATEGORY_MAP, and falls through headless LAN-reachable packages to Websites. Additive — service-by-name infra and curated known apps are unchanged, so no currently-correct app moves. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../views/apps/__tests__/appsConfig.test.ts | 18 +++++++++++++++++- neode-ui/src/views/apps/appsConfig.ts | 15 +++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) 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) }