/** * Cmd Extension Background Script * * Command palette for quick command access via keyboard shortcut. * * Implements the PROVIDER pattern for extension-to-extension APIs: * - Owns the command registry * - Subscribes to cmd:register, cmd:unregister for command management * * Runs in isolated extension process (peek://ext/cmd/background.html) */ import { id, labels, schemas, storageKeys, defaults } from './config.js'; import { log } from 'peek://app/log.js'; import { generateCommandsFromNoun, validateNounDef } from './noun-registry.js'; const api = window.app; log('ext:cmd', 'background', labels.name); // ===== Command Registry (PROVIDER PATTERN) ===== // This extension owns the command registry. Other extensions register // commands by publishing to cmd:register, and we store them here. const commandRegistry = new Map(); // Noun registry — stores noun metadata for regenerating commands const nounRegistry = new Map(); // Track commands that were freshly registered by live extensions (not from cache) const liveRegisteredCommands = new Set(); // Track registered shortcut for cleanup let registeredShortcut = null; // Panel window address const panelAddress = 'peek://ext/cmd/panel.html'; // In-memory settings cache let currentSettings = { prefs: defaults.prefs }; /** * Load settings from datastore */ const loadSettings = async () => { const result = await api.settings.get(); if (result.success && result.data) { return { prefs: result.data.prefs || defaults.prefs }; } return { prefs: defaults.prefs }; }; /** * Save settings to datastore */ const saveSettings = async (settings) => { const result = await api.settings.set(settings); if (!result.success) { log.error('ext:cmd', 'Failed to save settings:', result.error); } }; // ===== Command Registry Cache ===== // Cache command metadata to avoid re-registration overhead when versions match /** * Load command cache from datastore * @returns {Promise<{appVersion: string, extensionVersions: Object, commands: Array} | null>} */ const loadCommandCache = async () => { try { const result = await api.datastore.getRow('feature_settings', `cmd:command_cache`); if (result.success && result.data && result.data.value) { const cache = JSON.parse(result.data.value); log('ext:cmd', 'Loaded command cache:', cache.commands?.length, 'commands'); return cache; } } catch (err) { log.error('ext:cmd', 'Failed to load command cache:', err); } return null; }; /** * Save command cache to datastore * @param {string} appVersion - Current app version * @param {Object} extensionVersions - Map of extension ID to version */ const saveCommandCache = async (appVersion, extensionVersions) => { try { const commands = Array.from(commandRegistry.values()).map(cmd => { const entry = { name: cmd.name, description: cmd.description, source: cmd.source, scope: cmd.scope || 'global', modes: cmd.modes || [], hasCanExecute: cmd.hasCanExecute || false, accepts: cmd.accepts, produces: cmd.produces, params: cmd.params || [] }; // Preserve noun routing metadata in cache if (cmd._nounName) entry._nounName = cmd._nounName; if (cmd._nounCapability) entry._nounCapability = cmd._nounCapability; return entry; }); const cache = { appVersion, extensionVersions, commands, nouns: Array.from(nounRegistry.values()), cachedAt: Date.now() }; await api.datastore.setRow('feature_settings', 'cmd:command_cache', { featureId: 'cmd', key: 'command_cache', value: JSON.stringify(cache), updatedAt: Date.now() }); log('ext:cmd', 'Saved command cache:', commands.length, 'commands'); } catch (err) { log.error('ext:cmd', 'Failed to save command cache:', err); } }; /** * Get current app and extension versions * @returns {Promise<{appVersion: string, extensionVersions: Object}>} */ const getCurrentVersions = async () => { const appInfo = await api.app.getInfo(); const appVersion = appInfo.success ? appInfo.data.version : '0.0.0'; const extList = await api.extensions.list(); const extensionVersions = {}; if (extList.success && extList.data) { for (const ext of extList.data) { if (ext.manifest?.version) { extensionVersions[ext.id] = ext.manifest.version; } } } return { appVersion, extensionVersions }; }; /** * Check if cache is valid by comparing versions * @param {Object} cache - Cached data with versions * @param {string} appVersion - Current app version * @param {Object} extensionVersions - Current extension versions * @returns {boolean} */ const isCacheValid = (cache, appVersion, extensionVersions) => { if (!cache) return false; if (cache.appVersion !== appVersion) { log('ext:cmd', 'Cache invalid: app version mismatch', cache.appVersion, '!=', appVersion); return false; } // Check if all cached extension versions match const cachedExtIds = Object.keys(cache.extensionVersions || {}); const currentExtIds = Object.keys(extensionVersions); // Different set of extensions if (cachedExtIds.length !== currentExtIds.length) { log('ext:cmd', 'Cache invalid: extension count mismatch'); return false; } for (const extId of currentExtIds) { if (cache.extensionVersions[extId] !== extensionVersions[extId]) { log('ext:cmd', 'Cache invalid: extension version mismatch for', extId); return false; } } return true; }; /** * Initialize the command registry subscriptions (PROVIDER PATTERN) * * This sets up the cmd extension as the owner of the command API. * Other extensions (consumers) communicate via pubsub: * - cmd:register - Consumer registers a command * - cmd:unregister - Consumer unregisters a command * - cmd:query-commands - Panel queries for all registered commands */ const initCommandRegistry = () => { // Handle batch command registrations (from preload batching) api.subscribe('cmd:register-batch', (msg) => { if (!msg.commands || !Array.isArray(msg.commands)) return; log('ext:cmd', 'cmd:register-batch received:', msg.commands.length, 'commands'); for (const cmd of msg.commands) { const entry = { name: cmd.name, description: cmd.description || '', source: cmd.source, // Scope: 'global' (app-wide), 'window' (target window), 'page' (page content) scope: cmd.scope || 'global', // Required major modes for command availability (empty = available in all modes) modes: cmd.modes || [], // Whether command has a canExecute guard hasCanExecute: cmd.hasCanExecute || false, // Connector metadata for chaining accepts: cmd.accepts || [], produces: cmd.produces || [], // Parameter definitions for completions params: cmd.params || [] }; // Preserve noun routing metadata for proxy dispatch if (cmd._nounName) entry._nounName = cmd._nounName; if (cmd._nounCapability) entry._nounCapability = cmd._nounCapability; commandRegistry.set(cmd.name, entry); liveRegisteredCommands.add(cmd.name); } }, api.scopes.GLOBAL); // Handle individual command registrations from extensions api.subscribe('cmd:register', (msg) => { log('ext:cmd', 'cmd:register received:', msg.name); const entry = { name: msg.name, description: msg.description || '', source: msg.source, // Scope: 'global' (app-wide), 'window' (target window), 'page' (page content) scope: msg.scope || 'global', // Required major modes for command availability modes: msg.modes || [], // Whether command has a canExecute guard hasCanExecute: msg.hasCanExecute || false, // Connector metadata for chaining accepts: msg.accepts || [], // MIME types this command accepts as input produces: msg.produces || [], // MIME types this command produces as output // Parameter definitions for completions params: msg.params || [] }; // Preserve noun routing metadata for proxy dispatch if (msg._nounName) entry._nounName = msg._nounName; if (msg._nounCapability) entry._nounCapability = msg._nounCapability; commandRegistry.set(msg.name, entry); liveRegisteredCommands.add(msg.name); }, api.scopes.GLOBAL); // Handle command unregistrations api.subscribe('cmd:unregister', (msg) => { log('ext:cmd', 'cmd:unregister received:', msg.name); commandRegistry.delete(msg.name); }, api.scopes.GLOBAL); // ===== Noun Registration Handlers ===== // Handle batch noun registrations from extensions api.subscribe('noun:register-batch', (msg) => { if (!msg.nouns || !Array.isArray(msg.nouns)) return; log('ext:cmd', 'noun:register-batch received:', msg.nouns.length, 'nouns'); const generatedCommands = []; for (const nounDef of msg.nouns) { const validation = validateNounDef(nounDef); if (!validation.valid) { log.error('ext:cmd', 'Invalid noun definition:', nounDef.name, validation.error); continue; } // Store noun metadata nounRegistry.set(nounDef.name, nounDef); // Generate commands from noun definition const commands = generateCommandsFromNoun(nounDef); for (const cmd of commands) { commandRegistry.set(cmd.name, cmd); liveRegisteredCommands.add(cmd.name); generatedCommands.push(cmd); } log('ext:cmd', 'Noun registered:', nounDef.name, '→', commands.map(c => c.name).join(', ')); } // Broadcast generated commands to panel via existing cmd:register-batch flow if (generatedCommands.length > 0) { api.publish('cmd:register-batch', { commands: generatedCommands }, api.scopes.GLOBAL); } }, api.scopes.GLOBAL); // Handle noun unregistrations api.subscribe('noun:unregister', (msg) => { if (!msg.name) return; const nounDef = nounRegistry.get(msg.name); if (!nounDef) return; log('ext:cmd', 'Noun unregistering:', msg.name); // Regenerate command names from noun metadata to know what to remove const commands = generateCommandsFromNoun(nounDef); for (const cmd of commands) { commandRegistry.delete(cmd.name); liveRegisteredCommands.delete(cmd.name); api.publish('cmd:unregister', { name: cmd.name }, api.scopes.GLOBAL); } nounRegistry.delete(msg.name); }, api.scopes.GLOBAL); // Handle command list queries from the panel api.subscribe('cmd:query-commands', () => { const commands = Array.from(commandRegistry.values()); log('ext:cmd', 'cmd:query-commands received'); api.publish('cmd:query-commands-response', { commands }, api.scopes.GLOBAL); }, api.scopes.GLOBAL); log('ext:cmd', 'Command registry initialized'); }; /** * Open the command panel window */ const openPanelWindow = (prefs) => { // Initial height just for the command bar (~50px visible) // Window will resize when results appear const initialHeight = 60; const maxHeight = prefs.height || 400; const width = prefs.width || 600; const params = { // IZUI role role: 'palette', debug: log.debug, key: panelAddress, height: initialHeight, maxHeight, width, // Keep resident in the background keepLive: true, // Completely remove window frame and decorations frame: false, transparent: true, // Make sure the window stays on top alwaysOnTop: true, // Center the window (works correctly with small initial height) center: true, // Set a reasonable minimum size minWidth: 400, minHeight: 50, // Make sure shadows are shown for visual appearance hasShadow: true, // Additional window behavior options skipTaskbar: true, resizable: false, fullscreenable: false, // Modal behavior modal: true, type: 'panel', openDevTools: log.debug, detachedDevTools: true, }; api.window.open(panelAddress, params) .then(result => { log('ext:cmd', 'Command window opened:', result); }) .catch(error => { log.error('ext:cmd', 'Failed to open command window:', error); }); }; /** * Register shortcuts: global (Option+Space) and local (Cmd+K) */ const LOCAL_SHORTCUT = 'CommandOrControl+K'; const URL_MODE_SHORTCUT = 'CommandOrControl+L'; const initShortcut = (prefs) => { if (registeredShortcut) { api.shortcuts.unregister(registeredShortcut, { global: true }); api.shortcuts.unregister(registeredShortcut); } api.shortcuts.unregister(LOCAL_SHORTCUT); api.shortcuts.unregister(URL_MODE_SHORTCUT); registeredShortcut = prefs.shortcutKey; api.shortcuts.register(prefs.shortcutKey, () => { openPanelWindow(prefs); }, { global: true }); // Also register as local so it works on Linux Wayland where global shortcuts may fail api.shortcuts.register(prefs.shortcutKey, () => { openPanelWindow(prefs); }); // Local shortcut (Cmd+K) — works when a Peek window is focused api.shortcuts.register(LOCAL_SHORTCUT, () => { openPanelWindow(prefs); }); // URL mode shortcut (Cmd+L) — opens panel in URL-only navigation mode // Page host windows have their own Cmd+L handler for the floating navbar. // main.ts skips local shortcut dispatch for Cmd+L on page host windows, // so this only fires from non-page windows. api.shortcuts.register(URL_MODE_SHORTCUT, () => { api.publish('cmd:url-mode', {}, api.scopes.GLOBAL); openPanelWindow(prefs); }); log('ext:cmd', 'Registered shortcuts:', prefs.shortcutKey, '(global),', LOCAL_SHORTCUT, '(local),', URL_MODE_SHORTCUT, '(url-mode)'); }; /** * Unregister shortcut and clean up */ const uninit = () => { log('ext:cmd', 'uninit'); if (registeredShortcut) { api.shortcuts.unregister(registeredShortcut, { global: true }); api.shortcuts.unregister(registeredShortcut); registeredShortcut = null; } api.shortcuts.unregister(LOCAL_SHORTCUT); api.shortcuts.unregister(URL_MODE_SHORTCUT); // Note: We don't clear the command registry here because other extensions // may still be running. The registry will be rebuilt on next init. }; /** * Reinitialize (called when settings change) */ const reinit = async () => { log('ext:cmd', 'reinit'); // Unregister old shortcuts if (registeredShortcut) { api.shortcuts.unregister(registeredShortcut, { global: true }); registeredShortcut = null; } api.shortcuts.unregister(LOCAL_SHORTCUT); api.shortcuts.unregister(URL_MODE_SHORTCUT); // Load new settings and re-register currentSettings = await loadSettings(); initShortcut(currentSettings.prefs); }; /** * Initialize the extension */ const init = async () => { log('ext:cmd', 'init'); // 1. Initialize command registry subscriptions FIRST // This ensures we're ready to receive registrations from other extensions initCommandRegistry(); // 1b. Load cached commands if versions match // This pre-populates the registry so panel can open immediately // Note: full version validation (isCacheValid) runs at ext:all-loaded when all // extension versions are available. Here we do a quick app-version check to // reject clearly stale caches without blocking startup. const cache = await loadCommandCache(); const appInfo = await api.app.getInfo(); const currentAppVersion = appInfo.success ? appInfo.data.version : '0.0.0'; const cacheAppVersionMatch = cache && cache.appVersion === currentAppVersion; if (cache && cache.commands && cacheAppVersionMatch) { // Pre-populate from cache (will be updated by fresh registrations) for (const cmd of cache.commands) { const entry = { name: cmd.name, description: cmd.description || '', source: cmd.source, scope: cmd.scope || 'global', modes: cmd.modes || [], hasCanExecute: cmd.hasCanExecute || false, accepts: cmd.accepts || [], produces: cmd.produces || [], params: cmd.params || [] }; // Restore noun routing metadata from cache if (cmd._nounName) entry._nounName = cmd._nounName; if (cmd._nounCapability) entry._nounCapability = cmd._nounCapability; commandRegistry.set(cmd.name, entry); } // Restore cached nouns and regenerate their commands if (cache.nouns) { for (const nounDef of cache.nouns) { nounRegistry.set(nounDef.name, nounDef); const nounCmds = generateCommandsFromNoun(nounDef); for (const cmd of nounCmds) { commandRegistry.set(cmd.name, cmd); } } log('ext:cmd', 'Restored', cache.nouns.length, 'nouns from cache'); } log('ext:cmd', 'Pre-populated registry from cache:', commandRegistry.size, 'commands'); } else if (cache) { log('ext:cmd', 'Cache invalid, discarding stale commands'); } // 2. Load settings from datastore currentSettings = await loadSettings(); // 3. Register the global shortcut initShortcut(currentSettings.prefs); // 3b. Register built-in commands api.commands.register({ name: 'devtools', description: 'Open devtools for last active content window', execute: async () => { const result = await api.window.devtools(); if (result.success) { log('ext:cmd', 'Opened devtools for:', result.url); } else { log.error('ext:cmd', 'Failed to open devtools:', result.error); } } }); // 4. Listen for settings changes to hot-reload api.subscribe('cmd:settings-changed', () => { log('ext:cmd', 'settings changed, reinitializing'); reinit(); }, api.scopes.GLOBAL); // 4b. Save command cache after all extensions have loaded api.subscribe('ext:all-loaded', async () => { log('ext:cmd', 'ext:all-loaded - saving command cache'); // Small delay to ensure all commands are registered setTimeout(async () => { // Purge stale cached commands that were NOT re-registered by live extensions const stale = []; for (const name of commandRegistry.keys()) { if (!liveRegisteredCommands.has(name)) { stale.push(name); } } for (const name of stale) { log('ext:cmd', 'Purging stale cached command:', name); commandRegistry.delete(name); // Notify panel so it removes the command from its local registry api.publish('cmd:unregister', { name }, api.scopes.GLOBAL); } const { appVersion, extensionVersions } = await getCurrentVersions(); await saveCommandCache(appVersion, extensionVersions); }, 100); }, api.scopes.GLOBAL); // Listen for settings updates from Settings UI api.subscribe('cmd:settings-update', async (msg) => { log('ext:cmd', 'settings-update received:', msg); try { if (msg.data) { currentSettings = { prefs: msg.data.prefs || currentSettings.prefs }; } else if (msg.key === 'prefs' && msg.path) { const field = msg.path.split('.')[1]; if (field) { currentSettings.prefs = { ...currentSettings.prefs, [field]: msg.value }; } } await saveSettings(currentSettings); await reinit(); api.publish('cmd:settings-changed', currentSettings, api.scopes.GLOBAL); } catch (err) { log.error('ext:cmd', 'settings-update error:', err); } }, api.scopes.GLOBAL); }; export default { defaults, id, init, uninit, labels, schemas, storageKeys };