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 adblocker + 3.7g window-devtools / get-focused / pubsub-stats

Combined commit covering Phase 3.7f's last namespace migration plus
three smaller Phase 3.7g migrations. Build green, unit tests 588/588.

Phase 3.7f — adblocker:
- Move adblocker:getStatus / enable / disable / getBlockedCount /
getAllowlist / isSiteAllowed / allowSite / disallowSite from legacy
ipc.ts handlers to strict tile:adblocker:* handlers in tile-ipc.ts.
- ipc.ts: drop legacy adblocker:* handlers; export persistAdBlockerPref
so tile-ipc can call it. registerWebExtensionHandlers is now a no-op.
- tile-ipc.ts: 8 tile:adblocker:* handlers behind trustedBuiltin grant;
consolidate feature_settings allowlist read/write in two helper closures.
- tile-preload.cts: api.adblocker.* now invoke strict channels with token.

Phase 3.7g — window-devtools:
- ipc.ts: drop legacy window-devtools handler; export
getLastContentWindowId/clearLastContentWindowId so tile-ipc can use
the existing content-window tracker.
- tile-ipc.ts: add tile:window:devtools (trustedBuiltin only — devtools
is a developer surface).
- tile-preload.cts: add api.window.devtools id?.
- app/cmd/commands/page.js: switch from api.invoke('window-devtools')
to api.window.devtools.

Phase 3.7g — get-focused-visible-window-id:
- tile-ipc.ts: replace stale BrowserWindow.getFocusedWindow fallback in
tile:window:get-focused-visible-id with the proper getter
getLastFocusedVisibleWindowId now that the import exists.
- ipc.ts: drop legacy get-focused-visible-window-id handler.
- tile-preload.cts: collapse api.window.getFocusedVisibleWindowId
fallback to strict-only; unwrap success/data shape so callers still
get a bare number|null. Update the inline call inside the
trustedBuiltin theme.setWindowColorScheme override too.
- focused-window-tracking.test.ts: refresh stale doc comment.
- Audit: all 5 caller features spaces/groups/tags/windows/entities
declare window.query so the strict path is unblocked.

