experiments in a post-browser web
10
fork

Configure Feed

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

feat(permissions): Phase 2 — user approval prompt for risky web permissions

Builds on Phase 1's policy module. Risky permissions (geolocation, media,
midi, midiSysex, display-capture) now resolve to 'prompt' instead of
'allow' — the request is deferred, the user sees a Peek-branded
approve/reject overlay in the page host, and the deferred Chromium
callback resolves with the user's decision.

End-to-end wire:

1. `permission-handler.ts` request handler resolves to 'prompt' →
stores callback in pendingRequests Map, finds the host page-host
BrowserWindow id via webview-guest-registry, publishes
`page:permission-request` `{requestId, windowId, permission, origin,
label}` to all renderers. Wires a one-shot `closed` listener on the
host BrowserWindow that denies the pending request if the user
closes the window before responding.
2. Page-host renderer subscribes to `page:permission-request`, filters
by `windowId === myWindowId`, renders a `.permission-prompt`
overlay (top-center, blur backdrop, 180ms ease-in animation,
stacks beneath any existing prompt). Click Allow / Deny publishes
`page:permission-response` `{requestId, allowed}` and removes the
overlay.
3. `permission-handler.ts` subscribes once at install time to
`page:permission-response`; matches requestId in pendingRequests,
resolves the Chromium callback with `allowed`, deletes the entry.

New modules:

* `permission-policy.ts` — pure policy (DEFAULT_POLICY, PERMISSION_LABELS,
resolveDecision, originFromUrl, labelFor). NO electron imports — the
unit test imports it under plain node without Electron-as-node.
* `webview-guest-registry.ts` — Map<guestWebContentsId,
hostBrowserWindowId>, populated on `did-attach-webview` (hooked from
windows.ts addEscHandler — same place ESC interception is wired).
`findHostWindowId(wc)` tries the registry first, then falls back to
`BrowserWindow.fromWebContents` so it works for both top-level
WebContents and guest webviews. Self-cleans on guest 'destroyed'.
Replaces the old fragile URL-substring matching used by the
download handler — pattern is reusable for other main-side code
that needs guest→host lookup.

UI:

* `app/page/index.html` adds CSS for `.permission-prompt`, `.permission-
prompt-message`, `.permission-prompt-origin`, `.permission-prompt-
actions`, `.permission-prompt-allow`, `.permission-prompt-deny`.
Top-center fixed position, blur backdrop, slide-in animation.
* `app/page/page.js` adds `renderPermissionPrompt` + the
`page:permission-request` subscriber. Multiple concurrent prompts
stack vertically (each new one positions itself below the last).
Idempotent click handlers (resolved-flag prevents double-publish).

Friendly labels:

* Each permission gets a human label rendered in the prompt
("know your location", "use your camera and microphone", etc.).
Falls back to the raw permission name if not in PERMISSION_LABELS.

Tests:

* `tests/unit/permission-handler.test.js` (13/13) — updated for the
new policy: geolocation/media/midi/display-capture → 'prompt';
notifications/clipboard/fullscreen/pointerLock/openExternal stay
'allow'; hid/serial/usb/unknown still fail-closed.
* `tests/desktop/permission-prompt.spec.ts` (NEW, 2/2) — drives the
full deferred-callback flow under Playwright: opens a page-host on
a test HTTP origin, kicks navigator.geolocation.getCurrentPosition
inside the guest, asserts the `.permission-prompt` overlay
appears with the right origin + label, clicks Deny → guest's
promise rejects with PERMISSION_DENIED (code 1); the Allow path
asserts the rejection is NOT code 1 (Chromium proceeds with the
actual lookup, which may fail with POSITION_UNAVAILABLE in headless
test mode but never PERMISSION_DENIED — that's the proof Allow
actually allowed).
* Regression: page-host-fsm 5/5; reopen-closed-window 5/5;
session-restore-page-host 2/2; full unit suite 641/641.

Tasks doc updated; Phase 3 (persistence) + Phase 4 (UI polish) spelled
out as the next slice.

