fix(apps): classify by declared UI — UI apps to My Apps, headless to Websites (#45)

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) <noreply@anthropic.com>
This commit is contained in:
archipelago 2026-06-17 06:09:46 -04:00
parent 56752ebfc0
commit d2d2b9dd68
2 changed files with 32 additions and 1 deletions

View File

@ -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<string, unknown>).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

View File

@ -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)
}