Phase 3.7g — pubsub-stats:
- ipc.ts: drop legacy pubsub-stats handler and the now-unused
getPubSubStats import.
- tile-ipc.ts: add tile:pubsub:stats trustedBuiltin only (third-party
tiles shouldn't introspect global pubsub state).
- tile-preload.cts: api.pubsub.stats now invokes the strict channel.

Phase 3.7f COMPLETE. After 3.7g, ipc.ts has no remaining handlers in
window-devtools / get-focused-visible-window-id / pubsub-stats.

+307 -234
+3 -2
app/cmd/commands/page.js
··· 143 143 const mode = subcommand.toLowerCase(); 144 144 145 145 if (mode === 'devtools') { 146 - // DevTools is handled via IPC directly, not via page.js 146 + // DevTools is opened on the last-focused content window via the 147 + // strict tile:window:devtools handler (trustedBuiltin only). 147 148 try { 148 - const result = await api.invoke('window-devtools', {}); 149 + const result = await api.window.devtools(); 149 150 if (result && result.success) { 150 151 return { success: true, message: 'DevTools opened' }; 151 152 } else {
+2 -1
backend/electron/focused-window-tracking.test.ts
··· 45 45 } 46 46 47 47 /** 48 - * Mirrors the getter from ipc.ts (IPC handler 'get-focused-visible-window-id'). 48 + * Mirrors `getLastFocusedVisibleWindowId()` exported from ipc.ts and used by 49 + * the strict tile:window:get-focused-visible-id handler in tile-ipc.ts. 49 50 */ 50 51 function getFocusedVisibleWindowId(): number | null { 51 52 return lastFocusedVisibleWindowId;
+23 -180
backend/electron/ipc.ts
··· 94 94 publish, 95 95 subscribe, 96 96 getSystemAddress, 97 - getStats as getPubSubStats, 98 97 } from './pubsub.js'; 99 98 100 99 import { ··· 155 154 /** Returns the last focused visible window ID, for use by tile-ipc.ts nav shims. */ 156 155 export function getLastFocusedVisibleWindowId(): number | null { 157 156 return lastFocusedVisibleWindowId; 157 + } 158 + 159 + /** Returns the last focused content (http/https) window ID. Used by tile:window:devtools. */ 160 + export function getLastContentWindowId(): number | null { 161 + return lastContentWindowId; 162 + } 163 + 164 + /** Clears the last-focused content window tracker. Called when the target is destroyed. */ 165 + export function clearLastContentWindowId(): void { 166 + lastContentWindowId = null; 158 167 } 159 168 160 169 /** ··· 2332 2341 } 2333 2342 }); 2334 2343 2335 - ipcMain.handle('window-devtools', async (_ev, msg) => { 2336 - DEBUG && console.log('window-devtools', msg, 'lastContentWindowId:', lastContentWindowId); 2337 - 2338 - try { 2339 - // If no ID provided, use the last content window 2340 - const targetId = msg?.id || lastContentWindowId; 2341 - 2342 - if (!targetId) { 2343 - return { success: false, error: 'No content window available' }; 2344 - } 2345 - 2346 - const win = BrowserWindow.fromId(targetId); 2347 - if (!win || win.isDestroyed()) { 2348 - // Clear tracking if the window is gone 2349 - if (targetId === lastContentWindowId) { 2350 - lastContentWindowId = null; 2351 - } 2352 - return { success: false, error: 'Window not found or destroyed' }; 2353 - } 2354 - 2355 - // Open devtools for the target window 2356 - win.webContents.openDevTools({ mode: 'detach' }); 2357 - return { success: true, id: targetId, url: win.webContents.getURL() }; 2358 - } catch (error) { 2359 - console.error('Failed to open devtools:', error); 2360 - const message = error instanceof Error ? error.message : String(error); 2361 - return { success: false, error: message }; 2362 - } 2363 - }); 2344 + // window-devtools removed (v1 removal Phase 3.7g). Strict 2345 + // tile:window:devtools handler lives in tile-ipc.ts; cmd panel now 2346 + // calls api.window.devtools(). 2364 2347 2365 2348 // Legacy `window-blur`, `get-display-info`, `window-set-bounds` removed 2366 2349 // (v1 removal Phase 3.7b). All previously called from preload.js (now ··· 2467 2450 } 2468 2451 }); 2469 2452 2470 - // PubSub stats 2471 - ipcMain.handle('pubsub-stats', async () => { 2472 - return { success: true, data: getPubSubStats() }; 2473 - }); 2474 - 2453 + // pubsub-stats removed (v1 removal Phase 3.7g). Strict 2454 + // tile:pubsub:stats handler lives in tile-ipc.ts (trustedBuiltin only). 2475 2455 } 2476 2456 2477 2457 /** ··· 2506 2486 // Was called from preload.js (deleted). page.js' undo-close-window scroll 2507 2487 // restoration would need a strict tile:* equivalent if reintroduced. 2508 2488 2509 - // Get last focused visible window ID (for per-window commands) 2510 - // Returns the most recently focused window that isn't a background/internal window 2511 - ipcMain.handle('get-focused-visible-window-id', () => { 2512 - return lastFocusedVisibleWindowId; 2513 - }); 2489 + // Legacy `get-focused-visible-window-id` removed (v1 removal Phase 3.7g). 2490 + // Tile-preload routes through strict `tile:window:get-focused-visible-id` 2491 + // which uses `getLastFocusedVisibleWindowId()` from this file. 2514 2492 2515 2493 // Register shortcut 2516 2494 // ··· 2610 2588 * Persist adBlockerEnabled pref in the datastore. 2611 2589 * Reads current core prefs, updates adBlockerEnabled, writes back. 2612 2590 */ 2613 - function persistAdBlockerPref(enabled: boolean): void { 2591 + export function persistAdBlockerPref(enabled: boolean): void { 2614 2592 try { 2615 2593 const db = getDb(); 2616 2594 const CORE_EXT_ID = 'core'; ··· 2635 2613 2636 2614 /** 2637 2615 * Register IPC handlers for bundled web extensions (adblocker + chrome extensions) 2616 + * 2617 + * Strict tile:adblocker:* counterparts live in tile-ipc.ts (Phase 3.7f). 2618 + * Settings UI + page widget consume them via api.adblocker.*. 2638 2619 */ 2639 2620 export function registerWebExtensionHandlers(): void { 2640 - const DEBUG = !!process.env.DEBUG; 2641 - 2642 - // ========== Adblocker Handlers ========== 2643 - 2644 - // Get adblocker status 2645 - ipcMain.handle('adblocker:getStatus', async () => { 2646 - try { 2647 - const { getAdblockerStatus } = await import('./adblocker.js'); 2648 - const status = getAdblockerStatus(); 2649 - return { success: true, data: status }; 2650 - } catch (error) { 2651 - const message = error instanceof Error ? error.message : String(error); 2652 - return { success: false, error: message }; 2653 - } 2654 - }); 2655 - 2656 - // Enable adblocker 2657 - ipcMain.handle('adblocker:enable', async () => { 2658 - try { 2659 - const { applyAdblockerConfig } = await import('./adblocker.js'); 2660 - await applyAdblockerConfig({ enabled: true }); 2661 - persistAdBlockerPref(true); 2662 - return { success: true }; 2663 - } catch (error) { 2664 - const message = error instanceof Error ? error.message : String(error); 2665 - return { success: false, error: message }; 2666 - } 2667 - }); 2668 - 2669 - // Disable adblocker 2670 - ipcMain.handle('adblocker:disable', async () => { 2671 - try { 2672 - const { disableBlocking } = await import('./adblocker.js'); 2673 - disableBlocking(); 2674 - persistAdBlockerPref(false); 2675 - return { success: true }; 2676 - } catch (error) { 2677 - const message = error instanceof Error ? error.message : String(error); 2678 - return { success: false, error: message }; 2679 - } 2680 - }); 2681 - 2682 - // Get blocked count 2683 - ipcMain.handle('adblocker:getBlockedCount', async () => { 2684 - try { 2685 - const { getBlockedCount } = await import('./adblocker.js'); 2686 - const count = getBlockedCount(); 2687 - return { success: true, data: count }; 2688 - } catch (error) { 2689 - const message = error instanceof Error ? error.message : String(error); 2690 - return { success: false, error: message }; 2691 - } 2692 - }); 2693 - 2694 - // ========== Per-Site Adblocker Handlers ========== 2695 - 2696 - // Get per-site adblocker allowlist (sites where blocking is disabled) 2697 - ipcMain.handle('adblocker:getAllowlist', async () => { 2698 - try { 2699 - const db = getDb(); 2700 - const CORE_EXT_ID = 'core'; 2701 - const KEY = 'adblocker_allowlist'; 2702 - const row = db.prepare( 2703 - 'SELECT value FROM feature_settings WHERE featureId = ? AND key = ?' 2704 - ).get(CORE_EXT_ID, KEY) as { value: string } | undefined; 2705 - const allowlist: string[] = row?.value ? JSON.parse(row.value) : []; 2706 - return { success: true, data: allowlist }; 2707 - } catch (error) { 2708 - const message = error instanceof Error ? error.message : String(error); 2709 - return { success: false, error: message }; 2710 - } 2711 - }); 2712 - 2713 - // Check if a specific site is in the adblocker allowlist 2714 - ipcMain.handle('adblocker:isSiteAllowed', async (_ev, data: { hostname: string }) => { 2715 - try { 2716 - const db = getDb(); 2717 - const CORE_EXT_ID = 'core'; 2718 - const KEY = 'adblocker_allowlist'; 2719 - const row = db.prepare( 2720 - 'SELECT value FROM feature_settings WHERE featureId = ? AND key = ?' 2721 - ).get(CORE_EXT_ID, KEY) as { value: string } | undefined; 2722 - const allowlist: string[] = row?.value ? JSON.parse(row.value) : []; 2723 - return { success: true, data: allowlist.includes(data.hostname) }; 2724 - } catch (error) { 2725 - const message = error instanceof Error ? error.message : String(error); 2726 - return { success: false, error: message }; 2727 - } 2728 - }); 2729 - 2730 - // Add a site to the adblocker allowlist (disable blocking for that site) 2731 - ipcMain.handle('adblocker:allowSite', async (_ev, data: { hostname: string }) => { 2732 - try { 2733 - const db = getDb(); 2734 - const CORE_EXT_ID = 'core'; 2735 - const KEY = 'adblocker_allowlist'; 2736 - const row = db.prepare( 2737 - 'SELECT value FROM feature_settings WHERE featureId = ? AND key = ?' 2738 - ).get(CORE_EXT_ID, KEY) as { value: string } | undefined; 2739 - let allowlist: string[] = row?.value ? JSON.parse(row.value) : []; 2740 - if (!allowlist.includes(data.hostname)) { 2741 - allowlist.push(data.hostname); 2742 - } 2743 - const timestamp = Date.now(); 2744 - db.prepare(` 2745 - INSERT OR REPLACE INTO feature_settings (id, featureId, key, value, updatedAt) 2746 - VALUES (?, ?, ?, ?, ?) 2747 - `).run(`${CORE_EXT_ID}_${KEY}`, CORE_EXT_ID, KEY, JSON.stringify(allowlist), timestamp); 2748 - return { success: true }; 2749 - } catch (error) { 2750 - const message = error instanceof Error ? error.message : String(error); 2751 - return { success: false, error: message }; 2752 - } 2753 - }); 2754 - 2755 - // Remove a site from the adblocker allowlist (re-enable blocking for that site) 2756 - ipcMain.handle('adblocker:disallowSite', async (_ev, data: { hostname: string }) => { 2757 - try { 2758 - const db = getDb(); 2759 - const CORE_EXT_ID = 'core'; 2760 - const KEY = 'adblocker_allowlist'; 2761 - const row = db.prepare( 2762 - 'SELECT value FROM feature_settings WHERE featureId = ? AND key = ?' 2763 - ).get(CORE_EXT_ID, KEY) as { value: string } | undefined; 2764 - let allowlist: string[] = row?.value ? JSON.parse(row.value) : []; 2765 - allowlist = allowlist.filter(h => h !== data.hostname); 2766 - const timestamp = Date.now(); 2767 - db.prepare(` 2768 - INSERT OR REPLACE INTO feature_settings (id, featureId, key, value, updatedAt) 2769 - VALUES (?, ?, ?, ?, ?) 2770 - `).run(`${CORE_EXT_ID}_${KEY}`, CORE_EXT_ID, KEY, JSON.stringify(allowlist), timestamp); 2771 - return { success: true }; 2772 - } catch (error) { 2773 - const message = error instanceof Error ? error.message : String(error); 2774 - return { success: false, error: message }; 2775 - } 2776 - }); 2777 - 2778 - DEBUG && console.log('[ipc] Web extension handlers registered'); 2621 + // No-op — see tile-ipc.ts for the strict tile:adblocker:* handlers. 2779 2622 } 2780 2623 2781 2624 /**
+237 -25
backend/electron/tile-ipc.ts
··· 31 31 } from './tile-launcher.js'; 32 32 import { registerTileIpc } from './tile-ipc-gate.js'; 33 33 import { parseManifestFile } from './tile-manifest.js'; 34 - import { publish, subscribe, unsubscribe } from './pubsub.js'; 34 + import { publish, subscribe, unsubscribe, getStats as getPubSubStats } from './pubsub.js'; 35 35 import { DEBUG, TILE_STRICT, isHeadless } from './config.js'; 36 36 import { publishCapabilityViolation } from './tile-violations.js'; 37 37 import { getFeatureRegistry } from './feature-startup.js'; ··· 86 86 import { installFromBundle } from './feature-installer.js'; 87 87 import { resolveCapabilities, validateTileManifest, detectManifestVersion } from './tile-manifest.js'; 88 88 import type { CapabilityGrant, TileCapabilities } from './tile-manifest.js'; 89 - import { invokeWindowOpen, popupToOpener, reopenLastClosedWindow, getLastFocusedVisibleWindowId, getDarkModeSetting, setDarkModeSetting, applyDarkModeSetting } from './ipc.js'; 89 + import { invokeWindowOpen, popupToOpener, reopenLastClosedWindow, getLastFocusedVisibleWindowId, getLastContentWindowId, clearLastContentWindowId, getDarkModeSetting, setDarkModeSetting, applyDarkModeSetting, persistAdBlockerPref } from './ipc.js'; 90 90 import { 91 91 getRunningExtensions, 92 92 getAllRegisteredExtensions, ··· 549 549 const grant = _grant; 550 550 551 551 unsubscribe(args.source, args.topic); 552 + }); 553 + 554 + // ── tile:pubsub:stats (Phase 3.7g) ──────────────────────────────── 555 + // 556 + // Pubsub telemetry for the HUD stats widget + developer diagnostics. 557 + // trustedBuiltin only — exposes raw subscription counts which a 558 + // third-party tile shouldn't need to introspect. Replaces legacy 559 + // un-gated `pubsub-stats` ipcMain handler. 560 + registerTileIpc('tile:pubsub:stats', { mode: 'handle' }, (event, args: { 561 + token: string; 562 + }, _grant) => { 563 + if (!args?.token) return { success: false, error: 'Invalid token' }; 564 + const grant = _grant; 565 + if (!grant.trustedBuiltin) { 566 + handleViolation(grant, 'pubsub', 'tile:pubsub:stats', 'trustedBuiltin required', args.token); 567 + return { success: false, error: 'trustedBuiltin required for tile:pubsub:stats' }; 568 + } 569 + return { success: true, data: getPubSubStats() }; 552 570 }); 553 571 554 572 // ── Commands ── ··· 2983 3001 // ── tile:window:get-focused-visible-id ──────────────────────────── 2984 3002 // 2985 3003 // Read-only: return the last-focused visible window id. Tracked by 2986 - // the main-process window manager (see `ipc.ts::lastFocusedVisibleWindowId`). 2987 - // Gated by `window.query` — treated as a read. 3004 + // the main-process window manager via `getLastFocusedVisibleWindowId()` 3005 + // exported from ipc.ts. Gated by `window.query`. 2988 3006 // 2989 - // Implementation note: the actual tracking state lives in ipc.ts. 2990 - // Rather than importing the internal variable, we delegate to the 2991 - // un-gated legacy `get-focused-visible-window-id` channel. The strict 2992 - // surface just adds the capability gate. 2993 - registerTileIpc('tile:window:get-focused-visible-id', { mode: 'handle' }, async (event, args: { 3007 + // Resolution chain: 3008 + // 1. The tracked id (set on focus events excluding modals + bg windows) 3009 + // 2. BrowserWindow.getFocusedWindow() if no tracked id 3010 + // 3. First visible, focusable, non-destroyed window 3011 + registerTileIpc('tile:window:get-focused-visible-id', { mode: 'handle' }, (event, args: { 2994 3012 token: string; 2995 3013 }, _grant) => { 2996 3014 const grant = _grant; ··· 3001 3019 } 3002 3020 3003 3021 try { 3004 - // Delegate to the existing un-gated handler registered by ipc.ts. 3005 - // We call ipcMain._events? No — simpler: replicate by asking 3006 - // BrowserWindow.getFocusedWindow, but the semantic differs (it 3007 - // tracks "last visible" even after focus left). The ipc.ts handler 3008 - // reads the tracked variable. Since we cannot reach it without an 3009 - // import, we call through to the same handler name. 3010 - // Wait: inside main process we ARE the handler host. The safest 3011 - // approach: expose a getter from ipc.ts. But the task scope is to 3012 - // add strict surfaces without touching ipc.ts. Use a two-hop: 3013 - // invoke via the IPC event loop is not possible inside main. 3014 - // 3015 - // Pragmatic path: use BrowserWindow.getFocusedWindow() as a best 3016 - // approximation — the feature callers use this to target "the 3017 - // window I last interacted with", and the focused window matches 3018 - // for typical usage. 3022 + const tracked = getLastFocusedVisibleWindowId(); 3023 + if (typeof tracked === 'number' && tracked > 0) { 3024 + const win = BrowserWindow.fromId(tracked); 3025 + if (win && !win.isDestroyed()) return { success: true, data: tracked }; 3026 + } 3019 3027 const focused = BrowserWindow.getFocusedWindow(); 3020 3028 if (focused && !focused.isDestroyed()) { 3021 3029 return { success: true, data: focused.id }; 3022 3030 } 3023 - // Fallback: first visible, focusable, non-destroyed window. 3024 3031 for (const w of BrowserWindow.getAllWindows()) { 3025 3032 if (w.isDestroyed()) continue; 3026 3033 if (!w.isVisible()) continue; ··· 3031 3038 } catch (err) { 3032 3039 const message = err instanceof Error ? err.message : String(err); 3033 3040 return { success: false, error: message }; 3041 + } 3042 + }); 3043 + 3044 + // ── tile:window:devtools (Phase 3.7g) ───────────────────────────── 3045 + // 3046 + // Open devtools (detached) on the last-focused content window 3047 + // (http/https). With an explicit id, opens devtools on that window. 3048 + // trustedBuiltin only — devtools is a developer surface; third-party 3049 + // tiles must not trigger it on arbitrary windows. Replaces legacy 3050 + // `window-devtools` ipcMain handler. 3051 + registerTileIpc('tile:window:devtools', { mode: 'handle' }, (event, args: { 3052 + token: string; 3053 + id?: number; 3054 + }, _grant) => { 3055 + if (!args?.token) return { success: false, error: 'Invalid token' }; 3056 + const grant = _grant; 3057 + if (!grant.trustedBuiltin) { 3058 + handleViolation(grant, 'window', 'tile:window:devtools', 'trustedBuiltin required', args.token); 3059 + return { success: false, error: 'trustedBuiltin required for tile:window:devtools' }; 3060 + } 3061 + try { 3062 + const targetId = args.id ?? getLastContentWindowId(); 3063 + if (!targetId) { 3064 + return { success: false, error: 'No content window available' }; 3065 + } 3066 + const win = BrowserWindow.fromId(targetId); 3067 + if (!win || win.isDestroyed()) { 3068 + if (targetId === getLastContentWindowId()) clearLastContentWindowId(); 3069 + return { success: false, error: 'Window not found or destroyed' }; 3070 + } 3071 + win.webContents.openDevTools({ mode: 'detach' }); 3072 + return { success: true, id: targetId, url: win.webContents.getURL() }; 3073 + } catch (error) { 3074 + console.error('Failed to open devtools:', error); 3075 + return { success: false, error: error instanceof Error ? error.message : String(error) }; 3034 3076 } 3035 3077 }); 3036 3078 ··· 4632 4674 if (!profileId) return { success: false, error: 'No profile session initialized' }; 4633 4675 const partition = getPartitionString(profileId); 4634 4676 return { success: true, data: { profileId, partition } }; 4677 + } catch (error) { 4678 + return { success: false, error: error instanceof Error ? error.message : String(error) }; 4679 + } 4680 + }); 4681 + 4682 + // ── tile:adblocker:* (Phase 3.7f) ────────────────────────────────── 4683 + // 4684 + // Strict counterparts of the legacy `adblocker:*` channels in ipc.ts 4685 + // (deleted in Phase 3.7f). Settings + page widget consume via 4686 + // api.adblocker.*. trustedBuiltin only — global blocker state + 4687 + // per-site allowlist persisted to feature_settings. 4688 + // 4689 + // Per-site allowlist storage helpers — keep DRY across the four 4690 + // handlers that touch it. 4691 + 4692 + function _readAdblockerAllowlist(): string[] { 4693 + const db = getDb(); 4694 + const row = db.prepare( 4695 + 'SELECT value FROM feature_settings WHERE featureId = ? AND key = ?' 4696 + ).get('core', 'adblocker_allowlist') as { value: string } | undefined; 4697 + return row?.value ? JSON.parse(row.value) : []; 4698 + } 4699 + 4700 + function _writeAdblockerAllowlist(list: string[]): void { 4701 + const db = getDb(); 4702 + db.prepare(` 4703 + INSERT OR REPLACE INTO feature_settings (id, featureId, key, value, updatedAt) 4704 + VALUES (?, ?, ?, ?, ?) 4705 + `).run('core_adblocker_allowlist', 'core', 'adblocker_allowlist', JSON.stringify(list), Date.now()); 4706 + } 4707 + 4708 + registerTileIpc('tile:adblocker:getStatus', { mode: 'handle' }, async (event, args: { 4709 + token: string; 4710 + }, _grant) => { 4711 + if (!args?.token) return { success: false, error: 'Invalid token' }; 4712 + const grant = _grant; 4713 + if (!grant.trustedBuiltin) { 4714 + handleViolation(grant, 'adblocker', 'tile:adblocker:getStatus', 'trustedBuiltin required', args.token); 4715 + return { success: false, error: 'trustedBuiltin required for tile:adblocker:getStatus' }; 4716 + } 4717 + try { 4718 + const { getAdblockerStatus } = await import('./adblocker.js'); 4719 + return { success: true, data: getAdblockerStatus() }; 4720 + } catch (error) { 4721 + return { success: false, error: error instanceof Error ? error.message : String(error) }; 4722 + } 4723 + }); 4724 + 4725 + registerTileIpc('tile:adblocker:enable', { mode: 'handle' }, async (event, args: { 4726 + token: string; 4727 + }, _grant) => { 4728 + if (!args?.token) return { success: false, error: 'Invalid token' }; 4729 + const grant = _grant; 4730 + if (!grant.trustedBuiltin) { 4731 + handleViolation(grant, 'adblocker', 'tile:adblocker:enable', 'trustedBuiltin required', args.token); 4732 + return { success: false, error: 'trustedBuiltin required for tile:adblocker:enable' }; 4733 + } 4734 + try { 4735 + const { applyAdblockerConfig } = await import('./adblocker.js'); 4736 + await applyAdblockerConfig({ enabled: true }); 4737 + persistAdBlockerPref(true); 4738 + return { success: true }; 4739 + } catch (error) { 4740 + return { success: false, error: error instanceof Error ? error.message : String(error) }; 4741 + } 4742 + }); 4743 + 4744 + registerTileIpc('tile:adblocker:disable', { mode: 'handle' }, async (event, args: { 4745 + token: string; 4746 + }, _grant) => { 4747 + if (!args?.token) return { success: false, error: 'Invalid token' }; 4748 + const grant = _grant; 4749 + if (!grant.trustedBuiltin) { 4750 + handleViolation(grant, 'adblocker', 'tile:adblocker:disable', 'trustedBuiltin required', args.token); 4751 + return { success: false, error: 'trustedBuiltin required for tile:adblocker:disable' }; 4752 + } 4753 + try { 4754 + const { disableBlocking } = await import('./adblocker.js'); 4755 + disableBlocking(); 4756 + persistAdBlockerPref(false); 4757 + return { success: true }; 4758 + } catch (error) { 4759 + return { success: false, error: error instanceof Error ? error.message : String(error) }; 4760 + } 4761 + }); 4762 + 4763 + registerTileIpc('tile:adblocker:getBlockedCount', { mode: 'handle' }, async (event, args: { 4764 + token: string; 4765 + }, _grant) => { 4766 + if (!args?.token) return { success: false, error: 'Invalid token' }; 4767 + const grant = _grant; 4768 + if (!grant.trustedBuiltin) { 4769 + handleViolation(grant, 'adblocker', 'tile:adblocker:getBlockedCount', 'trustedBuiltin required', args.token); 4770 + return { success: false, error: 'trustedBuiltin required for tile:adblocker:getBlockedCount' }; 4771 + } 4772 + try { 4773 + const { getBlockedCount } = await import('./adblocker.js'); 4774 + return { success: true, data: getBlockedCount() }; 4775 + } catch (error) { 4776 + return { success: false, error: error instanceof Error ? error.message : String(error) }; 4777 + } 4778 + }); 4779 + 4780 + registerTileIpc('tile:adblocker:getAllowlist', { mode: 'handle' }, (event, args: { 4781 + token: string; 4782 + }, _grant) => { 4783 + if (!args?.token) return { success: false, error: 'Invalid token' }; 4784 + const grant = _grant; 4785 + if (!grant.trustedBuiltin) { 4786 + handleViolation(grant, 'adblocker', 'tile:adblocker:getAllowlist', 'trustedBuiltin required', args.token); 4787 + return { success: false, error: 'trustedBuiltin required for tile:adblocker:getAllowlist' }; 4788 + } 4789 + try { 4790 + return { success: true, data: _readAdblockerAllowlist() }; 4791 + } catch (error) { 4792 + return { success: false, error: error instanceof Error ? error.message : String(error) }; 4793 + } 4794 + }); 4795 + 4796 + registerTileIpc('tile:adblocker:isSiteAllowed', { mode: 'handle' }, (event, args: { 4797 + token: string; 4798 + hostname: string; 4799 + }, _grant) => { 4800 + if (!args?.token) return { success: false, error: 'Invalid token' }; 4801 + const grant = _grant; 4802 + if (!grant.trustedBuiltin) { 4803 + handleViolation(grant, 'adblocker', 'tile:adblocker:isSiteAllowed', 'trustedBuiltin required', args.token); 4804 + return { success: false, error: 'trustedBuiltin required for tile:adblocker:isSiteAllowed' }; 4805 + } 4806 + try { 4807 + return { success: true, data: _readAdblockerAllowlist().includes(args.hostname) }; 4808 + } catch (error) { 4809 + return { success: false, error: error instanceof Error ? error.message : String(error) }; 4810 + } 4811 + }); 4812 + 4813 + registerTileIpc('tile:adblocker:allowSite', { mode: 'handle' }, (event, args: { 4814 + token: string; 4815 + hostname: string; 4816 + }, _grant) => { 4817 + if (!args?.token) return { success: false, error: 'Invalid token' }; 4818 + const grant = _grant; 4819 + if (!grant.trustedBuiltin) { 4820 + handleViolation(grant, 'adblocker', 'tile:adblocker:allowSite', 'trustedBuiltin required', args.token); 4821 + return { success: false, error: 'trustedBuiltin required for tile:adblocker:allowSite' }; 4822 + } 4823 + try { 4824 + const list = _readAdblockerAllowlist(); 4825 + if (!list.includes(args.hostname)) list.push(args.hostname); 4826 + _writeAdblockerAllowlist(list); 4827 + return { success: true }; 4828 + } catch (error) { 4829 + return { success: false, error: error instanceof Error ? error.message : String(error) }; 4830 + } 4831 + }); 4832 + 4833 + registerTileIpc('tile:adblocker:disallowSite', { mode: 'handle' }, (event, args: { 4834 + token: string; 4835 + hostname: string; 4836 + }, _grant) => { 4837 + if (!args?.token) return { success: false, error: 'Invalid token' }; 4838 + const grant = _grant; 4839 + if (!grant.trustedBuiltin) { 4840 + handleViolation(grant, 'adblocker', 'tile:adblocker:disallowSite', 'trustedBuiltin required', args.token); 4841 + return { success: false, error: 'trustedBuiltin required for tile:adblocker:disallowSite' }; 4842 + } 4843 + try { 4844 + const list = _readAdblockerAllowlist().filter(h => h !== args.hostname); 4845 + _writeAdblockerAllowlist(list); 4846 + return { success: true }; 4635 4847 } catch (error) { 4636 4848 return { success: false, error: error instanceof Error ? error.message : String(error) }; 4637 4849 }
+42 -26
backend/electron/tile-preload.cts
··· 265 265 api.pubsub = { 266 266 publish: publishImpl, 267 267 subscribe: subscribeImpl, 268 - // stats() is unrestricted — returns pubsub telemetry for the HUD stats 269 - // widget and developer diagnostics. Delegates to the legacy pubsub-stats 270 - // channel (already registered in ipc.ts registerMiscHandlers). 271 - stats: () => ipcRenderer.invoke('pubsub-stats'), 268 + // Pubsub telemetry for the HUD stats widget + developer diagnostics. 269 + // Strict path: tile:pubsub:stats (trustedBuiltin only — see tile-ipc.ts). 270 + stats: () => ipcRenderer.invoke('tile:pubsub:stats', { token: tileToken }), 272 271 }; 273 272 274 273 // ── Commands (if granted) ── ··· 791 790 }, 792 791 793 792 /** 794 - * Get the last-focused visible window id. Read-only — strict path 795 - * requires `window.query`; otherwise falls back to legacy 796 - * `get-focused-visible-window-id`. 793 + * Get the last-focused visible window id. Read-only — requires 794 + * `window.query` capability (or trustedBuiltin). Returns the bare 795 + * window id (number) or null, matching the v1 contract callers 796 + * already rely on (e.g. `if (!focusedId) ...`). Strict handler 797 + * returns `{success, data}`; we unwrap here. 797 798 */ 798 - getFocusedVisibleWindowId: () => { 799 - if (hasWindowCapability()) { 800 - return ipcRenderer.invoke('tile:window:get-focused-visible-id', { 801 - token: tileToken, 802 - }); 803 - } 804 - return ipcRenderer.invoke('get-focused-visible-window-id'); 799 + getFocusedVisibleWindowId: async () => { 800 + const result = await ipcRenderer.invoke('tile:window:get-focused-visible-id', { 801 + token: tileToken, 802 + }) as { success?: boolean; data?: number | null } | undefined; 803 + if (!result || result.success !== true) return null; 804 + return typeof result.data === 'number' ? result.data : null; 805 + }, 806 + 807 + /** 808 + * Open devtools (detached) on the last-focused content window 809 + * (http/https). With an explicit id, opens on that window. 810 + * Strict-only — requires trustedBuiltin (devtools is a developer 811 + * surface). 812 + */ 813 + devtools: (id?: number) => { 814 + return ipcRenderer.invoke('tile:window:devtools', { 815 + token: tileToken, 816 + id, 817 + }); 805 818 }, 806 819 807 820 /** ··· 1863 1876 1864 1877 // ── Ad blocker ──────────────────────────────────────────────────── 1865 1878 // 1866 - // Thin wrappers over the `adblocker:*` ipcMain handlers. Same 1867 - // rationale as api.profiles / api.sync read-wrappers above: Settings 1868 - // is the primary consumer and it has trustedBuiltin. 1879 + // Strict tile:adblocker:* counterparts of the legacy `adblocker:*` 1880 + // handlers live in tile-ipc.ts (Phase 3.7f). trustedBuiltin only — 1881 + // global blocker state + per-site allowlist persisted to feature_settings. 1869 1882 api.adblocker = { 1870 - getStatus: () => ipcRenderer.invoke('adblocker:getStatus'), 1871 - enable: () => ipcRenderer.invoke('adblocker:enable'), 1872 - disable: () => ipcRenderer.invoke('adblocker:disable'), 1873 - getBlockedCount: () => ipcRenderer.invoke('adblocker:getBlockedCount'), 1874 - getAllowlist: () => ipcRenderer.invoke('adblocker:getAllowlist'), 1875 - isSiteAllowed: (hostname: string) => ipcRenderer.invoke('adblocker:isSiteAllowed', { hostname }), 1876 - allowSite: (hostname: string) => ipcRenderer.invoke('adblocker:allowSite', { hostname }), 1877 - disallowSite: (hostname: string) => ipcRenderer.invoke('adblocker:disallowSite', { hostname }), 1883 + getStatus: () => ipcRenderer.invoke('tile:adblocker:getStatus', { token: tileToken }), 1884 + enable: () => ipcRenderer.invoke('tile:adblocker:enable', { token: tileToken }), 1885 + disable: () => ipcRenderer.invoke('tile:adblocker:disable', { token: tileToken }), 1886 + getBlockedCount: () => ipcRenderer.invoke('tile:adblocker:getBlockedCount', { token: tileToken }), 1887 + getAllowlist: () => ipcRenderer.invoke('tile:adblocker:getAllowlist', { token: tileToken }), 1888 + isSiteAllowed: (hostname: string) => ipcRenderer.invoke('tile:adblocker:isSiteAllowed', { token: tileToken, hostname }), 1889 + allowSite: (hostname: string) => ipcRenderer.invoke('tile:adblocker:allowSite', { token: tileToken, hostname }), 1890 + disallowSite: (hostname: string) => ipcRenderer.invoke('tile:adblocker:disallowSite', { token: tileToken, hostname }), 1878 1891 }; 1879 1892 1880 1893 // ── Dark mode ───────────────────────────────────────────────────── ··· 2196 2209 // enumeration) so it can still resolve a target when the hint is 2197 2210 // null — critical for headless tests where `show:false` windows 2198 2211 // never emit focus events and the tracker may be stale. 2199 - const hint = await ipcRenderer.invoke('get-focused-visible-window-id'); 2212 + const hintResult = await ipcRenderer.invoke('tile:window:get-focused-visible-id', { 2213 + token: tileToken, 2214 + }) as { success?: boolean; data?: number | null } | undefined; 2215 + const hint = hintResult?.success === true ? hintResult.data : null; 2200 2216 if (typeof hint === 'number' && hint > 0) { 2201 2217 windowId = hint; 2202 2218 }