+622 -112
+69
app/page/index.html
··· 1342 1342 opacity: 0.9; 1343 1343 } 1344 1344 1345 + /* --- Permission prompt (web permission requests — geolocation, etc.) --- */ 1346 + 1347 + .permission-prompt { 1348 + position: fixed; 1349 + top: 16px; 1350 + left: 50%; 1351 + transform: translateX(-50%); 1352 + z-index: 9999; 1353 + max-width: 480px; 1354 + width: calc(100% - 32px); 1355 + background: color-mix(in srgb, var(--theme-bg-secondary, #2a2a2a) 95%, transparent); 1356 + backdrop-filter: blur(12px); 1357 + -webkit-backdrop-filter: blur(12px); 1358 + border: 1px solid color-mix(in srgb, var(--theme-border, rgba(255,255,255,0.1)) 80%, transparent); 1359 + border-radius: 10px; 1360 + padding: 14px 16px; 1361 + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3); 1362 + color: var(--theme-text, #e0e0e0); 1363 + font-family: var(--theme-font-sans, system-ui, -apple-system, BlinkMacSystemFont, sans-serif); 1364 + font-size: 13px; 1365 + animation: permission-prompt-in 180ms ease-out; 1366 + } 1367 + 1368 + @keyframes permission-prompt-in { 1369 + from { opacity: 0; transform: translate(-50%, -8px); } 1370 + to { opacity: 1; transform: translate(-50%, 0); } 1371 + } 1372 + 1373 + .permission-prompt-message { 1374 + margin-bottom: 10px; 1375 + line-height: 1.4; 1376 + } 1377 + 1378 + .permission-prompt-origin { 1379 + font-family: var(--theme-font-mono, ui-monospace, SFMono-Regular, monospace); 1380 + font-weight: 600; 1381 + color: var(--theme-text, #e0e0e0); 1382 + } 1383 + 1384 + .permission-prompt-actions { 1385 + display: flex; 1386 + gap: 8px; 1387 + justify-content: flex-end; 1388 + } 1389 + 1390 + .permission-prompt-actions button { 1391 + border: none; 1392 + padding: 6px 14px; 1393 + border-radius: 6px; 1394 + font-size: 12px; 1395 + cursor: pointer; 1396 + font-family: inherit; 1397 + } 1398 + 1399 + .permission-prompt-allow { 1400 + background: var(--theme-accent, #007aff); 1401 + color: #fff; 1402 + } 1403 + 1404 + .permission-prompt-deny { 1405 + background: color-mix(in srgb, var(--theme-bg-tertiary, #2a2a2a) 80%, transparent); 1406 + color: var(--theme-text, #e0e0e0); 1407 + border: 1px solid color-mix(in srgb, var(--theme-border, rgba(255,255,255,0.1)) 60%, transparent) !important; 1408 + } 1409 + 1410 + .permission-prompt-actions button:hover { 1411 + opacity: 0.9; 1412 + } 1413 + 1345 1414 /* --- Find bar (top-right overlay for find-in-page) --- */ 1346 1415 1347 1416 .find-bar {
+70
app/page/page.js
··· 2280 2280 toggleMaximize(); 2281 2281 }); 2282 2282 2283 + // --- Web permission prompt --- 2284 + // Backend's permission-handler.ts publishes `page:permission-request` when a 2285 + // guest webview requests a risky permission (geolocation, camera/mic, etc.). 2286 + // We render an approve/reject overlay near the top of the page; on click the 2287 + // user's decision is published back as `page:permission-response` and the 2288 + // backend resolves the deferred Chromium callback. 2289 + // 2290 + // Multiple concurrent prompts stack vertically — each is a separate DOM node 2291 + // with its own requestId. No queueing logic; whichever the user clicks first 2292 + // resolves first. See backend/electron/permission-handler.ts for the wire 2293 + // protocol (requestId / windowId / permission / origin / label). 2294 + 2295 + function renderPermissionPrompt(req) { 2296 + const wrap = document.createElement('div'); 2297 + wrap.className = 'permission-prompt'; 2298 + wrap.dataset.requestId = req.requestId; 2299 + 2300 + const message = document.createElement('div'); 2301 + message.className = 'permission-prompt-message'; 2302 + const originSpan = document.createElement('span'); 2303 + originSpan.className = 'permission-prompt-origin'; 2304 + originSpan.textContent = req.origin || '(unknown origin)'; 2305 + message.appendChild(originSpan); 2306 + message.appendChild(document.createTextNode(` wants to ${req.label || req.permission}.`)); 2307 + 2308 + const actions = document.createElement('div'); 2309 + actions.className = 'permission-prompt-actions'; 2310 + 2311 + const denyBtn = document.createElement('button'); 2312 + denyBtn.className = 'permission-prompt-deny'; 2313 + denyBtn.textContent = 'Deny'; 2314 + 2315 + const allowBtn = document.createElement('button'); 2316 + allowBtn.className = 'permission-prompt-allow'; 2317 + allowBtn.textContent = 'Allow'; 2318 + 2319 + let resolved = false; 2320 + function respond(allowed) { 2321 + if (resolved) return; 2322 + resolved = true; 2323 + api.publish('page:permission-response', { 2324 + requestId: req.requestId, 2325 + allowed, 2326 + }); 2327 + wrap.remove(); 2328 + } 2329 + denyBtn.addEventListener('click', () => respond(false)); 2330 + allowBtn.addEventListener('click', () => respond(true)); 2331 + 2332 + actions.appendChild(denyBtn); 2333 + actions.appendChild(allowBtn); 2334 + wrap.appendChild(message); 2335 + wrap.appendChild(actions); 2336 + 2337 + // Stack subsequent prompts beneath any existing ones. 2338 + const existing = document.querySelectorAll('.permission-prompt'); 2339 + if (existing.length > 0) { 2340 + const lastBottom = existing[existing.length - 1].getBoundingClientRect().bottom; 2341 + wrap.style.top = `${lastBottom + 8}px`; 2342 + } 2343 + 2344 + document.body.appendChild(wrap); 2345 + } 2346 + 2347 + api.subscribe('page:permission-request', (msg) => { 2348 + if (msg.windowId != null && msg.windowId !== myWindowId) return; 2349 + if (!msg.requestId) return; 2350 + renderPermissionPrompt(msg); 2351 + }); 2352 + 2283 2353 // Navbar double-click to toggle maximize 2284 2354 navbar.addEventListener('dblclick', (e) => { 2285 2355 // Only on the navbar background (not on buttons/inputs inside shadow DOM)
+114 -95
backend/electron/permission-handler.ts
··· 2 2 * Web Permission Request Handler 3 3 * 4 4 * Centralizes Chromium permission request handling for the profile session. 5 - * Replaces the previous blanket-allow installed by chrome-extensions.ts so we 6 - * can apply per-permission policy and (in a future phase) surface a Peek-native 7 - * approve/reject prompt to the user. 5 + * Replaces the previous blanket-allow installed by chrome-extensions.ts. 8 6 * 9 - * Today's policy (Phase 1 — visibility only): 10 - * - chrome-extension:// origins → allow (preserves prior behavior; extensions 11 - * declare their permissions in manifest.json and Electron honors them). 12 - * - All other origins → consult DEFAULT_POLICY map per permission name. 13 - * Currently every permission defaults to allow so no real-world site 14 - * regresses on this rollout. Each request is logged to stderr with origin 15 - * + permission so we can see the live traffic before adding the prompt UI. 7 + * Policy by origin class is defined in `permission-policy.ts` (no Electron 8 + * imports — pure module, unit-testable under plain node): 9 + * chrome-extension:// → allow (extensions declare permissions in 10 + * manifest.json; Electron honors them) 11 + * peek:// → allow (we control these pages) 12 + * everything else → consult DEFAULT_POLICY map per permission name. 13 + * 14 + * Risky permissions (geolocation, media/camera/mic, midi, display-capture) 15 + * resolve to `'prompt'` — the handler stores the callback in 16 + * `pendingRequests`, finds the host page-host BrowserWindow via the 17 + * webview-guest-registry, publishes `page:permission-request` to that 18 + * window's renderer, and resolves the callback when the user responds via 19 + * `page:permission-response`. 16 20 * 17 - * Phase 2 will: (a) flip risky permissions (geolocation, media, midi, 18 - * screen-share) to "prompt" — store the callback in `pendingRequests`, publish 19 - * `page:permission-request` to the page-host renderer, and resolve when the 20 - * user responds via IPC; (b) persist per-origin decisions in feature_settings. 21 + * Phase 3 will persist per-origin decisions in `feature_settings` so the 22 + * user isn't re-prompted on every page load. Today every prompt fires 23 + * fresh — the requestId tracks the in-flight grant only. 21 24 * 22 25 * See docs/tasks.md "Web permission requests" for the full plan. 23 26 */ 24 27 28 + import { BrowserWindow } from 'electron'; 25 29 import type { Session } from 'electron'; 26 30 31 + import { publish, subscribe } from './pubsub.js'; 32 + import { findHostWindowId } from './webview-guest-registry.js'; 33 + import { 34 + resolveDecision, 35 + originFromUrl, 36 + labelFor, 37 + type PermissionName, 38 + } from './permission-policy.js'; 39 + 27 40 const DEBUG = !!process.env.DEBUG; 28 41 29 - /** Permissions that Electron may request via setPermissionRequestHandler. */ 30 - type PermissionName = 31 - | 'media' 32 - | 'geolocation' 33 - | 'notifications' 34 - | 'midi' 35 - | 'midiSysex' 36 - | 'pointerLock' 37 - | 'fullscreen' 38 - | 'openExternal' 39 - | 'window-management' 40 - | 'unknown' 41 - | 'clipboard-read' 42 - | 'clipboard-sanitized-write' 43 - | 'display-capture' 44 - | 'hid' 45 - | 'usb' 46 - | 'serial' 47 - | 'storage-access' 48 - | 'top-level-storage-access' 49 - | 'idle-detection' 50 - | 'window-placement' 51 - | 'speaker-selection' 52 - | 'background-fetch' 53 - | 'background-sync' 54 - | 'periodic-background-sync' 55 - | 'screen-wake-lock'; 42 + interface PendingRequest { 43 + callback: (granted: boolean) => void; 44 + windowId: number | null; 45 + permission: PermissionName; 46 + origin: string; 47 + } 56 48 57 - type Decision = 'allow' | 'deny' | 'prompt'; 49 + const pendingRequests = new Map<string, PendingRequest>(); 58 50 59 - /** 60 - * Per-permission default policy applied to non-extension origins. 61 - * 62 - * 'prompt' is a placeholder for Phase 2 — today every entry resolves to 63 - * 'allow' via the resolveDecision() shim so we don't regress live sites 64 - * before the UI is shipped. Flipping a row to 'prompt' (once the UI lands) 65 - * is a one-line change. 66 - */ 67 - const DEFAULT_POLICY: Partial<Record<PermissionName, Decision>> = { 68 - geolocation: 'allow', 69 - media: 'allow', 70 - notifications: 'allow', 71 - midi: 'allow', 72 - midiSysex: 'allow', 73 - 'clipboard-read': 'allow', 74 - 'clipboard-sanitized-write': 'allow', 75 - 'display-capture': 'allow', 76 - pointerLock: 'allow', 77 - fullscreen: 'allow', 78 - openExternal: 'allow', 79 - }; 51 + let permissionHandlerInstalled = false; 52 + let nextRequestSeq = 0; 80 53 81 - /** 82 - * Pending request map — populated when a request is deferred to the UI. 83 - * Phase 2 will use this; today it stays empty (no policy entry resolves to 84 - * 'prompt'). Kept here so the Phase 2 IPC handler has somewhere to live 85 - * without churning the module shape. 86 - */ 87 - const pendingRequests = new Map<string, (granted: boolean) => void>(); 88 - 89 - let permissionHandlerInstalled = false; 54 + function makeRequestId(): string { 55 + // Time + counter avoids collision without crypto overhead. Resets if the 56 + // process restarts (pendingRequests is cleared anyway). 57 + nextRequestSeq++; 58 + return `pr-${Date.now().toString(36)}-${nextRequestSeq.toString(36)}`; 59 + } 90 60 91 61 export function installPermissionHandler(ses: Session): void { 92 62 if (permissionHandlerInstalled) { ··· 97 67 98 68 ses.setPermissionCheckHandler((webContents, permission, requestingOrigin) => { 99 69 // Synchronous check: invoked when a page calls e.g. navigator.permissions.query(). 100 - // Mirror the request handler's policy so the answer is consistent. 70 + // Mirror the request handler's policy so the answer is consistent. For 71 + // 'prompt' decisions, return false here — `permissions.query()` will 72 + // report 'denied' (Chromium has no concept of "ask later" from this API). 73 + // The actual prompt fires when the page calls the API itself 74 + // (e.g. navigator.geolocation.getCurrentPosition()). 101 75 const url = webContents?.getURL?.() ?? requestingOrigin ?? ''; 102 76 const decision = resolveDecision(url, permission as PermissionName); 103 77 return decision === 'allow'; ··· 122 96 callback(false); 123 97 return; 124 98 } 125 - // 'prompt' — Phase 2 stores the callback and surfaces the UI. For now 126 - // (no policy entry returns 'prompt'), this branch is unreachable; if a 127 - // future policy entry lands without the UI being wired, fall back to 128 - // deny so we never leave the page silently waiting forever. 129 - console.warn( 130 - `[permission] ${permission} resolved to 'prompt' but no UI installed — denying`, 131 - ); 132 - callback(false); 133 - }); 134 99 135 - DEBUG && console.log('[permission] Handler installed on profile session'); 136 - } 100 + // 'prompt' — defer the callback and surface the UI. 101 + const windowId = webContents ? findHostWindowId(webContents) : null; 102 + if (windowId == null) { 103 + console.warn( 104 + `[permission] ${permission} from ${requestingUrl}: no host window found, denying`, 105 + ); 106 + callback(false); 107 + return; 108 + } 137 109 138 - function resolveDecision(url: string, permission: PermissionName): Decision { 139 - // Chrome extension URLs are always allowed — extensions declare their 140 - // permissions in manifest.json and Electron honors them. 141 - if (url.startsWith('chrome-extension://')) return 'allow'; 110 + const requestId = makeRequestId(); 111 + const origin = originFromUrl(requestingUrl); 112 + pendingRequests.set(requestId, { 113 + callback, 114 + windowId, 115 + permission: permission as PermissionName, 116 + origin, 117 + }); 142 118 143 - // peek:// internal pages — allow (we control them). 144 - if (url.startsWith('peek://')) return 'allow'; 119 + // Clean up if the host window closes before the user responds. 120 + const bw = BrowserWindow.fromId(windowId); 121 + if (bw) { 122 + const onClosed = () => { 123 + const pending = pendingRequests.get(requestId); 124 + if (pending) { 125 + DEBUG && console.log(`[permission] window ${windowId} closed, denying ${requestId}`); 126 + pending.callback(false); 127 + pendingRequests.delete(requestId); 128 + } 129 + }; 130 + bw.once('closed', onClosed); 131 + } 145 132 146 - return DEFAULT_POLICY[permission] ?? 'deny'; 133 + publish('permission-handler', 'page:permission-request', { 134 + requestId, 135 + windowId, 136 + permission, 137 + origin, 138 + label: labelFor(permission as PermissionName), 139 + }); 140 + }); 141 + 142 + // Subscribe to renderer responses. The page-host renderer publishes this 143 + // topic when the user clicks Allow / Deny in the prompt overlay. 144 + subscribe('permission-handler', 'page:permission-response', (msg) => { 145 + const m = msg as { requestId?: unknown; allowed?: unknown }; 146 + const requestId = typeof m?.requestId === 'string' ? m.requestId : null; 147 + const allowed = m?.allowed === true; 148 + if (!requestId) { 149 + console.warn('[permission] page:permission-response missing requestId:', msg); 150 + return; 151 + } 152 + const pending = pendingRequests.get(requestId); 153 + if (!pending) { 154 + // Late response after window close cleanup, or duplicate — safe to ignore. 155 + DEBUG && console.log(`[permission] no pending request for ${requestId} (already resolved?)`); 156 + return; 157 + } 158 + pendingRequests.delete(requestId); 159 + console.log( 160 + `[permission] ${pending.permission} for ${pending.origin} → ${allowed ? 'allowed' : 'denied'} by user`, 161 + ); 162 + pending.callback(allowed); 163 + }); 164 + 165 + DEBUG && console.log('[permission] Handler installed on profile session'); 147 166 } 148 167 149 168 /** ··· 152 171 export function _resetForTests(): void { 153 172 permissionHandlerInstalled = false; 154 173 pendingRequests.clear(); 174 + nextRequestSeq = 0; 155 175 } 156 176 157 177 /** 158 - * Test-only: expose the policy resolver so unit tests can verify decisions 159 - * without needing a real Electron Session. 178 + * Test-only: expose the pending-request map size for assertions. 160 179 */ 161 - export function _resolveDecisionForTests(url: string, permission: string): Decision { 162 - return resolveDecision(url, permission as PermissionName); 180 + export function _pendingRequestCountForTests(): number { 181 + return pendingRequests.size; 163 182 }
+100
backend/electron/permission-policy.ts
··· 1 + /** 2 + * Pure policy module for the web permission handler. 3 + * 4 + * Lives in its own file (no `electron` imports) so unit tests can run under 5 + * plain node without going through Electron-as-node. The orchestration 6 + * (deferred callbacks, IPC, BrowserWindow lookup) lives in permission-handler.ts. 7 + * 8 + * See backend/electron/permission-handler.ts for the full Phase 2 plan. 9 + */ 10 + 11 + /** Permissions that Electron may request via setPermissionRequestHandler. */ 12 + export type PermissionName = 13 + | 'media' 14 + | 'geolocation' 15 + | 'notifications' 16 + | 'midi' 17 + | 'midiSysex' 18 + | 'pointerLock' 19 + | 'fullscreen' 20 + | 'openExternal' 21 + | 'window-management' 22 + | 'unknown' 23 + | 'clipboard-read' 24 + | 'clipboard-sanitized-write' 25 + | 'display-capture' 26 + | 'hid' 27 + | 'usb' 28 + | 'serial' 29 + | 'storage-access' 30 + | 'top-level-storage-access' 31 + | 'idle-detection' 32 + | 'window-placement' 33 + | 'speaker-selection' 34 + | 'background-fetch' 35 + | 'background-sync' 36 + | 'periodic-background-sync' 37 + | 'screen-wake-lock'; 38 + 39 + export type Decision = 'allow' | 'deny' | 'prompt'; 40 + 41 + /** 42 + * Per-permission default policy applied to non-extension origins. 43 + * 44 + * Risky permissions (anything that exposes sensors, captures media, or grants 45 + * device access) resolve to `'prompt'` so the user sees an explicit 46 + * approve/reject UI. Less-risky permissions (notifications, fullscreen, 47 + * pointer-lock, openExternal) keep `'allow'` so common web flows aren't 48 + * interrupted on every visit. 49 + */ 50 + export const DEFAULT_POLICY: Partial<Record<PermissionName, Decision>> = { 51 + geolocation: 'prompt', 52 + media: 'prompt', 53 + midi: 'prompt', 54 + midiSysex: 'prompt', 55 + 'display-capture': 'prompt', 56 + notifications: 'allow', 57 + 'clipboard-read': 'allow', 58 + 'clipboard-sanitized-write': 'allow', 59 + pointerLock: 'allow', 60 + fullscreen: 'allow', 61 + openExternal: 'allow', 62 + }; 63 + 64 + /** Friendly labels rendered in the prompt UI. */ 65 + export const PERMISSION_LABELS: Partial<Record<PermissionName, string>> = { 66 + geolocation: 'know your location', 67 + media: 'use your camera and microphone', 68 + midi: 'access MIDI devices', 69 + midiSysex: 'send and receive MIDI System Exclusive messages', 70 + 'display-capture': 'capture your screen', 71 + notifications: 'show notifications', 72 + 'clipboard-read': 'read from your clipboard', 73 + 'clipboard-sanitized-write': 'write to your clipboard', 74 + pointerLock: 'capture your pointer', 75 + fullscreen: 'enter fullscreen mode', 76 + openExternal: 'open external apps', 77 + }; 78 + 79 + export function resolveDecision(url: string, permission: PermissionName): Decision { 80 + // Chrome extension URLs are always allowed — extensions declare their 81 + // permissions in manifest.json and Electron honors them. 82 + if (url.startsWith('chrome-extension://')) return 'allow'; 83 + 84 + // peek:// internal pages — allow (we control them). 85 + if (url.startsWith('peek://')) return 'allow'; 86 + 87 + return DEFAULT_POLICY[permission] ?? 'deny'; 88 + } 89 + 90 + export function originFromUrl(url: string): string { 91 + try { 92 + return new URL(url).origin; 93 + } catch { 94 + return url || '(unknown)'; 95 + } 96 + } 97 + 98 + export function labelFor(permission: PermissionName): string { 99 + return PERMISSION_LABELS[permission] ?? permission; 100 + }
+86
backend/electron/webview-guest-registry.ts
··· 1 + /** 2 + * Webview Guest → Host Window Registry 3 + * 4 + * Maps guest <webview> WebContents IDs to their host BrowserWindow IDs. 5 + * Populated on `did-attach-webview` (registered alongside the ESC handler in 6 + * windows.ts) and cleared on guest destroy. 7 + * 8 + * Why this exists: when an Electron API gives us a guest WebContents (e.g. 9 + * `setPermissionRequestHandler`'s `webContents` arg) we need to know which 10 + * page-host BrowserWindow to surface UI in. `BrowserWindow.fromWebContents` 11 + * returns null for guest contents; `webContents.hostWebContents` is 12 + * deprecated; URL-substring matching (the old download-handler approach) is 13 + * fragile. This registry makes the lookup O(1) and reliable. 14 + */ 15 + 16 + import { BrowserWindow, type WebContents } from 'electron'; 17 + 18 + const guestToHost = new Map<number, number>(); 19 + 20 + /** 21 + * Register a guest WebContents as belonging to a host BrowserWindow. 22 + * Idempotent — a second call for the same guestId overwrites. 23 + * 24 + * Wires a `destroyed` listener so the entry self-cleans when the guest goes 25 + * away. Caller should still call `unregisterGuest` if the host window 26 + * closes before the guest emits destroyed. 27 + */ 28 + export function registerGuest(guest: WebContents, hostWindowId: number): void { 29 + const guestId = guest.id; 30 + guestToHost.set(guestId, hostWindowId); 31 + guest.once('destroyed', () => { 32 + guestToHost.delete(guestId); 33 + }); 34 + } 35 + 36 + /** 37 + * Look up the host BrowserWindow ID for a given guest WebContents. 38 + * 39 + * Tries the registry first. If the lookup misses (e.g. for a top-level 40 + * BrowserWindow's own WebContents, where there's no guest relationship), 41 + * falls back to `BrowserWindow.fromWebContents` so the same call works for 42 + * both top-level and guest contexts. 43 + * 44 + * Returns null if no owning window can be found. 45 + */ 46 + export function findHostWindowId(wc: WebContents): number | null { 47 + const hostId = guestToHost.get(wc.id); 48 + if (hostId != null) { 49 + const bw = BrowserWindow.fromId(hostId); 50 + if (bw && !bw.isDestroyed()) return hostId; 51 + // Stale entry — fall through to live scan 52 + guestToHost.delete(wc.id); 53 + } 54 + 55 + // Top-level case: the WebContents IS a BrowserWindow's own contents 56 + const direct = BrowserWindow.fromWebContents(wc); 57 + if (direct && !direct.isDestroyed()) return direct.id; 58 + 59 + return null; 60 + } 61 + 62 + /** 63 + * Manually drop a guest entry. Normally not needed (the `destroyed` listener 64 + * handles it) but useful when batch-cleaning a closed window. 65 + */ 66 + export function unregisterGuest(guestWebContentsId: number): void { 67 + guestToHost.delete(guestWebContentsId); 68 + } 69 + 70 + /** 71 + * Return all guest WebContents IDs registered to a given host window. 72 + * Used by callers that want to clean up per-window state when the window 73 + * closes (e.g. denying pending permission requests). 74 + */ 75 + export function getGuestsForWindow(hostWindowId: number): number[] { 76 + const out: number[] = []; 77 + for (const [guestId, hostId] of guestToHost) { 78 + if (hostId === hostWindowId) out.push(guestId); 79 + } 80 + return out; 81 + } 82 + 83 + /** Test-only: clear all registry state. */ 84 + export function _resetForTests(): void { 85 + guestToHost.clear(); 86 + }
+5
backend/electron/windows.ts
··· 22 22 getWindowInfo, 23 23 getChildWindows, 24 24 } from './main.js'; 25 + import { registerGuest } from './webview-guest-registry.js'; 25 26 26 27 import { 27 28 getIzuiCoordinator, ··· 252 253 bw.webContents.on('did-attach-webview', (_event, guestWebContents) => { 253 254 DEBUG && console.log(`[esc] Webview guest attached to window ${bw.id}, adding ESC listener`); 254 255 attachEscListener(guestWebContents, 'webview'); 256 + // Track the guest→host relationship so the permission handler (and any 257 + // other main-process code that gets a guest WebContents from Electron) 258 + // can find which page-host BrowserWindow to surface UI in. 259 + registerGuest(guestWebContents, bw.id); 255 260 }); 256 261 } 257 262
+1 -1
docs/tasks.md
··· 23 23 24 24 ## Bugs 25 25 26 - - [ ] **Web permission requests — Phase 2: surface user approval prompt.** Phase 1 shipped 2026-04-27: extracted permission handling into `backend/electron/permission-handler.ts`, removed the blanket-allow that `chrome-extensions.ts` had installed on the entire profile session, and added per-permission `DEFAULT_POLICY` (every entry resolves to `allow` today so no live site regresses). chrome-extension:// + peek:// origins always allowed; unknown/risky permissions fail-closed (`hid`, `serial`, etc.). Every request is logged to stderr with origin + permission. Phase 2: flip risky permissions (`geolocation`, `media`, `midi`, `display-capture`) to `'prompt'` in `DEFAULT_POLICY`; wire `pendingRequests` map → publish `page:permission-request` to the page-host renderer with `{requestId, permission, origin, windowId}`; render a Peek-branded approve/reject overlay in `app/page/index.html` + page.js (reuse `.load-error-overlay` styling pattern); on click publish/IPC `permission:respond` back to main; resolve the deferred callback. Phase 3: persist per-origin decisions in `feature_settings` (`featureId='page-permissions'`, key=`{permission}:{origin}`, value=`{allowed, timestamp}`); add settings UI to query/revoke. Phase 4: cover the full permission set (clipboard variants, screen-share, notifications) with sensible defaults. 26 + - [ ] **Web permission requests — Phase 3: persist per-origin decisions.** Phases 1 + 2 shipped 2026-04-27. Phase 1: `backend/electron/permission-handler.ts` replaces the blanket-allow `chrome-extensions.ts` had installed on the entire profile session. Phase 2: risky permissions (`geolocation`, `media`, `midi`, `midiSysex`, `display-capture`) now resolve to `'prompt'`; backend stores the deferred callback in `pendingRequests`, looks up the host page-host BrowserWindow via the new `webview-guest-registry.ts` (populated on `did-attach-webview` in `windows.ts`), and publishes `page:permission-request` `{requestId, windowId, permission, origin, label}` to the host renderer. Page-host renders a Peek-branded `.permission-prompt` overlay near the top of the page; click Allow/Deny publishes `page:permission-response` `{requestId, allowed}`; backend resolves the Chromium callback. Window-close cleanup denies any pending requests. Pure policy lives in `permission-policy.ts` (no electron imports — unit-testable under plain node). Phase 3: persist per-origin decisions in `feature_settings` (`featureId='page-permissions'`, key=`{permission}:{origin}`, value=`{allowed, timestamp}`) so the user isn't re-prompted on every page load; add settings UI to query/revoke. Phase 4: prompt UI polish — remember-this-decision toggle, favicon next to origin, multi-prompt queueing UX (today they stack but each fires in arrival order). 27 27 28 28 - [ ] **Server-not-found / page-load failure shows blank white page forever.** User-reported 2026-04-27 with `http://www.metikmusic.com/` (DNS resolves but server returns nothing useful, or DNS fails). The page-host webview just sits on white with no feedback. We should handle the full lifecycle of a failed page open: DNS failure, connection refused, TLS errors, HTTP 4xx/5xx, hung loads, ERR_NAME_NOT_RESOLVED, ERR_INTERNET_DISCONNECTED. Show a Peek-branded error UI inside the canvas with: the URL that failed, the underlying error reason, an obvious Retry button, and a "Go back" / "Close" affordance. Likely hooks: `did-fail-load` and `did-fail-provisional-load` on the webview's webContents in `app/page/page.js`, plus `certificate-error` on the session. Audit other entry points too (cmd web search, external URL handler, address bar) to ensure they all funnel into the same error UI. 29 29
+144
tests/desktop/permission-prompt.spec.ts
··· 1 + /** 2 + * Permission prompt — Phase 2 integration test. 3 + * 4 + * Drives the full deferred-callback flow: 5 + * 1. Open a page-host on a test HTTP origin. 6 + * 2. Inside the guest webview, call navigator.geolocation.getCurrentPosition. 7 + * 3. Backend permission-handler resolves the policy to 'prompt', stores 8 + * the callback, and publishes `page:permission-request` to the host 9 + * renderer. 10 + * 4. The page-host renderer renders `.permission-prompt` near the top. 11 + * 5. Click "Deny" — page.js publishes `page:permission-response`, backend 12 + * resolves the deferred callback with false, geolocation rejects with 13 + * PERMISSION_DENIED in the guest. 14 + * 15 + * The Allow path is symmetric (asserted via the second test). 16 + * 17 + * Run with: yarn test:grep "Permission Prompt" 18 + */ 19 + 20 + import { test, expect, DesktopApp } from '../fixtures/desktop-app'; 21 + import { Page } from '@playwright/test'; 22 + import { createPerDescribeApp } from '../helpers/test-app'; 23 + import http from 'http'; 24 + 25 + let app: DesktopApp; 26 + let bgWindow: Page; 27 + let server: http.Server; 28 + let serverPort: number; 29 + 30 + test.describe('Permission Prompt @desktop', () => { 31 + test.beforeAll(async () => { 32 + ({ app, bgWindow } = await createPerDescribeApp('permission-prompt')); 33 + 34 + await new Promise<void>((resolve) => { 35 + server = http.createServer((_req, res) => { 36 + res.writeHead(200, { 'Content-Type': 'text/html' }); 37 + res.end(`<!DOCTYPE html> 38 + <html> 39 + <body> 40 + <h1>perm test</h1> 41 + <script> 42 + window.__lastGeoResult = null; 43 + window.__requestGeo = function() { 44 + return new Promise(resolve => { 45 + navigator.geolocation.getCurrentPosition( 46 + () => { window.__lastGeoResult = 'allowed'; resolve('allowed'); }, 47 + (err) => { 48 + window.__lastGeoResult = 'denied:' + err.code; 49 + resolve('denied:' + err.code); 50 + }, 51 + { timeout: 5000 } 52 + ); 53 + }); 54 + }; 55 + </script> 56 + </body> 57 + </html>`); 58 + }); 59 + server.listen(0, '127.0.0.1', () => { 60 + const addr = server.address(); 61 + serverPort = typeof addr === 'object' && addr ? addr.port : 0; 62 + resolve(); 63 + }); 64 + }); 65 + }); 66 + 67 + test.afterAll(async () => { 68 + if (server) server.close(); 69 + if (app) await app.close(); 70 + }); 71 + 72 + async function openPageHost(slug: string): Promise<Page> { 73 + const url = `http://127.0.0.1:${serverPort}/${slug}`; 74 + const result = await bgWindow.evaluate(async (u: string) => { 75 + return await (window as any).app.window.open(u, { width: 800, height: 600 }); 76 + }, url); 77 + expect(result.success).toBe(true); 78 + const pageWindow = await app.getWindow(slug, 15000); 79 + await pageWindow.waitForFunction(() => (window as any).__pageModuleReady === true); 80 + // Wait for the guest webview's content to be ready (the script tag needs 81 + // to have executed for window.__requestGeo to exist on the guest). 82 + await pageWindow.waitForFunction(() => { 83 + const wv = document.getElementById('content') as any; 84 + return wv && typeof wv.executeJavaScript === 'function'; 85 + }); 86 + return pageWindow; 87 + } 88 + 89 + test('geolocation request renders prompt; Deny resolves with PERMISSION_DENIED', async () => { 90 + const pageWindow = await openPageHost('perm-deny'); 91 + 92 + const webview = await pageWindow.evaluateHandle(() => document.getElementById('content')); 93 + 94 + // Kick off the geolocation request from inside the guest. We don't await 95 + // it — the call blocks until the user responds. The promise resolves 96 + // after we click Deny below. 97 + await pageWindow.evaluate((wv: any) => { 98 + // Stash the result promise on the host window so we can read its 99 + // resolution after clicking the prompt. 100 + (window as any).__geoPromise = wv.executeJavaScript('window.__requestGeo()'); 101 + }, webview); 102 + 103 + // Wait for the prompt to appear in the host renderer. 104 + const prompt = pageWindow.locator('.permission-prompt').first(); 105 + await expect(prompt).toBeVisible({ timeout: 5000 }); 106 + await expect(prompt).toContainText('know your location'); 107 + await expect(prompt.locator('.permission-prompt-origin')).toContainText('127.0.0.1'); 108 + 109 + // Click Deny. 110 + await prompt.locator('.permission-prompt-deny').click(); 111 + 112 + // Prompt removes itself. 113 + await expect(prompt).toHaveCount(0); 114 + 115 + // The guest's geolocation promise resolves with PERMISSION_DENIED (code 1). 116 + const result = await pageWindow.evaluate(() => (window as any).__geoPromise); 117 + expect(result).toBe('denied:1'); 118 + }); 119 + 120 + test('geolocation request renders prompt; Allow attempts the actual call', async () => { 121 + const pageWindow = await openPageHost('perm-allow'); 122 + 123 + const webview = await pageWindow.evaluateHandle(() => document.getElementById('content')); 124 + 125 + await pageWindow.evaluate((wv: any) => { 126 + (window as any).__geoPromise = wv.executeJavaScript('window.__requestGeo()'); 127 + }, webview); 128 + 129 + const prompt = pageWindow.locator('.permission-prompt').first(); 130 + await expect(prompt).toBeVisible({ timeout: 5000 }); 131 + 132 + await prompt.locator('.permission-prompt-allow').click(); 133 + 134 + await expect(prompt).toHaveCount(0); 135 + 136 + // After Allow, Chromium proceeds with the actual geolocation lookup. 137 + // In headless test mode this typically fails with POSITION_UNAVAILABLE 138 + // (code 2) or TIMEOUT (code 3) since the OS geolocation service is 139 + // off — but it MUST NOT be PERMISSION_DENIED (code 1). That's the 140 + // assertion that proves Allow actually allowed. 141 + const result = await pageWindow.evaluate(() => (window as any).__geoPromise); 142 + expect(result).not.toBe('denied:1'); 143 + }); 144 + });
+33 -16
tests/unit/permission-handler.test.js
··· 11 11 import assert from 'node:assert/strict'; 12 12 13 13 import { 14 - _resolveDecisionForTests as resolve, 15 - } from '../../dist/backend/electron/permission-handler.js'; 14 + resolveDecision as resolve, 15 + } from '../../dist/backend/electron/permission-policy.js'; 16 16 17 17 describe('permission-handler: chrome-extension origins', () => { 18 18 it('always allows geolocation for chrome-extension URLs', () => { ··· 40 40 }); 41 41 42 42 describe('permission-handler: web origins (default policy)', () => { 43 - it('allows geolocation by default (Phase 1 — visibility-only rollout)', () => { 43 + it('prompts for geolocation — risky permission, user must approve', () => { 44 44 assert.equal( 45 45 resolve('https://example.com/map', 'geolocation'), 46 - 'allow', 46 + 'prompt', 47 47 ); 48 48 }); 49 49 50 - it('allows media (camera/mic) by default', () => { 51 - assert.equal(resolve('https://meet.example.com/', 'media'), 'allow'); 50 + it('prompts for media (camera/mic) — risky permission', () => { 51 + assert.equal(resolve('https://meet.example.com/', 'media'), 'prompt'); 52 + }); 53 + 54 + it('prompts for midi and midiSysex', () => { 55 + assert.equal(resolve('https://piano.example.com/', 'midi'), 'prompt'); 56 + assert.equal(resolve('https://piano.example.com/', 'midiSysex'), 'prompt'); 52 57 }); 53 58 54 - it('allows notifications by default', () => { 59 + it('prompts for display-capture (screen sharing)', () => { 60 + assert.equal(resolve('https://meet.example.com/', 'display-capture'), 'prompt'); 61 + }); 62 + 63 + it('allows notifications by default — common pattern, low risk', () => { 55 64 assert.equal(resolve('https://news.example.com/', 'notifications'), 'allow'); 56 65 }); 57 66 67 + it('allows clipboard read/write by default', () => { 68 + assert.equal(resolve('https://example.com/', 'clipboard-read'), 'allow'); 69 + assert.equal(resolve('https://example.com/', 'clipboard-sanitized-write'), 'allow'); 70 + }); 71 + 72 + it('allows fullscreen, pointerLock, openExternal — common low-risk APIs', () => { 73 + assert.equal(resolve('https://example.com/', 'fullscreen'), 'allow'); 74 + assert.equal(resolve('https://example.com/', 'pointerLock'), 'allow'); 75 + assert.equal(resolve('https://example.com/', 'openExternal'), 'allow'); 76 + }); 77 + 58 78 it('denies an unknown permission by default — fail-closed for new permission types', () => { 59 79 assert.equal( 60 80 resolve('https://example.com/', 'unknown'), ··· 62 82 ); 63 83 }); 64 84 65 - it('denies hid by default — risky permission with no policy entry', () => { 85 + it('denies hid, serial, usb by default — risky device APIs with no policy entry', () => { 66 86 assert.equal(resolve('https://example.com/', 'hid'), 'deny'); 67 - }); 68 - 69 - it('denies serial by default — risky permission with no policy entry', () => { 70 87 assert.equal(resolve('https://example.com/', 'serial'), 'deny'); 88 + assert.equal(resolve('https://example.com/', 'usb'), 'deny'); 71 89 }); 72 90 }); 73 91 74 92 describe('permission-handler: about:blank / empty origins', () => { 75 - it('denies geolocation when the requesting URL is empty (no origin to attribute to)', () => { 93 + it('prompts for geolocation when the requesting URL is empty — same policy as web origins', () => { 76 94 // Empty URL doesn't match chrome-extension:// or peek:// — falls through 77 - // to DEFAULT_POLICY. geolocation defaults to allow today, so this case 78 - // documents that fact rather than asserting deny — when Phase 2 flips 79 - // geolocation to 'prompt' the assertion changes too. 80 - assert.equal(resolve('', 'geolocation'), 'allow'); 95 + // to DEFAULT_POLICY. With no origin to attribute to, the prompt UI will 96 + // render '(unknown origin)' — the user can still choose to deny. 97 + assert.equal(resolve('', 'geolocation'), 'prompt'); 81 98 }); 82 99 });