experiments in a post-browser web
10
fork

Configure Feed

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

refactor(hud): migrate HUD overlay + widgets to tile-preload + delete preload.js (Phase 3.11b-hud)

+70 -2739
+1
.yarn/releases
··· 1 + /Users/dietrich/misc/mpeek/.yarn/releases
+1 -1
app/hud/hud.js
··· 19 19 * Load HUD sheet config from feature_settings 20 20 */ 21 21 const loadSheetConfig = async () => { 22 - const result = await api.settings.getKey(HUD_SHEET_KEY); 22 + const result = await api.settings.get(HUD_SHEET_KEY); 23 23 if (result.success && result.data) { 24 24 return result.data; 25 25 }
-15
backend/electron/config.ts
··· 23 23 export const TILE_STRICT = process.env.TILE_STRICT === 'true'; 24 24 25 25 // Runtime configuration (set during app initialization) 26 - let _preloadPath: string = ''; 27 26 let _tilePreloadPath: string = ''; 28 27 let _profile: string = ''; 29 28 ··· 33 32 */ 34 33 export function isHeadless(): boolean { 35 34 return !!process.env.PEEK_HEADLESS; 36 - } 37 - 38 - /** 39 - * Set runtime paths (called during app initialization) 40 - */ 41 - export function setPreloadPath(preloadPath: string): void { 42 - _preloadPath = preloadPath; 43 35 } 44 36 45 37 /** ··· 47 39 */ 48 40 export function setProfile(profile: string): void { 49 41 _profile = profile; 50 - } 51 - 52 - /** 53 - * Get the preload script path 54 - */ 55 - export function getPreloadPath(): string { 56 - return _preloadPath; 57 42 } 58 43 59 44 /**
+38 -23
backend/electron/entry.ts
··· 60 60 // Config 61 61 WEB_CORE_ADDRESS, 62 62 SETTINGS_ADDRESS, 63 - setPreloadPath, 64 63 setProfile, 65 64 isTestProfile, 66 65 isHeadless, ··· 104 103 import { getTileWebPreferencesForWebviewUrl } from './tile-launcher.js'; 105 104 import { getLazyTileManifest } from './tile-lazy.js'; 106 105 import { getTilePreloadPath } from './config.js'; 106 + import { createTrustedBuiltinGrant } from './tile-manifest.js'; 107 + import { generateToken } from './tile-tokens.js'; 107 108 108 109 // Early diagnostic logging for URL handling (writes to /tmp/ to avoid userData path issues) 109 110 try { ··· 148 149 } 149 150 } 150 151 151 - // script loaded into every app window 152 - const preloadPath = path.join(ROOT_DIR, 'preload.js'); 153 - 154 152 const systemAddress = getSystemAddress(); 155 153 156 - // Initialize backend config with runtime values 157 - setPreloadPath(preloadPath); 158 - 159 154 // Read Proton Pass webauthn.js for main-world injection into web content webviews. 160 155 // Electron ignores "world": "MAIN" in Chrome extension manifest content_scripts, 161 156 // so we inject the script ourselves via executeJavaScript() which runs in the main world. ··· 170 165 console.error('[webauthn] Failed to read webauthn.js:', err); 171 166 } 172 167 173 - // Inject preload into <webview> elements that load peek:// URLs. 168 + // Inject tile-preload into <webview> elements that load peek:// URLs. 174 169 // This gives internal widget pages (e.g., HUD widgets, widget-demo cards) 175 170 // access to window.app API while leaving external web content webviews untouched. 176 171 // 177 - // Two paths (Phase 2.5 #2 — webview preload migration): 178 - // 1. v2 tiles: if the URL resolves to a registered v2 tile manifest, 179 - // use tile-preload.cjs with capability-token argv (the same shape 180 - // v2 BrowserWindows get via webPreferences.additionalArguments). 181 - // Webview children inherit the parent tile's grant via 182 - // `getTileWebPreferencesForWebviewUrl`. 183 - // 2. v1 fallback: any peek:// URL whose tile-id isn't a v2 tile (e.g. 184 - // `peek://ext/hud/widgets/...` while HUD is still v1) keeps the 185 - // legacy preload.js. This will go away when HUD is migrated. 172 + // Resolution order (Phase 3.11b-hud — v1 preload.js removed): 173 + // 1. HUD widget webviews: peek://ext/hud/widgets/*.html get a dedicated 174 + // trustedBuiltin token (hud-widget). HUD has no v2 manifest but 175 + // widgets use api.window/context/izui/pubsub — all covered by 176 + // trustedBuiltin grant. 177 + // 2. v2 tiles: URL resolves to a registered v2 tile manifest — use 178 + // tile-preload.cjs with manifest-derived capability token. 179 + // 3. Unrecognised peek:// URL: log a warning and skip preload injection 180 + // (no v1 fallback — preload.js has been deleted). 186 181 // 187 182 // Also inject Proton Pass webauthn.js into http/https webviews for passkey support. 188 183 // The script overrides navigator.credentials.create/get in the main world. ··· 190 185 contents.on('will-attach-webview', (_wvEvent, webPreferences, params) => { 191 186 if (params.src && params.src.startsWith('peek://')) { 192 187 const tilePreloadPath = getTilePreloadPath(); 193 - const tileWebPrefs = tilePreloadPath 194 - ? getTileWebPreferencesForWebviewUrl(params.src, tilePreloadPath, getLazyTileManifest) 195 - : null; 188 + if (!tilePreloadPath) return; 189 + 190 + // Special-case: HUD widget webviews (peek://ext/hud/widgets/*.html). 191 + // HUD has no v2 manifest; mint a trustedBuiltin token for all widgets 192 + // under a unified hud-widget tile id so they can use api.window.*, 193 + // api.context.*, api.izui.*, api.pubsub.*, etc. 194 + if (params.src.startsWith('peek://ext/hud/widgets/')) { 195 + const HUD_WIDGET_ID = 'hud-widget'; 196 + const HUD_WIDGET_ENTRY = 'main'; 197 + const hudWidgetGrant = createTrustedBuiltinGrant(HUD_WIDGET_ID); 198 + const hudWidgetToken = generateToken(HUD_WIDGET_ID, HUD_WIDGET_ENTRY, hudWidgetGrant); 199 + webPreferences.preload = tilePreloadPath; 200 + (webPreferences as { additionalArguments?: string[] }).additionalArguments = [ 201 + `--tile-id=${HUD_WIDGET_ID}`, 202 + `--tile-entry=${HUD_WIDGET_ENTRY}`, 203 + `--tile-token=${hudWidgetToken}`, 204 + ]; 205 + DEBUG && console.log(`[webview] Injecting trustedBuiltin tile-preload for HUD widget: ${params.src}`); 206 + return; 207 + } 208 + 209 + const tileWebPrefs = getTileWebPreferencesForWebviewUrl(params.src, tilePreloadPath, getLazyTileManifest); 196 210 if (tileWebPrefs) { 197 211 webPreferences.preload = tileWebPrefs.preload; 198 212 // Cast: Electron's WebPreferences type for webviews omits ··· 204 218 `[webview] Injecting v2 tile-preload for ${params.src} (tile=${tileWebPrefs.tileId}, entry=${tileWebPrefs.entryId})` 205 219 ); 206 220 } else { 207 - webPreferences.preload = preloadPath; 208 - DEBUG && console.log(`[webview] Injecting v1 preload for peek:// webview: ${params.src}`); 221 + // No v1 preload fallback — preload.js has been deleted. Any peek:// 222 + // URL that reaches here is either a developer-extension webview that 223 + // needs a v2 manifest, or a bug. Log a warning so it surfaces. 224 + console.warn(`[webview] No tile-preload found for peek:// webview ${params.src} — no preload injected (v1 preload.js removed)`); 209 225 } 210 226 } 211 227 }); ··· 879 895 // Configure app before ready (registers protocol scheme, sets theme) 880 896 configure({ 881 897 rootDir: ROOT_DIR, 882 - preloadPath: preloadPath, 883 898 userDataPath: defaultUserDataPath, 884 899 profile: PROFILE, 885 900 isDev: DEBUG,
+1 -3
backend/electron/index.ts
··· 12 12 SETTINGS_ADDRESS, 13 13 IPC_CHANNELS, 14 14 TOPICS, 15 - setPreloadPath, 16 15 setProfile, 17 - getPreloadPath, 18 16 getProfile, 19 17 isTestProfile, 20 18 isDevProfile, ··· 227 225 listBackups, 228 226 } from './backup.js'; 229 227 230 - // Re-export frontend API types (the contract that preload.js implements) 228 + // Re-export frontend API types (the contract that tile-preload.cjs implements) 231 229 export type { 232 230 IPeekApi, 233 231 ApiResult,
+24 -3
backend/electron/ipc.ts
··· 77 77 APP_DEF_WIDTH, 78 78 APP_DEF_HEIGHT, 79 79 WEB_CORE_ADDRESS, 80 - getPreloadPath, 81 80 getTilePreloadPath, 82 81 IPC_CHANNELS, 83 82 isHeadless, ··· 1072 1071 DEBUG && console.log('[window-open] settings window: assigned trustedBuiltin tile-preload token'); 1073 1072 } 1074 1073 1074 + // Special-case: peek://ext/hud/hud.html is the HUD overlay BrowserWindow 1075 + // opened on-demand by app/hud/background.js via api.window.open(). It 1076 + // hosts webview widget cards (app/hud/hud.js) and needs tile-preload for 1077 + // api.settings.get / api.window.resize. No v2 manifest — trustedBuiltin. 1078 + if (!tileWebPrefs && url === 'peek://ext/hud/hud.html') { 1079 + const HUD_OVERLAY_ID = 'hud-overlay'; 1080 + const HUD_OVERLAY_ENTRY = 'main'; 1081 + const hudOverlayGrant = createTrustedBuiltinGrant(HUD_OVERLAY_ID); 1082 + const hudOverlayToken = generateToken(HUD_OVERLAY_ID, HUD_OVERLAY_ENTRY, hudOverlayGrant); 1083 + tileWebPrefs = { 1084 + preload: getTilePreloadPath(), 1085 + additionalArguments: [ 1086 + `--tile-id=${HUD_OVERLAY_ID}`, 1087 + `--tile-entry=${HUD_OVERLAY_ENTRY}`, 1088 + `--tile-token=${hudOverlayToken}`, 1089 + ], 1090 + tileId: HUD_OVERLAY_ID, 1091 + entryId: HUD_OVERLAY_ENTRY, 1092 + }; 1093 + DEBUG && console.log('[window-open] hud-overlay window: assigned trustedBuiltin tile-preload token'); 1094 + } 1095 + 1075 1096 // Special-case: peek://app/datastore/viewer.html uses api.datastore.* to 1076 1097 // display raw DB tables — not a registered v2 tile but needs tile-preload. 1077 1098 if (!tileWebPrefs && url === 'peek://app/datastore/viewer.html') { ··· 1122 1143 ...options.webPreferences, 1123 1144 // Canvas page host windows get tile-preload with a trustedBuiltin grant; 1124 1145 // other v2 tile URLs get their manifest-derived preload; everything else 1125 - // falls back to the legacy preload.js. 1146 + // gets no preload (v1 preload.js has been deleted — Phase 3.11b-hud). 1126 1147 preload: useCanvas 1127 1148 ? getTilePreloadPath() 1128 - : tileWebPrefs ? tileWebPrefs.preload : getPreloadPath(), 1149 + : tileWebPrefs ? tileWebPrefs.preload : undefined, 1129 1150 additionalArguments: useCanvas 1130 1151 ? [ 1131 1152 `--tile-id=${PAGE_HOST_TILE_ID}`,
-1
backend/electron/main.ts
··· 33 33 // Configuration 34 34 export interface AppConfig { 35 35 rootDir: string; 36 - preloadPath: string; 37 36 userDataPath: string; 38 37 profile: string; 39 38 isDev: boolean;
+4
backend/electron/tile-preload.cts
··· 210 210 api.pubsub = { 211 211 publish: publishImpl, 212 212 subscribe: subscribeImpl, 213 + // stats() is unrestricted — returns pubsub telemetry for the HUD stats 214 + // widget and developer diagnostics. Delegates to the legacy pubsub-stats 215 + // channel (already registered in ipc.ts registerMiscHandlers). 216 + stats: () => ipcRenderer.invoke('pubsub-stats'), 213 217 }; 214 218 215 219 // ── Commands (if granted) ──
+1
node_modules
··· 1 + /Users/dietrich/misc/mpeek/node_modules
-2693
preload.js
··· 1 - const { 2 - contextBridge, 3 - ipcRenderer 4 - } = require('electron'); 5 - 6 - const src = 'preload'; 7 - const preloadStart = Date.now(); 8 - 9 - const DEBUG = !!process.env.DEBUG; 10 - // If DEBUG is "1" or "true", enable all categories; otherwise it's a comma-separated list 11 - const DEBUG_CATEGORIES = (process.env.DEBUG && process.env.DEBUG !== '1' && process.env.DEBUG !== 'true') 12 - ? process.env.DEBUG 13 - : ''; 14 - DEBUG && console.log(src, 'init, DEBUG:', DEBUG, 'categories:', DEBUG_CATEGORIES || '(all)'); 15 - const DEBUG_LEVELS = { 16 - BASIC: 1, 17 - FIRST_RUN: 2 18 - }; 19 - 20 - const DEBUG_LEVEL = DEBUG_LEVELS.BASIC; 21 - //const DEBUG_LEVEL = DEBUG_LEVELS.FIRST_RUN; 22 - 23 - const APP_SCHEME = 'peek'; 24 - const APP_PROTOCOL = `${APP_SCHEME}:`; 25 - 26 - const sourceAddress = window.location.toString(); 27 - 28 - const rndm = () => Math.random().toString(16).slice(2); 29 - 30 - // Command registration batching for startup performance 31 - // Collects registrations and sends as single batch after debounce 32 - let pendingRegistrations = []; 33 - let registrationTimer = null; 34 - const BATCH_DELAY_MS = 16; // ~1 frame 35 - 36 - // Persistent record of every command registered via this preload context. 37 - // Used to re-publish to cmd if it loads after these registrations were sent — 38 - // otherwise the initial cmd:register-batch is lost (cmd's subscriber not yet 39 - // installed). Deduped server-side by name (commandRegistry.set). 40 - const registeredCommandsByName = new Map(); 41 - let cmdReadySubscribed = false; 42 - 43 - function flushRegistrations() { 44 - if (pendingRegistrations.length === 0) return; 45 - 46 - const batch = pendingRegistrations; 47 - pendingRegistrations = []; 48 - registrationTimer = null; 49 - 50 - ipcRenderer.send('publish', { 51 - source: sourceAddress, 52 - scope: 3, // GLOBAL 53 - topic: 'cmd:register-batch', 54 - data: { commands: batch } 55 - }); 56 - 57 - DEBUG && console.log('[preload] commands.flush: sent batch of', batch.length, 'commands'); 58 - } 59 - 60 - // Subscribe once to ext:ready — when cmd extension reports ready, re-publish 61 - // the entire accumulated registration set so commands registered before cmd 62 - // loaded still land in the registry. Safe: cmd dedupes by name. 63 - function ensureCmdReadyReplaySubscribed() { 64 - if (cmdReadySubscribed) return; 65 - cmdReadySubscribed = true; 66 - 67 - const replyTopic = `ext:ready:${rndm()}`; 68 - ipcRenderer.send('subscribe', { 69 - source: sourceAddress, 70 - scope: 1, // SYSTEM — ext:ready is published on SYSTEM scope 71 - topic: 'ext:ready', 72 - replyTopic 73 - }); 74 - 75 - ipcRenderer.on(replyTopic, (_ev, msg) => { 76 - if (!msg || msg.id !== 'cmd') return; 77 - if (registeredCommandsByName.size === 0) return; 78 - 79 - const commands = Array.from(registeredCommandsByName.values()); 80 - DEBUG && console.log('[preload] cmd ready — replaying', commands.length, 'command registrations'); 81 - 82 - ipcRenderer.send('publish', { 83 - source: sourceAddress, 84 - scope: 3, // GLOBAL 85 - topic: 'cmd:register-batch', 86 - data: { commands } 87 - }); 88 - }); 89 - } 90 - 91 - // Context detection for permission tiers 92 - const isCore = sourceAddress.startsWith('peek://app/'); 93 - 94 - // Extension detection: supports both legacy (peek://ext/{id}/...) and hybrid (peek://{extId}/...) modes 95 - // In hybrid mode, extension URLs are peek://{extId}/... where extId is NOT 'app' or 'ext' 96 - const isLegacyExtension = sourceAddress.startsWith('peek://ext/'); 97 - const isHybridExtension = (() => { 98 - const match = sourceAddress.match(/^peek:\/\/([^/]+)/); 99 - if (!match) return false; 100 - const host = match[1]; 101 - // Hybrid extension hosts are anything except 'app' and 'ext' (reserved for core) 102 - return host !== 'app' && host !== 'ext'; 103 - })(); 104 - const isExtension = isLegacyExtension || isHybridExtension; 105 - 106 - /** 107 - * Get the extension ID from the current context 108 - * @returns {string|null} Extension ID or null if not in an extension context 109 - */ 110 - const getExtensionId = () => { 111 - if (isLegacyExtension) { 112 - // Legacy format: peek://ext/{id}/... 113 - const match = sourceAddress.match(/peek:\/\/ext\/([^/]+)/); 114 - return match ? match[1] : null; 115 - } 116 - if (isHybridExtension) { 117 - // Hybrid format: peek://{extId}/... 118 - const match = sourceAddress.match(/^peek:\/\/([^/]+)/); 119 - return match ? match[1] : null; 120 - } 121 - return null; 122 - }; 123 - 124 - let api = {}; 125 - 126 - // Log to main process (shows in terminal) 127 - api.log = (...args) => { 128 - ipcRenderer.send('renderer-log', { source: sourceAddress, args }); 129 - }; 130 - 131 - api.debug = DEBUG; 132 - api.debugCategories = DEBUG_CATEGORIES; 133 - api.debugLevels = DEBUG_LEVELS; 134 - api.debugLevel = DEBUG_LEVEL; 135 - 136 - // App info API 137 - api.app = { 138 - /** 139 - * Get app info including version 140 - * @returns {Promise<{success: boolean, data?: {version: string, name: string, isPackaged: boolean}, error?: string}>} 141 - */ 142 - getInfo: () => { 143 - return ipcRenderer.invoke('app-info'); 144 - } 145 - }; 146 - 147 - api.shortcuts = { 148 - /** 149 - * Register a keyboard shortcut 150 - * @param {string} shortcut - The shortcut key combination (e.g., 'Alt+1', 'CommandOrControl+Q') 151 - * @param {function} cb - Callback function when shortcut is triggered 152 - * @param {object} options - Optional configuration 153 - * @param {boolean} options.global - If true, shortcut works even when app doesn't have focus (default: false) 154 - * @param {string} options.mode - Only trigger in this major mode ('page', 'group', 'default') 155 - */ 156 - register: (shortcut, cb, options = {}) => { 157 - const isGlobal = options.global === true; 158 - const modeStr = options.mode ? ` mode:${options.mode}` : ''; 159 - DEBUG && console.log(src, `registering ${isGlobal ? 'global' : 'local'} shortcut ${shortcut}${modeStr} for ${window.location}`); 160 - 161 - const replyTopic = `${shortcut}${rndm()}`; 162 - 163 - ipcRenderer.send('registershortcut', { 164 - source: sourceAddress, 165 - shortcut, 166 - replyTopic, 167 - global: isGlobal, 168 - mode: options.mode 169 - }); 170 - 171 - ipcRenderer.on(replyTopic, (ev, msg) => { 172 - DEBUG && console.log(src, 'shortcut execution reply'); 173 - cb(); 174 - DEBUG && console.log(src, 'shortcut execution reply done'); 175 - }); 176 - }, 177 - /** 178 - * Unregister a keyboard shortcut 179 - * @param {string} shortcut - The shortcut to unregister 180 - * @param {object} options - Optional configuration (must match registration) 181 - * @param {boolean} options.global - If true, unregisters a global shortcut (default: false) 182 - * @param {string} options.mode - Mode condition (must match registration) 183 - */ 184 - unregister: (shortcut, options = {}) => { 185 - const isGlobal = options.global === true; 186 - DEBUG && console.log(`unregistering ${isGlobal ? 'global' : 'local'} shortcut`, shortcut, 'for', window.location); 187 - ipcRenderer.send('unregistershortcut', { 188 - source: sourceAddress, 189 - shortcut, 190 - global: isGlobal, 191 - mode: options.mode 192 - }); 193 - } 194 - }; 195 - 196 - api.closeWindow = (id, callback) => { 197 - DEBUG && console.log(src, ['api.closewindow', id, 'for', window.location].join(', ')); 198 - 199 - const replyTopic = `${id}${rndm()}`; 200 - 201 - const params = { 202 - source: sourceAddress, 203 - id 204 - }; 205 - 206 - ipcRenderer.send('closewindow', { 207 - params, 208 - replyTopic 209 - }); 210 - 211 - ipcRenderer.once(replyTopic, (ev, msg) => { 212 - DEBUG && console.log(src, 'api.closewindow', 'resp from main', msg); 213 - if (callback) { 214 - callback(msg); 215 - } 216 - }); 217 - }; 218 - 219 - api.scopes = { 220 - SYSTEM: 1, 221 - SELF: 2, 222 - GLOBAL: 3 223 - }; 224 - 225 - api.publish = (topic, msg, scope = api.scopes.SELF) => { 226 - DEBUG && console.log(sourceAddress, 'publish', topic) 227 - 228 - // TODO: c'mon 229 - if (!topic) { 230 - return new Error('wtf'); 231 - } 232 - 233 - ipcRenderer.send('publish', { 234 - source: sourceAddress, 235 - scope, 236 - topic, 237 - data: msg, 238 - }); 239 - }; 240 - 241 - api.subscribe = (topic, callback, scope = api.scopes.SELF) => { 242 - DEBUG && console.log(src, 'subscribe', topic) 243 - 244 - // TODO: c'mon 245 - if (!topic || !callback) { 246 - return new Error('wtf'); 247 - } 248 - 249 - const replyTopic = `${topic}:${rndm()}`; 250 - 251 - ipcRenderer.send('subscribe', { 252 - source: sourceAddress, 253 - scope, 254 - topic, 255 - replyTopic 256 - }); 257 - 258 - ipcRenderer.on(replyTopic, (ev, msg) => { 259 - DEBUG && console.log('topic', topic, msg); 260 - // Only set source on object messages (not undefined/null/primitives) 261 - if (msg && typeof msg === 'object') { 262 - msg.source = sourceAddress; 263 - } 264 - try { 265 - callback(msg); 266 - } 267 - catch(ex) { 268 - console.log('preload:subscribe subscriber callback errored for topic', topic, 'and source', sourceAddress, ex); 269 - } 270 - }); 271 - }; 272 - 273 - api.pubsub = { 274 - stats: () => { 275 - DEBUG && console.log('pubsub.stats'); 276 - return ipcRenderer.invoke('pubsub-stats'); 277 - } 278 - }; 279 - 280 - api.window = { 281 - open: (url, options = {}) => { 282 - DEBUG && console.log('window.open', url, options); 283 - return ipcRenderer.invoke('window-open', { 284 - source: sourceAddress, 285 - url, 286 - options 287 - }); 288 - }, 289 - close: (id = null) => { 290 - DEBUG && console.log('window.close', id); 291 - if (id === null) { 292 - window.close(); 293 - return; 294 - } 295 - return ipcRenderer.invoke('window-close', { 296 - source: sourceAddress, 297 - id 298 - }); 299 - }, 300 - hide: (id) => { 301 - DEBUG && console.log('window.hide', id); 302 - return ipcRenderer.invoke('window-hide', { 303 - source: sourceAddress, 304 - id 305 - }); 306 - }, 307 - show: (id) => { 308 - DEBUG && console.log('window.show', id); 309 - return ipcRenderer.invoke('window-show', { 310 - source: sourceAddress, 311 - id 312 - }); 313 - }, 314 - exists: (id) => { 315 - DEBUG && console.log('window.exists', id); 316 - return ipcRenderer.invoke('window-exists', { 317 - source: sourceAddress, 318 - id 319 - }); 320 - }, 321 - move: (id, x, y) => { 322 - DEBUG && console.log('window.move', id, x, y); 323 - return ipcRenderer.invoke('window-move', { 324 - source: sourceAddress, 325 - id, 326 - x, 327 - y 328 - }); 329 - }, 330 - resize: (width, height, id = null) => { 331 - DEBUG && console.log('window.resize', width, height, id); 332 - return ipcRenderer.invoke('window-resize', { 333 - source: sourceAddress, 334 - id, 335 - width, 336 - height 337 - }); 338 - }, 339 - getPosition: (id = null) => { 340 - DEBUG && console.log('window.getPosition', id); 341 - return ipcRenderer.invoke('window-get-position', { 342 - source: sourceAddress, 343 - id 344 - }); 345 - }, 346 - focus: (id) => { 347 - DEBUG && console.log('window.focus', id); 348 - return ipcRenderer.invoke('window-focus', { 349 - source: sourceAddress, 350 - id 351 - }); 352 - }, 353 - setOverlayFocusTarget: (targetWindowId) => { 354 - DEBUG && console.log('window.setOverlayFocusTarget', targetWindowId); 355 - return ipcRenderer.invoke('window-set-overlay-focus-target', { 356 - source: sourceAddress, 357 - targetWindowId 358 - }); 359 - }, 360 - blur: (id) => { 361 - DEBUG && console.log('window.blur', id); 362 - return ipcRenderer.invoke('window-blur', { 363 - source: sourceAddress, 364 - id 365 - }); 366 - }, 367 - list: (options = {}) => { 368 - DEBUG && console.log('window.list', options); 369 - return ipcRenderer.invoke('window-list', { 370 - source: sourceAddress, 371 - ...options 372 - }); 373 - }, 374 - devtools: (id = null) => { 375 - DEBUG && console.log('window.devtools', id); 376 - return ipcRenderer.invoke('window-devtools', { 377 - source: sourceAddress, 378 - id 379 - }); 380 - }, 381 - getBounds: (id = null) => { 382 - DEBUG && console.log('window.getBounds', id); 383 - return ipcRenderer.invoke('window-get-bounds', { 384 - source: sourceAddress, 385 - id 386 - }); 387 - }, 388 - setBounds: (bounds) => { 389 - return ipcRenderer.invoke('window-set-bounds', bounds); 390 - }, 391 - getDisplayInfo: () => { 392 - return ipcRenderer.invoke('get-display-info'); 393 - }, 394 - setIgnoreMouseEvents: (id, ignore, forward = false) => { 395 - DEBUG && console.log('window.setIgnoreMouseEvents', id, ignore, forward); 396 - return ipcRenderer.invoke('window-set-ignore-mouse-events', { 397 - source: sourceAddress, 398 - id, 399 - ignore, 400 - forward 401 - }); 402 - }, 403 - /** 404 - * Get the last focused visible (non-modal) window's ID 405 - * Use this to get the target window when operating from modal windows like cmd bar 406 - * @returns {Promise<number|null>} Window ID or null if not available 407 - */ 408 - getFocusedVisibleWindowId: () => { 409 - return ipcRenderer.invoke('get-focused-visible-window-id'); 410 - }, 411 - /** 412 - * Get the current window's ID 413 - * @returns {Promise<number|null>} Window ID or null if not available 414 - */ 415 - getWindowId: () => { 416 - return ipcRenderer.invoke('get-window-id'); 417 - }, 418 - /** 419 - * Check if this window was opened transiently (app wasn't focused when opened) 420 - * Used for IZUI policy: transient windows use different escape/mode behavior 421 - * @returns {Promise<boolean>} True if window is transient 422 - */ 423 - isTransient: () => { 424 - return ipcRenderer.invoke('window-is-transient'); 425 - }, 426 - /** 427 - * Store scroll position for undo-close-window restoration 428 - * @param {{x: number, y: number}} pos - Scroll position 429 - */ 430 - setScrollPosition: (pos) => { 431 - return ipcRenderer.invoke('window-set-scroll-position', pos); 432 - }, 433 - center: (id) => { 434 - DEBUG && console.log('window.center', id); 435 - return ipcRenderer.invoke('window-center', { source: sourceAddress, id }); 436 - }, 437 - centerAll: () => { 438 - DEBUG && console.log('window.centerAll'); 439 - return ipcRenderer.invoke('window-center-all'); 440 - }, 441 - maximize: (id) => { 442 - DEBUG && console.log('window.maximize', id); 443 - return ipcRenderer.invoke('window-maximize', { source: sourceAddress, id }); 444 - }, 445 - fullscreen: (id) => { 446 - DEBUG && console.log('window.fullscreen', id); 447 - return ipcRenderer.invoke('window-fullscreen', { source: sourceAddress, id }); 448 - } 449 - }; 450 - 451 - // ============================================================================ 452 - // IZUI State Coordinator API 453 - // ============================================================================ 454 - // Centralized IZUI state management. Preferred over api.window.isTransient() 455 - // because it re-evaluates state on each invocation, fixing keepLive windows 456 - // that would otherwise be stuck with stale transient state. 457 - api.izui = { 458 - /** 459 - * Check if the current IZUI session is in transient mode 460 - * Re-evaluates on each call (unlike window.isTransient which is frozen at creation) 461 - * @returns {Promise<boolean>} True if session is transient (app wasn't focused when invoked) 462 - */ 463 - isTransient: () => { 464 - return ipcRenderer.invoke('izui-is-transient'); 465 - }, 466 - 467 - /** 468 - * Get the effective mode for display 469 - * Returns 'default' for transient sessions, 'active' for focused sessions 470 - * @returns {Promise<string>} 'default' or 'active' 471 - */ 472 - getEffectiveMode: () => { 473 - return ipcRenderer.invoke('izui-get-effective-mode'); 474 - }, 475 - 476 - /** 477 - * Get the current IZUI state 478 - * @returns {Promise<string>} 'idle' | 'transient' | 'active' | 'overlay' 479 - */ 480 - getState: () => { 481 - return ipcRenderer.invoke('izui-get-state'); 482 - }, 483 - 484 - /** 485 - * Get the pre-overlay focus target (the window that was focused before overlay opened). 486 - * Used by the windows switcher to highlight the correct window on init. 487 - * @returns {Promise<number|null>} Window ID or null 488 - */ 489 - getPreOverlayFocusTarget: () => { 490 - return ipcRenderer.invoke('izui-get-pre-overlay-focus-target'); 491 - }, 492 - 493 - /** 494 - * Request this window to close/hide via the IZUI model. 495 - * Goes through closeOrHideWindow for proper keepLive/modal/child handling. 496 - * Used by navigate mode windows where renderer owns the lifecycle. 497 - * @returns {Promise<{success: boolean}>} 498 - */ 499 - closeSelf: () => { 500 - return ipcRenderer.invoke('izui-close-self'); 501 - } 502 - }; 503 - 504 - api.modifyWindow = (winName, params) => { 505 - DEBUG && console.log('modifyWindow(): window', winName, params); 506 - //w.name = `${sourceAddress}:${rndm()}`; 507 - DEBUG && console.log('NAME', winName); 508 - ipcRenderer.send('modifywindow', { 509 - source: sourceAddress, 510 - name: winName, 511 - params 512 - }); 513 - }; 514 - 515 - // Datastore API 516 - api.datastore = { 517 - addAddress: (uri, options) => { 518 - return ipcRenderer.invoke('datastore-add-address', { uri, options }); 519 - }, 520 - getAddress: (id) => { 521 - return ipcRenderer.invoke('datastore-get-address', { id }); 522 - }, 523 - updateAddress: (id, updates) => { 524 - return ipcRenderer.invoke('datastore-update-address', { id, updates }); 525 - }, 526 - queryAddresses: (filter) => { 527 - return ipcRenderer.invoke('datastore-query-addresses', { filter }); 528 - }, 529 - addVisit: (addressId, options) => { 530 - return ipcRenderer.invoke('datastore-add-visit', { addressId, options }); 531 - }, 532 - queryVisits: (filter) => { 533 - return ipcRenderer.invoke('datastore-query-visits', { filter }); 534 - }, 535 - addContent: (options) => { 536 - return ipcRenderer.invoke('datastore-add-content', { options }); 537 - }, 538 - queryContent: (filter) => { 539 - return ipcRenderer.invoke('datastore-query-content', { filter }); 540 - }, 541 - getTable: (tableName) => { 542 - return ipcRenderer.invoke('datastore-get-table', { tableName }); 543 - }, 544 - setRow: (tableName, rowId, rowData) => { 545 - return ipcRenderer.invoke('datastore-set-row', { tableName, rowId, rowData }); 546 - }, 547 - getRow: (tableName, rowId) => { 548 - return ipcRenderer.invoke('datastore-get-row', { tableName, rowId }); 549 - }, 550 - getStats: () => { 551 - return ipcRenderer.invoke('datastore-get-stats'); 552 - }, 553 - // Tag operations 554 - getOrCreateTag: (name) => { 555 - return ipcRenderer.invoke('datastore-get-or-create-tag', { name }); 556 - }, 557 - tagAddress: (addressId, tagId) => { 558 - return ipcRenderer.invoke('datastore-tag-address', { addressId, tagId }); 559 - }, 560 - untagAddress: (addressId, tagId) => { 561 - return ipcRenderer.invoke('datastore-untag-address', { addressId, tagId }); 562 - }, 563 - getTagsByFrecency: (domain) => { 564 - return ipcRenderer.invoke('datastore-get-tags-by-frecency', { domain }); 565 - }, 566 - getAddressTags: (addressId) => { 567 - return ipcRenderer.invoke('datastore-get-address-tags', { addressId }); 568 - }, 569 - getAddressesByTag: (tagId) => { 570 - return ipcRenderer.invoke('datastore-get-addresses-by-tag', { tagId }); 571 - }, 572 - getUntaggedAddresses: () => { 573 - return ipcRenderer.invoke('datastore-get-untagged-addresses', {}); 574 - }, 575 - 576 - // Item operations (mobile-style lightweight content: notes, tagsets, images) 577 - addItem: (type, options = {}) => { 578 - return ipcRenderer.invoke('datastore-add-item', { type, options }); 579 - }, 580 - getItem: (id) => { 581 - return ipcRenderer.invoke('datastore-get-item', { id }); 582 - }, 583 - updateItem: (id, options) => { 584 - return ipcRenderer.invoke('datastore-update-item', { id, options }); 585 - }, 586 - deleteItem: (id) => { 587 - return ipcRenderer.invoke('datastore-delete-item', { id }); 588 - }, 589 - hardDeleteItem: (id) => { 590 - return ipcRenderer.invoke('datastore-hard-delete-item', { id }); 591 - }, 592 - queryItems: (filter = {}) => { 593 - return ipcRenderer.invoke('datastore-query-items', { filter }); 594 - }, 595 - tagItem: (itemId, tagId) => { 596 - return ipcRenderer.invoke('datastore-tag-item', { itemId, tagId }); 597 - }, 598 - untagItem: (itemId, tagId) => { 599 - return ipcRenderer.invoke('datastore-untag-item', { itemId, tagId }); 600 - }, 601 - getItemTags: (itemId) => { 602 - return ipcRenderer.invoke('datastore-get-item-tags', { itemId }); 603 - }, 604 - getItemsByTag: (tagId) => { 605 - return ipcRenderer.invoke('datastore-get-items-by-tag', { tagId }); 606 - }, 607 - renameTag: (tagId, newName) => { 608 - return ipcRenderer.invoke('datastore-rename-tag', { tagId, newName }); 609 - }, 610 - updateTagColor: (tagId, color) => { 611 - return ipcRenderer.invoke('datastore-update-tag-color', { tagId, color }); 612 - }, 613 - deleteTag: (tagId) => { 614 - return ipcRenderer.invoke('datastore-delete-tag', { tagId }); 615 - }, 616 - 617 - // History operations (visits joined with addresses) 618 - getHistory: (filter = {}) => { 619 - return ipcRenderer.invoke('datastore-get-history', { filter }); 620 - }, 621 - 622 - // Item visit operations (modern URL history API) 623 - queryItemVisits: (filter = {}) => { 624 - return ipcRenderer.invoke('datastore-query-item-visits', { filter }); 625 - }, 626 - getItemVisits: (itemId, filter = {}) => { 627 - return ipcRenderer.invoke('datastore-get-item-visits', { itemId, filter }); 628 - }, 629 - recordItemVisit: (itemId, options = {}) => { 630 - return ipcRenderer.invoke('datastore-record-item-visit', { itemId, options }); 631 - }, 632 - 633 - // Navigation tracking (unified entry point for page loads) 634 - trackNavigation: (uri, options = {}) => { 635 - return ipcRenderer.invoke('datastore-track-navigation', { uri, options }); 636 - }, 637 - 638 - // Update title for a URL item (if currently empty or "Loading...") 639 - updateItemTitle: (url, title) => { 640 - return ipcRenderer.invoke('datastore-update-item-title', { url, title }); 641 - }, 642 - 643 - // Update favicon for a URL item (3-phase lookup: item.content, address.uri, item.id) 644 - updateItemFavicon: (url, faviconUrl) => { 645 - return ipcRenderer.invoke('datastore-update-item-favicon', { url, faviconUrl }); 646 - }, 647 - 648 - // Extract page content from a live webContents by URL 649 - extractPageContent: (url) => { 650 - return ipcRenderer.invoke('extract-page-content', { url }); 651 - }, 652 - 653 - // Item event operations (series & feeds) 654 - addItemEvent: (itemId, options = {}) => { 655 - return ipcRenderer.invoke('datastore-add-item-event', { itemId, options }); 656 - }, 657 - getItemEvent: (eventId) => { 658 - return ipcRenderer.invoke('datastore-get-item-event', { eventId }); 659 - }, 660 - queryItemEvents: (filter = {}) => { 661 - return ipcRenderer.invoke('datastore-query-item-events', { filter }); 662 - }, 663 - deleteItemEvent: (eventId) => { 664 - return ipcRenderer.invoke('datastore-delete-item-event', { eventId }); 665 - }, 666 - deleteItemEvents: (itemId) => { 667 - return ipcRenderer.invoke('datastore-delete-item-events', { itemId }); 668 - }, 669 - getLatestItemEvent: (itemId) => { 670 - return ipcRenderer.invoke('datastore-get-latest-item-event', { itemId }); 671 - }, 672 - countItemEvents: (itemId, filter = {}) => { 673 - return ipcRenderer.invoke('datastore-count-item-events', { itemId, filter }); 674 - } 675 - }; 676 - 677 - // Theme API 678 - api.theme = { 679 - /** 680 - * Get current theme settings 681 - * @returns {Promise<{themeId: string, colorScheme: string, isDark: boolean, effectiveScheme: string}>} 682 - */ 683 - get: () => { 684 - return ipcRenderer.invoke('theme:get'); 685 - }, 686 - 687 - /** 688 - * Set color scheme preference 689 - * @param {string} colorScheme - 'system' | 'light' | 'dark' 690 - * @returns {Promise<{success: boolean, colorScheme?: string, effectiveScheme?: string, error?: string}>} 691 - */ 692 - setColorScheme: (colorScheme) => { 693 - return ipcRenderer.invoke('theme:setColorScheme', colorScheme); 694 - }, 695 - 696 - /** 697 - * Set active theme 698 - * @param {string} themeId - Theme ID 699 - * @returns {Promise<{success: boolean, themeId?: string, error?: string}>} 700 - */ 701 - setTheme: (themeId) => { 702 - return ipcRenderer.invoke('theme:setTheme', themeId); 703 - }, 704 - 705 - /** 706 - * List available themes (simple list for basic UI) 707 - * @returns {Promise<{themes: Array<{id: string, name: string, version: string, description: string, colorSchemes: string[]}>}>} 708 - */ 709 - list: () => { 710 - return ipcRenderer.invoke('theme:list'); 711 - }, 712 - 713 - /** 714 - * Get all themes with full details (builtin + external) 715 - * @returns {Promise<{success: boolean, data?: Array, error?: string}>} 716 - */ 717 - getAll: () => { 718 - return ipcRenderer.invoke('theme:getAll'); 719 - }, 720 - 721 - /** 722 - * Open folder picker dialog to select a theme folder 723 - * @returns {Promise<{success: boolean, canceled?: boolean, data?: {path: string}, error?: string}>} 724 - */ 725 - pickFolder: () => { 726 - return ipcRenderer.invoke('theme:pickFolder'); 727 - }, 728 - 729 - /** 730 - * Validate a theme folder (checks manifest.json) 731 - * @param {string} folderPath - Path to theme folder 732 - * @returns {Promise<{success: boolean, data?: {manifest: object, path: string}, error?: string}>} 733 - */ 734 - validateFolder: (folderPath) => { 735 - return ipcRenderer.invoke('theme:validateFolder', { folderPath }); 736 - }, 737 - 738 - /** 739 - * Add a theme from a folder 740 - * @param {string} folderPath - Path to theme folder 741 - * @returns {Promise<{success: boolean, data?: {id: string, manifest: object}, error?: string}>} 742 - */ 743 - add: (folderPath) => { 744 - return ipcRenderer.invoke('theme:add', { folderPath }); 745 - }, 746 - 747 - /** 748 - * Remove a theme 749 - * @param {string} id - Theme ID 750 - * @returns {Promise<{success: boolean, error?: string}>} 751 - */ 752 - remove: (id) => { 753 - return ipcRenderer.invoke('theme:remove', { id }); 754 - }, 755 - 756 - /** 757 - * Reload a theme (re-read CSS, notify windows) 758 - * @param {string} id - Theme ID 759 - * @returns {Promise<{success: boolean, error?: string}>} 760 - */ 761 - reload: (id) => { 762 - return ipcRenderer.invoke('theme:reload', { id }); 763 - }, 764 - 765 - /** 766 - * Subscribe to theme changes 767 - * @param {function} callback - Called with {colorScheme: string} when theme changes 768 - */ 769 - onChange: (callback) => { 770 - ipcRenderer.on('theme:changed', (ev, data) => { 771 - callback(data); 772 - }); 773 - }, 774 - 775 - /** 776 - * Subscribe to theme reload events 777 - * @param {function} callback - Called with {themeId: string} when theme should be reloaded 778 - */ 779 - onReload: (callback) => { 780 - ipcRenderer.on('theme:reload', (ev, data) => { 781 - callback(data); 782 - }); 783 - }, 784 - 785 - /** 786 - * Set color scheme for current window only (doesn't affect global setting) 787 - * When called from background.html (via commands), targets the last focused visible window. 788 - * @param {string} colorScheme - 'light', 'dark', 'system', or 'global' (to use global theme) 789 - * @returns {Promise<{success: boolean, windowId?: number, colorScheme?: string, error?: string}>} 790 - */ 791 - setWindowColorScheme: async (colorScheme) => { 792 - // Use focused visible window ID if calling from background context, 793 - // otherwise use the current window ID 794 - let windowId = await ipcRenderer.invoke('get-focused-visible-window-id'); 795 - if (!windowId) { 796 - // Fallback to current window (for direct calls from visible windows) 797 - windowId = await ipcRenderer.invoke('get-window-id'); 798 - } 799 - if (!windowId) { 800 - return { success: false, error: 'No visible window to target' }; 801 - } 802 - return ipcRenderer.invoke('theme:setWindowColorScheme', { windowId, colorScheme }); 803 - } 804 - }; 805 - 806 - // Sync API - server synchronization for bidirectional sync 807 - api.sync = { 808 - /** 809 - * Get sync configuration 810 - * @returns {Promise<{success: boolean, data?: {serverUrl: string, apiKey: string, autoSync: boolean}, error?: string}>} 811 - */ 812 - getConfig: () => { 813 - return ipcRenderer.invoke('sync-get-config'); 814 - }, 815 - 816 - /** 817 - * Set sync configuration 818 - * @param {object} config - { serverUrl?, apiKey?, autoSync? } 819 - * @returns {Promise<{success: boolean, error?: string}>} 820 - */ 821 - setConfig: (config) => { 822 - return ipcRenderer.invoke('sync-set-config', config); 823 - }, 824 - 825 - /** 826 - * Pull items from server 827 - * @param {object} options - { since?: number } - optional timestamp to pull changes since 828 - * @returns {Promise<{success: boolean, data?: {pulled: number, conflicts: number}, error?: string}>} 829 - */ 830 - pull: (options = {}) => { 831 - return ipcRenderer.invoke('sync-pull', options); 832 - }, 833 - 834 - /** 835 - * Push local items to server 836 - * @param {object} options - { force?: boolean } - force push even if conflicts 837 - * @returns {Promise<{success: boolean, data?: {pushed: number, skipped: number}, error?: string}>} 838 - */ 839 - push: (options = {}) => { 840 - return ipcRenderer.invoke('sync-push', options); 841 - }, 842 - 843 - /** 844 - * Full bidirectional sync (pull then push) 845 - * @returns {Promise<{success: boolean, data?: {pulled: number, pushed: number, conflicts: number}, error?: string}>} 846 - */ 847 - syncAll: () => { 848 - return ipcRenderer.invoke('sync-full'); 849 - }, 850 - 851 - /** 852 - * Get current sync status 853 - * @returns {Promise<{success: boolean, data?: {configured: boolean, lastSync: number, pendingCount: number}, error?: string}>} 854 - */ 855 - getStatus: () => { 856 - return ipcRenderer.invoke('sync-status'); 857 - } 858 - }; 859 - 860 - // Profiles API - user profile management 861 - api.profiles = { 862 - /** 863 - * List all profiles 864 - * @returns {Promise<{success: boolean, data?: Profile[], error?: string}>} 865 - */ 866 - list: () => { 867 - return ipcRenderer.invoke('profiles:list'); 868 - }, 869 - 870 - /** 871 - * Create a new profile 872 - * @param {string} name - Profile name (e.g., "Work", "Personal") 873 - * @returns {Promise<{success: boolean, data?: Profile, error?: string}>} 874 - */ 875 - create: (name) => { 876 - return ipcRenderer.invoke('profiles:create', { name }); 877 - }, 878 - 879 - /** 880 - * Get a specific profile by slug 881 - * @param {string} slug - Profile slug (e.g., "work", "personal") 882 - * @returns {Promise<{success: boolean, data?: Profile, error?: string}>} 883 - */ 884 - get: (slug) => { 885 - return ipcRenderer.invoke('profiles:get', { slug }); 886 - }, 887 - 888 - /** 889 - * Delete a profile (cannot delete default or active profile) 890 - * @param {string} id - Profile ID 891 - * @returns {Promise<{success: boolean, error?: string}>} 892 - */ 893 - delete: (id) => { 894 - return ipcRenderer.invoke('profiles:delete', { id }); 895 - }, 896 - 897 - /** 898 - * Get the currently active profile 899 - * @returns {Promise<{success: boolean, data?: Profile, error?: string}>} 900 - */ 901 - getCurrent: () => { 902 - return ipcRenderer.invoke('profiles:getCurrent'); 903 - }, 904 - 905 - /** 906 - * Switch to a different profile (causes app restart) 907 - * @param {string} slug - Profile slug to switch to 908 - * @returns {Promise<{success: boolean, error?: string}>} 909 - */ 910 - switch: (slug) => { 911 - return ipcRenderer.invoke('profiles:switch', { slug }); 912 - }, 913 - 914 - /** 915 - * Enable sync for a profile 916 - * @param {string} profileId - Profile ID 917 - * @param {string} apiKey - Server API key 918 - * @param {string} serverProfileSlug - Server profile slug to sync with 919 - * @returns {Promise<{success: boolean, error?: string}>} 920 - */ 921 - enableSync: (profileId, apiKey, serverProfileSlug) => { 922 - return ipcRenderer.invoke('profiles:enableSync', { profileId, apiKey, serverProfileSlug }); 923 - }, 924 - 925 - /** 926 - * Disable sync for a profile 927 - * @param {string} profileId - Profile ID 928 - * @returns {Promise<{success: boolean, error?: string}>} 929 - */ 930 - disableSync: (profileId) => { 931 - return ipcRenderer.invoke('profiles:disableSync', { profileId }); 932 - }, 933 - 934 - /** 935 - * Get sync configuration for a profile 936 - * @param {string} profileId - Profile ID 937 - * @returns {Promise<{success: boolean, data?: {apiKey: string, serverProfileSlug: string} | null, error?: string}>} 938 - */ 939 - getSyncConfig: (profileId) => { 940 - return ipcRenderer.invoke('profiles:getSyncConfig', { profileId }); 941 - }, 942 - 943 - /** 944 - * Get the partition string for the current profile's session 945 - * Used for setting webview partition attribute for session isolation 946 - * @returns {Promise<{success: boolean, data?: {profileId: string, partition: string}, error?: string}>} 947 - */ 948 - getPartition: () => { 949 - return ipcRenderer.invoke('profiles:getPartition'); 950 - } 951 - }; 952 - 953 - // ========== Dark Mode API ========== 954 - 955 - /** 956 - * Dark Mode API - 3-tier dark mode system for web pages 957 - * 958 - * Tier 1 (system): Sets prefers-color-scheme: dark — sites with dark mode support switch automatically 959 - * Tier 2 (force): Chromium WebContentsForceDark — CIELAB-based inversion for all sites 960 - * Tier 3 (per-site): Dark Reader injection — TODO, not yet implemented 961 - */ 962 - api.darkMode = { 963 - /** 964 - * Get current dark mode setting 965 - * @returns {Promise<{success: boolean, data?: {mode: string, tier2Active: boolean}}>} 966 - */ 967 - get: () => { 968 - return ipcRenderer.invoke('darkMode:get'); 969 - }, 970 - 971 - /** 972 - * Set dark mode setting 973 - * @param {string} mode - 'off' | 'system' | 'force' 974 - * @returns {Promise<{success: boolean, data?: {mode: string, restartRequired: boolean}, error?: string}>} 975 - */ 976 - set: (mode) => { 977 - return ipcRenderer.invoke('darkMode:set', mode); 978 - }, 979 - 980 - /** 981 - * Listen for dark mode changes 982 - * @param {function} callback - Called with { mode: string } 983 - * @returns {function} Unsubscribe function 984 - */ 985 - onChange: (callback) => { 986 - const handler = (_event, data) => callback(data); 987 - ipcRenderer.on('darkMode:changed', handler); 988 - return () => ipcRenderer.removeListener('darkMode:changed', handler); 989 - }, 990 - }; 991 - 992 - // ========== Bundled Web Extensions API ========== 993 - 994 - /** 995 - * Adblocker API - Native ad blocking powered by @ghostery/adblocker-electron 996 - */ 997 - api.adblocker = { 998 - /** 999 - * Get adblocker status 1000 - * @returns {Promise<{success: boolean, data?: {initialized: boolean, enabled: boolean, blockedCount: number}, error?: string}>} 1001 - */ 1002 - getStatus: () => { 1003 - return ipcRenderer.invoke('adblocker:getStatus'); 1004 - }, 1005 - 1006 - /** 1007 - * Enable ad blocking 1008 - * @returns {Promise<{success: boolean, error?: string}>} 1009 - */ 1010 - enable: () => { 1011 - return ipcRenderer.invoke('adblocker:enable'); 1012 - }, 1013 - 1014 - /** 1015 - * Disable ad blocking 1016 - * @returns {Promise<{success: boolean, error?: string}>} 1017 - */ 1018 - disable: () => { 1019 - return ipcRenderer.invoke('adblocker:disable'); 1020 - }, 1021 - 1022 - /** 1023 - * Get count of blocked requests 1024 - * @returns {Promise<{success: boolean, data?: number, error?: string}>} 1025 - */ 1026 - getBlockedCount: () => { 1027 - return ipcRenderer.invoke('adblocker:getBlockedCount'); 1028 - }, 1029 - 1030 - /** 1031 - * Get the per-site allowlist (sites where blocking is disabled) 1032 - * @returns {Promise<{success: boolean, data?: string[], error?: string}>} 1033 - */ 1034 - getAllowlist: () => { 1035 - return ipcRenderer.invoke('adblocker:getAllowlist'); 1036 - }, 1037 - 1038 - /** 1039 - * Check if a specific site is in the allowlist 1040 - * @param {string} hostname - The hostname to check 1041 - * @returns {Promise<{success: boolean, data?: boolean, error?: string}>} 1042 - */ 1043 - isSiteAllowed: (hostname) => { 1044 - return ipcRenderer.invoke('adblocker:isSiteAllowed', { hostname }); 1045 - }, 1046 - 1047 - /** 1048 - * Add a site to the allowlist (disable blocking for that site) 1049 - * @param {string} hostname - The hostname to allow 1050 - * @returns {Promise<{success: boolean, error?: string}>} 1051 - */ 1052 - allowSite: (hostname) => { 1053 - return ipcRenderer.invoke('adblocker:allowSite', { hostname }); 1054 - }, 1055 - 1056 - /** 1057 - * Remove a site from the allowlist (re-enable blocking for that site) 1058 - * @param {string} hostname - The hostname to disallow 1059 - * @returns {Promise<{success: boolean, error?: string}>} 1060 - */ 1061 - disallowSite: (hostname) => { 1062 - return ipcRenderer.invoke('adblocker:disallowSite', { hostname }); 1063 - } 1064 - }; 1065 - 1066 - /** 1067 - * Chrome Extensions API - Bundled Chrome extension management 1068 - */ 1069 - api.chromeExtensions = { 1070 - /** 1071 - * List all bundled chrome extensions 1072 - * @returns {Promise<{success: boolean, data?: Array<{id: string, name: string, version: string, description: string, enabled: boolean, loaded: boolean}>, error?: string}>} 1073 - */ 1074 - list: () => { 1075 - return ipcRenderer.invoke('chrome-ext:list'); 1076 - }, 1077 - 1078 - /** 1079 - * Enable a chrome extension 1080 - * @param {string} id - Extension ID 1081 - * @returns {Promise<{success: boolean, error?: string}>} 1082 - */ 1083 - enable: (id) => { 1084 - return ipcRenderer.invoke('chrome-ext:enable', { id }); 1085 - }, 1086 - 1087 - /** 1088 - * Disable a chrome extension 1089 - * @param {string} id - Extension ID 1090 - * @returns {Promise<{success: boolean, error?: string}>} 1091 - */ 1092 - disable: (id) => { 1093 - return ipcRenderer.invoke('chrome-ext:disable', { id }); 1094 - }, 1095 - 1096 - /** 1097 - * Get chrome extension manager status 1098 - * @returns {Promise<{success: boolean, data?: {initialized: boolean, discoveredCount: number, loadedCount: number}, error?: string}>} 1099 - */ 1100 - getStatus: () => { 1101 - return ipcRenderer.invoke('chrome-ext:getStatus'); 1102 - }, 1103 - 1104 - /** 1105 - * Get UI entry points for all loaded chrome extensions (options pages, popups) 1106 - * @returns {Promise<{success: boolean, data?: Array<{extensionId: string, extensionName: string, type: string, title: string, url: string, width: number, height: number}>, error?: string}>} 1107 - */ 1108 - getUiEntries: () => { 1109 - return ipcRenderer.invoke('chrome-ext:getUiEntries'); 1110 - }, 1111 - 1112 - /** 1113 - * Open a chrome extension's options page or popup in a window 1114 - * @param {string} id - Extension ID 1115 - * @param {string} type - Page type to open (popup, options, newtab, etc.) 1116 - * @returns {Promise<{success: boolean, data?: {windowId: number}, error?: string}>} 1117 - */ 1118 - openPage: (id, type) => { 1119 - return ipcRenderer.invoke('chrome-ext:openPage', { id, type }); 1120 - } 1121 - }; 1122 - 1123 - // Track per-window color scheme override (null = use global) 1124 - let windowColorSchemeOverride = null; 1125 - 1126 - // Apply theme on page load 1127 - (async () => { 1128 - try { 1129 - const theme = await ipcRenderer.invoke('theme:get'); 1130 - if (theme && theme.colorScheme !== 'system') { 1131 - document.documentElement.setAttribute('data-theme', theme.colorScheme); 1132 - } 1133 - 1134 - // Listen for global color scheme changes 1135 - ipcRenderer.on('theme:changed', (ev, { colorScheme }) => { 1136 - // Only apply global changes if this window doesn't have an override 1137 - if (windowColorSchemeOverride !== null) { 1138 - DEBUG && console.log('[preload] Ignoring global theme change, window has override:', windowColorSchemeOverride); 1139 - return; 1140 - } 1141 - if (colorScheme === 'system') { 1142 - document.documentElement.removeAttribute('data-theme'); 1143 - } else { 1144 - document.documentElement.setAttribute('data-theme', colorScheme); 1145 - } 1146 - }); 1147 - 1148 - // Listen for window-specific color scheme changes 1149 - ipcRenderer.on('theme:windowChanged', (ev, { colorScheme }) => { 1150 - DEBUG && console.log('[preload] Window-specific color scheme:', colorScheme); 1151 - if (colorScheme === 'global') { 1152 - // Clear override, revert to global theme 1153 - windowColorSchemeOverride = null; 1154 - // Re-fetch and apply global theme 1155 - ipcRenderer.invoke('theme:get').then(theme => { 1156 - if (theme && theme.colorScheme !== 'system') { 1157 - document.documentElement.setAttribute('data-theme', theme.colorScheme); 1158 - } else { 1159 - document.documentElement.removeAttribute('data-theme'); 1160 - } 1161 - }); 1162 - } else { 1163 - // Set window-specific override 1164 - windowColorSchemeOverride = colorScheme; 1165 - if (colorScheme === 'system') { 1166 - document.documentElement.removeAttribute('data-theme'); 1167 - } else { 1168 - document.documentElement.setAttribute('data-theme', colorScheme); 1169 - } 1170 - } 1171 - }); 1172 - 1173 - // Listen for theme changes (different theme selected) - reload CSS 1174 - ipcRenderer.on('theme:themeChanged', (ev, { themeId }) => { 1175 - DEBUG && console.log('[preload] Theme changed to:', themeId, '- reloading stylesheets'); 1176 - reloadStylesheets(); 1177 - }); 1178 - 1179 - // Listen for theme reload requests 1180 - ipcRenderer.on('theme:reload', (ev, { themeId }) => { 1181 - DEBUG && console.log('[preload] Theme reload requested:', themeId); 1182 - reloadStylesheets(); 1183 - }); 1184 - } catch (e) { 1185 - // Theme not available yet (before app ready) 1186 - } 1187 - })(); 1188 - 1189 - // Set data-microseason attribute for season-aware themes (e.g. 七十二候) 1190 - (() => { try { 1191 - // 72 microseason start dates [month, day], starting from Risshun (Feb 4) 1192 - const S = [ 1193 - [2,4],[2,9],[2,14],[2,19],[2,24],[3,1], 1194 - [3,6],[3,11],[3,16],[3,21],[3,26],[3,31], 1195 - [4,5],[4,10],[4,15],[4,20],[4,25],[4,30], 1196 - [5,5],[5,10],[5,15],[5,21],[5,26],[5,31], 1197 - [6,5],[6,11],[6,16],[6,21],[6,26],[7,1], 1198 - [7,7],[7,12],[7,17],[7,23],[7,28],[8,2], 1199 - [8,7],[8,12],[8,18],[8,23],[8,28],[9,2], 1200 - [9,7],[9,12],[9,17],[9,23],[9,28],[10,3], 1201 - [10,8],[10,13],[10,18],[10,23],[10,28],[11,2], 1202 - [11,7],[11,12],[11,17],[11,22],[11,27],[12,2], 1203 - [12,7],[12,12],[12,17],[12,22],[12,27],[1,1], 1204 - [1,5],[1,10],[1,15],[1,20],[1,25],[1,30] 1205 - ]; 1206 - // Normalize dates to "season year" starting Feb 4 = day 0 1207 - const dim = [0,31,28,31,30,31,30,31,31,30,31,30,31]; 1208 - const doy = (m, d) => { let t = d; for (let i = 1; i < m; i++) t += dim[i]; return t; }; 1209 - const toSD = (m, d) => (doy(m, d) - 35 + 365) % 365; // 35 = doy(2,4) 1210 - const now = new Date(); 1211 - const key = toSD(now.getMonth() + 1, now.getDate()); 1212 - let idx = 0; 1213 - for (let i = S.length - 1; i >= 0; i--) { 1214 - if (key >= toSD(S[i][0], S[i][1])) { idx = i; break; } 1215 - } 1216 - const root = document.documentElement; 1217 - if (root) root.setAttribute('data-microseason', String(idx)); 1218 - } catch (e) { console.error('[preload] microseason error:', e); } })(); 1219 - 1220 - // Auto-apply persisted color scheme preference for this URL 1221 - window.addEventListener('DOMContentLoaded', async () => { 1222 - try { 1223 - const url = window.location.href; 1224 - // Only check for http/https URLs (not peek:// internal pages) 1225 - if (!url || url.startsWith('peek://')) return; 1226 - 1227 - const result = await ipcRenderer.invoke('datastore-query-addresses', {}); 1228 - if (!result.success || !result.data) return; 1229 - 1230 - const addr = result.data.find(a => a.uri === url); 1231 - if (addr && addr.metadata) { 1232 - const meta = JSON.parse(addr.metadata); 1233 - if (meta.colorScheme && (meta.colorScheme === 'light' || meta.colorScheme === 'dark')) { 1234 - DEBUG && console.log('[preload] Applying persisted color scheme for URL:', meta.colorScheme); 1235 - windowColorSchemeOverride = meta.colorScheme; 1236 - document.documentElement.setAttribute('data-theme', meta.colorScheme); 1237 - } 1238 - } 1239 - } catch (e) { 1240 - // Ignore errors (datastore not ready, etc.) 1241 - } 1242 - }); 1243 - 1244 - /** 1245 - * Reload all stylesheets by removing and re-adding link elements 1246 - * This forces the browser to completely re-fetch CSS including @import statements 1247 - */ 1248 - function reloadStylesheets() { 1249 - const timestamp = Date.now(); 1250 - // Reload <link> stylesheets by removing and re-adding them 1251 - // This is more aggressive than just changing href and ensures @imports are re-fetched 1252 - document.querySelectorAll('link[rel="stylesheet"]').forEach(link => { 1253 - const href = link.getAttribute('href'); 1254 - if (href) { 1255 - const baseHref = href.split('?')[0]; 1256 - const newHref = `${baseHref}?_t=${timestamp}`; 1257 - 1258 - // Create a new link element to force complete re-fetch 1259 - const newLink = document.createElement('link'); 1260 - newLink.rel = 'stylesheet'; 1261 - newLink.href = newHref; 1262 - 1263 - // Replace the old link with the new one 1264 - link.parentNode.insertBefore(newLink, link); 1265 - link.remove(); 1266 - } 1267 - }); 1268 - 1269 - // For inline <style> with @import, we need to reload them too 1270 - document.querySelectorAll('style').forEach(style => { 1271 - const content = style.textContent; 1272 - if (content && content.includes('@import')) { 1273 - // Replace @import URLs with cache-busted versions 1274 - const newContent = content.replace( 1275 - /@import\s+url\(['"]?([^'")\s]+)['"]?\)/g, 1276 - (match, url) => { 1277 - const baseUrl = url.split('?')[0]; 1278 - return `@import url('${baseUrl}?_t=${timestamp}')`; 1279 - } 1280 - ); 1281 - style.textContent = newContent; 1282 - } 1283 - }); 1284 - } 1285 - 1286 - // App control API 1287 - api.quit = () => { 1288 - ipcRenderer.send('app-quit', { source: sourceAddress }); 1289 - }; 1290 - 1291 - api.restart = () => { 1292 - ipcRenderer.send('app-restart', { source: sourceAddress }); 1293 - }; 1294 - 1295 - // Command registration API for extensions 1296 - // Commands API 1297 - // The cmd extension is loaded first and its cmd:register-batch subscriber is set up synchronously. 1298 - // Extensions can call api.commands.register() directly in their init(). 1299 - 1300 - api.commands = { 1301 - /** 1302 - * Register a command with the cmd palette 1303 - * @param {Object} command - Command object with: 1304 - * - name: string (required) 1305 - * - description: string (optional) 1306 - * - scope: 'global' | 'window' | 'page' (optional, defaults to 'global') 1307 - * - modes: string[] (optional, major modes where command is available) 1308 - * - canExecute: function(context) => boolean (optional, guard function) 1309 - * - execute: function(msg) (required) 1310 - */ 1311 - register: (command) => { 1312 - if (!command.name || !command.execute) { 1313 - console.error('commands.register: name and execute are required'); 1314 - return; 1315 - } 1316 - 1317 - // Store the execute handler locally (can't serialize functions via pubsub) 1318 - window._cmdHandlers = window._cmdHandlers || {}; 1319 - window._cmdHandlers[command.name] = command.execute; 1320 - 1321 - // Store canExecute handler if provided 1322 - if (command.canExecute) { 1323 - window._cmdCanExecuteHandlers = window._cmdCanExecuteHandlers || {}; 1324 - window._cmdCanExecuteHandlers[command.name] = command.canExecute; 1325 - } 1326 - 1327 - // Subscribe to execution requests for this command (GLOBAL scope) 1328 - const execTopic = `cmd:execute:${command.name}`; 1329 - const replyTopic = `${execTopic}:${rndm()}`; 1330 - 1331 - ipcRenderer.send('subscribe', { 1332 - source: sourceAddress, 1333 - scope: 3, 1334 - topic: execTopic, 1335 - replyTopic 1336 - }); 1337 - 1338 - ipcRenderer.on(replyTopic, async (ev, msg) => { 1339 - DEBUG && console.log('cmd:execute', command.name, msg); 1340 - const handler = window._cmdHandlers?.[command.name]; 1341 - if (handler) { 1342 - try { 1343 - const result = await handler(msg); 1344 - // If caller expects a result (for chaining), publish it back 1345 - if (msg.expectResult && msg.resultTopic) { 1346 - ipcRenderer.send('publish', { 1347 - source: sourceAddress, 1348 - scope: 3, 1349 - topic: msg.resultTopic, 1350 - data: result 1351 - }); 1352 - } 1353 - } catch (err) { 1354 - console.error('Error executing command', command.name, err); 1355 - // Still publish result on error so panel doesn't hang 1356 - if (msg.expectResult && msg.resultTopic) { 1357 - ipcRenderer.send('publish', { 1358 - source: sourceAddress, 1359 - scope: 3, 1360 - topic: msg.resultTopic, 1361 - data: { error: err.message } 1362 - }); 1363 - } 1364 - } 1365 - } else { 1366 - DEBUG && console.log('cmd:execute no handler for', command.name); 1367 - } 1368 - }); 1369 - 1370 - // Subscribe to canExecute queries for this command 1371 - const canExecTopic = `cmd:canExecute:${command.name}`; 1372 - const canExecReplyTopic = `${canExecTopic}:${rndm()}`; 1373 - 1374 - ipcRenderer.send('subscribe', { 1375 - source: sourceAddress, 1376 - scope: 3, 1377 - topic: canExecTopic, 1378 - replyTopic: canExecReplyTopic 1379 - }); 1380 - 1381 - ipcRenderer.on(canExecReplyTopic, async (ev, msg) => { 1382 - const canExecuteHandler = window._cmdCanExecuteHandlers?.[command.name]; 1383 - let canExecute = true; 1384 - 1385 - if (canExecuteHandler) { 1386 - try { 1387 - canExecute = await canExecuteHandler(msg.context); 1388 - } catch (err) { 1389 - console.error('Error in canExecute for', command.name, err); 1390 - canExecute = false; 1391 - } 1392 - } 1393 - 1394 - // Publish result back 1395 - if (msg.responseTopic) { 1396 - ipcRenderer.send('publish', { 1397 - source: sourceAddress, 1398 - scope: 3, 1399 - topic: msg.responseTopic, 1400 - data: { name: command.name, canExecute } 1401 - }); 1402 - } 1403 - }); 1404 - 1405 - const registrationEntry = { 1406 - name: command.name, 1407 - description: command.description || '', 1408 - source: sourceAddress, 1409 - scope: command.scope || 'global', 1410 - modes: command.modes || [], 1411 - hasCanExecute: !!command.canExecute, 1412 - accepts: command.accepts || [], 1413 - produces: command.produces || [], 1414 - params: command.params || [] 1415 - }; 1416 - 1417 - // Queue registration for batching (improves startup performance) 1418 - pendingRegistrations.push(registrationEntry); 1419 - 1420 - // Remember every command registered through this preload so we can replay 1421 - // them when cmd extension loads (handles register-before-cmd-ready race). 1422 - registeredCommandsByName.set(command.name, registrationEntry); 1423 - ensureCmdReadyReplaySubscribed(); 1424 - 1425 - // Debounce: flush after BATCH_DELAY_MS of no new registrations 1426 - clearTimeout(registrationTimer); 1427 - registrationTimer = setTimeout(flushRegistrations, BATCH_DELAY_MS); 1428 - 1429 - DEBUG && console.log('[preload] commands.register:', command.name, 'scope:', command.scope || 'global'); 1430 - }, 1431 - 1432 - /** 1433 - * Flush any pending command registrations immediately 1434 - * Useful for extensions that need commands available before debounce completes 1435 - */ 1436 - flush: flushRegistrations, 1437 - 1438 - /** 1439 - * Unregister a command from the cmd palette 1440 - * @param {string} name - Command name to unregister 1441 - */ 1442 - unregister: (name) => { 1443 - // Remove local handler 1444 - if (window._cmdHandlers) { 1445 - delete window._cmdHandlers[name]; 1446 - } 1447 - 1448 - // Drop from replay record so it isn't re-published on the next cmd ready 1449 - registeredCommandsByName.delete(name); 1450 - 1451 - // Notify cmd to remove the command (GLOBAL scope for cross-window) 1452 - ipcRenderer.send('publish', { 1453 - source: sourceAddress, 1454 - scope: 3, 1455 - topic: 'cmd:unregister', 1456 - data: { name } 1457 - }); 1458 - 1459 - DEBUG && console.log('[preload] commands.unregister:', name); 1460 - }, 1461 - 1462 - /** 1463 - * List all registered commands from the cmd extension 1464 - * Queries via pubsub cmd:query-commands and returns the result 1465 - * @returns {Promise<{success: boolean, data?: Array<{name: string, description: string, source: string, scope?: string, modes?: string[]}>, error?: string}>} 1466 - */ 1467 - list: () => { 1468 - return new Promise((resolve) => { 1469 - const responseTopic = `cmd:query-commands-response:${rndm()}`; 1470 - 1471 - const handler = (ev, msg) => { 1472 - ipcRenderer.removeListener(responseTopic, handler); 1473 - const commands = msg.commands || []; 1474 - resolve({ success: true, data: commands }); 1475 - }; 1476 - 1477 - // Subscribe to the unique response topic 1478 - ipcRenderer.send('subscribe', { 1479 - source: sourceAddress, 1480 - scope: 3, 1481 - topic: 'cmd:query-commands-response', 1482 - replyTopic: responseTopic 1483 - }); 1484 - ipcRenderer.on(responseTopic, handler); 1485 - 1486 - // Publish query request 1487 - ipcRenderer.send('publish', { 1488 - source: sourceAddress, 1489 - scope: 3, 1490 - topic: 'cmd:query-commands', 1491 - data: {} 1492 - }); 1493 - 1494 - // Timeout after 3 seconds 1495 - setTimeout(() => { 1496 - ipcRenderer.removeListener(responseTopic, handler); 1497 - resolve({ success: false, data: [], error: 'Timeout querying commands' }); 1498 - }, 3000); 1499 - }); 1500 - }, 1501 - 1502 - /** 1503 - * Get all registered commands 1504 - * Note: Commands are owned by cmd extension - use pubsub cmd:query-commands 1505 - * @returns {Promise<Array>} Empty array - use pubsub directly 1506 - * @deprecated Use commands.list() instead 1507 - */ 1508 - getAll: async () => { 1509 - // Commands are queried via pubsub cmd:query-commands 1510 - // Return empty - caller should use pubsub directly 1511 - return []; 1512 - }, 1513 - 1514 - /** 1515 - * Get the current command context (target window, mode state, etc.) 1516 - * Useful for determining command availability 1517 - * @returns {Promise<{success: boolean, data?: object, error?: string}>} 1518 - */ 1519 - getContext: () => { 1520 - return ipcRenderer.invoke('modes:getCommandContext'); 1521 - }, 1522 - 1523 - /** 1524 - * Check if a specific command can execute in the current context 1525 - * @param {string} name - Command name to check 1526 - * @returns {Promise<boolean>} 1527 - */ 1528 - canExecute: async (name) => { 1529 - // Get current context 1530 - const contextResult = await ipcRenderer.invoke('modes:getCommandContext'); 1531 - if (!contextResult.success) { 1532 - return false; 1533 - } 1534 - 1535 - // Request canExecute check from the command's source 1536 - return new Promise((resolve) => { 1537 - const responseTopic = `cmd:canExecute:response:${rndm()}`; 1538 - 1539 - // Subscribe to response 1540 - const handler = (ev, msg) => { 1541 - ipcRenderer.removeListener(responseTopic, handler); 1542 - resolve(msg.canExecute ?? true); 1543 - }; 1544 - 1545 - // Set up one-time listener 1546 - ipcRenderer.send('subscribe', { 1547 - source: sourceAddress, 1548 - scope: 3, 1549 - topic: responseTopic, 1550 - replyTopic: responseTopic 1551 - }); 1552 - ipcRenderer.on(responseTopic, handler); 1553 - 1554 - // Request canExecute check 1555 - ipcRenderer.send('publish', { 1556 - source: sourceAddress, 1557 - scope: 3, 1558 - topic: `cmd:canExecute:${name}`, 1559 - data: { 1560 - context: contextResult.data, 1561 - responseTopic 1562 - } 1563 - }); 1564 - 1565 - // Timeout after 1 second - assume can execute 1566 - setTimeout(() => { 1567 - ipcRenderer.removeListener(responseTopic, handler); 1568 - resolve(true); 1569 - }, 1000); 1570 - }); 1571 - } 1572 - }; 1573 - 1574 - // Extension management API 1575 - // Only available to core app (peek://app/...) and builtin extensions 1576 - // Uses pubsub to communicate with the extension loader in background.html 1577 - api.extensions = { 1578 - /** 1579 - * Check if caller has permission to manage extensions 1580 - * Permission is denied for external extensions (non-builtin) 1581 - * @returns {boolean} 1582 - */ 1583 - _hasPermission: () => { 1584 - // Core app always has permission 1585 - if (sourceAddress.startsWith('peek://app/')) { 1586 - return true; 1587 - } 1588 - // External extensions are not allowed to manage extensions 1589 - // (builtin extensions run from peek://ext/ but are loaded by core) 1590 - return false; 1591 - }, 1592 - 1593 - /** 1594 - * Get list of running extensions (read-only, no permission check) 1595 - * @returns {Promise<{success: boolean, data?: Array, error?: string}>} 1596 - */ 1597 - list: () => { 1598 - return ipcRenderer.invoke('extension-window-list'); 1599 - }, 1600 - 1601 - /** 1602 - * Get list of all registered (discovered) builtin extensions, whether running or not. 1603 - * @returns {Promise<{success: boolean, data?: Array, error?: string}>} 1604 - */ 1605 - listAllRegistered: () => { 1606 - return ipcRenderer.invoke('extension-list-all-registered'); 1607 - }, 1608 - 1609 - /** 1610 - * Load an extension (permission required) 1611 - * @param {string} id - Extension ID to load 1612 - * @returns {Promise<{success: boolean, error?: string}>} 1613 - */ 1614 - load: (id) => { 1615 - if (!api.extensions._hasPermission()) { 1616 - return Promise.resolve({ success: false, error: 'Permission denied: only core app can manage extensions' }); 1617 - } 1618 - return new Promise((resolve) => { 1619 - const replyTopic = `ext:load:reply:${rndm()}`; 1620 - 1621 - ipcRenderer.send('subscribe', { 1622 - source: sourceAddress, 1623 - scope: 1, 1624 - topic: replyTopic, 1625 - replyTopic: replyTopic 1626 - }); 1627 - 1628 - const handler = (ev, msg) => { 1629 - ipcRenderer.removeListener(replyTopic, handler); 1630 - resolve(msg); 1631 - }; 1632 - ipcRenderer.on(replyTopic, handler); 1633 - 1634 - ipcRenderer.send('publish', { 1635 - source: sourceAddress, 1636 - scope: 1, 1637 - topic: 'ext:load', 1638 - data: { id, replyTopic } 1639 - }); 1640 - 1641 - setTimeout(() => { 1642 - ipcRenderer.removeListener(replyTopic, handler); 1643 - resolve({ success: false, error: 'Timeout loading extension' }); 1644 - }, 10000); 1645 - }); 1646 - }, 1647 - 1648 - /** 1649 - * Unload an extension (permission required) 1650 - * @param {string} id - Extension ID to unload 1651 - * @returns {Promise<{success: boolean, error?: string}>} 1652 - */ 1653 - unload: (id) => { 1654 - if (!api.extensions._hasPermission()) { 1655 - return Promise.resolve({ success: false, error: 'Permission denied: only core app can manage extensions' }); 1656 - } 1657 - return new Promise((resolve) => { 1658 - const replyTopic = `ext:unload:reply:${rndm()}`; 1659 - 1660 - ipcRenderer.send('subscribe', { 1661 - source: sourceAddress, 1662 - scope: 1, 1663 - topic: replyTopic, 1664 - replyTopic: replyTopic 1665 - }); 1666 - 1667 - const handler = (ev, msg) => { 1668 - ipcRenderer.removeListener(replyTopic, handler); 1669 - resolve(msg); 1670 - }; 1671 - ipcRenderer.on(replyTopic, handler); 1672 - 1673 - ipcRenderer.send('publish', { 1674 - source: sourceAddress, 1675 - scope: 1, 1676 - topic: 'ext:unload', 1677 - data: { id, replyTopic } 1678 - }); 1679 - 1680 - setTimeout(() => { 1681 - ipcRenderer.removeListener(replyTopic, handler); 1682 - resolve({ success: false, error: 'Timeout unloading extension' }); 1683 - }, 10000); 1684 - }); 1685 - }, 1686 - 1687 - /** 1688 - * Reload an extension (permission required) 1689 - * Destroys the extension window and recreates it, reloading all code. 1690 - * @param {string} id - Extension ID to reload 1691 - * @returns {Promise<{success: boolean, data?: object, error?: string}>} 1692 - */ 1693 - reload: (id) => { 1694 - if (!api.extensions._hasPermission()) { 1695 - return Promise.resolve({ success: false, error: 'Permission denied: only core app can manage extensions' }); 1696 - } 1697 - return ipcRenderer.invoke('extension-reload', { id }); 1698 - }, 1699 - 1700 - /** 1701 - * Open devtools for an extension (permission required) 1702 - * @param {string} id - Extension ID 1703 - * @returns {Promise<{success: boolean, data?: object, error?: string}>} 1704 - */ 1705 - devtools: (id) => { 1706 - if (!api.extensions._hasPermission()) { 1707 - return Promise.resolve({ success: false, error: 'Permission denied: only core app can manage extensions' }); 1708 - } 1709 - return ipcRenderer.invoke('extension-window-devtools', { id }); 1710 - }, 1711 - 1712 - /** 1713 - * Get manifest for a running extension (read-only, no permission check) 1714 - * @param {string} id - Extension ID 1715 - * @returns {Promise<{success: boolean, data?: object, error?: string}>} 1716 - */ 1717 - getManifest: (id) => { 1718 - return new Promise((resolve) => { 1719 - const replyTopic = `ext:manifest:reply:${rndm()}`; 1720 - 1721 - ipcRenderer.send('subscribe', { 1722 - source: sourceAddress, 1723 - scope: 1, 1724 - topic: replyTopic, 1725 - replyTopic: replyTopic 1726 - }); 1727 - 1728 - const handler = (ev, msg) => { 1729 - ipcRenderer.removeListener(replyTopic, handler); 1730 - resolve(msg); 1731 - }; 1732 - ipcRenderer.on(replyTopic, handler); 1733 - 1734 - ipcRenderer.send('publish', { 1735 - source: sourceAddress, 1736 - scope: 1, 1737 - topic: 'ext:manifest', 1738 - data: { id, replyTopic } 1739 - }); 1740 - 1741 - setTimeout(() => { 1742 - ipcRenderer.removeListener(replyTopic, handler); 1743 - resolve({ success: false, error: 'Timeout getting manifest' }); 1744 - }, 5000); 1745 - }); 1746 - }, 1747 - 1748 - // ===== Datastore-backed extension management (persisted) ===== 1749 - 1750 - /** 1751 - * Open folder picker dialog to select an extension folder 1752 - * @returns {Promise<{success: boolean, canceled?: boolean, data?: {path: string}, error?: string}>} 1753 - */ 1754 - pickFolder: () => { 1755 - return ipcRenderer.invoke('extension-pick-folder'); 1756 - }, 1757 - 1758 - /** 1759 - * Validate an extension folder (checks manifest.json) 1760 - * @param {string} folderPath - Path to extension folder 1761 - * @returns {Promise<{success: boolean, valid: boolean, errors?: string[], manifest?: object, error?: string}>} 1762 - */ 1763 - validateFolder: (folderPath) => { 1764 - return ipcRenderer.invoke('extension-validate-folder', { folderPath }); 1765 - }, 1766 - 1767 - /** 1768 - * Add extension to datastore (persisted) 1769 - * @param {string} folderPath - Path to extension folder 1770 - * @param {object} manifest - Parsed manifest (can be partial/invalid) 1771 - * @param {boolean} enabled - Whether to enable immediately 1772 - * @returns {Promise<{success: boolean, data?: {id: string}, error?: string}>} 1773 - */ 1774 - add: (folderPath, manifest, enabled = false) => { 1775 - if (!api.extensions._hasPermission()) { 1776 - return Promise.resolve({ success: false, error: 'Permission denied' }); 1777 - } 1778 - return ipcRenderer.invoke('extension-add', { folderPath, manifest, enabled }); 1779 - }, 1780 - 1781 - /** 1782 - * Remove extension from datastore 1783 - * @param {string} id - Extension ID 1784 - * @returns {Promise<{success: boolean, error?: string}>} 1785 - */ 1786 - remove: (id) => { 1787 - if (!api.extensions._hasPermission()) { 1788 - return Promise.resolve({ success: false, error: 'Permission denied' }); 1789 - } 1790 - return ipcRenderer.invoke('extension-remove', { id }); 1791 - }, 1792 - 1793 - /** 1794 - * Update extension in datastore (enable/disable, etc.) 1795 - * @param {string} id - Extension ID 1796 - * @param {object} updates - Fields to update 1797 - * @returns {Promise<{success: boolean, data?: object, error?: string}>} 1798 - */ 1799 - update: (id, updates) => { 1800 - if (!api.extensions._hasPermission()) { 1801 - return Promise.resolve({ success: false, error: 'Permission denied' }); 1802 - } 1803 - return ipcRenderer.invoke('extension-update', { id, updates }); 1804 - }, 1805 - 1806 - /** 1807 - * Get all extensions from datastore (includes non-running) 1808 - * @returns {Promise<{success: boolean, data?: Array, error?: string}>} 1809 - */ 1810 - getAll: () => { 1811 - return ipcRenderer.invoke('extension-get-all'); 1812 - }, 1813 - 1814 - /** 1815 - * Get single extension from datastore 1816 - * @param {string} id - Extension ID 1817 - * @returns {Promise<{success: boolean, data?: object, error?: string}>} 1818 - */ 1819 - get: (id) => { 1820 - return ipcRenderer.invoke('extension-get', { id }); 1821 - }, 1822 - 1823 - /** 1824 - * Get settings schema for an extension 1825 - * Reads schema from file specified in manifest.settingsSchema 1826 - * @param {string} extId - Extension ID 1827 - * @returns {Promise<{success: boolean, data?: {extId, name, schema}, error?: string}>} 1828 - */ 1829 - getSettingsSchema: (extId) => { 1830 - return ipcRenderer.invoke('feature-settings-schema', { extId }); 1831 - } 1832 - }; 1833 - 1834 - // Extension settings API (for isolated extension processes) 1835 - // Extensions can only access their own settings via datastore 1836 - api.settings = { 1837 - /** 1838 - * Get settings for the current extension 1839 - * Only works from extension context (peek://ext/{id}/...) 1840 - * @returns {Promise<{success: boolean, data?: object, error?: string}>} 1841 - */ 1842 - get: () => { 1843 - const extId = getExtensionId(); 1844 - if (!extId) { 1845 - return Promise.resolve({ success: false, error: 'Not an extension context' }); 1846 - } 1847 - return ipcRenderer.invoke('feature-settings-get', { extId }); 1848 - }, 1849 - 1850 - /** 1851 - * Save settings for the current extension 1852 - * Only works from extension context (peek://ext/{id}/...) 1853 - * @param {object} settings - Settings object to save (keys: prefs, items, etc.) 1854 - * @returns {Promise<{success: boolean, error?: string}>} 1855 - */ 1856 - set: (settings) => { 1857 - const extId = getExtensionId(); 1858 - if (!extId) { 1859 - return Promise.resolve({ success: false, error: 'Not an extension context' }); 1860 - } 1861 - return ipcRenderer.invoke('feature-settings-set', { extId, settings }); 1862 - }, 1863 - 1864 - /** 1865 - * Get a single setting key for the current extension 1866 - * @param {string} key - Setting key (e.g., 'prefs', 'items') 1867 - * @returns {Promise<{success: boolean, data?: any, error?: string}>} 1868 - */ 1869 - getKey: (key) => { 1870 - const extId = getExtensionId(); 1871 - if (!extId) { 1872 - return Promise.resolve({ success: false, error: 'Not an extension context' }); 1873 - } 1874 - return ipcRenderer.invoke('feature-settings-get-key', { extId, key }); 1875 - }, 1876 - 1877 - /** 1878 - * Set a single setting key for the current extension 1879 - * @param {string} key - Setting key 1880 - * @param {any} value - Value to set (will be JSON stringified) 1881 - * @returns {Promise<{success: boolean, error?: string}>} 1882 - */ 1883 - setKey: (key, value) => { 1884 - const extId = getExtensionId(); 1885 - if (!extId) { 1886 - return Promise.resolve({ success: false, error: 'Not an extension context' }); 1887 - } 1888 - return ipcRenderer.invoke('feature-settings-set-key', { extId, key, value }); 1889 - }, 1890 - 1891 - /** 1892 - * Read a single setting key from another extension (read-only) 1893 - * Allows extensions to share preferences (e.g., tags reading editor's vim mode) 1894 - * @param {string} extId - Target extension ID 1895 - * @param {string} key - Setting key (e.g., 'prefs') 1896 - * @returns {Promise<{success: boolean, data?: any, error?: string}>} 1897 - */ 1898 - getExtKey: (extId, key) => { 1899 - if (!extId || !key) { 1900 - return Promise.resolve({ success: false, error: 'extId and key are required' }); 1901 - } 1902 - return ipcRenderer.invoke('feature-settings-get-key', { extId, key }); 1903 - } 1904 - }; 1905 - 1906 - // Session API — allows extensions to trigger session-level operations 1907 - api.session = { 1908 - /** 1909 - * Save space workspace layouts (window positions per space) 1910 - * @returns {Promise<{success: boolean, error?: string}>} 1911 - */ 1912 - saveSpaceWorkspaces: () => { 1913 - return ipcRenderer.invoke('save-space-workspaces'); 1914 - } 1915 - }; 1916 - 1917 - // Net fetch API - proxies HTTP requests through main process to bypass CORS 1918 - // Useful for extensions that need to fetch from external APIs 1919 - api.net = { 1920 - /** 1921 - * Fetch a URL through the main process (bypasses CORS restrictions) 1922 - * @param {string} url - URL to fetch (http/https only) 1923 - * @param {object} options - Fetch options (method, headers, timeout) 1924 - * @returns {Promise<{success: boolean, data?: string, status?: number, error?: string}>} 1925 - */ 1926 - fetch: (url, options = {}) => { 1927 - return ipcRenderer.invoke('net-fetch', { url, options }); 1928 - } 1929 - }; 1930 - 1931 - // Escape handling API 1932 - // For windows with escapeMode: 'navigate' or 'auto' 1933 - // Callback should return { handled: true } if escape was handled internally 1934 - // or { handled: false } to let the window close 1935 - let _escapeCallback = null; 1936 - 1937 - api.escape = { 1938 - /** 1939 - * Register an escape handler for this window 1940 - * @param {function} callback - Returns { handled: true } if escape was handled 1941 - */ 1942 - onEscape: (callback) => { 1943 - _escapeCallback = callback; 1944 - // The callback is purely informational — the backend consults it via 1945 - // escape-pressed IPC and decides close/nothing based on the window's 1946 - // declared role (set at open time), not self-declaration. 1947 - }, 1948 - /** 1949 - * Trigger the escape handler directly (for testing) 1950 - * Returns the handler's response without going through IPC 1951 - */ 1952 - trigger: async () => { 1953 - if (_escapeCallback) { 1954 - return await _escapeCallback(); 1955 - } 1956 - return { handled: false }; 1957 - } 1958 - }; 1959 - 1960 - // OAuth loopback server — available to all pages including extensions 1961 - api.oauth = { 1962 - startLoopback: (options) => ipcRenderer.invoke('oauth-start-loopback', options), 1963 - awaitCallback: (port) => ipcRenderer.invoke('oauth-await-callback', { port }), 1964 - }; 1965 - 1966 - // Escape handler - responds to backend's escape query 1967 - // The backend intercepts ESC on keyDown via before-input-event and calls e.preventDefault(), 1968 - // so the DOM keydown event never reaches the page. This means peek-dialog's own keydown 1969 - // handler won't fire, and we handle dialog closing here in the preload instead. 1970 - // 1971 - // Order of precedence: 1972 - // 1. Open peek-dialog or native <dialog> - close the topmost one automatically 1973 - // 2. Extension's onEscape() callback - for internal navigation (search clear, filter reset, etc.) 1974 - // 3. Return { handled: false } - backend decides whether to close the window 1975 - // 1976 - // This eliminates the keyDown/keyUp race condition: since we intercept on keyDown and 1977 - // prevent DOM propagation, the dialog is still open when this handler runs. 1978 - ipcRenderer.on('escape-pressed', async (event, data) => { 1979 - console.log('[preload:esc] escape-pressed received, hasCallback:', !!_escapeCallback, 'source:', sourceAddress); 1980 - 1981 - try { 1982 - // Check for open dialogs first (peek-dialog or native <dialog>). 1983 - // Find the topmost open dialog and close it. This handles ALL extensions 1984 - // using peek-dialog without requiring per-extension workarounds. 1985 - const openDialog = _findTopmostOpenDialog(); 1986 - if (openDialog) { 1987 - const tag = openDialog.tagName; 1988 - console.log('[preload:esc] Found open overlay:', tag, '- closing it'); 1989 - // Close via component API (peek-dialog, peek-drawer) or native dialog close 1990 - if (typeof openDialog.close === 'function') { 1991 - openDialog.close(); 1992 - } 1993 - ipcRenderer.send(data.responseChannel, { handled: true }); 1994 - return; 1995 - } 1996 - 1997 - // If app registered a handler (IZUI), use it 1998 - if (_escapeCallback) { 1999 - console.log('[preload:esc] Calling app escape handler'); 2000 - const startTime = Date.now(); 2001 - const result = await _escapeCallback(); 2002 - const elapsed = Date.now() - startTime; 2003 - console.log('[preload:esc] App handler returned:', JSON.stringify(result), `(${elapsed}ms)`); 2004 - ipcRenderer.send(data.responseChannel, result || { handled: false }); 2005 - return; 2006 - } 2007 - 2008 - // No handler registered - let backend close the window 2009 - console.log('[preload:esc] No handler registered, returning { handled: false }'); 2010 - ipcRenderer.send(data.responseChannel, { handled: false }); 2011 - } catch (err) { 2012 - console.error('[preload:esc] Error in escape handler:', err); 2013 - ipcRenderer.send(data.responseChannel, { handled: false }); 2014 - } 2015 - }); 2016 - 2017 - /** 2018 - * Find the topmost open dialog/drawer in the document that should close on ESC. 2019 - * Checks for peek-dialog[open], peek-drawer[open], and native dialog[open] elements. 2020 - * Respects the closeOnEscape property (defaults to true on peek-dialog/peek-drawer). 2021 - * Returns the element to close, or null if none found. 2022 - */ 2023 - function _findTopmostOpenDialog() { 2024 - // Check for open peek-dialog and peek-drawer components (both reflect `open` as attribute) 2025 - const peekOverlays = document.querySelectorAll('peek-dialog[open], peek-drawer[open]'); 2026 - if (peekOverlays.length > 0) { 2027 - // Walk from last (topmost in DOM order) to first, respecting closeOnEscape property 2028 - for (let i = peekOverlays.length - 1; i >= 0; i--) { 2029 - const overlay = peekOverlays[i]; 2030 - // Default is true, so only skip if explicitly set to false 2031 - if (overlay.closeOnEscape !== false) { 2032 - return overlay; 2033 - } 2034 - } 2035 - } 2036 - 2037 - // Check for native <dialog open> elements at document level 2038 - const nativeDialogs = document.querySelectorAll('dialog[open]'); 2039 - if (nativeDialogs.length > 0) { 2040 - return nativeDialogs[nativeDialogs.length - 1]; 2041 - } 2042 - 2043 - return null; 2044 - } 2045 - 2046 - // ==================== Modes API ==================== 2047 - // Context-aware command system 2048 - api.modes = { 2049 - /** 2050 - * Get the current mode state for a window 2051 - * @param {number|null} windowId - Window ID (null = current/last focused) 2052 - * @returns {Promise<{success: boolean, data?: {major: string}, error?: string}>} 2053 - */ 2054 - getWindowMode: (windowId = null) => { 2055 - return ipcRenderer.invoke('modes:getWindowMode', { windowId }); 2056 - }, 2057 - 2058 - /** 2059 - * Set the major mode for a window 2060 - * @param {string} mode - Major mode ID ('page', 'group', 'default') 2061 - * @param {number|null} windowId - Window ID (null = current/last focused) 2062 - * @returns {Promise<{success: boolean, error?: string}>} 2063 - */ 2064 - setMajorMode: (mode, windowId = null) => { 2065 - return ipcRenderer.invoke('modes:setMajorMode', { mode, windowId }); 2066 - }, 2067 - 2068 - /** 2069 - * Get all available modes 2070 - * @returns {Promise<{success: boolean, data?: Array<{id: string, name: string, description: string}>, error?: string}>} 2071 - */ 2072 - listModes: () => { 2073 - return ipcRenderer.invoke('modes:listModes'); 2074 - }, 2075 - 2076 - /** 2077 - * Get command context for current state 2078 - * Returns context with target window info and mode state 2079 - * @returns {Promise<{success: boolean, data?: object, error?: string}>} 2080 - */ 2081 - getCommandContext: () => { 2082 - return ipcRenderer.invoke('modes:getCommandContext'); 2083 - }, 2084 - 2085 - /** 2086 - * Subscribe to mode changes 2087 - * @param {function} callback - Called with (state, windowId) when mode changes 2088 - */ 2089 - onModeChange: (callback) => { 2090 - api.subscribe('modes:changed', (msg) => { 2091 - callback({ major: msg.major }, msg.windowId); 2092 - }, api.scopes.GLOBAL); 2093 - } 2094 - }; 2095 - 2096 - // ==================== Context API ==================== 2097 - // General-purpose key-value store for application context. 2098 - // "mode" is just one key in this system - extensions can define their own context keys. 2099 - api.context = { 2100 - /** 2101 - * Get the current context entry for a key 2102 - * @param {string} key - Context key (e.g., 'mode') 2103 - * @param {number|null} windowId - Window ID (null = current window) 2104 - * @returns {Promise<{success: boolean, data?: {value: any, metadata: object, timestamp: number, source: string}, error?: string}>} 2105 - */ 2106 - get: (key, windowId = null) => { 2107 - return ipcRenderer.invoke('context-get', { key, windowId }); 2108 - }, 2109 - 2110 - /** 2111 - * Set a context value 2112 - * @param {string} key - Context key (e.g., 'mode') 2113 - * @param {any} value - Value to set 2114 - * @param {object} options - Options 2115 - * @param {object} [options.metadata={}] - Additional metadata 2116 - * @param {number|null} [options.windowId=null] - Window ID (null = current window) 2117 - * @returns {Promise<{success: boolean, data?: {id: string, prevEntryId: string|null}, error?: string}>} 2118 - */ 2119 - set: (key, value, options = {}) => { 2120 - return ipcRenderer.invoke('context-set', { 2121 - key, 2122 - value, 2123 - metadata: options.metadata, 2124 - windowId: options.windowId, 2125 - source: options.source || sourceAddress 2126 - }); 2127 - }, 2128 - 2129 - /** 2130 - * Watch for context changes on a key 2131 - * @param {string} key - Context key to watch 2132 - * @param {function} callback - Called with (entry, oldEntry) when context changes 2133 - * @returns {function} Unsubscribe function 2134 - */ 2135 - watch: (key, callback) => { 2136 - // Use internal topic pattern for specific key watching 2137 - const topic = 'context:changed'; 2138 - const wrappedCallback = (msg) => { 2139 - if (msg.key === key) { 2140 - callback({ 2141 - value: msg.value, 2142 - metadata: msg.metadata, 2143 - windowId: msg.windowId, 2144 - source: msg.source, 2145 - entryId: msg.entryId 2146 - }, null); // oldEntry not available via pubsub 2147 - } 2148 - }; 2149 - 2150 - api.subscribe(topic, wrappedCallback, api.scopes.GLOBAL); 2151 - 2152 - // Return unsubscribe function (note: pubsub doesn't support unsubscribe yet) 2153 - return () => { 2154 - DEBUG && console.log('[preload] context.watch unsubscribe not fully supported'); 2155 - }; 2156 - }, 2157 - 2158 - /** 2159 - * Query context history 2160 - * @param {string} key - Context key 2161 - * @param {object} options - Query options 2162 - * @param {number} [options.since] - Start timestamp 2163 - * @param {number} [options.until] - End timestamp 2164 - * @param {number|null} [options.windowId] - Filter by window 2165 - * @param {number} [options.limit] - Max entries 2166 - * @param {string} [options.order='desc'] - Sort order ('asc' or 'desc') 2167 - * @returns {Promise<{success: boolean, data?: Array, error?: string}>} 2168 - */ 2169 - getHistory: (key, options = {}) => { 2170 - return ipcRenderer.invoke('context-history', { key, ...options }); 2171 - }, 2172 - 2173 - /** 2174 - * Get context snapshot at a point in time 2175 - * @param {number} timestamp - Point in time 2176 - * @param {string[]} keys - Keys to include (empty = all) 2177 - * @returns {Promise<{success: boolean, data?: Object, error?: string}>} 2178 - */ 2179 - getSnapshot: (timestamp, keys = []) => { 2180 - return ipcRenderer.invoke('context-snapshot', { timestamp, keys }); 2181 - }, 2182 - 2183 - /** 2184 - * Get all windows with a specific context value 2185 - * @param {string} key - Context key 2186 - * @param {any} value - Value to match 2187 - * @returns {Promise<{success: boolean, data?: number[], error?: string}>} 2188 - */ 2189 - getWindowsWithValue: (key, value) => { 2190 - return ipcRenderer.invoke('context-windows-with-value', { key, value }); 2191 - }, 2192 - 2193 - /** 2194 - * Get all windows in a specific space 2195 - * @param {string} spaceId - Space ID 2196 - * @returns {Promise<{success: boolean, data?: number[], error?: string}>} 2197 - */ 2198 - getWindowsInSpace: (spaceId) => { 2199 - return ipcRenderer.invoke('context-windows-in-space', { spaceId }); 2200 - }, 2201 - 2202 - // ===== Mode-specific convenience methods ===== 2203 - 2204 - /** 2205 - * Get the current mode for a window 2206 - * @param {number|null} windowId - Window ID (null = current) 2207 - * @returns {Promise<string|null>} Mode value or null 2208 - */ 2209 - getMode: async (windowId = null) => { 2210 - const result = await ipcRenderer.invoke('context-get', { key: 'mode', windowId }); 2211 - return result.success && result.data ? result.data.value : null; 2212 - }, 2213 - 2214 - /** 2215 - * Set the mode for a window 2216 - * @param {string} mode - Mode value ('default', 'page', 'group', etc.) 2217 - * @param {object} options - Options 2218 - * @param {object} [options.metadata={}] - Mode metadata (e.g., groupId, groupName) 2219 - * @param {number|null} [options.windowId=null] - Window ID 2220 - * @returns {Promise<{success: boolean, error?: string}>} 2221 - */ 2222 - setMode: (mode, options = {}) => { 2223 - return ipcRenderer.invoke('context-set', { 2224 - key: 'mode', 2225 - value: mode, 2226 - metadata: options.metadata, 2227 - windowId: options.windowId, 2228 - source: options.source || sourceAddress 2229 - }); 2230 - }, 2231 - 2232 - /** 2233 - * Watch for mode changes 2234 - * @param {function} callback - Called with (mode, entry) when mode changes 2235 - * @returns {function} Unsubscribe function 2236 - */ 2237 - watchMode: (callback) => { 2238 - return api.context.watch('mode', (entry) => { 2239 - callback(entry?.value ?? null, entry); 2240 - }); 2241 - }, 2242 - 2243 - /** 2244 - * Get all windows in a specific mode 2245 - * @param {string} mode - Mode value 2246 - * @returns {Promise<{success: boolean, data?: number[], error?: string}>} 2247 - */ 2248 - getWindowsInMode: (mode) => { 2249 - return ipcRenderer.invoke('context-windows-with-value', { key: 'mode', value: mode }); 2250 - } 2251 - }; 2252 - 2253 - /** 2254 - * File operations 2255 - */ 2256 - api.files = { 2257 - /** 2258 - * Show native save dialog and write content to file 2259 - * @param {string} content - Content to save 2260 - * @param {object} options - Options { filename, mimeType } 2261 - * @returns {Promise<{success: boolean, path?: string, canceled?: boolean, error?: string}>} 2262 - */ 2263 - save: (content, options = {}) => { 2264 - return ipcRenderer.invoke('file-save-dialog', { 2265 - content, 2266 - filename: options.filename, 2267 - mimeType: options.mimeType 2268 - }); 2269 - }, 2270 - 2271 - /** 2272 - * Show native open dialog, read file, and return content + metadata 2273 - * @returns {Promise<{success: boolean, canceled?: boolean, data?: {name: string, path: string, size: number, mimeType: string, content: string|null, isText: boolean}, error?: string}>} 2274 - */ 2275 - open: () => { 2276 - return ipcRenderer.invoke('file-open-dialog'); 2277 - }, 2278 - 2279 - /** 2280 - * Read content directly from a file path (no dialog) 2281 - * @param {string} filePath - Absolute path to read from 2282 - * @returns {Promise<{success: boolean, data?: {content: string, name: string, path: string}, error?: string}>} 2283 - */ 2284 - readFromPath: (filePath) => { 2285 - return ipcRenderer.invoke('file-read-from-path', { filePath }); 2286 - }, 2287 - 2288 - /** 2289 - * Write content directly to a file path (no dialog) 2290 - * @param {string} filePath - Absolute path to write to 2291 - * @param {string} content - Content to write 2292 - * @returns {Promise<{success: boolean, path?: string, error?: string}>} 2293 - */ 2294 - writeToPath: (filePath, content) => { 2295 - return ipcRenderer.invoke('file-write-to-path', { filePath, content }); 2296 - } 2297 - }; 2298 - 2299 - // IPC message receiving for core pages (peek://app/...) 2300 - // Used by extension host for ext:load, overlay for nav state, etc. 2301 - if (isCore) { 2302 - api.ipc = { 2303 - /** 2304 - * Listen for IPC messages from main process 2305 - * @param {string} channel - IPC channel to listen on 2306 - * @param {function} callback - Handler for incoming messages 2307 - */ 2308 - on: (channel, callback) => { 2309 - ipcRenderer.on(channel, (event, ...args) => { 2310 - callback(...args); 2311 - }); 2312 - }, 2313 - /** 2314 - * Send an IPC message to the main process 2315 - * @param {string} channel - IPC channel to send on 2316 - * @param {*} data - Data to send 2317 - */ 2318 - send: (channel, data) => { 2319 - ipcRenderer.send(channel, data); 2320 - } 2321 - }; 2322 - } 2323 - 2324 - // Generic IPC invoke for core pages (permission required for security) 2325 - // Used by diagnostic page and other core utilities 2326 - if (isCore) { 2327 - /** 2328 - * Invoke an IPC handler by channel name 2329 - * Only available to core pages (peek://app/...) 2330 - * @param {string} channel - IPC channel name 2331 - * @param {any} data - Optional data to send 2332 - * @returns {Promise<any>} - Result from IPC handler 2333 - */ 2334 - api.invoke = (channel, data) => { 2335 - return ipcRenderer.invoke(channel, data); 2336 - }; 2337 - } 2338 - 2339 - contextBridge.exposeInMainWorld('app', api); 2340 - DEBUG && console.log(src, 'api exposed in', Date.now() - preloadStart, 'ms'); 2341 - 2342 - window.addEventListener('load', () => { 2343 - DEBUG && console.log(src, 'window.load in', Date.now() - preloadStart, 'ms'); 2344 - }); 2345 - 2346 - // ============================================================================ 2347 - // Click-and-hold window dragging (unified, application-wide) 2348 - // 2349 - // Provides drag-to-move for all frameless windows. Hold the mouse button down 2350 - // for a short delay, then drag to move the window. Works in every window that 2351 - // doesn't opt out via `data-no-drag` on <body> (e.g., peek://page which has 2352 - // its own canvas-based drag system). 2353 - // 2354 - // Design (v3 rewrite): 2355 - // - Window ID is fetched once at init (it never changes). 2356 - // - Window position is fetched eagerly on mousedown (overlaps with hold timer). 2357 - // - Hold delay is configurable via dragHoldDelay pref (default 1s). 2358 - // - NO movement threshold during hold period. Mouse can move freely while 2359 - // waiting for the hold timer to fire. When it fires, the CURRENT mouse 2360 - // position becomes the drag origin (no jump from early movement). 2361 - // - Text selection takes priority: if a selection appears during hold/drag, 2362 - // the drag is cancelled. 2363 - // - Only blocks drag when mousedown target IS a focused text input/textarea 2364 - // or contentEditable. Other elements (buttons, labels, etc.) allow drag. 2365 - // - Elements with data-no-drag attribute (or ancestors) never trigger drag. 2366 - // - Button state tracked via mousedown/mouseup boolean (not e.buttons which 2367 - // can be stale during fast cursor movement — known Chromium issue). 2368 - // - Click suppression: after a drag, the subsequent click event is suppressed 2369 - // to prevent accidental button/link activation. 2370 - // - Window-level capture-phase listeners ensure events aren't lost when the 2371 - // cursor escapes element boundaries during fast movement. 2372 - // - requestAnimationFrame batches window-move IPC calls for smoothness. 2373 - // - Comprehensive console.log('[DRAG]') breadcrumbs for diagnostics. 2374 - // ============================================================================ 2375 - (function initWindowDrag() { 2376 - const MOVE_THRESHOLD = 3; // px of movement required to engage drag after hold fires 2377 - const JITTER_TOLERANCE = 3; // px of movement allowed during hold without cancelling 2378 - 2379 - // Configurable hold delay (read from prefs at init, default 1s) 2380 - let HOLD_DELAY = 1000; 2381 - 2382 - // State 2383 - let isDragging = false; 2384 - let holdReady = false; // hold timer fired, waiting for movement to engage drag 2385 - let holdTimer = null; 2386 - let mouseButtonDown = false; 2387 - let cachedWindowId = null; 2388 - 2389 - // Mouse positions 2390 - let lastScreenX = 0; // updated every mousemove while button is held 2391 - let lastScreenY = 0; 2392 - let holdOriginX = 0; // screen coords at mousedown (for jitter detection during hold) 2393 - let holdOriginY = 0; 2394 - let dragOriginX = 0; // screen coords at the moment drag began 2395 - let dragOriginY = 0; 2396 - let windowOriginX = 0; // window position at the moment drag began 2397 - let windowOriginY = 0; 2398 - 2399 - // Eager position fetch (started on mousedown, resolved when hold fires) 2400 - let positionPromise = null; 2401 - 2402 - // Click suppression after drag 2403 - let suppressClick = false; 2404 - 2405 - // requestAnimationFrame dedup 2406 - let rafPending = false; 2407 - let rafX = 0; 2408 - let rafY = 0; 2409 - 2410 - // ------------------------------------------------------------------------- 2411 - // Helpers 2412 - // ------------------------------------------------------------------------- 2413 - 2414 - /** Should we block drag for this mousedown target? */ 2415 - const shouldBlockDrag = (target) => { 2416 - if (!target) return false; 2417 - // data-no-drag on element or ancestor 2418 - if (target.closest && target.closest('[data-no-drag]')) return true; 2419 - if (target.hasAttribute && target.hasAttribute('data-no-drag')) return true; 2420 - // Block only when target IS a focused text input/textarea 2421 - const tag = (target.tagName || '').toLowerCase(); 2422 - if ((tag === 'input' || tag === 'textarea') && target === document.activeElement) return true; 2423 - // Block on contentEditable elements that are focused 2424 - if (target.isContentEditable && target === document.activeElement) return true; 2425 - return false; 2426 - }; 2427 - 2428 - /** Is there an active text selection? */ 2429 - const hasTextSelection = () => { 2430 - const sel = window.getSelection(); 2431 - return sel && sel.toString().length > 0; 2432 - }; 2433 - 2434 - /** Cancel the hold timer without ending a drag in progress. */ 2435 - const cancelHold = () => { 2436 - if (holdTimer) { 2437 - clearTimeout(holdTimer); 2438 - holdTimer = null; 2439 - } 2440 - }; 2441 - 2442 - /** End everything: cancel hold and/or stop drag. */ 2443 - const endDrag = (reason) => { 2444 - const wasDragging = isDragging; 2445 - const wasHoldReady = holdReady; 2446 - cancelHold(); 2447 - holdReady = false; 2448 - if (isDragging) { 2449 - isDragging = false; 2450 - suppressClick = true; 2451 - document.body.classList.remove('is-dragging'); 2452 - document.body.style.userSelect = ''; 2453 - document.body.style.webkitUserSelect = ''; 2454 - } 2455 - // Remove cursor classes 2456 - if (wasDragging || wasHoldReady) { 2457 - document.body.classList.remove('hold-ready'); 2458 - } 2459 - positionPromise = null; 2460 - if (wasDragging) { 2461 - console.log('[DRAG] ended:', reason); 2462 - } 2463 - }; 2464 - 2465 - // ------------------------------------------------------------------------- 2466 - // Core event handlers 2467 - // ------------------------------------------------------------------------- 2468 - 2469 - const onMouseDown = (e) => { 2470 - if (e.button !== 0) return; 2471 - mouseButtonDown = true; 2472 - 2473 - // Always record position (used by hold timer to set drag origin) 2474 - lastScreenX = e.screenX; 2475 - lastScreenY = e.screenY; 2476 - holdOriginX = e.screenX; 2477 - holdOriginY = e.screenY; 2478 - 2479 - const blocked = shouldBlockDrag(e.target); 2480 - console.log('[DRAG] mousedown', e.target?.tagName, 2481 - (e.target?.className?.toString?.() || '').slice(0, 40), 2482 - '| blocked:', blocked, '| wid:', cachedWindowId); 2483 - 2484 - if (blocked) return; 2485 - if (!cachedWindowId) { 2486 - console.log('[DRAG] abort: no windowId'); 2487 - return; 2488 - } 2489 - 2490 - // Eagerly fetch window position (overlaps with hold timer) 2491 - positionPromise = ipcRenderer.invoke('window-get-position', { id: cachedWindowId }) 2492 - .catch((err) => { console.log('[DRAG] pos fetch fail:', err); return null; }); 2493 - 2494 - // Start hold timer. Mouse must stay still (within jitter tolerance) during 2495 - // the hold period. Movement cancels the timer, allowing text selection. 2496 - holdTimer = setTimeout(async () => { 2497 - holdTimer = null; 2498 - 2499 - if (!mouseButtonDown) { 2500 - console.log('[DRAG] hold expired but button already released'); 2501 - positionPromise = null; 2502 - return; 2503 - } 2504 - if (hasTextSelection()) { 2505 - console.log('[DRAG] hold expired but text selected'); 2506 - positionPromise = null; 2507 - return; 2508 - } 2509 - 2510 - const pos = await positionPromise; 2511 - positionPromise = null; 2512 - 2513 - if (!pos || !pos.success) { 2514 - console.log('[DRAG] hold expired but pos fetch failed:', pos); 2515 - return; 2516 - } 2517 - if (!mouseButtonDown) { 2518 - console.log('[DRAG] hold expired but button released during await'); 2519 - return; 2520 - } 2521 - 2522 - // Hold fired — show visual cursor feedback. Drag engages on next movement. 2523 - dragOriginX = lastScreenX; 2524 - dragOriginY = lastScreenY; 2525 - windowOriginX = pos.x; 2526 - windowOriginY = pos.y; 2527 - holdReady = true; 2528 - document.body.classList.add('hold-ready'); 2529 - 2530 - console.log('[DRAG] HOLD READY (cursor→grab) origin=(' + dragOriginX + ',' + dragOriginY + 2531 - ') window=(' + windowOriginX + ',' + windowOriginY + ')'); 2532 - }, HOLD_DELAY); 2533 - }; 2534 - 2535 - const onMouseMove = (e) => { 2536 - if (!mouseButtonDown) return; 2537 - 2538 - // Always track latest mouse position (hold timer reads this) 2539 - lastScreenX = e.screenX; 2540 - lastScreenY = e.screenY; 2541 - 2542 - // During hold period (timer not yet fired): cancel if mouse moves beyond jitter tolerance. 2543 - // This allows text selection to proceed normally. 2544 - if (holdTimer && !holdReady && !isDragging) { 2545 - const dx = Math.abs(e.screenX - holdOriginX); 2546 - const dy = Math.abs(e.screenY - holdOriginY); 2547 - if (dx > JITTER_TOLERANCE || dy > JITTER_TOLERANCE) { 2548 - cancelHold(); 2549 - positionPromise = null; 2550 - console.log('[DRAG] hold cancelled — mouse moved during hold period'); 2551 - return; 2552 - } 2553 - } 2554 - 2555 - // Hold timer fired (cursor is 'grab') — engage drag once movement exceeds threshold 2556 - if (holdReady && !isDragging) { 2557 - const dx = e.screenX - dragOriginX; 2558 - const dy = e.screenY - dragOriginY; 2559 - if (Math.abs(dx) < MOVE_THRESHOLD && Math.abs(dy) < MOVE_THRESHOLD) return; 2560 - 2561 - holdReady = false; 2562 - isDragging = true; 2563 - document.body.classList.remove('hold-ready'); 2564 - document.body.classList.add('is-dragging'); 2565 - window.getSelection()?.removeAllRanges(); 2566 - document.body.style.userSelect = 'none'; 2567 - document.body.style.webkitUserSelect = 'none'; 2568 - console.log('[DRAG] ENGAGED after movement'); 2569 - } 2570 - 2571 - if (!isDragging) return; 2572 - 2573 - // Compute new window position 2574 - const deltaX = e.screenX - dragOriginX; 2575 - const deltaY = e.screenY - dragOriginY; 2576 - rafX = windowOriginX + deltaX; 2577 - rafY = windowOriginY + deltaY; 2578 - 2579 - // Batch via requestAnimationFrame for smoothness 2580 - if (!rafPending) { 2581 - rafPending = true; 2582 - requestAnimationFrame(() => { 2583 - rafPending = false; 2584 - if (isDragging && cachedWindowId) { 2585 - ipcRenderer.invoke('window-move', { id: cachedWindowId, x: rafX, y: rafY }); 2586 - } 2587 - }); 2588 - } 2589 - }; 2590 - 2591 - const onMouseUp = (e) => { 2592 - if (e.button !== 0) return; 2593 - mouseButtonDown = false; 2594 - 2595 - if (isDragging) { 2596 - console.log('[DRAG] mouseup ending drag'); 2597 - } 2598 - endDrag('mouseup'); 2599 - }; 2600 - 2601 - /** Suppress click events that fire after a drag. */ 2602 - const onClickCapture = (e) => { 2603 - if (suppressClick) { 2604 - suppressClick = false; 2605 - e.stopImmediatePropagation(); 2606 - e.preventDefault(); 2607 - console.log('[DRAG] suppressed post-drag click on', e.target?.tagName); 2608 - } 2609 - }; 2610 - 2611 - /** End drag if window loses focus — but only cancel the hold timer, not an active drag. 2612 - * During fast dragging the cursor can momentarily leave the window, firing blur. 2613 - * An active drag should only end on mouseup. */ 2614 - const onBlur = () => { 2615 - cancelHold(); 2616 - // Don't cancel an active drag on blur — the window is moving under the cursor 2617 - // and blur fires spuriously during fast drags. mouseup will end the drag. 2618 - }; 2619 - 2620 - // ------------------------------------------------------------------------- 2621 - // Initialization 2622 - // ------------------------------------------------------------------------- 2623 - const init = async () => { 2624 - try { 2625 - cachedWindowId = await ipcRenderer.invoke('get-window-id'); 2626 - } catch (err) { 2627 - console.error('[DRAG] failed to get window ID:', err); 2628 - } 2629 - 2630 - // Read drag hold delay from prefs (in seconds, convert to ms) 2631 - try { 2632 - const prefs = await ipcRenderer.invoke('get-app-prefs'); 2633 - if (prefs && typeof prefs.dragHoldDelay === 'number') { 2634 - HOLD_DELAY = Math.max(0, prefs.dragHoldDelay * 1000); 2635 - } 2636 - } catch (err) { 2637 - console.warn('[DRAG] failed to read prefs, using default hold delay:', err); 2638 - } 2639 - 2640 - // Inject drag cursor styles with !important to override element-level cursor rules 2641 - const dragStyle = document.createElement('style'); 2642 - dragStyle.textContent = ` 2643 - body.hold-ready, body.hold-ready * { cursor: grab !important; } 2644 - body.is-dragging, body.is-dragging * { cursor: grabbing !important; } 2645 - `; 2646 - document.head.appendChild(dragStyle); 2647 - 2648 - // Capture-phase on document for mousedown (need e.target for shouldBlockDrag). 2649 - // Capture-phase on window for mousemove/mouseup (survive fast cursor escape). 2650 - document.addEventListener('mousedown', onMouseDown, true); 2651 - window.addEventListener('mousemove', onMouseMove, true); 2652 - window.addEventListener('mouseup', onMouseUp, true); 2653 - window.addEventListener('blur', onBlur); 2654 - document.addEventListener('click', onClickCapture, true); 2655 - 2656 - console.log('[DRAG] initialized, windowId:', cachedWindowId, 'holdDelay:', HOLD_DELAY + 'ms'); 2657 - }; 2658 - 2659 - if (document.readyState === 'loading') { 2660 - document.addEventListener('DOMContentLoaded', init); 2661 - } else { 2662 - init(); 2663 - } 2664 - })(); 2665 - 2666 - 2667 - /* 2668 - const handleMainWindow = () => { 2669 - window.addEventListener('load', () => { 2670 - const replaceText = (selector, text) => { 2671 - const element = document.getElementById(selector) 2672 - if (element) element.innerText = text 2673 - } 2674 - 2675 - for (const dependency of ['chrome', 'node', 'electron']) { 2676 - replaceText(`${dependency}-version`, process.versions[dependency]) 2677 - } 2678 - }); 2679 - }; 2680 - */ 2681 - 2682 - /* 2683 - window.addEventListener('DOMContentLoaded', () => { 2684 - const replaceText = (selector, text) => { 2685 - const element = document.getElementById(selector) 2686 - if (element) element.innerText = text 2687 - } 2688 - 2689 - for (const dependency of ['chrome', 'node', 'electron']) { 2690 - replaceText(`${dependency}-version`, process.versions[dependency]) 2691 - } 2692 - }) 2693 - */