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

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