feat(web): app-registry architecture — OS + Apps, lazy loading, installable apps
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
ci / web (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled

Problem: the frontend had an implicit OS+Apps metaphor (desktop, floating
windows, an app registry) but the contract was informal — the mascot was
hardcoded into the shell, all apps were statically imported into one
800KB bundle, and there was no install/uninstall path.

Change: three phases landed.
- Phase 1 (contract + docked kind): AppDef extended with docked/noIcon
  and optional geometry; the mascot registered as a docked app via a
  generic DockedLayer that replaces the hardcoded <MascotLayer />;
  openAppWindow branches on docked → toggleDocked; persisted docked
  visibility store (absent key = visible, no APPS import to avoid a
  static cycle).
- Phase 2 (lazy loading): AppDef.component is now a dynamic-import
  loader; LazyApp renders with a loading skeleton; Vite code-splits
  each app (main bundle 800KB→485KB); the LazyMascot wrapper is gone
  since the lazy loader breaks the import cycle directly.
- Phase 3 (installable apps, local bundles): AppManifest + catalog +
  installApp/uninstallApp + localStorage persistence; reactive apps
  store (built-in + installed) and derived appById; App Store page;
  Notes demo app; icons.ts and WindowLayer's orphan-close react to
  registration so installs appear without a reload.
- Structure: data-table casing unified to PascalCase; the mislabeled
  DataTable.svelte.ts (pure types, not runes) renamed to types.ts;
  LazyApp colocated with its desktop-shell consumers; app-store moved
  under lib/ so the dependency direction is consistent.

Risk: the app registry is now a reactive store, not a static array, so
every consumer (Desktop, DockedLayer, Taskbar, icons, windows) reads
from derived stores. Two static-cycle traps are documented in
docs/mbse/components.md §9: docked.ts must not import APPS (it would
fire a TDZ at init via the apps.ts→pages→windows.ts→here path), and
apps.ts must not statically import the mascot (the lazy loader defers
its module graph). Remote bundle loading, the /api/v1/apps endpoint,
and permission enforcement are deliberately NOT in this commit — they
are security-critical and deferred to Phase 4 with an ADR.

Verification: vitest 38/38; svelte-check + tsc clean for changed files;
eslint clean; vite build green; runtime smoke confirmed (install
Notes → icon appears → open → uninstall → icon + window gone; survives
reload). docs/mbse/components.md Component 9 and the plan updated.

Plan: plans/2026-07-21-frontend-os-apps-architecture.md
This commit is contained in:
2026-07-21 14:37:36 +02:00
parent 50aed11cc4
commit 482c7f3448
30 changed files with 1597 additions and 127 deletions

View File

@@ -5,7 +5,7 @@
// or dragged window can never end up underneath the taskbar. This replaces
// the old sidebar + hash-routed page shell in App.svelte entirely; apps are
// desktop icons now (see $lib/apps.ts), not nav items.
import { APPS } from '$lib/apps'
import { apps } from '$lib/apps'
import { iconPositions, resetIconLayout } from '$lib/stores/icons'
import { wm, openAppWindow, toggleShowDesktop } from '$lib/stores/windows'
import { summary } from '$lib/stores/context'
@@ -13,8 +13,8 @@
import DesktopIcon from './DesktopIcon.svelte'
import TaskLauncher from './TaskLauncher.svelte'
import WindowLayer from './WindowLayer.svelte'
import DockedLayer from './DockedLayer.svelte'
import Taskbar from './Taskbar.svelte'
import MascotLayer from '$lib/mascot/MascotLayer.svelte'
import LayersIcon from '@lucide/svelte/icons/layers'
import Rows3Icon from '@lucide/svelte/icons/rows-3'
import MonitorIcon from '@lucide/svelte/icons/monitor'
@@ -87,7 +87,7 @@
<GraphBackground />
<div class="pointer-events-none absolute inset-0 z-0">
{#each APPS as app (app.id)}
{#each $apps as app (app.id)}
{@const pos = $iconPositions[app.id] ?? { col: 0, row: 0 }}
{@const badge = app.badge?.($summary) ?? 0}
<DesktopIcon {app} {pos} {badge} onOpen={() => openAppWindow(app.id)} />
@@ -102,7 +102,7 @@
<WindowLayer />
<MascotLayer />
<DockedLayer />
</div>
<Taskbar />

View File

@@ -0,0 +1,24 @@
<script lang="ts">
// The docked-app layer: renders apps flagged `docked: true` on a
// pointer-events-none absolute inset-0 overlay above WindowLayer's z-40,
// below the desktop context menu's z-50. Docked apps have no wmkit window,
// no titlebar, and no taskbar button; their visibility is toggled by
// clicking their desktop icon (see stores/docked.ts). Replaces the
// previously-hardcoded <MascotLayer /> in Desktop.svelte — the mascot is
// now the first docked app, not a shell special case. Rendered as a sibling
// inside the surface div so docked apps share the surface's coordinate
// space (the mascot's ground-line computation depends on this).
import { apps } from '$lib/apps'
import { dockedVisibility } from '$lib/stores/docked'
import LazyApp from '$lib/components/desktop-shell/LazyApp.svelte'
const dockedApps = $derived($apps.filter((a) => a.docked))
</script>
<div class="pointer-events-none absolute inset-0 z-45">
{#each dockedApps as app (app.id)}
{#if $dockedVisibility[app.id] ?? true}
<LazyApp load={app.component} />
{/if}
{/each}
</div>

View File

@@ -0,0 +1,35 @@
<script lang="ts">
// Renders an App's lazily-loaded component (AppDef.component is a
// dynamic-import loader, not the component itself). Shows the shared
// spinner while the chunk fetches; Vite's module cache makes repeat
// opens resolve from cache on the next microtask, so the spinner is
// one-tick at most after first load. Used by both WindowLayer
// (windowed apps) and DockedLayer (docked apps) so the loading state
// is uniform across app kinds.
import type { Component } from 'svelte'
import { untrack } from 'svelte'
import Spinner from '../Spinner.svelte'
let { load }: { load: () => Promise<{ default: Component }> } = $props()
// Created once per mount, not per render. `load` is the app's stable
// registry loader (app.component — defined once in the APPS array, never
// reassigned), so reading it at init is correct; untrack tells Svelte the
// one-shot read is intentional and silences the state_referenced_locally
// lint. Without pinning, {#await} would re-subscribe to a fresh Promise on
// every reactive re-evaluation of load() and loop.
const promise = untrack(() => load())
</script>
{#await promise}
<div class="flex h-full min-h-0 items-center justify-center text-muted-foreground">
<Spinner class="size-5" />
</div>
{:then mod}
{@const C = mod.default}
<C />
{:catch error}
<div class="flex h-full min-h-0 items-center justify-center p-4 text-center text-sm text-destructive">
Failed to load app: {(error as Error).message}
</div>
{/await}

View File

@@ -30,14 +30,14 @@
function iconFor(id: string) {
const appId = appIdFromWindowId(id)
if (appId) return appById.get(appId)?.icon
if (appId) return $appById.get(appId)?.icon
if (id.startsWith(SESSION_PREFIX)) return MessageSquareIcon
return DatabaseIcon
}
function badgeFor(id: string): number {
const appId = appIdFromWindowId(id)
const app = appId ? appById.get(appId) : undefined
const app = appId ? $appById.get(appId) : undefined
return app?.badge?.($summary) ?? 0
}

View File

@@ -14,18 +14,22 @@
import EntityDetailContent from '../EntityDetailContent.svelte'
import SessionChatWindow from '../SessionChatWindow.svelte'
import NewTaskChat from './NewTaskChat.svelte'
import LazyApp from '$lib/components/desktop-shell/LazyApp.svelte'
import XIcon from '@lucide/svelte/icons/x'
import MinusIcon from '@lucide/svelte/icons/minus'
import Maximize2Icon from '@lucide/svelte/icons/maximize-2'
// A hydrated `app:<id>` window whose id no longer matches any registry
// entry (the app was renamed/removed since the layout was persisted) has
// nothing to render — close it rather than leaving a permanently-blank
// window stuck in the taskbar.
// entry (the app was uninstalled/removed since the layout was persisted)
// has nothing to render — close it rather than leaving a permanently-blank
// window stuck in the taskbar. Reactive on `appById` so reinstalling an
// app revives its persisted window on the next tick rather than requiring
// a reload, and uninstalling closes its orphan window immediately.
$effect(() => {
const idx = $appById
for (const id of $wmState.order) {
const appId = appIdFromWindowId(id)
if (appId && !appById.has(appId)) wm.close(id)
if (appId && !idx.has(appId)) wm.close(id)
}
})
</script>
@@ -34,7 +38,7 @@
{#each $wmState.order as id (id)}
{@const win = $wmState.windows[id]}
{@const appId = appIdFromWindowId(id)}
{@const app = appId ? appById.get(appId) : undefined}
{@const app = appId ? $appById.get(appId) : undefined}
{#if win && (!appId || app)}
<section use:dk.window={{ id }} class="min-w-0" aria-label={win.title}>
<header data-wm-drag class="flex shrink-0 cursor-move items-center justify-between gap-2 border-b bg-muted/40 px-3 py-1.5">
@@ -73,7 +77,7 @@
{:else if id === NEW_TASK_WINDOW_ID}
<NewTaskChat />
{:else if app}
<app.component />
<LazyApp load={app.component} />
{:else}
<EntityDetailContent slug={id} onSelectEntity={openEntityWindow} />
{/if}