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 4 — settings UI for revoking stored decisions

The user can now see and revoke every per-origin permission decision
the page-host has remembered, via a new "Permissions" section in
Settings (between Extensions and Dark Mode).

IPC surface (`tile-ipc.ts` + `tile-preload.cts`):
* `tile:permissions:list` → `api.permissions.list()`
* `tile:permissions:forget` → `api.permissions.forget(origin, perm)`
* `tile:permissions:forgetAll`→ `api.permissions.forgetAll()`

All three handlers gate on `trustedBuiltin` so only peek-core renderers
(the settings page) can read or mutate stored decisions; tile renderers
can't snoop or tamper with another tile's saved choices via this surface.

Settings UI (`app/settings/settings.js`):
* New `renderPermissionsSettings()` lists each decision as a card
showing the origin (mono font, breakable), a friendly permission
label ("Location", "Camera & microphone", "MIDI devices", "Screen
capture", etc. — kept in sync with permission-policy.ts
PERMISSION_LABELS), the verdict (Allowed/Denied), and a timestamp.
Each row gets a Revoke button; the page-level "Clear all" button
wipes everything after a confirm() prompt.
* The section auto-refreshes on every nav-click — `window._refresh
PermissionsList` is exposed so external mutations (a fresh prompt
landing while settings is open) can trigger a re-fetch. Without
this the initial render is stuck at empty state if the user
visited Permissions before any decisions were stored.
* Empty-state message + Clear-all button are hidden/shown based on
list contents.

Type defs (`tile-api.d.ts`):
* New `TilePermissionDecision`, `TilePermissionsResult`,
`TilePermissions` interfaces; added `permissions: TilePermissions`
to `TileAPI`.

Tests:
* `tests/desktop/permissions-settings.spec.ts` (NEW, 3/3) — drives
the settings UI end-to-end: seed decisions via the same
`feature_settings` table the backend reads from, open settings,
click into Permissions, assert each row renders with origin +
label + verdict + Revoke button. The Revoke test clicks a single
Revoke and asserts both the UI row vanishes AND the backend list
drops to length 0. The Clear-all test seeds three decisions,
auto-accepts the confirm() dialog, asserts every row vanishes and
backend wipes.
* Regression: `tests/desktop/permission-prompt.spec.ts` 3/3 (Phase 2
+ Phase 3 unchanged). Build clean.

Tasks doc updated: Phase 4 marked done, remaining Phase 5 polish
(favicon next to origin, multi-prompt queueing UX) tracked.

