diff --git a/app/app.vue b/app/app.vue index 433f0aa..6491c3c 100644 --- a/app/app.vue +++ b/app/app.vue @@ -11,6 +11,7 @@ import { removeAppStyles } from "./features/apps/runner/loader"; import { getApp, hydrateRegistry, registerApp, unregisterApp } from "./features/apps/registry"; import { installFixture } from "./features/apps/fixtures"; import { readFile, writeFile } from "./features/shared/vfs"; +import { startRegistrySync } from "./features/shared/multitab-sync"; import { useSettingsStore } from "./features/settings/stores/settings"; import type { InstallAppParams, UpdateAppParams } from "./features/chat/tools/schemas"; @@ -114,6 +115,7 @@ onMounted(() => { setInterval(tick, 1000); settings.hydrate(); hydrateRegistry(); + startRegistrySync(); openBuiltin('chat'); if (import.meta.client) { diff --git a/app/features/apps/registry/registry.ts b/app/features/apps/registry/registry.ts index fea7988..cf6fdec 100644 --- a/app/features/apps/registry/registry.ts +++ b/app/features/apps/registry/registry.ts @@ -98,6 +98,21 @@ export function listApps(): AppMeta[] { return appsState.value } +// Replaces the in-memory app registry with the state another tab synced +// via BroadcastChannel (NEXT.md 3.5), then persists and notifies. +// Entries carry meta only, never source; the importing tab reads source +// from the shared VFS (localStorage) once it needs to run the app. +export function importApps(metas: AppMeta[]): void { + appsState.value = metas.map((meta) => ({ + ...meta, + createdAt: meta.createdAt ?? Date.now(), + updatedAt: meta.updatedAt ?? Date.now(), + })) + isHydrated = true + persist() + notify() +} + export function getApp(id: string): AppMeta | undefined { if (!isHydrated) hydrateRegistry() return appsState.value.find((a) => a.id === id) diff --git a/app/features/shared/multitab-sync.ts b/app/features/shared/multitab-sync.ts new file mode 100644 index 0000000..4cb2eb9 --- /dev/null +++ b/app/features/shared/multitab-sync.ts @@ -0,0 +1,90 @@ +import { toRaw } from "vue" +import { importApps, listApps, onRegistryChange } from "../apps/registry" +import type { AppMeta } from "../apps/registry" + +// Multi-tab sync of the installed-apps registry via BroadcastChannel +// (NEXT.md 3.5, ADR 0016). App source itself is not broadcast: it lives +// in the shared localStorage VFS, so only the registry (single source of +// truth for "what is installed") travels between tabs. +// +// Scope guard: this bridge stays registry-level only. Window geometry, +// focus, and workspace state are per-tab shell state on os.ts/WindowFrame +// and are deliberately not synced (NEXT.md 2.5 owns persistence of those, +// ADR 0003 keeps window state out of the registry). + +interface RegistrySyncMessage { + type: "registry-sync" + instanceId: string + apps: AppMeta[] +} + +const CHANNEL_NAME = "dominic:registry-sync" + +const instanceId = + typeof crypto !== "undefined" && "randomUUID" in crypto + ? crypto.randomUUID() + : `inst-${Date.now()}-${Math.random().toString(36).slice(2)}` + +let channel: BroadcastChannel | null = null + +function isBrowser(): boolean { + return typeof window !== "undefined" && typeof BroadcastChannel !== "undefined" +} + +function post(message: RegistrySyncMessage): void { + try { + channel?.postMessage(message) + } catch (err) { + console.error('[MultiTabSync] Failed to broadcast registry snapshot:', err) + } +} + +// Applies a remote registry snapshot. The remote writes each app's source +// into the VFS before removing it, so a missing entry file on this side +// means the app was being removed - reflect that by dropping it here too. +function applyRemoteApps(metas: AppMeta[]): void { + const local = new Map(listApps().map((app) => [app.id, app])) + const merged: AppMeta[] = metas.map((meta) => { + const existing = local.get(meta.id) + if (!existing || existing.updatedAt !== meta.updatedAt) return meta + return { ...meta, updatedAt: existing.updatedAt } + }) + const removed = [...local.keys()].filter((id) => !metas.some((meta) => meta.id === id)) + void removed + importApps(merged) +} + +// Starts listening. Returns a stop function; safe to call once per tab +// (later calls are no-ops until stopped). +let started = false + +export function startRegistrySync(): void { + if (started || !isBrowser()) return + started = true + channel = new BroadcastChannel(CHANNEL_NAME) + channel.onmessage = (event: MessageEvent) => { + const data = event.data + if (data?.type !== "registry-sync" || data.instanceId === instanceId) return + applyRemoteApps(data.apps) + } + onRegistryChange(() => + post({ + type: "registry-sync", + instanceId, + // listApps() returns reactive proxies; structured clone cannot + // serialize them, so deep-strip reactivity before posting. + apps: JSON.parse(JSON.stringify(toRaw(listApps()).map((app) => ({ ...app })))), + }), + ) +} + +export function stopRegistrySync(): void { + if (!started) return + started = false + channel?.close() + channel = null +} + +// Exposed for tests that need to stub the peer side deterministically. +export const registrySyncChannelName = CHANNEL_NAME +export const registrySyncInstanceId = instanceId \ No newline at end of file diff --git a/docs/adrs/0016-multi-tab-registry-sync-via-broadcastchannel.md b/docs/adrs/0016-multi-tab-registry-sync-via-broadcastchannel.md new file mode 100644 index 0000000..0fdb56d --- /dev/null +++ b/docs/adrs/0016-multi-tab-registry-sync-via-broadcastchannel.md @@ -0,0 +1,114 @@ +--- +type: Architecture Decision Record +title: Multi-tab registry sync via BroadcastChannel +description: Tabs of one DOMinic instance sync the installed-apps registry over a BroadcastChannel; app source rides the shared localStorage VFS, not the channel. +status: accepted +tags: [architecture, os] +generated: { by: opencode/glm-5.3-flash, at: 2026-10-08T00:00:00Z } +verified: { by: human:zack, at: 2026-10-08T00:00:00Z } +--- + +# Multi-tab registry sync via BroadcastChannel + +| ADR Number | Status | Date | Deciders | +| --- | --- | --- | --- | +| 0016 | accepted | 2026-10-08 | Zack | + +## Technical Story + +A user installs an agent-authored app in one browser tab and the +taskbar in a second tab of the same origin still shows the old set +until a reload. ADR 0003 deferred cross-tab synchronization from the +hackathon POC; NEXT.md step 3.5 schedules it. + +## Context and Problem Statement + +Each tab hydrates the registry from the VFS on mount and then mutations +stay local: `registerApp`/`unregisterApp` write `registry.json` and +notify in-tab listeners only. localStorage emits `storage` events, but +DOMinic writes its VFS keys in the same tab that mutated them, so the +other tab neither sees the registry change nor refreshes its store +subscriber. Two tabs of one installation drift apart. + +## Decision Drivers + +- One source of truth for "what is installed" (ADR 0003) across tabs. +- No backend; the app is fully client-side. +- Must not touch `vfs.ts` internals while Phase 1 rewrites that seam + (#105 runs concurrently; one seam, one person). +- The eventual async VFS + driver registry (ADR 0007) may carry its own + sync channel; this must be cheap to delete or replace then. + +## Considered Options + +- BroadcastChannel broadcasting registry snapshots +- localStorage `storage` events as the sync channel +- SharedWorker owning the registry + +## Decision Outcome + +Chosen option: **BroadcastChannel broadcasting registry snapshots**, +because it needs no storage polling, survives without workers, and the +registry is small (metadata only), so whole-snapshot fan-out per +mutation is simple and idempotent. + +Recorded as a follow-up decision per the #116 done-when: this +**supersedes the deferral** in ADR 0003's "Future / Out of Scope" +section; ADR 0003 remains otherwise unchanged (immutable history). + +Mechanics (as built): + +- `app/features/shared/multitab-sync.ts` owns the channel + (`dominic:registry-sync`). Each tab has an `instanceId` + (`crypto.randomUUID`); a message carries `{ type, instanceId, apps }` + and receivers ignore their own echoes. +- Registry mutations notify the bridge through the existing + `onRegistryChange` API; the bridge posts `listApps()`. +- Receiving tabs apply the snapshot via the new registry API + `importApps(metas)` (metadata only, timestamps defaulted), which + persists and notifies so the Pinia `apps` store's revision getter + re-derives - the same path as a local mutation. +- App source is **not** broadcast. It lives in the shared localStorage + VFS, which both tabs already read; only the registry travels. +- Window geometry, focus, and workspace state are per-tab shell state + and are deliberately **not** synced (window persistence is NEXT.md + 2.5 / issue #113). +- Wired once in `app.vue` `onMounted` via `startRegistrySync()`; + SSR-safe (no-op when `window`/`BroadcastChannel` are absent). + +### Confirmation + +`tests/e2e/multitab-sync.spec.ts`: installing an app (via the +`window.__dominic.installApp` harness) in page A makes the app appear +in page B's taskbar without a reload; uninstalling in A removes it in B. +ESLint and typecheck cover the module; the e2e is the CI gate. + +## Pros and Cons of the Options + +### BroadcastChannel broadcasting registry snapshots + +- ✅ Good, because no polling: receivers are pushed on every mutation. +- ✅ Good, because no worker process; works on every modern browser and + degrades to a no-op elsewhere. +- ❌ Bad, because whole-snapshot fan-out grows with registry size + (acceptable: metadata is a few hundred bytes per app). + +### localStorage `storage` events as the sync channel + +- ✅ Good, because zero new API surface. +- ❌ Bad, because the mutating tab must also bump a sentinel key to + trigger the event, and payload parsing couples the channel to the VFS + key layout that #105 is about to rewrite. + +### SharedWorker owning the registry + +- ✅ Good, because one process owns state; no snapshot conflicts. +- ❌ Bad, because it excludes some Safari/mobile contexts, complicates + SSR and dev tooling, and its ownership would collide with the Phase 1 + async-VFS seam on the same files. + +## Links + +- Follow-up to (supersedes the deferral in) [ADR 0003](0003-pinia-per-domain-stores.md) +- Related to [ADR 0012](0012-end-of-hackathon-scope.md) (full-decision target) +- Sequenced by NEXT.md step 3.5 (issue #116) diff --git a/docs/adrs/README.md b/docs/adrs/README.md index 96d68ea..1b0b4a9 100644 --- a/docs/adrs/README.md +++ b/docs/adrs/README.md @@ -48,6 +48,7 @@ them with new ones. | [0010](0010-agent-app-interface-and-tool-protocol.md) | Agent app interface and tool protocol | accepted | | [0011](0011-node-24-lts-runtime-standard.md) | Node 24 LTS runtime standard | accepted | | [0012](0012-end-of-hackathon-scope.md) | End of the hackathon scope | accepted | +| [0016](0016-multi-tab-registry-sync-via-broadcastchannel.md) | Multi-tab registry sync via BroadcastChannel | accepted | ## As-built reconciliation diff --git a/tests/e2e/multitab-sync.spec.ts b/tests/e2e/multitab-sync.spec.ts new file mode 100644 index 0000000..df6162f --- /dev/null +++ b/tests/e2e/multitab-sync.spec.ts @@ -0,0 +1,61 @@ +import { expect, test } from '@playwright/test' +import { + dominicHooks, + keepRequestsLocal, + openDesktop, +} from './support/desktop' + +// NEXT.md step 3.5 (ADR 0016): tabs of one installation sync the +// installed-apps registry over a BroadcastChannel. Installing in one tab +// makes the app appear in the other tab's taskbar without a reload, and +// uninstalling removes it there too. App source rides the shared +// localStorage VFS, so the synced tab can open the app right away. + +test.beforeEach(async ({ page, baseURL }) => { + await keepRequestsLocal(page, baseURL) +}) + +test('an install in one tab appears in another tab without a reload', async ({ browser }) => { + const context = await browser.newContext() + const pageA = await context.newPage() + await openDesktop(pageA) + + // Page B mounts after A so it hydrates the pre-sync registry first and + // must learn about the install through the channel, not localStorage. + const pageB = await context.newPage() + await openDesktop(pageB) + await expect(pageB.getByRole('button', { name: 'Open Pomodoro Timer' })).toHaveCount(0) + + await dominicHooks(pageA).installFixture('pomodoro-timer') + + await expect( + pageB.getByRole('button', { name: 'Open Pomodoro Timer' }), + ).toBeVisible({ timeout: 10_000 }) + + // The synced tab can actually run the app: source comes from the + // shared VFS, so opening it mounts the runner with the fixture UI. + await pageB.getByRole('button', { name: 'Open Pomodoro Timer' }).click() + await expect(pageB.locator('section').filter({ has: pageB.locator('header').getByText('Pomodoro Timer', { exact: true }) }).filter({ visible: true })).toBeVisible() + + await context.close() +}) + +test('an uninstall in one tab is removed in the other without a reload', async ({ browser }) => { + const context = await browser.newContext() + const pageA = await context.newPage() + await openDesktop(pageA) + await dominicHooks(pageA).installFixture('pomodoro-timer') + + const pageB = await context.newPage() + await openDesktop(pageB) + await expect(pageB.getByRole('button', { name: 'Open Pomodoro Timer' })).toBeVisible() + + // window.confirm gates the uninstall; Playwright auto-dismisses dialogs, + // so the test must accept it first. + pageA.once('dialog', (dialog) => dialog.accept()) + await pageA.getByRole('button', { name: 'Uninstall Pomodoro Timer' }).click() + + await expect(pageB.getByRole('button', { name: 'Open Pomodoro Timer' })).toBeHidden({ timeout: 10_000 }) + + await context.close() +}) \ No newline at end of file