···1411141114121412 // Register canvas page host windows with the tile broadcaster so pubsub
14131413 // messages (tag:item-added, sync:pull-completed, etc.) are forwarded to
14141414- // page.js via the same extensionBroadcaster path used by feature tiles.
14141414+ // page.js via the same pubsubBroadcaster path used by feature tiles.
14151415 // Token cleanup (revokeToken) happens automatically on 'closed'.
14161416 if (useCanvas && pageHostToken && pageHostEntryId) {
14171417 registerTrustedBuiltinWindow(PAGE_HOST_TILE_ID, pageHostEntryId, win, pageHostToken);
+33-4
backend/electron/main.ts
···2121import { installLoadOnDispatchHook } from './tile-lazy.js';
2222import { initTray } from './tray.js';
2323import { registerLocalShortcut, unregisterLocalShortcut, handleLocalShortcut, registerGlobalShortcut, unregisterGlobalShortcut, unregisterShortcutsForAddress } from './shortcuts.js';
2424-import { scopes, publish, subscribe, unsubscribe, hasSubscriber, setExtensionBroadcaster, getSystemAddress } from './pubsub.js';
2424+import { scopes, publish, subscribe, unsubscribe, hasSubscriber, setPubsubBroadcaster, getSystemAddress } from './pubsub.js';
2525import { WEB_CORE_ADDRESS, isTestProfile, isDevProfile, isEphemeralProfile, isHeadless, getProfile, setTilePreloadPath, getTilePreloadPath, DEBUG } from './config.js';
2626import { getSystemThemeBackgroundColor } from './windows.js';
2727import { getProfileSession, getPartitionString, getCurrentProfileId } from './session-partition.js';
···209209 const dbPath = path.join(config.userDataPath, config.profile, 'datastore.sqlite');
210210 initDatabase(dbPath);
211211212212- // Set up extension broadcaster for pubsub
213213- // Hybrid mode: broadcast to BOTH consolidated (iframes) AND separate windows
212212+ // Set up pubsub broadcaster.
213213+ // Forwards GLOBAL publishes to every renderer that needs to see them:
214214+ // bgWindow (core), tile BrowserWindows, and peek:// webview guests.
214215 // All send() calls are wrapped in try-catch to prevent errors during shutdown
215216 // when windows are being destroyed concurrently (race between isDestroyed check and send).
216216- setExtensionBroadcaster((topic, msg, source) => {
217217+ setPubsubBroadcaster((topic, msg, source) => {
218218+ // Broadcast to bgWindow (core orchestrator).
219219+ // bgWindow hosts app/index.js which runs cmd/page/hud registries and is a
220220+ // first-class pubsub subscriber. Every publish must reach it — historical
221221+ // omissions from this loop were directly responsible for "hello-world-only
222222+ // commands visible in cmd panel" and "v2 tile cmd:execute:*:result never
223223+ // reaches core subscribers" bug classes. bgWindow is NOT a tile; it is
224224+ // broadcast to explicitly rather than via getAllTileWindows().
225225+ const bgWindow = getCoreBackgroundWindow();
226226+ if (bgWindow && !bgWindow.isDestroyed()) {
227227+ try {
228228+ const winUrl = bgWindow.webContents.getURL();
229229+ if (winUrl !== source) {
230230+ bgWindow.webContents.send(`pubsub:${topic}`, {
231231+ ...(msg as object),
232232+ source
233233+ });
234234+ }
235235+ } catch {
236236+ // Window may have been destroyed between check and send
237237+ }
238238+ }
239239+217240 // Broadcast to v2 tile BrowserWindows (launched by tile-launcher).
218241 // Without this, v2 tiles that subscribe to global pubsub events (editor:open,
219242 // item:created, etc.) never receive them.
220243 //
244244+ // Skip bgWindow if it appears in the tile registry — it is registered there
245245+ // via `registerTrustedBuiltinWindow` for lifecycle/cleanup bookkeeping, but
246246+ // we already sent to it above. Without this guard, bgWindow would receive
247247+ // each frame twice.
248248+ //
221249 // Echo-prevention compares FULL source URL against FULL window URL rather
222250 // than just the peek host. One feature tile can have multiple
223251 // BrowserWindows sharing the same host (e.g. websearch has peek://websearch/
···226254 // intra-feature pubsub round-trips. Full-URL compare only excludes the
227255 // exact originating window, allowing sibling entries to receive.
228256 for (const tileWin of getAllTileWindows()) {
257257+ if (tileWin === bgWindow) continue;
229258 try {
230259 const winUrl = tileWin.webContents.getURL();
231260 if (winUrl !== source) {
+48
backend/electron/pubsub.test.ts
···146146 pubsub.unsubscribe('peek://real/', 'test:has:excl');
147147 });
148148 });
149149+150150+ describe('setPubsubBroadcaster', () => {
151151+ it('GLOBAL publishes invoke the registered broadcaster with topic/msg/source', () => {
152152+ const calls: Array<{ topic: string; msg: unknown; source: string }> = [];
153153+ pubsub.setPubsubBroadcaster((topic, msg, source) => {
154154+ calls.push({ topic, msg, source });
155155+ });
156156+ pubsub.publish('peek://tile-foo/entry', pubsub.scopes.GLOBAL, 'test:broadcaster:fanout', { n: 1 });
157157+ assert.strictEqual(calls.length, 1);
158158+ assert.strictEqual(calls[0].topic, 'test:broadcaster:fanout');
159159+ assert.deepStrictEqual(calls[0].msg, { n: 1 });
160160+ assert.strictEqual(calls[0].source, 'peek://tile-foo/entry');
161161+ // Tear down so other tests aren't affected.
162162+ pubsub.setPubsubBroadcaster(() => {});
163163+ });
164164+165165+ it('SELF-scoped publishes do NOT invoke the broadcaster (broadcaster is for GLOBAL fan-out only)', () => {
166166+ let called = false;
167167+ pubsub.setPubsubBroadcaster(() => { called = true; });
168168+ pubsub.publish('peek://tile-foo/entry', pubsub.scopes.SELF, 'test:broadcaster:self', {});
169169+ assert.strictEqual(called, false);
170170+ pubsub.setPubsubBroadcaster(() => {});
171171+ });
172172+173173+ it('broadcaster receives a v2-tile publish of cmd:execute:*:result so bgWindow-subscriber dispatchers can resolve', () => {
174174+ // This is the Phase-1 regression guard. A v2 tile publishes a command
175175+ // result topic via GLOBAL scope. The main.ts broadcaster callback is
176176+ // responsible for forwarding this to bgWindow (core), which is where
177177+ // the dispatcher that set `resultTopic` lives. If the broadcaster is
178178+ // not invoked for GLOBAL publishes, bgWindow never resolves the
179179+ // pending result promise and tag/untag/widget-update smoke tests
180180+ // time out at 10s.
181181+ const received: Array<{ topic: string; source: string }> = [];
182182+ pubsub.setPubsubBroadcaster((topic, _msg, source) => {
183183+ received.push({ topic, source });
184184+ });
185185+ pubsub.publish(
186186+ 'peek://tags/background',
187187+ pubsub.scopes.GLOBAL,
188188+ 'cmd:execute:tag:result:abc-123',
189189+ { ok: true },
190190+ );
191191+ assert.strictEqual(received.length, 1);
192192+ assert.strictEqual(received[0].topic, 'cmd:execute:tag:result:abc-123');
193193+ assert.strictEqual(received[0].source, 'peek://tags/background');
194194+ pubsub.setPubsubBroadcaster(() => {});
195195+ });
196196+ });
149197});
+9-9
backend/electron/pubsub.ts
···44 * Handles:
55 * - Topic-based publish/subscribe
66 * - Scope-based message filtering (SYSTEM, SELF, GLOBAL)
77- * - Extension window broadcasting (via callback)
77+ * - Renderer window broadcasting (via callback)
88 *
99 * NOTE: The scope/topic logic is mirrored in app/lib/pubsub.js (the shared
1010 * renderer-layer PubSub engine). This file stays separate because the main
···2727// Topic subscribers: topic -> Map<source, callback>
2828const topics = new Map<string, Map<string, (msg: unknown) => void>>();
29293030-// Callback for broadcasting to extension windows
3131-let extensionBroadcaster: ((topic: string, msg: unknown, source: string) => void) | null = null;
3030+// Callback for broadcasting to renderer windows (bgWindow, tile windows, webview guests)
3131+let pubsubBroadcaster: ((topic: string, msg: unknown, source: string) => void) | null = null;
32323333/**
3434 * Pre-publish hooks let a module intercept publish calls before delivery.
···8989}
90909191/**
9292- * Set the callback for broadcasting to extension windows
9292+ * Set the callback for broadcasting to renderer windows
9393 * This is called from the main process to inject the window broadcasting logic
9494 */
9595-export function setExtensionBroadcaster(
9595+export function setPubsubBroadcaster(
9696 broadcaster: (topic: string, msg: unknown, source: string) => void
9797): void {
9898- extensionBroadcaster = broadcaster;
9898+ pubsubBroadcaster = broadcaster;
9999}
100100101101/**
···170170 }
171171 }
172172173173- // Route to extension windows (GLOBAL scope only)
174174- if (scope === scopes.GLOBAL && extensionBroadcaster) {
175175- extensionBroadcaster(topic, msg, source);
173173+ // Route to renderer windows (GLOBAL scope only)
174174+ if (scope === scopes.GLOBAL && pubsubBroadcaster) {
175175+ pubsubBroadcaster(topic, msg, source);
176176 }
177177}
178178
+1-1
backend/electron/test-fixture-glue.ts
···102102103103 testFixtureWindow = win;
104104105105- // Register with the tile launcher so the main-process extensionBroadcaster
105105+ // Register with the tile launcher so the main-process pubsubBroadcaster
106106 // forwards pubsub messages to this window. Tests subscribe to topics
107107 // (e.g. `editor:open`) and assert on the data received — without this
108108 // registration, the broadcaster would skip the fixture and tests hang.
+2-2
backend/electron/tile-launcher.ts
···5757// where electron's named ESM exports are empty. Unit tests load
5858// tile-launcher.js under that mode and would crash at module parse
5959// time. We inject the getters at app startup instead — same pattern
6060-// as `setExtensionBroadcaster` in pubsub.ts. Tests that stub the
6060+// as `setPubsubBroadcaster` in pubsub.ts. Tests that stub the
6161// launcher never call createTileBrowserWindow, so the hooks stay
6262// null and the baseline helpers fall back cleanly.
6363let _getProfileSession: (() => Electron.Session) | null = null;
···524524 * page-glue call this helper after creating their BrowserWindow to:
525525 *
526526 * - Add the window to the `tileWindows` registry so the pubsub
527527- * extensionBroadcaster in main.ts forwards messages to it.
527527+ * broadcaster in main.ts forwards messages to it.
528528 * - Wire the same close/revoke cleanup the regular launcher uses.
529529 *
530530 * See docs/v1-removal-plan.md — Phase 1a/1b.
+1
docs/tasks.md
···51515252- [x] **Commands for browser-extension options pages.** Registered `<name> options` command per installed Chromium extension that declares `options_page`/`options_ui.page`.
5353- [x] **Bundle TWP (Translate Web Pages) as a default browser extension.** TWP v10.1.1.0 vendored at `chrome-extensions/twp/` (~2.1MB). Auto-loads on startup.
5454+- [ ] **Restore bundled-extension links in page host widget.** Regression — at some point we lost the affordance where each bundled Chromium extension (TWP, Proton Pass, etc.) surfaced links in the page host widget to open its popup / options page / extension UI directly from the page canvas. Commands for options pages still exist (see above) but the in-widget links are gone. Investigate when this was lost (git log on the page widget / extension-host widget), restore the UI, and confirm it works for all bundled extensions.
54555556---
5657