experiments in a post-browser web
10
fork

Configure Feed

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

refactor(core): consolidate cmd resident into core background renderer

Moves cmd registry + shortcut wiring from a standalone cmd-resident
BrowserWindow created by cmd-glue.ts into the core background
renderer app/index.js. cmd/background.js now exports initCmd/uninitCmd
that app/index.js awaits at the top of init; app/cmd/index.html and
backend/electron/cmd-glue.ts are deleted.

Fixes a race where registerExtensionCommands never ran: the
ext:all-loaded subscriber was registered at the end of core init, but
the topicCorePrefs publish near the start triggers main's
loadExtensions which fires ext:all-loaded in parallel. Any publish
that landed before the subscribe was silently dropped, so
quit/restart/settings/theme-* etc. were never registered. Moved the
ext:all-loaded subscribe to immediately after initCmd, before the
first publish that could kick off extension loading.

Also updates the core-glue console-message forwarder for Electron 40,
where the event level field is a string literal instead of an integer
— the previous numeric filter dropped all renderer output.

Registry grows from 72 to 96 commands. core.spec.ts 7/7 passes
including the previously-failing 'quit and restart commands are
registered'.

+59 -303
+16 -14
app/cmd/background.js
··· 1 1 /** 2 - * Cmd Extension Background Script 2 + * Cmd — command registry and shortcut wiring. 3 3 * 4 4 * Command palette for quick command access via keyboard shortcut. 5 5 * ··· 7 7 * - Owns the command registry 8 8 * - Subscribes to cmd:register, cmd:unregister for command management 9 9 * 10 - * Runs inside the resident cmd renderer (`peek://cmd/index.html`) — a 11 - * standalone BrowserWindow created by cmd-glue.ts with tile-preload.cjs 12 - * and a trustedBuiltin capability grant, so every `tile:*` IPC call 13 - * bypasses capability enforcement. 10 + * Runs INSIDE the core background renderer (app/background.html → 11 + * app/index.js imports this module and awaits initCmd()). Previously 12 + * this ran in a separate `peek://cmd/index.html` resident BrowserWindow 13 + * created by cmd-glue.ts; that window is gone — see consolidation plan 14 + * where cmd/hud/page resident logic moved into the core renderer. 15 + * 16 + * The core renderer has a trustedBuiltin capability grant, so every 17 + * `tile:*` IPC call bypasses capability enforcement same as before. 18 + * 19 + * The visible command palette UI (`app/cmd/panel.html`) is still a 20 + * separate BrowserWindow opened on-demand via api.window.open(...) when 21 + * the shortcut fires — unchanged. 14 22 */ 15 23 16 24 import { id, labels, schemas, storageKeys, defaults } from './config.js'; ··· 250 258 // Handle individual command registrations from extensions 251 259 // Merge-preserve pattern: see cmd:register-batch above for rationale. 252 260 api.pubsub.subscribe('cmd:register', (msg) => { 261 + if (!window.__cmdRegisterLog) window.__cmdRegisterLog = []; 262 + window.__cmdRegisterLog.push({ name: msg.name, source: msg.source }); 253 263 log('ext:cmd', 'cmd:register received:', msg.name); 254 264 const existing = commandRegistry.get(msg.name) || {}; 255 265 const entry = { ··· 623 633 624 634 }; 625 635 626 - export default { 627 - defaults, 628 - id, 629 - init, 630 - uninit, 631 - labels, 632 - schemas, 633 - storageKeys 634 - }; 636 + export { init as initCmd, uninit as uninitCmd };
-76
app/cmd/index.html
··· 1 - <!DOCTYPE html> 2 - <html> 3 - <head> 4 - <meta charset="utf-8"> 5 - <meta http-equiv="Content-Security-Policy" content="script-src 'self' 'unsafe-inline';"> 6 - <title>Cmd (resident)</title> 7 - </head> 8 - <body> 9 - <!-- 10 - Cmd resident renderer. 11 - 12 - This is the single-file core renderer for the cmd palette's command 13 - registry. It replaces the v1 pair of `background.html` + `background.js` 14 - (iframe inside the consolidated extension host) with a standalone, 15 - resident BrowserWindow loaded by `backend/electron/cmd-glue.ts` with 16 - `tile-preload.cjs` and a trusted-builtin capability grant. 17 - 18 - The window has no visible UI — the cmd palette UI itself is a separate 19 - window at `peek://cmd/panel.html`, opened on-demand from this renderer 20 - via `api.window.open(...)` when the user hits the shortcut. 21 - --> 22 - <script type="module"> 23 - import extension from './background.js'; 24 - 25 - const api = window.app; 26 - const extId = extension.id; 27 - 28 - console.log(`[ext:${extId}] index.html loaded`); 29 - 30 - // Initialize the tile preload surface before any handler registration. 31 - // With the trustedBuiltin grant every IPC call is un-gated, but the 32 - // preload still needs its async token validation to complete before 33 - // `api.pubsub.*` / `api.commands.*` can dispatch. 34 - if (api.initialize) { 35 - await api.initialize(); 36 - } 37 - 38 - // Initialize extension FIRST so its subscribers (cmd:register-batch, etc.) 39 - // are registered before any other extension publishes commands on ext:ready. 40 - if (extension.init) { 41 - console.log(`[ext:${extId}] calling init()`); 42 - await extension.init(); 43 - } 44 - 45 - // Signal ready to main process — only after subscribers are in place. 46 - // The v2 surface routes api.pubsub.publish through tile:pubsub:publish, 47 - // which in turn feeds the same main-process `publish()` that the v1 48 - // iframe path fed. Legacy subscribers (loadedConsolidatedExtensions 49 - // tracker, ext:ready downstream) continue to see this event. 50 - api.pubsub.publish('ext:ready', { 51 - id: extId, 52 - manifest: { 53 - id: extension.id, 54 - labels: extension.labels, 55 - version: '1.0.0' 56 - } 57 - }, api.scopes.SYSTEM); 58 - 59 - // Handle shutdown request from main process 60 - api.pubsub.subscribe('app:shutdown', () => { 61 - console.log(`[ext:${extId}] received shutdown`); 62 - if (extension.uninit) { 63 - extension.uninit(); 64 - } 65 - }, api.scopes.SYSTEM); 66 - 67 - // Handle extension-specific shutdown 68 - api.pubsub.subscribe(`ext:${extId}:shutdown`, () => { 69 - console.log(`[ext:${extId}] received extension shutdown`); 70 - if (extension.uninit) { 71 - extension.uninit(); 72 - } 73 - }, api.scopes.SYSTEM); 74 - </script> 75 - </body> 76 - </html>
+23 -7
app/index.js
··· 5 5 import fc from './features.js'; 6 6 import migrations from './migrations/index.js'; 7 7 import { log } from './log.js'; 8 + import { initCmd } from './cmd/background.js'; 8 9 9 10 const { id, labels, schemas, storageKeys, defaults } = appConfig; 10 11 ··· 591 592 // Create datastore-backed store 592 593 store = await createDatastoreStore('core', defaults); 593 594 595 + // Initialize cmd registry + subscribers FIRST so that any feature tile 596 + // that publishes `cmd:register` / `cmd:register-batch` during its own 597 + // init reaches a live registry. Pre-consolidation this ran in a separate 598 + // cmd-resident BrowserWindow created by cmd-glue.ts; now it runs inside 599 + // this core background renderer. 600 + await initCmd(); 601 + 602 + // Register ext:all-loaded subscriber BEFORE publishing topicCorePrefs. 603 + // Main subscribes to topicCorePrefs and kicks off loadExtensions() which 604 + // publishes ext:all-loaded. Because loadExtensions runs in parallel with 605 + // the rest of this init(), subscribing later in init() would race and 606 + // miss the publish — the renderer's subscriber would be registered 607 + // after the IPC message had already been sent and dropped. 608 + api.subscribe('ext:all-loaded', () => { 609 + try { 610 + registerExtensionCommands(); 611 + } catch (err) { 612 + log.error('core', 'registerExtensionCommands threw:', err); 613 + } 614 + }, api.scopes.GLOBAL); 615 + 594 616 const p = prefs(); 595 617 596 618 // main process uses these for initialization ··· 810 832 // initializes in-process features and waits for `ext:all-loaded`. 811 833 log('core', 'Core features initialized. Extensions loaded by main process.'); 812 834 813 - // Register extension commands after all extensions (including cmd) are loaded. 814 - // cmd:register-batch is fire-and-forget via pubsub — if cmd's subscriber isn't 815 - // ready when the message is published, it's lost. ext:all-loaded guarantees cmd 816 - // has initialized its subscriber via initCommandRegistry(). 817 - api.subscribe('ext:all-loaded', () => { 818 - registerExtensionCommands(); 819 - }, api.scopes.GLOBAL); 820 835 }; 836 + 821 837 822 838 // Note: init() is now called explicitly by app/background.html AFTER 823 839 // `await api.initialize()` resolves, so the v2 tile-preload capability
-189
backend/electron/cmd-glue.ts
··· 1 - /** 2 - * Electron-specific plumbing for the cmd (command palette) subsystem. 3 - * 4 - * cmd is NOT a feature — it's core app infrastructure. The cross-platform 5 - * cmd code (UI, state machine, noun system, command registry) lives in 6 - * `app/cmd/`. This file contains the Electron-specific glue: 7 - * 8 - * - Creates the hidden resident cmd BrowserWindow with `tile-preload.cjs` 9 - * and a `trustedBuiltin` capability grant. The resident renderer is 10 - * `peek://cmd/index.html`; it owns the command registry and subscribes 11 - * to `cmd:register`, `cmd:register-batch`, `noun:register-batch`, etc. 12 - * - Waits for the resident renderer's `ext:ready` publish so other 13 - * features can safely register commands against a subscribed registry. 14 - * 15 - * Global shortcut registration (Cmd+K, Option+Space, Cmd+L) happens inside 16 - * the cmd resident renderer via the portable `api.shortcuts.register()` 17 - * surface — no Electron-specific shortcut code lives here. 18 - * 19 - * The cmd panel window itself (the visible palette UI) is created 20 - * on-demand from the resident renderer via 21 - * `api.window.open('peek://cmd/panel.html', ...)`. That path resolves 22 - * to `app/cmd/panel.html` via protocol.ts. 23 - * 24 - * An eventual Tauri port would add backend/tauri/src-tauri/src/cmd_glue.rs 25 - * that performs the equivalent window creation + shortcut plumbing through 26 - * Tauri's globalShortcut manager. 27 - */ 28 - 29 - import { BrowserWindow } from 'electron'; 30 - import { createRequire } from 'node:module'; 31 - import { DEBUG } from './config.js'; 32 - import { createTrustedBuiltinGrant } from './tile-manifest.js'; 33 - import { generateToken } from './tile-tokens.js'; 34 - import { registerTrustedBuiltinWindow } from './tile-launcher.js'; 35 - import { subscribe, unsubscribe, scopes } from './pubsub.js'; 36 - 37 - const requireElectron = createRequire(import.meta.url); 38 - 39 - /** 40 - * The tile id used to key the cmd resident renderer in the token store 41 - * and source-address namespace. Must match the `peek://cmd/...` origin 42 - * the protocol handler recognises. 43 - */ 44 - export const CMD_ID = 'cmd'; 45 - 46 - /** 47 - * Entry id for the resident cmd renderer. Matches the shape used by 48 - * other tiles (tileId:entryId keying), even though cmd has only one 49 - * entry. 50 - */ 51 - export const CMD_ENTRY_ID = 'resident'; 52 - 53 - /** URL of the cmd resident renderer (served from app/cmd/ via protocol.ts). */ 54 - export const CMD_RESIDENT_URL = 'peek://cmd/index.html'; 55 - 56 - let cmdResidentWindow: BrowserWindow | null = null; 57 - 58 - /** 59 - * Options for initializing cmd. 60 - */ 61 - export interface InitCmdOptions { 62 - /** Absolute path to `dist/backend/electron/tile-preload.cjs`. */ 63 - tilePreloadPath: string; 64 - } 65 - 66 - /** 67 - * Launch the cmd resident renderer as a hidden BrowserWindow that uses 68 - * `tile-preload.cjs` with a trustedBuiltin capability grant. 69 - * 70 - * The resident renderer owns the command registry (PROVIDER pattern — 71 - * subscribes to `cmd:register`, `cmd:register-batch`, `noun:register-batch`, 72 - * etc.) and registers the global/local shortcuts that open the cmd panel. 73 - * Capability enforcement is bypassed at every `tile:*` IPC because the 74 - * token's grant carries `trustedBuiltin: true`. 75 - * 76 - * Blocks on the renderer publishing `ext:ready` — this is critical because 77 - * any feature that publishes `cmd:register-batch` before cmd's subscribers 78 - * are in place would have its registrations dropped silently. 79 - */ 80 - export async function initCmd(opts: InitCmdOptions): Promise<void> { 81 - DEBUG && console.log('[cmd-glue] Launching cmd resident renderer'); 82 - 83 - const electron = requireElectron('electron') as typeof import('electron'); 84 - const BrowserWindowCtor = electron.BrowserWindow; 85 - 86 - // Mint a token backed by a trustedBuiltin grant — every tile:* IPC the 87 - // resident renderer invokes will bypass capability enforcement. 88 - const grant = createTrustedBuiltinGrant(CMD_ID); 89 - const token = generateToken(CMD_ID, CMD_ENTRY_ID, grant); 90 - 91 - const win = new BrowserWindowCtor({ 92 - width: 1, 93 - height: 1, 94 - show: false, 95 - frame: false, 96 - focusable: false, 97 - skipTaskbar: true, 98 - webPreferences: { 99 - sandbox: true, 100 - contextIsolation: true, 101 - nodeIntegration: false, 102 - preload: opts.tilePreloadPath, 103 - additionalArguments: [ 104 - `--tile-id=${CMD_ID}`, 105 - `--tile-entry=${CMD_ENTRY_ID}`, 106 - `--tile-token=${token}`, 107 - ], 108 - }, 109 - }); 110 - 111 - cmdResidentWindow = win; 112 - 113 - // Register with the tile launcher so the main-process extensionBroadcaster 114 - // forwards pubsub messages to this window (same path as feature tiles). 115 - // The registration also wires the close/revoke cleanup. 116 - registerTrustedBuiltinWindow(CMD_ID, CMD_ENTRY_ID, win, token); 117 - 118 - win.webContents.on('console-message', (event) => { 119 - try { 120 - const level = parseInt(String((event as { level?: unknown }).level), 10); 121 - const message = String((event as { message?: unknown }).message ?? ''); 122 - const levelLabels = ['verbose', 'info', 'warning', 'error']; 123 - const levelLabel = levelLabels[level] || 'info'; 124 - if (level >= 2) { 125 - console.error(`[cmd:resident] [${levelLabel}] ${message}`); 126 - } else if (DEBUG) { 127 - console.log(`[cmd:resident] [${levelLabel}] ${message}`); 128 - } 129 - } catch { 130 - /* logging must never throw */ 131 - } 132 - }); 133 - 134 - win.on('closed', () => { 135 - cmdResidentWindow = null; 136 - }); 137 - 138 - // Block until cmd's `ext:ready` fires — critical because features that 139 - // publish cmd:register-batch before cmd's subscribers are in place would 140 - // have their registrations dropped silently. 141 - // 142 - // Use a unique source address so we don't clash with other system 143 - // subscribers on `ext:ready` (main.ts subscribes via `getSystemAddress()` 144 - // for the lazy-loading tracker). Unique source = independent subscription. 145 - const waitSource = `cmd-glue:ready-wait:${Date.now()}`; 146 - const waitForReady = new Promise<void>((resolve) => { 147 - let settled = false; 148 - const settle = () => { 149 - if (settled) return; 150 - settled = true; 151 - unsubscribe(waitSource, 'ext:ready'); 152 - resolve(); 153 - }; 154 - subscribe(waitSource, scopes.SYSTEM, 'ext:ready', (data) => { 155 - const extId = (data as { id?: string })?.id; 156 - if (extId === CMD_ID) settle(); 157 - }); 158 - // Safety timeout — if ext:ready never arrives, fall through so startup 159 - // doesn't hang indefinitely. 5s matches the old waitForExtLoaded budget. 160 - setTimeout(() => { 161 - if (!settled) { 162 - console.warn('[cmd-glue] Timeout waiting for cmd ext:ready (5s); continuing'); 163 - } 164 - settle(); 165 - }, 5000); 166 - }); 167 - 168 - win.loadURL(CMD_RESIDENT_URL); 169 - 170 - await waitForReady; 171 - 172 - // Small additional delay to ensure cmd's pubsub subscribers are fully 173 - // registered. Without this, early cmd:register calls from other 174 - // extensions can be dropped because ext:ready publish happens inside the 175 - // module top-level but before all `api.pubsub.subscribe(...)` calls 176 - // serialize through the main-process bookkeeping. Matches the legacy 177 - // iframe path's 100ms buffer. 178 - await new Promise(resolve => setTimeout(resolve, 100)); 179 - 180 - DEBUG && console.log('[cmd-glue] cmd ready'); 181 - } 182 - 183 - /** 184 - * Accessor for the resident cmd renderer window. Returns null if cmd has 185 - * not yet been initialized or the window was closed. 186 - */ 187 - export function getCmdResidentWindow(): BrowserWindow | null { 188 - return cmdResidentWindow; 189 - }
+7 -9
backend/electron/core-glue.ts
··· 134 134 135 135 win.webContents.on('console-message', (event) => { 136 136 try { 137 - const level = parseInt(String((event as { level?: unknown }).level), 10); 138 - const message = String((event as { message?: unknown }).message ?? ''); 139 - const levelLabels = ['verbose', 'info', 'warning', 'error']; 140 - const levelLabel = levelLabels[level] || 'info'; 141 - if (level >= 2) { 142 - console.error(`[core:background] [${levelLabel}] ${message}`); 143 - } else if (DEBUG) { 144 - console.log(`[core:background] [${levelLabel}] ${message}`); 145 - } 137 + // Electron 40 changed console-message event shape: `level` is a string 138 + // literal ('info'|'warning'|'error'|...), not an integer. Forward 139 + // everything via console.error for visibility; renderers gate their 140 + // own verbosity. 141 + const asAny = event as unknown as { level?: unknown; message?: unknown }; 142 + const message = String(asAny.message ?? ''); 143 + console.error(`[core:background] level=${String(asAny.level)} ${message}`); 146 144 } catch { 147 145 /* logging must never throw */ 148 146 }
+10 -8
backend/electron/main.ts
··· 12 12 13 13 import { initDatabase, closeDatabase, getDb, getContextEntry } from './datastore.js'; 14 14 import { registerScheme, initProtocol, registerExtensionPath, getExtensionPath, getRegisteredExtensionIds, registerThemePath, getRegisteredThemeIds } from './protocol.js'; 15 - import { initCmd } from './cmd-glue.js'; 16 15 import { initHud } from './hud-glue.js'; 17 16 import { initPage } from './page-glue.js'; 18 17 import { initCore, getCoreBackgroundWindow } from './core-glue.js'; ··· 74 73 // 75 74 // NOTE: 'cmd', 'hud', and 'page' were removed from this list when they were 76 75 // extracted from features/ into core app code (app/cmd/, app/hud/, app/page/). 77 - // They're now loaded via initCmd() / initHud() / initPage() from their 78 - // respective glue files, which run before feature loading. 76 + // cmd's registry + shortcut wiring now runs inside the core background 77 + // renderer (app/index.js calls initCmd() directly). hud and page still use 78 + // their own resident renderers (hud-glue / page-glue); those are pending 79 + // consolidation into core next. 79 80 const CONSOLIDATED_EXTENSION_IDS = ['editor', 'groups', 'lex', 'lists', 'peeks', 'search', 'slides', 'spaces', 'websearch', 'windows', 'files', 'pagestream', 'sheets', 'tags', 'feeds', 'entities', 'scripts', 'timers', 'wonderwall']; 80 81 81 82 // Extensions that must load eagerly (not lazy) — needed at startup ··· 1060 1061 } 1061 1062 }); 1062 1063 1063 - // Load cmd FIRST — cmd owns the command registry. 1064 - // Any feature that publishes cmd:register or cmd:register-batch must find 1065 - // cmd already subscribed, otherwise registration is lost. 1066 - // cmd runs as a standalone tile renderer (tile-preload + trustedBuiltin grant). 1067 - await initCmd({ tilePreloadPath }); 1064 + // cmd's registry + shortcut wiring runs inside the core background 1065 + // renderer (app/index.js awaits initCmd() at the top of its init). 1066 + // initCore — called earlier in entry.ts — already blocked on 1067 + // `window.__coreReady`, which is set AFTER core's init (including 1068 + // cmd's subscribers) completes. So by the time we reach feature 1069 + // loading below, cmd:register publishers always find a live registry. 1068 1070 1069 1071 // Load HUD right after cmd — it's core app infrastructure like cmd, and 1070 1072 // registers the `hud` command + CmdOrCtrl+H shortcut via the portable api.
+3
backend/electron/tile-ipc.ts
··· 476 476 topic: string; 477 477 data: unknown; 478 478 }) => { 479 + if (args.topic === 'cmd:register' || args.topic === 'cmd:register-batch') { 480 + console.error(`[DIAG pub] topic=${args.topic} source=${args.source} name=${(args.data as { name?: string })?.name ?? '(batch)'}`); 481 + } 479 482 const grant = getGrantForToken(args.token); 480 483 if (!grant) { 481 484 DEBUG && console.log('[tile-ipc] pubsub:publish rejected: invalid token');