feat(web): app-registry architecture — OS + Apps, lazy loading, installable apps
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:
92
web/src/pages/AppStore.svelte
Normal file
92
web/src/pages/AppStore.svelte
Normal 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>
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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[]>([])
|
||||
|
||||
Reference in New Issue
Block a user