experiments in a post-browser web
10
fork

Configure Feed

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

move extensions fully out of core. mostly.

+920 -580
+1 -1
app/config.js
··· 117 117 width: 800, 118 118 startupFeature: 'peek://app/settings/settings.html', 119 119 showTrayIcon: true, 120 - showInDockAndSwitcher: false 120 + showInDockAndSwitcher: true 121 121 }, 122 122 items: [ 123 123 { id: 'cee1225d-40ac-41e5-a34c-e2edba69d599',
+14
app/datastore/schema.js
··· 172 172 lastErrorAt: { type: 'number', default: 0 }, 173 173 lastError: { type: 'string', default: '' }, 174 174 metadata: { type: 'string', default: '{}' } 175 + }, 176 + 177 + // Extension settings storage (replaces localStorage for cross-origin access) 178 + extension_settings: { 179 + extensionId: { type: 'string' }, // Extension ID (e.g., 'peeks', 'slides') 180 + key: { type: 'string' }, // Setting key (e.g., 'prefs', 'items') 181 + value: { type: 'string' }, // JSON stringified value 182 + updatedAt: { type: 'number' } 175 183 } 176 184 }; 177 185 ··· 289 297 extensions_byBuiltin: { 290 298 table: 'extensions', 291 299 on: 'builtin' 300 + }, 301 + 302 + // Extension settings indexes 303 + extension_settings_byExtension: { 304 + table: 'extension_settings', 305 + on: 'extensionId' 292 306 } 293 307 }; 294 308
-337
app/extensions/loader.js
··· 1 - /** 2 - * Extension Loader 3 - * 4 - * Manages extension lifecycle: loading, unloading, and reloading extensions. 5 - * Runs in the core background context (app/index.js). 6 - */ 7 - 8 - const api = window.app; 9 - const debug = api.debug; 10 - 11 - // Track running extensions: id -> { module, manifest, extension } 12 - const runningExtensions = new Map(); 13 - 14 - // Track registered shortnames: shortname -> extension id 15 - const registeredShortnames = new Map(); 16 - 17 - // Reserved shortnames that cannot be used by external extensions 18 - const reservedShortnames = new Set(['app', 'ext', 'extensions', 'settings', 'system']); 19 - 20 - /** 21 - * Fetch and parse an extension's manifest.json 22 - */ 23 - const fetchManifest = async (path) => { 24 - const manifestUrl = `${path}/manifest.json`; 25 - try { 26 - const response = await fetch(manifestUrl); 27 - if (!response.ok) { 28 - throw new Error(`HTTP ${response.status}: ${response.statusText}`); 29 - } 30 - return await response.json(); 31 - } catch (error) { 32 - throw new Error(`Failed to fetch manifest from ${manifestUrl}: ${error.message}`); 33 - } 34 - }; 35 - 36 - /** 37 - * Validate an extension's manifest 38 - * Returns { valid: true } or { valid: false, error: string } 39 - */ 40 - const validateManifest = (manifest, isBuiltin = false) => { 41 - // Required fields 42 - if (!manifest.id) { 43 - return { valid: false, error: 'Missing required field: id' }; 44 - } 45 - if (!manifest.shortname) { 46 - return { valid: false, error: 'Missing required field: shortname' }; 47 - } 48 - if (!manifest.name) { 49 - return { valid: false, error: 'Missing required field: name' }; 50 - } 51 - 52 - // Shortname format validation (alphanumeric, lowercase, hyphens allowed) 53 - if (!/^[a-z0-9-]+$/.test(manifest.shortname)) { 54 - return { valid: false, error: `Invalid shortname format: ${manifest.shortname}. Must be lowercase alphanumeric with hyphens.` }; 55 - } 56 - 57 - // Check for reserved shortnames (only for non-builtin extensions) 58 - if (!isBuiltin && reservedShortnames.has(manifest.shortname)) { 59 - return { valid: false, error: `Shortname '${manifest.shortname}' is reserved and cannot be used.` }; 60 - } 61 - 62 - // Check for shortname conflicts 63 - const existingOwner = registeredShortnames.get(manifest.shortname); 64 - if (existingOwner && existingOwner !== manifest.id) { 65 - return { valid: false, error: `Shortname '${manifest.shortname}' is already registered by extension '${existingOwner}'.` }; 66 - } 67 - 68 - return { valid: true }; 69 - }; 70 - 71 - /** 72 - * List of built-in extensions bundled with the app. 73 - * External extensions will be loaded from the datastore. 74 - */ 75 - export const builtinExtensions = [ 76 - { 77 - id: 'groups', 78 - path: 'peek://ext/groups', 79 - backgroundScript: 'background.js' 80 - }, 81 - { 82 - id: 'peeks', 83 - path: 'peek://ext/peeks', 84 - backgroundScript: 'background.js' 85 - }, 86 - { 87 - id: 'slides', 88 - path: 'peek://ext/slides', 89 - backgroundScript: 'background.js' 90 - } 91 - ]; 92 - 93 - /** 94 - * Load a single extension by dynamically importing its background script 95 - */ 96 - export const loadExtension = async (extension) => { 97 - const { id, path, backgroundScript } = extension; 98 - 99 - if (runningExtensions.has(id)) { 100 - debug && console.log(`[ext:loader] Extension ${id} already running`); 101 - return { success: true, alreadyRunning: true }; 102 - } 103 - 104 - try { 105 - debug && console.log(`[ext:loader] Loading extension: ${id}`); 106 - 107 - // Fetch and validate manifest 108 - const manifest = await fetchManifest(path); 109 - const isBuiltin = manifest.builtin === true; 110 - const validation = validateManifest(manifest, isBuiltin); 111 - 112 - if (!validation.valid) { 113 - console.error(`[ext:loader] Invalid manifest for ${id}: ${validation.error}`); 114 - return { success: false, error: validation.error }; 115 - } 116 - 117 - // Register shortname 118 - registeredShortnames.set(manifest.shortname, id); 119 - debug && console.log(`[ext:loader] Registered shortname '${manifest.shortname}' for ${id}`); 120 - 121 - // Dynamically import the extension's background script 122 - const backgroundUrl = `${path}/${backgroundScript}`; 123 - const module = await import(backgroundUrl); 124 - 125 - // Call init if it exists 126 - if (module.default && typeof module.default.init === 'function') { 127 - module.default.init(); 128 - } 129 - 130 - runningExtensions.set(id, { 131 - module: module.default, 132 - manifest, 133 - extension 134 - }); 135 - 136 - console.log(`[ext:loader] Extension loaded: ${id} (shortname: ${manifest.shortname}, builtin: ${isBuiltin})`); 137 - return { success: true, manifest }; 138 - 139 - } catch (error) { 140 - console.error(`[ext:loader] Failed to load extension ${id}:`, error); 141 - return { success: false, error: error.message }; 142 - } 143 - }; 144 - 145 - /** 146 - * Unload an extension 147 - */ 148 - export const unloadExtension = async (id) => { 149 - const running = runningExtensions.get(id); 150 - if (!running) { 151 - debug && console.log(`[ext:loader] Extension ${id} not running`); 152 - return { success: true, wasRunning: false }; 153 - } 154 - 155 - try { 156 - debug && console.log(`[ext:loader] Unloading extension: ${id}`); 157 - 158 - // Call uninit if it exists 159 - if (running.module && typeof running.module.uninit === 'function') { 160 - running.module.uninit(); 161 - } 162 - 163 - // Unregister shortname 164 - if (running.manifest && running.manifest.shortname) { 165 - registeredShortnames.delete(running.manifest.shortname); 166 - debug && console.log(`[ext:loader] Unregistered shortname '${running.manifest.shortname}'`); 167 - } 168 - 169 - runningExtensions.delete(id); 170 - console.log(`[ext:loader] Extension unloaded: ${id}`); 171 - return { success: true, wasRunning: true }; 172 - 173 - } catch (error) { 174 - console.error(`[ext:loader] Failed to unload extension ${id}:`, error); 175 - return { success: false, error: error.message }; 176 - } 177 - }; 178 - 179 - /** 180 - * Reload an extension (unload + load) 181 - */ 182 - export const reloadExtension = async (id) => { 183 - const running = runningExtensions.get(id); 184 - if (!running) { 185 - console.log(`[ext:loader] Extension ${id} not running, cannot reload`); 186 - return { success: false, error: 'Extension not running' }; 187 - } 188 - 189 - await unloadExtension(id); 190 - return loadExtension(running.extension); 191 - }; 192 - 193 - /** 194 - * Get list of running extensions 195 - */ 196 - export const getRunningExtensions = () => { 197 - return Array.from(runningExtensions.entries()).map(([id, data]) => ({ 198 - id, 199 - manifest: data.manifest, 200 - ...data.extension 201 - })); 202 - }; 203 - 204 - /** 205 - * Check if an extension is running 206 - */ 207 - export const isExtensionRunning = (id) => { 208 - return runningExtensions.has(id); 209 - }; 210 - 211 - /** 212 - * Get extension by shortname 213 - */ 214 - export const getExtensionByShortname = (shortname) => { 215 - const extId = registeredShortnames.get(shortname); 216 - if (!extId) return null; 217 - return runningExtensions.get(extId) || null; 218 - }; 219 - 220 - /** 221 - * Check if a shortname is registered 222 - */ 223 - export const isShortNameRegistered = (shortname) => { 224 - return registeredShortnames.has(shortname); 225 - }; 226 - 227 - /** 228 - * Get manifest for a running extension 229 - */ 230 - export const getExtensionManifest = (id) => { 231 - const running = runningExtensions.get(id); 232 - return running ? running.manifest : null; 233 - }; 234 - 235 - /** 236 - * Load all enabled built-in extensions. 237 - * Called during app initialization. 238 - * 239 - * @param {Function} isFeatureEnabled - Function to check if a feature is enabled 240 - */ 241 - export const loadBuiltinExtensions = async (isFeatureEnabled) => { 242 - console.log('[ext:loader] Loading built-in extensions...'); 243 - 244 - for (const ext of builtinExtensions) { 245 - // Check if this extension's corresponding feature is enabled 246 - if (isFeatureEnabled && !isFeatureEnabled(ext.id)) { 247 - debug && console.log(`[ext:loader] Extension ${ext.id} is disabled, skipping`); 248 - continue; 249 - } 250 - 251 - await loadExtension(ext); 252 - } 253 - 254 - console.log(`[ext:loader] Loaded ${runningExtensions.size} extensions`); 255 - }; 256 - 257 - /** 258 - * Set up pubsub handlers for extension management API. 259 - * This allows other contexts (e.g., settings UI) to manage extensions. 260 - */ 261 - const initApiHandlers = () => { 262 - // Handle ext:list requests 263 - api.subscribe('ext:list', (msg) => { 264 - const extensions = getRunningExtensions(); 265 - api.publish(msg.replyTopic, { 266 - success: true, 267 - data: extensions 268 - }, api.scopes.SYSTEM); 269 - }, api.scopes.SYSTEM); 270 - 271 - // Handle ext:load requests 272 - api.subscribe('ext:load', async (msg) => { 273 - const { id, replyTopic } = msg; 274 - 275 - // Find extension config (check builtin first, then could check datastore for external) 276 - const extConfig = builtinExtensions.find(e => e.id === id); 277 - if (!extConfig) { 278 - api.publish(replyTopic, { 279 - success: false, 280 - error: `Extension not found: ${id}` 281 - }, api.scopes.SYSTEM); 282 - return; 283 - } 284 - 285 - const result = await loadExtension(extConfig); 286 - api.publish(replyTopic, result, api.scopes.SYSTEM); 287 - }, api.scopes.SYSTEM); 288 - 289 - // Handle ext:unload requests 290 - api.subscribe('ext:unload', async (msg) => { 291 - const { id, replyTopic } = msg; 292 - const result = await unloadExtension(id); 293 - api.publish(replyTopic, result, api.scopes.SYSTEM); 294 - }, api.scopes.SYSTEM); 295 - 296 - // Handle ext:reload requests 297 - api.subscribe('ext:reload', async (msg) => { 298 - const { id, replyTopic } = msg; 299 - const result = await reloadExtension(id); 300 - api.publish(replyTopic, result, api.scopes.SYSTEM); 301 - }, api.scopes.SYSTEM); 302 - 303 - // Handle ext:manifest requests 304 - api.subscribe('ext:manifest', (msg) => { 305 - const { id, replyTopic } = msg; 306 - const manifest = getExtensionManifest(id); 307 - if (manifest) { 308 - api.publish(replyTopic, { 309 - success: true, 310 - data: manifest 311 - }, api.scopes.SYSTEM); 312 - } else { 313 - api.publish(replyTopic, { 314 - success: false, 315 - error: `Extension not found or not running: ${id}` 316 - }, api.scopes.SYSTEM); 317 - } 318 - }, api.scopes.SYSTEM); 319 - 320 - console.log('[ext:loader] API handlers initialized'); 321 - }; 322 - 323 - // Initialize API handlers when loader is first imported 324 - initApiHandlers(); 325 - 326 - export default { 327 - builtinExtensions, 328 - loadExtension, 329 - unloadExtension, 330 - reloadExtension, 331 - getRunningExtensions, 332 - isExtensionRunning, 333 - getExtensionByShortname, 334 - isShortNameRegistered, 335 - getExtensionManifest, 336 - loadBuiltinExtensions 337 - };
+29 -7
app/features.js
··· 1 - // features 2 - // Note: groups is now an extension (./extensions/groups/) 1 + /** 2 + * Features Collection 3 + * 4 + * This module provides feature schemas for the Settings UI. 5 + * 6 + * Architecture note (Jan 2025): 7 + * - cmd and scripts are CORE features (run in peek://app context) 8 + * - peeks, slides, groups are EXTENSIONS (run in isolated peek://ext contexts) 9 + * 10 + * Extensions are now loaded by the main process ExtensionManager, not by 11 + * importing them here. However, this module still exports extension schemas 12 + * for the Settings UI to render settings forms. 13 + * 14 + * TODO: Settings UI should load extension schemas from manifest.json instead, 15 + * then app/peeks/ and app/slides/ can be deleted. 16 + */ 17 + 18 + // Core features 3 19 import cmd from './cmd/index.js'; 20 + import scripts from './scripts/index.js'; 21 + 22 + // Extension schemas (for Settings UI only - extensions run in isolated processes) 4 23 import peeks from './peeks/index.js'; 5 - import scripts from './scripts/index.js'; 6 24 import slides from './slides/index.js'; 7 25 8 26 const fc = {}; 9 - fc[cmd.id] = cmd, 10 - fc[peeks.id] = peeks, 11 - fc[scripts.id] = scripts, 12 - fc[slides.id] = slides 27 + 28 + // Core features 29 + fc[cmd.id] = cmd; 30 + fc[scripts.id] = scripts; 31 + 32 + // Extension schemas for Settings UI 33 + fc[peeks.id] = peeks; 34 + fc[slides.id] = slides; 13 35 14 36 export default fc;
+45 -108
app/index.js
··· 3 3 import windowManager from "./windows.js"; 4 4 import api from './api.js'; 5 5 import fc from './features.js'; 6 - import extensionLoader from './extensions/loader.js'; 6 + import migrations from './migrations/index.js'; 7 7 8 8 const { id, labels, schemas, storageKeys, defaults } = appConfig; 9 9 ··· 27 27 const settingsAddress = 'peek://app/settings/settings.html'; 28 28 const topicCorePrefs = 'topic:core:prefs'; 29 29 const topicFeatureToggle = 'core:feature:toggle'; 30 + 31 + // Built-in extensions (now loaded by main process ExtensionManager) 32 + const builtinExtensions = ['groups', 'peeks', 'slides']; 30 33 31 34 let _settingsWin = null; 32 35 ··· 46 49 }; 47 50 48 51 console.log('Opening settings window with params:', params); 49 - 52 + 50 53 try { 51 54 // Use the window creation API from windows.js 52 55 const windowController = await windowManager.createWindow(settingsAddress, params); 53 - 56 + 54 57 console.log('Settings window opened successfully with controller:', windowController); 55 58 _settingsWin = windowController; 56 - 59 + 57 60 // Focus the window to bring it to front 58 61 await windowController.focus(); 59 62 } catch (error) { ··· 73 76 return; 74 77 } 75 78 76 - // Skip extension-based features (they're loaded by the extension loader) 77 - if (f.extension) { 78 - debug && console.log('skipping extension-based feature:', f.name); 79 + // Skip extension-based features (they're loaded by main process ExtensionManager) 80 + const extId = f.name.toLowerCase(); 81 + if (builtinExtensions.includes(extId)) { 82 + debug && console.log('skipping extension-based feature (loaded by main process):', f.name); 79 83 return; 80 84 } 81 85 ··· 116 120 117 121 // Register extension management commands for cmd palette 118 122 const registerExtensionCommands = () => { 119 - // Reload extension command 123 + // Reload extension command (uses main process IPC) 120 124 api.commands.register({ 121 125 name: 'extension reload', 122 126 description: 'Reload an extension by name', ··· 127 131 return; 128 132 } 129 133 130 - // Find extension by name or id (case-insensitive) 131 - const extensions = extensionLoader.getRunningExtensions(); 132 - const ext = extensions.find(e => 133 - e.id.toLowerCase() === extName.toLowerCase() || 134 - (e.manifest?.name || '').toLowerCase() === extName.toLowerCase() 135 - ); 136 - 137 - if (!ext) { 138 - console.log(`extension reload: extension not found: ${extName}`); 139 - return; 140 - } 141 - 142 - console.log(`Reloading extension: ${ext.id}`); 143 - const result = await extensionLoader.reloadExtension(ext.id); 134 + console.log(`Reloading extension: ${extName}`); 135 + const result = await api.extensions.reload(extName.toLowerCase()); 144 136 if (result.success) { 145 - console.log(`Extension reloaded: ${ext.id}`); 137 + console.log(`Extension reloaded: ${extName}`); 146 138 } else { 147 139 console.error(`Failed to reload extension: ${result.error}`); 148 140 } 149 141 } 150 142 }); 151 143 152 - // List extensions command 144 + // List extensions command (uses main process IPC) 153 145 api.commands.register({ 154 146 name: 'extensions', 155 147 description: 'List running extensions', 156 148 execute: async (ctx) => { 157 - const extensions = extensionLoader.getRunningExtensions(); 158 - console.log('Running extensions:'); 159 - extensions.forEach(ext => { 160 - const manifest = ext.manifest || {}; 161 - console.log(` - ${manifest.name || ext.id} (${ext.id}) v${manifest.version || '?'}`); 162 - }); 149 + const listResult = await api.extensions.list(); 150 + if (listResult.success && listResult.data) { 151 + console.log('Running extensions:'); 152 + listResult.data.forEach(ext => { 153 + const manifest = ext.manifest || {}; 154 + console.log(` - ${manifest.name || ext.id} (${ext.id}) v${manifest.version || '?'}`); 155 + }); 156 + } else { 157 + console.log('No extensions running'); 158 + } 163 159 164 160 // Open settings to Extensions section 165 161 const p = prefs(); ··· 210 206 } 211 207 }); 212 208 213 - // Open settings window on startup if configured 214 - if (p.startupFeature == settingsAddress) { 215 - try { 216 - await openSettingsWindow(p); 217 - } catch (error) { 218 - console.error('Error opening startup settings window:', error); 219 - } 209 + // Always open settings window on startup 210 + try { 211 + await openSettingsWindow(p); 212 + } catch (error) { 213 + console.error('Error opening startup settings window:', error); 220 214 } 221 215 222 - // feature enable/disable 216 + // Feature enable/disable handler 217 + // Extensions are now managed by main process ExtensionManager via IPC 223 218 api.subscribe(topicFeatureToggle, async msg => { 224 219 console.log('feature toggle', msg) 225 220 ··· 233 228 234 229 // Check if this feature is backed by an extension 235 230 const extId = f.name.toLowerCase(); 236 - const isExtension = extensionLoader.builtinExtensions.some(e => e.id === extId); 231 + const isExtension = builtinExtensions.includes(extId); 237 232 238 233 if (msg.enabled == false) { 239 234 console.log('disabling', f.name); 240 235 if (isExtension) { 241 - await extensionLoader.unloadExtension(extId); 236 + // Use main process IPC to unload extension 237 + await api.extensions.unload(extId); 242 238 } else { 243 239 uninitFeature(f); 244 240 } ··· 246 242 else if (msg.enabled == true) { 247 243 console.log('enabling', f.name); 248 244 if (isExtension) { 249 - const ext = extensionLoader.builtinExtensions.find(e => e.id === extId); 250 - if (ext) { 251 - await extensionLoader.loadExtension(ext); 252 - } 245 + // Use main process IPC to load extension 246 + await api.extensions.load(extId); 253 247 } else { 254 248 initFeature(f); 255 249 } ··· 262 256 263 257 initSettingsShortcut(p); 264 258 265 - features().forEach(initFeature); 259 + // Run any pending migrations (e.g., localStorage -> datastore) 260 + await migrations.runMigrations(); 266 261 267 - // Load extensions 268 - // Helper to check if an extension (by name) is enabled in features 269 - const isExtensionEnabled = (extId) => { 270 - const featureList = features(); 271 - // Match extension ID to feature name (case-insensitive) 272 - const feature = featureList.find(f => 273 - f.name.toLowerCase() === extId.toLowerCase() 274 - ); 275 - return feature ? feature.enabled : false; 276 - }; 262 + // Initialize core features (non-extension features only) 263 + features().forEach(initFeature); 277 264 278 - await extensionLoader.loadBuiltinExtensions(isExtensionEnabled); 265 + // Extensions are now loaded by main process ExtensionManager 266 + // It receives the 'core:ready' signal and calls loadEnabledExtensions() 267 + console.log('Core features initialized. Extensions loaded by main process.'); 279 268 280 269 // Register extension dev commands 281 270 registerExtensionCommands(); 282 - 283 - //features.forEach(initIframeFeature); 284 - 285 - /* 286 - // Example of using the new windows.js API: 287 - const addy = 'http://localhost'; 288 - const params = { 289 - debug, 290 - key: addy, 291 - height: 300, 292 - width: 300 293 - }; 294 - 295 - windowManager.createWindow(addy, params) 296 - .then(windowController => { 297 - // Can use windowController to interact with the window 298 - windowController.hide(); 299 - }) 300 - .catch(error => { 301 - console.error('Error opening example window:', error); 302 - }); 303 - */ 304 271 }; 305 272 306 273 window.addEventListener('load', () => { ··· 308 275 console.error('Error during application initialization:', error); 309 276 }); 310 277 }); 311 - 312 - /* 313 - const odiff = (a, b) => Object.entries(b).reduce((c, [k, v]) => Object.assign(c, a[k] ? {} : { [k]: v }), {}); 314 - 315 - const onStorageChange = (e) => { 316 - const old = JSON.parse(e.oldValue); 317 - const now = JSON.parse(e.newValue); 318 - 319 - const featureKey = `${id}+${storageKeys.ITEMS}`; 320 - //console.log('onStorageChane', e.key, featureKey) 321 - if (e.key == featureKey) { 322 - //console.log('STORAGE CHANGE', e.key, old[0].enabled, now[0].enabled); 323 - features().forEach((feat, i) => { 324 - console.log(feat.title, i, feat.enabled, old[i].enabled, now[i].enabled); 325 - // disabled, so unload 326 - if (old[i].enabled == true && now[i].enabled == false) { 327 - // TODO 328 - console.log('TODO: add unloading of features', feat) 329 - } 330 - // enabled, so load 331 - else if (old[i].enabled == false && now[i].enabled == true) { 332 - initFeature(feat); 333 - } 334 - }); 335 - } 336 - //JSON.stringify(e.storageArea); 337 - }; 338 - 339 - window.addEventListener('storage', onStorageChange); 340 - */
+65 -3
app/settings/settings.js
··· 633 633 }; 634 634 635 635 // Render feature settings (Peeks, Slides, etc.) 636 + // Now reads/writes from datastore extension_settings table for isolated extensions 636 637 const renderFeatureSettings = (feature) => { 637 638 const { id, labels, schemas, storageKeys, defaults } = feature; 639 + 640 + // Use extension shortname for datastore key (e.g., 'peeks', 'slides', 'groups') 641 + const extId = labels.name.toLowerCase(); 642 + 643 + // For now, still use localStorage as fallback (will be migrated) 638 644 const store = openStore(id, defaults, clear); 639 645 640 646 let prefs = store.get(storageKeys.PREFS); ··· 645 651 // Topic for notifying feature of settings changes (e.g., 'peeks:settings-changed') 646 652 const settingsChangedTopic = `${labels.name.toLowerCase()}:settings-changed`; 647 653 648 - const save = () => { 654 + const save = async () => { 655 + // Save to localStorage (legacy) 649 656 store.set(storageKeys.PREFS, prefs); 650 657 store.set(storageKeys.ITEMS, items); 651 - // Notify feature to hot-reload with new settings 658 + 659 + // Also save to datastore for isolated extensions 660 + const rowIdPrefs = `${extId}:prefs`; 661 + const rowIdItems = `${extId}:items`; 662 + const now = Date.now(); 663 + 664 + await api.datastore.setRow('extension_settings', rowIdPrefs, { 665 + extensionId: extId, 666 + key: 'prefs', 667 + value: JSON.stringify(prefs), 668 + updatedAt: now 669 + }); 670 + 671 + if (items) { 672 + await api.datastore.setRow('extension_settings', rowIdItems, { 673 + extensionId: extId, 674 + key: 'items', 675 + value: JSON.stringify(items), 676 + updatedAt: now 677 + }); 678 + } 679 + 680 + // Notify feature to hot-reload with new settings (GLOBAL for cross-process) 652 681 api.publish(settingsChangedTopic, {}, api.scopes.GLOBAL); 653 682 }; 654 683 ··· 812 841 } 813 842 }; 814 843 844 + // Helper to check if a feature is enabled 845 + const isFeatureEnabled = (featureName) => { 846 + const store = openStore(appConfig.id, appConfig.defaults, false); 847 + const features = store.get(appConfig.storageKeys.ITEMS) || []; 848 + const feature = features.find(f => f.name.toLowerCase() === featureName.toLowerCase()); 849 + return feature ? feature.enabled : false; 850 + }; 851 + 815 852 // Initialize 816 853 const init = () => { 817 854 const sidebarNav = document.getElementById('sidebarNav'); ··· 829 866 coreSection.classList.add('active'); 830 867 contentArea.appendChild(coreSection); 831 868 832 - // Add feature sections 869 + // Track feature nav items and sections for dynamic updates 870 + const featureElements = new Map(); 871 + 872 + // Add feature sections (only show enabled ones, but create all for hot-reload) 833 873 for (const i in fc) { 834 874 const feature = fc[i]; 835 875 const name = feature.labels.name; 836 876 const sectionId = name.toLowerCase().replace(/\s+/g, '-'); 877 + const enabled = isFeatureEnabled(name); 837 878 838 879 // Add nav item 839 880 const navItem = document.createElement('a'); 840 881 navItem.className = 'nav-item'; 841 882 navItem.textContent = name; 842 883 navItem.dataset.section = sectionId; 884 + navItem.dataset.featureName = name; 843 885 navItem.addEventListener('click', () => showSection(sectionId)); 886 + if (!enabled) navItem.style.display = 'none'; 844 887 sidebarNav.appendChild(navItem); 845 888 846 889 // Add section 847 890 const section = createSection(sectionId, name, () => renderFeatureSettings(feature)); 891 + if (!enabled) section.style.display = 'none'; 848 892 contentArea.appendChild(section); 893 + 894 + featureElements.set(name.toLowerCase(), { navItem, section }); 849 895 } 896 + 897 + // Listen for feature toggle events to update sidebar 898 + api.subscribe('core:feature:toggle', (msg) => { 899 + const featureName = msg.featureId?.toLowerCase(); 900 + const elements = featureElements.get(featureName); 901 + if (elements) { 902 + const display = msg.enabled ? '' : 'none'; 903 + elements.navItem.style.display = display; 904 + elements.section.style.display = display; 905 + 906 + // If currently viewing a disabled section, switch to Core 907 + if (!msg.enabled && elements.section.classList.contains('active')) { 908 + showSection('core'); 909 + } 910 + } 911 + }); 850 912 851 913 // Add Extensions section 852 914 const extNav = document.createElement('a');
+60 -23
extensions/groups/background.js
··· 1 - // Groups extension background script 2 - // This runs in the core background context and registers the extension 1 + /** 2 + * Groups Extension Background Script 3 + * 4 + * Tag-based grouping of addresses 5 + * 6 + * Runs in isolated extension process (peek://ext/groups/background.html) 7 + * Uses api.settings for datastore-backed settings storage 8 + */ 3 9 4 10 import { id, labels, schemas, storageKeys, defaults } from './config.js'; 5 - // Use absolute peek:// URLs since relative paths stay within the ext host 6 - import { openStore } from "peek://app/utils.js"; 7 - import windows from "peek://app/windows.js"; 8 11 9 12 const api = window.app; 10 13 const debug = api.debug; 11 - const clear = false; 12 14 13 - const store = openStore(id, defaults, clear /* clear storage */); 15 + console.log('[ext:groups] background', labels.name); 14 16 15 17 // Extension content is served from peek://ext/groups/ 16 18 const address = 'peek://ext/groups/home.html'; 17 19 20 + // In-memory settings cache (loaded from datastore on init) 21 + let currentSettings = { 22 + prefs: defaults.prefs 23 + }; 24 + 25 + /** 26 + * Load settings from datastore 27 + * @returns {Promise<{prefs: object}>} 28 + */ 29 + const loadSettings = async () => { 30 + const result = await api.settings.get(); 31 + if (result.success && result.data) { 32 + return { 33 + prefs: result.data.prefs || defaults.prefs 34 + }; 35 + } 36 + return { prefs: defaults.prefs }; 37 + }; 38 + 39 + /** 40 + * Save settings to datastore 41 + * @param {object} settings - Settings object with prefs 42 + */ 43 + const saveSettings = async (settings) => { 44 + const result = await api.settings.set(settings); 45 + if (!result.success) { 46 + console.error('[ext:groups] Failed to save settings:', result.error); 47 + } 48 + }; 49 + 18 50 const openGroupsWindow = () => { 19 51 const height = 600; 20 52 const width = 800; ··· 23 55 key: address, 24 56 height, 25 57 width, 26 - escapeMode: 'navigate', // Allow internal navigation before closing 58 + escapeMode: 'navigate', 27 59 trackingSource: 'cmd', 28 60 trackingSourceId: 'groups' 29 61 }; 30 62 31 - windows.createWindow(address, params) 63 + api.window.open(address, params) 32 64 .then(window => { 33 - debug && console.log('Groups window opened:', window); 65 + debug && console.log('[ext:groups] Groups window opened:', window); 34 66 }) 35 67 .catch(error => { 36 - console.error('Failed to open groups window:', error); 68 + console.error('[ext:groups] Failed to open groups window:', error); 37 69 }); 38 70 }; 39 71 40 - // ===== Command helpers (moved from app/cmd/commands/groups.js) ===== 72 + // ===== Command helpers ===== 41 73 42 74 /** 43 75 * Helper to get or create an address for a URI ··· 49 81 const existing = result.data.find(addr => addr.uri === uri); 50 82 if (existing) return existing; 51 83 52 - // Create new address 53 84 const addResult = await api.datastore.addAddress(uri, {}); 54 85 if (!addResult.success) return null; 55 86 ··· 71 102 const saveToGroup = async (groupName) => { 72 103 console.log('[ext:groups] Saving to group:', groupName); 73 104 74 - // Get or create the tag 75 105 const tagResult = await api.datastore.getOrCreateTag(groupName); 76 106 if (!tagResult.success) { 77 107 console.error('[ext:groups] Failed to get/create tag:', tagResult.error); ··· 80 110 81 111 const tagId = tagResult.data.id; 82 112 83 - // Get all open windows (excluding internal peek:// URLs) 84 113 const listResult = await api.window.list({ includeInternal: false }); 85 114 if (!listResult.success || listResult.windows.length === 0) { 86 115 console.log('[ext:groups] No windows to save'); ··· 109 138 const openGroup = async (groupName) => { 110 139 console.log('[ext:groups] Opening group:', groupName); 111 140 112 - // Find the tag by name 113 141 const tagsResult = await api.datastore.getTagsByFrecency(); 114 142 if (!tagsResult.success) { 115 143 return { success: false, error: 'Failed to get tags' }; ··· 121 149 return { success: false, error: 'Group not found' }; 122 150 } 123 151 124 - // Get addresses with this tag 125 152 const addressesResult = await api.datastore.getAddressesByTag(tag.id); 126 153 if (!addressesResult.success || addressesResult.data.length === 0) { 127 154 console.log('[ext:groups] No addresses in group:', groupName); ··· 129 156 } 130 157 131 158 for (const addr of addressesResult.data) { 132 - await windows.createWindow(addr.uri, { 159 + await api.window.open(addr.uri, { 133 160 trackingSource: 'cmd', 134 161 trackingSourceId: `group:${groupName}` 135 162 }); ··· 173 200 const groupName = ctx.search.trim(); 174 201 await openGroup(groupName); 175 202 } else { 176 - // Show available groups 177 203 const groups = await getAllGroups(); 178 204 if (groups.length === 0) { 179 205 console.log('[ext:groups] No groups saved yet. Use "save group <name>" to create one.'); ··· 191 217 let registeredShortcut = null; 192 218 let registeredCommands = []; 193 219 194 - const initShortcut = shortcut => { 220 + const initShortcut = (shortcut) => { 195 221 api.shortcuts.register(shortcut, () => { 196 222 openGroupsWindow(); 197 223 }, { global: true }); ··· 214 240 console.log('[ext:groups] Unregistered commands'); 215 241 }; 216 242 217 - const init = () => { 243 + const init = async () => { 218 244 console.log('[ext:groups] init'); 219 245 220 - const prefs = () => store.get(storageKeys.PREFS); 221 - initShortcut(prefs().shortcutKey); 246 + // Load settings from datastore 247 + currentSettings = await loadSettings(); 248 + 249 + initShortcut(currentSettings.prefs.shortcutKey); 222 250 initCommands(); 251 + 252 + // Listen for settings changes to hot-reload (GLOBAL scope for cross-process) 253 + api.subscribe('groups:settings-changed', async () => { 254 + console.log('[ext:groups] settings changed, reinitializing'); 255 + uninit(); 256 + currentSettings = await loadSettings(); 257 + initShortcut(currentSettings.prefs.shortcutKey); 258 + initCommands(); 259 + }, api.scopes.GLOBAL); 223 260 }; 224 261 225 262 const uninit = () => {
+12 -2
extensions/groups/manifest.json
··· 4 4 "name": "Groups", 5 5 "description": "Tag-based grouping of addresses", 6 6 "version": "1.0.0", 7 - "background": "background.js", 8 - "builtin": true 7 + "background": "background.html", 8 + "builtin": true, 9 + "settings": { 10 + "prefs": { 11 + "shortcutKey": { 12 + "type": "string", 13 + "label": "Shortcut", 14 + "description": "Global OS hotkey to open groups manager", 15 + "default": "Option+g" 16 + } 17 + } 18 + } 9 19 }
+63 -25
extensions/peeks/background.js
··· 2 2 * Peeks Extension Background Script 3 3 * 4 4 * Quick access modal windows for web pages via keyboard shortcuts (Option+0-9) 5 + * 6 + * Runs in isolated extension process (peek://ext/peeks/background.html) 7 + * Uses api.settings for datastore-backed settings storage 5 8 */ 6 9 7 10 import { id, labels, schemas, storageKeys, defaults } from './config.js'; 8 - import { openStore } from 'peek://app/utils.js'; 9 - import windows from 'peek://app/windows.js'; 10 11 11 12 const api = window.app; 12 13 const debug = api.debug; 13 14 14 15 console.log('[ext:peeks] background', labels.name); 15 16 16 - const clear = false; 17 - const store = openStore(id, defaults, clear /* clear storage */); 18 - 19 17 // Track registered shortcuts for cleanup 20 18 let registeredShortcuts = []; 21 19 20 + // In-memory settings cache (loaded from datastore on init) 21 + let currentSettings = { 22 + prefs: defaults.prefs, 23 + items: defaults.items 24 + }; 25 + 26 + /** 27 + * Load settings from datastore 28 + * @returns {Promise<{prefs: object, items: array}>} 29 + */ 30 + const loadSettings = async () => { 31 + const result = await api.settings.get(); 32 + if (result.success && result.data) { 33 + return { 34 + prefs: result.data.prefs || defaults.prefs, 35 + items: result.data.items || defaults.items 36 + }; 37 + } 38 + return { prefs: defaults.prefs, items: defaults.items }; 39 + }; 40 + 41 + /** 42 + * Save settings to datastore 43 + * @param {object} settings - Settings object with prefs and items 44 + */ 45 + const saveSettings = async (settings) => { 46 + const result = await api.settings.set(settings); 47 + if (!result.success) { 48 + console.error('[ext:peeks] Failed to save settings:', result.error); 49 + } 50 + }; 51 + 52 + /** 53 + * Open a peek window for the given item 54 + */ 22 55 const executeItem = (item) => { 23 56 console.log('[ext:peeks] executeItem', item); 24 57 const height = item.height || 600; ··· 29 62 height, 30 63 width, 31 64 65 + // modal behavior 66 + modal: true, 67 + type: 'panel', 68 + 32 69 // peek 33 70 feature: labels.name, 34 71 keepLive: item.keepLive || false, ··· 37 74 // Create a unique key for this peek using its address 38 75 key: `peek:${item.address}`, 39 76 40 - // tracking (handled automatically by windows API) 77 + // tracking 41 78 trackingSource: 'peek', 42 79 trackingSourceId: item.keyNum ? `peek_${item.keyNum}` : 'peek', 43 80 title: item.title || '' 44 81 }; 45 82 46 - windows.openModalWindow(item.address, params) 83 + api.window.open(item.address, params) 47 84 .then(result => { 48 85 console.log('[ext:peeks] Peek window opened:', result); 49 86 }) ··· 52 89 }); 53 90 }; 54 91 92 + /** 93 + * Initialize shortcuts for enabled items 94 + */ 55 95 const initItems = (prefs, items) => { 56 96 const cmdPrefix = prefs.shortcutKeyPrefix; 57 97 58 98 items.forEach(item => { 59 - if (item.enabled == true && item.address.length > 0) { 99 + if (item.enabled == true && item.address && item.address.length > 0) { 60 100 const shortcut = `${cmdPrefix}${item.keyNum}`; 61 101 62 102 api.shortcuts.register(shortcut, () => { ··· 83 123 84 124 /** 85 125 * Reinitialize peeks (called when settings change) 86 - * 87 - * TODO: This is inefficient - reinitializes all peeks when any single 88 - * property changes. A better approach would be to diff the old and new 89 - * settings and only update the shortcuts that actually changed. 90 126 */ 91 - const reinit = () => { 127 + const reinit = async () => { 92 128 console.log('[ext:peeks] reinit'); 93 129 uninit(); 94 130 95 - const prefs = store.get(storageKeys.PREFS); 96 - const items = store.get(storageKeys.ITEMS); 131 + currentSettings = await loadSettings(); 97 132 98 - if (items && items.length > 0) { 99 - initItems(prefs, items); 133 + if (currentSettings.items && currentSettings.items.length > 0) { 134 + initItems(currentSettings.prefs, currentSettings.items); 100 135 } 101 136 }; 102 137 103 - const init = () => { 138 + /** 139 + * Initialize the extension 140 + */ 141 + const init = async () => { 104 142 console.log('[ext:peeks] init'); 105 143 106 - const prefs = () => store.get(storageKeys.PREFS); 107 - const items = () => store.get(storageKeys.ITEMS); 144 + // Load settings from datastore 145 + currentSettings = await loadSettings(); 108 146 109 - // Initialize peeks 110 - if (items().length > 0) { 111 - initItems(prefs(), items()); 147 + // Initialize peeks if we have items 148 + if (currentSettings.items && currentSettings.items.length > 0) { 149 + initItems(currentSettings.prefs, currentSettings.items); 112 150 } 113 151 114 - // Listen for settings changes to hot-reload 152 + // Listen for settings changes to hot-reload (GLOBAL scope for cross-process) 115 153 api.subscribe('peeks:settings-changed', () => { 116 154 console.log('[ext:peeks] settings changed, reinitializing'); 117 155 reinit(); 118 - }); 156 + }, api.scopes.GLOBAL); 119 157 }; 120 158 121 159 export default {
+62 -2
extensions/peeks/manifest.json
··· 4 4 "name": "Peeks", 5 5 "description": "Quick access modal windows for web pages via keyboard shortcuts", 6 6 "version": "1.0.0", 7 - "background": "background.js", 8 - "builtin": true 7 + "background": "background.html", 8 + "builtin": true, 9 + "settings": { 10 + "prefs": { 11 + "shortcutKeyPrefix": { 12 + "type": "string", 13 + "label": "Shortcut prefix", 14 + "description": "Global OS hotkey prefix to trigger peeks - followed by 0-9", 15 + "default": "Option+" 16 + } 17 + }, 18 + "items": { 19 + "type": "array", 20 + "maxItems": 10, 21 + "itemSchema": { 22 + "keyNum": { 23 + "type": "integer", 24 + "label": "Key number", 25 + "description": "Number key (0-9) to trigger this peek", 26 + "minimum": 0, 27 + "maximum": 9 28 + }, 29 + "title": { 30 + "type": "string", 31 + "label": "Title", 32 + "description": "Name of the peek" 33 + }, 34 + "address": { 35 + "type": "string", 36 + "label": "URL", 37 + "description": "URL to load" 38 + }, 39 + "height": { 40 + "type": "integer", 41 + "label": "Height", 42 + "default": 600 43 + }, 44 + "width": { 45 + "type": "integer", 46 + "label": "Width", 47 + "default": 800 48 + }, 49 + "persistState": { 50 + "type": "boolean", 51 + "label": "Persist state", 52 + "description": "Persist local state between sessions", 53 + "default": false 54 + }, 55 + "keepLive": { 56 + "type": "boolean", 57 + "label": "Keep live", 58 + "description": "Keep page alive in background", 59 + "default": false 60 + }, 61 + "enabled": { 62 + "type": "boolean", 63 + "label": "Enabled", 64 + "default": false 65 + } 66 + } 67 + } 68 + } 9 69 }
+54 -69
extensions/slides/background.js
··· 2 2 * Slides Extension Background Script 3 3 * 4 4 * Edge-anchored slide-in panels triggered by keyboard shortcuts (Option+Arrow) 5 + * 6 + * Runs in isolated extension process (peek://ext/slides/background.html) 7 + * Uses api.settings for datastore-backed settings storage 5 8 */ 6 9 7 10 import { id, labels, schemas, storageKeys, defaults } from './config.js'; 8 - import { openStore } from 'peek://app/utils.js'; 9 - import windows from 'peek://app/windows.js'; 10 11 11 12 const api = window.app; 12 13 const debug = api.debug; 13 14 14 15 console.log('[ext:slides] background', labels.name); 15 - 16 - const clear = false; 17 - const store = openStore(id, defaults, clear /* clear storage */); 18 16 19 17 // Map to track opened slides - key is slide key, value is window ID 20 18 const slideWindows = new Map(); ··· 22 20 // Track registered shortcuts for cleanup 23 21 let registeredShortcuts = []; 24 22 23 + // In-memory settings cache (loaded from datastore on init) 24 + let currentSettings = { 25 + prefs: defaults.prefs, 26 + items: defaults.items 27 + }; 28 + 29 + /** 30 + * Load settings from datastore 31 + * @returns {Promise<{prefs: object, items: array}>} 32 + */ 33 + const loadSettings = async () => { 34 + const result = await api.settings.get(); 35 + if (result.success && result.data) { 36 + return { 37 + prefs: result.data.prefs || defaults.prefs, 38 + items: result.data.items || defaults.items 39 + }; 40 + } 41 + return { prefs: defaults.prefs, items: defaults.items }; 42 + }; 43 + 44 + /** 45 + * Save settings to datastore 46 + * @param {object} settings - Settings object with prefs and items 47 + */ 48 + const saveSettings = async (settings) => { 49 + const result = await api.settings.set(settings); 50 + if (!result.success) { 51 + console.error('[ext:slides] Failed to save settings:', result.error); 52 + } 53 + }; 54 + 25 55 const executeItem = (item) => { 26 56 const height = item.height || 600; 27 57 const width = item.width || 800; ··· 35 65 36 66 switch(item.screenEdge) { 37 67 case 'Up': 38 - // horizontally center 39 68 x = (screen.width - width) / 2; 40 - 41 - // y starts at screen top and stays there 42 69 y = 0; 43 - 44 - //width = item.width; 45 - //height = 1; 46 70 break; 47 71 case 'Down': 48 - // horizonally center 49 72 x = (screen.width - item.width) / 2; 50 - 51 - // y ends up at window height from bottom 52 - // 53 - // eg: y = screen.height - item.height; 54 - // 55 - // but starts at screen bottom 56 73 y = screen.height; 57 - 58 - //width = item.width; 59 - //height = 1; 60 74 break; 61 75 case 'Left': 62 - // x starts and ends at at left screen edge 63 - // at left edge 64 76 x = 0; 65 - 66 - // vertically center 67 77 y = (screen.height - item.height) / 2; 68 - 69 - //width = 1; 70 - //height = item.height; 71 78 break; 72 79 case 'Right': 73 - // x ends at at right screen edge - window size 74 - // 75 - // eg: x = screen.width - item.width; 76 - // 77 - // but starts at screen right edge, will animate in 78 80 x = screen.width; 79 - 80 - // vertically center 81 81 y = (screen.height - item.height) / 2; 82 - 83 - //width = 1; 84 - //height = item.height; 85 82 break; 86 83 default: 87 84 center = true; ··· 94 91 95 92 // Check if this slide is already open 96 93 if (slideWindows.has(key)) { 97 - // Get the window ID for the existing slide 98 94 const windowId = slideWindows.get(key); 99 95 console.log('[ext:slides] Slide already open, verifying window exists with ID:', windowId); 100 96 101 - // First check if window exists 102 97 api.window.exists({ id: windowId }).then(existsResult => { 103 98 if (existsResult.exists) { 104 - // Window exists, try to show it 105 99 api.window.show({ id: windowId }).then(result => { 106 100 if (result.success) { 107 101 console.log('[ext:slides] Successfully showed existing slide:', key); ··· 136 130 width, 137 131 key, 138 132 133 + // modal behavior 134 + modal: true, 135 + type: 'panel', 136 + 139 137 feature: labels.name, 140 138 keepLive: item.keepLive || false, 141 139 persistState: item.persistState || false, ··· 143 141 x, 144 142 y, 145 143 146 - // tracking (handled automatically by windows API) 144 + // tracking 147 145 trackingSource: 'slide', 148 146 trackingSourceId: item.screenEdge ? `slide_${item.screenEdge}` : 'slide', 149 147 title: item.title || '' 150 148 }; 151 149 152 - // Open the window 153 - windows.openModalWindow(item.address, params).then(result => { 150 + api.window.open(item.address, params).then(result => { 154 151 if (result.success) { 155 152 console.log('[ext:slides] Successfully opened slide with ID:', result.id); 156 - // Store the window ID for future reference 157 153 slideWindows.set(key, result.id); 158 154 } else { 159 155 console.error('[ext:slides] Failed to open slide:', result.error); 160 156 } 161 157 }); 162 158 } 163 - 164 159 }; 165 160 166 161 const initItems = (prefs, items) => { 167 162 const cmdPrefix = prefs.shortcutKeyPrefix; 168 163 169 164 items.forEach(item => { 170 - if (item.enabled == true && item.address.length > 0) { 165 + if (item.enabled == true && item.address && item.address.length > 0) { 171 166 const shortcut = `${cmdPrefix}${item.screenEdge}`; 172 167 173 168 api.shortcuts.register(shortcut, () => { ··· 185 180 const uninit = () => { 186 181 console.log('[ext:slides] uninit - unregistering', registeredShortcuts.length, 'shortcuts'); 187 182 188 - // Unregister all shortcuts 189 183 registeredShortcuts.forEach(shortcut => { 190 184 api.shortcuts.unregister(shortcut, { global: true }); 191 185 }); ··· 206 200 207 201 /** 208 202 * Reinitialize slides (called when settings change) 209 - * 210 - * TODO: This is inefficient - reinitializes all slides when any single 211 - * property changes. A better approach would be to diff the old and new 212 - * settings and only update the shortcuts that actually changed. 213 203 */ 214 - const reinit = () => { 204 + const reinit = async () => { 215 205 console.log('[ext:slides] reinit'); 216 206 uninit(); 217 207 218 - const prefs = store.get(storageKeys.PREFS); 219 - const items = store.get(storageKeys.ITEMS); 208 + currentSettings = await loadSettings(); 220 209 221 - if (items && items.length > 0) { 222 - initItems(prefs, items); 210 + if (currentSettings.items && currentSettings.items.length > 0) { 211 + initItems(currentSettings.prefs, currentSettings.items); 223 212 } 224 213 }; 225 214 226 - const init = () => { 215 + const init = async () => { 227 216 console.log('[ext:slides] init'); 228 217 229 - const prefs = () => store.get(storageKeys.PREFS); 230 - const items = () => store.get(storageKeys.ITEMS); 218 + // Load settings from datastore 219 + currentSettings = await loadSettings(); 231 220 232 221 // Add global window closed handler 233 222 api.subscribe('window:closed', (data) => { 234 - // Check all slide windows to see if any match the closed window ID 235 223 for (const [key, windowId] of slideWindows.entries()) { 236 224 if (data.id === windowId) { 237 225 console.log('[ext:slides] Slide window was closed externally:', key); 238 226 slideWindows.delete(key); 239 227 } 240 228 } 241 - }); 229 + }, api.scopes.GLOBAL); 242 230 243 231 // Initialize slides 244 - if (items().length > 0) { 245 - initItems(prefs(), items()); 232 + if (currentSettings.items && currentSettings.items.length > 0) { 233 + initItems(currentSettings.prefs, currentSettings.items); 246 234 } 247 235 248 - // Listen for settings changes to hot-reload 236 + // Listen for settings changes to hot-reload (GLOBAL scope for cross-process) 249 237 api.subscribe('slides:settings-changed', () => { 250 238 console.log('[ext:slides] settings changed, reinitializing'); 251 239 reinit(); 252 - }); 253 - 254 - // Set up listener for app shutdown to clean up windows 255 - api.subscribe('app:shutdown', uninit); 240 + }, api.scopes.GLOBAL); 256 241 }; 257 242 258 243 export default {
+61 -2
extensions/slides/manifest.json
··· 4 4 "name": "Slides", 5 5 "description": "Edge-anchored slide-in panels triggered by keyboard shortcuts", 6 6 "version": "1.0.0", 7 - "background": "background.js", 8 - "builtin": true 7 + "background": "background.html", 8 + "builtin": true, 9 + "settings": { 10 + "prefs": { 11 + "shortcutKeyPrefix": { 12 + "type": "string", 13 + "label": "Shortcut prefix", 14 + "description": "Global OS hotkey prefix to trigger slides - followed by arrow keys", 15 + "default": "Option+" 16 + } 17 + }, 18 + "items": { 19 + "type": "array", 20 + "maxItems": 4, 21 + "itemSchema": { 22 + "screenEdge": { 23 + "type": "string", 24 + "label": "Screen edge", 25 + "description": "Edge of screen to slide from", 26 + "enum": ["Up", "Down", "Left", "Right"] 27 + }, 28 + "title": { 29 + "type": "string", 30 + "label": "Title", 31 + "description": "Name of the slide" 32 + }, 33 + "address": { 34 + "type": "string", 35 + "label": "URL", 36 + "description": "URL to load" 37 + }, 38 + "height": { 39 + "type": "integer", 40 + "label": "Height", 41 + "default": 600 42 + }, 43 + "width": { 44 + "type": "integer", 45 + "label": "Width", 46 + "default": 800 47 + }, 48 + "persistState": { 49 + "type": "boolean", 50 + "label": "Persist state", 51 + "description": "Persist local state between sessions", 52 + "default": false 53 + }, 54 + "keepLive": { 55 + "type": "boolean", 56 + "label": "Keep live", 57 + "description": "Keep page alive in background", 58 + "default": false 59 + }, 60 + "enabled": { 61 + "type": "boolean", 62 + "label": "Enabled", 63 + "default": false 64 + } 65 + } 66 + } 67 + } 9 68 }
+382 -1
index.js
··· 401 401 publish: (source, scope, topic, msg) => { 402 402 //console.log('ps.pub', topic); 403 403 404 + // Route to traditional subscribers (via IPC callbacks) 404 405 if (topics.has(topic)) { 405 406 406 407 const t = topics.get(topic); ··· 411 412 cb(msg); 412 413 } 413 414 }; 415 + } 416 + 417 + // Route to extension windows (GLOBAL scope only) 418 + // This enables cross-origin communication to isolated extension processes 419 + if (scope === scopes.GLOBAL && extensionWindows) { 420 + for (const [extId, entry] of extensionWindows) { 421 + if (entry.win && !entry.win.isDestroyed() && entry.status === 'running') { 422 + // Don't send back to the source extension 423 + const extOrigin = `peek://ext/${extId}/`; 424 + if (!source.startsWith(extOrigin)) { 425 + entry.win.webContents.send(`pubsub:${topic}`, { 426 + ...msg, 427 + source 428 + }); 429 + } 430 + } 431 + } 414 432 } 415 433 }, 416 434 subscribe: (source, scope, topic, cb) => { ··· 511 529 return null; 512 530 }; 513 531 532 + // ***** Extension Window Management ***** 533 + // Each extension runs in its own isolated BrowserWindow at peek://ext/{id}/background.html 534 + 535 + const extensionWindows = new Map(); // extId -> { win, manifest, status } 536 + 537 + const createExtensionWindow = async (extId) => { 538 + if (extensionWindows.has(extId)) { 539 + console.log(`[ext:win] Extension ${extId} already has a window`); 540 + return extensionWindows.get(extId).win; 541 + } 542 + 543 + const extPath = getExtensionPath(extId); 544 + if (!extPath) { 545 + console.error(`[ext:win] Extension path not found: ${extId}`); 546 + return null; 547 + } 548 + 549 + console.log(`[ext:win] Creating window for extension: ${extId}`); 550 + 551 + const win = new BrowserWindow({ 552 + show: false, 553 + webPreferences: { 554 + preload: preloadPath 555 + } 556 + }); 557 + 558 + // Forward console logs from extension to main process stdout 559 + win.webContents.on('console-message', (event, level, message, line, sourceId) => { 560 + const levelStr = ['debug', 'info', 'warn', 'error'][level] || 'log'; 561 + console.log(`[ext:${extId}] ${message}`); 562 + }); 563 + 564 + // Track crash events 565 + win.webContents.on('crashed', (event, killed) => { 566 + console.error(`[ext:win] Extension ${extId} crashed (killed: ${killed})`); 567 + const entry = extensionWindows.get(extId); 568 + if (entry) { 569 + entry.status = 'crashed'; 570 + } 571 + // Optionally auto-restart: createExtensionWindow(extId); 572 + }); 573 + 574 + // Track close events 575 + win.on('closed', () => { 576 + console.log(`[ext:win] Extension ${extId} window closed`); 577 + extensionWindows.delete(extId); 578 + }); 579 + 580 + // Store before loading to handle async issues 581 + extensionWindows.set(extId, { win, manifest: null, status: 'loading' }); 582 + 583 + try { 584 + await win.loadURL(`peek://ext/${extId}/background.html`); 585 + console.log(`[ext:win] Extension ${extId} loaded successfully`); 586 + const entry = extensionWindows.get(extId); 587 + if (entry) { 588 + entry.status = 'running'; 589 + } 590 + return win; 591 + } catch (error) { 592 + console.error(`[ext:win] Failed to load extension ${extId}:`, error); 593 + extensionWindows.delete(extId); 594 + win.destroy(); 595 + return null; 596 + } 597 + }; 598 + 599 + const destroyExtensionWindow = (extId) => { 600 + const entry = extensionWindows.get(extId); 601 + if (!entry) { 602 + console.log(`[ext:win] No window to destroy for: ${extId}`); 603 + return false; 604 + } 605 + 606 + console.log(`[ext:win] Destroying window for: ${extId}`); 607 + 608 + // Notify extension of shutdown before destroying 609 + if (entry.win && !entry.win.isDestroyed()) { 610 + entry.win.webContents.send('pubsub:app:shutdown', {}); 611 + // Give it a moment to clean up, then destroy 612 + setTimeout(() => { 613 + if (!entry.win.isDestroyed()) { 614 + entry.win.destroy(); 615 + } 616 + }, 100); 617 + } 618 + 619 + extensionWindows.delete(extId); 620 + return true; 621 + }; 622 + 623 + const getExtensionWindow = (extId) => { 624 + const entry = extensionWindows.get(extId); 625 + return entry ? entry.win : null; 626 + }; 627 + 628 + const getRunningExtensions = () => { 629 + const running = []; 630 + for (const [extId, entry] of extensionWindows) { 631 + if (entry.status === 'running') { 632 + running.push({ 633 + id: extId, 634 + manifest: entry.manifest, 635 + status: entry.status 636 + }); 637 + } 638 + } 639 + return running; 640 + }; 641 + 642 + // Load enabled extensions on startup 643 + const loadEnabledExtensions = async () => { 644 + // Built-in extensions 645 + const builtinExtensions = ['groups', 'peeks', 'slides']; 646 + 647 + // Check which are enabled from datastore/localStorage 648 + // For now, load all builtins; actual enable/disable can be added later 649 + for (const extId of builtinExtensions) { 650 + // Check if enabled in extension_settings or extensions table 651 + let enabled = true; // Default to enabled for builtins 652 + 653 + if (datastoreStore) { 654 + // Check extension_settings for enabled state 655 + const settingsTable = datastoreStore.getTable('extension_settings') || {}; 656 + for (const [rowId, row] of Object.entries(settingsTable)) { 657 + if (row.extensionId === extId && row.key === 'enabled') { 658 + try { 659 + enabled = JSON.parse(row.value) !== false; 660 + } catch (e) { 661 + enabled = true; 662 + } 663 + } 664 + } 665 + } 666 + 667 + if (enabled) { 668 + console.log(`[ext:win] Loading enabled extension: ${extId}`); 669 + await createExtensionWindow(extId); 670 + } else { 671 + console.log(`[ext:win] Skipping disabled extension: ${extId}`); 672 + } 673 + } 674 + 675 + console.log(`[ext:win] Loaded ${extensionWindows.size} extensions`); 676 + }; 677 + 514 678 // TODO: unhack all this trash fire 515 679 const initAppProtocol = () => { 516 680 protocol.handle(APP_SCHEME, req => { ··· 682 846 setTimeout(() => handleExternalUrl(urlArg, 'cli'), 1000); 683 847 } 684 848 849 + // Track if extensions have been loaded (only load once) 850 + let extensionsLoaded = false; 851 + 685 852 // listen for app prefs to configure ourself 686 853 // TODO: kinda janky, needs rethink 687 - pubsub.subscribe(systemAddress, scopes.SYSTEM, strings.topics.prefs, msg => { 854 + pubsub.subscribe(systemAddress, scopes.SYSTEM, strings.topics.prefs, async msg => { 688 855 console.log('PREFS', msg); 689 856 690 857 // cache all prefs ··· 709 876 console.log('registering new quit shortcut:', newQuitShortcut); 710 877 registerLocalShortcut(newQuitShortcut, onQuit); 711 878 _quitShortcut = newQuitShortcut; 879 + } 880 + 881 + // Load extensions after core app is ready (only once) 882 + if (!extensionsLoaded) { 883 + extensionsLoaded = true; 884 + console.log('[ext:win] Core app ready, loading extensions...'); 885 + await loadEnabledExtensions(); 712 886 } 713 887 }); 714 888 ··· 2095 2269 } 2096 2270 }); 2097 2271 2272 + // ==================== Extension Window Management ==================== 2273 + 2274 + // Load extension (create window) - permission check in preload.js 2275 + ipcMain.handle('extension-window-load', async (ev, data) => { 2276 + const { extId } = data; 2277 + const url = ev.sender.getURL(); 2278 + 2279 + // Permission check: only core app can manage extension windows 2280 + if (!url.startsWith('peek://app/')) { 2281 + console.warn(`[ext:win] Permission denied for extension load from: ${url}`); 2282 + return { success: false, error: 'Permission denied' }; 2283 + } 2284 + 2285 + try { 2286 + const win = await createExtensionWindow(extId); 2287 + if (win) { 2288 + return { success: true, data: { extId } }; 2289 + } else { 2290 + return { success: false, error: 'Failed to create extension window' }; 2291 + } 2292 + } catch (error) { 2293 + console.error('extension-window-load error:', error); 2294 + return { success: false, error: error.message }; 2295 + } 2296 + }); 2297 + 2298 + // Unload extension (destroy window) - permission check in preload.js 2299 + ipcMain.handle('extension-window-unload', async (ev, data) => { 2300 + const { extId } = data; 2301 + const url = ev.sender.getURL(); 2302 + 2303 + // Permission check: only core app can manage extension windows 2304 + if (!url.startsWith('peek://app/')) { 2305 + console.warn(`[ext:win] Permission denied for extension unload from: ${url}`); 2306 + return { success: false, error: 'Permission denied' }; 2307 + } 2308 + 2309 + try { 2310 + const result = destroyExtensionWindow(extId); 2311 + return { success: true, data: { wasRunning: result } }; 2312 + } catch (error) { 2313 + console.error('extension-window-unload error:', error); 2314 + return { success: false, error: error.message }; 2315 + } 2316 + }); 2317 + 2318 + // Reload extension (destroy and recreate window) 2319 + ipcMain.handle('extension-window-reload', async (ev, data) => { 2320 + const { extId } = data; 2321 + const url = ev.sender.getURL(); 2322 + 2323 + // Permission check: only core app can manage extension windows 2324 + if (!url.startsWith('peek://app/')) { 2325 + console.warn(`[ext:win] Permission denied for extension reload from: ${url}`); 2326 + return { success: false, error: 'Permission denied' }; 2327 + } 2328 + 2329 + try { 2330 + destroyExtensionWindow(extId); 2331 + // Small delay to ensure cleanup 2332 + await new Promise(resolve => setTimeout(resolve, 200)); 2333 + const win = await createExtensionWindow(extId); 2334 + if (win) { 2335 + return { success: true, data: { extId } }; 2336 + } else { 2337 + return { success: false, error: 'Failed to reload extension window' }; 2338 + } 2339 + } catch (error) { 2340 + console.error('extension-window-reload error:', error); 2341 + return { success: false, error: error.message }; 2342 + } 2343 + }); 2344 + 2345 + // List running extension windows 2346 + ipcMain.handle('extension-window-list', async (ev) => { 2347 + try { 2348 + const running = getRunningExtensions(); 2349 + return { success: true, data: running }; 2350 + } catch (error) { 2351 + console.error('extension-window-list error:', error); 2352 + return { success: false, error: error.message }; 2353 + } 2354 + }); 2355 + 2356 + // ==================== Extension Settings (Cross-Origin Storage) ==================== 2357 + 2358 + // Get extension settings from datastore 2359 + ipcMain.handle('extension-settings-get', async (ev, data) => { 2360 + const { extId } = data; 2361 + 2362 + try { 2363 + const table = datastoreStore.getTable('extension_settings') || {}; 2364 + const settings = {}; 2365 + 2366 + for (const [rowId, row] of Object.entries(table)) { 2367 + if (row.extensionId === extId) { 2368 + try { 2369 + settings[row.key] = JSON.parse(row.value); 2370 + } catch (e) { 2371 + settings[row.key] = row.value; 2372 + } 2373 + } 2374 + } 2375 + 2376 + return { success: true, data: settings }; 2377 + } catch (error) { 2378 + console.error('extension-settings-get error:', error); 2379 + return { success: false, error: error.message }; 2380 + } 2381 + }); 2382 + 2383 + // Set extension settings in datastore 2384 + ipcMain.handle('extension-settings-set', async (ev, data) => { 2385 + const { extId, settings } = data; 2386 + 2387 + try { 2388 + const now = Date.now(); 2389 + 2390 + for (const [key, value] of Object.entries(settings)) { 2391 + const rowId = `${extId}:${key}`; 2392 + datastoreStore.setRow('extension_settings', rowId, { 2393 + extensionId: extId, 2394 + key, 2395 + value: JSON.stringify(value), 2396 + updatedAt: now 2397 + }); 2398 + } 2399 + 2400 + return { success: true }; 2401 + } catch (error) { 2402 + console.error('extension-settings-set error:', error); 2403 + return { success: false, error: error.message }; 2404 + } 2405 + }); 2406 + 2407 + // Get a single setting key for an extension 2408 + ipcMain.handle('extension-settings-get-key', async (ev, data) => { 2409 + const { extId, key } = data; 2410 + 2411 + try { 2412 + const rowId = `${extId}:${key}`; 2413 + const row = datastoreStore.getRow('extension_settings', rowId); 2414 + 2415 + if (!row || Object.keys(row).length === 0) { 2416 + return { success: true, data: null }; 2417 + } 2418 + 2419 + try { 2420 + return { success: true, data: JSON.parse(row.value) }; 2421 + } catch (e) { 2422 + return { success: true, data: row.value }; 2423 + } 2424 + } catch (error) { 2425 + console.error('extension-settings-get-key error:', error); 2426 + return { success: false, error: error.message }; 2427 + } 2428 + }); 2429 + 2430 + // Set a single setting key for an extension 2431 + ipcMain.handle('extension-settings-set-key', async (ev, data) => { 2432 + const { extId, key, value } = data; 2433 + 2434 + try { 2435 + const rowId = `${extId}:${key}`; 2436 + datastoreStore.setRow('extension_settings', rowId, { 2437 + extensionId: extId, 2438 + key, 2439 + value: JSON.stringify(value), 2440 + updatedAt: Date.now() 2441 + }); 2442 + 2443 + return { success: true }; 2444 + } catch (error) { 2445 + console.error('extension-settings-set-key error:', error); 2446 + return { success: false, error: error.message }; 2447 + } 2448 + }); 2449 + 2450 + // Get extension manifest from filesystem 2451 + ipcMain.handle('extension-manifest-get', async (ev, data) => { 2452 + const { extId } = data; 2453 + 2454 + try { 2455 + const extPath = getExtensionPath(extId); 2456 + if (!extPath) { 2457 + return { success: false, error: 'Extension not found' }; 2458 + } 2459 + 2460 + const manifestPath = path.join(extPath, 'manifest.json'); 2461 + if (!fs.existsSync(manifestPath)) { 2462 + return { success: false, error: 'manifest.json not found' }; 2463 + } 2464 + 2465 + const manifestContent = fs.readFileSync(manifestPath, 'utf-8'); 2466 + const manifest = JSON.parse(manifestContent); 2467 + 2468 + return { success: true, data: manifest }; 2469 + } catch (error) { 2470 + console.error('extension-manifest-get error:', error); 2471 + return { success: false, error: error.message }; 2472 + } 2473 + }); 2474 + 2098 2475 // ==================== End Extension Management ==================== 2099 2476 2100 2477 const modWindow = (bw, params) => { ··· 2451 2828 const visibleCount = getVisibleWindowCount(excludeId); 2452 2829 const prefShowDock = _prefs?.showInDockAndSwitcher === true; 2453 2830 2831 + console.log('updateDockVisibility:', { visibleCount, prefShowDock, excludeId }); 2832 + 2454 2833 if (visibleCount > 0 || prefShowDock) { 2834 + console.log('Showing dock'); 2455 2835 app.dock.show(); 2456 2836 } else { 2837 + console.log('Hiding dock'); 2457 2838 app.dock.hide(); 2458 2839 } 2459 2840 };
+72
preload.js
··· 23 23 24 24 const rndm = () => Math.random().toString(16).slice(2); 25 25 26 + // Context detection for permission tiers 27 + const isCore = sourceAddress.startsWith('peek://app/'); 28 + const isExtension = sourceAddress.startsWith('peek://ext/'); 29 + 30 + /** 31 + * Get the extension ID from the current context 32 + * @returns {string|null} Extension ID or null if not in an extension context 33 + */ 34 + const getExtensionId = () => { 35 + if (!isExtension) return null; 36 + const match = sourceAddress.match(/peek:\/\/ext\/([^/]+)/); 37 + return match ? match[1] : null; 38 + }; 39 + 26 40 let api = {}; 27 41 28 42 // Log to main process (shows in terminal) ··· 660 674 */ 661 675 get: (id) => { 662 676 return ipcRenderer.invoke('extension-get', { id }); 677 + } 678 + }; 679 + 680 + // Extension settings API (for isolated extension processes) 681 + // Extensions can only access their own settings via datastore 682 + api.settings = { 683 + /** 684 + * Get settings for the current extension 685 + * Only works from extension context (peek://ext/{id}/...) 686 + * @returns {Promise<{success: boolean, data?: object, error?: string}>} 687 + */ 688 + get: () => { 689 + const extId = getExtensionId(); 690 + if (!extId) { 691 + return Promise.resolve({ success: false, error: 'Not an extension context' }); 692 + } 693 + return ipcRenderer.invoke('extension-settings-get', { extId }); 694 + }, 695 + 696 + /** 697 + * Save settings for the current extension 698 + * Only works from extension context (peek://ext/{id}/...) 699 + * @param {object} settings - Settings object to save (keys: prefs, items, etc.) 700 + * @returns {Promise<{success: boolean, error?: string}>} 701 + */ 702 + set: (settings) => { 703 + const extId = getExtensionId(); 704 + if (!extId) { 705 + return Promise.resolve({ success: false, error: 'Not an extension context' }); 706 + } 707 + return ipcRenderer.invoke('extension-settings-set', { extId, settings }); 708 + }, 709 + 710 + /** 711 + * Get a single setting key for the current extension 712 + * @param {string} key - Setting key (e.g., 'prefs', 'items') 713 + * @returns {Promise<{success: boolean, data?: any, error?: string}>} 714 + */ 715 + getKey: (key) => { 716 + const extId = getExtensionId(); 717 + if (!extId) { 718 + return Promise.resolve({ success: false, error: 'Not an extension context' }); 719 + } 720 + return ipcRenderer.invoke('extension-settings-get-key', { extId, key }); 721 + }, 722 + 723 + /** 724 + * Set a single setting key for the current extension 725 + * @param {string} key - Setting key 726 + * @param {any} value - Value to set (will be JSON stringified) 727 + * @returns {Promise<{success: boolean, error?: string}>} 728 + */ 729 + setKey: (key, value) => { 730 + const extId = getExtensionId(); 731 + if (!extId) { 732 + return Promise.resolve({ success: false, error: 'Not an extension context' }); 733 + } 734 + return ipcRenderer.invoke('extension-settings-set-key', { extId, key, value }); 663 735 } 664 736 }; 665 737