experiments in a post-browser web
at main 610 lines 20 kB view raw
1/** 2 * Cmd Extension Background Script 3 * 4 * Command palette for quick command access via keyboard shortcut. 5 * 6 * Implements the PROVIDER pattern for extension-to-extension APIs: 7 * - Owns the command registry 8 * - Subscribes to cmd:register, cmd:unregister for command management 9 * 10 * Runs in isolated extension process (peek://ext/cmd/background.html) 11 */ 12 13import { id, labels, schemas, storageKeys, defaults } from './config.js'; 14import { log } from 'peek://app/log.js'; 15import { generateCommandsFromNoun, validateNounDef } from './noun-registry.js'; 16 17const api = window.app; 18 19log('ext:cmd', 'background', labels.name); 20 21// ===== Command Registry (PROVIDER PATTERN) ===== 22// This extension owns the command registry. Other extensions register 23// commands by publishing to cmd:register, and we store them here. 24const commandRegistry = new Map(); 25 26// Noun registry — stores noun metadata for regenerating commands 27const nounRegistry = new Map(); 28 29// Track commands that were freshly registered by live extensions (not from cache) 30const liveRegisteredCommands = new Set(); 31 32// Track registered shortcut for cleanup 33let registeredShortcut = null; 34 35// Panel window address 36const panelAddress = 'peek://ext/cmd/panel.html'; 37 38// In-memory settings cache 39let currentSettings = { 40 prefs: defaults.prefs 41}; 42 43/** 44 * Load settings from datastore 45 */ 46const loadSettings = async () => { 47 const result = await api.settings.get(); 48 if (result.success && result.data) { 49 return { 50 prefs: result.data.prefs || defaults.prefs 51 }; 52 } 53 return { prefs: defaults.prefs }; 54}; 55 56/** 57 * Save settings to datastore 58 */ 59const saveSettings = async (settings) => { 60 const result = await api.settings.set(settings); 61 if (!result.success) { 62 log.error('ext:cmd', 'Failed to save settings:', result.error); 63 } 64}; 65 66// ===== Command Registry Cache ===== 67// Cache command metadata to avoid re-registration overhead when versions match 68 69/** 70 * Load command cache from datastore 71 * @returns {Promise<{appVersion: string, extensionVersions: Object, commands: Array} | null>} 72 */ 73const loadCommandCache = async () => { 74 try { 75 const result = await api.datastore.getRow('feature_settings', `cmd:command_cache`); 76 if (result.success && result.data && result.data.value) { 77 const cache = JSON.parse(result.data.value); 78 log('ext:cmd', 'Loaded command cache:', cache.commands?.length, 'commands'); 79 return cache; 80 } 81 } catch (err) { 82 log.error('ext:cmd', 'Failed to load command cache:', err); 83 } 84 return null; 85}; 86 87/** 88 * Save command cache to datastore 89 * @param {string} appVersion - Current app version 90 * @param {Object} extensionVersions - Map of extension ID to version 91 */ 92const saveCommandCache = async (appVersion, extensionVersions) => { 93 try { 94 const commands = Array.from(commandRegistry.values()).map(cmd => { 95 const entry = { 96 name: cmd.name, 97 description: cmd.description, 98 source: cmd.source, 99 scope: cmd.scope || 'global', 100 modes: cmd.modes || [], 101 hasCanExecute: cmd.hasCanExecute || false, 102 accepts: cmd.accepts, 103 produces: cmd.produces, 104 params: cmd.params || [] 105 }; 106 // Preserve noun routing metadata in cache 107 if (cmd._nounName) entry._nounName = cmd._nounName; 108 if (cmd._nounCapability) entry._nounCapability = cmd._nounCapability; 109 return entry; 110 }); 111 112 const cache = { 113 appVersion, 114 extensionVersions, 115 commands, 116 nouns: Array.from(nounRegistry.values()), 117 cachedAt: Date.now() 118 }; 119 120 await api.datastore.setRow('feature_settings', 'cmd:command_cache', { 121 featureId: 'cmd', 122 key: 'command_cache', 123 value: JSON.stringify(cache), 124 updatedAt: Date.now() 125 }); 126 127 log('ext:cmd', 'Saved command cache:', commands.length, 'commands'); 128 } catch (err) { 129 log.error('ext:cmd', 'Failed to save command cache:', err); 130 } 131}; 132 133/** 134 * Get current app and extension versions 135 * @returns {Promise<{appVersion: string, extensionVersions: Object}>} 136 */ 137const getCurrentVersions = async () => { 138 const appInfo = await api.app.getInfo(); 139 const appVersion = appInfo.success ? appInfo.data.version : '0.0.0'; 140 141 const extList = await api.extensions.list(); 142 const extensionVersions = {}; 143 144 if (extList.success && extList.data) { 145 for (const ext of extList.data) { 146 if (ext.manifest?.version) { 147 extensionVersions[ext.id] = ext.manifest.version; 148 } 149 } 150 } 151 152 return { appVersion, extensionVersions }; 153}; 154 155/** 156 * Check if cache is valid by comparing versions 157 * @param {Object} cache - Cached data with versions 158 * @param {string} appVersion - Current app version 159 * @param {Object} extensionVersions - Current extension versions 160 * @returns {boolean} 161 */ 162const isCacheValid = (cache, appVersion, extensionVersions) => { 163 if (!cache) return false; 164 if (cache.appVersion !== appVersion) { 165 log('ext:cmd', 'Cache invalid: app version mismatch', cache.appVersion, '!=', appVersion); 166 return false; 167 } 168 169 // Check if all cached extension versions match 170 const cachedExtIds = Object.keys(cache.extensionVersions || {}); 171 const currentExtIds = Object.keys(extensionVersions); 172 173 // Different set of extensions 174 if (cachedExtIds.length !== currentExtIds.length) { 175 log('ext:cmd', 'Cache invalid: extension count mismatch'); 176 return false; 177 } 178 179 for (const extId of currentExtIds) { 180 if (cache.extensionVersions[extId] !== extensionVersions[extId]) { 181 log('ext:cmd', 'Cache invalid: extension version mismatch for', extId); 182 return false; 183 } 184 } 185 186 return true; 187}; 188 189/** 190 * Initialize the command registry subscriptions (PROVIDER PATTERN) 191 * 192 * This sets up the cmd extension as the owner of the command API. 193 * Other extensions (consumers) communicate via pubsub: 194 * - cmd:register - Consumer registers a command 195 * - cmd:unregister - Consumer unregisters a command 196 * - cmd:query-commands - Panel queries for all registered commands 197 */ 198const initCommandRegistry = () => { 199 // Handle batch command registrations (from preload batching) 200 api.subscribe('cmd:register-batch', (msg) => { 201 if (!msg.commands || !Array.isArray(msg.commands)) return; 202 203 log('ext:cmd', 'cmd:register-batch received:', msg.commands.length, 'commands'); 204 205 for (const cmd of msg.commands) { 206 const entry = { 207 name: cmd.name, 208 description: cmd.description || '', 209 source: cmd.source, 210 // Scope: 'global' (app-wide), 'window' (target window), 'page' (page content) 211 scope: cmd.scope || 'global', 212 // Required major modes for command availability (empty = available in all modes) 213 modes: cmd.modes || [], 214 // Whether command has a canExecute guard 215 hasCanExecute: cmd.hasCanExecute || false, 216 // Connector metadata for chaining 217 accepts: cmd.accepts || [], 218 produces: cmd.produces || [], 219 // Parameter definitions for completions 220 params: cmd.params || [] 221 }; 222 // Preserve noun routing metadata for proxy dispatch 223 if (cmd._nounName) entry._nounName = cmd._nounName; 224 if (cmd._nounCapability) entry._nounCapability = cmd._nounCapability; 225 commandRegistry.set(cmd.name, entry); 226 liveRegisteredCommands.add(cmd.name); 227 } 228 }, api.scopes.GLOBAL); 229 230 // Handle individual command registrations from extensions 231 api.subscribe('cmd:register', (msg) => { 232 log('ext:cmd', 'cmd:register received:', msg.name); 233 const entry = { 234 name: msg.name, 235 description: msg.description || '', 236 source: msg.source, 237 // Scope: 'global' (app-wide), 'window' (target window), 'page' (page content) 238 scope: msg.scope || 'global', 239 // Required major modes for command availability 240 modes: msg.modes || [], 241 // Whether command has a canExecute guard 242 hasCanExecute: msg.hasCanExecute || false, 243 // Connector metadata for chaining 244 accepts: msg.accepts || [], // MIME types this command accepts as input 245 produces: msg.produces || [], // MIME types this command produces as output 246 // Parameter definitions for completions 247 params: msg.params || [] 248 }; 249 // Preserve noun routing metadata for proxy dispatch 250 if (msg._nounName) entry._nounName = msg._nounName; 251 if (msg._nounCapability) entry._nounCapability = msg._nounCapability; 252 commandRegistry.set(msg.name, entry); 253 liveRegisteredCommands.add(msg.name); 254 }, api.scopes.GLOBAL); 255 256 // Handle command unregistrations 257 api.subscribe('cmd:unregister', (msg) => { 258 log('ext:cmd', 'cmd:unregister received:', msg.name); 259 commandRegistry.delete(msg.name); 260 }, api.scopes.GLOBAL); 261 262 // ===== Noun Registration Handlers ===== 263 264 // Handle batch noun registrations from extensions 265 api.subscribe('noun:register-batch', (msg) => { 266 if (!msg.nouns || !Array.isArray(msg.nouns)) return; 267 268 log('ext:cmd', 'noun:register-batch received:', msg.nouns.length, 'nouns'); 269 270 const generatedCommands = []; 271 272 for (const nounDef of msg.nouns) { 273 const validation = validateNounDef(nounDef); 274 if (!validation.valid) { 275 log.error('ext:cmd', 'Invalid noun definition:', nounDef.name, validation.error); 276 continue; 277 } 278 279 // Store noun metadata 280 nounRegistry.set(nounDef.name, nounDef); 281 282 // Generate commands from noun definition 283 const commands = generateCommandsFromNoun(nounDef); 284 for (const cmd of commands) { 285 commandRegistry.set(cmd.name, cmd); 286 liveRegisteredCommands.add(cmd.name); 287 generatedCommands.push(cmd); 288 } 289 290 log('ext:cmd', 'Noun registered:', nounDef.name, '→', commands.map(c => c.name).join(', ')); 291 } 292 293 // Broadcast generated commands to panel via existing cmd:register-batch flow 294 if (generatedCommands.length > 0) { 295 api.publish('cmd:register-batch', { commands: generatedCommands }, api.scopes.GLOBAL); 296 } 297 }, api.scopes.GLOBAL); 298 299 // Handle noun unregistrations 300 api.subscribe('noun:unregister', (msg) => { 301 if (!msg.name) return; 302 303 const nounDef = nounRegistry.get(msg.name); 304 if (!nounDef) return; 305 306 log('ext:cmd', 'Noun unregistering:', msg.name); 307 308 // Regenerate command names from noun metadata to know what to remove 309 const commands = generateCommandsFromNoun(nounDef); 310 for (const cmd of commands) { 311 commandRegistry.delete(cmd.name); 312 liveRegisteredCommands.delete(cmd.name); 313 api.publish('cmd:unregister', { name: cmd.name }, api.scopes.GLOBAL); 314 } 315 316 nounRegistry.delete(msg.name); 317 }, api.scopes.GLOBAL); 318 319 // Handle command list queries from the panel 320 api.subscribe('cmd:query-commands', () => { 321 const commands = Array.from(commandRegistry.values()); 322 log('ext:cmd', 'cmd:query-commands received'); 323 api.publish('cmd:query-commands-response', { commands }, api.scopes.GLOBAL); 324 }, api.scopes.GLOBAL); 325 326 log('ext:cmd', 'Command registry initialized'); 327}; 328 329/** 330 * Open the command panel window 331 */ 332const openPanelWindow = (prefs) => { 333 // Initial height just for the command bar (~50px visible) 334 // Window will resize when results appear 335 const initialHeight = 60; 336 const maxHeight = prefs.height || 400; 337 const width = prefs.width || 600; 338 339 const params = { 340 // IZUI role 341 role: 'palette', 342 343 debug: log.debug, 344 key: panelAddress, 345 height: initialHeight, 346 maxHeight, 347 width, 348 349 // Keep resident in the background 350 keepLive: true, 351 352 // Completely remove window frame and decorations 353 frame: false, 354 transparent: true, 355 356 // Make sure the window stays on top 357 alwaysOnTop: true, 358 359 // Center the window (works correctly with small initial height) 360 center: true, 361 362 // Set a reasonable minimum size 363 minWidth: 400, 364 minHeight: 50, 365 366 // Make sure shadows are shown for visual appearance 367 hasShadow: true, 368 369 // Additional window behavior options 370 skipTaskbar: true, 371 resizable: false, 372 fullscreenable: false, 373 374 // Modal behavior 375 modal: true, 376 type: 'panel', 377 378 openDevTools: log.debug, 379 detachedDevTools: true, 380 }; 381 382 api.window.open(panelAddress, params) 383 .then(result => { 384 log('ext:cmd', 'Command window opened:', result); 385 }) 386 .catch(error => { 387 log.error('ext:cmd', 'Failed to open command window:', error); 388 }); 389}; 390 391/** 392 * Register shortcuts: global (Option+Space) and local (Cmd+K) 393 */ 394const LOCAL_SHORTCUT = 'CommandOrControl+K'; 395const URL_MODE_SHORTCUT = 'CommandOrControl+L'; 396 397const initShortcut = (prefs) => { 398 if (registeredShortcut) { 399 api.shortcuts.unregister(registeredShortcut, { global: true }); 400 api.shortcuts.unregister(registeredShortcut); 401 } 402 api.shortcuts.unregister(LOCAL_SHORTCUT); 403 api.shortcuts.unregister(URL_MODE_SHORTCUT); 404 405 registeredShortcut = prefs.shortcutKey; 406 api.shortcuts.register(prefs.shortcutKey, () => { 407 openPanelWindow(prefs); 408 }, { global: true }); 409 410 // Also register as local so it works on Linux Wayland where global shortcuts may fail 411 api.shortcuts.register(prefs.shortcutKey, () => { 412 openPanelWindow(prefs); 413 }); 414 415 // Local shortcut (Cmd+K) — works when a Peek window is focused 416 api.shortcuts.register(LOCAL_SHORTCUT, () => { 417 openPanelWindow(prefs); 418 }); 419 420 // URL mode shortcut (Cmd+L) — opens panel in URL-only navigation mode 421 // Page host windows have their own Cmd+L handler for the floating navbar. 422 // main.ts skips local shortcut dispatch for Cmd+L on page host windows, 423 // so this only fires from non-page windows. 424 api.shortcuts.register(URL_MODE_SHORTCUT, () => { 425 api.publish('cmd:url-mode', {}, api.scopes.GLOBAL); 426 openPanelWindow(prefs); 427 }); 428 429 log('ext:cmd', 'Registered shortcuts:', prefs.shortcutKey, '(global),', LOCAL_SHORTCUT, '(local),', URL_MODE_SHORTCUT, '(url-mode)'); 430}; 431 432/** 433 * Unregister shortcut and clean up 434 */ 435const uninit = () => { 436 log('ext:cmd', 'uninit'); 437 438 if (registeredShortcut) { 439 api.shortcuts.unregister(registeredShortcut, { global: true }); 440 api.shortcuts.unregister(registeredShortcut); 441 registeredShortcut = null; 442 } 443 api.shortcuts.unregister(LOCAL_SHORTCUT); 444 api.shortcuts.unregister(URL_MODE_SHORTCUT); 445 446 // Note: We don't clear the command registry here because other extensions 447 // may still be running. The registry will be rebuilt on next init. 448}; 449 450/** 451 * Reinitialize (called when settings change) 452 */ 453const reinit = async () => { 454 log('ext:cmd', 'reinit'); 455 456 // Unregister old shortcuts 457 if (registeredShortcut) { 458 api.shortcuts.unregister(registeredShortcut, { global: true }); 459 registeredShortcut = null; 460 } 461 api.shortcuts.unregister(LOCAL_SHORTCUT); 462 api.shortcuts.unregister(URL_MODE_SHORTCUT); 463 464 // Load new settings and re-register 465 currentSettings = await loadSettings(); 466 initShortcut(currentSettings.prefs); 467}; 468 469/** 470 * Initialize the extension 471 */ 472const init = async () => { 473 log('ext:cmd', 'init'); 474 475 // 1. Initialize command registry subscriptions FIRST 476 // This ensures we're ready to receive registrations from other extensions 477 initCommandRegistry(); 478 479 // 1b. Load cached commands if versions match 480 // This pre-populates the registry so panel can open immediately 481 // Note: full version validation (isCacheValid) runs at ext:all-loaded when all 482 // extension versions are available. Here we do a quick app-version check to 483 // reject clearly stale caches without blocking startup. 484 const cache = await loadCommandCache(); 485 const appInfo = await api.app.getInfo(); 486 const currentAppVersion = appInfo.success ? appInfo.data.version : '0.0.0'; 487 const cacheAppVersionMatch = cache && cache.appVersion === currentAppVersion; 488 if (cache && cache.commands && cacheAppVersionMatch) { 489 // Pre-populate from cache (will be updated by fresh registrations) 490 for (const cmd of cache.commands) { 491 const entry = { 492 name: cmd.name, 493 description: cmd.description || '', 494 source: cmd.source, 495 scope: cmd.scope || 'global', 496 modes: cmd.modes || [], 497 hasCanExecute: cmd.hasCanExecute || false, 498 accepts: cmd.accepts || [], 499 produces: cmd.produces || [], 500 params: cmd.params || [] 501 }; 502 // Restore noun routing metadata from cache 503 if (cmd._nounName) entry._nounName = cmd._nounName; 504 if (cmd._nounCapability) entry._nounCapability = cmd._nounCapability; 505 commandRegistry.set(cmd.name, entry); 506 } 507 508 // Restore cached nouns and regenerate their commands 509 if (cache.nouns) { 510 for (const nounDef of cache.nouns) { 511 nounRegistry.set(nounDef.name, nounDef); 512 const nounCmds = generateCommandsFromNoun(nounDef); 513 for (const cmd of nounCmds) { 514 commandRegistry.set(cmd.name, cmd); 515 } 516 } 517 log('ext:cmd', 'Restored', cache.nouns.length, 'nouns from cache'); 518 } 519 520 log('ext:cmd', 'Pre-populated registry from cache:', commandRegistry.size, 'commands'); 521 } else if (cache) { 522 log('ext:cmd', 'Cache invalid, discarding stale commands'); 523 } 524 525 // 2. Load settings from datastore 526 currentSettings = await loadSettings(); 527 528 // 3. Register the global shortcut 529 initShortcut(currentSettings.prefs); 530 531 // 3b. Register built-in commands 532 api.commands.register({ 533 name: 'devtools', 534 description: 'Open devtools for last active content window', 535 execute: async () => { 536 const result = await api.window.devtools(); 537 if (result.success) { 538 log('ext:cmd', 'Opened devtools for:', result.url); 539 } else { 540 log.error('ext:cmd', 'Failed to open devtools:', result.error); 541 } 542 } 543 }); 544 545 // 4. Listen for settings changes to hot-reload 546 api.subscribe('cmd:settings-changed', () => { 547 log('ext:cmd', 'settings changed, reinitializing'); 548 reinit(); 549 }, api.scopes.GLOBAL); 550 551 // 4b. Save command cache after all extensions have loaded 552 api.subscribe('ext:all-loaded', async () => { 553 log('ext:cmd', 'ext:all-loaded - saving command cache'); 554 // Small delay to ensure all commands are registered 555 setTimeout(async () => { 556 // Purge stale cached commands that were NOT re-registered by live extensions 557 const stale = []; 558 for (const name of commandRegistry.keys()) { 559 if (!liveRegisteredCommands.has(name)) { 560 stale.push(name); 561 } 562 } 563 for (const name of stale) { 564 log('ext:cmd', 'Purging stale cached command:', name); 565 commandRegistry.delete(name); 566 // Notify panel so it removes the command from its local registry 567 api.publish('cmd:unregister', { name }, api.scopes.GLOBAL); 568 } 569 570 const { appVersion, extensionVersions } = await getCurrentVersions(); 571 await saveCommandCache(appVersion, extensionVersions); 572 }, 100); 573 }, api.scopes.GLOBAL); 574 575 // Listen for settings updates from Settings UI 576 api.subscribe('cmd:settings-update', async (msg) => { 577 log('ext:cmd', 'settings-update received:', msg); 578 579 try { 580 if (msg.data) { 581 currentSettings = { 582 prefs: msg.data.prefs || currentSettings.prefs 583 }; 584 } else if (msg.key === 'prefs' && msg.path) { 585 const field = msg.path.split('.')[1]; 586 if (field) { 587 currentSettings.prefs = { ...currentSettings.prefs, [field]: msg.value }; 588 } 589 } 590 591 await saveSettings(currentSettings); 592 await reinit(); 593 594 api.publish('cmd:settings-changed', currentSettings, api.scopes.GLOBAL); 595 } catch (err) { 596 log.error('ext:cmd', 'settings-update error:', err); 597 } 598 }, api.scopes.GLOBAL); 599 600}; 601 602export default { 603 defaults, 604 id, 605 init, 606 uninit, 607 labels, 608 schemas, 609 storageKeys 610};