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,92 @@
<script lang="ts">
// App Store — lists the installable-app catalog (web/src/lib/app-store/catalog.ts),
// shows install state, and installs/uninstalls. Installing persists the
// manifest id (localStorage) and the registry's reactive `apps` store
// immediately renders the new app's desktop icon — no reload. Uninstalling
// removes the icon and (via WindowLayer's reactive orphan-close effect)
// closes any open window for that app.
//
// Phase 3 scope: local bundles only (apps that ship with the build,
// discovered via the catalog). Phase 4 will swap the catalog for a
// fetched `/api/v1/apps` endpoint + remote bundle loading with
// sandboxing; the install/uninstall lifecycle here is the mechanism that
// generalizes.
import { CATALOG } from '$lib/app-store/catalog'
import { installApp, uninstallApp, installedAppIds } from '$lib/apps'
import CheckCircleIcon from '@lucide/svelte/icons/circle-check-big'
import DownloadIcon from '@lucide/svelte/icons/download'
import TrashIcon from '@lucide/svelte/icons/trash'
import ShieldIcon from '@lucide/svelte/icons/shield'
// Reactive membership test — re-renders when the installed set changes.
const installedSet = $derived(new Set($installedAppIds))
</script>
<div class="flex h-full min-h-0 flex-col">
<header class="shrink-0 border-b px-4 py-3">
<h1 class="text-base font-semibold">App Store</h1>
<p class="mt-0.5 text-xs text-muted-foreground">
Installable apps. Local bundles for now — a remote catalog + sandboxing
is Phase 4.
</p>
</header>
<div class="min-h-0 flex-1 overflow-auto p-4">
{#if CATALOG.length === 0}
<p class="text-sm text-muted-foreground">No apps available yet.</p>
{:else}
<ul class="flex flex-col gap-3">
{#each CATALOG as entry (entry.manifest.id)}
{@const m = entry.manifest}
{@const isOn = installedSet.has(m.id)}
<li class="flex items-start gap-3 rounded-lg border p-3">
<div class="flex size-10 shrink-0 items-center justify-center rounded-md border bg-muted/40">
<entry.icon class="size-5" />
</div>
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<h2 class="truncate text-sm font-medium">{m.title}</h2>
<span class="shrink-0 text-[10px] font-mono text-muted-foreground">v{m.version}</span>
{#if m.author}<span class="shrink-0 text-[10px] text-muted-foreground">by {m.author}</span>{/if}
</div>
<p class="mt-0.5 line-clamp-2 text-xs text-muted-foreground">{m.description}</p>
<div class="mt-1.5 flex items-center gap-1 text-[10px] text-muted-foreground">
<ShieldIcon class="size-3" />
<span>
{#if m.permissions.length}{m.permissions.join(', ')}{:else}no permissions requested{/if}
</span>
</div>
</div>
<div class="shrink-0">
{#if isOn}
<button
type="button"
class="flex items-center gap-1.5 rounded-md border px-2.5 py-1.5 text-xs text-muted-foreground hover:bg-destructive/10 hover:text-destructive hover:border-destructive/50"
onclick={() => uninstallApp(m.id)}
>
<TrashIcon class="size-3.5" /> Uninstall
</button>
{:else}
<button
type="button"
class="flex items-center gap-1.5 rounded-md bg-primary px-2.5 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90"
onclick={() => installApp(m.id)}
>
<DownloadIcon class="size-3.5" /> Install
</button>
{/if}
</div>
</li>
{/each}
</ul>
{/if}
<div class="mt-6 rounded-md border border-dashed p-3 text-xs text-muted-foreground">
<p class="flex items-center gap-1.5">
<CheckCircleIcon class="size-3.5" />
Installed apps appear on the desktop immediately. Uninstalling closes
any open window for that app.
</p>
</div>
</div>
</div>

View File

@@ -13,7 +13,7 @@
import { Badge } from '$lib/components/ui/badge'
import { toast } from 'svelte-sonner'
import DataTable from '$lib/components/data-table/DataTable.svelte'
import type { DataTableColumn } from '$lib/components/data-table/DataTable.svelte.ts'
import type { DataTableColumn } from '$lib/components/data-table/types'
import ApprovalActions from '$lib/components/data-table/renderers/ApprovalActions.svelte'
import ActivityCancel from '$lib/components/data-table/renderers/ActivityCancel.svelte'
import ActivityAction from '$lib/components/data-table/renderers/ActivityAction.svelte'

View File

@@ -6,7 +6,7 @@
import { bucket, FILTERS, TASK_EVENTS, heading, type Bucket } from '$lib/tasks'
import { Button } from '$lib/components/ui/button'
import DataTable from '$lib/components/data-table/DataTable.svelte'
import type { DataTableColumn } from '$lib/components/data-table/DataTable.svelte.ts'
import type { DataTableColumn } from '$lib/components/data-table/types'
import StatusDotRenderer from '$lib/components/data-table/renderers/StatusDotRenderer.svelte'
import PlusIcon from '@lucide/svelte/icons/plus'
import type { Session } from '$lib/api'

View File

@@ -7,7 +7,7 @@
import * as Select from '$lib/components/ui/select'
import { toast } from 'svelte-sonner'
import DataTable from '$lib/components/data-table/DataTable.svelte'
import type { DataTableColumn } from '$lib/components/data-table/DataTable.svelte.ts'
import type { DataTableColumn } from '$lib/components/data-table/types'
import SignalActions from '$lib/components/data-table/renderers/SignalActions.svelte'
let signals = $state<Signal[]>([])