experiments in a post-browser web
10
fork

Configure Feed

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

feat(tile-ipc): strict tile:context:* and tile:dialogs:* handlers

+556 -2
+136
backend/electron/tile-context-enforcement.ts
··· 1 + /** 2 + * Strict Context Capability Enforcement — Pure module (no Electron deps) 3 + * 4 + * Used by the `tile:context:*` IPC handlers in `tile-ipc.ts` to decide 5 + * whether a capability grant may perform a context-store operation. 6 + * 7 + * Mirrors the extracted pure-module pattern from 8 + * `tile-shortcuts-enforcement.ts` so these checks can be unit-tested 9 + * without pulling in `tile-ipc`'s Electron dependencies (ipcMain, 10 + * BrowserWindow, dialog, webContents, nativeTheme, etc.). 11 + * 12 + * See `docs/tile-preload-trimming-plan.md` §2.5 for the capability 13 + * shape and §6 for the test strategy. 14 + */ 15 + 16 + import type { CapabilityGrant, ContextCapability } from './tile-manifest.js'; 17 + 18 + /** 19 + * Which context operation is being checked. Matches the 20 + * `tile:context:*` IPC channel suffix and the shape of the 21 + * v1-compat `api.context.*` surface in tile-preload. 22 + * 23 + * - `get` / `history` — read a key; gated by `read` allowlist. 24 + * - `set` — write a key; gated by `write` allowlist. Writes to the 25 + * well-known `mode` key additionally require `modes: true`. 26 + * - `snapshot` — requires only that the capability is granted. 27 + * - `windowsWithValue` / `windowsInSpace` — require `queryWindows`. 28 + */ 29 + export type ContextOp = 30 + | 'get' 31 + | 'set' 32 + | 'history' 33 + | 'snapshot' 34 + | 'windowsWithValue' 35 + | 'windowsInSpace'; 36 + 37 + export type ContextCheckResult = 38 + | { ok: true } 39 + | { ok: false; error: string }; 40 + 41 + /** 42 + * Normalise a context-capability grant into a `ContextCapability` 43 + * object shape. `context: true` becomes `{}` (unrestricted). Any 44 + * falsy/invalid value returns `null` so the caller can short-circuit 45 + * with "capability not granted". 46 + */ 47 + function normaliseGrant(grant: CapabilityGrant | null): ContextCapability | null { 48 + if (!grant) return null; 49 + const cc = grant.capabilities.context; 50 + if (!cc) return null; 51 + if (cc === true) return {}; 52 + if (typeof cc === 'object') return cc; 53 + return null; 54 + } 55 + 56 + /** 57 + * Validate whether a grant may perform a context operation. 58 + * 59 + * For key-scoped ops (`get`, `set`, `history`), the `key` argument is 60 + * required and checked against the relevant allowlist. For the other 61 + * ops the `key` is ignored. 62 + * 63 + * Order of checks: 64 + * 1. Grant must be non-null (token resolved). 65 + * 2. `context` capability must be present. 66 + * 3. Per-op checks: 67 + * - `get` / `history`: key must be in `read` allowlist when set. 68 + * - `set`: key must be in `write` allowlist when set. If key is 69 + * the well-known `mode` string, `modes: true` is additionally 70 + * required. 71 + * - `snapshot`: no additional check. 72 + * - `windowsWithValue` / `windowsInSpace`: require `queryWindows: true`. 73 + * 74 + * Rationale for the `mode`-key special case: the `mode` key drives 75 + * window/space/group routing across Peek and is power-user territory. 76 + * Tiles that declare `context: { write: ['mode'] }` without 77 + * `modes: true` are almost certainly doing something unintended; the 78 + * explicit flag is a belt-and-braces guard. 79 + */ 80 + export function checkContextAllowed( 81 + grant: CapabilityGrant | null, 82 + op: ContextOp, 83 + key?: string, 84 + ): ContextCheckResult { 85 + if (!grant) return { ok: false, error: 'Invalid token' }; 86 + 87 + const cap = normaliseGrant(grant); 88 + if (!cap) return { ok: false, error: 'context capability not granted' }; 89 + 90 + switch (op) { 91 + case 'get': 92 + case 'history': { 93 + if (cap.read) { 94 + if (typeof key !== 'string' || key.length === 0) { 95 + return { ok: false, error: 'context key required for read operations' }; 96 + } 97 + if (!cap.read.includes(key)) { 98 + return { ok: false, error: `context key "${key}" not in read allowlist` }; 99 + } 100 + } 101 + return { ok: true }; 102 + } 103 + 104 + case 'set': { 105 + if (typeof key !== 'string' || key.length === 0) { 106 + return { ok: false, error: 'context key required for set operations' }; 107 + } 108 + if (cap.write && !cap.write.includes(key)) { 109 + return { ok: false, error: `context key "${key}" not in write allowlist` }; 110 + } 111 + // Belt-and-braces: writing the `mode` key requires the modes flag. 112 + if (key === 'mode' && cap.modes !== true) { 113 + return { ok: false, error: 'context.modes capability required to write "mode" key' }; 114 + } 115 + return { ok: true }; 116 + } 117 + 118 + case 'snapshot': { 119 + return { ok: true }; 120 + } 121 + 122 + case 'windowsWithValue': 123 + case 'windowsInSpace': { 124 + if (cap.queryWindows !== true) { 125 + return { ok: false, error: 'context.queryWindows capability required' }; 126 + } 127 + return { ok: true }; 128 + } 129 + 130 + default: { 131 + // Exhaustiveness guard. 132 + const _exhaustive: never = op; 133 + return { ok: false, error: `unknown context op: ${String(_exhaustive)}` }; 134 + } 135 + } 136 + }
+71
backend/electron/tile-dialogs-enforcement.ts
··· 1 + /** 2 + * Strict Dialogs Capability Enforcement — Pure module (no Electron deps) 3 + * 4 + * Used by the `tile:dialogs:*` IPC handlers in `tile-ipc.ts` to decide 5 + * whether a capability grant may invoke a native file dialog (save/open). 6 + * 7 + * Mirrors the extracted pure-module pattern from 8 + * `tile-shortcuts-enforcement.ts` so these checks can be unit-tested 9 + * without pulling in `tile-ipc`'s Electron dependencies. 10 + * 11 + * Background: the v1-compat surface exposed file dialogs via the 12 + * `file-save-dialog` / `file-open-dialog` IPC channels and the 13 + * `api.files.save` / `api.files.open` preload wrappers, with NO 14 + * capability check. The strict handlers gate the same underlying 15 + * Electron `dialog.show{Save,Open}Dialog` calls behind a declared 16 + * `dialogs` capability plus an optional per-type allowlist. 17 + */ 18 + 19 + import type { CapabilityGrant, DialogType, DialogsCapability } from './tile-manifest.js'; 20 + 21 + export type DialogsCheckResult = 22 + | { ok: true } 23 + | { ok: false; error: string }; 24 + 25 + /** 26 + * Normalise a dialogs-capability grant into a `DialogsCapability` 27 + * object shape. `dialogs: true` becomes `{}` (unrestricted). Any 28 + * falsy/invalid value returns `null` so the caller can short-circuit 29 + * with "capability not granted". 30 + */ 31 + function normaliseGrant(grant: CapabilityGrant | null): DialogsCapability | null { 32 + if (!grant) return null; 33 + const dc = grant.capabilities.dialogs; 34 + if (!dc) return null; 35 + if (dc === true) return {}; 36 + if (typeof dc === 'object') return dc; 37 + return null; 38 + } 39 + 40 + /** 41 + * Validate whether a grant may show the given dialog type. 42 + * 43 + * Order of checks: 44 + * 1. Grant must be non-null (token resolved). 45 + * 2. `dialogs` capability must be present. 46 + * 3. If the capability is `{ types: [...] }`, the requested type 47 + * must appear in the list. 48 + * 49 + * Treats `dialogs: true` and `dialogs: {}` (object without `types`) 50 + * as unrestricted. Treats `dialogs: { types: [] }` as fully locked — 51 + * no dialog type is allowed. This matches the manifest author's 52 + * intent: an explicit empty allowlist means "I declared the 53 + * capability but block everything". 54 + */ 55 + export function checkDialogAllowed( 56 + grant: CapabilityGrant | null, 57 + type: DialogType, 58 + ): DialogsCheckResult { 59 + if (!grant) return { ok: false, error: 'Invalid token' }; 60 + 61 + const cap = normaliseGrant(grant); 62 + if (!cap) return { ok: false, error: 'dialogs capability not granted' }; 63 + 64 + if (cap.types) { 65 + if (!cap.types.includes(type)) { 66 + return { ok: false, error: `dialog type "${type}" not in capability types allowlist` }; 67 + } 68 + } 69 + 70 + return { ok: true }; 71 + }
+349 -2
backend/electron/tile-ipc.ts
··· 58 58 deleteItemEvent as dsDeleteItemEvent, 59 59 deleteItemEvents as dsDeleteItemEvents, 60 60 getItemEvent as dsGetItemEvent, 61 + addContextEntry, 62 + getContextEntry, 63 + queryContextHistory, 64 + getContextSnapshot, 65 + getWindowsWithContextValue, 66 + getWindowsMatchingContext, 61 67 } from './datastore.js'; 62 68 import type { TableName } from '../types/index.js'; 63 69 import { installFromBundle } from './feature-installer.js'; ··· 71 77 unregisterLocalShortcut, 72 78 } from './shortcuts.js'; 73 79 import { checkShortcutAllowed } from './tile-shortcuts-enforcement.js'; 80 + import { checkContextAllowed } from './tile-context-enforcement.js'; 81 + import { checkDialogAllowed } from './tile-dialogs-enforcement.js'; 74 82 import { getActiveThemeId } from './protocol.js'; 75 83 import { validateTileDatastoreRequest } from './tile-datastore-scope.js'; 76 84 import { resolveSettingDefault } from './tile-settings-defaults.js'; ··· 81 89 // Re-export the strict-shortcut helper so older callers / tests can still 82 90 // import from 'tile-ipc' even though the logic lives in its own pure module. 83 91 export { checkShortcutAllowed, type ShortcutCheckResult } from './tile-shortcuts-enforcement.js'; 92 + export { checkContextAllowed, type ContextCheckResult, type ContextOp } from './tile-context-enforcement.js'; 93 + export { checkDialogAllowed, type DialogsCheckResult } from './tile-dialogs-enforcement.js'; 84 94 85 95 // ─── Helpers ───────────────────────────────────────────────────────── 86 96 ··· 193 203 const cur = currentCaps || {} as TileCapabilities; 194 204 const nxt = newCaps || {} as TileCapabilities; 195 205 196 - // Boolean capabilities 197 - const boolCaps: Array<keyof TileCapabilities> = ['commands', 'shortcuts', 'sync', 'theme', 'settings']; 206 + // Boolean capabilities (or boolean-or-object capabilities treated as 207 + // present/absent for change detection — deeper allowlist diffs are 208 + // out of scope for the auto-update expansion guard). 209 + const boolCaps: Array<keyof TileCapabilities> = ['commands', 'shortcuts', 'sync', 'theme', 'settings', 'context', 'dialogs']; 198 210 for (const cap of boolCaps) { 199 211 const had = !!cur[cap]; 200 212 const has = !!nxt[cap]; ··· 543 555 } else { 544 556 const modeConditions = modeStr ? { majorMode: modeStr as import('./datastore.js').MajorModeId } : undefined; 545 557 unregisterLocalShortcut(args.shortcut, source, modeConditions); 558 + } 559 + }); 560 + 561 + // ── Context (strict) ────────────────────────────────────────────── 562 + // 563 + // Strict tile:context:* handlers that enforce the `context` capability. 564 + // The legacy `context-get` / `context-set` / `context-history` / 565 + // `context-snapshot` / `context-windows-*` channels in ipc.ts remain 566 + // the v1-compat surface and are NOT token-gated — tile-preload routes 567 + // through these strict handlers when the tile's manifest declares the 568 + // `context` capability. See docs/tile-preload-trimming-plan.md §2.5. 569 + // 570 + // Validation order for each op: 571 + // 1. Token must resolve to a valid grant. 572 + // 2. Grant must include the `context` capability (boolean or object). 573 + // 3. Per-op gates applied via checkContextAllowed (pure module): 574 + // - get/history: key must be in `read` allowlist (if set). 575 + // - set: key must be in `write` allowlist (if set); writing the 576 + // `mode` key additionally requires `modes: true`. 577 + // - windowsWithValue/windowsInSpace: require `queryWindows: true`. 578 + // - snapshot: no per-op gate beyond the capability itself. 579 + 580 + ipcMain.handle('tile:context:get', async (ev, args: { 581 + token: string; 582 + key: string; 583 + windowId?: number | null; 584 + }) => { 585 + const grant = getGrantForToken(args.token); 586 + const check = checkContextAllowed(grant, 'get', args.key); 587 + if (!check.ok) { 588 + DEBUG && console.log(`[tile-ipc] context:get rejected: ${check.error}`); 589 + return { success: false, error: check.error }; 590 + } 591 + try { 592 + let windowId = args.windowId; 593 + if (windowId == null) { 594 + const callingWin = BrowserWindow.fromWebContents(ev.sender); 595 + windowId = callingWin?.id ?? null; 596 + } 597 + const entry = getContextEntry(args.key, windowId); 598 + return { success: true, data: entry }; 599 + } catch (err) { 600 + const message = err instanceof Error ? err.message : String(err); 601 + return { success: false, error: message }; 602 + } 603 + }); 604 + 605 + ipcMain.handle('tile:context:set', async (ev, args: { 606 + token: string; 607 + key: string; 608 + value: unknown; 609 + metadata?: Record<string, unknown>; 610 + windowId?: number | null; 611 + }) => { 612 + const grant = getGrantForToken(args.token); 613 + const check = checkContextAllowed(grant, 'set', args.key); 614 + if (!check.ok) { 615 + DEBUG && console.log(`[tile-ipc] context:set rejected: ${check.error}`); 616 + return { success: false, error: check.error }; 617 + } 618 + // check.ok guarantees grant is non-null. 619 + const activeGrant = grant as CapabilityGrant; 620 + try { 621 + let windowId = args.windowId; 622 + if (windowId == null) { 623 + const callingWin = BrowserWindow.fromWebContents(ev.sender); 624 + windowId = callingWin?.id ?? null; 625 + } 626 + const source = `peek://${activeGrant.tileId}/background`; 627 + const result = addContextEntry(args.key, args.value, { 628 + metadata: args.metadata, 629 + windowId, 630 + source, 631 + }); 632 + // Publish context:changed so watchers see the write (parity with 633 + // the legacy context-set handler in ipc.ts). 634 + publish( 635 + source, 636 + scopes.GLOBAL, 637 + 'context:changed', 638 + { 639 + key: args.key, 640 + value: args.value, 641 + metadata: args.metadata || {}, 642 + windowId, 643 + source, 644 + entryId: result.id, 645 + }, 646 + ); 647 + return { success: true, data: result }; 648 + } catch (err) { 649 + const message = err instanceof Error ? err.message : String(err); 650 + return { success: false, error: message }; 651 + } 652 + }); 653 + 654 + ipcMain.handle('tile:context:history', async (_ev, args: { 655 + token: string; 656 + key?: string; 657 + windowId?: number | null; 658 + since?: number; 659 + until?: number; 660 + limit?: number; 661 + order?: 'asc' | 'desc'; 662 + }) => { 663 + const grant = getGrantForToken(args.token); 664 + // history has a key-scoped gate only when the caller specified a 665 + // key. Unscoped history queries still require the capability but 666 + // not a specific allowlist entry — the datastore query itself 667 + // returns all keys, which the read allowlist cannot meaningfully 668 + // restrict without iterating per-entry. We therefore require an 669 + // explicit `key` when a read allowlist is configured. 670 + const cap = grant?.capabilities.context; 671 + if (grant && cap && typeof cap === 'object' && Array.isArray((cap as { read?: string[] }).read) && !args.key) { 672 + return { success: false, error: 'context key required for history when read allowlist is configured' }; 673 + } 674 + const check = checkContextAllowed(grant, 'history', args.key); 675 + if (!check.ok) { 676 + DEBUG && console.log(`[tile-ipc] context:history rejected: ${check.error}`); 677 + return { success: false, error: check.error }; 678 + } 679 + try { 680 + const entries = queryContextHistory({ 681 + key: args.key, 682 + windowId: args.windowId ?? undefined, 683 + since: args.since, 684 + until: args.until, 685 + limit: args.limit, 686 + order: args.order, 687 + }); 688 + return { success: true, data: entries }; 689 + } catch (err) { 690 + const message = err instanceof Error ? err.message : String(err); 691 + return { success: false, error: message }; 692 + } 693 + }); 694 + 695 + ipcMain.handle('tile:context:snapshot', async (_ev, args: { 696 + token: string; 697 + timestamp?: number; 698 + keys?: string[]; 699 + }) => { 700 + const grant = getGrantForToken(args.token); 701 + const check = checkContextAllowed(grant, 'snapshot'); 702 + if (!check.ok) { 703 + DEBUG && console.log(`[tile-ipc] context:snapshot rejected: ${check.error}`); 704 + return { success: false, error: check.error }; 705 + } 706 + try { 707 + const ts = typeof args.timestamp === 'number' ? args.timestamp : Date.now(); 708 + const snapshot = getContextSnapshot(ts, args.keys || []); 709 + return { success: true, data: snapshot }; 710 + } catch (err) { 711 + const message = err instanceof Error ? err.message : String(err); 712 + return { success: false, error: message }; 713 + } 714 + }); 715 + 716 + ipcMain.handle('tile:context:windows-with-value', async (_ev, args: { 717 + token: string; 718 + key: string; 719 + value: unknown; 720 + }) => { 721 + const grant = getGrantForToken(args.token); 722 + const check = checkContextAllowed(grant, 'windowsWithValue', args.key); 723 + if (!check.ok) { 724 + DEBUG && console.log(`[tile-ipc] context:windows-with-value rejected: ${check.error}`); 725 + return { success: false, error: check.error }; 726 + } 727 + try { 728 + const windowIds = getWindowsWithContextValue(args.key, args.value); 729 + return { success: true, data: windowIds }; 730 + } catch (err) { 731 + const message = err instanceof Error ? err.message : String(err); 732 + return { success: false, error: message }; 733 + } 734 + }); 735 + 736 + ipcMain.handle('tile:context:windows-in-space', async (_ev, args: { 737 + token: string; 738 + spaceId: string; 739 + }) => { 740 + const grant = getGrantForToken(args.token); 741 + const check = checkContextAllowed(grant, 'windowsInSpace'); 742 + if (!check.ok) { 743 + DEBUG && console.log(`[tile-ipc] context:windows-in-space rejected: ${check.error}`); 744 + return { success: false, error: check.error }; 745 + } 746 + try { 747 + const windowIds = getWindowsMatchingContext('mode', (entry) => { 748 + return entry.value === 'space' && (entry.metadata as { spaceId?: string } | undefined)?.spaceId === args.spaceId; 749 + }); 750 + return { success: true, data: windowIds }; 751 + } catch (err) { 752 + const message = err instanceof Error ? err.message : String(err); 753 + return { success: false, error: message }; 754 + } 755 + }); 756 + 757 + // ── Dialogs (strict) ────────────────────────────────────────────── 758 + // 759 + // Strict tile:dialogs:save / tile:dialogs:open handlers that enforce 760 + // the `dialogs` capability. The v1-compat surface was the 761 + // `file-save-dialog` / `file-open-dialog` IPC channels with 762 + // `api.files.save/open` preload wrappers, neither capability-gated. 763 + // The strict handlers reuse the same Electron `dialog.show*Dialog` 764 + // flow while requiring a declared capability + optional type 765 + // allowlist. See docs/tile-preload-trimming-plan.md §2.4. 766 + 767 + ipcMain.handle('tile:dialogs:save', async (ev, args: { 768 + token: string; 769 + content: string; 770 + filename?: string; 771 + mimeType?: string; 772 + }) => { 773 + const grant = getGrantForToken(args.token); 774 + const check = checkDialogAllowed(grant, 'save'); 775 + if (!check.ok) { 776 + DEBUG && console.log(`[tile-ipc] dialogs:save rejected: ${check.error}`); 777 + return { success: false, error: check.error }; 778 + } 779 + try { 780 + const filters: Electron.FileFilter[] = []; 781 + if (args.mimeType) { 782 + const extMap: Record<string, { name: string; extensions: string[] }> = { 783 + 'application/json': { name: 'JSON', extensions: ['json'] }, 784 + 'text/csv': { name: 'CSV', extensions: ['csv'] }, 785 + 'text/plain': { name: 'Text', extensions: ['txt'] }, 786 + 'text/html': { name: 'HTML', extensions: ['html', 'htm'] }, 787 + }; 788 + const filter = extMap[args.mimeType]; 789 + if (filter) filters.push(filter); 790 + } 791 + filters.push({ name: 'All Files', extensions: ['*'] }); 792 + 793 + const senderWindow = BrowserWindow.fromWebContents(ev.sender); 794 + const focusedWindow = BrowserWindow.getFocusedWindow(); 795 + const parentWindow = focusedWindow && !focusedWindow.isDestroyed() 796 + ? focusedWindow 797 + : (senderWindow && !senderWindow.isDestroyed() && senderWindow.isVisible() ? senderWindow : null); 798 + 799 + const dialogOpts: Electron.SaveDialogOptions = { 800 + defaultPath: args.filename, 801 + filters, 802 + }; 803 + const result = parentWindow 804 + ? await dialog.showSaveDialog(parentWindow, dialogOpts) 805 + : await dialog.showSaveDialog(dialogOpts); 806 + 807 + if (result.canceled || !result.filePath) { 808 + return { success: false, canceled: true }; 809 + } 810 + 811 + fs.writeFileSync(result.filePath, args.content, 'utf-8'); 812 + return { success: true, path: result.filePath }; 813 + } catch (err) { 814 + const message = err instanceof Error ? err.message : String(err); 815 + return { success: false, error: message }; 816 + } 817 + }); 818 + 819 + ipcMain.handle('tile:dialogs:open', async (ev, args: { 820 + token: string; 821 + }) => { 822 + const grant = getGrantForToken(args.token); 823 + const check = checkDialogAllowed(grant, 'open'); 824 + if (!check.ok) { 825 + DEBUG && console.log(`[tile-ipc] dialogs:open rejected: ${check.error}`); 826 + return { success: false, error: check.error }; 827 + } 828 + try { 829 + const senderWindow = BrowserWindow.fromWebContents(ev.sender); 830 + const focusedWindow = BrowserWindow.getFocusedWindow(); 831 + const parentWindow = focusedWindow && !focusedWindow.isDestroyed() 832 + ? focusedWindow 833 + : (senderWindow && !senderWindow.isDestroyed() && senderWindow.isVisible() ? senderWindow : null); 834 + 835 + const openDialogOpts: Electron.OpenDialogOptions = { 836 + properties: ['openFile'], 837 + filters: [ 838 + { name: 'All Files', extensions: ['*'] }, 839 + { name: 'Text Files', extensions: ['txt', 'md', 'json', 'html', 'csv', 'xml', 'yaml', 'yml', 'toml', 'ini', 'cfg', 'log'] }, 840 + { name: 'Documents', extensions: ['pdf', 'doc', 'docx', 'rtf'] }, 841 + ], 842 + }; 843 + const result = parentWindow 844 + ? await dialog.showOpenDialog(parentWindow, openDialogOpts) 845 + : await dialog.showOpenDialog(openDialogOpts); 846 + 847 + if (result.canceled || result.filePaths.length === 0) { 848 + return { success: false, canceled: true }; 849 + } 850 + 851 + const filePath = result.filePaths[0]; 852 + const stats = fs.statSync(filePath); 853 + const fileName = path.basename(filePath); 854 + const ext = path.extname(filePath).toLowerCase(); 855 + 856 + const extToMime: Record<string, string> = { 857 + '.md': 'text/markdown', 858 + '.markdown': 'text/markdown', 859 + '.txt': 'text/plain', 860 + '.text': 'text/plain', 861 + '.json': 'application/json', 862 + '.html': 'text/html', 863 + '.htm': 'text/html', 864 + '.csv': 'text/csv', 865 + '.xml': 'application/xml', 866 + '.yaml': 'text/yaml', 867 + '.yml': 'text/yaml', 868 + '.svg': 'image/svg+xml', 869 + }; 870 + const mimeType = extToMime[ext] || 'application/octet-stream'; 871 + const isText = mimeType.startsWith('text/') || 872 + mimeType === 'application/json' || 873 + mimeType === 'application/xml' || 874 + mimeType === 'image/svg+xml'; 875 + 876 + let content: string | null = null; 877 + if (isText) content = fs.readFileSync(filePath, 'utf-8'); 878 + 879 + return { 880 + success: true, 881 + data: { 882 + name: fileName, 883 + path: filePath, 884 + size: stats.size, 885 + mimeType, 886 + content, 887 + isText, 888 + }, 889 + }; 890 + } catch (err) { 891 + const message = err instanceof Error ? err.message : String(err); 892 + return { success: false, error: message }; 546 893 } 547 894 }); 548 895