Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions app/app.vue
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
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";

Expand Down Expand Up @@ -114,15 +115,16 @@
setInterval(tick, 1000);
settings.hydrate();
hydrateRegistry();
startRegistrySync();
openBuiltin('chat');

if (import.meta.client) {
(window as any).__dominicReset = () => {

Check warning on line 122 in app/app.vue

View workflow job for this annotation

GitHub Actions / ESLint

Unexpected any. Specify a different type
settings.resetOs();
window.location.reload();
};
(window as any).__dominic = {

Check warning on line 126 in app/app.vue

View workflow job for this annotation

GitHub Actions / ESLint

Unexpected any. Specify a different type
reset: (window as any).__dominicReset,

Check warning on line 127 in app/app.vue

View workflow job for this annotation

GitHub Actions / ESLint

Unexpected any. Specify a different type
installApp,
updateApp,
openApp,
Expand Down
15 changes: 15 additions & 0 deletions app/features/apps/registry/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
90 changes: 90 additions & 0 deletions app/features/shared/multitab-sync.ts
Original file line number Diff line number Diff line change
@@ -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<RegistrySyncMessage>) => {
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
114 changes: 114 additions & 0 deletions docs/adrs/0016-multi-tab-registry-sync-via-broadcastchannel.md
Original file line number Diff line number Diff line change
@@ -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&nbsp;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)
1 change: 1 addition & 0 deletions docs/adrs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
61 changes: 61 additions & 0 deletions tests/e2e/multitab-sync.spec.ts
Original file line number Diff line number Diff line change
@@ -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()
})
Loading