···5252import { generateToken } from './tile-tokens.js';
5353import { registerTrustedBuiltinWindow } from './tile-launcher.js';
5454import { ensureTileIpcHandlers } from './tile-compat.js';
5555+import { registerBgWindow } from './tile-lifecycle.js';
5556import { getSystemThemeBackgroundColor } from './windows.js';
5657import { getProfileSession } from './session-partition.js';
5758···9394 // Belt-and-suspenders: main.ts::initialize() already calls this
9495 // centrally, but the helper is idempotent. Documents the dependency.
9596 ensureTileIpcHandlers();
9797+9898+ // Declare this renderer as bgWindow so the subscribe-before-publish
9999+ // latch in tile-lifecycle.ts knows which `tile:lifecycle:ready`
100100+ // arrival unlatches feature-tile loads. Must happen BEFORE the
101101+ // BrowserWindow is created — the tile's preload sends ready on the
102102+ // next event-loop tick after `api.initialize()` resolves.
103103+ registerBgWindow(CORE_ID, CORE_ENTRY_ID);
9610497105 const electron = requireElectron('electron') as typeof import('electron');
98106 const BrowserWindowCtor = electron.BrowserWindow;
+17-3
backend/electron/main.ts
···1717import { discoverExtensions, loadExtensionManifest, isBuiltinExtensionEnabled, type ExtensionManifest, type ManifestCommand, type ManifestShortcut } from './extensions.js';
1818import { initializeFeatures, type FeatureStartupResult } from './feature-startup.js';
1919import { ensureTileIpcHandlers } from './tile-compat.js';
2020-import { getLoadedTileIds, getTileManifest, getAllTileWindows, unloadAllTiles, relaunchTile, configureTileLauncher } from './tile-launcher.js';
2020+import {
2121+ registerTileLifecycleIpc,
2222+ setReadyCallback as setLifecycleReadyCallback,
2323+} from './tile-lifecycle.js';
2424+import { signalTileReady, getLoadedTileIds, getTileManifest, getAllTileWindows, unloadAllTiles, relaunchTile, configureTileLauncher } from './tile-launcher.js';
2125import { installLoadOnDispatchHook } from './tile-lazy.js';
2226import { initTray } from './tray.js';
2327import { registerLocalShortcut, unregisterLocalShortcut, handleLocalShortcut, registerGlobalShortcut, unregisterGlobalShortcut, unregisterShortcutsForAddress } from './shortcuts.js';
···180184 // `tile:pubsub:*` IPC sends — handlers were historically registered
181185 // lazily from `loadV2Tile()` which runs after core glue.
182186 ensureTileIpcHandlers();
187187+188188+ // Register the private `tile:lifecycle:ready` IPC handler. Must also
189189+ // run before any BrowserWindow is created; the core background
190190+ // renderer launched by `initCore()` sends `tile:lifecycle:ready` from
191191+ // its preload `api.initialize()` resolution tick. `setReadyCallback`
192192+ // injects the tile-launcher's `signalTileReady` so the lifecycle
193193+ // module can drive LOADING→READY without importing tile-launcher
194194+ // (one-way dependency: tile-launcher → tile-lifecycle).
195195+ setLifecycleReadyCallback(signalTileReady);
196196+ registerTileLifecycleIpc();
183197184198 // Resolve & cache the tile-preload path early (also used by `initCore`
185199 // from entry.ts before `loadExtensions` runs). Preload source is
···15631577/**
15641578 * Shutdown the application
15651579 *
15661566- * Fires the `app:shutdown` pubsub event, then broadcasts `tile:shutdown` to
15671567- * every live v2 tile and waits one grace period so tile `api.onShutdown()`
15801580+ * Fires the `app:shutdown` pubsub event, then broadcasts `tile:lifecycle:shutdown`
15811581+ * to every live v2 tile and waits one grace period so tile `api.onShutdown()`
15681582 * callbacks can run before the BrowserWindows are closed. Finally closes the
15691583 * database.
15701584 *
+1-1
backend/electron/tile-ipc-sender-check.ts
···2424 * `will-attach-webview` where Electron doesn't surface the guest
2525 * wc id), the first IPC frame that carries the token binds
2626 * `event.sender.id` atomically. Legitimate renderers always send
2727- * their own first frame (tile:validate-token or tile:ready during
2727+ * their own first frame (tile:validate-token or tile:lifecycle:ready during
2828 * preload) before any other code can access the token, so TOFU is
2929 * race-free in practice.
3030 *
+4-16
backend/electron/tile-ipc.ts
···33 *
44 * Handles:
55 * - tile:validate-token — validate a capability token and return grants
66- * - tile:ready — tile signals initialization complete
76 * - tile:pubsub:* — scoped pubsub operations
87 * - tile:command:* — command registration and results
98 * - tile:window:* — window management
···2423import {
2524 validateToken,
2625 getGrantForToken,
2727- signalTileReady,
2826 getTileWindow,
2927 isTileLoaded,
3028 unloadTile,
···476474 });
477475478476 // ── Tile Ready ──
479479-480480- ipcMain.on('tile:ready', (_event, args: {
481481- tileId: string;
482482- tileEntry: string;
483483- }) => {
484484- // tile:ready does not carry a capability token (the renderer
485485- // signals readiness by tile id + entry id only — the launcher
486486- // already knows which window sent it via the tileWindows map).
487487- // Phase 8 will tighten this: lifecycle becomes private IPC, not
488488- // a pubsub-reachable surface, at which point the sender-frame
489489- // check applies there too. For Phase 2 there is no token on the
490490- // wire to cross-check against.
491491- signalTileReady(args.tileId, args.tileEntry);
492492- });
477477+ //
478478+ // Private lifecycle IPC (`tile:lifecycle:ready` / `tile:lifecycle:shutdown`)
479479+ // moved to tile-lifecycle.ts in Phase 4. See
480480+ // docs/pubsub-state-machine.md §Topics that are NOT pubsub.
493481494482 // ── PubSub ──
495483
+9-9
backend/electron/tile-launcher.test.ts
···103103 __clearTileStateForTest();
104104 });
105105106106- it('sends tile:shutdown to the registered window and then closes it', async () => {
106106+ it('sends tile:lifecycle:shutdown to the registered window and then closes it', async () => {
107107 const win = makeFakeWin();
108108 __setTileWindowForTest('fx-tile', 'bg', win);
109109110110 await shutdownTile('fx-tile', 'bg');
111111112112- assert.deepStrictEqual(win._sends, ['tile:shutdown']);
112112+ assert.deepStrictEqual(win._sends, ['tile:lifecycle:shutdown']);
113113 assert.strictEqual(win._closed, true);
114114 });
115115···124124 __clearTileStateForTest();
125125 });
126126127127- it('sends tile:shutdown before closing and clears registry entry', async () => {
127127+ it('sends tile:lifecycle:shutdown before closing and clears registry entry', async () => {
128128 const win = makeFakeWin();
129129 __setTileWindowForTest('unload-tile', 'bg', win);
130130 assert.strictEqual(isTileLoaded('unload-tile', 'bg'), true);
131131132132 await unloadTile('unload-tile', 'bg');
133133134134- assert.deepStrictEqual(win._sends, ['tile:shutdown']);
134134+ assert.deepStrictEqual(win._sends, ['tile:lifecycle:shutdown']);
135135 assert.strictEqual(win._closed, true);
136136 assert.strictEqual(isTileLoaded('unload-tile', 'bg'), false);
137137 });
···145145// ordering via a unit-friendly replica:
146146147147describe('shutdownTile ordering contract', () => {
148148- it('sends tile:shutdown IPC, then closes after the grace delay', async () => {
148148+ it('sends tile:lifecycle:shutdown IPC, then closes after the grace delay', async () => {
149149 // This test mirrors the exact sequence shutdownTile follows:
150150- // webContents.send('tile:shutdown') (immediate)
150150+ // webContents.send('tile:lifecycle:shutdown') (immediate)
151151 // await sleep(grace)
152152 // win.close() (after)
153153 // It guards against regressions that move win.close() before the IPC send
···168168 async function shutdown(win: typeof fakeWin): Promise<void> {
169169 if (!win || win.isDestroyed()) return;
170170 if (!win.webContents.isDestroyed()) {
171171- win.webContents.send('tile:shutdown');
171171+ win.webContents.send('tile:lifecycle:shutdown');
172172 }
173173 await new Promise<void>((resolve) => setTimeout(resolve, grace));
174174 if (!win.isDestroyed()) {
···180180 await shutdown(fakeWin);
181181 const elapsed = Date.now() - t0;
182182183183- assert.deepStrictEqual(events, ['send:tile:shutdown', 'close']);
183183+ assert.deepStrictEqual(events, ['send:tile:lifecycle:shutdown', 'close']);
184184 assert.ok(elapsed >= grace - 10, `should have waited at least ~${grace}ms, waited ${elapsed}ms`);
185185 });
186186···198198 async function shutdown(win: typeof fakeWin): Promise<void> {
199199 if (!win || win.isDestroyed()) return;
200200 if (!win.webContents.isDestroyed()) {
201201- win.webContents.send('tile:shutdown');
201201+ win.webContents.send('tile:lifecycle:shutdown');
202202 }
203203 await new Promise<void>((resolve) => setTimeout(resolve, 5));
204204 if (!win.isDestroyed()) {
+11-11
backend/electron/tile-launcher.ts
···77 * - Creating hidden windows for background tiles
88 * - Generating capability tokens (opaque string encoding tile ID + granted capabilities)
99 * - Passing tokens to tiles via additionalArguments in WebPreferences
1010- * - Lifecycle: load manifest -> grant capabilities -> create context -> tile calls initialize() -> tile:ready
1010+ * - Lifecycle: load manifest -> grant capabilities -> create context -> tile calls initialize() -> tile:lifecycle:ready
1111 */
12121313import type { BrowserWindow } from 'electron';
···717717}
718718719719/**
720720- * Send the `tile:shutdown` IPC to a single tile window, wait a short grace
721721- * period for its `api.onShutdown()` callbacks to run, and then close the
722722- * BrowserWindow.
720720+ * Send the `tile:lifecycle:shutdown` IPC to a single tile window, wait a
721721+ * short grace period for its `api.onShutdown()` callbacks to run, and then
722722+ * close the BrowserWindow.
723723 *
724724 * Tiles register shutdown callbacks via `api.onShutdown(cb)` in tile-preload.
725725 * Without this signal the callback never fires, so intervals / timers /
···739739 try {
740740 // Notify the tile so `api.onShutdown()` callbacks can run.
741741 if (!win.webContents.isDestroyed()) {
742742- win.webContents.send('tile:shutdown');
742742+ win.webContents.send('tile:lifecycle:shutdown');
743743 }
744744 } catch (err) {
745745- console.error(`[tile-launcher:${tileId}:${entryId}] Failed to send tile:shutdown:`, err);
745745+ console.error(`[tile-launcher:${tileId}:${entryId}] Failed to send tile:lifecycle:shutdown:`, err);
746746 }
747747748748 // Give the tile a short grace period to clean up. We intentionally don't
···869869/**
870870 * Unload all tiles — called during app shutdown.
871871 *
872872- * Broadcasts `tile:shutdown` to every tile in parallel, waits the shared grace
873873- * period once, then closes all windows. This keeps quit latency bounded to
874874- * TILE_SHUTDOWN_GRACE_MS regardless of how many tiles are active.
872872+ * Broadcasts `tile:lifecycle:shutdown` to every tile in parallel, waits the
873873+ * shared grace period once, then closes all windows. This keeps quit latency
874874+ * bounded to TILE_SHUTDOWN_GRACE_MS regardless of how many tiles are active.
875875 */
876876export async function unloadAllTiles(): Promise<void> {
877877 // Phase 1: fan out shutdown signal to every tile.
···879879 if (win.isDestroyed()) continue;
880880 try {
881881 if (!win.webContents.isDestroyed()) {
882882- win.webContents.send('tile:shutdown');
882882+ win.webContents.send('tile:lifecycle:shutdown');
883883 }
884884 } catch (err) {
885885- console.error('[tile-launcher] Failed to send tile:shutdown during unloadAllTiles:', err);
885885+ console.error('[tile-launcher] Failed to send tile:lifecycle:shutdown during unloadAllTiles:', err);
886886 }
887887 }
888888
+275
backend/electron/tile-lifecycle.test.ts
···11+/**
22+ * Unit tests for tile-lifecycle.ts — Phase 4.
33+ *
44+ * Covers:
55+ * - `tile:state-changed` System-topic emission on every transition.
66+ * - bgWindow-ready gate: `requestLoad()` for non-bgWindow tiles is
77+ * held until bgWindow signals `tile:lifecycle:ready`.
88+ * - Subscribe-before-publish invariant: a subscriber registered
99+ * synchronously against pubsub before the publisher fires (simulating
1010+ * `cmd:register` at t=0 boot) receives the publish.
1111+ *
1212+ * Runs under Electron's Node host (via `yarn test:unit`). The tile-
1313+ * lifecycle module imports `ipcMain` from electron for the lifecycle
1414+ * IPC handler; we avoid exercising that code path here and use
1515+ * `__simulateLifecycleReadyForTest()` instead — same side effects
1616+ * (latch release + ready callback) without needing ipcMain in the
1717+ * test host.
1818+ */
1919+2020+import { describe, it, beforeEach } from 'node:test';
2121+import * as assert from 'node:assert';
2222+2323+import {
2424+ STATES,
2525+ TRIGGERS,
2626+ transition,
2727+ requestLoad,
2828+ registerBgWindow,
2929+ isBgWindowReady,
3030+ waitForBgWindowReady,
3131+ setReadyCallback,
3232+ __simulateLifecycleReadyForTest,
3333+ resetForTests,
3434+} from './tile-lifecycle.js';
3535+import {
3636+ subscribe,
3737+ unsubscribe,
3838+ publish,
3939+ getSystemAddress,
4040+ __clearPrePublishHooksForTest,
4141+ scopes,
4242+} from './pubsub.js';
4343+4444+// Ensure NODE_ENV is 'test' — resetForTests / __simulateLifecycleReadyForTest
4545+// throw otherwise, and we need them to guard against cross-test leakage.
4646+if (process.env.NODE_ENV !== 'test') {
4747+ process.env.NODE_ENV = 'test';
4848+}
4949+5050+// ─── Helpers ─────────────────────────────────────────────────────────
5151+5252+interface StateChangedPayload {
5353+ tileId: string;
5454+ entryId: string;
5555+ from: string;
5656+ to: string;
5757+ trigger: string;
5858+ ts: number;
5959+}
6060+6161+/**
6262+ * Subscribe to `tile:state-changed` and accumulate every payload. Returns
6363+ * the events array and an unsubscribe function.
6464+ */
6565+function captureStateChanged(): {
6666+ events: StateChangedPayload[];
6767+ stop: () => void;
6868+} {
6969+ const events: StateChangedPayload[] = [];
7070+ const source = `test-state-listener-${Math.random().toString(36).slice(2, 8)}`;
7171+ subscribe(source, scopes.GLOBAL, 'tile:state-changed', (msg) => {
7272+ events.push(msg as StateChangedPayload);
7373+ });
7474+ return {
7575+ events,
7676+ stop: () => {
7777+ unsubscribe(source, 'tile:state-changed');
7878+ },
7979+ };
8080+}
8181+8282+// Walk the FSM into REGISTERED for a given tile so we have somewhere
8383+// for LOAD to transition from.
8484+function installRegistered(tileId: string, entryId: string): void {
8585+ transition(tileId, entryId, TRIGGERS.INSTALL);
8686+}
8787+8888+// ─── Tests ───────────────────────────────────────────────────────────
8989+9090+describe('tile-lifecycle: tile:state-changed observer topic', () => {
9191+ beforeEach(() => {
9292+ resetForTests();
9393+ __clearPrePublishHooksForTest();
9494+ });
9595+9696+ it('publishes a tile:state-changed event on every successful transition', () => {
9797+ const capture = captureStateChanged();
9898+ try {
9999+ installRegistered('fx', 'bg');
100100+ transition('fx', 'bg', TRIGGERS.LOAD);
101101+ transition('fx', 'bg', TRIGGERS.TILE_READY);
102102+103103+ assert.strictEqual(capture.events.length, 3);
104104+105105+ const [t0, t1, t2] = capture.events;
106106+ assert.deepStrictEqual(
107107+ { tileId: t0.tileId, entryId: t0.entryId, from: t0.from, to: t0.to, trigger: t0.trigger },
108108+ { tileId: 'fx', entryId: 'bg', from: STATES.UNREGISTERED, to: STATES.REGISTERED, trigger: TRIGGERS.INSTALL },
109109+ );
110110+ assert.deepStrictEqual(
111111+ { tileId: t1.tileId, entryId: t1.entryId, from: t1.from, to: t1.to, trigger: t1.trigger },
112112+ { tileId: 'fx', entryId: 'bg', from: STATES.REGISTERED, to: STATES.LOADING, trigger: TRIGGERS.LOAD },
113113+ );
114114+ assert.deepStrictEqual(
115115+ { tileId: t2.tileId, entryId: t2.entryId, from: t2.from, to: t2.to, trigger: t2.trigger },
116116+ { tileId: 'fx', entryId: 'bg', from: STATES.LOADING, to: STATES.READY, trigger: TRIGGERS.TILE_READY },
117117+ );
118118+ // ts present on every event
119119+ for (const ev of capture.events) {
120120+ assert.strictEqual(typeof ev.ts, 'number');
121121+ }
122122+ } finally {
123123+ capture.stop();
124124+ }
125125+ });
126126+127127+ it('does NOT publish on rejected transitions (disallowed trigger)', () => {
128128+ const capture = captureStateChanged();
129129+ try {
130130+ // UNREGISTERED tile can't receive LOAD — transition is rejected.
131131+ const result = transition('never', 'bg', TRIGGERS.LOAD);
132132+ assert.strictEqual(result.ok, false);
133133+ assert.strictEqual(capture.events.length, 0);
134134+ } finally {
135135+ capture.stop();
136136+ }
137137+ });
138138+});
139139+140140+describe('tile-lifecycle: bgWindow-ready gate', () => {
141141+ beforeEach(() => {
142142+ resetForTests();
143143+ __clearPrePublishHooksForTest();
144144+ });
145145+146146+ it('holds non-bgWindow requestLoad() until bgWindow signals ready', async () => {
147147+ // Wire up the injected ready callback so simulate() can drive it.
148148+ const readyCalls: Array<{ tileId: string; entryId: string }> = [];
149149+ setReadyCallback((tileId, entryId) => {
150150+ readyCalls.push({ tileId, entryId });
151151+ // Advance bgWindow's FSM to READY so a realistic "bgWindow ready"
152152+ // state actually exists. Not strictly required for the gate
153153+ // semantics under test, but mirrors production.
154154+ transition(tileId, entryId, TRIGGERS.TILE_READY);
155155+ });
156156+157157+ // Register the core bgWindow + the feature tile.
158158+ registerBgWindow('core', 'background');
159159+ installRegistered('core', 'background');
160160+ installRegistered('feature-a', 'bg');
161161+162162+ // bgWindow itself can load before signalling ready — otherwise the
163163+ // FSM would deadlock against its own gate.
164164+ transition('core', 'background', TRIGGERS.LOAD);
165165+166166+ // Start the feature-a load request. It must NOT resolve before
167167+ // bgWindow signals ready.
168168+ let featureSettled = false;
169169+ const featurePromise = requestLoad('feature-a', 'bg').then((r) => {
170170+ featureSettled = true;
171171+ return r;
172172+ });
173173+174174+ // Give the microtask queue a few turns — featurePromise should
175175+ // remain pending because the latch is not released.
176176+ await new Promise((r) => setTimeout(r, 10));
177177+ assert.strictEqual(featureSettled, false, 'feature load should be held until bgWindow ready');
178178+ assert.strictEqual(isBgWindowReady(), false);
179179+180180+ // Signal bgWindow ready — this unlatches feature-a's transition.
181181+ __simulateLifecycleReadyForTest('core', 'background');
182182+ assert.strictEqual(isBgWindowReady(), true);
183183+ assert.deepStrictEqual(readyCalls, [{ tileId: 'core', entryId: 'background' }]);
184184+185185+ const result = await featurePromise;
186186+ assert.strictEqual(result.ok, true);
187187+ assert.strictEqual(result.to, STATES.LOADING);
188188+ });
189189+190190+ it('resolves immediately for the bgWindow itself (no self-deadlock)', async () => {
191191+ registerBgWindow('core', 'background');
192192+ installRegistered('core', 'background');
193193+ // bgWindow's own requestLoad should complete without waiting for
194194+ // anything — the latch is not yet released.
195195+ assert.strictEqual(isBgWindowReady(), false);
196196+ const result = await requestLoad('core', 'background');
197197+ assert.strictEqual(result.ok, true);
198198+ assert.strictEqual(result.to, STATES.LOADING);
199199+ });
200200+201201+ it('resolves immediately once the latch is released', async () => {
202202+ setReadyCallback(() => {});
203203+ registerBgWindow('core', 'background');
204204+ installRegistered('core', 'background');
205205+ installRegistered('feature-b', 'bg');
206206+207207+ transition('core', 'background', TRIGGERS.LOAD);
208208+ __simulateLifecycleReadyForTest('core', 'background');
209209+ // bgWindow is now marked ready — subsequent feature loads should
210210+ // resolve on the first tick.
211211+ assert.strictEqual(isBgWindowReady(), true);
212212+ const result = await requestLoad('feature-b', 'bg');
213213+ assert.strictEqual(result.ok, true);
214214+ assert.strictEqual(result.to, STATES.LOADING);
215215+ });
216216+217217+ it('waitForBgWindowReady() resolves exactly once bgWindow signals ready', async () => {
218218+ registerBgWindow('core', 'background');
219219+ setReadyCallback(() => {});
220220+221221+ let resolved = false;
222222+ const p = waitForBgWindowReady().then(() => { resolved = true; });
223223+ await new Promise((r) => setTimeout(r, 5));
224224+ assert.strictEqual(resolved, false);
225225+226226+ __simulateLifecycleReadyForTest('core', 'background');
227227+ await p;
228228+ assert.strictEqual(resolved, true);
229229+ });
230230+231231+ it('does not block when no bgWindow has been registered (fallback)', async () => {
232232+ // Unit-test scenarios that never call registerBgWindow() must still
233233+ // work — requestLoad falls through to an immediate transition.
234234+ installRegistered('standalone', 'bg');
235235+ const result = await requestLoad('standalone', 'bg');
236236+ assert.strictEqual(result.ok, true);
237237+ assert.strictEqual(result.to, STATES.LOADING);
238238+ });
239239+});
240240+241241+describe('tile-lifecycle: subscribe-before-publish invariant', () => {
242242+ beforeEach(() => {
243243+ resetForTests();
244244+ __clearPrePublishHooksForTest();
245245+ });
246246+247247+ it('a subscriber registered at t=0 boot receives a cmd:register published immediately after', () => {
248248+ // This simulates the core subscriber (inside app/index.js → cmd
249249+ // background) landing synchronously during core init, BEFORE any
250250+ // tile fires its first `cmd:register`. With the Phase 4 gate in
251251+ // place, tiles can't transition REGISTERED→LOADING until bgWindow
252252+ // signals ready — by that time these subscribers exist, so the
253253+ // first publishes are never dropped.
254254+ const received: unknown[] = [];
255255+ const source = 'test-core-cmd-registry';
256256+ subscribe(source, scopes.GLOBAL, 'cmd:register', (msg) => {
257257+ received.push(msg);
258258+ });
259259+ try {
260260+ // Simulate a tile publishing its cmd:register. In the real
261261+ // system the publish goes through `publish()` in pubsub.ts.
262262+ // We call the same function directly here — the invariant under
263263+ // test is about ordering, not about which process code path fires.
264264+ publish(getSystemAddress(), scopes.GLOBAL, 'cmd:register', {
265265+ name: 'hello',
266266+ source: 'fx',
267267+ });
268268+269269+ assert.strictEqual(received.length, 1);
270270+ assert.deepStrictEqual(received[0], { name: 'hello', source: 'fx' });
271271+ } finally {
272272+ unsubscribe(source, 'cmd:register');
273273+ }
274274+ });
275275+});
+255-13
backend/electron/tile-lifecycle.ts
···22 * tile-lifecycle — Main-process enforcement engine for the tile FSM.
33 *
44 * Owns the authoritative `tileId → state` map. Wires renderer events
55- * (`tile:ready` pubsub, `render-process-gone` on BrowserWindow
66- * webContents, window close) to state transitions. All callers that
77- * mutate tile lifecycle go through this module rather than poking
88- * BrowserWindow / tileWindows / token registries directly.
55+ * (`tile:lifecycle:ready` private IPC, `render-process-gone` on
66+ * BrowserWindow webContents, window close) to state transitions. All
77+ * callers that mutate tile lifecycle go through this module rather
88+ * than poking BrowserWindow / tileWindows / token registries directly.
99 *
1010 * The transition table lives in `./tile-fsm.ts` as a pure module
1111 * (no electron / node imports). This file is the side-effectful
1212 * engine that applies those transitions against concrete electron
1313 * primitives.
1414 *
1515- * Phase A (this commit): exports + state store + transition helper.
1616- * No callers are wired yet. Phase D replaces the direct uses of
1717- * `launchTile` / `registerTrustedBuiltinWindow` / ad-hoc token revoke
1818- * calls with `requestLoad()` / `requestShow()` / `requestShutdown()`.
1515+ * Phase 4: ownership of the private lifecycle IPC channels
1616+ * (`tile:lifecycle:ready` / `tile:lifecycle:shutdown`) moves here
1717+ * from tile-ipc.ts. Also introduces:
1818+ * - `registerBgWindow()` + bgWindow-ready latch: the subscribe-
1919+ * before-publish invariant. No non-bgWindow tile may transition
2020+ * REGISTERED→LOADING until bgWindow signals `tile:lifecycle:ready`.
2121+ * - `tile:state-changed` System pubsub topic: a read-only mirror of
2222+ * every lifecycle transition. Observers (drift detectors, HUD
2323+ * widgets) subscribe; publishers must not publish it directly.
1924 *
2020- * See docs/tile-lifecycle-fsm.md.
2525+ * See docs/pubsub-state-machine.md §Topics that are NOT pubsub
2626+ * (private lifecycle IPC) and §Subscribe-before-publish invariant.
2127 */
2828+2929+import { createRequire } from 'node:module';
22302331import {
2432 STATES,
···2735 isDispatchable,
2836 acceptsDynamicRegistration,
2937} from './tile-fsm.js';
3838+import { publish, scopes, getSystemAddress } from './pubsub.js';
3939+4040+// Lazy-load ipcMain via CommonJS require so this module can be imported
4141+// under ELECTRON_RUN_AS_NODE=1 (where electron's named ESM exports are
4242+// empty). Consumers that never actually call `registerTileLifecycleIpc`
4343+// (e.g. unit tests) don't trigger the require. Same pattern as
4444+// tile-launcher.ts::getBrowserWindowCtor.
4545+const requireElectron = createRequire(import.meta.url);
4646+let _ipcMain: typeof import('electron').ipcMain | null = null;
4747+function getIpcMain(): typeof import('electron').ipcMain {
4848+ if (_ipcMain) return _ipcMain;
4949+ const electron = requireElectron('electron') as typeof import('electron');
5050+ _ipcMain = electron.ipcMain;
5151+ return _ipcMain;
5252+}
30533154export { STATES, TRIGGERS, isDispatchable, acceptsDynamicRegistration };
5555+5656+/**
5757+ * Injected side-effect callback for the private `tile:lifecycle:ready`
5858+ * IPC handler. Called with `(tileId, entryId)` after the bgWindow-gate
5959+ * latch (if applicable) has been resolved.
6060+ *
6161+ * Historically this was `tile-launcher.signalTileReady`. Registering it
6262+ * via a setter rather than a direct import keeps the dependency arrow
6363+ * tile-launcher → tile-lifecycle (one-way) and avoids a circular
6464+ * import — see docs/pubsub-state-machine.md §Module layout.
6565+ */
6666+type ReadyCallback = (tileId: string, entryId: string) => void;
6767+let onReadyCallback: ReadyCallback | null = null;
6868+6969+/**
7070+ * Inject the `signalTileReady` callback. Called once at startup from
7171+ * `main.ts::initialize()` before `registerTileLifecycleIpc()`.
7272+ */
7373+export function setReadyCallback(cb: ReadyCallback): void {
7474+ onReadyCallback = cb;
7575+}
32763377// ---------------------------------------------------------------------------
3478// State store
···66110}
6711168112// ---------------------------------------------------------------------------
113113+// bgWindow readiness gate — subscribe-before-publish invariant
114114+// ---------------------------------------------------------------------------
115115+//
116116+// Core subscribers (cmd registry, noun registry, core domain topics) live
117117+// inside the bgWindow renderer (`app/background.html` → `app/index.js`).
118118+// Those subscribers are registered synchronously during `init()`. Until
119119+// bgWindow's `tile:lifecycle:ready` IPC arrives, we MUST hold off any
120120+// other tile's REGISTERED→LOADING transition — otherwise the other
121121+// tile's first publishes (`cmd:register`, etc.) race the subscribers
122122+// and are silently dropped.
123123+//
124124+// Implementation: a simple one-shot latch. Once bgWindow signals ready,
125125+// the latch resolves and stays resolved for the rest of the process
126126+// lifetime. Crashing bgWindow mid-boot is a separate concern (Phase 7
127127+// timeout UX) — for Phase 4 a permanent latch is sufficient.
128128+129129+interface BgWindowRegistration {
130130+ tileId: string;
131131+ entryId: string;
132132+}
133133+let bgWindow: BgWindowRegistration | null = null;
134134+let bgWindowReady = false;
135135+let bgWindowReadyResolve: (() => void) | null = null;
136136+let bgWindowReadyPromise: Promise<void> = new Promise<void>((resolve) => {
137137+ bgWindowReadyResolve = resolve;
138138+});
139139+140140+/**
141141+ * Declare which tile entry is the core bgWindow. Called once at startup
142142+ * from `core-glue.ts::initCore()` (before the window signals ready).
143143+ *
144144+ * A second registration replaces the first — tests exercise this by
145145+ * calling `resetForTests()` between scenarios. In production there is
146146+ * exactly one bgWindow.
147147+ */
148148+export function registerBgWindow(tileId: string, entryId: string): void {
149149+ bgWindow = { tileId, entryId };
150150+}
151151+152152+/** True iff bgWindow has signaled `tile:lifecycle:ready`. */
153153+export function isBgWindowReady(): boolean {
154154+ return bgWindowReady;
155155+}
156156+157157+/** Promise that resolves when bgWindow signals ready. Never rejects. */
158158+export function waitForBgWindowReady(): Promise<void> {
159159+ return bgWindowReadyPromise;
160160+}
161161+162162+/**
163163+ * Check whether a tile is the registered bgWindow.
164164+ * Returns false if bgWindow has not been registered yet.
165165+ */
166166+function isBgWindow(tileId: string, entryId: string): boolean {
167167+ return !!bgWindow && bgWindow.tileId === tileId && bgWindow.entryId === entryId;
168168+}
169169+170170+/**
171171+ * Resolve the latch. Called when bgWindow's `tile:lifecycle:ready`
172172+ * IPC lands. Idempotent.
173173+ */
174174+function markBgWindowReady(): void {
175175+ if (bgWindowReady) return;
176176+ bgWindowReady = true;
177177+ if (bgWindowReadyResolve) {
178178+ const r = bgWindowReadyResolve;
179179+ bgWindowReadyResolve = null;
180180+ r();
181181+ }
182182+}
183183+184184+/**
185185+ * Async gate for `REGISTERED → LOADING` transitions.
186186+ *
187187+ * - bgWindow itself: resolves immediately (bgWindow must be allowed
188188+ * to load before it can signal ready).
189189+ * - Any other tile: awaits the latch.
190190+ * - If no bgWindow has been registered yet we resolve immediately.
191191+ * This preserves backwards compatibility with unit tests /
192192+ * trustedBuiltin paths that never call `registerBgWindow()`.
193193+ * Production always registers bgWindow before launching any feature
194194+ * tile (see `main.ts` wiring).
195195+ */
196196+export async function requestLoad(tileId: string, entryId: string): Promise<TransitionResult> {
197197+ if (bgWindow && !isBgWindow(tileId, entryId) && !bgWindowReady) {
198198+ await bgWindowReadyPromise;
199199+ }
200200+ return transition(tileId, entryId, TRIGGERS.LOAD, { source: 'requestLoad' });
201201+}
202202+203203+// ---------------------------------------------------------------------------
69204// Transition — the only way state changes
70205// ---------------------------------------------------------------------------
71206···79214/**
80215 * Request a transition. Returns a result object. Never throws.
81216 *
8282- * On success, updates the internal state map and records the
8383- * transition. On failure, leaves the state map unchanged and returns
8484- * an error — caller decides whether to log, surface, or ignore.
217217+ * On success, updates the internal state map, records the transition,
218218+ * and publishes the observer-mirror `tile:state-changed` System topic.
219219+ * On failure, leaves the state map unchanged and returns an error —
220220+ * caller decides whether to log, surface, or ignore.
85221 *
86222 * Rationale for never throwing: the enforcement engine is called from
87223 * IPC handlers, event listeners, and renderer-driven requests. Throws
···113249 records.set(k, { tileId, entryId, state: result.to });
114250 }
115251252252+ const now = Date.now();
116253 lastTransition.set(k, {
117254 from,
118255 to: result.to,
119256 trigger,
120120- timestamp: Date.now(),
257257+ timestamp: now,
121258 context,
122259 });
123260261261+ // Publish the System-owned observer-mirror topic. Every transition
262262+ // fires this — drift detectors (Phase 8) and HUD widgets that want
263263+ // to display tile state subscribe to it. Publishers must not publish
264264+ // `tile:state-changed` directly; the authorization-rules table
265265+ // restricts writes to System.
266266+ try {
267267+ publish(getSystemAddress(), scopes.GLOBAL, 'tile:state-changed', {
268268+ tileId,
269269+ entryId,
270270+ from,
271271+ to: result.to,
272272+ trigger,
273273+ ts: now,
274274+ });
275275+ } catch (err) {
276276+ // Observer-topic publish failures must not corrupt the transition —
277277+ // the FSM state is authoritative regardless of whether observers
278278+ // got the mirror event. Log and move on.
279279+ console.error('[tile-lifecycle] tile:state-changed publish threw:', err);
280280+ }
281281+124282 return { ok: true, from, to: result.to };
125283}
126284285285+// ---------------------------------------------------------------------------
286286+// Private lifecycle IPC — tile:lifecycle:ready / tile:lifecycle:shutdown
287287+// ---------------------------------------------------------------------------
288288+//
289289+// These are NOT pubsub topics. They're private IPC between a tile's
290290+// preload and this module. See docs/pubsub-state-machine.md §Topics
291291+// that are NOT pubsub.
292292+//
293293+// `tile:lifecycle:ready`: preload → main, sent once after the tile
294294+// has called `api.initialize()`. Causes the LOADING → READY
295295+// transition (via the injected ready callback). If the signaller is
296296+// the registered bgWindow, also resolves the subscribe-before-publish
297297+// latch.
298298+//
299299+// `tile:lifecycle:shutdown`: main → preload, sent during the unload
300300+// grace window. Lives here as a channel name constant; the actual
301301+// sends live in tile-launcher.ts where the BrowserWindow handle
302302+// is in scope.
303303+304304+/** Channel name constants — exported so tile-launcher / tile-preload stay in sync. */
305305+export const TILE_LIFECYCLE_READY_CHANNEL = 'tile:lifecycle:ready';
306306+export const TILE_LIFECYCLE_SHUTDOWN_CHANNEL = 'tile:lifecycle:shutdown';
307307+308308+let lifecycleIpcRegistered = false;
309309+310310+/**
311311+ * Register the private lifecycle IPC handlers. Called once from
312312+ * `main.ts::initialize()` (alongside `ensureTileIpcHandlers()`).
313313+ * Idempotent.
314314+ */
315315+export function registerTileLifecycleIpc(): void {
316316+ if (lifecycleIpcRegistered) return;
317317+ lifecycleIpcRegistered = true;
318318+319319+ const ipcMain = getIpcMain();
320320+ ipcMain.on(TILE_LIFECYCLE_READY_CHANNEL, (_event, args: {
321321+ tileId: string;
322322+ tileEntry: string;
323323+ }) => {
324324+ // tile:lifecycle:ready does not carry a capability token (the
325325+ // renderer signals readiness by tile id + entry id only — the
326326+ // launcher already knows which window sent it via the tileWindows
327327+ // map). Phase 8 introduces a main-process IPC gate that will add
328328+ // sender-frame verification here too.
329329+ if (!args || typeof args.tileId !== 'string' || typeof args.tileEntry !== 'string') {
330330+ console.error('[tile-lifecycle] tile:lifecycle:ready dropped: malformed payload');
331331+ return;
332332+ }
333333+334334+ // If the signaller is the registered bgWindow, unlatch first. Doing
335335+ // this BEFORE the ready callback ensures any observer that reacts
336336+ // to the LOADING → READY transition on bgWindow already sees
337337+ // bgWindow marked ready in the same tick.
338338+ if (isBgWindow(args.tileId, args.tileEntry)) {
339339+ markBgWindowReady();
340340+ }
341341+342342+ if (onReadyCallback) {
343343+ onReadyCallback(args.tileId, args.tileEntry);
344344+ } else {
345345+ console.error('[tile-lifecycle] tile:lifecycle:ready arrived before setReadyCallback() — dropping');
346346+ }
347347+ });
348348+}
349349+350350+/** @internal Test hook: simulate a `tile:lifecycle:ready` IPC arrival without wiring ipcMain. */
351351+export function __simulateLifecycleReadyForTest(tileId: string, entryId: string): void {
352352+ if (process.env.NODE_ENV !== 'test') {
353353+ throw new Error('[tile-lifecycle] __simulateLifecycleReadyForTest called outside NODE_ENV=test');
354354+ }
355355+ if (isBgWindow(tileId, entryId)) {
356356+ markBgWindowReady();
357357+ }
358358+ if (onReadyCallback) {
359359+ onReadyCallback(tileId, entryId);
360360+ }
361361+}
362362+127363// Deliberately no `setStateUnchecked()` — every state change MUST go
128364// through `transition()` so the FSM is the only source of truth. Tests
129365// that need a specific state walk the transition graph like production
···178414 }
179415 records.clear();
180416 lastTransition.clear();
417417+ bgWindow = null;
418418+ bgWindowReady = false;
419419+ bgWindowReadyPromise = new Promise<void>((resolve) => {
420420+ bgWindowReadyResolve = resolve;
421421+ });
422422+ onReadyCallback = null;
181423}
+12-8
backend/electron/tile-preload.cts
···106106 throw new Error('Tile token validation failed. Tile may have been revoked.');
107107 }
108108109109- // Defer tile:ready to the next event-loop tick so any synchronous
110110- // post-initialize code in background.html (e.g. extension.init() +
111111- // api.commands.register()) has a chance to complete before the lazy
112112- // system's hasSubscriber check fires.
109109+ // Defer tile:lifecycle:ready to the next event-loop tick so any
110110+ // synchronous post-initialize code in background.html (e.g.
111111+ // extension.init() + api.commands.register()) has a chance to
112112+ // complete before the lazy system's hasSubscriber check fires.
113113+ //
114114+ // Channel is private lifecycle IPC — not pubsub. See
115115+ // docs/pubsub-state-machine.md §Topics that are NOT pubsub.
113116 const result = { capabilities: grantedCapabilities };
114117 setTimeout(() => {
115115- ipcRenderer.send('tile:ready', { tileId, tileEntry });
118118+ ipcRenderer.send('tile:lifecycle:ready', { tileId, tileEntry });
116119 }, 0);
117120 return result;
118121 };
···122125 *
123126 * The underlying IPC listener is installed exactly once. Subsequent calls
124127 * replace the stored callback — repeated `api.onShutdown(cb)` invocations
125125- * do NOT accumulate listeners on the `tile:shutdown` channel. This matters
126126- * for hot-reload scenarios and features that re-register on every init.
128128+ * do NOT accumulate listeners on the `tile:lifecycle:shutdown` channel.
129129+ * This matters for hot-reload scenarios and features that re-register on
130130+ * every init.
127131 */
128132 let shutdownCallback: (() => void) | null = null;
129133 let shutdownListenerInstalled = false;
···131135 shutdownCallback = typeof callback === 'function' ? callback : null;
132136 if (!shutdownListenerInstalled) {
133137 shutdownListenerInstalled = true;
134134- ipcRenderer.on('tile:shutdown', () => {
138138+ ipcRenderer.on('tile:lifecycle:shutdown', () => {
135139 try {
136140 shutdownCallback?.();
137141 } catch (err) {
+3-3
tests/desktop/localsearch.spec.ts
···3838 test('lists window opens via command', async () => {
3939 // Ensure the lists command is registered before executing it.
4040 // The lists tile is lazy-loaded — the background tile must be loaded and
4141- // tile:ready received before the execute handler is registered.
4141+ // tile:lifecycle:ready received before the execute handler is registered.
4242 await waitForCommand(sharedBgWindow, 'lists', 15000);
43434444 // Start waiting for the window BEFORE publishing the command so we don't
4545 // miss a fast creation (getWindow polls every 200ms and returns immediately
4646 // when the window appears — the 15s cap handles the full lazy-load chain:
4747- // background-tile launch → tile:ready → execute replay → tile:window:open).
4747+ // background-tile launch → tile:lifecycle:ready → execute replay → tile:window:open).
4848 const windowPromise = sharedApp.getWindow('lists/home.html', 15000);
49495050 // Execute the lists command
···7171 test('lists input renders and is auto-focused', async () => {
7272 // Ensure the lists command is registered before executing it.
7373 // The lists tile is lazy-loaded — the background tile must be loaded and
7474- // tile:ready received before the execute handler is registered.
7474+ // tile:lifecycle:ready received before the execute handler is registered.
7575 await waitForCommand(sharedBgWindow, 'lists', 15000);
76767777 // Start waiting for the window BEFORE publishing the command so we don't