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.