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:theme:* strict shims with trustedBuiltin gating (Phase 3.5b)

Add 6 new tile:theme:* IPC handlers in tile-ipc.ts mirroring the legacy
theme:* surface from ipc.ts:

tile:theme:get — current theme id + color scheme
tile:theme:setColorScheme — global color scheme (system/light/dark)
tile:theme:setWindowColorScheme — per-window color scheme
tile:theme:setTheme — set active theme by id
tile:theme:list — list registered (builtin) themes
tile:theme:getAll — all themes (builtin + external from DB)

All 6 handlers require trustedBuiltin; non-trusted callers receive
{ success: false, error: 'trustedBuiltin required' } and a capability
violation event. Private theme helpers (tileGetThemeSetting,
tileSetThemeSetting, tileBroadcastThemeChange) added at module scope to
avoid coupling to ipc.ts internals.

tile-preload.cts: add corresponding wrapper methods to the base api.theme
object routing to tile:theme:*. The existing trustedBuiltin block still
overrides get/list/getAll/setTheme/setColorScheme/setWindowColorScheme
with legacy theme:* routes — Wave 3.6b will flip that override.

No legacy handler deletions. tsc --noEmit clean.
.yarn symlinks absent in worktree — yarn test:unit skipped.

+371 -1
+307 -1
backend/electron/tile-ipc.ts
··· 104 104 import { checkEscapeAllowed } from './tile-escape-enforcement.js'; 105 105 import { checkSessionAllowed } from './tile-session-enforcement.js'; 106 106 import { checkIzuiAllowed } from './tile-izui-enforcement.js'; 107 - import { getActiveThemeId } from './protocol.js'; 107 + import { getActiveThemeId, setActiveThemeId, getRegisteredThemeIds, getThemePath } from './protocol.js'; 108 108 import { validateTileDatastoreRequest } from './tile-datastore-scope.js'; 109 109 import { resolveSettingDefault } from './tile-settings-defaults.js'; 110 110 ··· 3305 3305 }; 3306 3306 }); 3307 3307 3308 + // ── tile:theme:get ───────────────────────────────────────────────── 3309 + // 3310 + // Strict counterpart of the legacy `theme:get` channel. 3311 + // Returns current theme id, color scheme preference and effective scheme. 3312 + // Requires trustedBuiltin — non-core tiles use tile:theme:info instead. 3313 + ipcMain.handle('tile:theme:get', (_event, args: { 3314 + token: string; 3315 + }) => { 3316 + if (!args?.token) return { success: false, error: 'Invalid token' }; 3317 + const grant = getGrantForToken(args.token); 3318 + if (!grant) return { success: false, error: 'Invalid token' }; 3319 + if (!grant.trustedBuiltin) { 3320 + handleViolation(grant, 'theme', 'tile:theme:get', 'trustedBuiltin required', args.token); 3321 + return { success: false, error: 'trustedBuiltin required for tile:theme:get' }; 3322 + } 3323 + 3324 + const themeId = tileGetThemeSetting(TILE_THEME_ID_KEY) || getActiveThemeId(); 3325 + const colorScheme = tileGetThemeSetting(TILE_THEME_COLOR_SCHEME_KEY) || 'system'; 3326 + const isDark = nativeTheme.shouldUseDarkColors; 3327 + return { 3328 + themeId, 3329 + colorScheme, 3330 + isDark, 3331 + effectiveScheme: colorScheme === 'system' ? (isDark ? 'dark' : 'light') : colorScheme, 3332 + }; 3333 + }); 3334 + 3335 + // ── tile:theme:setColorScheme ────────────────────────────────────── 3336 + // 3337 + // Strict counterpart of the legacy `theme:setColorScheme` channel. 3338 + // Sets the global color scheme preference (system/light/dark). 3339 + // Requires trustedBuiltin. 3340 + ipcMain.handle('tile:theme:setColorScheme', (_event, args: { 3341 + token: string; 3342 + colorScheme: string; 3343 + }) => { 3344 + if (!args?.token) return { success: false, error: 'Invalid token' }; 3345 + const grant = getGrantForToken(args.token); 3346 + if (!grant) return { success: false, error: 'Invalid token' }; 3347 + if (!grant.trustedBuiltin) { 3348 + handleViolation(grant, 'theme', 'tile:theme:setColorScheme', 'trustedBuiltin required', args.token); 3349 + return { success: false, error: 'trustedBuiltin required for tile:theme:setColorScheme' }; 3350 + } 3351 + 3352 + const { colorScheme } = args; 3353 + if (!['system', 'light', 'dark'].includes(colorScheme)) { 3354 + return { success: false, error: 'Invalid color scheme' }; 3355 + } 3356 + 3357 + tileSetThemeSetting(TILE_THEME_COLOR_SCHEME_KEY, colorScheme); 3358 + nativeTheme.themeSource = colorScheme as 'system' | 'light' | 'dark'; 3359 + tileBroadcastThemeChange(colorScheme); 3360 + 3361 + return { 3362 + success: true, 3363 + colorScheme, 3364 + effectiveScheme: colorScheme === 'system' 3365 + ? (nativeTheme.shouldUseDarkColors ? 'dark' : 'light') 3366 + : colorScheme, 3367 + }; 3368 + }); 3369 + 3370 + // ── tile:theme:setWindowColorScheme ─────────────────────────────── 3371 + // 3372 + // Strict counterpart of the legacy `theme:setWindowColorScheme` channel. 3373 + // Sets color scheme for a specific window only (does not affect global setting). 3374 + // Requires trustedBuiltin. 3375 + ipcMain.handle('tile:theme:setWindowColorScheme', (_event, args: { 3376 + token: string; 3377 + windowId: number; 3378 + colorScheme: string; 3379 + }) => { 3380 + if (!args?.token) return { success: false, error: 'Invalid token' }; 3381 + const grant = getGrantForToken(args.token); 3382 + if (!grant) return { success: false, error: 'Invalid token' }; 3383 + if (!grant.trustedBuiltin) { 3384 + handleViolation(grant, 'theme', 'tile:theme:setWindowColorScheme', 'trustedBuiltin required', args.token); 3385 + return { success: false, error: 'trustedBuiltin required for tile:theme:setWindowColorScheme' }; 3386 + } 3387 + 3388 + const { windowId, colorScheme } = args; 3389 + if (!['system', 'light', 'dark', 'global'].includes(colorScheme)) { 3390 + return { success: false, error: 'Invalid color scheme' }; 3391 + } 3392 + 3393 + const win = BrowserWindow.fromId(windowId); 3394 + if (!win || win.isDestroyed()) { 3395 + return { success: false, error: 'Window not found' }; 3396 + } 3397 + 3398 + win.webContents.send('theme:windowChanged', { colorScheme }); 3399 + return { success: true, windowId, colorScheme }; 3400 + }); 3401 + 3402 + // ── tile:theme:setTheme ──────────────────────────────────────────── 3403 + // 3404 + // Strict counterpart of the legacy `theme:setTheme` channel. 3405 + // Sets the active theme by id and broadcasts to all windows. 3406 + // Requires trustedBuiltin. 3407 + ipcMain.handle('tile:theme:setTheme', (_event, args: { 3408 + token: string; 3409 + themeId: string; 3410 + }) => { 3411 + if (!args?.token) return { success: false, error: 'Invalid token' }; 3412 + const grant = getGrantForToken(args.token); 3413 + if (!grant) return { success: false, error: 'Invalid token' }; 3414 + if (!grant.trustedBuiltin) { 3415 + handleViolation(grant, 'theme', 'tile:theme:setTheme', 'trustedBuiltin required', args.token); 3416 + return { success: false, error: 'trustedBuiltin required for tile:theme:setTheme' }; 3417 + } 3418 + 3419 + const { themeId } = args; 3420 + if (!setActiveThemeId(themeId)) { 3421 + return { success: false, error: 'Theme not found' }; 3422 + } 3423 + tileSetThemeSetting(TILE_THEME_ID_KEY, themeId); 3424 + 3425 + const windows = BrowserWindow.getAllWindows(); 3426 + for (const win of windows) { 3427 + win.webContents.send('theme:themeChanged', { themeId }); 3428 + } 3429 + return { success: true, themeId }; 3430 + }); 3431 + 3432 + // ── tile:theme:list ──────────────────────────────────────────────── 3433 + // 3434 + // Strict counterpart of the legacy `theme:list` channel. 3435 + // Returns metadata for all registered (builtin) themes. 3436 + // Requires trustedBuiltin. 3437 + ipcMain.handle('tile:theme:list', (_event, args: { 3438 + token: string; 3439 + }) => { 3440 + if (!args?.token) return { success: false, error: 'Invalid token' }; 3441 + const grant = getGrantForToken(args.token); 3442 + if (!grant) return { success: false, error: 'Invalid token' }; 3443 + if (!grant.trustedBuiltin) { 3444 + handleViolation(grant, 'theme', 'tile:theme:list', 'trustedBuiltin required', args.token); 3445 + return { success: false, error: 'trustedBuiltin required for tile:theme:list' }; 3446 + } 3447 + 3448 + const themeIds = getRegisteredThemeIds(); 3449 + const themes = themeIds.map(id => { 3450 + const themePath = getThemePath(id); 3451 + if (!themePath) return null; 3452 + try { 3453 + const manifestPath = path.join(themePath, 'manifest.json'); 3454 + const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8')); 3455 + return { 3456 + id: manifest.id || id, 3457 + name: manifest.name || id, 3458 + version: manifest.version || '1.0.0', 3459 + description: manifest.description || '', 3460 + colorSchemes: manifest.colorSchemes || ['light', 'dark'], 3461 + }; 3462 + } catch { 3463 + return { id, name: id, version: '1.0.0', description: '', colorSchemes: ['light', 'dark'] }; 3464 + } 3465 + }).filter(Boolean); 3466 + 3467 + themes.sort((a: any, b: any) => { 3468 + if (a.id === 'peek') return -1; 3469 + if (b.id === 'peek') return 1; 3470 + return a.name.localeCompare(b.name); 3471 + }); 3472 + 3473 + return { themes }; 3474 + }); 3475 + 3476 + // ── tile:theme:getAll ────────────────────────────────────────────── 3477 + // 3478 + // Strict counterpart of the legacy `theme:getAll` channel. 3479 + // Returns all themes (builtin + external from DB). 3480 + // Requires trustedBuiltin. 3481 + ipcMain.handle('tile:theme:getAll', async (_event, args: { 3482 + token: string; 3483 + }) => { 3484 + if (!args?.token) return { success: false, error: 'Invalid token' }; 3485 + const grant = getGrantForToken(args.token); 3486 + if (!grant) return { success: false, error: 'Invalid token' }; 3487 + if (!grant.trustedBuiltin) { 3488 + handleViolation(grant, 'theme', 'tile:theme:getAll', 'trustedBuiltin required', args.token); 3489 + return { success: false, error: 'trustedBuiltin required for tile:theme:getAll' }; 3490 + } 3491 + 3492 + try { 3493 + const db = getDb(); 3494 + const builtinIds = getRegisteredThemeIds(); 3495 + const themes: Array<{ 3496 + id: string; 3497 + name: string; 3498 + description: string; 3499 + version: string; 3500 + author: string; 3501 + path: string; 3502 + builtin: boolean; 3503 + colorSchemes: string[]; 3504 + }> = []; 3505 + 3506 + for (const id of builtinIds) { 3507 + const themePath = getThemePath(id); 3508 + if (!themePath) continue; 3509 + try { 3510 + const manifestPath = path.join(themePath, 'manifest.json'); 3511 + const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8')); 3512 + themes.push({ 3513 + id: manifest.id || id, 3514 + name: manifest.name || id, 3515 + description: manifest.description || '', 3516 + version: manifest.version || '1.0.0', 3517 + author: manifest.author || '', 3518 + path: themePath, 3519 + builtin: true, 3520 + colorSchemes: manifest.colorSchemes || ['light', 'dark'], 3521 + }); 3522 + } catch { 3523 + themes.push({ 3524 + id, 3525 + name: id, 3526 + description: '', 3527 + version: '1.0.0', 3528 + author: '', 3529 + path: themePath, 3530 + builtin: true, 3531 + colorSchemes: ['light', 'dark'], 3532 + }); 3533 + } 3534 + } 3535 + 3536 + const externalThemes = db.prepare('SELECT * FROM themes WHERE builtin = 0').all() as Array<{ 3537 + id: string; 3538 + name: string; 3539 + description: string; 3540 + version: string; 3541 + author: string; 3542 + path: string; 3543 + metadata?: string; 3544 + }>; 3545 + 3546 + for (const ext of externalThemes) { 3547 + let colorSchemes = ['light', 'dark']; 3548 + try { 3549 + const metadata = JSON.parse(ext.metadata || '{}'); 3550 + colorSchemes = metadata.colorSchemes || colorSchemes; 3551 + } catch { /* ignore */ } 3552 + themes.push({ 3553 + id: ext.id, 3554 + name: ext.name, 3555 + description: ext.description, 3556 + version: ext.version, 3557 + author: ext.author, 3558 + path: ext.path, 3559 + builtin: false, 3560 + colorSchemes, 3561 + }); 3562 + } 3563 + 3564 + return { success: true, data: themes }; 3565 + } catch (error) { 3566 + const message = error instanceof Error ? error.message : String(error); 3567 + return { success: false, error: message }; 3568 + } 3569 + }); 3570 + 3308 3571 // ── Settings ── 3309 3572 3310 3573 ipcMain.handle('tile:settings:get', async (_event, args: { ··· 5713 5976 }); 5714 5977 5715 5978 DEBUG && console.log('[tile-ipc] All tile IPC handlers registered'); 5979 + } 5980 + 5981 + // ─── Theme helpers for tile:theme:* strict shims ────────────────── 5982 + // 5983 + // Private mirrors of the ipc.ts theme helpers. Used only by the 5984 + // tile:theme:* handlers registered in registerTileHandlers() above. 5985 + // Duplicated here to avoid coupling tile-ipc.ts to ipc.ts internals. 5986 + 5987 + const TILE_THEME_SETTINGS_KEY = 'core'; 5988 + const TILE_THEME_ID_KEY = 'theme.id'; 5989 + const TILE_THEME_COLOR_SCHEME_KEY = 'theme.colorScheme'; 5990 + 5991 + function tileGetThemeSetting(key: string): string | null { 5992 + try { 5993 + const db = getDb(); 5994 + const row = db.prepare( 5995 + 'SELECT value FROM feature_settings WHERE featureId = ? AND key = ?' 5996 + ).get(TILE_THEME_SETTINGS_KEY, key) as { value: string } | undefined; 5997 + if (!row?.value) return null; 5998 + try { 5999 + return JSON.parse(row.value); 6000 + } catch { 6001 + return row.value; 6002 + } 6003 + } catch { 6004 + return null; 6005 + } 6006 + } 6007 + 6008 + function tileSetThemeSetting(key: string, value: string): void { 6009 + const db = getDb(); 6010 + const timestamp = Date.now(); 6011 + db.prepare(` 6012 + INSERT OR REPLACE INTO feature_settings (id, featureId, key, value, updatedAt) 6013 + VALUES (?, ?, ?, ?, ?) 6014 + `).run(`${TILE_THEME_SETTINGS_KEY}_${key}`, TILE_THEME_SETTINGS_KEY, key, JSON.stringify(value), timestamp); 6015 + } 6016 + 6017 + function tileBroadcastThemeChange(colorScheme: string): void { 6018 + const windows = BrowserWindow.getAllWindows(); 6019 + for (const win of windows) { 6020 + win.webContents.send('theme:changed', { colorScheme }); 6021 + } 5716 6022 } 5717 6023 5718 6024 // ─── MIME type helper ─────────────────────────────────────────────
+64
backend/electron/tile-preload.cts
··· 1061 1061 callback(data); 1062 1062 }); 1063 1063 }, 1064 + 1065 + // ── tile:theme:* strict shims (Phase 3.5b) ────────────────────────── 1066 + // These route through the new tile:theme:* strict channels which enforce 1067 + // trustedBuiltin gating on the main-process side. Non-trusted callers 1068 + // will receive { success: false, error: 'trustedBuiltin required' }. 1069 + // 1070 + // NOTE: the trustedBuiltin block below OVERRIDES get/list/getAll/setTheme/ 1071 + // setColorScheme/setWindowColorScheme with legacy theme:* routes for now. 1072 + // Wave 3.6b will flip that block to call tile:theme:* and remove the 1073 + // override, at which point these base implementations become the sole path. 1074 + 1075 + /** 1076 + * Get current theme settings (id, colorScheme, isDark, effectiveScheme). 1077 + * Strict path; requires trustedBuiltin enforcement on the IPC side. 1078 + */ 1079 + get: () => { 1080 + if (!tokenValid) return Promise.reject(new Error('Not initialized')); 1081 + return ipcRenderer.invoke('tile:theme:get', { token: tileToken }); 1082 + }, 1083 + 1084 + /** 1085 + * Set global color scheme preference (system/light/dark). 1086 + * Strict path; requires trustedBuiltin enforcement on the IPC side. 1087 + */ 1088 + setColorScheme: (colorScheme: string) => { 1089 + if (!tokenValid) return Promise.reject(new Error('Not initialized')); 1090 + return ipcRenderer.invoke('tile:theme:setColorScheme', { token: tileToken, colorScheme }); 1091 + }, 1092 + 1093 + /** 1094 + * Set color scheme for a specific window only (does not affect global setting). 1095 + * Strict path; requires trustedBuiltin enforcement on the IPC side. 1096 + */ 1097 + setWindowColorScheme: (windowId: number, colorScheme: string) => { 1098 + if (!tokenValid) return Promise.reject(new Error('Not initialized')); 1099 + return ipcRenderer.invoke('tile:theme:setWindowColorScheme', { token: tileToken, windowId, colorScheme }); 1100 + }, 1101 + 1102 + /** 1103 + * Set the active theme by id. 1104 + * Strict path; requires trustedBuiltin enforcement on the IPC side. 1105 + */ 1106 + setTheme: (themeId: string) => { 1107 + if (!tokenValid) return Promise.reject(new Error('Not initialized')); 1108 + return ipcRenderer.invoke('tile:theme:setTheme', { token: tileToken, themeId }); 1109 + }, 1110 + 1111 + /** 1112 + * List available (builtin) themes with metadata. 1113 + * Strict path; requires trustedBuiltin enforcement on the IPC side. 1114 + */ 1115 + list: () => { 1116 + if (!tokenValid) return Promise.reject(new Error('Not initialized')); 1117 + return ipcRenderer.invoke('tile:theme:list', { token: tileToken }); 1118 + }, 1119 + 1120 + /** 1121 + * Get all themes (builtin + external from DB). 1122 + * Strict path; requires trustedBuiltin enforcement on the IPC side. 1123 + */ 1124 + getAll: () => { 1125 + if (!tokenValid) return Promise.reject(new Error('Not initialized')); 1126 + return ipcRenderer.invoke('tile:theme:getAll', { token: tileToken }); 1127 + }, 1064 1128 }; 1065 1129 1066 1130 // ── Settings (if granted) ──