experiments in a post-browser web
10
fork

Configure Feed

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

feat(v1-removal): Phase 3.7f — migrate theme directory mgmt to strict tile:theme:* handlers

Move theme:pickFolder / theme:validateFolder / theme:add / theme:remove /
theme:reload from legacy ipc.ts handlers to strict tile:theme:* handlers
in tile-ipc.ts. Behavior preserved exactly (same dialog, same DB inserts,
same theme:reload broadcast).

- ipc.ts: drop legacy theme:* handlers (registerThemeHandlers() now just
the strict-handler comment)
- tile-ipc.ts: import registerThemePath from protocol.js, add
tile:theme:pickFolder/validateFolder/add/remove/reload behind
trustedBuiltin grant
- tile-preload.cts: api.theme.{pickFolder,validateFolder,add,remove,reload}
now invoke strict channels with token

Second of 5 namespace migrations in Phase 3.7f. Build green, unit tests
588/588.

+229 -191
+6 -182
backend/electron/ipc.ts
··· 384 384 } 385 385 }); 386 386 387 - // ===== Theme management (add/remove/reload) ===== 388 - 389 - // Open folder picker for themes 390 - ipcMain.handle('theme:pickFolder', async () => { 391 - try { 392 - const result = await dialog.showOpenDialog({ 393 - properties: ['openDirectory'] 394 - }); 395 - if (result.canceled || result.filePaths.length === 0) { 396 - return { success: false, canceled: true }; 397 - } 398 - return { success: true, data: { path: result.filePaths[0] } }; 399 - } catch (error) { 400 - const message = error instanceof Error ? error.message : String(error); 401 - return { success: false, error: message }; 402 - } 403 - }); 404 - 405 - // Validate a theme folder 406 - ipcMain.handle('theme:validateFolder', async (_ev, data) => { 407 - try { 408 - const themePath = data.folderPath || data.path; 409 - const manifestPath = path.join(themePath, 'manifest.json'); 410 - 411 - if (!fs.existsSync(manifestPath)) { 412 - return { success: false, error: 'No manifest.json found in folder' }; 413 - } 414 - 415 - const manifestContent = fs.readFileSync(manifestPath, 'utf-8'); 416 - const manifest = JSON.parse(manifestContent); 417 - 418 - if (!manifest.id && !manifest.name) { 419 - return { success: false, error: 'Manifest must have id or name' }; 420 - } 421 - 422 - // Check for variables.css (or whatever the manifest points to) 423 - const variablesFile = manifest.variables || 'variables.css'; 424 - const variablesPath = path.join(themePath, variablesFile.replace(/^\.\//, '')); 425 - if (!fs.existsSync(variablesPath)) { 426 - return { success: false, error: `Theme CSS file not found: ${variablesFile}` }; 427 - } 428 - 429 - // Validate that all required Base16 variables and semantic aliases are present 430 - const validation = validateThemeCSS(variablesPath); 431 - if (!validation.valid) { 432 - return { 433 - success: false, 434 - error: `Theme CSS missing required variables: ${validation.missing!.join(', ')}` 435 - }; 436 - } 437 - 438 - return { 439 - success: true, 440 - data: { 441 - manifest, 442 - path: themePath 443 - } 444 - }; 445 - } catch (error) { 446 - const message = error instanceof Error ? error.message : String(error); 447 - return { success: false, error: message }; 448 - } 449 - }); 450 - 451 - // Add a theme 452 - ipcMain.handle('theme:add', async (_ev, data) => { 453 - try { 454 - const db = getDb(); 455 - const themePath = data.folderPath || data.path; 456 - const manifestPath = path.join(themePath, 'manifest.json'); 457 - 458 - if (!fs.existsSync(manifestPath)) { 459 - return { success: false, error: 'No manifest.json found' }; 460 - } 461 - 462 - const manifestContent = fs.readFileSync(manifestPath, 'utf-8'); 463 - const manifest = JSON.parse(manifestContent); 464 - const id = manifest.id || manifest.name || `theme_${Date.now()}`; 465 - 466 - // Validate theme CSS has all required variables 467 - const variablesFile = manifest.variables || 'variables.css'; 468 - const cssPath = path.join(themePath, variablesFile.replace(/^\.\//, '')); 469 - const validation = validateThemeCSS(cssPath); 470 - if (!validation.valid) { 471 - return { 472 - success: false, 473 - error: `Theme CSS missing required variables: ${validation.missing!.join(', ')}` 474 - }; 475 - } 476 - 477 - // Check if already exists 478 - const existing = db.prepare('SELECT * FROM themes WHERE id = ?').get(id); 479 - if (existing) { 480 - return { success: false, error: `Theme ${id} already installed` }; 481 - } 482 - 483 - const lastError = data.lastError || ''; 484 - const lastErrorAt = lastError ? Date.now() : 0; 485 - 486 - // Insert into database 487 - db.prepare(` 488 - INSERT INTO themes (id, name, description, version, author, path, builtin, enabled, installedAt, updatedAt, lastError, lastErrorAt, metadata) 489 - VALUES (?, ?, ?, ?, ?, ?, 0, 1, ?, ?, ?, ?, ?) 490 - `).run( 491 - id, 492 - manifest.name || id, 493 - manifest.description || '', 494 - manifest.version || '1.0.0', 495 - manifest.author || '', 496 - themePath, 497 - Date.now(), 498 - Date.now(), 499 - lastError, 500 - lastErrorAt, 501 - JSON.stringify(manifest) 502 - ); 503 - 504 - // Register the theme path so it's available via peek://theme/ 505 - const { registerThemePath } = await import('./protocol.js'); 506 - registerThemePath(id, themePath); 507 - 508 - return { success: true, data: { id, manifest, path: themePath, lastError: lastError || null } }; 509 - } catch (error) { 510 - const message = error instanceof Error ? error.message : String(error); 511 - return { success: false, error: message }; 512 - } 513 - }); 514 - 515 - // Remove a theme 516 - ipcMain.handle('theme:remove', async (_ev, data) => { 517 - try { 518 - const db = getDb(); 519 - const themeId = data.id; 520 - 521 - // Check if exists 522 - const existing = db.prepare('SELECT * FROM themes WHERE id = ?').get(themeId) as { builtin?: number } | undefined; 523 - if (!existing) { 524 - return { success: false, error: `Theme ${themeId} not found` }; 525 - } 526 - 527 - // Don't allow removing builtin themes 528 - if (existing.builtin === 1) { 529 - return { success: false, error: 'Cannot remove built-in theme' }; 530 - } 531 - 532 - // Prevent removing the active theme — at least one theme must always be enabled 533 - const activeId = getActiveThemeId(); 534 - if (themeId === activeId) { 535 - return { success: false, error: 'Cannot remove the active theme. Switch to another theme first.' }; 536 - } 537 - 538 - // Remove from database 539 - db.prepare('DELETE FROM themes WHERE id = ?').run(themeId); 540 - 541 - return { success: true, data: { id: themeId } }; 542 - } catch (error) { 543 - const message = error instanceof Error ? error.message : String(error); 544 - return { success: false, error: message }; 545 - } 546 - }); 547 - 548 - // Reload a theme (re-read manifest, notify windows) 549 - ipcMain.handle('theme:reload', async (_ev, data) => { 550 - try { 551 - const themeId = data.id; 552 - const themePath = getThemePath(themeId); 553 - if (!themePath) { 554 - return { success: false, error: 'Theme not found' }; 555 - } 556 - 557 - // Broadcast to all windows to reload their CSS 558 - const windows = BrowserWindow.getAllWindows(); 559 - for (const win of windows) { 560 - win.webContents.send('theme:reload', { themeId }); 561 - } 562 - 563 - return { success: true, data: { id: themeId } }; 564 - } catch (error) { 565 - const message = error instanceof Error ? error.message : String(error); 566 - return { success: false, error: message }; 567 - } 568 - }); 387 + // ===== Theme management (add/remove/reload) — Phase 3.7f ===== 388 + // 389 + // Strict `tile:theme:pickFolder` / `tile:theme:validateFolder` / 390 + // `tile:theme:add` / `tile:theme:remove` / `tile:theme:reload` handlers 391 + // live in tile-ipc.ts. Settings UI consumes them via api.theme.{pickFolder, 392 + // validateFolder, add, remove, reload}. 569 393 } 570 394 571 395 // Keep old function name for compatibility but call new one
+216 -1
backend/electron/tile-ipc.ts
··· 113 113 import { checkEscapeAllowed } from './tile-escape-enforcement.js'; 114 114 import { checkSessionAllowed } from './tile-session-enforcement.js'; 115 115 import { checkIzuiAllowed } from './tile-izui-enforcement.js'; 116 - import { getActiveThemeId, setActiveThemeId, getRegisteredThemeIds, getThemePath } from './protocol.js'; 116 + import { getActiveThemeId, setActiveThemeId, getRegisteredThemeIds, getThemePath, registerThemePath } from './protocol.js'; 117 117 import { validateTileDatastoreRequest } from './tile-datastore-scope.js'; 118 118 import { resolveSettingDefault } from './tile-settings-defaults.js'; 119 119 import { ··· 3870 3870 } 3871 3871 3872 3872 return { success: true, data: themes }; 3873 + } catch (error) { 3874 + const message = error instanceof Error ? error.message : String(error); 3875 + return { success: false, error: message }; 3876 + } 3877 + }); 3878 + 3879 + // ── tile:theme:pickFolder / validateFolder / add / remove / reload (Phase 3.7f) ─ 3880 + // 3881 + // Strict counterparts of the legacy `theme:pickFolder`/`theme:validateFolder`/ 3882 + // `theme:add`/`theme:remove`/`theme:reload` channels in ipc.ts (deleted in 3883 + // Phase 3.7f). Settings UI uses these to install user-supplied themes from 3884 + // disk. trustedBuiltin only — touches filesystem and theme registration. 3885 + 3886 + registerTileIpc('tile:theme:pickFolder', { mode: 'handle' }, async (event, args: { 3887 + token: string; 3888 + }, _grant) => { 3889 + if (!args?.token) return { success: false, error: 'Invalid token' }; 3890 + const grant = _grant; 3891 + if (!grant.trustedBuiltin) { 3892 + handleViolation(grant, 'theme', 'tile:theme:pickFolder', 'trustedBuiltin required', args.token); 3893 + return { success: false, error: 'trustedBuiltin required for tile:theme:pickFolder' }; 3894 + } 3895 + try { 3896 + const result = await dialog.showOpenDialog({ properties: ['openDirectory'] }); 3897 + if (result.canceled || result.filePaths.length === 0) { 3898 + return { success: false, canceled: true }; 3899 + } 3900 + return { success: true, data: { path: result.filePaths[0] } }; 3901 + } catch (error) { 3902 + const message = error instanceof Error ? error.message : String(error); 3903 + return { success: false, error: message }; 3904 + } 3905 + }); 3906 + 3907 + registerTileIpc('tile:theme:validateFolder', { mode: 'handle' }, async (event, args: { 3908 + token: string; 3909 + folderPath?: string; 3910 + path?: string; 3911 + }, _grant) => { 3912 + if (!args?.token) return { success: false, error: 'Invalid token' }; 3913 + const grant = _grant; 3914 + if (!grant.trustedBuiltin) { 3915 + handleViolation(grant, 'theme', 'tile:theme:validateFolder', 'trustedBuiltin required', args.token); 3916 + return { success: false, error: 'trustedBuiltin required for tile:theme:validateFolder' }; 3917 + } 3918 + try { 3919 + const themePath = args.folderPath || args.path; 3920 + if (!themePath) return { success: false, error: 'No folder path provided' }; 3921 + const manifestPath = path.join(themePath, 'manifest.json'); 3922 + 3923 + if (!fs.existsSync(manifestPath)) { 3924 + return { success: false, error: 'No manifest.json found in folder' }; 3925 + } 3926 + 3927 + const manifestContent = fs.readFileSync(manifestPath, 'utf-8'); 3928 + const manifest = JSON.parse(manifestContent); 3929 + 3930 + if (!manifest.id && !manifest.name) { 3931 + return { success: false, error: 'Manifest must have id or name' }; 3932 + } 3933 + 3934 + const variablesFile = manifest.variables || 'variables.css'; 3935 + const variablesPath = path.join(themePath, variablesFile.replace(/^\.\//, '')); 3936 + if (!fs.existsSync(variablesPath)) { 3937 + return { success: false, error: `Theme CSS file not found: ${variablesFile}` }; 3938 + } 3939 + 3940 + const validation = validateThemeCSS(variablesPath); 3941 + if (!validation.valid) { 3942 + return { 3943 + success: false, 3944 + error: `Theme CSS missing required variables: ${validation.missing!.join(', ')}`, 3945 + }; 3946 + } 3947 + 3948 + return { success: true, data: { manifest, path: themePath } }; 3949 + } catch (error) { 3950 + const message = error instanceof Error ? error.message : String(error); 3951 + return { success: false, error: message }; 3952 + } 3953 + }); 3954 + 3955 + registerTileIpc('tile:theme:add', { mode: 'handle' }, async (event, args: { 3956 + token: string; 3957 + folderPath?: string; 3958 + path?: string; 3959 + lastError?: string; 3960 + }, _grant) => { 3961 + if (!args?.token) return { success: false, error: 'Invalid token' }; 3962 + const grant = _grant; 3963 + if (!grant.trustedBuiltin) { 3964 + handleViolation(grant, 'theme', 'tile:theme:add', 'trustedBuiltin required', args.token); 3965 + return { success: false, error: 'trustedBuiltin required for tile:theme:add' }; 3966 + } 3967 + try { 3968 + const db = getDb(); 3969 + const themePath = args.folderPath || args.path; 3970 + if (!themePath) return { success: false, error: 'No folder path provided' }; 3971 + const manifestPath = path.join(themePath, 'manifest.json'); 3972 + 3973 + if (!fs.existsSync(manifestPath)) { 3974 + return { success: false, error: 'No manifest.json found' }; 3975 + } 3976 + 3977 + const manifestContent = fs.readFileSync(manifestPath, 'utf-8'); 3978 + const manifest = JSON.parse(manifestContent); 3979 + const id = manifest.id || manifest.name || `theme_${Date.now()}`; 3980 + 3981 + const variablesFile = manifest.variables || 'variables.css'; 3982 + const cssPath = path.join(themePath, variablesFile.replace(/^\.\//, '')); 3983 + const validation = validateThemeCSS(cssPath); 3984 + if (!validation.valid) { 3985 + return { 3986 + success: false, 3987 + error: `Theme CSS missing required variables: ${validation.missing!.join(', ')}`, 3988 + }; 3989 + } 3990 + 3991 + const existing = db.prepare('SELECT * FROM themes WHERE id = ?').get(id); 3992 + if (existing) { 3993 + return { success: false, error: `Theme ${id} already installed` }; 3994 + } 3995 + 3996 + const lastError = args.lastError || ''; 3997 + const lastErrorAt = lastError ? Date.now() : 0; 3998 + 3999 + db.prepare(` 4000 + INSERT INTO themes (id, name, description, version, author, path, builtin, enabled, installedAt, updatedAt, lastError, lastErrorAt, metadata) 4001 + VALUES (?, ?, ?, ?, ?, ?, 0, 1, ?, ?, ?, ?, ?) 4002 + `).run( 4003 + id, 4004 + manifest.name || id, 4005 + manifest.description || '', 4006 + manifest.version || '1.0.0', 4007 + manifest.author || '', 4008 + themePath, 4009 + Date.now(), 4010 + Date.now(), 4011 + lastError, 4012 + lastErrorAt, 4013 + JSON.stringify(manifest), 4014 + ); 4015 + 4016 + registerThemePath(id, themePath); 4017 + 4018 + return { success: true, data: { id, manifest, path: themePath, lastError: lastError || null } }; 4019 + } catch (error) { 4020 + const message = error instanceof Error ? error.message : String(error); 4021 + return { success: false, error: message }; 4022 + } 4023 + }); 4024 + 4025 + registerTileIpc('tile:theme:remove', { mode: 'handle' }, (event, args: { 4026 + token: string; 4027 + themeId?: string; 4028 + id?: string; 4029 + }, _grant) => { 4030 + if (!args?.token) return { success: false, error: 'Invalid token' }; 4031 + const grant = _grant; 4032 + if (!grant.trustedBuiltin) { 4033 + handleViolation(grant, 'theme', 'tile:theme:remove', 'trustedBuiltin required', args.token); 4034 + return { success: false, error: 'trustedBuiltin required for tile:theme:remove' }; 4035 + } 4036 + try { 4037 + const db = getDb(); 4038 + const themeId = args.themeId || args.id; 4039 + if (!themeId) return { success: false, error: 'No themeId provided' }; 4040 + 4041 + const existing = db.prepare('SELECT * FROM themes WHERE id = ?').get(themeId) as { builtin?: number } | undefined; 4042 + if (!existing) { 4043 + return { success: false, error: `Theme ${themeId} not found` }; 4044 + } 4045 + 4046 + if (existing.builtin === 1) { 4047 + return { success: false, error: 'Cannot remove built-in theme' }; 4048 + } 4049 + 4050 + const activeId = getActiveThemeId(); 4051 + if (themeId === activeId) { 4052 + return { success: false, error: 'Cannot remove the active theme. Switch to another theme first.' }; 4053 + } 4054 + 4055 + db.prepare('DELETE FROM themes WHERE id = ?').run(themeId); 4056 + 4057 + return { success: true, data: { id: themeId } }; 4058 + } catch (error) { 4059 + const message = error instanceof Error ? error.message : String(error); 4060 + return { success: false, error: message }; 4061 + } 4062 + }); 4063 + 4064 + registerTileIpc('tile:theme:reload', { mode: 'handle' }, (event, args: { 4065 + token: string; 4066 + themeId?: string; 4067 + id?: string; 4068 + }, _grant) => { 4069 + if (!args?.token) return { success: false, error: 'Invalid token' }; 4070 + const grant = _grant; 4071 + if (!grant.trustedBuiltin) { 4072 + handleViolation(grant, 'theme', 'tile:theme:reload', 'trustedBuiltin required', args.token); 4073 + return { success: false, error: 'trustedBuiltin required for tile:theme:reload' }; 4074 + } 4075 + try { 4076 + const themeId = args.themeId || args.id; 4077 + if (!themeId) return { success: false, error: 'No themeId provided' }; 4078 + const themePath = getThemePath(themeId); 4079 + if (!themePath) { 4080 + return { success: false, error: 'Theme not found' }; 4081 + } 4082 + 4083 + for (const win of BrowserWindow.getAllWindows()) { 4084 + win.webContents.send('theme:reload', { themeId }); 4085 + } 4086 + 4087 + return { success: true, data: { id: themeId } }; 3873 4088 } catch (error) { 3874 4089 const message = error instanceof Error ? error.message : String(error); 3875 4090 return { success: false, error: message };
+7 -8
backend/electron/tile-preload.cts
··· 1266 1266 }, 1267 1267 1268 1268 // Theme directory management — Settings UI uses these to install 1269 - // user-supplied themes from disk. Thin wrappers over the existing 1270 - // legacy `theme:*` ipcMain handlers; consumed by settings.js. 1271 - // Same pattern as api.profiles / api.adblocker / api.darkMode shims. 1272 - pickFolder: () => ipcRenderer.invoke('theme:pickFolder'), 1273 - validateFolder: (folderPath: string) => ipcRenderer.invoke('theme:validateFolder', { folderPath }), 1274 - add: (folderPath: string) => ipcRenderer.invoke('theme:add', { folderPath }), 1275 - remove: (themeId: string) => ipcRenderer.invoke('theme:remove', { themeId }), 1276 - reload: (themeId: string) => ipcRenderer.invoke('theme:reload', { themeId }), 1269 + // user-supplied themes from disk. Strict tile:theme:* counterparts 1270 + // live in tile-ipc.ts (Phase 3.7f). trustedBuiltin only. 1271 + pickFolder: () => ipcRenderer.invoke('tile:theme:pickFolder', { token: tileToken }), 1272 + validateFolder: (folderPath: string) => ipcRenderer.invoke('tile:theme:validateFolder', { token: tileToken, folderPath }), 1273 + add: (folderPath: string) => ipcRenderer.invoke('tile:theme:add', { token: tileToken, folderPath }), 1274 + remove: (themeId: string) => ipcRenderer.invoke('tile:theme:remove', { token: tileToken, themeId }), 1275 + reload: (themeId: string) => ipcRenderer.invoke('tile:theme:reload', { token: tileToken, themeId }), 1277 1276 }; 1278 1277 1279 1278 // ── Settings (if granted) ──