experiments in a post-browser web
10
fork

Configure Feed

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

feat(tile-preload): route api.context.* and api.dialogs.* through strict IPC when capability declared

+96 -2
+96 -2
backend/electron/tile-preload.ts
··· 907 907 }; 908 908 api.modes = wrapCompatObject('modes', modesCompat as unknown as Record<string, unknown>); 909 909 910 - // ── Context (v1-compat) ─────────────────────────────────────────── 910 + // ── Context ─────────────────────────────────────────────────────── 911 + // 912 + // Dual-path implementation mirroring api.shortcuts: 913 + // 914 + // - STRICT: when the tile's manifest declared a `context` capability 915 + // (`true` or `{ read?, write?, modes?, queryWindows? }`), route 916 + // through `tile:context:*`. Main-process handlers validate the 917 + // token + capability shape + per-op gates. 918 + // 919 + // - V1-COMPAT: when no context capability was declared, fall back 920 + // to the legacy `context-*` IPC channels. Those remain available 921 + // through the Phase 3/4 migration window. 922 + // 923 + // The decision is made per-call because `grantedCapabilities` is 924 + // populated asynchronously by `initialize()`. 925 + 926 + function hasContextCapability(): boolean { 927 + const cc = grantedCapabilities?.context; 928 + if (cc === true) return true; 929 + if (cc && typeof cc === 'object') return true; 930 + return false; 931 + } 932 + 911 933 const contextCompat = { 912 934 get: (key: string, windowId: number | null = null) => 913 935 ipcRenderer.invoke('context-get', { key, windowId }), ··· 922 944 windowsInSpace: (spaceId: string) => 923 945 ipcRenderer.invoke('context-windows-in-space', { spaceId }), 924 946 }; 925 - api.context = wrapCompatObject('context', contextCompat as unknown as Record<string, unknown>); 947 + 948 + const contextStrict = { 949 + get: (key: string, windowId: number | null = null) => 950 + ipcRenderer.invoke('tile:context:get', { token: tileToken, key, windowId }), 951 + set: (key: string, value: unknown, metadata?: Record<string, unknown>, windowId?: number | null) => 952 + ipcRenderer.invoke('tile:context:set', { token: tileToken, key, value, metadata, windowId }), 953 + history: (key: string, limit?: number) => 954 + ipcRenderer.invoke('tile:context:history', { token: tileToken, key, limit }), 955 + snapshot: (windowId?: number | null) => 956 + ipcRenderer.invoke('tile:context:snapshot', { token: tileToken, windowId }), 957 + windowsWithValue: (key: string, value: unknown) => 958 + ipcRenderer.invoke('tile:context:windows-with-value', { token: tileToken, key, value }), 959 + windowsInSpace: (spaceId: string) => 960 + ipcRenderer.invoke('tile:context:windows-in-space', { token: tileToken, spaceId }), 961 + }; 962 + 963 + api.context = { 964 + get: wrapCompat('context', 'get', (key: unknown, windowId: unknown) => { 965 + return hasContextCapability() 966 + ? contextStrict.get(key as string, (windowId ?? null) as number | null) 967 + : contextCompat.get(key as string, (windowId ?? null) as number | null); 968 + }), 969 + set: wrapCompat('context', 'set', (key: unknown, value: unknown, metadata: unknown, windowId: unknown) => { 970 + return hasContextCapability() 971 + ? contextStrict.set(key as string, value, metadata as Record<string, unknown> | undefined, (windowId ?? null) as number | null) 972 + : contextCompat.set(key as string, value, metadata, (windowId ?? null) as number | null); 973 + }), 974 + history: wrapCompat('context', 'history', (key: unknown, limit: unknown) => { 975 + return hasContextCapability() 976 + ? contextStrict.history(key as string, limit as number | undefined) 977 + : contextCompat.history(key as string, limit as number | undefined); 978 + }), 979 + snapshot: wrapCompat('context', 'snapshot', (windowId: unknown) => { 980 + return hasContextCapability() 981 + ? contextStrict.snapshot((windowId ?? null) as number | null) 982 + : contextCompat.snapshot((windowId ?? null) as number | null); 983 + }), 984 + windowsWithValue: wrapCompat('context', 'windowsWithValue', (key: unknown, value: unknown) => { 985 + return hasContextCapability() 986 + ? contextStrict.windowsWithValue(key as string, value) 987 + : contextCompat.windowsWithValue(key as string, value); 988 + }), 989 + windowsInSpace: wrapCompat('context', 'windowsInSpace', (spaceId: unknown) => { 990 + return hasContextCapability() 991 + ? contextStrict.windowsInSpace(spaceId as string) 992 + : contextCompat.windowsInSpace(spaceId as string); 993 + }), 994 + }; 995 + 996 + // ── Dialogs ─────────────────────────────────────────────────────── 997 + // 998 + // Strict-only API: `api.dialogs.save` / `api.dialogs.open` route 999 + // through `tile:dialogs:*` when the tile declared a `dialogs` 1000 + // capability (`true` or `{ types: [...] }`). When the capability is 1001 + // not declared the strict handler rejects with `dialogs capability 1002 + // not granted`. 1003 + // 1004 + // v1-compat continues through `api.files.save` / `api.files.open`, 1005 + // which directly hit the un-gated `file-save-dialog` / `file-open- 1006 + // dialog` channels. The two surfaces coexist until Phase 4 removal. 1007 + 1008 + api.dialogs = { 1009 + save: (content: unknown, options: unknown = {}) => { 1010 + const opts = (options || {}) as { filename?: string; mimeType?: string }; 1011 + return ipcRenderer.invoke('tile:dialogs:save', { 1012 + token: tileToken, 1013 + content: content as string, 1014 + filename: opts.filename, 1015 + mimeType: opts.mimeType, 1016 + }); 1017 + }, 1018 + open: () => ipcRenderer.invoke('tile:dialogs:open', { token: tileToken }), 1019 + }; 926 1020 927 1021 // ── closeWindow (v1-compat) ─────────────────────────────────────── 928 1022 api.closeWindow = wrapCompat('closeWindow', '(call)', (id?: unknown) => {