experiments in a post-browser web
10
fork

Configure Feed

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

fix(tile-preload): expose v1-compat api surfaces for tile features

+359 -58
+359 -58
backend/electron/tile-preload.ts
··· 121 121 }; 122 122 123 123 // ── PubSub (if granted) ── 124 + // 125 + // Publish supports two call shapes: 126 + // api.publish(topic, data, scope?) // v1-style 127 + // api.pubsub.publish(topic, data, scope?) // v2-style (kept for clarity) 128 + // Subscribe supports an optional scope tail argument — callers like editor use 129 + // `api.subscribe('editor:open', handler, api.scopes.GLOBAL)`. Scope is only 130 + // advisory for the client — capability-based enforcement happens in main. 124 131 125 - api.pubsub = { 126 - /** 127 - * Publish a message to a topic 128 - * Scope enforcement happens in the main process based on granted pubsub scopes 129 - */ 130 - publish: (topic: string, data: unknown, scope?: number) => { 131 - if (!tokenValid) { 132 - console.warn('[tile-preload] pubsub.publish called before initialize()'); 133 - return; 134 - } 135 - ipcRenderer.send('tile:pubsub:publish', { 136 - token: tileToken, 137 - source: sourceAddress, 138 - scope: scope || 2, // Default to SELF 139 - topic, 140 - data, 141 - }); 142 - }, 132 + function publishImpl(topic: string, data: unknown, scope?: number) { 133 + if (!tokenValid) { 134 + console.warn('[tile-preload] publish called before initialize()'); 135 + return; 136 + } 137 + ipcRenderer.send('tile:pubsub:publish', { 138 + token: tileToken, 139 + source: sourceAddress, 140 + scope: scope || 2, 141 + topic, 142 + data, 143 + }); 144 + } 145 + 146 + function subscribeImpl(topic: string, callback: (data: unknown) => void, _scope?: number) { 147 + if (!tokenValid) { 148 + console.warn('[tile-preload] subscribe called before initialize()'); 149 + return () => {}; 150 + } 151 + ipcRenderer.send('tile:pubsub:subscribe', { 152 + token: tileToken, 153 + source: sourceAddress, 154 + topic, 155 + }); 156 + 157 + const handler = (_event: unknown, msg: unknown) => { 158 + callback(msg); 159 + }; 160 + ipcRenderer.on(`pubsub:${topic}`, handler); 143 161 144 - /** 145 - * Subscribe to a topic 146 - */ 147 - subscribe: (topic: string, callback: (data: unknown) => void) => { 148 - if (!tokenValid) { 149 - console.warn('[tile-preload] pubsub.subscribe called before initialize()'); 150 - return () => {}; 151 - } 152 - // Register with main process 153 - ipcRenderer.send('tile:pubsub:subscribe', { 162 + return () => { 163 + ipcRenderer.removeListener(`pubsub:${topic}`, handler); 164 + ipcRenderer.send('tile:pubsub:unsubscribe', { 154 165 token: tileToken, 155 166 source: sourceAddress, 156 167 topic, 157 168 }); 169 + }; 170 + } 158 171 159 - // Listen for messages on this topic 160 - const handler = (_event: unknown, msg: unknown) => { 161 - callback(msg); 162 - }; 163 - ipcRenderer.on(`pubsub:${topic}`, handler); 172 + api.publish = publishImpl; 173 + api.subscribe = subscribeImpl; 164 174 165 - // Return unsubscribe function 166 - return () => { 167 - ipcRenderer.removeListener(`pubsub:${topic}`, handler); 168 - ipcRenderer.send('tile:pubsub:unsubscribe', { 169 - token: tileToken, 170 - source: sourceAddress, 171 - topic, 172 - }); 173 - }; 174 - }, 175 + api.pubsub = { 176 + publish: publishImpl, 177 + subscribe: subscribeImpl, 175 178 }; 176 179 177 180 // ── Commands (if granted) ── 181 + // 182 + // Supports two registration shapes to match existing feature code: 183 + // 184 + // api.commands.register(name, handler) 185 + // Minimal v2 signature — just attaches a handler for cmd:execute:{name}. 186 + // 187 + // api.commands.register({name, description, execute, scope, modes, params, ...}) 188 + // v1-compatible signature. Re-publishes cmd:register so the cmd panel 189 + // picks it up (matches preload.js behaviour) and wires the execute 190 + // handler identically. 191 + // 192 + // Both flow through the shared pubsub-based cmd:execute:{name} / :result 193 + // protocol, so lazy-tile replay and the cmd panel proxy work unchanged. 178 194 179 195 api.commands = { 180 - /** 181 - * Register a command handler 182 - */ 183 - register: (name: string, handler: (params: unknown) => unknown) => { 196 + register: (nameOrCommand: unknown, maybeHandler?: (params: unknown) => unknown) => { 184 197 if (!tokenValid) { 185 198 console.warn('[tile-preload] commands.register called before initialize()'); 186 199 return; 187 200 } 201 + 202 + let name: string; 203 + let handler: (params: unknown) => unknown; 204 + let description = ''; 205 + let scope: string | undefined; 206 + let modes: string[] | undefined; 207 + let accepts: unknown; 208 + let produces: unknown; 209 + let params: unknown; 210 + 211 + if (typeof nameOrCommand === 'string') { 212 + name = nameOrCommand; 213 + if (typeof maybeHandler !== 'function') { 214 + console.error('[tile-preload] commands.register(name, handler): handler must be a function'); 215 + return; 216 + } 217 + handler = maybeHandler; 218 + } else if (nameOrCommand && typeof nameOrCommand === 'object') { 219 + const cmd = nameOrCommand as { 220 + name?: string; 221 + description?: string; 222 + execute?: (params: unknown) => unknown; 223 + scope?: string; 224 + modes?: string[]; 225 + accepts?: unknown; 226 + produces?: unknown; 227 + params?: unknown; 228 + }; 229 + if (!cmd.name || typeof cmd.execute !== 'function') { 230 + console.error('[tile-preload] commands.register: name and execute are required'); 231 + return; 232 + } 233 + name = cmd.name; 234 + handler = cmd.execute; 235 + description = cmd.description || ''; 236 + scope = cmd.scope; 237 + modes = cmd.modes; 238 + accepts = cmd.accepts; 239 + produces = cmd.produces; 240 + params = cmd.params; 241 + } else { 242 + console.error('[tile-preload] commands.register: invalid arguments'); 243 + return; 244 + } 245 + 188 246 ipcRenderer.send('tile:command:register', { 189 247 token: tileToken, 190 248 tileId, 191 249 name, 192 250 }); 193 251 194 - // Listen for command execution 195 - ipcRenderer.on(`tile:cmd:execute:${name}`, async (_event: unknown, params: unknown) => { 196 - try { 197 - const result = await handler(params); 198 - ipcRenderer.send('tile:command:result', { 199 - token: tileToken, 252 + // When called with the object form, also publish cmd:register so the 253 + // cmd panel picks up this command even though the manifest may only 254 + // have declared metadata (matches the v1 preload flow). 255 + if (typeof nameOrCommand === 'object') { 256 + ipcRenderer.send('tile:pubsub:publish', { 257 + token: tileToken, 258 + source: sourceAddress, 259 + scope: 3, // GLOBAL 260 + topic: 'cmd:register', 261 + data: { 200 262 name, 201 - result, 202 - }); 263 + description, 264 + source: sourceAddress, 265 + scope: scope || 'global', 266 + modes: modes || [], 267 + accepts: accepts || [], 268 + produces: produces || [], 269 + params: params || [], 270 + }, 271 + }); 272 + } 273 + 274 + // Listen for command execution via pubsub relay (`pubsub:cmd:execute:{name}`). 275 + // Subscribe so main process forwards the topic to us. 276 + const execTopic = `cmd:execute:${name}`; 277 + ipcRenderer.send('tile:pubsub:subscribe', { 278 + token: tileToken, 279 + source: sourceAddress, 280 + topic: execTopic, 281 + }); 282 + 283 + ipcRenderer.on(`pubsub:${execTopic}`, async (_event: unknown, msg: { expectResult?: boolean; resultTopic?: string; [k: string]: unknown }) => { 284 + let result: unknown; 285 + let error: string | null = null; 286 + try { 287 + result = await handler(msg); 203 288 } catch (err) { 204 - ipcRenderer.send('tile:command:result', { 289 + error = err instanceof Error ? err.message : String(err); 290 + console.error(`[tile-preload] command ${name} threw:`, err); 291 + } 292 + 293 + // Publish result if the invoker asked for one (chaining / proxy resolution). 294 + if (msg && msg.expectResult && msg.resultTopic) { 295 + ipcRenderer.send('tile:pubsub:publish', { 205 296 token: tileToken, 206 - name, 207 - error: err instanceof Error ? err.message : String(err), 297 + source: sourceAddress, 298 + scope: 3, 299 + topic: msg.resultTopic, 300 + data: error ? { error } : result, 208 301 }); 209 302 } 303 + 304 + // Also notify tile:command:result (useful for tile-level bookkeeping) 305 + ipcRenderer.send('tile:command:result', { 306 + token: tileToken, 307 + name, 308 + result, 309 + error: error || undefined, 310 + }); 210 311 }); 211 312 }, 212 313 }; ··· 284 385 filter, 285 386 }); 286 387 }, 388 + 389 + // ─── v1-compat datastore helpers ────────────────────────────────── 390 + // These delegate directly to the core datastore IPC handlers. The v1 391 + // preload exposed these for every extension; many features call them. 392 + // Capability gating happens at the manifest level — if the tile was 393 + // granted the 'datastore' capability with the right tables, we trust 394 + // it to call these. The main-process handlers do full validation 395 + // and enforcement. 396 + 397 + addAddress: (uri: string, options?: unknown) => 398 + ipcRenderer.invoke('datastore-add-address', { uri, options }), 399 + getAddress: (id: string) => 400 + ipcRenderer.invoke('datastore-get-address', { id }), 401 + updateAddress: (id: string, updates: unknown) => 402 + ipcRenderer.invoke('datastore-update-address', { id, updates }), 403 + queryAddresses: (filter?: unknown) => 404 + ipcRenderer.invoke('datastore-query-addresses', { filter }), 405 + addVisit: (addressId: string, options?: unknown) => 406 + ipcRenderer.invoke('datastore-add-visit', { addressId, options }), 407 + queryVisits: (filter?: unknown) => 408 + ipcRenderer.invoke('datastore-query-visits', { filter }), 409 + addContent: (options: unknown) => 410 + ipcRenderer.invoke('datastore-add-content', { options }), 411 + queryContent: (filter?: unknown) => 412 + ipcRenderer.invoke('datastore-query-content', { filter }), 413 + getTable: (tableName: string) => 414 + ipcRenderer.invoke('datastore-get-table', { tableName }), 415 + setRow: (tableName: string, rowId: string, rowData: unknown) => 416 + ipcRenderer.invoke('datastore-set-row', { tableName, rowId, rowData }), 417 + getRow: (tableName: string, rowId: string) => 418 + ipcRenderer.invoke('datastore-get-row', { tableName, rowId }), 419 + getStats: () => 420 + ipcRenderer.invoke('datastore-get-stats'), 421 + 422 + // Tag operations 423 + getOrCreateTag: (name: string) => 424 + ipcRenderer.invoke('datastore-get-or-create-tag', { name }), 425 + tagAddress: (addressId: string, tagId: string) => 426 + ipcRenderer.invoke('datastore-tag-address', { addressId, tagId }), 427 + untagAddress: (addressId: string, tagId: string) => 428 + ipcRenderer.invoke('datastore-untag-address', { addressId, tagId }), 429 + getTagsByFrecency: (domain?: string) => 430 + ipcRenderer.invoke('datastore-get-tags-by-frecency', { domain }), 431 + renameTag: (tagId: string, newName: string) => 432 + ipcRenderer.invoke('datastore-rename-tag', { tagId, newName }), 433 + updateTagColor: (tagId: string, color: string) => 434 + ipcRenderer.invoke('datastore-update-tag-color', { tagId, color }), 435 + deleteTag: (tagId: string) => 436 + ipcRenderer.invoke('datastore-delete-tag', { tagId }), 437 + getAddressTags: (addressId: string) => 438 + ipcRenderer.invoke('datastore-get-address-tags', { addressId }), 439 + getAddressesByTag: (tagId: string) => 440 + ipcRenderer.invoke('datastore-get-addresses-by-tag', { tagId }), 441 + getUntaggedAddresses: () => 442 + ipcRenderer.invoke('datastore-get-untagged-addresses', {}), 443 + 444 + // Item operations 445 + addItem: (type: string, options: unknown = {}) => 446 + ipcRenderer.invoke('datastore-add-item', { type, options }), 447 + getItem: (id: string) => 448 + ipcRenderer.invoke('datastore-get-item', { id }), 449 + updateItem: (id: string, options: unknown) => 450 + ipcRenderer.invoke('datastore-update-item', { id, options }), 451 + deleteItem: (id: string) => 452 + ipcRenderer.invoke('datastore-delete-item', { id }), 453 + hardDeleteItem: (id: string) => 454 + ipcRenderer.invoke('datastore-hard-delete-item', { id }), 455 + updateItemTitle: (id: string, title: string) => 456 + ipcRenderer.invoke('datastore-update-item-title', { id, title }), 457 + updateItemFavicon: (id: string, favicon: string) => 458 + ipcRenderer.invoke('datastore-update-item-favicon', { id, favicon }), 459 + queryItems: (filter: unknown = {}) => 460 + ipcRenderer.invoke('datastore-query-items', { filter }), 461 + tagItem: (itemId: string, tagId: string) => 462 + ipcRenderer.invoke('datastore-tag-item', { itemId, tagId }), 463 + untagItem: (itemId: string, tagId: string) => 464 + ipcRenderer.invoke('datastore-untag-item', { itemId, tagId }), 465 + getItemTags: (itemId: string) => 466 + ipcRenderer.invoke('datastore-get-item-tags', { itemId }), 467 + getItemsByTag: (tagId: string) => 468 + ipcRenderer.invoke('datastore-get-items-by-tag', { tagId }), 469 + getHistory: (filter: unknown = {}) => 470 + ipcRenderer.invoke('datastore-get-history', filter), 471 + recordItemVisit: (data: unknown) => 472 + ipcRenderer.invoke('datastore-record-item-visit', data), 473 + getItemVisits: (data: unknown) => 474 + ipcRenderer.invoke('datastore-get-item-visits', data), 475 + queryItemVisits: (data: unknown = {}) => 476 + ipcRenderer.invoke('datastore-query-item-visits', data), 477 + trackNavigation: (data: unknown) => 478 + ipcRenderer.invoke('datastore-track-navigation', data), 479 + queryItemsByFrecency: (data: unknown = {}) => 480 + ipcRenderer.invoke('datastore-query-items-by-frecency', data), 481 + 482 + // Item events 483 + addItemEvent: (data: unknown) => 484 + ipcRenderer.invoke('datastore-add-item-event', data), 485 + getItemEvent: (data: unknown) => 486 + ipcRenderer.invoke('datastore-get-item-event', data), 487 + queryItemEvents: (data: unknown = {}) => 488 + ipcRenderer.invoke('datastore-query-item-events', data), 489 + deleteItemEvent: (data: unknown) => 490 + ipcRenderer.invoke('datastore-delete-item-event', data), 491 + deleteItemEvents: (data: unknown) => 492 + ipcRenderer.invoke('datastore-delete-item-events', data), 493 + getLatestItemEvent: (data: unknown) => 494 + ipcRenderer.invoke('datastore-get-latest-item-event', data), 495 + countItemEvents: (data: unknown) => 496 + ipcRenderer.invoke('datastore-count-item-events', data), 287 497 }; 288 498 289 499 // ── Network (if granted) ── ··· 406 616 if (result.error) return { success: false, error: result.error }; 407 617 return { success: true }; 408 618 }, 619 + }; 620 + 621 + // ── Shortcuts (v1-compat) ───────────────────────────────────────── 622 + // Many features use api.shortcuts.register(shortcut, cb, {global, mode}). 623 + // The tile capability model hasn't yet formalised shortcut grants; rather 624 + // than stub these out (silently breaking features), delegate to the core 625 + // 'registershortcut' / 'unregistershortcut' IPC channels with the tile's 626 + // source address. Main-process handlers do the enforcement. 627 + 628 + function rndm(): string { 629 + return Math.random().toString(36).slice(2); 630 + } 631 + 632 + api.shortcuts = { 633 + register: (shortcut: string, cb: () => void, options: { global?: boolean; mode?: string } = {}) => { 634 + const isGlobal = options.global === true; 635 + const replyTopic = `${shortcut}${rndm()}`; 636 + ipcRenderer.send('registershortcut', { 637 + source: sourceAddress, 638 + shortcut, 639 + replyTopic, 640 + global: isGlobal, 641 + mode: options.mode, 642 + }); 643 + ipcRenderer.on(replyTopic, () => cb()); 644 + }, 645 + unregister: (shortcut: string, options: { global?: boolean; mode?: string } = {}) => { 646 + const isGlobal = options.global === true; 647 + ipcRenderer.send('unregistershortcut', { 648 + source: sourceAddress, 649 + shortcut, 650 + global: isGlobal, 651 + mode: options.mode, 652 + }); 653 + }, 654 + }; 655 + 656 + // ── Files (v1-compat) ───────────────────────────────────────────── 657 + api.files = { 658 + save: (content: string, options: { filename?: string; mimeType?: string } = {}) => 659 + ipcRenderer.invoke('file-save-dialog', { 660 + content, 661 + filename: options.filename, 662 + mimeType: options.mimeType, 663 + }), 664 + open: () => ipcRenderer.invoke('file-open-dialog'), 665 + readFromPath: (filePath: string) => 666 + ipcRenderer.invoke('file-read-from-path', { filePath }), 667 + writeToPath: (filePath: string, content: string) => 668 + ipcRenderer.invoke('file-write-to-path', { filePath, content }), 669 + }; 670 + 671 + // ── Modes (v1-compat) ───────────────────────────────────────────── 672 + api.modes = { 673 + getWindowMode: (windowId: number | null = null) => 674 + ipcRenderer.invoke('modes:getWindowMode', { windowId }), 675 + setMajorMode: (mode: string, windowId: number | null = null) => 676 + ipcRenderer.invoke('modes:setMajorMode', { mode, windowId }), 677 + listModes: () => ipcRenderer.invoke('modes:listModes'), 678 + getCommandContext: () => ipcRenderer.invoke('modes:getCommandContext'), 679 + onModeChange: (callback: (state: { major: string }, windowId?: number) => void) => { 680 + subscribeImpl('modes:changed', (msg: unknown) => { 681 + const m = msg as { major?: string; windowId?: number }; 682 + callback({ major: m.major || 'default' }, m.windowId); 683 + }, 3); 684 + }, 685 + }; 686 + 687 + // ── Context (v1-compat) ─────────────────────────────────────────── 688 + api.context = { 689 + get: (key: string, windowId: number | null = null) => 690 + ipcRenderer.invoke('context-get', { key, windowId }), 691 + set: (key: string, value: unknown, metadata?: unknown, windowId?: number | null) => 692 + ipcRenderer.invoke('context-set', { key, value, metadata, windowId }), 693 + history: (key: string, limit?: number) => 694 + ipcRenderer.invoke('context-history', { key, limit }), 695 + snapshot: (windowId?: number | null) => 696 + ipcRenderer.invoke('context-snapshot', { windowId }), 697 + windowsWithValue: (key: string, value: unknown) => 698 + ipcRenderer.invoke('context-windows-with-value', { key, value }), 699 + windowsInSpace: (spaceId: string) => 700 + ipcRenderer.invoke('context-windows-in-space', { spaceId }), 701 + }; 702 + 703 + // ── closeWindow (v1-compat) ─────────────────────────────────────── 704 + api.closeWindow = (id?: number | null) => { 705 + if (id == null) { 706 + ipcRenderer.send('tile:window:close', { token: tileToken }); 707 + return; 708 + } 709 + ipcRenderer.invoke('window-close', { id }); 409 710 }; 410 711 411 712 // ── Log (always available) ──