+471 -1
+189
app/settings/settings.js
··· 1062 1062 return container; 1063 1063 }; 1064 1064 1065 + // ─── Permissions ──────────────────────────────────────────────────── 1066 + // 1067 + // Lists per-origin web permission decisions persisted by the page-host 1068 + // permission handler (geolocation, camera/mic, midi, display-capture, etc.) 1069 + // and lets the user revoke them. Backed by `tile:permissions:list/forget/ 1070 + // forgetAll` IPC; trustedBuiltin gated so only this page can read/clear. 1071 + // 1072 + // Exposes `window._refreshPermissionsList` so re-entry into the section 1073 + // (and external mutations like a fresh prompt landing while the page is 1074 + // open) re-fetches the live state — initial render alone is insufficient. 1075 + const renderPermissionsSettings = async () => { 1076 + const container = document.createElement('div'); 1077 + 1078 + const desc = document.createElement('p'); 1079 + desc.className = 'help-text'; 1080 + desc.style.marginBottom = '16px'; 1081 + desc.textContent = 'Sites you have allowed or denied for sensors, media, screen capture, MIDI, etc. Revoking a decision means the next request from that site will prompt again.'; 1082 + container.appendChild(desc); 1083 + 1084 + const list = document.createElement('div'); 1085 + list.className = 'permissions-list'; 1086 + container.appendChild(list); 1087 + 1088 + const emptyMsg = document.createElement('p'); 1089 + emptyMsg.className = 'help-text'; 1090 + emptyMsg.style.fontStyle = 'italic'; 1091 + emptyMsg.textContent = 'No saved permission decisions.'; 1092 + emptyMsg.style.display = 'none'; 1093 + container.appendChild(emptyMsg); 1094 + 1095 + // Friendly labels — keep in sync with backend/electron/permission-policy.ts 1096 + // PERMISSION_LABELS. Falls back to the raw permission name if missing. 1097 + const PERMISSION_LABELS = { 1098 + geolocation: 'Location', 1099 + media: 'Camera & microphone', 1100 + midi: 'MIDI devices', 1101 + midiSysex: 'MIDI System Exclusive', 1102 + 'display-capture': 'Screen capture', 1103 + notifications: 'Notifications', 1104 + 'clipboard-read': 'Clipboard read', 1105 + 'clipboard-sanitized-write': 'Clipboard write', 1106 + pointerLock: 'Pointer lock', 1107 + fullscreen: 'Fullscreen', 1108 + openExternal: 'Open external apps', 1109 + }; 1110 + 1111 + const renderRow = (decision) => { 1112 + const row = document.createElement('div'); 1113 + row.className = 'item-card no-collapse'; 1114 + row.style.marginBottom = '8px'; 1115 + 1116 + const header = document.createElement('div'); 1117 + header.className = 'item-card-header'; 1118 + header.style.cssText = 'display: flex; align-items: center; justify-content: space-between; gap: 12px;'; 1119 + 1120 + const left = document.createElement('div'); 1121 + left.style.cssText = 'display: flex; flex-direction: column; gap: 2px; min-width: 0;'; 1122 + 1123 + const originEl = document.createElement('div'); 1124 + originEl.style.cssText = 'font-family: var(--font-mono, monospace); font-size: 13px; word-break: break-all;'; 1125 + originEl.textContent = decision.origin; 1126 + left.appendChild(originEl); 1127 + 1128 + const meta = document.createElement('div'); 1129 + meta.className = 'help-text'; 1130 + meta.style.fontSize = '12px'; 1131 + const label = PERMISSION_LABELS[decision.permission] || decision.permission; 1132 + const verdict = decision.allowed ? 'Allowed' : 'Denied'; 1133 + const when = decision.timestamp ? new Date(decision.timestamp).toLocaleString() : ''; 1134 + meta.textContent = `${label} · ${verdict}${when ? ' · ' + when : ''}`; 1135 + left.appendChild(meta); 1136 + 1137 + const revokeBtn = document.createElement('button'); 1138 + revokeBtn.className = 'btn btn-secondary'; 1139 + revokeBtn.textContent = 'Revoke'; 1140 + revokeBtn.style.flexShrink = '0'; 1141 + revokeBtn.addEventListener('click', async () => { 1142 + revokeBtn.disabled = true; 1143 + try { 1144 + const res = await api.permissions.forget(decision.origin, decision.permission); 1145 + if (res?.success) { 1146 + row.remove(); 1147 + if (list.children.length === 0) { 1148 + emptyMsg.style.display = ''; 1149 + clearAllBtn.style.display = 'none'; 1150 + } 1151 + } else { 1152 + revokeBtn.disabled = false; 1153 + console.error('[settings] permissions.forget failed:', res?.error); 1154 + } 1155 + } catch (err) { 1156 + revokeBtn.disabled = false; 1157 + console.error('[settings] permissions.forget threw:', err); 1158 + } 1159 + }); 1160 + 1161 + header.appendChild(left); 1162 + header.appendChild(revokeBtn); 1163 + row.appendChild(header); 1164 + return row; 1165 + }; 1166 + 1167 + const clearAllWrap = document.createElement('div'); 1168 + clearAllWrap.style.cssText = 'margin-top: 16px; display: flex; justify-content: flex-end;'; 1169 + const clearAllBtn = document.createElement('button'); 1170 + clearAllBtn.className = 'btn btn-secondary'; 1171 + clearAllBtn.textContent = 'Clear all'; 1172 + clearAllBtn.addEventListener('click', async () => { 1173 + if (!confirm('Clear all saved permission decisions? Sites will be prompted again next time they request a permission.')) return; 1174 + clearAllBtn.disabled = true; 1175 + try { 1176 + const res = await api.permissions.forgetAll(); 1177 + if (res?.success) { 1178 + list.replaceChildren(); 1179 + emptyMsg.style.display = ''; 1180 + clearAllBtn.style.display = 'none'; 1181 + } else { 1182 + clearAllBtn.disabled = false; 1183 + console.error('[settings] permissions.forgetAll failed:', res?.error); 1184 + } 1185 + } catch (err) { 1186 + clearAllBtn.disabled = false; 1187 + console.error('[settings] permissions.forgetAll threw:', err); 1188 + } 1189 + }); 1190 + clearAllWrap.appendChild(clearAllBtn); 1191 + container.appendChild(clearAllWrap); 1192 + 1193 + // Reusable refresh — called both on initial load and on every visit 1194 + // to the section (so external mutations show up). 1195 + const refresh = async () => { 1196 + try { 1197 + const res = await api.permissions.list(); 1198 + list.replaceChildren(); 1199 + if (res?.success && Array.isArray(res.data) && res.data.length > 0) { 1200 + for (const decision of res.data) list.appendChild(renderRow(decision)); 1201 + emptyMsg.style.display = 'none'; 1202 + clearAllBtn.style.display = ''; 1203 + } else { 1204 + emptyMsg.style.display = ''; 1205 + clearAllBtn.style.display = 'none'; 1206 + } 1207 + } catch (err) { 1208 + console.error('[settings] permissions.list threw:', err); 1209 + list.replaceChildren(); 1210 + emptyMsg.textContent = 'Failed to load permissions.'; 1211 + emptyMsg.style.display = ''; 1212 + clearAllBtn.style.display = 'none'; 1213 + } 1214 + }; 1215 + 1216 + window._refreshPermissionsList = refresh; 1217 + await refresh(); 1218 + 1219 + return container; 1220 + }; 1221 + 1065 1222 // Render dark mode settings for web pages 1066 1223 const renderDarkModeSettings = async () => { 1067 1224 const container = document.createElement('div'); ··· 3188 3345 }); 3189 3346 3190 3347 contentArea.appendChild(privacySection); 3348 + 3349 + // Add Permissions section (between Extensions and Dark Mode). 3350 + // Lists per-origin web permission decisions persisted by the page-host 3351 + // permission handler (geolocation, camera/mic, etc.) and lets the user 3352 + // revoke them. See backend/electron/permission-handler.ts + 3353 + // permission-store.ts. 3354 + const permissionsNav = document.createElement('a'); 3355 + permissionsNav.className = 'nav-item'; 3356 + permissionsNav.textContent = 'Permissions'; 3357 + permissionsNav.dataset.section = 'permissions'; 3358 + permissionsNav.addEventListener('click', () => { 3359 + showSection('permissions'); 3360 + if (window._refreshPermissionsList) { 3361 + window._refreshPermissionsList(); 3362 + } 3363 + }); 3364 + sidebarNav.appendChild(permissionsNav); 3365 + 3366 + const permissionsSection = document.createElement('div'); 3367 + permissionsSection.className = 'section'; 3368 + permissionsSection.id = 'section-permissions'; 3369 + 3370 + const permissionsTitle = document.createElement('h2'); 3371 + permissionsTitle.className = 'section-title'; 3372 + permissionsTitle.textContent = 'Permissions'; 3373 + permissionsSection.appendChild(permissionsTitle); 3374 + 3375 + renderPermissionsSettings().then(content => { 3376 + permissionsSection.appendChild(content); 3377 + }); 3378 + 3379 + contentArea.appendChild(permissionsSection); 3191 3380 3192 3381 // Add Dark Mode section (between Privacy and Sync) 3193 3382 const darkModeNav = document.createElement('a');
+22
backend/electron/tile-api.d.ts
··· 607 607 set(mode: string): Promise<{ success: boolean } | { error: string }>; 608 608 } 609 609 610 + // ─── Web permissions (settings UI) ────────────────────────────────── 611 + 612 + interface TilePermissionDecision { 613 + permission: string; 614 + origin: string; 615 + allowed: boolean; 616 + timestamp: number; 617 + } 618 + 619 + interface TilePermissionsResult { 620 + success: boolean; 621 + data?: TilePermissionDecision[]; 622 + error?: string; 623 + } 624 + 625 + interface TilePermissions { 626 + list(): Promise<TilePermissionsResult>; 627 + forget(origin: string, permission: string): Promise<{ success: boolean; error?: string }>; 628 + forgetAll(): Promise<{ success: boolean; error?: string }>; 629 + } 630 + 610 631 // ─── Full window.app API ───────────────────────────────────────────── 611 632 612 633 interface TileAPI { ··· 649 670 profiles: TileProfiles; 650 671 adblocker: TileAdblocker; 651 672 darkMode: TileDarkMode; 673 + permissions: TilePermissions; 652 674 653 675 // ── Removed v1 shims — these throw if called ────────────────────── 654 676
+75
backend/electron/tile-ipc.ts
··· 4840 4840 } 4841 4841 }); 4842 4842 4843 + // ── Web Permissions (settings UI) ── 4844 + // 4845 + // Read/clear stored per-origin permission decisions persisted by the web 4846 + // permission handler (see backend/electron/permission-handler.ts + 4847 + // permission-store.ts). All handlers gate on `trustedBuiltin` so only 4848 + // peek-core renderers (settings page) can list/revoke; tile renderers 4849 + // can't snoop or tamper with another tile's stored decisions. 4850 + 4851 + registerTileIpc('tile:permissions:list', { mode: 'handle' }, async (event, args: { 4852 + token: string; 4853 + }, _grant) => { 4854 + if (!args?.token) return { success: false, error: 'Invalid token' }; 4855 + const grant = _grant; 4856 + if (!grant.trustedBuiltin) { 4857 + handleViolation(grant, 'permissions', 'tile:permissions:list', 'trustedBuiltin required', args.token); 4858 + return { success: false, error: 'trustedBuiltin required for tile:permissions:list' }; 4859 + } 4860 + try { 4861 + const { getDb } = await import('./datastore.js'); 4862 + const db = getDb(); 4863 + if (!db) return { success: true, data: [] }; 4864 + const { listDecisions } = await import('./permission-store.js'); 4865 + return { success: true, data: listDecisions(db as unknown as Parameters<typeof listDecisions>[0]) }; 4866 + } catch (error) { 4867 + return { success: false, error: error instanceof Error ? error.message : String(error) }; 4868 + } 4869 + }); 4870 + 4871 + registerTileIpc('tile:permissions:forget', { mode: 'handle' }, async (event, args: { 4872 + token: string; 4873 + origin: string; 4874 + permission: string; 4875 + }, _grant) => { 4876 + if (!args?.token) return { success: false, error: 'Invalid token' }; 4877 + const grant = _grant; 4878 + if (!grant.trustedBuiltin) { 4879 + handleViolation(grant, 'permissions', 'tile:permissions:forget', 'trustedBuiltin required', args.token); 4880 + return { success: false, error: 'trustedBuiltin required for tile:permissions:forget' }; 4881 + } 4882 + if (!args.origin || !args.permission) { 4883 + return { success: false, error: 'origin and permission required' }; 4884 + } 4885 + try { 4886 + const { getDb } = await import('./datastore.js'); 4887 + const db = getDb(); 4888 + if (!db) return { success: false, error: 'datastore not ready' }; 4889 + const { forgetDecision } = await import('./permission-store.js'); 4890 + forgetDecision(db as unknown as Parameters<typeof forgetDecision>[0], args.origin, args.permission); 4891 + return { success: true }; 4892 + } catch (error) { 4893 + return { success: false, error: error instanceof Error ? error.message : String(error) }; 4894 + } 4895 + }); 4896 + 4897 + registerTileIpc('tile:permissions:forgetAll', { mode: 'handle' }, async (event, args: { 4898 + token: string; 4899 + }, _grant) => { 4900 + if (!args?.token) return { success: false, error: 'Invalid token' }; 4901 + const grant = _grant; 4902 + if (!grant.trustedBuiltin) { 4903 + handleViolation(grant, 'permissions', 'tile:permissions:forgetAll', 'trustedBuiltin required', args.token); 4904 + return { success: false, error: 'trustedBuiltin required for tile:permissions:forgetAll' }; 4905 + } 4906 + try { 4907 + const { getDb } = await import('./datastore.js'); 4908 + const db = getDb(); 4909 + if (!db) return { success: false, error: 'datastore not ready' }; 4910 + const { forgetAllDecisions } = await import('./permission-store.js'); 4911 + forgetAllDecisions(db as unknown as Parameters<typeof forgetAllDecisions>[0]); 4912 + return { success: true }; 4913 + } catch (error) { 4914 + return { success: false, error: error instanceof Error ? error.message : String(error) }; 4915 + } 4916 + }); 4917 + 4843 4918 // ── Datastore ── 4844 4919 // 4845 4920 // Scoped per-table access. Every handler:
+13
backend/electron/tile-preload.cts
··· 1766 1766 disallowSite: (hostname: string) => ipcRenderer.invoke('tile:adblocker:disallowSite', { token: tileToken, hostname }), 1767 1767 }; 1768 1768 1769 + // ── Web permissions (settings UI) ───────────────────────────────── 1770 + // 1771 + // Read/clear stored per-origin permission decisions persisted by the 1772 + // backend permission handler. trustedBuiltin only — the settings page 1773 + // is the canonical caller. Tile renderers can't snoop or tamper with 1774 + // another tile's stored decisions via this surface. 1775 + api.permissions = { 1776 + list: () => ipcRenderer.invoke('tile:permissions:list', { token: tileToken }), 1777 + forget: (origin: string, permission: string) => 1778 + ipcRenderer.invoke('tile:permissions:forget', { token: tileToken, origin, permission }), 1779 + forgetAll: () => ipcRenderer.invoke('tile:permissions:forgetAll', { token: tileToken }), 1780 + }; 1781 + 1769 1782 // ── Dark mode ───────────────────────────────────────────────────── 1770 1783 // 1771 1784 // Thin wrappers over `darkMode:*` ipcMain handlers.
+1 -1
docs/tasks.md
··· 23 23 24 24 ## Bugs 25 25 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). 26 + - [ ] **Web permission requests — Phase 5 (polish): favicon + multi-prompt queueing UX.** Phases 1-4 shipped 2026-04-27. Phase 4: new "Permissions" section in `app/settings/settings.js` (between Extensions and Dark Mode) lists every stored decision via `tile:permissions:list`, with per-row Revoke + page-level Clear-all. IPC handlers in `tile-ipc.ts` (`list/forget/forgetAll`) gate on `trustedBuiltin` so only peek-core renderers can read or mutate. `api.permissions = { list, forget, forgetAll }` exposed in `tile-preload.cts` with matching type defs in `tile-api.d.ts`. Section auto-refreshes on every nav-click (and via `window._refreshPermissionsList` for external triggers) so freshly persisted decisions show up without reopening the window. Coverage: `tests/desktop/permissions-settings.spec.ts` (3/3) — render with origin/label/verdict/Revoke, single revoke clears the row + backend, Clear-all wipes everything. Phase 5 polish remaining: favicon next to origin in both prompt overlay and settings list; multi-prompt queueing UX (today they stack visually but each fires in arrival order — could surface a single prompt with "X more pending" badge). 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
+171
tests/desktop/permissions-settings.spec.ts
··· 1 + /** 2 + * Permissions settings UI — Phase 4 integration test. 3 + * 4 + * Drives the settings page's Permissions section end-to-end: 5 + * 1. Seed two stored decisions via api.permissions.forget(...) to clear 6 + * then api.permissions inserts indirectly. (We use a backdoor via 7 + * bgWindow `app.invoke`-style — but cleanest is to drive the prompt 8 + * itself in Phase 2's spec; here we just verify the UI rendering 9 + * and revoke flow against pre-seeded data via the IPC.) 10 + * 2. Open settings → Permissions section. 11 + * 3. Assert each row renders with origin + label + verdict + Revoke btn. 12 + * 4. Click Revoke on one row → row disappears AND backend list shrinks. 13 + * 5. Click Clear All → all rows disappear + empty msg shows. 14 + * 15 + * Run with: yarn test:grep "Permissions Settings" 16 + */ 17 + 18 + import { test, expect, DesktopApp } from '../fixtures/desktop-app'; 19 + import { Page } from '@playwright/test'; 20 + import { createPerDescribeApp } from '../helpers/test-app'; 21 + 22 + test.describe('Permissions Settings UI @desktop', () => { 23 + let app: DesktopApp; 24 + let bgWindow: Page; 25 + 26 + test.beforeAll(async () => { 27 + ({ app, bgWindow } = await createPerDescribeApp('permissions-settings')); 28 + }); 29 + 30 + test.afterAll(async () => { 31 + if (app) await app.close(); 32 + }); 33 + 34 + test.beforeEach(async () => { 35 + // Reset stored decisions between tests so each is hermetic. 36 + await bgWindow.evaluate(async () => { 37 + await (window as any).app.permissions.forgetAll(); 38 + }); 39 + }); 40 + 41 + /** Seed a stored decision via the same IPC the prompt uses. */ 42 + async function seedDecision(origin: string, permission: string, allowed: boolean) { 43 + // We don't have a direct setDecision IPC (only forget); instead, drive 44 + // the full prompt flow by publishing the response to a synthetic 45 + // page:permission-request. The handler would normally fire from a 46 + // setPermissionRequestHandler callback; since we're seeding test 47 + // state, we use a SQL-direct backdoor via the datastore IPC, which 48 + // settings page can reach via api.datastore. 49 + // 50 + // Simpler: the page-host renderer sends `page:permission-response` 51 + // which the handler resolves via pendingRequests. To seed without a 52 + // real prompt, we'd need a test hook. For this Phase 4 test, just 53 + // assert the UI behavior once the data is there — populate via the 54 + // raw datastore IPC: 55 + await bgWindow.evaluate(async ({ origin, permission, allowed }) => { 56 + const api = (window as any).app; 57 + const value = JSON.stringify({ allowed, timestamp: Date.now() }); 58 + const rowId = `page-permissions-${permission}-${origin}`; 59 + await api.datastore.setRow('feature_settings', rowId, { 60 + id: rowId, 61 + featureId: 'page-permissions', 62 + key: `${permission}:${origin}`, 63 + value, 64 + updatedAt: Date.now(), 65 + }); 66 + }, { origin, permission, allowed }); 67 + } 68 + 69 + async function openSettings(): Promise<Page> { 70 + const result = await bgWindow.evaluate(async () => { 71 + return await (window as any).app.window.open('peek://app/settings/settings.html', { 72 + width: 1000, height: 700, key: 'settings', 73 + }); 74 + }); 75 + expect(result.success).toBe(true); 76 + const settingsWindow = await app.getWindow('settings/settings.html', 10000); 77 + await settingsWindow.waitForLoadState('domcontentloaded'); 78 + return settingsWindow; 79 + } 80 + 81 + test('Permissions section renders stored decisions with origin, label, and Revoke button', async () => { 82 + await seedDecision('https://example.com', 'geolocation', false); 83 + await seedDecision('https://meet.example.com', 'media', true); 84 + 85 + const settings = await openSettings(); 86 + 87 + // Click into the Permissions section — the click handler refreshes the 88 + // list, so newly-seeded decisions show up even if the settings window 89 + // was opened before the seed. 90 + await settings.locator('a.nav-item[data-section="permissions"]').click(); 91 + 92 + // Wait for at least one row to render. 93 + await settings.waitForFunction( 94 + () => document.querySelectorAll('#section-permissions .item-card').length >= 2, 95 + undefined, 96 + { timeout: 5000 }, 97 + ); 98 + 99 + const rows = settings.locator('#section-permissions .item-card'); 100 + await expect(rows).toHaveCount(2); 101 + 102 + // Each row should mention an origin and a verdict. 103 + const rowTexts = await rows.allInnerTexts(); 104 + const combined = rowTexts.join('\n'); 105 + expect(combined).toContain('https://example.com'); 106 + expect(combined).toContain('https://meet.example.com'); 107 + expect(combined).toMatch(/Location/); 108 + expect(combined).toMatch(/Camera/); 109 + expect(combined).toMatch(/Denied/); 110 + expect(combined).toMatch(/Allowed/); 111 + }); 112 + 113 + test('Revoke button removes the row and clears the backend entry', async () => { 114 + await seedDecision('https://example.com', 'geolocation', false); 115 + 116 + const settings = await openSettings(); 117 + await settings.locator('a.nav-item[data-section="permissions"]').click(); 118 + 119 + await settings.waitForSelector('#section-permissions .item-card', { timeout: 5000 }); 120 + expect(await settings.locator('#section-permissions .item-card').count()).toBe(1); 121 + 122 + await settings.locator('#section-permissions .item-card button:has-text("Revoke")').click(); 123 + 124 + // Row gone in UI. 125 + await settings.waitForFunction( 126 + () => document.querySelectorAll('#section-permissions .item-card').length === 0, 127 + undefined, 128 + { timeout: 5000 }, 129 + ); 130 + 131 + // Backend confirms removal. 132 + const stillThere = await bgWindow.evaluate(async () => { 133 + const res = await (window as any).app.permissions.list(); 134 + return res?.data?.length ?? -1; 135 + }); 136 + expect(stillThere).toBe(0); 137 + }); 138 + 139 + test('Clear all removes every decision and shows empty message', async () => { 140 + await seedDecision('https://a.com', 'geolocation', false); 141 + await seedDecision('https://b.com', 'media', true); 142 + await seedDecision('https://c.com', 'midi', true); 143 + 144 + const settings = await openSettings(); 145 + await settings.locator('a.nav-item[data-section="permissions"]').click(); 146 + 147 + await settings.waitForFunction( 148 + () => document.querySelectorAll('#section-permissions .item-card').length >= 3, 149 + undefined, 150 + { timeout: 5000 }, 151 + ); 152 + 153 + // Auto-accept the confirm() dialog. 154 + settings.on('dialog', dialog => dialog.accept()); 155 + 156 + await settings.locator('#section-permissions button:has-text("Clear all")').click(); 157 + 158 + await settings.waitForFunction( 159 + () => document.querySelectorAll('#section-permissions .item-card').length === 0, 160 + undefined, 161 + { timeout: 5000 }, 162 + ); 163 + 164 + // Backend confirms wipe. 165 + const remaining = await bgWindow.evaluate(async () => { 166 + const res = await (window as any).app.permissions.list(); 167 + return res?.data?.length ?? -1; 168 + }); 169 + expect(remaining).toBe(0); 170 + }); 171 + });