experiments in a post-browser web
10
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(pubsub): Phase 4 — private lifecycle IPC + subscribe-before-publish

+595 -64
+8
backend/electron/core-glue.ts
··· 52 52 import { generateToken } from './tile-tokens.js'; 53 53 import { registerTrustedBuiltinWindow } from './tile-launcher.js'; 54 54 import { ensureTileIpcHandlers } from './tile-compat.js'; 55 + import { registerBgWindow } from './tile-lifecycle.js'; 55 56 import { getSystemThemeBackgroundColor } from './windows.js'; 56 57 import { getProfileSession } from './session-partition.js'; 57 58 ··· 93 94 // Belt-and-suspenders: main.ts::initialize() already calls this 94 95 // centrally, but the helper is idempotent. Documents the dependency. 95 96 ensureTileIpcHandlers(); 97 + 98 + // Declare this renderer as bgWindow so the subscribe-before-publish 99 + // latch in tile-lifecycle.ts knows which `tile:lifecycle:ready` 100 + // arrival unlatches feature-tile loads. Must happen BEFORE the 101 + // BrowserWindow is created — the tile's preload sends ready on the 102 + // next event-loop tick after `api.initialize()` resolves. 103 + registerBgWindow(CORE_ID, CORE_ENTRY_ID); 96 104 97 105 const electron = requireElectron('electron') as typeof import('electron'); 98 106 const BrowserWindowCtor = electron.BrowserWindow;
+17 -3
backend/electron/main.ts
··· 17 17 import { discoverExtensions, loadExtensionManifest, isBuiltinExtensionEnabled, type ExtensionManifest, type ManifestCommand, type ManifestShortcut } from './extensions.js'; 18 18 import { initializeFeatures, type FeatureStartupResult } from './feature-startup.js'; 19 19 import { ensureTileIpcHandlers } from './tile-compat.js'; 20 - import { getLoadedTileIds, getTileManifest, getAllTileWindows, unloadAllTiles, relaunchTile, configureTileLauncher } from './tile-launcher.js'; 20 + import { 21 + registerTileLifecycleIpc, 22 + setReadyCallback as setLifecycleReadyCallback, 23 + } from './tile-lifecycle.js'; 24 + import { signalTileReady, getLoadedTileIds, getTileManifest, getAllTileWindows, unloadAllTiles, relaunchTile, configureTileLauncher } from './tile-launcher.js'; 21 25 import { installLoadOnDispatchHook } from './tile-lazy.js'; 22 26 import { initTray } from './tray.js'; 23 27 import { registerLocalShortcut, unregisterLocalShortcut, handleLocalShortcut, registerGlobalShortcut, unregisterGlobalShortcut, unregisterShortcutsForAddress } from './shortcuts.js'; ··· 180 184 // `tile:pubsub:*` IPC sends — handlers were historically registered 181 185 // lazily from `loadV2Tile()` which runs after core glue. 182 186 ensureTileIpcHandlers(); 187 + 188 + // Register the private `tile:lifecycle:ready` IPC handler. Must also 189 + // run before any BrowserWindow is created; the core background 190 + // renderer launched by `initCore()` sends `tile:lifecycle:ready` from 191 + // its preload `api.initialize()` resolution tick. `setReadyCallback` 192 + // injects the tile-launcher's `signalTileReady` so the lifecycle 193 + // module can drive LOADING→READY without importing tile-launcher 194 + // (one-way dependency: tile-launcher → tile-lifecycle). 195 + setLifecycleReadyCallback(signalTileReady); 196 + registerTileLifecycleIpc(); 183 197 184 198 // Resolve & cache the tile-preload path early (also used by `initCore` 185 199 // from entry.ts before `loadExtensions` runs). Preload source is ··· 1563 1577 /** 1564 1578 * Shutdown the application 1565 1579 * 1566 - * Fires the `app:shutdown` pubsub event, then broadcasts `tile:shutdown` to 1567 - * every live v2 tile and waits one grace period so tile `api.onShutdown()` 1580 + * Fires the `app:shutdown` pubsub event, then broadcasts `tile:lifecycle:shutdown` 1581 + * to every live v2 tile and waits one grace period so tile `api.onShutdown()` 1568 1582 * callbacks can run before the BrowserWindows are closed. Finally closes the 1569 1583 * database. 1570 1584 *
+1 -1
backend/electron/tile-ipc-sender-check.ts
··· 24 24 * `will-attach-webview` where Electron doesn't surface the guest 25 25 * wc id), the first IPC frame that carries the token binds 26 26 * `event.sender.id` atomically. Legitimate renderers always send 27 - * their own first frame (tile:validate-token or tile:ready during 27 + * their own first frame (tile:validate-token or tile:lifecycle:ready during 28 28 * preload) before any other code can access the token, so TOFU is 29 29 * race-free in practice. 30 30 *
+4 -16
backend/electron/tile-ipc.ts
··· 3 3 * 4 4 * Handles: 5 5 * - tile:validate-token — validate a capability token and return grants 6 - * - tile:ready — tile signals initialization complete 7 6 * - tile:pubsub:* — scoped pubsub operations 8 7 * - tile:command:* — command registration and results 9 8 * - tile:window:* — window management ··· 24 23 import { 25 24 validateToken, 26 25 getGrantForToken, 27 - signalTileReady, 28 26 getTileWindow, 29 27 isTileLoaded, 30 28 unloadTile, ··· 476 474 }); 477 475 478 476 // ── Tile Ready ── 479 - 480 - ipcMain.on('tile:ready', (_event, args: { 481 - tileId: string; 482 - tileEntry: string; 483 - }) => { 484 - // tile:ready does not carry a capability token (the renderer 485 - // signals readiness by tile id + entry id only — the launcher 486 - // already knows which window sent it via the tileWindows map). 487 - // Phase 8 will tighten this: lifecycle becomes private IPC, not 488 - // a pubsub-reachable surface, at which point the sender-frame 489 - // check applies there too. For Phase 2 there is no token on the 490 - // wire to cross-check against. 491 - signalTileReady(args.tileId, args.tileEntry); 492 - }); 477 + // 478 + // Private lifecycle IPC (`tile:lifecycle:ready` / `tile:lifecycle:shutdown`) 479 + // moved to tile-lifecycle.ts in Phase 4. See 480 + // docs/pubsub-state-machine.md §Topics that are NOT pubsub. 493 481 494 482 // ── PubSub ── 495 483
+9 -9
backend/electron/tile-launcher.test.ts
··· 103 103 __clearTileStateForTest(); 104 104 }); 105 105 106 - it('sends tile:shutdown to the registered window and then closes it', async () => { 106 + it('sends tile:lifecycle:shutdown to the registered window and then closes it', async () => { 107 107 const win = makeFakeWin(); 108 108 __setTileWindowForTest('fx-tile', 'bg', win); 109 109 110 110 await shutdownTile('fx-tile', 'bg'); 111 111 112 - assert.deepStrictEqual(win._sends, ['tile:shutdown']); 112 + assert.deepStrictEqual(win._sends, ['tile:lifecycle:shutdown']); 113 113 assert.strictEqual(win._closed, true); 114 114 }); 115 115 ··· 124 124 __clearTileStateForTest(); 125 125 }); 126 126 127 - it('sends tile:shutdown before closing and clears registry entry', async () => { 127 + it('sends tile:lifecycle:shutdown before closing and clears registry entry', async () => { 128 128 const win = makeFakeWin(); 129 129 __setTileWindowForTest('unload-tile', 'bg', win); 130 130 assert.strictEqual(isTileLoaded('unload-tile', 'bg'), true); 131 131 132 132 await unloadTile('unload-tile', 'bg'); 133 133 134 - assert.deepStrictEqual(win._sends, ['tile:shutdown']); 134 + assert.deepStrictEqual(win._sends, ['tile:lifecycle:shutdown']); 135 135 assert.strictEqual(win._closed, true); 136 136 assert.strictEqual(isTileLoaded('unload-tile', 'bg'), false); 137 137 }); ··· 145 145 // ordering via a unit-friendly replica: 146 146 147 147 describe('shutdownTile ordering contract', () => { 148 - it('sends tile:shutdown IPC, then closes after the grace delay', async () => { 148 + it('sends tile:lifecycle:shutdown IPC, then closes after the grace delay', async () => { 149 149 // This test mirrors the exact sequence shutdownTile follows: 150 - // webContents.send('tile:shutdown') (immediate) 150 + // webContents.send('tile:lifecycle:shutdown') (immediate) 151 151 // await sleep(grace) 152 152 // win.close() (after) 153 153 // It guards against regressions that move win.close() before the IPC send ··· 168 168 async function shutdown(win: typeof fakeWin): Promise<void> { 169 169 if (!win || win.isDestroyed()) return; 170 170 if (!win.webContents.isDestroyed()) { 171 - win.webContents.send('tile:shutdown'); 171 + win.webContents.send('tile:lifecycle:shutdown'); 172 172 } 173 173 await new Promise<void>((resolve) => setTimeout(resolve, grace)); 174 174 if (!win.isDestroyed()) { ··· 180 180 await shutdown(fakeWin); 181 181 const elapsed = Date.now() - t0; 182 182 183 - assert.deepStrictEqual(events, ['send:tile:shutdown', 'close']); 183 + assert.deepStrictEqual(events, ['send:tile:lifecycle:shutdown', 'close']); 184 184 assert.ok(elapsed >= grace - 10, `should have waited at least ~${grace}ms, waited ${elapsed}ms`); 185 185 }); 186 186 ··· 198 198 async function shutdown(win: typeof fakeWin): Promise<void> { 199 199 if (!win || win.isDestroyed()) return; 200 200 if (!win.webContents.isDestroyed()) { 201 - win.webContents.send('tile:shutdown'); 201 + win.webContents.send('tile:lifecycle:shutdown'); 202 202 } 203 203 await new Promise<void>((resolve) => setTimeout(resolve, 5)); 204 204 if (!win.isDestroyed()) {
+11 -11
backend/electron/tile-launcher.ts
··· 7 7 * - Creating hidden windows for background tiles 8 8 * - Generating capability tokens (opaque string encoding tile ID + granted capabilities) 9 9 * - Passing tokens to tiles via additionalArguments in WebPreferences 10 - * - Lifecycle: load manifest -> grant capabilities -> create context -> tile calls initialize() -> tile:ready 10 + * - Lifecycle: load manifest -> grant capabilities -> create context -> tile calls initialize() -> tile:lifecycle:ready 11 11 */ 12 12 13 13 import type { BrowserWindow } from 'electron'; ··· 717 717 } 718 718 719 719 /** 720 - * Send the `tile:shutdown` IPC to a single tile window, wait a short grace 721 - * period for its `api.onShutdown()` callbacks to run, and then close the 722 - * BrowserWindow. 720 + * Send the `tile:lifecycle:shutdown` IPC to a single tile window, wait a 721 + * short grace period for its `api.onShutdown()` callbacks to run, and then 722 + * close the BrowserWindow. 723 723 * 724 724 * Tiles register shutdown callbacks via `api.onShutdown(cb)` in tile-preload. 725 725 * Without this signal the callback never fires, so intervals / timers / ··· 739 739 try { 740 740 // Notify the tile so `api.onShutdown()` callbacks can run. 741 741 if (!win.webContents.isDestroyed()) { 742 - win.webContents.send('tile:shutdown'); 742 + win.webContents.send('tile:lifecycle:shutdown'); 743 743 } 744 744 } catch (err) { 745 - console.error(`[tile-launcher:${tileId}:${entryId}] Failed to send tile:shutdown:`, err); 745 + console.error(`[tile-launcher:${tileId}:${entryId}] Failed to send tile:lifecycle:shutdown:`, err); 746 746 } 747 747 748 748 // Give the tile a short grace period to clean up. We intentionally don't ··· 869 869 /** 870 870 * Unload all tiles — called during app shutdown. 871 871 * 872 - * Broadcasts `tile:shutdown` to every tile in parallel, waits the shared grace 873 - * period once, then closes all windows. This keeps quit latency bounded to 874 - * TILE_SHUTDOWN_GRACE_MS regardless of how many tiles are active. 872 + * Broadcasts `tile:lifecycle:shutdown` to every tile in parallel, waits the 873 + * shared grace period once, then closes all windows. This keeps quit latency 874 + * bounded to TILE_SHUTDOWN_GRACE_MS regardless of how many tiles are active. 875 875 */ 876 876 export async function unloadAllTiles(): Promise<void> { 877 877 // Phase 1: fan out shutdown signal to every tile. ··· 879 879 if (win.isDestroyed()) continue; 880 880 try { 881 881 if (!win.webContents.isDestroyed()) { 882 - win.webContents.send('tile:shutdown'); 882 + win.webContents.send('tile:lifecycle:shutdown'); 883 883 } 884 884 } catch (err) { 885 - console.error('[tile-launcher] Failed to send tile:shutdown during unloadAllTiles:', err); 885 + console.error('[tile-launcher] Failed to send tile:lifecycle:shutdown during unloadAllTiles:', err); 886 886 } 887 887 } 888 888
+275
backend/electron/tile-lifecycle.test.ts
··· 1 + /** 2 + * Unit tests for tile-lifecycle.ts — Phase 4. 3 + * 4 + * Covers: 5 + * - `tile:state-changed` System-topic emission on every transition. 6 + * - bgWindow-ready gate: `requestLoad()` for non-bgWindow tiles is 7 + * held until bgWindow signals `tile:lifecycle:ready`. 8 + * - Subscribe-before-publish invariant: a subscriber registered 9 + * synchronously against pubsub before the publisher fires (simulating 10 + * `cmd:register` at t=0 boot) receives the publish. 11 + * 12 + * Runs under Electron's Node host (via `yarn test:unit`). The tile- 13 + * lifecycle module imports `ipcMain` from electron for the lifecycle 14 + * IPC handler; we avoid exercising that code path here and use 15 + * `__simulateLifecycleReadyForTest()` instead — same side effects 16 + * (latch release + ready callback) without needing ipcMain in the 17 + * test host. 18 + */ 19 + 20 + import { describe, it, beforeEach } from 'node:test'; 21 + import * as assert from 'node:assert'; 22 + 23 + import { 24 + STATES, 25 + TRIGGERS, 26 + transition, 27 + requestLoad, 28 + registerBgWindow, 29 + isBgWindowReady, 30 + waitForBgWindowReady, 31 + setReadyCallback, 32 + __simulateLifecycleReadyForTest, 33 + resetForTests, 34 + } from './tile-lifecycle.js'; 35 + import { 36 + subscribe, 37 + unsubscribe, 38 + publish, 39 + getSystemAddress, 40 + __clearPrePublishHooksForTest, 41 + scopes, 42 + } from './pubsub.js'; 43 + 44 + // Ensure NODE_ENV is 'test' — resetForTests / __simulateLifecycleReadyForTest 45 + // throw otherwise, and we need them to guard against cross-test leakage. 46 + if (process.env.NODE_ENV !== 'test') { 47 + process.env.NODE_ENV = 'test'; 48 + } 49 + 50 + // ─── Helpers ───────────────────────────────────────────────────────── 51 + 52 + interface StateChangedPayload { 53 + tileId: string; 54 + entryId: string; 55 + from: string; 56 + to: string; 57 + trigger: string; 58 + ts: number; 59 + } 60 + 61 + /** 62 + * Subscribe to `tile:state-changed` and accumulate every payload. Returns 63 + * the events array and an unsubscribe function. 64 + */ 65 + function captureStateChanged(): { 66 + events: StateChangedPayload[]; 67 + stop: () => void; 68 + } { 69 + const events: StateChangedPayload[] = []; 70 + const source = `test-state-listener-${Math.random().toString(36).slice(2, 8)}`; 71 + subscribe(source, scopes.GLOBAL, 'tile:state-changed', (msg) => { 72 + events.push(msg as StateChangedPayload); 73 + }); 74 + return { 75 + events, 76 + stop: () => { 77 + unsubscribe(source, 'tile:state-changed'); 78 + }, 79 + }; 80 + } 81 + 82 + // Walk the FSM into REGISTERED for a given tile so we have somewhere 83 + // for LOAD to transition from. 84 + function installRegistered(tileId: string, entryId: string): void { 85 + transition(tileId, entryId, TRIGGERS.INSTALL); 86 + } 87 + 88 + // ─── Tests ─────────────────────────────────────────────────────────── 89 + 90 + describe('tile-lifecycle: tile:state-changed observer topic', () => { 91 + beforeEach(() => { 92 + resetForTests(); 93 + __clearPrePublishHooksForTest(); 94 + }); 95 + 96 + it('publishes a tile:state-changed event on every successful transition', () => { 97 + const capture = captureStateChanged(); 98 + try { 99 + installRegistered('fx', 'bg'); 100 + transition('fx', 'bg', TRIGGERS.LOAD); 101 + transition('fx', 'bg', TRIGGERS.TILE_READY); 102 + 103 + assert.strictEqual(capture.events.length, 3); 104 + 105 + const [t0, t1, t2] = capture.events; 106 + assert.deepStrictEqual( 107 + { tileId: t0.tileId, entryId: t0.entryId, from: t0.from, to: t0.to, trigger: t0.trigger }, 108 + { tileId: 'fx', entryId: 'bg', from: STATES.UNREGISTERED, to: STATES.REGISTERED, trigger: TRIGGERS.INSTALL }, 109 + ); 110 + assert.deepStrictEqual( 111 + { tileId: t1.tileId, entryId: t1.entryId, from: t1.from, to: t1.to, trigger: t1.trigger }, 112 + { tileId: 'fx', entryId: 'bg', from: STATES.REGISTERED, to: STATES.LOADING, trigger: TRIGGERS.LOAD }, 113 + ); 114 + assert.deepStrictEqual( 115 + { tileId: t2.tileId, entryId: t2.entryId, from: t2.from, to: t2.to, trigger: t2.trigger }, 116 + { tileId: 'fx', entryId: 'bg', from: STATES.LOADING, to: STATES.READY, trigger: TRIGGERS.TILE_READY }, 117 + ); 118 + // ts present on every event 119 + for (const ev of capture.events) { 120 + assert.strictEqual(typeof ev.ts, 'number'); 121 + } 122 + } finally { 123 + capture.stop(); 124 + } 125 + }); 126 + 127 + it('does NOT publish on rejected transitions (disallowed trigger)', () => { 128 + const capture = captureStateChanged(); 129 + try { 130 + // UNREGISTERED tile can't receive LOAD — transition is rejected. 131 + const result = transition('never', 'bg', TRIGGERS.LOAD); 132 + assert.strictEqual(result.ok, false); 133 + assert.strictEqual(capture.events.length, 0); 134 + } finally { 135 + capture.stop(); 136 + } 137 + }); 138 + }); 139 + 140 + describe('tile-lifecycle: bgWindow-ready gate', () => { 141 + beforeEach(() => { 142 + resetForTests(); 143 + __clearPrePublishHooksForTest(); 144 + }); 145 + 146 + it('holds non-bgWindow requestLoad() until bgWindow signals ready', async () => { 147 + // Wire up the injected ready callback so simulate() can drive it. 148 + const readyCalls: Array<{ tileId: string; entryId: string }> = []; 149 + setReadyCallback((tileId, entryId) => { 150 + readyCalls.push({ tileId, entryId }); 151 + // Advance bgWindow's FSM to READY so a realistic "bgWindow ready" 152 + // state actually exists. Not strictly required for the gate 153 + // semantics under test, but mirrors production. 154 + transition(tileId, entryId, TRIGGERS.TILE_READY); 155 + }); 156 + 157 + // Register the core bgWindow + the feature tile. 158 + registerBgWindow('core', 'background'); 159 + installRegistered('core', 'background'); 160 + installRegistered('feature-a', 'bg'); 161 + 162 + // bgWindow itself can load before signalling ready — otherwise the 163 + // FSM would deadlock against its own gate. 164 + transition('core', 'background', TRIGGERS.LOAD); 165 + 166 + // Start the feature-a load request. It must NOT resolve before 167 + // bgWindow signals ready. 168 + let featureSettled = false; 169 + const featurePromise = requestLoad('feature-a', 'bg').then((r) => { 170 + featureSettled = true; 171 + return r; 172 + }); 173 + 174 + // Give the microtask queue a few turns — featurePromise should 175 + // remain pending because the latch is not released. 176 + await new Promise((r) => setTimeout(r, 10)); 177 + assert.strictEqual(featureSettled, false, 'feature load should be held until bgWindow ready'); 178 + assert.strictEqual(isBgWindowReady(), false); 179 + 180 + // Signal bgWindow ready — this unlatches feature-a's transition. 181 + __simulateLifecycleReadyForTest('core', 'background'); 182 + assert.strictEqual(isBgWindowReady(), true); 183 + assert.deepStrictEqual(readyCalls, [{ tileId: 'core', entryId: 'background' }]); 184 + 185 + const result = await featurePromise; 186 + assert.strictEqual(result.ok, true); 187 + assert.strictEqual(result.to, STATES.LOADING); 188 + }); 189 + 190 + it('resolves immediately for the bgWindow itself (no self-deadlock)', async () => { 191 + registerBgWindow('core', 'background'); 192 + installRegistered('core', 'background'); 193 + // bgWindow's own requestLoad should complete without waiting for 194 + // anything — the latch is not yet released. 195 + assert.strictEqual(isBgWindowReady(), false); 196 + const result = await requestLoad('core', 'background'); 197 + assert.strictEqual(result.ok, true); 198 + assert.strictEqual(result.to, STATES.LOADING); 199 + }); 200 + 201 + it('resolves immediately once the latch is released', async () => { 202 + setReadyCallback(() => {}); 203 + registerBgWindow('core', 'background'); 204 + installRegistered('core', 'background'); 205 + installRegistered('feature-b', 'bg'); 206 + 207 + transition('core', 'background', TRIGGERS.LOAD); 208 + __simulateLifecycleReadyForTest('core', 'background'); 209 + // bgWindow is now marked ready — subsequent feature loads should 210 + // resolve on the first tick. 211 + assert.strictEqual(isBgWindowReady(), true); 212 + const result = await requestLoad('feature-b', 'bg'); 213 + assert.strictEqual(result.ok, true); 214 + assert.strictEqual(result.to, STATES.LOADING); 215 + }); 216 + 217 + it('waitForBgWindowReady() resolves exactly once bgWindow signals ready', async () => { 218 + registerBgWindow('core', 'background'); 219 + setReadyCallback(() => {}); 220 + 221 + let resolved = false; 222 + const p = waitForBgWindowReady().then(() => { resolved = true; }); 223 + await new Promise((r) => setTimeout(r, 5)); 224 + assert.strictEqual(resolved, false); 225 + 226 + __simulateLifecycleReadyForTest('core', 'background'); 227 + await p; 228 + assert.strictEqual(resolved, true); 229 + }); 230 + 231 + it('does not block when no bgWindow has been registered (fallback)', async () => { 232 + // Unit-test scenarios that never call registerBgWindow() must still 233 + // work — requestLoad falls through to an immediate transition. 234 + installRegistered('standalone', 'bg'); 235 + const result = await requestLoad('standalone', 'bg'); 236 + assert.strictEqual(result.ok, true); 237 + assert.strictEqual(result.to, STATES.LOADING); 238 + }); 239 + }); 240 + 241 + describe('tile-lifecycle: subscribe-before-publish invariant', () => { 242 + beforeEach(() => { 243 + resetForTests(); 244 + __clearPrePublishHooksForTest(); 245 + }); 246 + 247 + it('a subscriber registered at t=0 boot receives a cmd:register published immediately after', () => { 248 + // This simulates the core subscriber (inside app/index.js → cmd 249 + // background) landing synchronously during core init, BEFORE any 250 + // tile fires its first `cmd:register`. With the Phase 4 gate in 251 + // place, tiles can't transition REGISTERED→LOADING until bgWindow 252 + // signals ready — by that time these subscribers exist, so the 253 + // first publishes are never dropped. 254 + const received: unknown[] = []; 255 + const source = 'test-core-cmd-registry'; 256 + subscribe(source, scopes.GLOBAL, 'cmd:register', (msg) => { 257 + received.push(msg); 258 + }); 259 + try { 260 + // Simulate a tile publishing its cmd:register. In the real 261 + // system the publish goes through `publish()` in pubsub.ts. 262 + // We call the same function directly here — the invariant under 263 + // test is about ordering, not about which process code path fires. 264 + publish(getSystemAddress(), scopes.GLOBAL, 'cmd:register', { 265 + name: 'hello', 266 + source: 'fx', 267 + }); 268 + 269 + assert.strictEqual(received.length, 1); 270 + assert.deepStrictEqual(received[0], { name: 'hello', source: 'fx' }); 271 + } finally { 272 + unsubscribe(source, 'cmd:register'); 273 + } 274 + }); 275 + });
+255 -13
backend/electron/tile-lifecycle.ts
··· 2 2 * tile-lifecycle — Main-process enforcement engine for the tile FSM. 3 3 * 4 4 * Owns the authoritative `tileId → state` map. Wires renderer events 5 - * (`tile:ready` pubsub, `render-process-gone` on BrowserWindow 6 - * webContents, window close) to state transitions. All callers that 7 - * mutate tile lifecycle go through this module rather than poking 8 - * BrowserWindow / tileWindows / token registries directly. 5 + * (`tile:lifecycle:ready` private IPC, `render-process-gone` on 6 + * BrowserWindow webContents, window close) to state transitions. All 7 + * callers that mutate tile lifecycle go through this module rather 8 + * than poking BrowserWindow / tileWindows / token registries directly. 9 9 * 10 10 * The transition table lives in `./tile-fsm.ts` as a pure module 11 11 * (no electron / node imports). This file is the side-effectful 12 12 * engine that applies those transitions against concrete electron 13 13 * primitives. 14 14 * 15 - * Phase A (this commit): exports + state store + transition helper. 16 - * No callers are wired yet. Phase D replaces the direct uses of 17 - * `launchTile` / `registerTrustedBuiltinWindow` / ad-hoc token revoke 18 - * calls with `requestLoad()` / `requestShow()` / `requestShutdown()`. 15 + * Phase 4: ownership of the private lifecycle IPC channels 16 + * (`tile:lifecycle:ready` / `tile:lifecycle:shutdown`) moves here 17 + * from tile-ipc.ts. Also introduces: 18 + * - `registerBgWindow()` + bgWindow-ready latch: the subscribe- 19 + * before-publish invariant. No non-bgWindow tile may transition 20 + * REGISTERED→LOADING until bgWindow signals `tile:lifecycle:ready`. 21 + * - `tile:state-changed` System pubsub topic: a read-only mirror of 22 + * every lifecycle transition. Observers (drift detectors, HUD 23 + * widgets) subscribe; publishers must not publish it directly. 19 24 * 20 - * See docs/tile-lifecycle-fsm.md. 25 + * See docs/pubsub-state-machine.md §Topics that are NOT pubsub 26 + * (private lifecycle IPC) and §Subscribe-before-publish invariant. 21 27 */ 28 + 29 + import { createRequire } from 'node:module'; 22 30 23 31 import { 24 32 STATES, ··· 27 35 isDispatchable, 28 36 acceptsDynamicRegistration, 29 37 } from './tile-fsm.js'; 38 + import { publish, scopes, getSystemAddress } from './pubsub.js'; 39 + 40 + // Lazy-load ipcMain via CommonJS require so this module can be imported 41 + // under ELECTRON_RUN_AS_NODE=1 (where electron's named ESM exports are 42 + // empty). Consumers that never actually call `registerTileLifecycleIpc` 43 + // (e.g. unit tests) don't trigger the require. Same pattern as 44 + // tile-launcher.ts::getBrowserWindowCtor. 45 + const requireElectron = createRequire(import.meta.url); 46 + let _ipcMain: typeof import('electron').ipcMain | null = null; 47 + function getIpcMain(): typeof import('electron').ipcMain { 48 + if (_ipcMain) return _ipcMain; 49 + const electron = requireElectron('electron') as typeof import('electron'); 50 + _ipcMain = electron.ipcMain; 51 + return _ipcMain; 52 + } 30 53 31 54 export { STATES, TRIGGERS, isDispatchable, acceptsDynamicRegistration }; 55 + 56 + /** 57 + * Injected side-effect callback for the private `tile:lifecycle:ready` 58 + * IPC handler. Called with `(tileId, entryId)` after the bgWindow-gate 59 + * latch (if applicable) has been resolved. 60 + * 61 + * Historically this was `tile-launcher.signalTileReady`. Registering it 62 + * via a setter rather than a direct import keeps the dependency arrow 63 + * tile-launcher → tile-lifecycle (one-way) and avoids a circular 64 + * import — see docs/pubsub-state-machine.md §Module layout. 65 + */ 66 + type ReadyCallback = (tileId: string, entryId: string) => void; 67 + let onReadyCallback: ReadyCallback | null = null; 68 + 69 + /** 70 + * Inject the `signalTileReady` callback. Called once at startup from 71 + * `main.ts::initialize()` before `registerTileLifecycleIpc()`. 72 + */ 73 + export function setReadyCallback(cb: ReadyCallback): void { 74 + onReadyCallback = cb; 75 + } 32 76 33 77 // --------------------------------------------------------------------------- 34 78 // State store ··· 66 110 } 67 111 68 112 // --------------------------------------------------------------------------- 113 + // bgWindow readiness gate — subscribe-before-publish invariant 114 + // --------------------------------------------------------------------------- 115 + // 116 + // Core subscribers (cmd registry, noun registry, core domain topics) live 117 + // inside the bgWindow renderer (`app/background.html` → `app/index.js`). 118 + // Those subscribers are registered synchronously during `init()`. Until 119 + // bgWindow's `tile:lifecycle:ready` IPC arrives, we MUST hold off any 120 + // other tile's REGISTERED→LOADING transition — otherwise the other 121 + // tile's first publishes (`cmd:register`, etc.) race the subscribers 122 + // and are silently dropped. 123 + // 124 + // Implementation: a simple one-shot latch. Once bgWindow signals ready, 125 + // the latch resolves and stays resolved for the rest of the process 126 + // lifetime. Crashing bgWindow mid-boot is a separate concern (Phase 7 127 + // timeout UX) — for Phase 4 a permanent latch is sufficient. 128 + 129 + interface BgWindowRegistration { 130 + tileId: string; 131 + entryId: string; 132 + } 133 + let bgWindow: BgWindowRegistration | null = null; 134 + let bgWindowReady = false; 135 + let bgWindowReadyResolve: (() => void) | null = null; 136 + let bgWindowReadyPromise: Promise<void> = new Promise<void>((resolve) => { 137 + bgWindowReadyResolve = resolve; 138 + }); 139 + 140 + /** 141 + * Declare which tile entry is the core bgWindow. Called once at startup 142 + * from `core-glue.ts::initCore()` (before the window signals ready). 143 + * 144 + * A second registration replaces the first — tests exercise this by 145 + * calling `resetForTests()` between scenarios. In production there is 146 + * exactly one bgWindow. 147 + */ 148 + export function registerBgWindow(tileId: string, entryId: string): void { 149 + bgWindow = { tileId, entryId }; 150 + } 151 + 152 + /** True iff bgWindow has signaled `tile:lifecycle:ready`. */ 153 + export function isBgWindowReady(): boolean { 154 + return bgWindowReady; 155 + } 156 + 157 + /** Promise that resolves when bgWindow signals ready. Never rejects. */ 158 + export function waitForBgWindowReady(): Promise<void> { 159 + return bgWindowReadyPromise; 160 + } 161 + 162 + /** 163 + * Check whether a tile is the registered bgWindow. 164 + * Returns false if bgWindow has not been registered yet. 165 + */ 166 + function isBgWindow(tileId: string, entryId: string): boolean { 167 + return !!bgWindow && bgWindow.tileId === tileId && bgWindow.entryId === entryId; 168 + } 169 + 170 + /** 171 + * Resolve the latch. Called when bgWindow's `tile:lifecycle:ready` 172 + * IPC lands. Idempotent. 173 + */ 174 + function markBgWindowReady(): void { 175 + if (bgWindowReady) return; 176 + bgWindowReady = true; 177 + if (bgWindowReadyResolve) { 178 + const r = bgWindowReadyResolve; 179 + bgWindowReadyResolve = null; 180 + r(); 181 + } 182 + } 183 + 184 + /** 185 + * Async gate for `REGISTERED → LOADING` transitions. 186 + * 187 + * - bgWindow itself: resolves immediately (bgWindow must be allowed 188 + * to load before it can signal ready). 189 + * - Any other tile: awaits the latch. 190 + * - If no bgWindow has been registered yet we resolve immediately. 191 + * This preserves backwards compatibility with unit tests / 192 + * trustedBuiltin paths that never call `registerBgWindow()`. 193 + * Production always registers bgWindow before launching any feature 194 + * tile (see `main.ts` wiring). 195 + */ 196 + export async function requestLoad(tileId: string, entryId: string): Promise<TransitionResult> { 197 + if (bgWindow && !isBgWindow(tileId, entryId) && !bgWindowReady) { 198 + await bgWindowReadyPromise; 199 + } 200 + return transition(tileId, entryId, TRIGGERS.LOAD, { source: 'requestLoad' }); 201 + } 202 + 203 + // --------------------------------------------------------------------------- 69 204 // Transition — the only way state changes 70 205 // --------------------------------------------------------------------------- 71 206 ··· 79 214 /** 80 215 * Request a transition. Returns a result object. Never throws. 81 216 * 82 - * On success, updates the internal state map and records the 83 - * transition. On failure, leaves the state map unchanged and returns 84 - * an error — caller decides whether to log, surface, or ignore. 217 + * On success, updates the internal state map, records the transition, 218 + * and publishes the observer-mirror `tile:state-changed` System topic. 219 + * On failure, leaves the state map unchanged and returns an error — 220 + * caller decides whether to log, surface, or ignore. 85 221 * 86 222 * Rationale for never throwing: the enforcement engine is called from 87 223 * IPC handlers, event listeners, and renderer-driven requests. Throws ··· 113 249 records.set(k, { tileId, entryId, state: result.to }); 114 250 } 115 251 252 + const now = Date.now(); 116 253 lastTransition.set(k, { 117 254 from, 118 255 to: result.to, 119 256 trigger, 120 - timestamp: Date.now(), 257 + timestamp: now, 121 258 context, 122 259 }); 123 260 261 + // Publish the System-owned observer-mirror topic. Every transition 262 + // fires this — drift detectors (Phase 8) and HUD widgets that want 263 + // to display tile state subscribe to it. Publishers must not publish 264 + // `tile:state-changed` directly; the authorization-rules table 265 + // restricts writes to System. 266 + try { 267 + publish(getSystemAddress(), scopes.GLOBAL, 'tile:state-changed', { 268 + tileId, 269 + entryId, 270 + from, 271 + to: result.to, 272 + trigger, 273 + ts: now, 274 + }); 275 + } catch (err) { 276 + // Observer-topic publish failures must not corrupt the transition — 277 + // the FSM state is authoritative regardless of whether observers 278 + // got the mirror event. Log and move on. 279 + console.error('[tile-lifecycle] tile:state-changed publish threw:', err); 280 + } 281 + 124 282 return { ok: true, from, to: result.to }; 125 283 } 126 284 285 + // --------------------------------------------------------------------------- 286 + // Private lifecycle IPC — tile:lifecycle:ready / tile:lifecycle:shutdown 287 + // --------------------------------------------------------------------------- 288 + // 289 + // These are NOT pubsub topics. They're private IPC between a tile's 290 + // preload and this module. See docs/pubsub-state-machine.md §Topics 291 + // that are NOT pubsub. 292 + // 293 + // `tile:lifecycle:ready`: preload → main, sent once after the tile 294 + // has called `api.initialize()`. Causes the LOADING → READY 295 + // transition (via the injected ready callback). If the signaller is 296 + // the registered bgWindow, also resolves the subscribe-before-publish 297 + // latch. 298 + // 299 + // `tile:lifecycle:shutdown`: main → preload, sent during the unload 300 + // grace window. Lives here as a channel name constant; the actual 301 + // sends live in tile-launcher.ts where the BrowserWindow handle 302 + // is in scope. 303 + 304 + /** Channel name constants — exported so tile-launcher / tile-preload stay in sync. */ 305 + export const TILE_LIFECYCLE_READY_CHANNEL = 'tile:lifecycle:ready'; 306 + export const TILE_LIFECYCLE_SHUTDOWN_CHANNEL = 'tile:lifecycle:shutdown'; 307 + 308 + let lifecycleIpcRegistered = false; 309 + 310 + /** 311 + * Register the private lifecycle IPC handlers. Called once from 312 + * `main.ts::initialize()` (alongside `ensureTileIpcHandlers()`). 313 + * Idempotent. 314 + */ 315 + export function registerTileLifecycleIpc(): void { 316 + if (lifecycleIpcRegistered) return; 317 + lifecycleIpcRegistered = true; 318 + 319 + const ipcMain = getIpcMain(); 320 + ipcMain.on(TILE_LIFECYCLE_READY_CHANNEL, (_event, args: { 321 + tileId: string; 322 + tileEntry: string; 323 + }) => { 324 + // tile:lifecycle:ready does not carry a capability token (the 325 + // renderer signals readiness by tile id + entry id only — the 326 + // launcher already knows which window sent it via the tileWindows 327 + // map). Phase 8 introduces a main-process IPC gate that will add 328 + // sender-frame verification here too. 329 + if (!args || typeof args.tileId !== 'string' || typeof args.tileEntry !== 'string') { 330 + console.error('[tile-lifecycle] tile:lifecycle:ready dropped: malformed payload'); 331 + return; 332 + } 333 + 334 + // If the signaller is the registered bgWindow, unlatch first. Doing 335 + // this BEFORE the ready callback ensures any observer that reacts 336 + // to the LOADING → READY transition on bgWindow already sees 337 + // bgWindow marked ready in the same tick. 338 + if (isBgWindow(args.tileId, args.tileEntry)) { 339 + markBgWindowReady(); 340 + } 341 + 342 + if (onReadyCallback) { 343 + onReadyCallback(args.tileId, args.tileEntry); 344 + } else { 345 + console.error('[tile-lifecycle] tile:lifecycle:ready arrived before setReadyCallback() — dropping'); 346 + } 347 + }); 348 + } 349 + 350 + /** @internal Test hook: simulate a `tile:lifecycle:ready` IPC arrival without wiring ipcMain. */ 351 + export function __simulateLifecycleReadyForTest(tileId: string, entryId: string): void { 352 + if (process.env.NODE_ENV !== 'test') { 353 + throw new Error('[tile-lifecycle] __simulateLifecycleReadyForTest called outside NODE_ENV=test'); 354 + } 355 + if (isBgWindow(tileId, entryId)) { 356 + markBgWindowReady(); 357 + } 358 + if (onReadyCallback) { 359 + onReadyCallback(tileId, entryId); 360 + } 361 + } 362 + 127 363 // Deliberately no `setStateUnchecked()` — every state change MUST go 128 364 // through `transition()` so the FSM is the only source of truth. Tests 129 365 // that need a specific state walk the transition graph like production ··· 178 414 } 179 415 records.clear(); 180 416 lastTransition.clear(); 417 + bgWindow = null; 418 + bgWindowReady = false; 419 + bgWindowReadyPromise = new Promise<void>((resolve) => { 420 + bgWindowReadyResolve = resolve; 421 + }); 422 + onReadyCallback = null; 181 423 }
+12 -8
backend/electron/tile-preload.cts
··· 106 106 throw new Error('Tile token validation failed. Tile may have been revoked.'); 107 107 } 108 108 109 - // Defer tile:ready to the next event-loop tick so any synchronous 110 - // post-initialize code in background.html (e.g. extension.init() + 111 - // api.commands.register()) has a chance to complete before the lazy 112 - // system's hasSubscriber check fires. 109 + // Defer tile:lifecycle:ready to the next event-loop tick so any 110 + // synchronous post-initialize code in background.html (e.g. 111 + // extension.init() + api.commands.register()) has a chance to 112 + // complete before the lazy system's hasSubscriber check fires. 113 + // 114 + // Channel is private lifecycle IPC — not pubsub. See 115 + // docs/pubsub-state-machine.md §Topics that are NOT pubsub. 113 116 const result = { capabilities: grantedCapabilities }; 114 117 setTimeout(() => { 115 - ipcRenderer.send('tile:ready', { tileId, tileEntry }); 118 + ipcRenderer.send('tile:lifecycle:ready', { tileId, tileEntry }); 116 119 }, 0); 117 120 return result; 118 121 }; ··· 122 125 * 123 126 * The underlying IPC listener is installed exactly once. Subsequent calls 124 127 * replace the stored callback — repeated `api.onShutdown(cb)` invocations 125 - * do NOT accumulate listeners on the `tile:shutdown` channel. This matters 126 - * for hot-reload scenarios and features that re-register on every init. 128 + * do NOT accumulate listeners on the `tile:lifecycle:shutdown` channel. 129 + * This matters for hot-reload scenarios and features that re-register on 130 + * every init. 127 131 */ 128 132 let shutdownCallback: (() => void) | null = null; 129 133 let shutdownListenerInstalled = false; ··· 131 135 shutdownCallback = typeof callback === 'function' ? callback : null; 132 136 if (!shutdownListenerInstalled) { 133 137 shutdownListenerInstalled = true; 134 - ipcRenderer.on('tile:shutdown', () => { 138 + ipcRenderer.on('tile:lifecycle:shutdown', () => { 135 139 try { 136 140 shutdownCallback?.(); 137 141 } catch (err) {
+3 -3
tests/desktop/localsearch.spec.ts
··· 38 38 test('lists window opens via command', async () => { 39 39 // Ensure the lists command is registered before executing it. 40 40 // The lists tile is lazy-loaded — the background tile must be loaded and 41 - // tile:ready received before the execute handler is registered. 41 + // tile:lifecycle:ready received before the execute handler is registered. 42 42 await waitForCommand(sharedBgWindow, 'lists', 15000); 43 43 44 44 // Start waiting for the window BEFORE publishing the command so we don't 45 45 // miss a fast creation (getWindow polls every 200ms and returns immediately 46 46 // when the window appears — the 15s cap handles the full lazy-load chain: 47 - // background-tile launch → tile:ready → execute replay → tile:window:open). 47 + // background-tile launch → tile:lifecycle:ready → execute replay → tile:window:open). 48 48 const windowPromise = sharedApp.getWindow('lists/home.html', 15000); 49 49 50 50 // Execute the lists command ··· 71 71 test('lists input renders and is auto-focused', async () => { 72 72 // Ensure the lists command is registered before executing it. 73 73 // The lists tile is lazy-loaded — the background tile must be loaded and 74 - // tile:ready received before the execute handler is registered. 74 + // tile:lifecycle:ready received before the execute handler is registered. 75 75 await waitForCommand(sharedBgWindow, 'lists', 15000); 76 76 77 77 // Start waiting for the window BEFORE publishing the command so we don't