experiments in a post-browser web
10
fork

Configure Feed

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

extension shortnames, basic permissions, settings pane, dev commands

+803 -23
+29
TODO.md
··· 1 1 # Roadmap 2 2 3 + ## v? - Entity centrism (NER streams) 4 + 5 + - [ ] Entity catalog definition 6 + - [ ] Datastore support 7 + - [ ] Basic NER 8 + - [ ] Page metadata viz 9 + - [ ] Entity search/browse 10 + - [ ] ML NER 11 + 3 12 ## v? - Minimum viable web workbench 4 13 5 14 - [ ] Design philosophy write-up w/ driving principles and characteristics ··· 21 30 22 31 ## v? - Extensibility 23 32 33 + Status quo 34 + - [ ] browser extensions (limited, mostly get a couple popular ones working) 35 + - [ ] opensearch 36 + - [ ] quicksearch 37 + - [ ] bookmarklets 38 + - [ ] userscripts 39 + - [ ] language packs 40 + 41 + 24 42 Peek extensions 25 43 - [ ] see notes/extensibility.md 44 + - [ ] poke at remote loading + provenance 45 + - [ ] window manager views (bad name, but what Peek "features" are now) 46 + - [ ] commands (eg Quicksilver, Ubiquity, Raycast style) 47 + - [ ] transformers 48 + - [ ] pipelines/chains 49 + - [ ] chaining requires something like activities/intents/applets for inputs/outputs 50 + - [ ] some kind of theming (https://github.com/AgregoreWeb/agregore-browser/pull/291) 51 + 52 + Search 53 + - [ ] Local 54 + - [ ] OpenSearch 26 55 27 56 Web extensions 28 57 - [ ] WebExtension integration for priority only, on some platforms
+178 -3
app/extensions/loader.js
··· 8 8 const api = window.app; 9 9 const debug = api.debug; 10 10 11 - // Track running extensions: id -> { module, manifest } 11 + // Track running extensions: id -> { module, manifest, extension } 12 12 const runningExtensions = new Map(); 13 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 + 14 71 /** 15 72 * List of built-in extensions bundled with the app. 16 73 * External extensions will be loaded from the datastore. ··· 47 104 try { 48 105 debug && console.log(`[ext:loader] Loading extension: ${id}`); 49 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 + 50 121 // Dynamically import the extension's background script 51 122 const backgroundUrl = `${path}/${backgroundScript}`; 52 123 const module = await import(backgroundUrl); ··· 58 129 59 130 runningExtensions.set(id, { 60 131 module: module.default, 132 + manifest, 61 133 extension 62 134 }); 63 135 64 - console.log(`[ext:loader] Extension loaded: ${id}`); 65 - return { success: true }; 136 + console.log(`[ext:loader] Extension loaded: ${id} (shortname: ${manifest.shortname}, builtin: ${isBuiltin})`); 137 + return { success: true, manifest }; 66 138 67 139 } catch (error) { 68 140 console.error(`[ext:loader] Failed to load extension ${id}:`, error); ··· 88 160 running.module.uninit(); 89 161 } 90 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 + 91 169 runningExtensions.delete(id); 92 170 console.log(`[ext:loader] Extension unloaded: ${id}`); 93 171 return { success: true, wasRunning: true }; ··· 118 196 export const getRunningExtensions = () => { 119 197 return Array.from(runningExtensions.entries()).map(([id, data]) => ({ 120 198 id, 199 + manifest: data.manifest, 121 200 ...data.extension 122 201 })); 123 202 }; ··· 130 209 }; 131 210 132 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 + /** 133 236 * Load all enabled built-in extensions. 134 237 * Called during app initialization. 135 238 * ··· 151 254 console.log(`[ext:loader] Loaded ${runningExtensions.size} extensions`); 152 255 }; 153 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 + 154 326 export default { 155 327 builtinExtensions, 156 328 loadExtension, ··· 158 330 reloadExtension, 159 331 getRunningExtensions, 160 332 isExtensionRunning, 333 + getExtensionByShortname, 334 + isShortNameRegistered, 335 + getExtensionManifest, 161 336 loadBuiltinExtensions 162 337 };
+59
app/index.js
··· 114 114 const prefs = () => store.get(storageKeys.PREFS); 115 115 const features = () => store.get(storageKeys.ITEMS); 116 116 117 + // Register extension management commands for cmd palette 118 + const registerExtensionCommands = () => { 119 + // Reload extension command 120 + api.commands.register({ 121 + name: 'extension reload', 122 + description: 'Reload an extension by name', 123 + execute: async (ctx) => { 124 + const extName = ctx.search?.trim(); 125 + if (!extName) { 126 + console.log('extension reload: no extension name provided'); 127 + return; 128 + } 129 + 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); 144 + if (result.success) { 145 + console.log(`Extension reloaded: ${ext.id}`); 146 + } else { 147 + console.error(`Failed to reload extension: ${result.error}`); 148 + } 149 + } 150 + }); 151 + 152 + // List extensions command 153 + api.commands.register({ 154 + name: 'extensions', 155 + description: 'List running extensions', 156 + 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 + }); 163 + 164 + // Open settings to Extensions section 165 + const p = prefs(); 166 + await openSettingsWindow(p); 167 + } 168 + }); 169 + 170 + console.log('Extension commands registered'); 171 + }; 172 + 117 173 const init = async () => { 118 174 console.log('init'); 119 175 ··· 216 272 }; 217 273 218 274 await extensionLoader.loadBuiltinExtensions(isExtensionEnabled); 275 + 276 + // Register extension dev commands 277 + registerExtensionCommands(); 219 278 220 279 //features.forEach(initIframeFeature); 221 280
+185
app/settings/settings.js
··· 211 211 return container; 212 212 }; 213 213 214 + // Render extensions settings 215 + const renderExtensionsSettings = async () => { 216 + const container = document.createElement('div'); 217 + 218 + // Loading state 219 + const loading = document.createElement('div'); 220 + loading.className = 'help-text'; 221 + loading.textContent = 'Loading extensions...'; 222 + container.appendChild(loading); 223 + 224 + try { 225 + const result = await api.extensions.list(); 226 + 227 + // Remove loading 228 + loading.remove(); 229 + 230 + if (!result.success) { 231 + const error = document.createElement('div'); 232 + error.className = 'help-text'; 233 + error.textContent = `Error: ${result.error}`; 234 + container.appendChild(error); 235 + return container; 236 + } 237 + 238 + const extensions = result.data || []; 239 + 240 + if (extensions.length === 0) { 241 + const empty = document.createElement('div'); 242 + empty.className = 'help-text'; 243 + empty.textContent = 'No extensions loaded.'; 244 + container.appendChild(empty); 245 + return container; 246 + } 247 + 248 + const extSection = document.createElement('div'); 249 + extSection.className = 'form-section'; 250 + 251 + const title = document.createElement('h3'); 252 + title.className = 'form-section-title'; 253 + title.textContent = 'Installed Extensions'; 254 + extSection.appendChild(title); 255 + 256 + extensions.forEach(ext => { 257 + const manifest = ext.manifest || {}; 258 + 259 + const card = document.createElement('div'); 260 + card.className = 'item-card'; 261 + 262 + const header = document.createElement('div'); 263 + header.className = 'item-card-header'; 264 + 265 + const cardTitle = document.createElement('div'); 266 + cardTitle.className = 'item-card-title'; 267 + 268 + const nameSpan = document.createElement('span'); 269 + nameSpan.textContent = manifest.name || ext.id; 270 + cardTitle.appendChild(nameSpan); 271 + 272 + if (manifest.version) { 273 + const versionSpan = document.createElement('span'); 274 + versionSpan.className = 'extension-version'; 275 + versionSpan.textContent = `v${manifest.version}`; 276 + versionSpan.style.cssText = 'margin-left: 8px; font-size: 11px; color: var(--text-tertiary);'; 277 + cardTitle.appendChild(versionSpan); 278 + } 279 + 280 + if (manifest.builtin) { 281 + const builtinBadge = document.createElement('span'); 282 + builtinBadge.className = 'extension-badge'; 283 + builtinBadge.textContent = 'built-in'; 284 + builtinBadge.style.cssText = 'margin-left: 8px; font-size: 10px; padding: 2px 6px; background: var(--bg-tertiary); border-radius: 4px; color: var(--text-tertiary);'; 285 + cardTitle.appendChild(builtinBadge); 286 + } 287 + 288 + header.appendChild(cardTitle); 289 + 290 + // Actions (reload button) 291 + const actions = document.createElement('div'); 292 + actions.className = 'extension-actions'; 293 + actions.style.cssText = 'display: flex; gap: 8px;'; 294 + 295 + const reloadBtn = document.createElement('button'); 296 + reloadBtn.textContent = 'Reload'; 297 + reloadBtn.style.cssText = ` 298 + padding: 4px 8px; 299 + font-size: 11px; 300 + background: var(--bg-tertiary); 301 + border: 1px solid var(--border-primary); 302 + border-radius: 4px; 303 + color: var(--text-secondary); 304 + cursor: pointer; 305 + `; 306 + reloadBtn.addEventListener('click', async () => { 307 + reloadBtn.textContent = 'Reloading...'; 308 + reloadBtn.disabled = true; 309 + try { 310 + const reloadResult = await api.extensions.reload(ext.id); 311 + if (reloadResult.success) { 312 + reloadBtn.textContent = 'Reloaded!'; 313 + setTimeout(() => { 314 + reloadBtn.textContent = 'Reload'; 315 + reloadBtn.disabled = false; 316 + }, 1000); 317 + } else { 318 + reloadBtn.textContent = 'Error'; 319 + console.error('Reload failed:', reloadResult.error); 320 + setTimeout(() => { 321 + reloadBtn.textContent = 'Reload'; 322 + reloadBtn.disabled = false; 323 + }, 2000); 324 + } 325 + } catch (err) { 326 + console.error('Reload error:', err); 327 + reloadBtn.textContent = 'Error'; 328 + setTimeout(() => { 329 + reloadBtn.textContent = 'Reload'; 330 + reloadBtn.disabled = false; 331 + }, 2000); 332 + } 333 + }); 334 + actions.appendChild(reloadBtn); 335 + 336 + header.appendChild(actions); 337 + card.appendChild(header); 338 + 339 + // Body with details 340 + const body = document.createElement('div'); 341 + body.className = 'item-card-body'; 342 + 343 + if (manifest.description) { 344 + const desc = document.createElement('div'); 345 + desc.className = 'help-text'; 346 + desc.style.marginBottom = '8px'; 347 + desc.textContent = manifest.description; 348 + body.appendChild(desc); 349 + } 350 + 351 + // Show shortname/URL 352 + const urlInfo = document.createElement('div'); 353 + urlInfo.className = 'help-text'; 354 + urlInfo.style.cssText = 'font-family: monospace; font-size: 11px;'; 355 + urlInfo.textContent = `peek://ext/${manifest.shortname || ext.id}/`; 356 + body.appendChild(urlInfo); 357 + 358 + card.appendChild(body); 359 + extSection.appendChild(card); 360 + }); 361 + 362 + container.appendChild(extSection); 363 + } catch (err) { 364 + loading.remove(); 365 + const error = document.createElement('div'); 366 + error.className = 'help-text'; 367 + error.textContent = `Error loading extensions: ${err.message}`; 368 + container.appendChild(error); 369 + } 370 + 371 + return container; 372 + }; 373 + 214 374 // Render feature settings (Peeks, Slides, etc.) 215 375 const renderFeatureSettings = (feature) => { 216 376 const { id, labels, schemas, storageKeys, defaults } = feature; ··· 426 586 const section = createSection(sectionId, name, () => renderFeatureSettings(feature)); 427 587 contentArea.appendChild(section); 428 588 } 589 + 590 + // Add Extensions section 591 + const extNav = document.createElement('a'); 592 + extNav.className = 'nav-item'; 593 + extNav.textContent = 'Extensions'; 594 + extNav.dataset.section = 'extensions'; 595 + extNav.addEventListener('click', () => showSection('extensions')); 596 + sidebarNav.appendChild(extNav); 597 + 598 + // Create extensions section with async content 599 + const extSection = document.createElement('div'); 600 + extSection.className = 'section'; 601 + extSection.id = 'section-extensions'; 602 + 603 + const extTitle = document.createElement('h2'); 604 + extTitle.className = 'section-title'; 605 + extTitle.textContent = 'Extensions'; 606 + extSection.appendChild(extTitle); 607 + 608 + // Load extensions content async 609 + renderExtensionsSettings().then(content => { 610 + extSection.appendChild(content); 611 + }); 612 + 613 + contentArea.appendChild(extSection); 429 614 430 615 // Add Datastore link 431 616 const datastoreNav = document.createElement('a');
+1
extensions/groups/manifest.json
··· 1 1 { 2 2 "id": "groups", 3 + "shortname": "groups", 3 4 "name": "Groups", 4 5 "description": "Tag-based grouping of addresses", 5 6 "version": "1.0.0",
+1
extensions/peeks/manifest.json
··· 1 1 { 2 2 "id": "peeks", 3 + "shortname": "peeks", 3 4 "name": "Peeks", 4 5 "description": "Quick access modal windows for web pages via keyboard shortcuts", 5 6 "version": "1.0.0",
+1
extensions/slides/manifest.json
··· 1 1 { 2 2 "id": "slides", 3 + "shortname": "slides", 3 4 "name": "Slides", 4 5 "description": "Edge-anchored slide-in panels triggered by keyboard shortcuts", 5 6 "version": "1.0.0",
notes/.extensibility.md.swp

This is a binary file and will not be displayed.

+138 -20
notes/extensibility.md
··· 2 2 3 3 Balance minimal install/development/distribution barriers with web-level safety at runtime. 4 4 5 - Key bits: 5 + ## Current Implementation 6 + 7 + ### Extension Structure 8 + 9 + Extensions live in `./extensions/{name}/` with: 10 + 11 + ``` 12 + extensions/ 13 + ├── groups/ 14 + │ ├── manifest.json # Extension metadata 15 + │ ├── background.js # Main logic (ES module) 16 + │ ├── config.js # Configuration 17 + │ ├── home.html # UI pages 18 + │ └── ... 19 + ├── peeks/ 20 + │ └── ... 21 + └── slides/ 22 + └── ... 23 + ``` 24 + 25 + ### Manifest Format 26 + 27 + ```json 28 + { 29 + "id": "groups", 30 + "shortname": "groups", 31 + "name": "Groups", 32 + "description": "Tag-based grouping of addresses", 33 + "version": "1.0.0", 34 + "background": "background.js", 35 + "builtin": true 36 + } 37 + ``` 38 + 39 + **Required fields:** 40 + - `id` - Unique extension identifier 41 + - `shortname` - URL path segment (lowercase alphanumeric + hyphens) 42 + - `name` - Display name 43 + - `background` - Background script filename 44 + 45 + **Optional fields:** 46 + - `description` - Extension description 47 + - `version` - Semver version string 48 + - `builtin` - If true, has full API access (default: false) 6 49 7 - - Extensions are a folder with a PWA manifest and web content files 8 - - They're opened under the peek:// protocol, each in a web content process 9 - - Their main window is hidden, like the Peek background content process 10 - - Extensions are run directly from their local folder (wherever the user selected) 11 - - Extensions are managed in the main settings app, eg add/remove, enable/disable 50 + ### Extension URLs 12 51 13 - Implementation 52 + Extensions are accessed via the `peek://ext/` protocol: 53 + 54 + ``` 55 + peek://ext/{shortname}/{file} 56 + ``` 57 + 58 + Examples: 59 + - `peek://ext/groups/home.html` 60 + - `peek://ext/peeks/settings.html` 61 + - `peek://ext/slides/background.js` 14 62 15 - - New table in datastore for extensions 16 - - New section in settings app, where users can: 17 - - Add/remove 18 - - Enable/disable 19 - - Activate/suspend/reload 20 - - Click to access settings 21 - - Peeks, Slides and Groups as built-in but disable-able extensions 22 - - Command registration moves to API, so extensions can call it 23 - - Extension related commands like the groups ones are moved to the extension 24 - - Coarse permissions flag: built-in extensions get full access to api, others are restricted from using the extensions management api (to start) 25 - - Extensions need to register a shortname for use in the peek:// address, conflicts are rejected at install time 63 + **Reserved shortnames** (cannot be used): 64 + - `app`, `ext`, `extensions`, `settings`, `system` 26 65 27 - Note: The implementation will also instigate another shift, moving as much logic into the background web app as possible, vs in node.js space, so we can eventually move to other back-ends than Electron. 66 + ### Extension Loader 28 67 29 - Capabilities via injected API: 68 + Located at `app/extensions/loader.js`, handles: 69 + 70 + - **Loading**: Fetches manifest, validates, registers shortname, imports background script 71 + - **Unloading**: Calls uninit(), unregisters shortname, removes from registry 72 + - **Reloading**: Unload + load cycle 73 + - **Conflict detection**: Rejects extensions with duplicate shortnames 74 + 75 + Built-in extensions are registered in the loader: 76 + ```javascript 77 + export const builtinExtensions = [ 78 + { id: 'groups', path: 'peek://ext/groups', backgroundScript: 'background.js' }, 79 + { id: 'peeks', path: 'peek://ext/peeks', backgroundScript: 'background.js' }, 80 + { id: 'slides', path: 'peek://ext/slides', backgroundScript: 'background.js' } 81 + ]; 82 + ``` 83 + 84 + ### Permissions Model 85 + 86 + **Coarse permission levels:** 87 + - `builtin: true` → Full API access including extension management 88 + - `builtin: false` → Restricted from `api.extensions` management APIs 89 + 90 + **Permission check:** Only `peek://app/...` addresses can manage extensions. 91 + 92 + ### Extension Management API 93 + 94 + ```javascript 95 + // List running extensions (no permission required) 96 + const result = await api.extensions.list(); 97 + // { success: true, data: [{ id, shortname, manifest, ... }] } 98 + 99 + // Get extension manifest (no permission required) 100 + const result = await api.extensions.getManifest('groups'); 101 + // { success: true, data: { id, shortname, name, ... } } 102 + 103 + // Load/unload/reload (permission required - core app only) 104 + await api.extensions.load('groups'); 105 + await api.extensions.unload('groups'); 106 + await api.extensions.reload('groups'); 107 + ``` 108 + 109 + ### Background Script Pattern 110 + 111 + ```javascript 112 + // extensions/myext/background.js 113 + import { openStore } from 'peek://app/utils.js'; 114 + import appConfig from './config.js'; 115 + 116 + const api = window.app; 117 + const { id, defaults, storageKeys } = appConfig; 118 + const store = openStore(id, defaults); 119 + 120 + const init = () => { 121 + // Register shortcuts, commands, subscriptions 122 + api.shortcuts.register('Option+x', handleShortcut, { global: true }); 123 + api.commands.register({ name: 'my command', execute: handler }); 124 + }; 125 + 126 + const uninit = () => { 127 + // Clean up 128 + api.shortcuts.unregister('Option+x', { global: true }); 129 + api.commands.unregister('my command'); 130 + }; 131 + 132 + export default { id, init, uninit }; 133 + ``` 134 + 135 + --- 136 + 137 + ## Design Goals 138 + 139 + - Extensions are a folder with a manifest and web content files 140 + - They're opened under the peek:// protocol, each in a web content process 141 + - Their main window is hidden, like the Peek background content process 142 + - Extensions are run directly from their local folder 143 + - Extensions are managed in the settings app (add/remove, enable/disable) 144 + 145 + Note: The implementation shifts logic into the background web app vs node.js space, enabling future non-Electron backends. 146 + 147 + ## Capabilities via Injected API 30 148 31 149 - Window management 32 150 - Datastore access
+211
preload.js
··· 377 377 } 378 378 }; 379 379 380 + // Extension management API 381 + // Only available to core app (peek://app/...) and builtin extensions 382 + // Uses pubsub to communicate with the extension loader in background.html 383 + api.extensions = { 384 + /** 385 + * Check if caller has permission to manage extensions 386 + * Permission is denied for external extensions (non-builtin) 387 + * @returns {boolean} 388 + */ 389 + _hasPermission: () => { 390 + // Core app always has permission 391 + if (sourceAddress.startsWith('peek://app/')) { 392 + return true; 393 + } 394 + // External extensions are not allowed to manage extensions 395 + // (builtin extensions run from peek://ext/ but are loaded by core) 396 + return false; 397 + }, 398 + 399 + /** 400 + * Get list of running extensions (read-only, no permission check) 401 + * @returns {Promise<{success: boolean, data?: Array, error?: string}>} 402 + */ 403 + list: () => { 404 + return new Promise((resolve) => { 405 + const replyTopic = `ext:list:reply:${rndm()}`; 406 + 407 + // One-time subscription for reply 408 + ipcRenderer.send('subscribe', { 409 + source: sourceAddress, 410 + scope: 1, // SYSTEM 411 + topic: replyTopic, 412 + replyTopic: replyTopic 413 + }); 414 + 415 + const handler = (ev, msg) => { 416 + ipcRenderer.removeListener(replyTopic, handler); 417 + resolve(msg); 418 + }; 419 + ipcRenderer.on(replyTopic, handler); 420 + 421 + // Request list 422 + ipcRenderer.send('publish', { 423 + source: sourceAddress, 424 + scope: 1, 425 + topic: 'ext:list', 426 + data: { replyTopic } 427 + }); 428 + 429 + // Timeout after 5s 430 + setTimeout(() => { 431 + ipcRenderer.removeListener(replyTopic, handler); 432 + resolve({ success: false, error: 'Timeout waiting for extension list' }); 433 + }, 5000); 434 + }); 435 + }, 436 + 437 + /** 438 + * Load an extension (permission required) 439 + * @param {string} id - Extension ID to load 440 + * @returns {Promise<{success: boolean, error?: string}>} 441 + */ 442 + load: (id) => { 443 + if (!api.extensions._hasPermission()) { 444 + return Promise.resolve({ success: false, error: 'Permission denied: only core app can manage extensions' }); 445 + } 446 + return new Promise((resolve) => { 447 + const replyTopic = `ext:load:reply:${rndm()}`; 448 + 449 + ipcRenderer.send('subscribe', { 450 + source: sourceAddress, 451 + scope: 1, 452 + topic: replyTopic, 453 + replyTopic: replyTopic 454 + }); 455 + 456 + const handler = (ev, msg) => { 457 + ipcRenderer.removeListener(replyTopic, handler); 458 + resolve(msg); 459 + }; 460 + ipcRenderer.on(replyTopic, handler); 461 + 462 + ipcRenderer.send('publish', { 463 + source: sourceAddress, 464 + scope: 1, 465 + topic: 'ext:load', 466 + data: { id, replyTopic } 467 + }); 468 + 469 + setTimeout(() => { 470 + ipcRenderer.removeListener(replyTopic, handler); 471 + resolve({ success: false, error: 'Timeout loading extension' }); 472 + }, 10000); 473 + }); 474 + }, 475 + 476 + /** 477 + * Unload an extension (permission required) 478 + * @param {string} id - Extension ID to unload 479 + * @returns {Promise<{success: boolean, error?: string}>} 480 + */ 481 + unload: (id) => { 482 + if (!api.extensions._hasPermission()) { 483 + return Promise.resolve({ success: false, error: 'Permission denied: only core app can manage extensions' }); 484 + } 485 + return new Promise((resolve) => { 486 + const replyTopic = `ext:unload:reply:${rndm()}`; 487 + 488 + ipcRenderer.send('subscribe', { 489 + source: sourceAddress, 490 + scope: 1, 491 + topic: replyTopic, 492 + replyTopic: replyTopic 493 + }); 494 + 495 + const handler = (ev, msg) => { 496 + ipcRenderer.removeListener(replyTopic, handler); 497 + resolve(msg); 498 + }; 499 + ipcRenderer.on(replyTopic, handler); 500 + 501 + ipcRenderer.send('publish', { 502 + source: sourceAddress, 503 + scope: 1, 504 + topic: 'ext:unload', 505 + data: { id, replyTopic } 506 + }); 507 + 508 + setTimeout(() => { 509 + ipcRenderer.removeListener(replyTopic, handler); 510 + resolve({ success: false, error: 'Timeout unloading extension' }); 511 + }, 10000); 512 + }); 513 + }, 514 + 515 + /** 516 + * Reload an extension (permission required) 517 + * @param {string} id - Extension ID to reload 518 + * @returns {Promise<{success: boolean, error?: string}>} 519 + */ 520 + reload: (id) => { 521 + if (!api.extensions._hasPermission()) { 522 + return Promise.resolve({ success: false, error: 'Permission denied: only core app can manage extensions' }); 523 + } 524 + return new Promise((resolve) => { 525 + const replyTopic = `ext:reload:reply:${rndm()}`; 526 + 527 + ipcRenderer.send('subscribe', { 528 + source: sourceAddress, 529 + scope: 1, 530 + topic: replyTopic, 531 + replyTopic: replyTopic 532 + }); 533 + 534 + const handler = (ev, msg) => { 535 + ipcRenderer.removeListener(replyTopic, handler); 536 + resolve(msg); 537 + }; 538 + ipcRenderer.on(replyTopic, handler); 539 + 540 + ipcRenderer.send('publish', { 541 + source: sourceAddress, 542 + scope: 1, 543 + topic: 'ext:reload', 544 + data: { id, replyTopic } 545 + }); 546 + 547 + setTimeout(() => { 548 + ipcRenderer.removeListener(replyTopic, handler); 549 + resolve({ success: false, error: 'Timeout reloading extension' }); 550 + }, 10000); 551 + }); 552 + }, 553 + 554 + /** 555 + * Get manifest for a running extension (read-only, no permission check) 556 + * @param {string} id - Extension ID 557 + * @returns {Promise<{success: boolean, data?: object, error?: string}>} 558 + */ 559 + getManifest: (id) => { 560 + return new Promise((resolve) => { 561 + const replyTopic = `ext:manifest:reply:${rndm()}`; 562 + 563 + ipcRenderer.send('subscribe', { 564 + source: sourceAddress, 565 + scope: 1, 566 + topic: replyTopic, 567 + replyTopic: replyTopic 568 + }); 569 + 570 + const handler = (ev, msg) => { 571 + ipcRenderer.removeListener(replyTopic, handler); 572 + resolve(msg); 573 + }; 574 + ipcRenderer.on(replyTopic, handler); 575 + 576 + ipcRenderer.send('publish', { 577 + source: sourceAddress, 578 + scope: 1, 579 + topic: 'ext:manifest', 580 + data: { id, replyTopic } 581 + }); 582 + 583 + setTimeout(() => { 584 + ipcRenderer.removeListener(replyTopic, handler); 585 + resolve({ success: false, error: 'Timeout getting manifest' }); 586 + }, 5000); 587 + }); 588 + } 589 + }; 590 + 380 591 // Escape handling API 381 592 // For windows with escapeMode: 'navigate' or 'auto' 382 593 // Callback should return { handled: true } if escape was handled internally