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