experiments in a post-browser web
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};