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 hud resident into core background renderer

Moves HUD's `hud` command + CmdOrCtrl+H shortcut registration from a
standalone hud-resident BrowserWindow created by hud-glue.ts into the
core background renderer. app/hud/background.js now exports
initHud/uninitHud; app/index.js awaits initHud() after initCmd +
initPage. app/hud/index.html and backend/electron/hud-glue.ts are
deleted.

The macOS did-become-active / did-resign-active handlers that hide
and restore non-focusable alwaysOnTop overlay windows (HUD) move
inline into main.ts's existing focus handler — they only need the
main process `app` and `BrowserWindow` APIs.

Moves the HUD sheet-config seeding from background.js into hud.js so
the overlay reads/writes its own layout in its own tile's namespace.
Previously background.js wrote the config to the `hud` tile's
namespace and the overlay read it back via `getExtKey('hud', ...)`.
Now that background.js runs inside core (tileId='core'), it would
write to the wrong namespace; owning the config in the overlay avoids
the cross-tile dependency entirely. background.js opens the HUD
window with a sensible default size; the overlay auto-resizes to its
widget layout on load (unchanged behaviour).

Also addresses a related namespace-collision for cmd: its
`api.settings.set('prefs', ...)` now running inside core would write
to `core_prefs` instead of `cmd_prefs`, breaking the cmd panel
window (which still has tileId='cmd' and reads from its own
namespace). cmd/background.js persists directly via
`api.datastore.setRow('feature_settings', 'cmd_prefs', ...)` to keep
the logical cmd namespace regardless of which tile runs the code.

Retargets the v2-pubsub reproducer test from the now-removed cmd
resident window to the core background renderer as publisher, and
from the now-removed hud resident to any eager feature tile as
subscriber.

Replaces external-url.spec.ts' post-publish 500ms sleep with a
deterministic subscribe-then-wait on `window:opened` — under parallel
workers the sleep was occasionally too short. Main publishes
window:opened as soon as the new window registers, so the test can
wait on the event directly instead of polling.

HUD tests (10/10), hybrid-settings (3/3), groups-context (5/5), and
core.spec (7/7) all pass. Full desktop suite: 217/218 pass (the one
failure is a shutdown-hang flake in page-redirect.spec.ts unrelated
to consolidation; passes in isolation).

