experiments in a post-browser web
10
fork

Configure Feed

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

feat(tiles): unify tile window creation (Phase D)

Phase D of the tile-lifecycle rewrite. Every tile BrowserWindow now
goes through one function, `createTileBrowserWindow`, whether it was
triggered by a lazy command (launchTile) or a renderer-side
`api.window.open()` call (window-open IPC). This kills the divergence
class that surfaced the entities-tile-with-an-OS-menubar bug: there's
no longer a "second place" where tile BrowserWindows get built with
different defaults.

- `tile-launcher.ts`:
- New `createTileBrowserWindow(spec)` is the canonical constructor.
Applies baseline chrome (`autoHideMenuBar: true`, `session:
profileSession`, `backgroundColor: system-theme`, sandbox /
contextIsolation / nodeIntegration / preload /
additionalArguments) merged with manifest-entry windowHints then
caller overrides in fixed precedence. Registers in `tileWindows`,
hooks console-message forwarding, renders-process-gone cleanup,
and per-entry pubsub unsubscribe on close.
- `launchTile` is now a thin wrapper: resolve entry + grant +
token → `createTileBrowserWindow` → `loadURL`. Same public
signature, same return shape.
- New `configureTileLauncher({ getProfileSession,
getSystemThemeBackgroundColor })` hook. Both values live on
modules that transitively `import { BrowserWindow }` from
'electron' as ESM, so tile-launcher can't import them directly
without crashing unit tests run under ELECTRON_RUN_AS_NODE=1
(where electron's named ESM exports are empty). The hook is
called once from main.ts::initialize at app startup; unit tests
never set it and the helper falls back to defaults cleanly
because they stub the launcher entirely.
- Per-entry unsubscribe on close/crash (`unsubscribeAll(peek://
{tileId}/{entryId})`) replaces the old per-prefix cleanup
(`unsubscribeAllByPrefix(peek://{tileId}/)`). A tile's window-
entry closing should never wipe its sibling background-entry's
pubsub subscriptions; matches the behavior
`registerTrustedBuiltinWindow` already had.

- `ipc.ts` window-open: when the URL resolves to a real v2 feature
tile (`getTileWebPreferencesForUrl` returned non-null AND the
handler didn't synthesize a trustedBuiltin token AND it's not a
canvas page host), delegate BrowserWindow construction to
`createTileBrowserWindow`. The caller-provided position / modal /
skipTaskbar / transparent flags thread through as
`browserWindowOverrides`; caller-provided `webPreferences` extras
thread through as `webPreferencesOverrides` (after stripping the
fields the helper owns canonically). The subsequent
`registerTrustedBuiltinWindow` call is gated off for this branch —
the helper already registered.

- `main.ts::initialize`: wires the tile-launcher hooks with the real
electron-side getters before any feature or window work.

Deliberately out of scope: canvas page hosts, trustedBuiltin special
cases (settings, diagnostic, hud-overlay, cmd-ui, about:blank,
datastore-viewer), and plain web pages still go through the direct
`new BrowserWindow(winOptions)` path in ipc.ts. They're not v2
feature tiles; unifying them is a separate cleanup.

Tests: 2250/2250 unit + 238/238 Playwright (desktop + desktop-serial).

+274 -101
+50 -4
backend/electron/ipc.ts
··· 84 84 DEBUG, 85 85 } from './config.js'; 86 86 87 - import { getTileWebPreferencesForUrl, registerTrustedBuiltinWindow } from './tile-launcher.js'; 87 + import { getTileWebPreferencesForUrl, registerTrustedBuiltinWindow, createTileBrowserWindow } from './tile-launcher.js'; 88 88 import { getLazyTileManifest } from './tile-lazy.js'; 89 89 import { createTrustedBuiltinGrant } from './tile-manifest.js'; 90 90 import { generateToken } from './tile-tokens.js'; ··· 1352 1352 if (options.skipTaskbar !== undefined) winOptions.skipTaskbar = !!options.skipTaskbar; 1353 1353 if (options.hasShadow !== undefined && !useCanvas) winOptions.hasShadow = !!options.hasShadow; 1354 1354 1355 - // Create new window 1356 - const win = new BrowserWindow(winOptions); 1355 + // Create new window. 1356 + // 1357 + // For real v2 feature tile URLs (tileWebPrefs resolved from the 1358 + // manifest, NOT a special-case trustedBuiltin synthesis and NOT a 1359 + // canvas page host), route through `createTileBrowserWindow` — the 1360 + // same helper `launchTile` uses. This is Phase D of the tile- 1361 + // lifecycle rewrite: one constructor, one set of baseline chrome 1362 + // defaults (autoHideMenuBar, session, backgroundColor), one 1363 + // registration path. The divergence that surfaced the entities- 1364 + // tile-has-a-menubar bug can't recur because there's no second 1365 + // place where tile BrowserWindows get built. 1366 + // 1367 + // Canvas page hosts, trustedBuiltin special cases (settings, 1368 + // diagnostic, hud-overlay, cmd-ui, about:blank, datastore-viewer), 1369 + // and plain web pages still go through the direct `new 1370 + // BrowserWindow(winOptions)` path — they're not v2 feature tiles 1371 + // and unifying them is a separate cleanup. 1372 + const isRealV2TileLaunch = 1373 + tileWebPrefs !== null && 1374 + !specialCaseToken && 1375 + !useCanvas && 1376 + tileWebPrefs.token !== undefined; 1377 + let win: BrowserWindow; 1378 + if (isRealV2TileLaunch && tileWebPrefs) { 1379 + // Pull webPreferences overrides out so the helper's baseline tile 1380 + // webPreferences (preload, additionalArguments, session, sandbox) 1381 + // remain in control. Anything else the caller asked for (webviewTag, 1382 + // etc.) is passed through as an override. 1383 + const { preload: _p, additionalArguments: _a, session: _s, sandbox: _sb, 1384 + contextIsolation: _ci, nodeIntegration: _ni, 1385 + ...extraWebPrefs } = (winOptions.webPreferences ?? {}) as Record<string, unknown>; 1386 + const { webPreferences: _wp, ...browserWindowOverrides } = winOptions; 1387 + win = createTileBrowserWindow({ 1388 + tileId: tileWebPrefs.tileId, 1389 + entryId: tileWebPrefs.entryId, 1390 + token: tileWebPrefs.token!, 1391 + preloadPath: tileWebPrefs.preload, 1392 + browserWindowOverrides: browserWindowOverrides as Partial<Electron.BrowserWindowConstructorOptions>, 1393 + webPreferencesOverrides: extraWebPrefs as Partial<Electron.WebPreferences>, 1394 + }); 1395 + } else { 1396 + win = new BrowserWindow(winOptions); 1397 + } 1357 1398 { const actualBounds = win.getBounds(); console.log(`[window-open:actual] Window ${win.id} actual bounds after creation: (${actualBounds.x},${actualBounds.y}) ${actualBounds.width}x${actualBounds.height}`); } 1358 1399 1359 1400 // Reinforce alwaysOnTop after creation — on macOS the constructor flag alone ··· 1386 1427 if (specialCaseToken && tileWebPrefs?.tileId && tileWebPrefs?.entryId) { 1387 1428 registerTrustedBuiltinWindow(tileWebPrefs.tileId, tileWebPrefs.entryId, win, specialCaseToken); 1388 1429 DEBUG && console.log(`[window-open] registered ${tileWebPrefs.tileId}:${tileWebPrefs.entryId} with tile broadcaster`); 1389 - } else if (tileWebPrefs?.tileId && tileWebPrefs?.entryId && tileWebPrefs?.token) { 1430 + } else if (!isRealV2TileLaunch && tileWebPrefs?.tileId && tileWebPrefs?.entryId && tileWebPrefs?.token) { 1390 1431 // Feature tile window-entry opened via api.window.open(): register the 1391 1432 // BrowserWindow with the tile broadcaster so GLOBAL-scope pubsub messages 1392 1433 // reach it. Without this, the window gets tile-preload + a valid token ··· 1396 1437 // tile subscribes to a topic the window publishes (or vice versa), and 1397 1438 // the subscribe-side handler never fires. See pubsub-repro.spec.ts for 1398 1439 // a minimal repro. 1440 + // 1441 + // Gate on !isRealV2TileLaunch — when the helper above created the 1442 + // window it already called tileWindows.set + wired the close 1443 + // handler, so this branch would double-register. Only reached now 1444 + // for legacy/edge paths that still go through `new BrowserWindow`. 1399 1445 registerTrustedBuiltinWindow(tileWebPrefs.tileId, tileWebPrefs.entryId, win, tileWebPrefs.token); 1400 1446 DEBUG && console.log(`[window-open] registered v2 tile window ${tileWebPrefs.tileId}:${tileWebPrefs.entryId} with tile broadcaster`); 1401 1447 }
+11 -1
backend/electron/main.ts
··· 17 17 import { discoverExtensions, loadExtensionManifest, isBuiltinExtensionEnabled, type ExtensionManifest, type ManifestCommand, type ManifestShortcut } from './extensions.js'; 18 18 import { initializeFeatures, type FeatureStartupResult } from './feature-startup.js'; 19 19 import { ensureTileIpcHandlers } from './tile-compat.js'; 20 - import { getLoadedTileIds, getTileManifest, getAllTileWindows, unloadAllTiles, relaunchTile } from './tile-launcher.js'; 20 + import { getLoadedTileIds, getTileManifest, getAllTileWindows, unloadAllTiles, relaunchTile, configureTileLauncher } from './tile-launcher.js'; 21 21 import { initTray } from './tray.js'; 22 22 import { registerLocalShortcut, unregisterLocalShortcut, handleLocalShortcut, registerGlobalShortcut, unregisterGlobalShortcut, unregisterShortcutsForAddress } from './shortcuts.js'; 23 23 import { scopes, publish, subscribe, unsubscribe, hasSubscriber, setExtensionBroadcaster, getSystemAddress } from './pubsub.js'; ··· 186 186 // Electron preload scripts must be CommonJS under `sandbox: true`. 187 187 const tilePreloadPath = path.join(config.rootDir, 'dist', 'backend', 'electron', 'tile-preload.cjs'); 188 188 setTilePreloadPath(tilePreloadPath); 189 + 190 + // Inject tile-launcher's electron-side dependencies. These live on 191 + // modules that tile-launcher can't import at its top level (they 192 + // transitively ESM-import BrowserWindow, which blows up under 193 + // ELECTRON_RUN_AS_NODE=1 during unit tests). See `configureTileLauncher` 194 + // for the why. 195 + configureTileLauncher({ 196 + getProfileSession, 197 + getSystemThemeBackgroundColor, 198 + }); 189 199 190 200 // Initialize database 191 201 const dbPath = path.join(config.userDataPath, config.profile, 'datastore.sqlite');
+213 -96
backend/electron/tile-launcher.ts
··· 44 44 revokeTokensForTile, 45 45 clearAllTokens, 46 46 } from './tile-tokens.js'; 47 - import { scopes, publish, getSystemAddress, unsubscribeAll, unsubscribeAllByPrefix } from './pubsub.js'; 47 + import { scopes, publish, getSystemAddress, unsubscribeAll } from './pubsub.js'; 48 48 import { DEBUG, getTilePreloadPath } from './config.js'; 49 49 import { loadSchemaDefaults } from './tile-settings-defaults.js'; 50 50 import { getExtensionPath } from './protocol.js'; 51 + 52 + // Config hooks for values sourced from electron-heavy modules 53 + // (session-partition, windows) that we can't safely import at top 54 + // level in this file: both transitively `import { BrowserWindow }` 55 + // from 'electron' as ESM, which breaks under ELECTRON_RUN_AS_NODE=1 56 + // where electron's named ESM exports are empty. Unit tests load 57 + // tile-launcher.js under that mode and would crash at module parse 58 + // time. We inject the getters at app startup instead — same pattern 59 + // as `setExtensionBroadcaster` in pubsub.ts. Tests that stub the 60 + // launcher never call createTileBrowserWindow, so the hooks stay 61 + // null and the baseline helpers fall back cleanly. 62 + let _getProfileSession: (() => Electron.Session) | null = null; 63 + let _getSystemThemeBackgroundColor: (() => string) | null = null; 64 + 65 + /** 66 + * Wire tile-launcher up to its electron-side dependencies. Called once 67 + * at app startup, before any `launchTile` / `createTileBrowserWindow` 68 + * call. Passing either as undefined keeps that dependency unused (unit 69 + * tests do this implicitly by never calling this function). 70 + */ 71 + export function configureTileLauncher(hooks: { 72 + getProfileSession?: () => Electron.Session; 73 + getSystemThemeBackgroundColor?: () => string; 74 + }): void { 75 + if (hooks.getProfileSession) _getProfileSession = hooks.getProfileSession; 76 + if (hooks.getSystemThemeBackgroundColor) _getSystemThemeBackgroundColor = hooks.getSystemThemeBackgroundColor; 77 + } 51 78 52 79 // Short grace period given to tiles to run their `api.onShutdown()` callbacks 53 80 // before the BrowserWindow is forcibly closed. Kept small so app-quit and ··· 102 129 } 103 130 104 131 /** 105 - * Launch a tile entry 132 + * Spec for `createTileBrowserWindow` — the canonical BrowserWindow 133 + * constructor for every tile window in the app. 134 + */ 135 + export interface CreateTileBrowserWindowSpec { 136 + tileId: string; 137 + entryId: string; 138 + token: string; 139 + preloadPath: string; 140 + /** 141 + * Optional manifest + entry used as the source of declared window hints 142 + * (width/height/frame/transparent/etc). Launchers derive hints from the 143 + * manifest; callers that have no manifest (e.g. trustedBuiltin callers 144 + * doing post-hoc registration) can omit this. 145 + */ 146 + manifest?: TileManifestV2; 147 + entry?: TileEntry; 148 + /** 149 + * Extra BrowserWindow options applied after manifest hints. Used by the 150 + * `window-open` IPC branch to thread through caller-provided position, 151 + * modal, skipTaskbar, etc. Takes precedence over manifest hints. 152 + */ 153 + browserWindowOverrides?: Partial<Electron.BrowserWindowConstructorOptions>; 154 + /** 155 + * Extra webPreferences options merged into the canonical tile 156 + * webPreferences (preload + additionalArguments + sandbox + session). 157 + */ 158 + webPreferencesOverrides?: Partial<Electron.WebPreferences>; 159 + /** 160 + * When true, the render-process-gone handler publishes a `tile:crashed` 161 + * event on the pubsub bus so UIs can react. Regular feature tiles want 162 + * this on; one-off trustedBuiltin helpers (cmd panel popup, settings 163 + * window) can opt out to avoid flooding the bus on dev reload crashes. 164 + * Default: true when `manifest` is provided, false otherwise. 165 + */ 166 + publishCrashEvent?: boolean; 167 + } 168 + 169 + /** 170 + * createTileBrowserWindow — the ONE function that creates tile 171 + * BrowserWindows. All tile-window lifecycles go through here so: 172 + * 173 + * - Baseline chrome defaults (autoHideMenuBar, session, backgroundColor) 174 + * can't drift between callers. 175 + * - The tile registry, console forwarding, crash handler, and 176 + * per-entry pubsub cleanup are wired identically every time. 177 + * 178 + * Merge order (later wins): 179 + * baseline defaults ◁ manifest-entry windowHints ◁ caller overrides 106 180 * 107 - * Creates the appropriate context (BrowserWindow) with: 108 - * - sandbox: true 109 - * - contextIsolation: true 110 - * - nodeIntegration: false 111 - * - Capability token passed via additionalArguments 181 + * Returns the BrowserWindow. Caller is responsible for `loadURL` (the 182 + * URL is not part of this helper so the window-open IPC branch can do 183 + * its own URL rewrites / redirects). 112 184 */ 113 - export function launchTile(options: TileLaunchOptions): TileLaunchResult { 114 - const { tilePath, manifest, preloadPath, entryId, windowOverrides } = options; 185 + export function createTileBrowserWindow(spec: CreateTileBrowserWindowSpec): BrowserWindow { 186 + const { 187 + tileId, entryId, token, preloadPath, manifest, entry, 188 + browserWindowOverrides, webPreferencesOverrides, 189 + } = spec; 190 + const publishCrashEvent = spec.publishCrashEvent ?? (manifest !== undefined); 115 191 116 - // Find the tile entry 117 - const entry = entryId 118 - ? manifest.tiles.find(t => t.id === entryId) 119 - : manifest.tiles[0]; 120 - 121 - if (!entry) { 122 - throw new Error(`[tile-launcher] Tile entry not found: ${entryId || '(first)'} in ${manifest.id}`); 192 + // Hints from the manifest entry (windowHints + flat fields on the 193 + // entry itself, which we treat as shorthand for the same thing). 194 + let hints: Partial<TileEntry['windowHints']> & Partial<TileEntry> = {}; 195 + if (entry) { 196 + const { width, height, minWidth, minHeight, alwaysOnTop, transparent, 197 + focusable, frame, resizable, role, key: entryKey, title } = entry; 198 + const entryFlat = { width, height, minWidth, minHeight, alwaysOnTop, transparent, 199 + focusable, frame, resizable, role, key: entryKey, title }; 200 + hints = { ...entry.windowHints, ...entryFlat }; 123 201 } 124 202 125 - // Resolve capabilities 126 - const grant = resolveCapabilities( 127 - manifest.id, 128 - manifest.capabilities, 129 - manifest.builtin === true 130 - ); 203 + const transparentResolved = browserWindowOverrides?.transparent ?? hints?.transparent === true; 131 204 132 - if (grant.denied.length > 0) { 133 - DEBUG && console.log(`[tile-launcher] Denied capabilities for ${manifest.id}:`, grant.denied); 134 - } 135 - 136 - // Generate token 137 - const token = generateToken(manifest.id, entry.id, grant); 205 + // Canonical baseline options. Every tile window inherits these unless 206 + // manifest hints or caller overrides explicitly change them. 207 + const baseline: Electron.BrowserWindowConstructorOptions = { 208 + // All tiles start hidden. Visibility is a runtime choice 209 + // (showSelf / window-open show flag / session restore). 210 + show: false, 211 + // No OS menubar anywhere, ever. Silent divergence between launchTile 212 + // (which never set this) and window-open (which sometimes inherited 213 + // it from callers) was the Phase D trigger — nothing opts in now. 214 + autoHideMenuBar: true, 215 + // System-theme background prevents a white flash on dark mode. Skip 216 + // for transparent tiles — setting backgroundColor defeats transparency. 217 + backgroundColor: transparentResolved ? undefined : _getSystemThemeBackgroundColor?.(), 218 + }; 138 219 139 - // Build window options — merge windowHints (compat) with top-level flat 140 - // window fields (top-level wins if both are set), then apply overrides. 141 - const { width, height, minWidth, minHeight, alwaysOnTop, transparent, 142 - focusable, frame, resizable, role, key: entryKey, title } = entry; 143 - const entryFlat = { width, height, minWidth, minHeight, alwaysOnTop, transparent, 144 - focusable, frame, resizable, role, key: entryKey, title }; 145 - const hints = { ...entry.windowHints, ...entryFlat, ...windowOverrides }; 220 + // Merge order: baseline ◁ hints ◁ overrides. The hints object is 221 + // spread into individual BrowserWindow fields so we can apply the 222 + // `hints.frame !== false` style defaults without the spread clobbering 223 + // them with undefined. 146 224 const windowOptions: Electron.BrowserWindowConstructorOptions = { 225 + ...baseline, 147 226 width: hints?.width || 800, 148 227 height: hints?.height || 600, 149 228 minWidth: hints?.minWidth, 150 229 minHeight: hints?.minHeight, 151 - // All tiles start hidden. Visibility is a runtime choice (showSelf / session restore). 152 - show: false, 153 230 frame: hints?.frame !== false, 154 - transparent: hints?.transparent === true, 231 + transparent: transparentResolved, 155 232 alwaysOnTop: hints?.alwaysOnTop === true, 156 233 focusable: hints?.focusable !== false, 157 234 resizable: hints?.resizable !== false, 158 - title: hints?.title || manifest.name, 235 + title: hints?.title || manifest?.name, 236 + ...browserWindowOverrides, 159 237 webPreferences: { 160 238 sandbox: true, 161 239 contextIsolation: true, 162 240 nodeIntegration: false, 241 + session: _getProfileSession?.(), 163 242 preload: preloadPath, 164 243 additionalArguments: [ 165 - `--tile-id=${manifest.id}`, 166 - `--tile-entry=${entry.id}`, 244 + `--tile-id=${tileId}`, 245 + `--tile-entry=${entryId}`, 167 246 `--tile-token=${token}`, 168 247 ], 248 + ...webPreferencesOverrides, 169 249 }, 170 250 }; 171 251 172 - // Tiles are resident or not, shown or not — orthogonal. No special 173 - // dimensions for "background" tiles: they use declared width/height like 174 - // any tile (default 800×600). 175 - 176 252 const BrowserWindow = getBrowserWindowCtor(); 177 253 const win = new BrowserWindow(windowOptions); 178 254 179 - // Build the URL for this tile: peek://{tileId}/{url} 180 - const tileUrl = `peek://${manifest.id}/${entry.url}`; 181 - win.loadURL(tileUrl); 182 - 183 - // Populate manifest.defaults from the tile's settingsSchema file so 184 - // tile:settings:get can fall back to declared defaults on DB miss. 185 - // Loaded once per tile process; subsequent launches reuse the cached 186 - // object if it was already populated. 187 - if (manifest.defaults === undefined) { 188 - const defaults = loadSchemaDefaults(tilePath, manifest.settingsSchema); 189 - if (defaults) manifest.defaults = defaults; 190 - } 191 - 192 - // Track the window 193 - const key = `${manifest.id}:${entry.id}`; 255 + const key = `${tileId}:${entryId}`; 194 256 tileWindows.set(key, win); 195 - loadedManifests.set(manifest.id, manifest); 257 + if (manifest) loadedManifests.set(tileId, manifest); 196 258 197 - // Forward console messages from the tile's webContents to the main process 198 - // stdout. Without this, console.log/error/warn inside tile code vanishes 199 - // silently and there is no way to correlate a log line to a specific tile. 200 - // Electron 40's console-message event surfaces `event.level` (0..3) and 201 - // `event.message` (see backend/electron/ipc.ts:2486 for the same pattern). 259 + // Forward console messages from the tile's webContents to main-process 260 + // stdout. Errors/warnings always forwarded; info/debug only under DEBUG. 202 261 win.webContents.on('console-message', (event) => { 203 262 try { 204 263 const level = parseInt(String((event as { level?: unknown }).level), 10); 205 264 const message = String((event as { message?: unknown }).message ?? ''); 206 265 const levelLabels = ['verbose', 'info', 'warning', 'error']; 207 266 const levelLabel = levelLabels[level] || 'info'; 208 - // Errors/warnings always forwarded; info/debug only when DEBUG is on. 209 267 if (level >= 2) { 210 - console.error(`[tile:${manifest.id}:${entry.id}] [${levelLabel}] ${message}`); 268 + console.error(`[tile:${tileId}:${entryId}] [${levelLabel}] ${message}`); 211 269 } else if (DEBUG) { 212 - console.log(`[tile:${manifest.id}:${entry.id}] [${levelLabel}] ${message}`); 270 + console.log(`[tile:${tileId}:${entryId}] [${levelLabel}] ${message}`); 213 271 } 214 272 } catch { 215 273 // Logging must never throw. 216 274 } 217 275 }); 218 276 219 - // Track render-process crashes — without this, tiles crash silently. 220 - // On crash: revoke the capability token, drop tracked state, unsubscribe 221 - // any leftover pubsub registrations, and publish a GLOBAL-scope 222 - // `tile:crashed` event so UI (e.g. manage/features-manager) can react. 277 + // Render-process crashes: revoke token, drop tracked state, unsubscribe 278 + // this entry's subs only (sibling entries stay subscribed), and 279 + // optionally publish a `tile:crashed` event. 223 280 win.webContents.on('render-process-gone', (_event, details) => { 224 281 console.error( 225 - `[tile:${manifest.id}:${entry.id}] Render process gone: ${details.reason} (exitCode=${details.exitCode})` 282 + `[tile:${tileId}:${entryId}] Render process gone: ${details.reason} (exitCode=${details.exitCode})` 226 283 ); 227 284 tileWindows.delete(key); 228 285 readyTiles.delete(key); 229 286 revokeToken(token); 230 287 try { 231 - // Crashed renderer can't unsubscribe itself — clear every subscription 232 - // sourced from this tile so the main-process registry doesn't leak. 233 - unsubscribeAllByPrefix(`peek://${manifest.id}/`); 288 + unsubscribeAll(`peek://${tileId}/${entryId}`); 234 289 } catch (err) { 235 - console.error(`[tile:${manifest.id}:${entry.id}] Failed to unsubscribe after crash:`, err); 290 + console.error(`[tile:${tileId}:${entryId}] Failed to unsubscribe after crash:`, err); 236 291 } 237 - try { 238 - // GLOBAL (not SYSTEM) so non-privileged UIs like the manage panel can 239 - // subscribe to tile:crashed and surface a crashed badge. 240 - publish(getSystemAddress(), scopes.GLOBAL, 'tile:crashed', { 241 - tileId: manifest.id, 242 - entryId: entry.id, 243 - reason: details.reason, 244 - exitCode: details.exitCode, 245 - }); 246 - } catch (err) { 247 - console.error(`[tile:${manifest.id}:${entry.id}] Failed to publish tile:crashed event:`, err); 292 + if (publishCrashEvent) { 293 + try { 294 + publish(getSystemAddress(), scopes.GLOBAL, 'tile:crashed', { 295 + tileId, entryId, 296 + reason: details.reason, 297 + exitCode: details.exitCode, 298 + }); 299 + } catch (err) { 300 + console.error(`[tile:${tileId}:${entryId}] Failed to publish tile:crashed event:`, err); 301 + } 248 302 } 249 303 }); 250 304 251 - // Clean up on close — drop tracked state, revoke token, and unsubscribe 252 - // any pubsub subscriptions the tile registered (these live in the 253 - // main-process registry keyed by `peek://{tileId}/{entry}` and won't 254 - // clean themselves up when the renderer goes away). 305 + // Normal close: same cleanup as crash, minus the crash broadcast. 255 306 win.on('closed', () => { 256 307 tileWindows.delete(key); 257 308 readyTiles.delete(key); 258 309 revokeToken(token); 259 310 try { 260 - unsubscribeAllByPrefix(`peek://${manifest.id}/`); 311 + unsubscribeAll(`peek://${tileId}/${entryId}`); 261 312 } catch (err) { 262 - console.error(`[tile-launcher:${manifest.id}:${entry.id}] Failed to unsubscribe on close:`, err); 313 + console.error(`[tile-launcher:${tileId}:${entryId}] Failed to unsubscribe on close:`, err); 263 314 } 264 - DEBUG && console.log(`[tile-launcher:${manifest.id}:${entry.id}] Tile closed`); 315 + DEBUG && console.log(`[tile-launcher:${tileId}:${entryId}] Tile closed`); 316 + }); 317 + 318 + return win; 319 + } 320 + 321 + /** 322 + * Launch a tile entry 323 + * 324 + * Thin wrapper over `createTileBrowserWindow` that: 325 + * - Resolves which tile entry to launch from the manifest 326 + * - Resolves and grants capabilities 327 + * - Mints a capability token 328 + * - Creates the window via the canonical helper 329 + * - Calls `loadURL` with the tile's peek:// URL 330 + */ 331 + export function launchTile(options: TileLaunchOptions): TileLaunchResult { 332 + const { tilePath, manifest, preloadPath, entryId, windowOverrides } = options; 333 + 334 + // Find the tile entry 335 + const entry = entryId 336 + ? manifest.tiles.find(t => t.id === entryId) 337 + : manifest.tiles[0]; 338 + 339 + if (!entry) { 340 + throw new Error(`[tile-launcher] Tile entry not found: ${entryId || '(first)'} in ${manifest.id}`); 341 + } 342 + 343 + // Resolve capabilities 344 + const grant = resolveCapabilities( 345 + manifest.id, 346 + manifest.capabilities, 347 + manifest.builtin === true 348 + ); 349 + 350 + if (grant.denied.length > 0) { 351 + DEBUG && console.log(`[tile-launcher] Denied capabilities for ${manifest.id}:`, grant.denied); 352 + } 353 + 354 + // Generate token 355 + const token = generateToken(manifest.id, entry.id, grant); 356 + 357 + // Apply per-launch windowOverrides by merging them onto the entry 358 + // before we hand it to the helper. windowOverrides are defined as 359 + // `Partial<TileEntry['windowHints']>` so they're semantically hints. 360 + const entryWithOverrides: TileEntry = windowOverrides 361 + ? { ...entry, windowHints: { ...entry.windowHints, ...windowOverrides } } 362 + : entry; 363 + 364 + const win = createTileBrowserWindow({ 365 + tileId: manifest.id, 366 + entryId: entry.id, 367 + token, 368 + preloadPath, 369 + manifest, 370 + entry: entryWithOverrides, 265 371 }); 372 + 373 + // Build the URL for this tile: peek://{tileId}/{url} 374 + const tileUrl = `peek://${manifest.id}/${entry.url}`; 375 + win.loadURL(tileUrl); 376 + 377 + // Populate manifest.defaults from the tile's settingsSchema file so 378 + // tile:settings:get can fall back to declared defaults on DB miss. 379 + if (manifest.defaults === undefined) { 380 + const defaults = loadSchemaDefaults(tilePath, manifest.settingsSchema); 381 + if (defaults) manifest.defaults = defaults; 382 + } 266 383 267 384 DEBUG && console.log(`[tile-launcher:${manifest.id}:${entry.id}] Launched -> ${tileUrl}`); 268 385