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): tile:extensions:* strict shims with trustedBuiltin gating (Phase 3.5c)

Add 11 tile:extensions:* IPC handlers in tile-ipc.ts mirroring the legacy
extension-* channels in ipc.ts. All handlers enforce grant.trustedBuiltin
before executing — feature tiles cannot manage other extensions through the
tile surface. Handlers added: pickFolder, validateFolder, add, remove, update,
getAll, get, windowList, listAllRegistered, windowDevtools, reload.

Add corresponding api.extensions._strict wrapper methods in tile-preload.cts
that invoke the new tile:extensions:* channels with the tile token. The
existing api.extensions.* methods continue to call legacy extension-* channels
unchanged — Wave 3.6c will flip those to the strict paths and remove the
legacy invocations.

Imports getRunningExtensions, getAllRegisteredExtensions, reloadExtension from
main.js for use by the windowList, listAllRegistered, and reload handlers.

+354 -4
+298
backend/electron/tile-ipc.ts
··· 86 86 import type { CapabilityGrant, TileCapabilities } from './tile-manifest.js'; 87 87 import { invokeWindowOpen, popupToOpener } from './ipc.js'; 88 88 import { 89 + getRunningExtensions, 90 + getAllRegisteredExtensions, 91 + reloadExtension, 92 + } from './main.js'; 93 + import { 89 94 registerGlobalShortcut, 90 95 unregisterGlobalShortcut, 91 96 registerLocalShortcut, ··· 6035 6040 } catch (err) { 6036 6041 const message = err instanceof Error ? err.message : String(err); 6037 6042 return { valid: false, errors: [`Validation failed: ${message}`], warnings: [] }; 6043 + } 6044 + }); 6045 + 6046 + // ── tile:extensions:* strict shims (Phase 3.5c) ──────────────────── 6047 + // 6048 + // Strict counterparts of the legacy `extension-*` channels in ipc.ts. 6049 + // All handlers require trustedBuiltin — feature tiles must not be able 6050 + // to manage other extensions through the tile surface. 6051 + // Wave 3.6c will flip tile-preload.cts to call these channels and 6052 + // remove the legacy `extension-*` invocations. 6053 + 6054 + ipcMain.handle('tile:extensions:pickFolder', async (_event, args: { 6055 + token: string; 6056 + }) => { 6057 + if (!args?.token) return { success: false, error: 'Invalid token' }; 6058 + const grant = getGrantForToken(args.token); 6059 + if (!grant) return { success: false, error: 'Invalid token' }; 6060 + if (!grant.trustedBuiltin) { 6061 + handleViolation(grant, 'extensions', 'tile:extensions:pickFolder', 'trustedBuiltin required', args.token); 6062 + return { success: false, error: 'trustedBuiltin required for tile:extensions:pickFolder' }; 6063 + } 6064 + try { 6065 + const result = await dialog.showOpenDialog({ properties: ['openDirectory'] }); 6066 + if (result.canceled || result.filePaths.length === 0) { 6067 + return { success: false, error: 'No folder selected' }; 6068 + } 6069 + return { success: true, data: { path: result.filePaths[0] } }; 6070 + } catch (err) { 6071 + return { success: false, error: err instanceof Error ? err.message : String(err) }; 6072 + } 6073 + }); 6074 + 6075 + ipcMain.handle('tile:extensions:validateFolder', async (_event, args: { 6076 + token: string; 6077 + folderPath: string; 6078 + }) => { 6079 + if (!args?.token) return { success: false, error: 'Invalid token' }; 6080 + const grant = getGrantForToken(args.token); 6081 + if (!grant) return { success: false, error: 'Invalid token' }; 6082 + if (!grant.trustedBuiltin) { 6083 + handleViolation(grant, 'extensions', 'tile:extensions:validateFolder', 'trustedBuiltin required', args.token); 6084 + return { success: false, error: 'trustedBuiltin required for tile:extensions:validateFolder' }; 6085 + } 6086 + try { 6087 + const extPath = args.folderPath; 6088 + const manifestPath = path.join(extPath, 'manifest.json'); 6089 + if (!fs.existsSync(manifestPath)) { 6090 + return { success: false, error: 'No manifest.json found in folder' }; 6091 + } 6092 + const manifestContent = fs.readFileSync(manifestPath, 'utf-8'); 6093 + const manifest = JSON.parse(manifestContent); 6094 + if (!manifest.id && !manifest.shortname && !manifest.name) { 6095 + return { success: false, error: 'Manifest must have id, shortname, or name' }; 6096 + } 6097 + const backgroundPath = path.join(extPath, 'background.html'); 6098 + if (!fs.existsSync(backgroundPath)) { 6099 + return { success: false, error: 'No background.html found in folder' }; 6100 + } 6101 + return { success: true, data: { manifest, path: extPath } }; 6102 + } catch (err) { 6103 + return { success: false, error: err instanceof Error ? err.message : String(err) }; 6104 + } 6105 + }); 6106 + 6107 + ipcMain.handle('tile:extensions:add', async (_event, args: { 6108 + token: string; 6109 + folderPath: string; 6110 + manifest?: unknown; 6111 + enabled?: boolean; 6112 + }) => { 6113 + if (!args?.token) return { success: false, error: 'Invalid token' }; 6114 + const grant = getGrantForToken(args.token); 6115 + if (!grant) return { success: false, error: 'Invalid token' }; 6116 + if (!grant.trustedBuiltin) { 6117 + handleViolation(grant, 'extensions', 'tile:extensions:add', 'trustedBuiltin required', args.token); 6118 + return { success: false, error: 'trustedBuiltin required for tile:extensions:add' }; 6119 + } 6120 + try { 6121 + const db = getDb(); 6122 + const extPath = args.folderPath; 6123 + const manifestPath = path.join(extPath, 'manifest.json'); 6124 + if (!fs.existsSync(manifestPath)) { 6125 + return { success: false, error: 'No manifest.json found' }; 6126 + } 6127 + const manifestContent = fs.readFileSync(manifestPath, 'utf-8'); 6128 + const manifest = JSON.parse(manifestContent); 6129 + const id = manifest.id || manifest.shortname || manifest.name || `ext_${Date.now()}`; 6130 + const existing = db.prepare('SELECT * FROM extensions WHERE id = ?').get(id); 6131 + if (existing) { 6132 + return { success: false, error: `Extension ${id} already installed` }; 6133 + } 6134 + db.prepare(` 6135 + INSERT INTO extensions (id, name, description, version, path, enabled, builtin, status, installedAt, updatedAt, metadata, lastError, lastErrorAt) 6136 + VALUES (?, ?, ?, ?, ?, 1, 0, 'installed', ?, ?, ?, ?, ?) 6137 + `).run( 6138 + id, 6139 + manifest.name || id, 6140 + manifest.description || '', 6141 + manifest.version || '0.0.0', 6142 + extPath, 6143 + Date.now(), 6144 + Date.now(), 6145 + JSON.stringify(manifest), 6146 + '', 6147 + 0, 6148 + ); 6149 + return { success: true, data: { id, manifest, path: extPath } }; 6150 + } catch (err) { 6151 + return { success: false, error: err instanceof Error ? err.message : String(err) }; 6152 + } 6153 + }); 6154 + 6155 + ipcMain.handle('tile:extensions:remove', async (_event, args: { 6156 + token: string; 6157 + id: string; 6158 + }) => { 6159 + if (!args?.token) return { success: false, error: 'Invalid token' }; 6160 + const grant = getGrantForToken(args.token); 6161 + if (!grant) return { success: false, error: 'Invalid token' }; 6162 + if (!grant.trustedBuiltin) { 6163 + handleViolation(grant, 'extensions', 'tile:extensions:remove', 'trustedBuiltin required', args.token); 6164 + return { success: false, error: 'trustedBuiltin required for tile:extensions:remove' }; 6165 + } 6166 + try { 6167 + const db = getDb(); 6168 + const extId = args.id; 6169 + const existing = db.prepare('SELECT * FROM extensions WHERE id = ?').get(extId); 6170 + if (!existing) { 6171 + return { success: false, error: `Extension ${extId} not found` }; 6172 + } 6173 + db.prepare('DELETE FROM extensions WHERE id = ?').run(extId); 6174 + db.prepare('DELETE FROM feature_settings WHERE featureId = ?').run(extId); 6175 + return { success: true, data: { id: extId } }; 6176 + } catch (err) { 6177 + return { success: false, error: err instanceof Error ? err.message : String(err) }; 6178 + } 6179 + }); 6180 + 6181 + ipcMain.handle('tile:extensions:update', async (_event, args: { 6182 + token: string; 6183 + id: string; 6184 + updates: Record<string, unknown>; 6185 + }) => { 6186 + if (!args?.token) return { success: false, error: 'Invalid token' }; 6187 + const grant = getGrantForToken(args.token); 6188 + if (!grant) return { success: false, error: 'Invalid token' }; 6189 + if (!grant.trustedBuiltin) { 6190 + handleViolation(grant, 'extensions', 'tile:extensions:update', 'trustedBuiltin required', args.token); 6191 + return { success: false, error: 'trustedBuiltin required for tile:extensions:update' }; 6192 + } 6193 + try { 6194 + const db = getDb(); 6195 + const extId = args.id; 6196 + const updates = args.updates || {}; 6197 + const existing = db.prepare('SELECT * FROM extensions WHERE id = ?').get(extId); 6198 + if (!existing) { 6199 + return { success: false, error: `Extension ${extId} not found` }; 6200 + } 6201 + const fields: string[] = []; 6202 + const values: unknown[] = []; 6203 + if (updates.enabled !== undefined) { fields.push('enabled = ?'); values.push(updates.enabled ? 1 : 0); } 6204 + if (updates.status !== undefined) { fields.push('status = ?'); values.push(updates.status); } 6205 + if (updates.lastError !== undefined) { fields.push('lastError = ?'); values.push(updates.lastError); } 6206 + if (updates.lastErrorAt !== undefined) { fields.push('lastErrorAt = ?'); values.push(updates.lastErrorAt); } 6207 + if (fields.length > 0) { 6208 + fields.push('updatedAt = ?'); 6209 + values.push(Date.now()); 6210 + values.push(extId); 6211 + db.prepare(`UPDATE extensions SET ${fields.join(', ')} WHERE id = ?`).run(...values); 6212 + } 6213 + const updated = db.prepare('SELECT * FROM extensions WHERE id = ?').get(extId); 6214 + return { success: true, data: updated }; 6215 + } catch (err) { 6216 + return { success: false, error: err instanceof Error ? err.message : String(err) }; 6217 + } 6218 + }); 6219 + 6220 + ipcMain.handle('tile:extensions:getAll', async (_event, args: { 6221 + token: string; 6222 + }) => { 6223 + if (!args?.token) return { success: false, error: 'Invalid token' }; 6224 + const grant = getGrantForToken(args.token); 6225 + if (!grant) return { success: false, error: 'Invalid token' }; 6226 + if (!grant.trustedBuiltin) { 6227 + handleViolation(grant, 'extensions', 'tile:extensions:getAll', 'trustedBuiltin required', args.token); 6228 + return { success: false, error: 'trustedBuiltin required for tile:extensions:getAll' }; 6229 + } 6230 + try { 6231 + const db = getDb(); 6232 + const extensions = db.prepare('SELECT * FROM extensions').all(); 6233 + return { success: true, data: extensions }; 6234 + } catch (err) { 6235 + return { success: false, error: err instanceof Error ? err.message : String(err) }; 6236 + } 6237 + }); 6238 + 6239 + ipcMain.handle('tile:extensions:get', async (_event, args: { 6240 + token: string; 6241 + id: string; 6242 + }) => { 6243 + if (!args?.token) return { success: false, error: 'Invalid token' }; 6244 + const grant = getGrantForToken(args.token); 6245 + if (!grant) return { success: false, error: 'Invalid token' }; 6246 + if (!grant.trustedBuiltin) { 6247 + handleViolation(grant, 'extensions', 'tile:extensions:get', 'trustedBuiltin required', args.token); 6248 + return { success: false, error: 'trustedBuiltin required for tile:extensions:get' }; 6249 + } 6250 + try { 6251 + const db = getDb(); 6252 + const ext = db.prepare('SELECT * FROM extensions WHERE id = ?').get(args.id); 6253 + if (!ext) { 6254 + return { success: false, error: `Extension ${args.id} not found` }; 6255 + } 6256 + return { success: true, data: ext }; 6257 + } catch (err) { 6258 + return { success: false, error: err instanceof Error ? err.message : String(err) }; 6259 + } 6260 + }); 6261 + 6262 + ipcMain.handle('tile:extensions:windowList', async (_event, args: { 6263 + token: string; 6264 + }) => { 6265 + if (!args?.token) return { success: false, error: 'Invalid token' }; 6266 + const grant = getGrantForToken(args.token); 6267 + if (!grant) return { success: false, error: 'Invalid token' }; 6268 + if (!grant.trustedBuiltin) { 6269 + handleViolation(grant, 'extensions', 'tile:extensions:windowList', 'trustedBuiltin required', args.token); 6270 + return { success: false, error: 'trustedBuiltin required for tile:extensions:windowList' }; 6271 + } 6272 + try { 6273 + const running = getRunningExtensions(); 6274 + return { success: true, data: running }; 6275 + } catch (err) { 6276 + return { success: false, error: err instanceof Error ? err.message : String(err) }; 6277 + } 6278 + }); 6279 + 6280 + ipcMain.handle('tile:extensions:listAllRegistered', async (_event, args: { 6281 + token: string; 6282 + }) => { 6283 + if (!args?.token) return { success: false, error: 'Invalid token' }; 6284 + const grant = getGrantForToken(args.token); 6285 + if (!grant) return { success: false, error: 'Invalid token' }; 6286 + if (!grant.trustedBuiltin) { 6287 + handleViolation(grant, 'extensions', 'tile:extensions:listAllRegistered', 'trustedBuiltin required', args.token); 6288 + return { success: false, error: 'trustedBuiltin required for tile:extensions:listAllRegistered' }; 6289 + } 6290 + try { 6291 + const all = getAllRegisteredExtensions(); 6292 + return { success: true, data: all }; 6293 + } catch (err) { 6294 + return { success: false, error: err instanceof Error ? err.message : String(err) }; 6295 + } 6296 + }); 6297 + 6298 + ipcMain.handle('tile:extensions:windowDevtools', async (_event, args: { 6299 + token: string; 6300 + id: string; 6301 + }) => { 6302 + if (!args?.token) return { success: false, error: 'Invalid token' }; 6303 + const grant = getGrantForToken(args.token); 6304 + if (!grant) return { success: false, error: 'Invalid token' }; 6305 + if (!grant.trustedBuiltin) { 6306 + handleViolation(grant, 'extensions', 'tile:extensions:windowDevtools', 'trustedBuiltin required', args.token); 6307 + return { success: false, error: 'trustedBuiltin required for tile:extensions:windowDevtools' }; 6308 + } 6309 + // extensionWindows map was removed in Phase 3.2; v2 tiles have their own devtools path. 6310 + return { success: false, error: `Extension ${args.id} is not running as a legacy window` }; 6311 + }); 6312 + 6313 + ipcMain.handle('tile:extensions:reload', async (_event, args: { 6314 + token: string; 6315 + id: string; 6316 + }) => { 6317 + if (!args?.token) return { success: false, error: 'Invalid token' }; 6318 + const grant = getGrantForToken(args.token); 6319 + if (!grant) return { success: false, error: 'Invalid token' }; 6320 + if (!grant.trustedBuiltin) { 6321 + handleViolation(grant, 'extensions', 'tile:extensions:reload', 'trustedBuiltin required', args.token); 6322 + return { success: false, error: 'trustedBuiltin required for tile:extensions:reload' }; 6323 + } 6324 + try { 6325 + const extId = args.id; 6326 + if (!extId) { 6327 + return { success: false, error: 'Missing extension id' }; 6328 + } 6329 + const win = await reloadExtension(extId); 6330 + if (!win) { 6331 + return { success: false, error: `Failed to reload extension: ${extId}` }; 6332 + } 6333 + return { success: true, data: { id: extId } }; 6334 + } catch (err) { 6335 + return { success: false, error: err instanceof Error ? err.message : String(err) }; 6038 6336 } 6039 6337 }); 6040 6338
+56 -4
backend/electron/tile-preload.cts
··· 1989 1989 // - Core admin UIs (features-manager, etc.) 1990 1990 // 1991 1991 // Each method is a thin wrapper over a named IPC channel in ipc.ts 1992 - // (`extension-window-list`, `extension-reload`, ...). They don't have 1993 - // strict `tile:*` equivalents yet; exposing them here keeps the shape 1994 - // consistent with v1 and unblocks Phase 2 of v1-removal without a 1995 - // large test rewrite. 1992 + // (`extension-window-list`, `extension-reload`, ...). Strict `tile:*` 1993 + // equivalents are available via `api.extensions._strict` (Phase 3.5c). 1994 + // Wave 3.6c will flip each method here to call those strict channels. 1996 1995 // 1997 1996 // Gated on trustedBuiltin — feature tiles must not be able to manage 1998 1997 // other extensions through the tile surface. ··· 2068 2067 return Promise.resolve({ success: false, error: 'api.extensions requires trustedBuiltin' }); 2069 2068 } 2070 2069 return ipcRenderer.invoke('feature-settings-schema', { extId }); 2070 + }, 2071 + }; 2072 + 2073 + // ── Extensions strict shims (Phase 3.5c) ───────────────────────────── 2074 + // 2075 + // Strict counterparts that route through the `tile:extensions:*` channels 2076 + // in tile-ipc.ts with trustedBuiltin enforcement and capability-token 2077 + // validation. Wave 3.6c will flip `api.extensions.*` above to call these 2078 + // and remove the legacy `extension-*` invocations. 2079 + (api.extensions as Record<string, unknown>)._strict = { 2080 + pickFolder: () => { 2081 + if (!tokenValid) return Promise.reject(new Error('Not initialized')); 2082 + return ipcRenderer.invoke('tile:extensions:pickFolder', { token: tileToken }); 2083 + }, 2084 + validateFolder: (folderPath: string) => { 2085 + if (!tokenValid) return Promise.reject(new Error('Not initialized')); 2086 + return ipcRenderer.invoke('tile:extensions:validateFolder', { token: tileToken, folderPath }); 2087 + }, 2088 + add: (folderPath: string, manifest: unknown, enabled: boolean = false) => { 2089 + if (!tokenValid) return Promise.reject(new Error('Not initialized')); 2090 + return ipcRenderer.invoke('tile:extensions:add', { token: tileToken, folderPath, manifest, enabled }); 2091 + }, 2092 + remove: (id: string) => { 2093 + if (!tokenValid) return Promise.reject(new Error('Not initialized')); 2094 + return ipcRenderer.invoke('tile:extensions:remove', { token: tileToken, id }); 2095 + }, 2096 + update: (id: string, updates: unknown) => { 2097 + if (!tokenValid) return Promise.reject(new Error('Not initialized')); 2098 + return ipcRenderer.invoke('tile:extensions:update', { token: tileToken, id, updates }); 2099 + }, 2100 + getAll: () => { 2101 + if (!tokenValid) return Promise.reject(new Error('Not initialized')); 2102 + return ipcRenderer.invoke('tile:extensions:getAll', { token: tileToken }); 2103 + }, 2104 + get: (id: string) => { 2105 + if (!tokenValid) return Promise.reject(new Error('Not initialized')); 2106 + return ipcRenderer.invoke('tile:extensions:get', { token: tileToken, id }); 2107 + }, 2108 + windowList: () => { 2109 + if (!tokenValid) return Promise.reject(new Error('Not initialized')); 2110 + return ipcRenderer.invoke('tile:extensions:windowList', { token: tileToken }); 2111 + }, 2112 + listAllRegistered: () => { 2113 + if (!tokenValid) return Promise.reject(new Error('Not initialized')); 2114 + return ipcRenderer.invoke('tile:extensions:listAllRegistered', { token: tileToken }); 2115 + }, 2116 + windowDevtools: (id: string) => { 2117 + if (!tokenValid) return Promise.reject(new Error('Not initialized')); 2118 + return ipcRenderer.invoke('tile:extensions:windowDevtools', { token: tileToken, id }); 2119 + }, 2120 + reload: (id: string) => { 2121 + if (!tokenValid) return Promise.reject(new Error('Not initialized')); 2122 + return ipcRenderer.invoke('tile:extensions:reload', { token: tileToken, id }); 2071 2123 }, 2072 2124 }; 2073 2125