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 3 — persist per-origin decisions

Builds on Phase 2's prompt UI. The user can now check "Remember this
decision" alongside Allow/Deny; the choice is persisted to the
`feature_settings` table and applied automatically on subsequent
permission requests for the same `(origin, permission)` pair — no
re-prompt.

New module: `backend/electron/permission-store.ts`

* Pure SQL wrapper around `feature_settings` with featureId=
`page-permissions`, key=`{permission}:{origin}`, value=JSON
`{allowed, timestamp}`. No electron imports — exposes a
`PermissionDb` interface so unit tests pass an in-memory
better-sqlite3.
* Operations: `getDecision`, `setDecision`, `forgetDecision`,
`forgetAllDecisions`, `listDecisions`. Phase 4 settings UI will
consume `listDecisions` + `forgetDecision` to render the revocation
page.
* `listDecisions` splits the stored key on the FIRST colon to
recover (permission, origin) — handles origins that themselves
contain colons (e.g. `https://a.com:8080`). Malformed JSON entries
are skipped silently rather than throwing.

`permission-handler.ts` integration:

* Stored-decision lookup happens AFTER `resolveDecision` returns
`'prompt'` and BEFORE the deferred-callback flow. Stored decisions
only override `'prompt'` — never `'allow'` (chrome-extension://,
peek://) or `'deny'` (unknown/risky permissions). A site that's
hard-allowed by policy never gets a stale stored deny applied; a
site that's hard-denied never gets a stale stored allow.
* `lookupStoredDecision()` wraps `getDb()` in try/catch — permission
requests can fire before the datastore is fully initialized
(e.g. an extension's background page during early app boot); a
miss falls through to the prompt UI rather than crashing.
* `persistStoredDecision()` is called only when the response includes
`remember:true` AND the origin isn't synthetic (`(unknown)`) — no
point persisting decisions for origins that can't match a future
request.

Renderer UI (app/page/page.js + index.html):

* `.permission-prompt-remember` — a labeled checkbox between the
message and the action buttons. Default unchecked.
* `respond()` includes `remember: rememberCheckbox.checked` in the
`page:permission-response` publish.

Tests:

* `backend/electron/permission-store.test.ts` (NEW, 17/17) — covers
every operation: getDecision returns null/value, distinguishes by
both origin AND permission, handles malformed JSON, setDecision
overwrites + uses provided timestamp, forgetDecision removes only
the targeted entry, listDecisions parses keys correctly (incl.
colon-bearing origins) and skips malformed entries, forgetAll
clears page-permissions but preserves other featureIds.
* `tests/desktop/permission-prompt.spec.ts` (was 2/2, now 3/3) —
new "Remember-this-decision" test opens a page-host, denies a
geolocation request with the checkbox ticked, opens a SECOND
page-host on the same origin, fires geolocation again, and
asserts (a) the promise rejects with PERMISSION_DENIED and (b) NO
`.permission-prompt` element is rendered.
* Regression: page-host-fsm 5/5; full unit suite 1706 (electron-as-
node) + 641 (plain node), 0 failures.

Tasks doc updated; Phase 4 (settings UI for revocation + favicon
polish + multi-prompt queueing UX) is the next slice.

+503 -3
+16
app/page/index.html
··· 1381 1381 color: var(--theme-text, #e0e0e0); 1382 1382 } 1383 1383 1384 + .permission-prompt-remember { 1385 + display: flex; 1386 + align-items: center; 1387 + gap: 6px; 1388 + margin-bottom: 10px; 1389 + font-size: 12px; 1390 + color: var(--theme-text-secondary, #aaa); 1391 + cursor: pointer; 1392 + user-select: none; 1393 + } 1394 + 1395 + .permission-prompt-remember input[type="checkbox"] { 1396 + cursor: pointer; 1397 + margin: 0; 1398 + } 1399 + 1384 1400 .permission-prompt-actions { 1385 1401 display: flex; 1386 1402 gap: 8px;
+16
app/page/page.js
··· 2330 2330 message.appendChild(originSpan); 2331 2331 message.appendChild(document.createTextNode(` wants to ${req.label || req.permission}.`)); 2332 2332 2333 + // "Remember this decision" checkbox — when checked, the response includes 2334 + // remember:true and permission-handler.ts persists the {origin, permission, 2335 + // allowed} tuple to feature_settings. Future requests with the same pair 2336 + // skip the prompt entirely. 2337 + const rememberLabel = document.createElement('label'); 2338 + rememberLabel.className = 'permission-prompt-remember'; 2339 + const rememberCheckbox = document.createElement('input'); 2340 + rememberCheckbox.type = 'checkbox'; 2341 + rememberCheckbox.checked = false; 2342 + const rememberText = document.createElement('span'); 2343 + rememberText.textContent = 'Remember this decision'; 2344 + rememberLabel.appendChild(rememberCheckbox); 2345 + rememberLabel.appendChild(rememberText); 2346 + 2333 2347 const actions = document.createElement('div'); 2334 2348 actions.className = 'permission-prompt-actions'; 2335 2349 ··· 2348 2362 api.publish('page:permission-response', { 2349 2363 requestId: req.requestId, 2350 2364 allowed, 2365 + remember: rememberCheckbox.checked, 2351 2366 }); 2352 2367 wrap.remove(); 2353 2368 } ··· 2357 2372 actions.appendChild(denyBtn); 2358 2373 actions.appendChild(allowBtn); 2359 2374 wrap.appendChild(message); 2375 + wrap.appendChild(rememberLabel); 2360 2376 wrap.appendChild(actions); 2361 2377 2362 2378 // Stack subsequent prompts beneath any existing ones.
+66 -2
backend/electron/permission-handler.ts
··· 36 36 labelFor, 37 37 type PermissionName, 38 38 } from './permission-policy.js'; 39 + import { getDb } from './datastore.js'; 40 + import { getDecision, setDecision } from './permission-store.js'; 39 41 40 42 const DEBUG = !!process.env.DEBUG; 41 43 ··· 84 86 ''; 85 87 const decision = resolveDecision(requestingUrl, permission as PermissionName); 86 88 89 + // Stored per-origin decision (Phase 3) overrides 'prompt' — but never 90 + // overrides 'deny' or 'allow' from the policy. A site that's hard-allowed 91 + // (chrome-extension://, peek://) never gets a stale stored deny applied; 92 + // a site that's hard-denied (e.g. unknown permission) never gets a stale 93 + // stored allow. 94 + if (decision === 'prompt') { 95 + const stored = lookupStoredDecision(originFromUrl(requestingUrl), permission as PermissionName); 96 + if (stored !== null) { 97 + console.log( 98 + `[permission] ${permission} requested by ${requestingUrl || '(unknown)'} → ${stored ? 'allow' : 'deny'} (stored)`, 99 + ); 100 + callback(stored); 101 + return; 102 + } 103 + } 104 + 87 105 console.log( 88 106 `[permission] ${permission} requested by ${requestingUrl || '(unknown)'} → ${decision}`, 89 107 ); ··· 142 160 // Subscribe to renderer responses. The page-host renderer publishes this 143 161 // topic when the user clicks Allow / Deny in the prompt overlay. 144 162 subscribe('permission-handler', 'page:permission-response', (msg) => { 145 - const m = msg as { requestId?: unknown; allowed?: unknown }; 163 + const m = msg as { requestId?: unknown; allowed?: unknown; remember?: unknown }; 146 164 const requestId = typeof m?.requestId === 'string' ? m.requestId : null; 147 165 const allowed = m?.allowed === true; 166 + const remember = m?.remember === true; 148 167 if (!requestId) { 149 168 console.warn('[permission] page:permission-response missing requestId:', msg); 150 169 return; ··· 157 176 } 158 177 pendingRequests.delete(requestId); 159 178 console.log( 160 - `[permission] ${pending.permission} for ${pending.origin} → ${allowed ? 'allowed' : 'denied'} by user`, 179 + `[permission] ${pending.permission} for ${pending.origin} → ${allowed ? 'allowed' : 'denied'} by user${remember ? ' (remembered)' : ''}`, 161 180 ); 181 + if (remember && pending.origin && pending.origin !== '(unknown)') { 182 + // Don't persist decisions for synthetic origins — there's no useful 183 + // identity to key off, and a stored entry would never match a real 184 + // future request anyway. 185 + persistStoredDecision(pending.origin, pending.permission, allowed); 186 + } 162 187 pending.callback(allowed); 163 188 }); 164 189 165 190 DEBUG && console.log('[permission] Handler installed on profile session'); 191 + } 192 + 193 + /** 194 + * Look up a previously-remembered decision for `(origin, permission)`. 195 + * Returns boolean (allowed) or null if no decision is stored. 196 + * 197 + * Wrapped in a try/catch because permission requests can fire before the 198 + * datastore is fully initialized (e.g. an extension's background page 199 + * requesting geolocation during early app boot). A datastore miss falls 200 + * through to the prompt UI rather than crashing the request handler. 201 + */ 202 + function lookupStoredDecision(origin: string, permission: PermissionName): boolean | null { 203 + if (!origin || origin === '(unknown)') return null; 204 + try { 205 + const db = getDb(); 206 + if (!db) return null; 207 + const decision = getDecision(db as unknown as Parameters<typeof getDecision>[0], origin, permission); 208 + return decision ? decision.allowed : null; 209 + } catch (err) { 210 + DEBUG && console.log('[permission] stored-decision lookup failed:', err); 211 + return null; 212 + } 213 + } 214 + 215 + /** 216 + * Persist a per-origin permission decision. Called when the user clicks 217 + * Allow/Deny with the "Remember this decision" checkbox set. 218 + */ 219 + function persistStoredDecision(origin: string, permission: PermissionName, allowed: boolean): void { 220 + try { 221 + const db = getDb(); 222 + if (!db) { 223 + console.warn('[permission] cannot persist decision — datastore not ready'); 224 + return; 225 + } 226 + setDecision(db as unknown as Parameters<typeof setDecision>[0], origin, permission, allowed); 227 + } catch (err) { 228 + console.error('[permission] persist failed:', err); 229 + } 166 230 } 167 231 168 232 /**
+229
backend/electron/permission-store.test.ts
··· 1 + /** 2 + * Unit tests for permission-store.ts — the persistence layer for the web 3 + * permission handler (Phase 3). 4 + * 5 + * Uses an in-memory better-sqlite3 with the same `feature_settings` schema 6 + * shape as production. Runs under Electron-as-node via the standard 7 + * dist-compile + ELECTRON_RUN_AS_NODE pipeline. 8 + */ 9 + 10 + import { describe, it, before, beforeEach } from 'node:test'; 11 + import * as assert from 'node:assert'; 12 + import Database from 'better-sqlite3'; 13 + 14 + import { 15 + getDecision, 16 + setDecision, 17 + forgetDecision, 18 + forgetAllDecisions, 19 + listDecisions, 20 + type PermissionDb, 21 + } from './permission-store.js'; 22 + 23 + let db: Database.Database; 24 + 25 + function makeDb(): Database.Database { 26 + const d = new Database(':memory:'); 27 + d.exec(` 28 + CREATE TABLE feature_settings ( 29 + id TEXT PRIMARY KEY, 30 + featureId TEXT NOT NULL, 31 + key TEXT NOT NULL, 32 + value TEXT, 33 + updatedAt INTEGER 34 + ); 35 + CREATE UNIQUE INDEX idx_feature_settings_unique ON feature_settings(featureId, key); 36 + `); 37 + return d; 38 + } 39 + 40 + describe('permission-store: getDecision', () => { 41 + before(() => { db = makeDb(); }); 42 + beforeEach(() => { db.exec('DELETE FROM feature_settings'); }); 43 + 44 + it('returns null when no decision is stored', () => { 45 + assert.strictEqual( 46 + getDecision(db as unknown as PermissionDb, 'https://example.com', 'geolocation'), 47 + null, 48 + ); 49 + }); 50 + 51 + it('returns the stored decision when present', () => { 52 + setDecision(db as unknown as PermissionDb, 'https://example.com', 'geolocation', true, 1000); 53 + const d = getDecision(db as unknown as PermissionDb, 'https://example.com', 'geolocation'); 54 + assert.deepStrictEqual(d, { allowed: true, timestamp: 1000 }); 55 + }); 56 + 57 + it('distinguishes by both origin and permission', () => { 58 + setDecision(db as unknown as PermissionDb, 'https://a.com', 'geolocation', true, 1); 59 + setDecision(db as unknown as PermissionDb, 'https://b.com', 'geolocation', false, 2); 60 + setDecision(db as unknown as PermissionDb, 'https://a.com', 'media', false, 3); 61 + 62 + assert.strictEqual(getDecision(db as unknown as PermissionDb, 'https://a.com', 'geolocation')?.allowed, true); 63 + assert.strictEqual(getDecision(db as unknown as PermissionDb, 'https://b.com', 'geolocation')?.allowed, false); 64 + assert.strictEqual(getDecision(db as unknown as PermissionDb, 'https://a.com', 'media')?.allowed, false); 65 + assert.strictEqual(getDecision(db as unknown as PermissionDb, 'https://b.com', 'media'), null); 66 + }); 67 + 68 + it('returns null for malformed JSON', () => { 69 + db.prepare( 70 + `INSERT INTO feature_settings (id, featureId, key, value, updatedAt) 71 + VALUES (?, ?, ?, ?, ?)`, 72 + ).run('bad-id', 'page-permissions', 'geolocation:https://bad.com', '{not valid json', 0); 73 + assert.strictEqual( 74 + getDecision(db as unknown as PermissionDb, 'https://bad.com', 'geolocation'), 75 + null, 76 + ); 77 + }); 78 + 79 + it('returns null when stored payload lacks an `allowed` boolean', () => { 80 + db.prepare( 81 + `INSERT INTO feature_settings (id, featureId, key, value, updatedAt) 82 + VALUES (?, ?, ?, ?, ?)`, 83 + ).run('partial-id', 'page-permissions', 'geolocation:https://partial.com', '{"timestamp":123}', 123); 84 + assert.strictEqual( 85 + getDecision(db as unknown as PermissionDb, 'https://partial.com', 'geolocation'), 86 + null, 87 + ); 88 + }); 89 + }); 90 + 91 + describe('permission-store: setDecision', () => { 92 + before(() => { db = makeDb(); }); 93 + beforeEach(() => { db.exec('DELETE FROM feature_settings'); }); 94 + 95 + it('overwrites a previous decision for the same (origin, permission)', () => { 96 + setDecision(db as unknown as PermissionDb, 'https://example.com', 'geolocation', true, 1); 97 + setDecision(db as unknown as PermissionDb, 'https://example.com', 'geolocation', false, 2); 98 + const d = getDecision(db as unknown as PermissionDb, 'https://example.com', 'geolocation'); 99 + assert.deepStrictEqual(d, { allowed: false, timestamp: 2 }); 100 + }); 101 + 102 + it('uses the provided timestamp', () => { 103 + setDecision(db as unknown as PermissionDb, 'https://example.com', 'media', true, 12345); 104 + const d = getDecision(db as unknown as PermissionDb, 'https://example.com', 'media'); 105 + assert.strictEqual(d?.timestamp, 12345); 106 + }); 107 + }); 108 + 109 + describe('permission-store: forgetDecision', () => { 110 + before(() => { db = makeDb(); }); 111 + beforeEach(() => { db.exec('DELETE FROM feature_settings'); }); 112 + 113 + it('removes a stored decision', () => { 114 + setDecision(db as unknown as PermissionDb, 'https://example.com', 'geolocation', true); 115 + forgetDecision(db as unknown as PermissionDb, 'https://example.com', 'geolocation'); 116 + assert.strictEqual( 117 + getDecision(db as unknown as PermissionDb, 'https://example.com', 'geolocation'), 118 + null, 119 + ); 120 + }); 121 + 122 + it('does not affect other origins or permissions', () => { 123 + setDecision(db as unknown as PermissionDb, 'https://a.com', 'geolocation', true); 124 + setDecision(db as unknown as PermissionDb, 'https://b.com', 'geolocation', true); 125 + setDecision(db as unknown as PermissionDb, 'https://a.com', 'media', true); 126 + 127 + forgetDecision(db as unknown as PermissionDb, 'https://a.com', 'geolocation'); 128 + 129 + assert.strictEqual(getDecision(db as unknown as PermissionDb, 'https://a.com', 'geolocation'), null); 130 + assert.strictEqual(getDecision(db as unknown as PermissionDb, 'https://b.com', 'geolocation')?.allowed, true); 131 + assert.strictEqual(getDecision(db as unknown as PermissionDb, 'https://a.com', 'media')?.allowed, true); 132 + }); 133 + 134 + it('is a no-op when no entry exists', () => { 135 + // Just shouldn't throw. 136 + forgetDecision(db as unknown as PermissionDb, 'https://nothing.com', 'geolocation'); 137 + }); 138 + }); 139 + 140 + describe('permission-store: listDecisions', () => { 141 + before(() => { db = makeDb(); }); 142 + beforeEach(() => { db.exec('DELETE FROM feature_settings'); }); 143 + 144 + it('returns an empty list when no decisions stored', () => { 145 + assert.deepStrictEqual(listDecisions(db as unknown as PermissionDb), []); 146 + }); 147 + 148 + it('returns all stored decisions with parsed origin/permission', () => { 149 + setDecision(db as unknown as PermissionDb, 'https://a.com', 'geolocation', true, 100); 150 + setDecision(db as unknown as PermissionDb, 'https://b.com', 'media', false, 200); 151 + 152 + const list = listDecisions(db as unknown as PermissionDb); 153 + assert.strictEqual(list.length, 2); 154 + // Sorted by key — `geolocation:` comes before `media:` alphabetically. 155 + assert.deepStrictEqual(list[0], { 156 + permission: 'geolocation', 157 + origin: 'https://a.com', 158 + allowed: true, 159 + timestamp: 100, 160 + }); 161 + assert.deepStrictEqual(list[1], { 162 + permission: 'media', 163 + origin: 'https://b.com', 164 + allowed: false, 165 + timestamp: 200, 166 + }); 167 + }); 168 + 169 + it('handles origins with colons (uses first colon as separator)', () => { 170 + // https URLs include `://` — listDecisions splits on the FIRST colon to 171 + // recover permission name; the rest is origin. 172 + setDecision(db as unknown as PermissionDb, 'https://a.com:8080', 'geolocation', true, 1); 173 + const list = listDecisions(db as unknown as PermissionDb); 174 + assert.strictEqual(list.length, 1); 175 + assert.strictEqual(list[0].permission, 'geolocation'); 176 + assert.strictEqual(list[0].origin, 'https://a.com:8080'); 177 + }); 178 + 179 + it('skips malformed entries instead of throwing', () => { 180 + setDecision(db as unknown as PermissionDb, 'https://good.com', 'geolocation', true, 1); 181 + db.prepare( 182 + `INSERT INTO feature_settings (id, featureId, key, value, updatedAt) 183 + VALUES (?, ?, ?, ?, ?)`, 184 + ).run('bad-id', 'page-permissions', 'media:https://bad.com', 'not json', 2); 185 + 186 + const list = listDecisions(db as unknown as PermissionDb); 187 + assert.strictEqual(list.length, 1); 188 + assert.strictEqual(list[0].origin, 'https://good.com'); 189 + }); 190 + 191 + it('only returns decisions for featureId page-permissions, not other features', () => { 192 + setDecision(db as unknown as PermissionDb, 'https://example.com', 'geolocation', true); 193 + db.prepare( 194 + `INSERT INTO feature_settings (id, featureId, key, value, updatedAt) 195 + VALUES (?, ?, ?, ?, ?)`, 196 + ).run('other-id', 'system', 'datastore_version', '1', 0); 197 + 198 + const list = listDecisions(db as unknown as PermissionDb); 199 + assert.strictEqual(list.length, 1); 200 + }); 201 + }); 202 + 203 + describe('permission-store: forgetAllDecisions', () => { 204 + before(() => { db = makeDb(); }); 205 + beforeEach(() => { db.exec('DELETE FROM feature_settings'); }); 206 + 207 + it('clears every page-permissions decision', () => { 208 + setDecision(db as unknown as PermissionDb, 'https://a.com', 'geolocation', true); 209 + setDecision(db as unknown as PermissionDb, 'https://b.com', 'media', false); 210 + forgetAllDecisions(db as unknown as PermissionDb); 211 + assert.deepStrictEqual(listDecisions(db as unknown as PermissionDb), []); 212 + }); 213 + 214 + it('preserves other featureId rows', () => { 215 + setDecision(db as unknown as PermissionDb, 'https://a.com', 'geolocation', true); 216 + db.prepare( 217 + `INSERT INTO feature_settings (id, featureId, key, value, updatedAt) 218 + VALUES (?, ?, ?, ?, ?)`, 219 + ).run('sys-id', 'system', 'datastore_version', '1', 0); 220 + 221 + forgetAllDecisions(db as unknown as PermissionDb); 222 + 223 + const sysRow = db 224 + .prepare(`SELECT key FROM feature_settings WHERE featureId = ?`) 225 + .get('system') as { key: string } | undefined; 226 + assert.ok(sysRow, 'system row should still exist'); 227 + assert.strictEqual(sysRow!.key, 'datastore_version'); 228 + }); 229 + });
+137
backend/electron/permission-store.ts
··· 1 + /** 2 + * Persistent per-origin permission decisions. 3 + * 4 + * Backed by the `feature_settings` table: 5 + * featureId = 'page-permissions' 6 + * key = `{permission}:{origin}` e.g. `geolocation:https://example.com` 7 + * value = JSON `{ allowed: boolean, timestamp: number }` 8 + * 9 + * Phase 3 of the web-permissions feature. When the user clicks Allow/Deny 10 + * with the "Remember this decision" checkbox set, the renderer includes 11 + * `remember: true` in `page:permission-response`; permission-handler.ts 12 + * routes that to `setDecision()` here. On subsequent permission requests 13 + * with the same `(permission, origin)` pair, `getDecision()` returns the 14 + * stored choice and the handler skips the prompt. 15 + * 16 + * No electron imports — pure SQL wrapper, unit-testable under plain node 17 + * via an in-memory better-sqlite3. 18 + */ 19 + 20 + const FEATURE_ID = 'page-permissions'; 21 + 22 + export interface StoredDecision { 23 + allowed: boolean; 24 + timestamp: number; 25 + } 26 + 27 + export interface DecisionRow { 28 + permission: string; 29 + origin: string; 30 + allowed: boolean; 31 + timestamp: number; 32 + } 33 + 34 + /** Subset of the better-sqlite3 surface this module needs — keeps the 35 + * type local so tests can pass an in-memory db without importing the 36 + * full Database type. */ 37 + export interface PermissionDb { 38 + prepare(sql: string): { 39 + get(...params: unknown[]): unknown; 40 + run(...params: unknown[]): unknown; 41 + all(...params: unknown[]): unknown[]; 42 + }; 43 + } 44 + 45 + function makeKey(permission: string, origin: string): string { 46 + return `${permission}:${origin}`; 47 + } 48 + 49 + function makeId(permission: string, origin: string): string { 50 + return `${FEATURE_ID}-${permission}-${origin}`; 51 + } 52 + 53 + export function getDecision( 54 + db: PermissionDb, 55 + origin: string, 56 + permission: string, 57 + ): StoredDecision | null { 58 + const row = db 59 + .prepare( 60 + `SELECT value FROM feature_settings WHERE featureId = ? AND key = ?`, 61 + ) 62 + .get(FEATURE_ID, makeKey(permission, origin)) as { value: string } | undefined; 63 + if (!row || !row.value) return null; 64 + try { 65 + const parsed = JSON.parse(row.value) as Partial<StoredDecision>; 66 + if (typeof parsed.allowed !== 'boolean') return null; 67 + return { 68 + allowed: parsed.allowed, 69 + timestamp: typeof parsed.timestamp === 'number' ? parsed.timestamp : 0, 70 + }; 71 + } catch { 72 + return null; 73 + } 74 + } 75 + 76 + export function setDecision( 77 + db: PermissionDb, 78 + origin: string, 79 + permission: string, 80 + allowed: boolean, 81 + now: number = Date.now(), 82 + ): void { 83 + const value = JSON.stringify({ allowed, timestamp: now }); 84 + db.prepare( 85 + `INSERT OR REPLACE INTO feature_settings (id, featureId, key, value, updatedAt) 86 + VALUES (?, ?, ?, ?, ?)`, 87 + ).run(makeId(permission, origin), FEATURE_ID, makeKey(permission, origin), value, now); 88 + } 89 + 90 + export function forgetDecision( 91 + db: PermissionDb, 92 + origin: string, 93 + permission: string, 94 + ): void { 95 + db.prepare( 96 + `DELETE FROM feature_settings WHERE featureId = ? AND key = ?`, 97 + ).run(FEATURE_ID, makeKey(permission, origin)); 98 + } 99 + 100 + export function forgetAllDecisions(db: PermissionDb): void { 101 + db.prepare(`DELETE FROM feature_settings WHERE featureId = ?`).run(FEATURE_ID); 102 + } 103 + 104 + export function listDecisions(db: PermissionDb): DecisionRow[] { 105 + const rows = db 106 + .prepare( 107 + `SELECT key, value FROM feature_settings WHERE featureId = ? ORDER BY key`, 108 + ) 109 + .all(FEATURE_ID) as Array<{ key: string; value: string }>; 110 + const out: DecisionRow[] = []; 111 + for (const row of rows) { 112 + const sep = row.key.indexOf(':'); 113 + if (sep < 0) continue; 114 + const permission = row.key.slice(0, sep); 115 + const origin = row.key.slice(sep + 1); 116 + let parsed: StoredDecision | null = null; 117 + try { 118 + const obj = JSON.parse(row.value) as Partial<StoredDecision>; 119 + if (typeof obj.allowed === 'boolean') { 120 + parsed = { 121 + allowed: obj.allowed, 122 + timestamp: typeof obj.timestamp === 'number' ? obj.timestamp : 0, 123 + }; 124 + } 125 + } catch { 126 + // skip malformed 127 + } 128 + if (!parsed) continue; 129 + out.push({ 130 + permission, 131 + origin, 132 + allowed: parsed.allowed, 133 + timestamp: parsed.timestamp, 134 + }); 135 + } 136 + return out; 137 + }
+1 -1
docs/tasks.md
··· 23 23 24 24 ## Bugs 25 25 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). 26 + - [ ] **Web permission requests — Phase 4: settings UI for revoking decisions.** Phases 1 + 2 + 3 shipped 2026-04-27. Phase 3: new `permission-store.ts` wraps the `feature_settings` table (featureId=`page-permissions`, key=`{permission}:{origin}`, value=JSON `{allowed, timestamp}`) with `getDecision` / `setDecision` / `forgetDecision` / `forgetAllDecisions` / `listDecisions`. `permission-handler.ts` consults `getDecision` BEFORE prompting (only overrides `'prompt'`, never `'deny'` / `'allow'`); on user response with `remember:true`, persists via `setDecision`. Renderer: prompt UI gains a "Remember this decision" checkbox; the response message includes `remember`. Phase 4: settings page that lists every stored decision via `listDecisions` and lets the user revoke individual entries (`forgetDecision`) or clear everything (`forgetAllDecisions`). Likely lives as a new tab/section in the existing settings tile. Also: prompt UI polish — favicon next to origin, multi-prompt queueing (today they stack visually but each fires in arrival order). 27 27 28 28 - [x] **Server-not-found / page-load failure shows blank white page forever.** User-reported 2026-04-27 with `http://www.metikmusic.com/`. DNS-failure / connection-refused paths were covered earlier via `did-fail-load` + `.load-error-overlay`. The remaining gap (and the actual repro) was hung loads — server accepts the connection, never responds. The 30s loading-timeout safety net silently flipped the spinner off and left the user staring at a blank page. Fixed 2026-04-27: timeout now surfaces the same `.load-error-overlay` ("The page took too long to load.") with the in-flight URL (read from `latestNavigationUrl`, not `webview.getURL()` which returns the previously-displayed URL during a pending navigation). Loading timeout is now overridable via `window.__loadingTimeoutMs` so the Playwright spec can fire the path in 1.5s instead of 30s. Test: `tests/desktop/page-load-failure.spec.ts` "Hung load: …" (6/6, was 5/5). Future polish (HTTP 4xx/5xx, TLS errors, certificate-error on session) tracked separately if/when reported. 29 29
+38
tests/desktop/permission-prompt.spec.ts
··· 141 141 const result = await pageWindow.evaluate(() => (window as any).__geoPromise); 142 142 expect(result).not.toBe('denied:1'); 143 143 }); 144 + 145 + test('Remember-this-decision: clicking Deny + checkbox persists, second request skips prompt', async () => { 146 + // First page-host: deny + remember. 147 + const first = await openPageHost('perm-remember-1'); 148 + const webview1 = await first.evaluateHandle(() => document.getElementById('content')); 149 + await first.evaluate((wv: any) => { 150 + (window as any).__geoPromise = wv.executeJavaScript('window.__requestGeo()'); 151 + }, webview1); 152 + 153 + const prompt1 = first.locator('.permission-prompt').first(); 154 + await expect(prompt1).toBeVisible({ timeout: 5000 }); 155 + 156 + // Tick the remember checkbox, then deny. 157 + await prompt1.locator('.permission-prompt-remember input[type="checkbox"]').check(); 158 + await prompt1.locator('.permission-prompt-deny').click(); 159 + await expect(prompt1).toHaveCount(0); 160 + 161 + const r1 = await first.evaluate(() => (window as any).__geoPromise); 162 + expect(r1).toBe('denied:1'); 163 + 164 + // Second page-host on the SAME origin (note: same hostname:port via 165 + // different path, so origin matches). The stored decision should be 166 + // applied without rendering a prompt. 167 + const second = await openPageHost('perm-remember-2'); 168 + const webview2 = await second.evaluateHandle(() => document.getElementById('content')); 169 + await second.evaluate((wv: any) => { 170 + (window as any).__geoPromise = wv.executeJavaScript('window.__requestGeo()'); 171 + }, webview2); 172 + 173 + // The geolocation promise should resolve quickly with PERMISSION_DENIED 174 + // (code 1) — the stored decision short-circuits the prompt. 175 + const r2 = await second.evaluate(() => (window as any).__geoPromise); 176 + expect(r2).toBe('denied:1'); 177 + 178 + // No prompt should have appeared in the second window. 179 + const promptCount = await second.locator('.permission-prompt').count(); 180 + expect(promptCount).toBe(0); 181 + }); 144 182 });