+173 -456
+26 -14
app/cmd/background.js
··· 51 51 prefs: defaults.prefs 52 52 }; 53 53 54 - /** 55 - * Load settings from datastore 56 - */ 54 + // cmd's prefs row lives in the `feature_settings` table keyed `cmd_prefs` 55 + // (legacy `${tileId}_${key}` format that `api.settings.set` uses). The 56 + // cmd panel window reads via `api.settings.get('prefs')` from its own 57 + // tile namespace (tileId='cmd') — so we must keep writing to the `cmd` 58 + // namespace even though this code now runs inside the core renderer 59 + // (tileId='core'). `api.settings.set` would route to `core_prefs`; 60 + // persist directly to the known row instead. 61 + const CMD_PREFS_ROW_ID = 'cmd_prefs'; 62 + 57 63 const loadSettings = async () => { 58 - const result = await api.settings.get('prefs'); 59 - if (result.success && result.data) { 60 - return { 61 - prefs: result.data || defaults.prefs 62 - }; 64 + try { 65 + const result = await api.datastore.getRow('feature_settings', CMD_PREFS_ROW_ID); 66 + if (result?.success && result.data?.value) { 67 + const parsed = JSON.parse(result.data.value); 68 + return { prefs: parsed || defaults.prefs }; 69 + } 70 + } catch (err) { 71 + log.error('ext:cmd', 'Failed to load settings:', err); 63 72 } 64 73 return { prefs: defaults.prefs }; 65 74 }; 66 75 67 - /** 68 - * Save settings to datastore 69 - */ 70 76 const saveSettings = async (settings) => { 71 - const result = await api.settings.set('prefs', settings.prefs); 72 - if (!result.success) { 73 - log.error('ext:cmd', 'Failed to save settings:', result.error); 77 + try { 78 + await api.datastore.setRow('feature_settings', CMD_PREFS_ROW_ID, { 79 + featureId: 'cmd', 80 + key: 'prefs', 81 + value: JSON.stringify(settings.prefs), 82 + updatedAt: Date.now(), 83 + }); 84 + } catch (err) { 85 + log.error('ext:cmd', 'Failed to save settings:', err); 74 86 } 75 87 }; 76 88
+26 -107
app/hud/background.js
··· 1 1 /** 2 - * HUD Background Script 3 - * 4 - * Always-on-top overlay showing current mode, IZUI state, and window context. 2 + * HUD — always-on-top overlay for mode, IZUI state, and window context. 5 3 * Uses the widget sheet system — each piece of HUD info is an individual 6 4 * widget page rendered via webview in a peek-grid freeform layout. 7 5 * 8 - * HUD is NOT a feature — it's core app infrastructure that ships with every 9 - * copy of Peek. It used to live in features/hud/; now it lives in app/hud/ 10 - * and is loaded by backend/electron/hud-glue.ts. 6 + * HUD is NOT a feature — it's core app infrastructure that ships with 7 + * every copy of Peek. Its command + shortcut registration now runs INSIDE 8 + * the core background renderer (app/index.js awaits initHud() from this 9 + * module). Previously this ran in a separate `peek://hud/index.html` 10 + * resident BrowserWindow created by hud-glue.ts; that window is gone. 11 11 * 12 - * Commands and shortcuts are registered imperatively here (unlike features 13 - * that declare them in manifest.json) — there is no manifest for HUD. 12 + * Commands and shortcuts are registered imperatively here (unlike 13 + * features that declare them in manifest.json) — there is no manifest 14 + * for HUD. The overlay window itself is still a separate window at 15 + * `peek://hud/hud.html`, opened on-demand via api.window.open(...). 14 16 * 15 - * Runs inside the resident HUD renderer (`peek://hud/index.html`) — a 16 - * standalone BrowserWindow created by hud-glue.ts with tile-preload.cjs 17 - * and a trustedBuiltin capability grant, so every `tile:*` IPC call 18 - * bypasses capability enforcement. 17 + * macOS app-focus hide/show of the alwaysOnTop HUD window is handled 18 + * directly in the main process (see backend/electron/main.ts) — 19 + * registering that handler does not require a renderer. 19 20 */ 20 21 21 22 const api = window.app; ··· 23 24 24 25 const HUD_ADDRESS = 'peek://hud/hud.html'; 25 26 const STORAGE_KEY = 'hud_enabled'; 26 - const HUD_SHEET_KEY = 'hud_sheet'; 27 27 const TOGGLE_SHORTCUT = 'CommandOrControl+H'; 28 28 29 - // Default HUD sheet layout — arranges widget pages vertically 30 - const WIDGET_WIDTH = 220; 31 - const WIDGET_HEIGHT = 40; 32 - const WIDGET_GAP = 2; 33 - 34 - const STATS_WIDGET_HEIGHT = 120; // taller for 6 metric rows 35 - 36 - const DEFAULT_HUD_WIDGETS = [ 37 - { id: 'mode', url: 'peek://hud/widgets/mode.html' }, 38 - { id: 'izui', url: 'peek://hud/widgets/izui.html' }, 39 - { id: 'window', url: 'peek://hud/widgets/window.html' }, 40 - { id: 'stats', url: 'peek://hud/widgets/stats.html', height: STATS_WIDGET_HEIGHT } 41 - ]; 42 - 43 - /** 44 - * Build the default HUD sheet config 45 - * Arranges widgets in a vertical stack 46 - */ 47 - const buildDefaultSheetConfig = () => { 48 - let y = 0; 49 - const items = DEFAULT_HUD_WIDGETS.map((widget) => { 50 - const h = widget.height || WIDGET_HEIGHT; 51 - const item = { 52 - id: widget.id, 53 - url: widget.url, 54 - x: 0, 55 - y, 56 - width: WIDGET_WIDTH, 57 - height: h 58 - }; 59 - y += h + WIDGET_GAP; 60 - return item; 61 - }); 62 - 63 - return { 64 - version: 2, 65 - name: 'HUD', 66 - createdAt: Date.now(), 67 - items 68 - }; 69 - }; 70 - 71 - /** 72 - * Ensure the HUD sheet config exists in feature_settings. 73 - * Creates a default config if none exists. 74 - */ 75 - const CURRENT_CONFIG_VERSION = 2; 76 - 77 - const ensureSheetConfig = async () => { 78 - const result = await api.settings.get(HUD_SHEET_KEY); 79 - if (result.success && result.data) { 80 - if (result.data.version && result.data.version >= CURRENT_CONFIG_VERSION) { 81 - return result.data; 82 - } 83 - } 84 - 85 - // Create default config (or upgrade) 86 - const config = buildDefaultSheetConfig(); 87 - await api.settings.set(HUD_SHEET_KEY, config); 88 - return config; 89 - }; 29 + // Approximate default window size for the initial HUD overlay open. The 30 + // overlay auto-resizes to fit its widget layout on load (see hud.js 31 + // `autoResizeWindow`), so this just needs to be close enough to avoid a 32 + // visible reflow. Default layout: 4 widgets stacked, ~40–120px tall, + 33 + // 22px container padding. 34 + const DEFAULT_HUD_WIDTH = 242; 35 + const DEFAULT_HUD_HEIGHT = 268; 90 36 91 37 // Track HUD state 92 38 let hudEnabled = false; ··· 119 65 }; 120 66 121 67 /** 122 - * Calculate HUD window size from sheet config 123 - */ 124 - const getHudWindowSize = (config) => { 125 - let maxRight = 0; 126 - let maxBottom = 0; 127 - for (const item of config.items) { 128 - maxRight = Math.max(maxRight, item.x + item.width); 129 - maxBottom = Math.max(maxBottom, item.y + item.height); 130 - } 131 - // Add container padding (10px each side) + border 132 - return { 133 - width: maxRight + 22, 134 - height: maxBottom + 22 135 - }; 136 - }; 137 - 138 - /** 139 68 * Open the HUD window 140 69 */ 141 70 const openHud = async () => { ··· 148 77 hudWindowId = null; 149 78 } 150 79 151 - const config = await ensureSheetConfig(); 152 - const size = getHudWindowSize(config); 153 - 80 + // The overlay (hud.js) loads / creates its sheet config on its own 81 + // tile, then calls api.window.resize() to match. See 82 + // autoResizeWindow() in hud.js. 154 83 const params = { 155 84 key: HUD_ADDRESS, 156 - width: size.width, 157 - height: size.height, 85 + width: DEFAULT_HUD_WIDTH, 86 + height: DEFAULT_HUD_HEIGHT, 158 87 x: 20, 159 88 y: 20, 160 89 transparent: true, ··· 239 168 // Load persisted state 240 169 await loadState(); 241 170 242 - // Ensure HUD sheet config exists (create default if needed) 243 - await ensureSheetConfig(); 244 - 245 171 // Register command and shortcut imperatively — HUD has no manifest. 246 172 api.commands.register({ 247 173 name: 'hud', ··· 275 201 closeHud(); 276 202 }; 277 203 278 - export default { 279 - id: 'hud', 280 - labels: { 281 - name: 'HUD' 282 - }, 283 - init, 284 - uninit 285 - }; 204 + export { init as initHud, uninit as uninitHud };
+53 -24
app/hud/hud.js
··· 12 12 const debug = api.debug; 13 13 14 14 const HUD_SHEET_KEY = 'hud_sheet'; 15 + const CURRENT_CONFIG_VERSION = 2; 16 + 17 + // Default HUD sheet layout — arranges widget pages vertically. 18 + const WIDGET_WIDTH = 220; 19 + const WIDGET_HEIGHT = 40; 20 + const WIDGET_GAP = 2; 21 + const STATS_WIDGET_HEIGHT = 120; 22 + 23 + const DEFAULT_HUD_WIDGETS = [ 24 + { id: 'mode', url: 'peek://hud/widgets/mode.html' }, 25 + { id: 'izui', url: 'peek://hud/widgets/izui.html' }, 26 + { id: 'window', url: 'peek://hud/widgets/window.html' }, 27 + { id: 'stats', url: 'peek://hud/widgets/stats.html', height: STATS_WIDGET_HEIGHT }, 28 + ]; 29 + 30 + const buildDefaultSheetConfig = () => { 31 + let y = 0; 32 + const items = DEFAULT_HUD_WIDGETS.map((widget) => { 33 + const h = widget.height || WIDGET_HEIGHT; 34 + const item = { 35 + id: widget.id, 36 + url: widget.url, 37 + x: 0, 38 + y, 39 + width: WIDGET_WIDTH, 40 + height: h, 41 + }; 42 + y += h + WIDGET_GAP; 43 + return item; 44 + }); 45 + 46 + return { 47 + version: CURRENT_CONFIG_VERSION, 48 + name: 'HUD', 49 + createdAt: Date.now(), 50 + items, 51 + }; 52 + }; 15 53 16 54 let sheetConfig = null; 17 55 18 56 /** 19 - * Load HUD sheet config from feature_settings. 20 - * Config is written by background.js (tileId='hud') so we must read from 21 - * the 'hud' namespace rather than our own 'hud-overlay' namespace. 57 + * Load HUD sheet config from this tile's settings, seeding a default 58 + * layout on first run. Previously the config was written by 59 + * background.js running in a `peek://hud/` resident renderer; with HUD 60 + * command/shortcut registration consolidated into the core renderer 61 + * (which writes to the `core` namespace), the overlay now owns its own 62 + * layout storage here. 22 63 */ 23 - const loadSheetConfig = async () => { 24 - const result = await api.settings.getExtKey('hud', HUD_SHEET_KEY); 25 - if (result.success && result.data) { 26 - return result.data; 64 + const ensureSheetConfig = async () => { 65 + const existing = await api.settings.get(HUD_SHEET_KEY); 66 + if (existing.success && existing.data && existing.data.version >= CURRENT_CONFIG_VERSION) { 67 + return existing.data; 27 68 } 28 - return null; 69 + 70 + const fresh = buildDefaultSheetConfig(); 71 + await api.settings.set(HUD_SHEET_KEY, fresh); 72 + return fresh; 29 73 }; 30 74 31 75 /** ··· 125 169 await api.initialize(); 126 170 } 127 171 128 - sheetConfig = await loadSheetConfig(); 129 - 130 - if (!sheetConfig) { 131 - console.warn('[hud] No HUD sheet config found — waiting for background.js to create it'); 132 - // Retry after a short delay (background.js may still be initializing) 133 - setTimeout(async () => { 134 - sheetConfig = await loadSheetConfig(); 135 - if (sheetConfig) { 136 - renderWidgets(); 137 - } else { 138 - console.error('[hud] HUD sheet config still not found'); 139 - } 140 - }, 1000); 141 - return; 142 - } 143 - 172 + sheetConfig = await ensureSheetConfig(); 144 173 renderWidgets(); 145 174 console.log('[hud] Widget sheet display initialized with', sheetConfig.items.length, 'widgets'); 146 175 };
-71
app/hud/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>HUD (resident)</title> 7 - </head> 8 - <body> 9 - <!-- 10 - HUD resident renderer. 11 - 12 - This is the single-file core renderer for the HUD. It replaces the 13 - v1 pair of `background.html` + `background.js` (iframe inside the 14 - consolidated extension host) with a standalone, resident BrowserWindow 15 - loaded by `backend/electron/hud-glue.ts` with `tile-preload.cjs` and a 16 - trusted-builtin capability grant. 17 - 18 - The window has no visible UI — the HUD overlay itself is a separate 19 - window at `peek://hud/hud.html`, opened on-demand from this renderer 20 - via `api.window.open(...)`. 21 - --> 22 - <script type="module"> 23 - import extension from './background.js'; 24 - 25 - const api = window.app; 26 - const extId = extension.id; 27 - 28 - // Initialize the tile preload surface before any handler registration. 29 - // With the trustedBuiltin grant every IPC call is un-gated, but the 30 - // preload still needs its async token validation to complete before 31 - // `api.pubsub.*` / `api.commands.*` can dispatch. 32 - if (api.initialize) { 33 - await api.initialize(); 34 - } 35 - 36 - // Initialize HUD BEFORE signaling ready so its handlers are registered 37 - // before any other code publishes commands on ext:ready. 38 - if (extension.init) { 39 - await extension.init(); 40 - } 41 - 42 - // Signal ready to main process. The v2 surface routes api.pubsub.publish 43 - // through tile:pubsub:publish, which in turn feeds the same main-process 44 - // `publish()` that the v1 iframe path fed. Legacy subscribers 45 - // (loadedConsolidatedExtensions tracker, ext:ready downstream) continue 46 - // to see this event. 47 - api.pubsub.publish('ext:ready', { 48 - id: extId, 49 - manifest: { 50 - id: extension.id, 51 - labels: extension.labels, 52 - version: '1.0.0' 53 - } 54 - }, api.scopes.SYSTEM); 55 - 56 - // Handle shutdown request from main process. 57 - api.pubsub.subscribe('app:shutdown', () => { 58 - if (extension.uninit) { 59 - extension.uninit(); 60 - } 61 - }, api.scopes.SYSTEM); 62 - 63 - // Handle hud-specific shutdown. 64 - api.pubsub.subscribe(`ext:${extId}:shutdown`, () => { 65 - if (extension.uninit) { 66 - extension.uninit(); 67 - } 68 - }, api.scopes.SYSTEM); 69 - </script> 70 - </body> 71 - </html>
+2
app/index.js
··· 7 7 import { log } from './log.js'; 8 8 import { initCmd } from './cmd/background.js'; 9 9 import { initPage } from './page/background.js'; 10 + import { initHud } from './hud/background.js'; 10 11 11 12 const { id, labels, schemas, storageKeys, defaults } = appConfig; 12 13 ··· 600 601 // this core background renderer. 601 602 await initCmd(); 602 603 await initPage(); 604 + await initHud(); 603 605 604 606 // Register ext:all-loaded subscriber BEFORE publishing topicCorePrefs. 605 607 // Main subscribes to topicCorePrefs and kicks off loadExtensions() which
-182
backend/electron/hud-glue.ts
··· 1 - /** 2 - * Electron-specific plumbing for the HUD (always-on-top heads-up display). 3 - * 4 - * HUD is NOT a feature — it's core app infrastructure. The cross-platform 5 - * HUD code (window UI, widgets, resident renderer) lives in `app/hud/`. 6 - * This file contains the Electron-specific glue: 7 - * 8 - * - Creates the hidden resident HUD BrowserWindow with `tile-preload.cjs` 9 - * and a `trustedBuiltin` capability grant. The resident renderer is 10 - * `peek://hud/index.html`; it registers the `hud` command and the 11 - * CmdOrCtrl+H shortcut via the standard `api.commands.register` / 12 - * `api.shortcuts.register` surfaces. 13 - * - Registers macOS app-focus handlers that hide/show alwaysOnTop 14 - * overlays (e.g. the HUD window) when the user Cmd+Tabs away from 15 - * and back to Peek. This direct main-process hide/show avoids the 16 - * pubsub round-trip through the renderer. 17 - * 18 - * The HUD overlay window itself is created on-demand from the resident 19 - * renderer via `api.window.open('peek://hud/hud.html', ...)`; that 20 - * path resolves to `app/hud/hud.html` via `protocol.ts`. 21 - * 22 - * An eventual Tauri port would add backend/tauri/src-tauri/src/hud_glue.rs 23 - * that performs the equivalent window creation + focus-handling through 24 - * Tauri's window manager. 25 - */ 26 - 27 - import { app, BrowserWindow } from 'electron'; 28 - import { createRequire } from 'node:module'; 29 - import { DEBUG } from './config.js'; 30 - import { createTrustedBuiltinGrant } from './tile-manifest.js'; 31 - import { generateToken } from './tile-tokens.js'; 32 - import { registerTrustedBuiltinWindow } from './tile-launcher.js'; 33 - 34 - const requireElectron = createRequire(import.meta.url); 35 - 36 - /** 37 - * The tile id used to key the HUD resident renderer in the token store 38 - * and source-address namespace. Must match the `peek://hud/...` origin 39 - * the protocol handler recognises. 40 - */ 41 - export const HUD_ID = 'hud'; 42 - 43 - /** 44 - * Entry id for the resident HUD renderer. Matches the shape used by 45 - * other tiles (tileId:entryId keying), even though HUD has only one 46 - * entry. 47 - */ 48 - export const HUD_ENTRY_ID = 'resident'; 49 - 50 - /** URL of the HUD resident renderer (served from app/hud/ via protocol.ts). */ 51 - export const HUD_RESIDENT_URL = 'peek://hud/index.html'; 52 - 53 - let focusHandlersRegistered = false; 54 - let hudResidentWindow: BrowserWindow | null = null; 55 - 56 - /** 57 - * Register macOS app-focus handlers that hide/show non-focusable alwaysOnTop 58 - * overlay windows (HUD) when Peek gains/loses OS focus. 59 - * 60 - * Idempotent — safe to call more than once. 61 - */ 62 - function registerFocusHandlers(): void { 63 - if (focusHandlersRegistered) return; 64 - if (process.platform !== 'darwin') return; 65 - focusHandlersRegistered = true; 66 - 67 - // macOS: when the app regains focus, show any alwaysOnTop overlay windows 68 - // that were hidden by the did-resign-active handler. 69 - app.on('did-become-active', () => { 70 - for (const win of BrowserWindow.getAllWindows()) { 71 - if (!win.isDestroyed() && win.isAlwaysOnTop() && (win as any).__hudHidden) { 72 - win.showInactive(); 73 - (win as any).__hudHidden = false; 74 - } 75 - } 76 - }); 77 - 78 - // macOS: when the app loses focus, hide non-focusable alwaysOnTop overlay 79 - // windows (e.g. HUD). Skip focusable alwaysOnTop windows (e.g. cmd panel) 80 - // — they have their own blur handler and would blink if hidden here. 81 - app.on('did-resign-active', () => { 82 - for (const win of BrowserWindow.getAllWindows()) { 83 - if (!win.isDestroyed() && win.isAlwaysOnTop() && win.isVisible() && !win.isFocusable()) { 84 - win.hide(); 85 - (win as any).__hudHidden = true; 86 - } 87 - } 88 - }); 89 - 90 - DEBUG && console.log('[hud-glue] Registered macOS did-become-active / did-resign-active handlers'); 91 - } 92 - 93 - /** 94 - * Options for initializing HUD. 95 - */ 96 - export interface InitHudOptions { 97 - /** Absolute path to `dist/backend/electron/tile-preload.cjs`. */ 98 - tilePreloadPath: string; 99 - } 100 - 101 - /** 102 - * Launch the HUD resident renderer as a hidden BrowserWindow that uses 103 - * `tile-preload.cjs` with a trustedBuiltin capability grant. 104 - * 105 - * The resident renderer registers the `hud` command + CmdOrCtrl+H 106 - * shortcut via the portable `api.commands.register` / 107 - * `api.shortcuts.register` surfaces. Capability enforcement is bypassed 108 - * at every `tile:*` IPC because the token's grant carries 109 - * `trustedBuiltin: true`. 110 - */ 111 - export async function initHud(opts: InitHudOptions): Promise<void> { 112 - DEBUG && console.log('[hud-glue] Launching HUD resident renderer'); 113 - 114 - registerFocusHandlers(); 115 - 116 - const electron = requireElectron('electron') as typeof import('electron'); 117 - const BrowserWindowCtor = electron.BrowserWindow; 118 - 119 - // Mint a token backed by a trustedBuiltin grant — every tile:* IPC the 120 - // resident renderer invokes will bypass capability enforcement. 121 - const grant = createTrustedBuiltinGrant(HUD_ID); 122 - const token = generateToken(HUD_ID, HUD_ENTRY_ID, grant); 123 - 124 - const win = new BrowserWindowCtor({ 125 - width: 1, 126 - height: 1, 127 - show: false, 128 - frame: false, 129 - focusable: false, 130 - skipTaskbar: true, 131 - webPreferences: { 132 - sandbox: true, 133 - contextIsolation: true, 134 - nodeIntegration: false, 135 - preload: opts.tilePreloadPath, 136 - additionalArguments: [ 137 - `--tile-id=${HUD_ID}`, 138 - `--tile-entry=${HUD_ENTRY_ID}`, 139 - `--tile-token=${token}`, 140 - ], 141 - }, 142 - }); 143 - 144 - hudResidentWindow = win; 145 - 146 - // Register with the tile launcher so the main-process extensionBroadcaster 147 - // forwards pubsub messages to this window (same path as feature tiles). 148 - // The registration also wires the close/revoke cleanup. 149 - registerTrustedBuiltinWindow(HUD_ID, HUD_ENTRY_ID, win, token); 150 - 151 - win.webContents.on('console-message', (event) => { 152 - try { 153 - const level = parseInt(String((event as { level?: unknown }).level), 10); 154 - const message = String((event as { message?: unknown }).message ?? ''); 155 - const levelLabels = ['verbose', 'info', 'warning', 'error']; 156 - const levelLabel = levelLabels[level] || 'info'; 157 - if (level >= 2) { 158 - console.error(`[hud:resident] [${levelLabel}] ${message}`); 159 - } else if (DEBUG) { 160 - console.log(`[hud:resident] [${levelLabel}] ${message}`); 161 - } 162 - } catch { 163 - /* logging must never throw */ 164 - } 165 - }); 166 - 167 - win.on('closed', () => { 168 - hudResidentWindow = null; 169 - }); 170 - 171 - win.loadURL(HUD_RESIDENT_URL); 172 - 173 - DEBUG && console.log('[hud-glue] HUD resident renderer loaded'); 174 - } 175 - 176 - /** 177 - * Accessor for the resident HUD renderer window. Returns null if HUD has 178 - * not yet been initialized or the window was closed. 179 - */ 180 - export function getHudResidentWindow(): BrowserWindow | null { 181 - return hudResidentWindow; 182 - }
+26 -23
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 { initHud } from './hud-glue.js'; 16 15 import { initCore, getCoreBackgroundWindow } from './core-glue.js'; 17 16 import { initTestFixture } from './test-fixture-glue.js'; 18 17 import { discoverExtensions, loadExtensionManifest, isBuiltinExtensionEnabled, type ExtensionManifest, type ManifestCommand, type ManifestShortcut } from './extensions.js'; ··· 258 257 (app as any).on('did-become-active', () => { 259 258 getIzuiCoordinator().setAppFocused(true); 260 259 publish(getSystemAddress(), scopes.GLOBAL, 'app:focus-changed', { focused: true }); 261 - // Showing alwaysOnTop overlay windows (e.g. HUD) when the app regains 262 - // focus is handled by hud-glue.ts. 260 + // Show any alwaysOnTop overlay windows (e.g. HUD) that were hidden 261 + // by the did-resign-active handler below. This hide/show pair 262 + // avoids a pubsub round-trip through a renderer. 263 + for (const win of BrowserWindow.getAllWindows()) { 264 + if (!win.isDestroyed() && win.isAlwaysOnTop() && (win as any).__hudHidden) { 265 + win.showInactive(); 266 + (win as any).__hudHidden = false; 267 + } 268 + } 263 269 }); 264 270 (app as any).on('did-resign-active', () => { 265 271 const coordBefore = getIzuiCoordinator(); ··· 299 305 DEBUG && console.log('[izui] Skipping overlay close in did-resign-active (within cooldown: exit=', coordinator.isWithinOverlayExitCooldown(), 'entry=', coordinator.isWithinOverlayEntryCooldown(), ')'); 300 306 } 301 307 302 - // Hiding non-focusable alwaysOnTop overlay windows (e.g. HUD) when the 303 - // app loses focus is handled by hud-glue.ts. 308 + // Hide non-focusable alwaysOnTop overlay windows (e.g. HUD) when 309 + // the app loses focus. Skip focusable alwaysOnTop windows (e.g. 310 + // the cmd panel) — they have their own blur handler and would 311 + // blink if hidden here. 312 + for (const win of BrowserWindow.getAllWindows()) { 313 + if (!win.isDestroyed() && win.isAlwaysOnTop() && win.isVisible() && !win.isFocusable()) { 314 + win.hide(); 315 + (win as any).__hudHidden = true; 316 + } 317 + } 304 318 }); 305 319 } else { 306 320 // Non-macOS fallback: use browser-window-focus/blur with focused-window check ··· 1060 1074 } 1061 1075 }); 1062 1076 1063 - // cmd's registry + shortcut wiring runs inside the core background 1064 - // renderer (app/index.js awaits initCmd() at the top of its init). 1065 - // initCore — called earlier in entry.ts — already blocked on 1066 - // `window.__coreReady`, which is set AFTER core's init (including 1067 - // cmd's subscribers) completes. So by the time we reach feature 1068 - // loading below, cmd:register publishers always find a live registry. 1069 - 1070 - // Load HUD right after cmd — it's core app infrastructure like cmd, and 1071 - // registers the `hud` command + CmdOrCtrl+H shortcut via the portable api. 1072 - // HUD now runs as a standalone tile renderer (tile-preload + trustedBuiltin 1073 - // grant) — no longer loaded as an iframe in the consolidated extension host. 1074 - await initHud({ tilePreloadPath }); 1075 - 1076 - // page's `open` / `modal` command registration now runs inside the 1077 - // core background renderer (app/index.js awaits initPage() from 1078 - // app/page/background.js). No separate resident window — the per-URL 1079 - // canvas page windows are still created on demand by the cmd 1080 - // handlers calling api.window.open(...). 1077 + // cmd, hud, and page's command/shortcut wiring all run inside the 1078 + // core background renderer (app/index.js awaits initCmd(), initHud(), 1079 + // and initPage() at the top of its init). initCore — called earlier 1080 + // in entry.ts — already blocked on `window.__coreReady`, set AFTER 1081 + // core's init (including those subscribers) completes. So by the time 1082 + // we reach feature loading below, cmd:register publishers always find 1083 + // a live registry. 1081 1084 1082 1085 // Launch the privileged test-fixture renderer when running under 1083 1086 // Playwright (E2E_TEST=true). This is the v2 replacement for the v1
+20 -2
tests/desktop/external-url.spec.ts
··· 286 286 // This bypasses the normal window.open flow and tests the handleExternalUrl path 287 287 const testUrl = 'https://example.com/external-test'; 288 288 289 + // Subscribe to the window:opened event BEFORE publishing the 290 + // trigger so there's no race — main publishes this as soon as the 291 + // new window is registered (see backend/electron/ipc.ts 292 + // window-create handler). 293 + await bgWindow.evaluate((url: string) => { 294 + const api = (window as any).app; 295 + (window as any).__externalUrlWindowOpened = null; 296 + api.subscribe('window:opened', (msg: any) => { 297 + if (msg?.url && (msg.url.includes(url) || msg.url.includes(encodeURIComponent(url)))) { 298 + (window as any).__externalUrlWindowOpened = msg; 299 + } 300 + }, api.scopes.GLOBAL); 301 + }, testUrl); 302 + 289 303 // Trigger external:open-url event directly (simulates what handleExternalUrl does) 290 304 await bgWindow.evaluate(async (url: string) => { 291 305 const api = (window as any).app; ··· 300 314 }, api.scopes.GLOBAL); 301 315 }, testUrl); 302 316 303 - // Wait for window to be created (give it time to process the event) 304 - await sleep(500); 317 + // Wait for the window:opened event for our URL — deterministic. 318 + await bgWindow.waitForFunction( 319 + () => (window as any).__externalUrlWindowOpened !== null, 320 + undefined, 321 + { timeout: 5000 } 322 + ); 305 323 306 324 // Verify URL was opened 307 325 const finalList = await bgWindow.evaluate(async () => {
+20 -33
tests/desktop/v2-pubsub-reproducer.spec.ts
··· 2 2 * Minimal reproducer for the v2-tile pubsub routing bug that blocked 3 3 * v1-removal Phase 2. 4 4 * 5 - * Approach: use windows that already exist post-Phase-1 as a pure 6 - * v2→v2 pubsub round-trip. Both cmd (via cmd-glue) and hud (via 7 - * hud-glue) are trustedBuiltin tile renderers loaded at startup 8 - * with `tile-preload.cjs`. cmd publishes, hud subscribes. If the 9 - * subscriber never fires, the v2→v2 pubsub path is broken — no test 10 - * fixture needed. 5 + * Post-consolidation (cmd + hud + page merged into the core background 6 + * renderer), the trustedBuiltin tile windows we can use for a pure v2 7 + * pubsub round-trip are: 8 + * - the core background renderer (peek://app/background.html) 9 + * - any eager feature tile (e.g. peek://entities/home.html) 11 10 * 12 - * Observed: under `yarn test:grep` (E2E test profile), hud window is 13 - * NOT present in `electronApp.windows()` (only cmd, app/background, 14 - * app/extension-host, app/settings). Falls back to looking for ANY 15 - * other v2 tile window (e.g. entities/background.html) as subscriber. 16 - * Test throws with the available URL list when no pairing is found — 17 - * serves as diagnostic output for later investigation. 11 + * core publishes; the feature tile subscribes. If the subscriber never 12 + * fires, the v2 pubsub routing is broken. 18 13 */ 19 14 20 15 import { test, expect, DesktopApp, getSharedApp, closeSharedApp } from '../fixtures/desktop-app'; ··· 42 37 } 43 38 44 39 test('v2 trustedBuiltin tile → v2 trustedBuiltin tile pubsub round-trip', async () => { 45 - // Give hud/page glue time to launch their resident renderers. 46 - // initHud / initPage are awaited inside loadExtensions() but the 47 - // BrowserWindow's URL may not resolve to the final peek:// host until 48 - // the protocol handler fires. 2s is generous. 49 - await new Promise(r => setTimeout(r, 2000)); 50 - 51 40 const urls = sharedApp.windows().map(w => w.url()); 52 41 console.log('[repro] available window URLs:', JSON.stringify(urls)); 53 42 54 - // cmd resident (publisher) — trustedBuiltin tile from cmd-glue 55 - const cmdWin = findTileWindow(sharedApp, 'peek://cmd/index.html'); 56 - if (!cmdWin) { 57 - throw new Error(`cmd window not found. URLs: ${JSON.stringify(urls)}`); 43 + // Publisher: core background renderer — trustedBuiltin tile. 44 + const pubWin = findTileWindow(sharedApp, 'peek://app/background.html'); 45 + if (!pubWin) { 46 + throw new Error(`core window not found. URLs: ${JSON.stringify(urls)}`); 58 47 } 59 48 60 - // Pick any second v2 tile window (subscriber). Try hud, then page, 61 - // then any eager feature tile. 49 + // Subscriber: any eager feature tile. 62 50 const subWin = 63 - findTileWindow(sharedApp, 'peek://hud/index.html') || 64 - findTileWindow(sharedApp, 'peek://page/') || 65 51 findTileWindow(sharedApp, 'peek://entities/') || 52 + findTileWindow(sharedApp, 'peek://page/') || 66 53 findTileWindow(sharedApp, 'peek://me/') || 67 54 findTileWindow(sharedApp, 'peek://atproto/'); 68 55 69 56 if (!subWin) { 70 57 throw new Error( 71 - `No second v2 tile window found for subscriber pairing. ` + 58 + `No feature tile window found for subscriber pairing. ` + 72 59 `Available URLs: ${JSON.stringify(urls)}` 73 60 ); 74 61 } 75 62 console.log('[repro] subscriber window:', subWin.url()); 76 63 77 - // 1. Subscribe on the second v2 tile. 64 + // 1. Subscribe on the feature tile. 78 65 await subWin.evaluate(() => { 79 66 const api = (window as any).app; 80 67 (window as any).__reproReceived = null; ··· 95 82 // Small buffer: IPC subscribe is async in main. 96 83 await new Promise(r => setTimeout(r, 200)); 97 84 98 - // 2. Publish from cmd. 99 - const pubResult = await cmdWin.evaluate(() => { 85 + // 2. Publish from core. 86 + const pubResult = await pubWin.evaluate(() => { 100 87 const api = (window as any).app; 101 88 const diag: any = { 102 89 apiExists: !!api, ··· 104 91 scopeGlobal: api && api.scopes && api.scopes.GLOBAL, 105 92 }; 106 93 if (api && api.publish && api.scopes) { 107 - api.publish('repro:v2-v2-hello', { from: 'cmd', ts: Date.now() }, api.scopes.GLOBAL); 94 + api.publish('repro:v2-v2-hello', { from: 'core', ts: Date.now() }, api.scopes.GLOBAL); 108 95 diag.published = true; 109 96 } 110 97 return diag; ··· 124 111 }); 125 112 console.log('[repro] received:', JSON.stringify(received)); 126 113 127 - expect(pubResult.published, `cmd failed to publish: ${JSON.stringify(pubResult)}`).toBe(true); 128 - expect(received, 'subscriber never received cmd publish — v2 pubsub routing broken').not.toBeNull(); 114 + expect(pubResult.published, `core failed to publish: ${JSON.stringify(pubResult)}`).toBe(true); 115 + expect(received, 'subscriber never received core publish — v2 pubsub routing broken').not.toBeNull(); 129 116 });