From 6e1531f32a23c7ad4b054562bcb212e58c64ea77 Mon Sep 17 00:00:00 2001 From: Zackary Lowery Date: Thu, 8 Oct 2026 03:35:40 -0400 Subject: [PATCH] feat(shared): sync the app registry across tabs via BroadcastChannel (NEXT.md 3.5) Adds multitab-sync.ts owning the dominic:registry-sync channel: registry mutations broadcast a plain-object snapshot through the existing onRegistryChange API, receiving tabs apply it via the new registry importApps() (which persists + notifies like a local mutation). Echoes are ignored by instance id; app source stays out of the channel (shared localStorage VFS). Wired once in app.vue onMounted; SSR-safe. Window geometry/focus/workspaces are per-tab shell state and are not synced (NEXT.md 2.5 owns their persistence). Recorded as ADR 0016 (accepted), superseding ADR 0003's deferral without editing that record. e2e covers install and uninstall fan-out across tabs. Closes #116 --- app/app.vue | 2 + app/features/apps/registry/registry.ts | 15 +++ app/features/shared/multitab-sync.ts | 90 ++++++++++++++ ...-tab-registry-sync-via-broadcastchannel.md | 114 ++++++++++++++++++ docs/adrs/README.md | 1 + tests/e2e/multitab-sync.spec.ts | 61 ++++++++++ 6 files changed, 283 insertions(+) create mode 100644 app/features/shared/multitab-sync.ts create mode 100644 docs/adrs/0016-multi-tab-registry-sync-via-broadcastchannel.md create mode 100644 tests/e2e/multitab-sync.spec.ts 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