···1717import { initPage } from './page-glue.js';
1818import { discoverExtensions, loadExtensionManifest, isBuiltinExtensionEnabled, getExternalExtensions, type ExtensionManifest, type ManifestCommand, type ManifestShortcut } from './extensions.js';
1919import { initializeFeatures, type FeatureStartupResult } from './feature-startup.js';
2020+import { ensureTileIpcHandlers } from './tile-compat.js';
2021import { getLoadedTileIds, getTileManifest, getAllTileWindows, unloadAllTiles } from './tile-launcher.js';
2122import { initTray } from './tray.js';
2223import { registerLocalShortcut, unregisterLocalShortcut, handleLocalShortcut, registerGlobalShortcut, unregisterGlobalShortcut, unregisterShortcutsForAddress } from './shortcuts.js';
···184185185186 // Initialize protocol handler
186187 initProtocol(config.rootDir);
188188+189189+ // Register tile:* IPC handlers BEFORE any BrowserWindow is created.
190190+ // Without this, core renderers (cmd, hud, page) launching with
191191+ // tile-preload would silently drop their `tile:validate-token` and
192192+ // `tile:pubsub:*` IPC sends — handlers were historically registered
193193+ // lazily from `loadV2Tile()` which runs after core glue.
194194+ ensureTileIpcHandlers();
187195188196 // Initialize database
189197 const dbPath = path.join(config.userDataPath, config.profile, 'datastore.sqlite');
+17
backend/electron/tile-compat.ts
···38383939let ipcHandlersRegistered = false;
40404141+/**
4242+ * Ensure the `tile:*` ipcMain handlers are registered. Idempotent.
4343+ *
4444+ * Historically called lazily from `loadV2Tile()`, so when trustedBuiltin
4545+ * core renderers (cmd, hud, page) launched before any v2 feature tile,
4646+ * the handlers weren't yet registered and every
4747+ * `ipcRenderer.send('tile:pubsub:publish', …)` silently dropped. Core
4848+ * renderers and any other caller that needs tile IPC up front should
4949+ * call this helper directly — typically from `main.ts::initialize()`
5050+ * before any BrowserWindow is created.
5151+ */
5252+export function ensureTileIpcHandlers(): void {
5353+ if (ipcHandlersRegistered) return;
5454+ registerTileIpcHandlers();
5555+ ipcHandlersRegistered = true;
5656+}
5757+4158// ─── Discovery ───────────────────────────────────────────────────────
42594360/**
+129
tests/desktop/v2-pubsub-reproducer.spec.ts
···11+/**
22+ * Minimal reproducer for the v2-tile pubsub routing bug that blocked
33+ * v1-removal Phase 2.
44+ *
55+ * Approach: use windows that already exist post-Phase-1 as a pure
66+ * v2→v2 pubsub round-trip. Both cmd (via cmd-glue) and hud (via
77+ * hud-glue) are trustedBuiltin tile renderers loaded at startup
88+ * with `tile-preload.cjs`. cmd publishes, hud subscribes. If the
99+ * subscriber never fires, the v2→v2 pubsub path is broken — no test
1010+ * fixture needed.
1111+ *
1212+ * Observed: under `yarn test:grep` (E2E test profile), hud window is
1313+ * NOT present in `electronApp.windows()` (only cmd, app/background,
1414+ * app/extension-host, app/settings). Falls back to looking for ANY
1515+ * other v2 tile window (e.g. entities/background.html) as subscriber.
1616+ * Test throws with the available URL list when no pairing is found —
1717+ * serves as diagnostic output for later investigation.
1818+ */
1919+2020+import { test, expect, DesktopApp, getSharedApp, closeSharedApp } from '../fixtures/desktop-app';
2121+import { Page } from '@playwright/test';
2222+import { waitForExtensionsReady } from '../helpers/window-utils';
2323+2424+let sharedApp: DesktopApp;
2525+let sharedBgWindow: Page;
2626+2727+test.beforeAll(async () => {
2828+ sharedApp = await getSharedApp();
2929+ sharedBgWindow = await sharedApp.getBackgroundWindow();
3030+ await waitForExtensionsReady(sharedBgWindow);
3131+});
3232+3333+test.afterAll(async () => {
3434+ await closeSharedApp();
3535+});
3636+3737+function findTileWindow(app: DesktopApp, urlMatch: string): Page | null {
3838+ for (const w of app.windows()) {
3939+ if (w.url().includes(urlMatch)) return w;
4040+ }
4141+ return null;
4242+}
4343+4444+test('v2 trustedBuiltin tile → v2 trustedBuiltin tile pubsub round-trip', async () => {
4545+ // Give hud/page glue time to launch their resident renderers.
4646+ // initHud / initPage are awaited inside loadExtensions() but the
4747+ // BrowserWindow's URL may not resolve to the final peek:// host until
4848+ // the protocol handler fires. 2s is generous.
4949+ await new Promise(r => setTimeout(r, 2000));
5050+5151+ const urls = sharedApp.windows().map(w => w.url());
5252+ console.log('[repro] available window URLs:', JSON.stringify(urls));
5353+5454+ // cmd resident (publisher) — trustedBuiltin tile from cmd-glue
5555+ const cmdWin = findTileWindow(sharedApp, 'peek://cmd/index.html');
5656+ if (!cmdWin) {
5757+ throw new Error(`cmd window not found. URLs: ${JSON.stringify(urls)}`);
5858+ }
5959+6060+ // Pick any second v2 tile window (subscriber). Try hud, then page,
6161+ // then any eager feature tile.
6262+ const subWin =
6363+ findTileWindow(sharedApp, 'peek://hud/index.html') ||
6464+ findTileWindow(sharedApp, 'peek://page/') ||
6565+ findTileWindow(sharedApp, 'peek://entities/') ||
6666+ findTileWindow(sharedApp, 'peek://me/') ||
6767+ findTileWindow(sharedApp, 'peek://atproto/');
6868+6969+ if (!subWin) {
7070+ throw new Error(
7171+ `No second v2 tile window found for subscriber pairing. ` +
7272+ `Available URLs: ${JSON.stringify(urls)}`
7373+ );
7474+ }
7575+ console.log('[repro] subscriber window:', subWin.url());
7676+7777+ // 1. Subscribe on the second v2 tile.
7878+ await subWin.evaluate(() => {
7979+ const api = (window as any).app;
8080+ (window as any).__reproReceived = null;
8181+ (window as any).__reproDiag = {
8282+ apiExists: !!api,
8383+ hasSubscribe: !!(api && api.subscribe),
8484+ hasScopes: !!(api && api.scopes),
8585+ scopeGlobal: api && api.scopes && api.scopes.GLOBAL,
8686+ };
8787+ if (api && api.subscribe && api.scopes) {
8888+ api.subscribe('repro:v2-v2-hello', (msg: unknown) => {
8989+ (window as any).__reproReceived = msg;
9090+ }, api.scopes.GLOBAL);
9191+ (window as any).__reproDiag.subscribed = true;
9292+ }
9393+ });
9494+9595+ // Small buffer: IPC subscribe is async in main.
9696+ await new Promise(r => setTimeout(r, 200));
9797+9898+ // 2. Publish from cmd.
9999+ const pubResult = await cmdWin.evaluate(() => {
100100+ const api = (window as any).app;
101101+ const diag: any = {
102102+ apiExists: !!api,
103103+ hasPublish: !!(api && api.publish),
104104+ scopeGlobal: api && api.scopes && api.scopes.GLOBAL,
105105+ };
106106+ if (api && api.publish && api.scopes) {
107107+ api.publish('repro:v2-v2-hello', { from: 'cmd', ts: Date.now() }, api.scopes.GLOBAL);
108108+ diag.published = true;
109109+ }
110110+ return diag;
111111+ });
112112+ console.log('[repro] publish diag:', JSON.stringify(pubResult));
113113+114114+ // 3. Wait for subscriber to receive. 5s is generous — v1 pubsub
115115+ // round-trips finish in <50ms.
116116+ const received = await subWin.waitForFunction(
117117+ () => (window as any).__reproReceived !== null,
118118+ undefined,
119119+ { timeout: 5000 }
120120+ ).then(h => h.jsonValue()).catch(async () => {
121121+ const diag = await subWin.evaluate(() => (window as any).__reproDiag);
122122+ console.log('[repro] sub diag at timeout:', JSON.stringify(diag));
123123+ return null;
124124+ });
125125+ console.log('[repro] received:', JSON.stringify(received));
126126+127127+ expect(pubResult.published, `cmd failed to publish: ${JSON.stringify(pubResult)}`).toBe(true);
128128+ expect(received, 'subscriber never received cmd publish — v2 pubsub routing broken').not.toBeNull();
129129+});