experiments in a post-browser web
at main 347 lines 10 kB view raw view rendered
1# Peek Extensions 2 3Extensions are isolated modules that communicate with the core app via IPC and pubsub messaging. 4 5## Hybrid Extension Architecture 6 7Peek uses a **hybrid extension loading model** that balances memory efficiency with crash isolation: 8 9### Built-in Extensions (Consolidated) 10Built-in extensions (`cmd`, `groups`, `peeks`, `slides`) run as **iframes in a single extension host window**: 11- Share a single Electron BrowserWindow process 12- Memory efficient (~80-120MB vs ~200-400MB for separate windows) 13- Origin isolation via unique URL hosts (`peek://cmd/`, `peek://groups/`, etc.) 14- If one crashes, others in the same host are affected 15 16### External Extensions (Separate Windows) 17External extensions (including `example` and user-installed) run in **separate BrowserWindows**: 18- Each has its own Electron process 19- Crash isolation - one extension crashing doesn't affect others 20- Uses `peek://ext/{id}/` URL scheme 21- Better for untrusted or experimental extensions 22 23``` 24┌─────────────────────────────────────────────────────────────┐ 25│ Extension Host Window (peek://app/extension-host.html) │ 26│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────┐ │ 27│ │ <iframe> │ │ <iframe> │ │ <iframe> │ │<iframe> │ │ 28│ │ peek://cmd/ │ │peek://groups│ │peek://peeks/│ │peek:// │ │ 29│ │ │ │ │ │ │ │slides/ │ │ 30│ └─────────────┘ └─────────────┘ └─────────────┘ └─────────┘ │ 31└─────────────────────────────────────────────────────────────┘ 32 33┌─────────────────┐ ┌─────────────────┐ 34│ BrowserWindow │ │ BrowserWindow │ 35│ peek://ext/ │ │ peek://ext/ │ 36│ example/ │ │ user-ext/ │ 37│ (separate proc) │ │ (separate proc) │ 38└─────────────────┘ └─────────────────┘ 39``` 40 41### Origin Isolation 42 43Each extension gets a unique origin regardless of loading mode: 44- Built-in: `peek://cmd/background.html` → origin `peek://cmd` 45- External: `peek://ext/example/background.html` → origin `peek://ext` 46 47This prevents cross-extension access to localStorage, DOM, and globals. 48 49## Extension Structure 50 51Each extension lives in its own directory under `features/`: 52 53``` 54features/ 55 example/ 56 manifest.json # Extension metadata 57 settings-schema.json # Settings UI schema (optional) 58 background.html # Entry point (loads background.js) 59 background.js # Main extension logic 60 *.html, *.js, *.css # Additional UI files 61``` 62 63### manifest.json 64 65Required fields: 66```json 67{ 68 "id": "example", 69 "shortname": "example", 70 "name": "Example Extension", 71 "description": "What this extension does", 72 "version": "1.0.0", 73 "background": "background.html" 74} 75``` 76 77Optional fields: 78```json 79{ 80 "builtin": true, 81 "settingsSchema": "./settings-schema.json" 82} 83``` 84 85### settings-schema.json 86 87Defines the settings UI for the extension. Used by Settings to render configuration forms. 88 89```json 90{ 91 "prefs": { 92 "type": "object", 93 "properties": { 94 "greeting": { 95 "type": "string", 96 "description": "Custom greeting message", 97 "default": "Hello World" 98 } 99 } 100 }, 101 "storageKeys": { 102 "PREFS": "prefs" 103 }, 104 "defaults": { 105 "prefs": { 106 "greeting": "Hello World" 107 } 108 } 109} 110``` 111 112For extensions with list-based settings (like peeks/slides), add an `item` schema: 113```json 114{ 115 "prefs": { ... }, 116 "item": { 117 "type": "object", 118 "properties": { 119 "title": { "type": "string", "title": "Title" }, 120 "enabled": { "type": "boolean", "title": "Enabled" } 121 } 122 }, 123 "storageKeys": { 124 "PREFS": "prefs", 125 "ITEMS": "items" 126 }, 127 "defaults": { 128 "prefs": { ... }, 129 "items": [] 130 } 131} 132``` 133 134### background.html 135 136Entry point that loads the extension as an ES module: 137 138```html 139<!DOCTYPE html> 140<html> 141<head> 142 <meta charset="UTF-8"> 143 <title>My Extension</title> 144</head> 145<body> 146<script type="module"> 147 import extension from './background.js'; 148 149 const api = window.app; 150 const extId = extension.id; 151 152 console.log(`[ext:${extId}] background.html loaded`); 153 154 // Signal ready to main process 155 api.publish('ext:ready', { 156 id: extId, 157 manifest: { 158 id: extension.id, 159 labels: extension.labels, 160 version: '1.0.0' 161 } 162 }, api.scopes.SYSTEM); 163 164 // Initialize extension 165 if (extension.init) { 166 console.log(`[ext:${extId}] calling init()`); 167 extension.init(); 168 } 169 170 // Handle shutdown 171 api.subscribe('app:shutdown', () => { 172 if (extension.uninit) extension.uninit(); 173 }, api.scopes.SYSTEM); 174 175 api.subscribe(`ext:${extId}:shutdown`, () => { 176 if (extension.uninit) extension.uninit(); 177 }, api.scopes.SYSTEM); 178</script> 179</body> 180</html> 181``` 182 183### background.js 184 185Main extension logic as an ES module: 186 187```javascript 188const api = window.app; 189 190const extension = { 191 id: 'example', 192 labels: { 193 name: 'Example' 194 }, 195 196 init() { 197 console.log('[example] init'); 198 199 // Register commands 200 api.commands.register({ 201 name: 'my-command', 202 description: 'Does something', 203 execute: () => { 204 console.log('Command executed!'); 205 } 206 }); 207 208 // Register shortcuts 209 api.shortcuts.register('Option+x', () => { 210 console.log('Shortcut triggered!'); 211 }); 212 213 // Subscribe to events 214 api.subscribe('some:event', (msg) => { 215 console.log('Event received:', msg); 216 }, api.scopes.GLOBAL); 217 }, 218 219 uninit() { 220 console.log('[example] uninit'); 221 api.commands.unregister('my-command'); 222 api.shortcuts.unregister('Option+x'); 223 } 224}; 225 226export default extension; 227``` 228 229## Extension API 230 231Extensions access the Peek API via `window.app`. See `docs/api.md` for the complete reference. 232 233Common APIs used by extensions: 234 235### Commands 236```javascript 237api.commands.register({ name, description, execute }) 238api.commands.unregister(name) 239``` 240 241### Shortcuts 242```javascript 243api.shortcuts.register(shortcut, callback) // e.g., 'Option+1' 244api.shortcuts.unregister(shortcut) 245``` 246 247### Pubsub Messaging 248```javascript 249api.publish(topic, data, scope) 250api.subscribe(topic, callback, scope) 251 252// Scopes 253api.scopes.SELF // Only this window 254api.scopes.SYSTEM // System-level events 255api.scopes.GLOBAL // All windows 256``` 257 258### Windows 259```javascript 260api.window.open(url, options) 261// Options: modal, keepLive, transparent, height, width, key 262``` 263 264### Datastore 265```javascript 266await api.datastore.getRow(table, id) 267await api.datastore.setRow(table, id, data) 268await api.datastore.deleteRow(table, id) 269await api.datastore.getTable(table) 270``` 271 272### Extension Settings 273```javascript 274await api.extensions.getSettings(extId) 275await api.extensions.setSettings(extId, key, value) 276``` 277 278## Extension Loading 279 280### Load Order and the cmd Extension 281 282The `cmd` extension is the command registry - all other extensions register their commands with it via `api.commands.register()`. Because of this dependency: 283 2841. **cmd loads first** (sequential) - must be ready before other extensions register commands 2852. **Other extensions load in parallel** - for faster startup 2863. **cmd cannot be disabled** - it's required infrastructure, not optional functionality 287 288This is enforced in `isBuiltinExtensionEnabled()` which always returns `true` for cmd. 289 290### Hybrid Loading Process 291 292Extensions are loaded in hybrid mode by `loadExtensions()` in `backend/electron/main.ts`: 293 2941. **Create extension host window** - Single BrowserWindow at `peek://app/extension-host.html` 2952. **Load built-in extensions as iframes** - `cmd`, `groups`, `peeks`, `slides` loaded via IPC into the host 2963. **Load external extensions as separate windows** - Each gets its own BrowserWindow 297 298```typescript 299// Which extensions use consolidated mode (defined in main.ts) 300const CONSOLIDATED_EXTENSION_IDS = ['cmd', 'groups', 'peeks', 'slides']; 301``` 302 303### Built-in Extensions 304 305Built-in extensions are registered in `index.js`: 306```javascript 307registerExtensionPath('example', path.join(__dirname, 'extensions', 'example')); 308``` 309 310Built-in extensions that are NOT in `CONSOLIDATED_EXTENSION_IDS` (like `example`) are treated as external and get separate windows. This is intentional - it exercises external extension code paths during development. 311 312### External Extensions 313 314External extensions are: 3151. Added via Settings UI (stored in datastore `extensions` table) 3162. Loaded on startup if `enabled === 1` and have a valid `path` 3173. Always run in separate BrowserWindows for crash isolation 318 319## Settings Integration 320 321Extensions with `settingsSchema` in their manifest automatically get a settings section in the Settings UI. The schema is loaded at runtime when the extension window is created. 322 323Settings are stored in the `extension_settings` datastore table with: 324- `extensionId`: The extension's ID 325- `key`: Setting key (e.g., 'prefs', 'items') 326- `value`: JSON-encoded setting value 327 328Extensions can listen for settings changes: 329```javascript 330api.subscribe(`${extId}:settings-changed`, (msg) => { 331 // Reload configuration 332}, api.scopes.GLOBAL); 333``` 334 335## Lifecycle Events 336 337- `ext:ready` - Published when extension is initialized 338- `ext:all-loaded` - Published when all extensions finish loading 339- `app:shutdown` - Sent before app closes 340- `ext:{id}:shutdown` - Sent when specific extension is being unloaded 341- `{extId}:settings-changed` - Sent when extension settings are modified 342 343## Debugging 344 345Console logs from extensions are forwarded to stdout with prefix `[ext:{id}]`. 346 347Run with `DEBUG=1 yarn start` for verbose logging.