From 6970cad0d3bfbcc29d8ad266c3edb23338f3daae Mon Sep 17 00:00:00 2001 From: Kelvin Amoaba <97001695+AmoabaKelvin@users.noreply.github.com> Date: Wed, 21 Oct 2026 14:45:15 +0000 Subject: [PATCH] feat(browser): ask before sites use camera, mic or location (#31158) --- config/focus-setting-read-baseline.txt | 2 +- ...er-session-registry-persistence-fixture.ts | 11 +- .../browser/browser-route-session-runtime.ts | 18 +- ...browser-session-partition-policies.test.ts | 172 ++++++++++- .../browser-session-partition-policies.ts | 111 ++++--- .../browser-session-permission-policy.ts | 8 +- ...owser-session-registry.persistence.test.ts | 132 ++++---- .../browser/browser-session-registry.test.ts | 51 +++- src/main/browser/browser-session-registry.ts | 2 + .../browser/browser-session-route-policies.ts | 5 +- src/main/browser/browser-session-startup.ts | 2 + .../browser-site-permission-prompt.test.ts | 281 ++++++++++++++++++ .../browser/browser-site-permission-prompt.ts | 204 +++++++++++++ .../browser/browser-site-permission-store.ts | 152 ++++++++++ ...browser-preview-tool-authorization.test.ts | 5 +- src/main/ipc/browser-session-profile-ipc.ts | 52 ++++ src/preload/api/browser-api.ts | 14 + ...er-bridge-page-interaction-and-sessions.ts | 12 + .../browser-pane/browser-page-prompts.tsx | 6 +- .../browser-site-permission-copy.ts | 87 ++++++ .../browser-site-permission-prompt.test.ts | 48 +++ .../browser-site-permission-prompt.ts | 35 +++ .../components/confirmation-dialog-context.ts | 4 +- .../components/confirmation-dialog.test.tsx | 45 ++- .../src/components/confirmation-dialog.tsx | 25 +- .../BrowserPane.search-catalog.test.tsx | 3 + .../src/components/settings/BrowserPane.tsx | 4 + .../BrowserSitePermissionsSetting.test.tsx | 111 +++++++ .../BrowserSitePermissionsSetting.tsx | 178 +++++++++++ .../settings/browser-search.test.ts | 3 +- .../src/components/settings/browser-search.ts | 4 +- .../browser-site-permissions-search.ts | 29 ++ src/renderer/src/i18n/locales/en.json | 44 +++ .../slices/browser-session-profiles.test.ts | 15 + .../browser/browser-profile-list-actions.ts | 9 +- .../slices/browser/browser-slice-contract.ts | 3 +- .../src/web/preload-api/web-browser-api.ts | 2 + src/shared/browser-page-prompt.ts | 9 +- src/shared/browser-site-permission.ts | 42 +++ 39 files changed, 1768 insertions(+), 172 deletions(-) create mode 100644 src/main/browser/browser-site-permission-prompt.test.ts create mode 100644 src/main/browser/browser-site-permission-prompt.ts create mode 100644 src/main/browser/browser-site-permission-store.ts create mode 100644 src/renderer/src/components/browser-pane/browser-site-permission-copy.ts create mode 100644 src/renderer/src/components/browser-pane/browser-site-permission-prompt.test.ts create mode 100644 src/renderer/src/components/browser-pane/browser-site-permission-prompt.ts create mode 100644 src/renderer/src/components/settings/BrowserSitePermissionsSetting.test.tsx create mode 100644 src/renderer/src/components/settings/BrowserSitePermissionsSetting.tsx create mode 100644 src/renderer/src/components/settings/browser-site-permissions-search.ts create mode 100644 src/shared/browser-site-permission.ts diff --git a/config/focus-setting-read-baseline.txt b/config/focus-setting-read-baseline.txt index cf701cb4d99..068dec6264d 100644 --- a/config/focus-setting-read-baseline.txt +++ b/config/focus-setting-read-baseline.txt @@ -343,7 +343,7 @@ 7 src/renderer/src/store/slices/browser/browser-host-state.ts 1 src/renderer/src/store/slices/browser/browser-page-conversion-actions.ts 14 src/renderer/src/store/slices/browser/browser-profile-import-actions.ts -14 src/renderer/src/store/slices/browser/browser-profile-list-actions.ts +13 src/renderer/src/store/slices/browser/browser-profile-list-actions.ts 6 src/renderer/src/store/slices/browser/browser-tab-actions.ts 1 src/renderer/src/store/slices/browser/create-browser-slice.ts 1 src/renderer/src/store/slices/browser/paired-browser-tab-creator.ts diff --git a/src/main/browser/__mocks__/browser-session-registry-persistence-fixture.ts b/src/main/browser/__mocks__/browser-session-registry-persistence-fixture.ts index 7213dbbba96..a32ae200a89 100644 --- a/src/main/browser/__mocks__/browser-session-registry-persistence-fixture.ts +++ b/src/main/browser/__mocks__/browser-session-registry-persistence-fixture.ts @@ -12,6 +12,7 @@ type RegistryMock = ReturnType export const USER_DATA = '/user-data' export const META_PATH = `${USER_DATA}/browser-session-meta.json` export const IDENTITY_RECORD_PATH = `${USER_DATA}/browser-identity-mode.json` +export const SITE_PERMISSIONS_PATH = `${USER_DATA}/browser-site-permissions.json` export const CLEAN_USER_AGENT = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.7871.224 Safari/537.36' @@ -30,6 +31,12 @@ export function seedMeta(fsState: FsState, meta: unknown): void { fsState.present.add(META_PATH) } +/** Remembered site answers, by browser profile id then origin. */ +export function seedSitePermissions(fsState: FsState, profiles: unknown): void { + fsState.files.set(SITE_PERMISSIONS_PATH, JSON.stringify({ version: 1, profiles })) + fsState.present.add(SITE_PERMISSIONS_PATH) +} + /** Annotated rather than inferred: vitest's inferred mock type cannot be named across a module boundary. */ export type BrowserSessionRegistryMocks = { sessionFromPartitionMock: RegistryMock @@ -138,7 +145,9 @@ export function installModuleMocks( notifyPermissionDenied: browserManagerNotifyPermissionDeniedMock, handleGuestWillDownload: browserManagerHandleGuestWillDownloadMock, installCertificateRequestGuard: vi.fn(), - removeCertificateRequestGuard: vi.fn() + removeCertificateRequestGuard: vi.fn(), + // No window here, so a site's first request is refused without asking. + resolvePagePromptTarget: vi.fn(() => null) } })) vi.doMock('../browser-media-access', () => ({ diff --git a/src/main/browser/browser-route-session-runtime.ts b/src/main/browser/browser-route-session-runtime.ts index b403caa689d..b72a6b8e869 100644 --- a/src/main/browser/browser-route-session-runtime.ts +++ b/src/main/browser/browser-route-session-runtime.ts @@ -54,13 +54,17 @@ export const browserRouteSessionRegistry = new BrowserRouteSessionRegistry({ } }, getSession: (partition) => session.fromPartition(partition), - setupPolicies: ({ partition, browserProfileId, profileMetadata }) => - profileMetadata - ? installBrowserRoutePartitionPolicies( - { ...BrowserClientProfileMetadata.parse(profileMetadata), partition, source: null }, - partition - ) - : browserSessionRegistry.setupRoutePartitionPolicies(partition, browserProfileId), + setupPolicies: ({ partition, browserProfileId, profileMetadata }) => { + // Why: the server omits metadata for its default profile, so a missing one still means its page. + const profile = profileMetadata + ? { ...BrowserClientProfileMetadata.parse(profileMetadata), partition, source: null } + : browserSessionRegistry.getProfile(browserProfileId) + if (!profile) { + throw new Error('browser_route_partition_profile_unavailable') + } + // Why: its profile ids collide with this computer's, and settings here cannot list them. + return installBrowserRoutePartitionPolicies(profile, partition, { sitePermissions: 'session' }) + }, clearPolicies: ({ partition }) => { browserSessionRegistry.clearRoutePartitionPolicies(partition) }, diff --git a/src/main/browser/browser-session-partition-policies.test.ts b/src/main/browser/browser-session-partition-policies.test.ts index 04b83d18333..4998069587a 100644 --- a/src/main/browser/browser-session-partition-policies.test.ts +++ b/src/main/browser/browser-session-partition-policies.test.ts @@ -1,3 +1,6 @@ +import { mkdtempSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' import { beforeEach, describe, expect, it, vi } from 'vitest' import type { BrowserSessionProfile } from '../../shared/browser-workspace-types' @@ -100,9 +103,20 @@ vi.mock('./browser-webauthn-access', () => ({ installBrowserWebAuthnAccessHandlers: mocks.installBrowserWebAuthnAccessHandlers })) +/** The parts of a tab the permission handlers read. */ +type FakeTab = { + id: number + getURL: () => string + mainFrame: { framesInSubtree: { url: string; origin: string }[] } +} + type PartitionPolicyInstaller = ( profile: BrowserSessionProfile, - options?: { downloads?: 'route' | 'deny'; permissions?: 'browser' | 'deny' } + options?: { + downloads?: 'route' | 'deny' + permissions?: 'browser' | 'deny' + sitePermissions?: 'saved' | 'session' + } ) => void // Why imported per test rather than at the top: the installer remembers which partitions it has @@ -205,6 +219,15 @@ describe('partition permission policy', () => { it('denies clipboard reads only to pages an SSH host has driven (#29145)', async () => { mocks.isRemoteCliDrivenGuest.mockImplementation((id?: number) => id === 7) const install = await loadInstaller() + const store = await import('./browser-site-permission-store') + store.configureBrowserSitePermissionStore(mkdtempSync(join(tmpdir(), 'orca-site-permissions-'))) + // The user allowed the site, so only the SSH rule can refuse. + store.setBrowserSitePermissionDecisions( + 'persist:browsing-2', + 'https://a.test', + ['clipboard'], + 'allow' + ) install(profileFor('persist:browsing-2')) const sess = sessionsByPartition.get('persist:browsing-2') if (!sess) { @@ -214,23 +237,150 @@ describe('partition permission policy', () => { const requestHandler = sess.setPermissionRequestHandler.mock.calls[0]?.[0] as ( webContents: { id: number; getURL: () => string }, permission: string, - callback: (allowed: boolean) => void + callback: (allowed: boolean) => void, + details: Partial ) => void // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the installer registered exactly this handler on the fake session. const checkHandler = sess.setPermissionCheckHandler.mock.calls[0]?.[0] as ( - webContents: { id: number }, - permission: string + webContents: { id: number; getURL: () => string }, + permission: string, + requestingOrigin: string ) => boolean const guest = (id: number) => ({ id, getURL: () => 'https://a.test/' }) - const decisions: boolean[] = [] - requestHandler(guest(7), 'clipboard-read', (allowed) => decisions.push(allowed)) - requestHandler(guest(8), 'clipboard-read', (allowed) => decisions.push(allowed)) - requestHandler(guest(7), 'clipboard-sanitized-write', (allowed) => decisions.push(allowed)) + const decisions = new Map() + const record = (key: string) => (allowed: boolean) => decisions.set(key, allowed) + requestHandler(guest(7), 'clipboard-read', record('driven read'), {}) + requestHandler(guest(8), 'clipboard-read', record('read'), {}) + requestHandler(guest(7), 'clipboard-sanitized-write', record('driven write'), {}) - expect(decisions).toEqual([false, true, true]) - expect(checkHandler({ id: 7 }, 'clipboard-read')).toBe(false) - expect(checkHandler({ id: 8 }, 'clipboard-read')).toBe(true) + await vi.waitFor(() => expect(decisions.size).toBe(3)) + expect(Object.fromEntries(decisions)).toEqual({ + 'driven read': false, + read: true, + 'driven write': true + }) + expect(checkHandler(guest(7), 'clipboard-read', 'https://a.test')).toBe(false) + expect(checkHandler(guest(8), 'clipboard-read', 'https://a.test')).toBe(true) + }) + + it("answers a notification from a frame with an inherited origin as the frame's real site", async () => { + const install = await loadInstaller() + const store = await import('./browser-site-permission-store') + store.configureBrowserSitePermissionStore(mkdtempSync(join(tmpdir(), 'orca-site-permissions-'))) + store.setBrowserSitePermissionDecisions( + 'persist:browsing-frames', + 'https://a.test', + ['notifications'], + 'allow' + ) + install(profileFor('persist:browsing-frames')) + const sess = sessionsByPartition.get('persist:browsing-frames') + if (!sess) { + throw new Error('Expected the browsing session') + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the installer registered exactly this handler on the fake session. + const requestHandler = sess.setPermissionRequestHandler.mock.calls[0]?.[0] as ( + webContents: FakeTab, + permission: string, + callback: (allowed: boolean) => void, + details: Partial + ) => void + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the installer registered exactly this handler on the fake session. + const checkHandler = sess.setPermissionCheckHandler.mock.calls[0]?.[0] as ( + webContents: FakeTab, + permission: string, + requestingOrigin: string, + details: Partial + ) => boolean + // Tab a.test holds a srcdoc frame of its own, a cross-origin b.test frame, and a blank frame + // that b.test created, which inherits b.test's origin. + const tab = { + id: 12, + getURL: () => 'https://a.test/', + mainFrame: { + framesInSubtree: [ + { url: 'https://a.test/', origin: 'https://a.test' }, + { url: 'about:srcdoc', origin: 'https://a.test' }, + { url: 'https://b.test/widget', origin: 'https://b.test' }, + { url: 'about:blank', origin: 'https://b.test' } + ] + } + } + const request = (requestingUrl: string): Promise => + new Promise((resolve) => + requestHandler(tab, 'notifications', resolve, { requestingUrl, isMainFrame: false }) + ) + + // Electron checks with the real origin, then asks again before it shows the notification. + expect( + checkHandler(tab, 'notifications', 'https://a.test', { + requestingUrl: 'about:srcdoc', + isMainFrame: false + }) + ).toBe(true) + await expect(request('about:srcdoc')).resolves.toBe(true) + // The tab's Allow is not b.test's, including in the blank frame b.test made. + await expect(request('about:blank')).resolves.toBe(false) + tab.mainFrame.framesInSubtree.push({ url: 'about:blank', origin: 'https://a.test' }) + // Two blank frames from different sites: Orca cannot tell which asked, so it refuses. + await expect(request('about:blank')).resolves.toBe(false) + }) + + it("keeps a paired server's pages off this computer's saved answers for the same profile id", async () => { + const install = await loadInstaller() + const store = await import('./browser-site-permission-store') + store.configureBrowserSitePermissionStore(mkdtempSync(join(tmpdir(), 'orca-site-permissions-'))) + store.setBrowserSitePermissionDecisions('default', 'https://a.test', ['clipboard'], 'allow') + const desktop = { ...profileFor('persist:desktop-default'), id: 'default' } + const paired = { ...profileFor('persist:paired-default'), id: 'default' } + install(desktop) + install(paired, { sitePermissions: 'session' }) + const check = (partition: string): boolean => { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the installer registered exactly this handler on the fake session. + const handler = sessionsByPartition.get(partition)?.setPermissionCheckHandler.mock + .calls[0]?.[0] as ( + webContents: { id: number; getURL: () => string }, + permission: string, + requestingOrigin: string + ) => boolean + return handler({ id: 1, getURL: () => 'https://a.test/' }, 'clipboard-read', 'https://a.test') + } + + expect(check('persist:desktop-default')).toBe(true) + expect(check('persist:paired-default')).toBe(false) + }) + + it("keeps a paired partition's answers after its last page closes and it reopens", async () => { + const install = await loadInstaller() + const policies = await import('./browser-session-partition-policies') + const prompts = await import('./browser-site-permission-prompt') + const paired = profileFor('persist:paired-work') + install(paired, { sitePermissions: 'session' }) + prompts + .sessionBrowserSitePermissions('persist:paired-work') + .set('https://a.test', ['clipboard'], 'allow') + const sess = sessionsByPartition.get('persist:paired-work') + if (!sess) { + throw new Error('Expected the paired session') + } + const check = (): boolean => { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the installer registered this handler on the fake session. + const handler = sess.setPermissionCheckHandler.mock.calls.at(-1)?.[0] as ( + webContents: { id: number; getURL: () => string }, + permission: string, + requestingOrigin: string + ) => boolean + return handler({ id: 1, getURL: () => 'https://a.test/' }, 'clipboard-read', 'https://a.test') + } + expect(check()).toBe(true) + + // Closing the last page clears the partition's policies; the next page installs them again. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the clear path only touches the fake session's handler setters and listeners. + const electronSession = sess as unknown as Electron.Session + policies.clearBrowserSessionPartitionPolicies('persist:paired-work', electronSession) + install(paired, { sitePermissions: 'session' }) + expect(check()).toBe(true) }) it('hands a link to another app to the open-app prompt instead of denying it', async () => { diff --git a/src/main/browser/browser-session-partition-policies.ts b/src/main/browser/browser-session-partition-policies.ts index a376baef72a..bad37bc4fc4 100644 --- a/src/main/browser/browser-session-partition-policies.ts +++ b/src/main/browser/browser-session-partition-policies.ts @@ -9,6 +9,14 @@ import { } from './browser-session-proxy' import { hasSystemMediaAccess, requestSystemMediaAccess } from './browser-media-access' import { isAutoGrantedBrowserSessionPermission } from './browser-session-permission-policy' +import { + browserSitePermissionRequestingOrigin, + browserSitePermissionsFor, + decideBrowserSitePermissions, + isBrowserSitePermissionAllowed, + savedBrowserSitePermissions, + sessionBrowserSitePermissions +} from './browser-site-permission-prompt' import { installBrowserSessionUserAgentPolicy } from './browser-session-ua' import { getBrowserProcessUserAgentIdentity } from './browser-process-user-agent' import { @@ -119,6 +127,8 @@ export async function installBrowserSessionPartitionPolicies( permissions?: BrowserPartitionPermissionPolicy applyAppWideProxy?: boolean userAgentExceptions?: boolean + /** `session`: answers last until Orca quits instead of being saved to the profile. */ + sitePermissions?: 'saved' | 'session' } = {} ): Promise { const { partition } = profile @@ -141,36 +151,11 @@ export async function installBrowserSessionPartitionPolicies( sess.setPermissionCheckHandler(() => false) clearBrowserWebAuthnAccessHandlers(sess) } else { + const siteMemory = + options.sitePermissions === 'session' + ? sessionBrowserSitePermissions(partition) + : savedBrowserSitePermissions(profile.id) sess.setPermissionRequestHandler((webContents, permission, callback, details) => { - // Why: defer media to macOS TCC; denying at the session layer throws NotAllowedError even after the user granted Camera/Mic to the OS. - if (permission === 'media') { - // Capture before async handling; opaque frames cannot be attributed to a named site. - const rawUrl = resolvePermissionNoticeUrl(webContents, details) - void requestSystemMediaAccess( - details as Electron.MediaAccessPermissionRequest | undefined - ).then( - (granted) => { - if (!granted) { - browserManager.notifyPermissionDenied({ - guestWebContentsId: webContents.id, - permission, - rawUrl - }) - } - callback(granted) - }, - (error: unknown) => { - console.error('[permissions] Browser media access failed:', error) - browserManager.notifyPermissionDenied({ - guestWebContentsId: webContents.id, - permission, - rawUrl - }) - callback(false) - } - ) - return - } if ( permission === 'openExternal' && browserManager.requestExternalAppLaunch( @@ -183,29 +168,77 @@ export async function installBrowserSessionPartitionPolicies( callback(false) return } - const allowed = - isAutoGrantedBrowserSessionPermission(permission) && - !deniesRemoteCliDrivenClipboardRead(permission, webContents?.id) - if (!allowed) { - const rawUrl = resolvePermissionNoticeUrl(webContents, details) + // Capture before async handling; opaque frames cannot be attributed to a named site. + const rawUrl = resolvePermissionNoticeUrl(webContents, details) + const notifyDenied = (): void => browserManager.notifyPermissionDenied({ guestWebContentsId: webContents.id, permission, rawUrl }) + if (deniesRemoteCliDrivenClipboardRead(permission, webContents.id)) { + notifyDenied() + callback(false) + return } - callback(allowed) + const mediaRequest = 'mediaTypes' in details ? details : undefined + const sitePermissions = browserSitePermissionsFor(permission, mediaRequest?.mediaTypes) + if (!sitePermissions) { + const allowed = isAutoGrantedBrowserSessionPermission(permission) + if (!allowed) { + notifyDenied() + } + callback(allowed) + return + } + const decide = async (): Promise => { + const allowed = await decideBrowserSitePermissions({ + requester: webContents, + frameOrigin: browserSitePermissionRequestingOrigin(webContents, details), + memory: siteMemory, + permissions: sitePermissions + }) + if (!allowed || permission !== 'media') { + return allowed + } + // Why: macOS gates camera and mic per app too; the user allowed the site, so the notice names the OS refusal. + const granted = await requestSystemMediaAccess(mediaRequest) + if (!granted) { + notifyDenied() + } + return granted + } + void decide().then(callback, (error: unknown) => { + console.error('[permissions] Browser site permission failed:', error) + callback(false) + }) }) - sess.setPermissionCheckHandler((webContents, permission, _origin, details) => { + sess.setPermissionCheckHandler((webContents, permission, requestingOrigin, details) => { if (deniesRemoteCliDrivenClipboardRead(permission, webContents?.id)) { return false } - if (permission === 'media') { - return hasSystemMediaAccess(details?.mediaType) - } if (allowsBrowserWebAuthnPermission(permission, details)) { return true } + const sitePermissions = browserSitePermissionsFor( + permission, + details?.mediaType ? [details.mediaType] : undefined + ) + if (sitePermissions) { + return ( + isBrowserSitePermissionAllowed({ + // Why: a service worker has no contents; its origin is the site's. + source: { + pageUrl: webContents?.getURL() ?? requestingOrigin, + // Why: unlike request details, a check names the frame's real (inherited) origin. + frameOrigin: requestingOrigin + }, + memory: siteMemory, + permissions: sitePermissions + }) && + (permission !== 'media' || hasSystemMediaAccess(details?.mediaType)) + ) + } return isAutoGrantedBrowserSessionPermission(permission) }) installBrowserWebAuthnAccessHandlers(sess) diff --git a/src/main/browser/browser-session-permission-policy.ts b/src/main/browser/browser-session-permission-policy.ts index 7dacf067dc7..87bcc9c39c5 100644 --- a/src/main/browser/browser-session-permission-policy.ts +++ b/src/main/browser/browser-session-permission-policy.ts @@ -1,12 +1,8 @@ +// Granted without asking, as Chrome does. What the user decides per site (camera, location, clipboard +// read, notifications) is in browser-site-permission-prompt.ts; anything in neither is refused. const AUTO_GRANTED_BROWSER_PERMISSIONS = new Set([ 'fullscreen', - // Agent-browser clipboard commands execute via CDP in this session; denying - // them breaks trusted runtime commands even when invoked with a user gesture. - 'clipboard-read', 'clipboard-sanitized-write', - // User-opened browser pages need these profile-scoped grants to complete - // normal site flows like web push setup and durable app storage. - 'notifications', // Chromium can request this at runtime even though Electron's TS union does // not list it; chatgpt.com uses it to keep browser storage from eviction. 'persistent-storage', diff --git a/src/main/browser/browser-session-registry.persistence.test.ts b/src/main/browser/browser-session-registry.persistence.test.ts index a64f341bf52..aed97070ab2 100644 --- a/src/main/browser/browser-session-registry.persistence.test.ts +++ b/src/main/browser/browser-session-registry.persistence.test.ts @@ -4,7 +4,8 @@ import { createFsState, installModuleMocks, META_PATH, - seedMeta + seedMeta, + seedSitePermissions } from './__mocks__/browser-session-registry-persistence-fixture' describe('BrowserSessionRegistry persistence', () => { @@ -225,6 +226,8 @@ describe('BrowserSessionRegistry persistence', () => { profiles: [] }) + seedSitePermissions(fsState, { default: { 'https://example.com': { camera: 'allow' } } }) + const { sessionFromPartitionMock, browserManagerHandleGuestWillDownloadMock, @@ -243,105 +246,94 @@ describe('BrowserSessionRegistry persistence', () => { const checkHandler = defaultSession.setPermissionCheckHandler.mock.calls[0][0] const guestWc = { id: 401, getURL: vi.fn(() => 'https://example.com/account') } const permissionCallback = vi.fn() + const decisions = new Map() + const request = ( + permission: string, + details: Partial = {} + ): void => + requestHandler( + guestWc, + permission, + (allowed: boolean) => decisions.set(permission, allowed), + details + ) - requestHandler(guestWc, 'fullscreen', permissionCallback) - requestHandler(guestWc, 'clipboard-read', permissionCallback) - requestHandler(guestWc, 'clipboard-sanitized-write', permissionCallback) - requestHandler(guestWc, 'notifications', permissionCallback) - requestHandler(guestWc, 'persistent-storage', permissionCallback) - requestHandler(guestWc, 'geolocation', permissionCallback) - requestHandler(guestWc, 'media', permissionCallback, { mediaTypes: ['video'] }) + // The user allowed this site's camera before; nothing else was answered, and the tab has no + // window to ask in. + request('fullscreen') + request('clipboard-read') + request('clipboard-sanitized-write') + request('notifications') + request('persistent-storage') + request('geolocation') + request('media', { mediaTypes: ['video'] }) - await vi.waitFor(() => - expect(permissionCallback.mock.calls).toEqual([ - [true], - [true], - [true], - [true], - [true], - [false], - [true] - ]) - ) - expect(browserManagerNotifyPermissionDeniedMock).toHaveBeenCalledWith({ - guestWebContentsId: 401, - permission: 'geolocation', - rawUrl: 'https://example.com/account' + await vi.waitFor(() => expect(decisions.size).toBe(7)) + expect(Object.fromEntries(decisions)).toEqual({ + fullscreen: true, + 'clipboard-read': false, + 'clipboard-sanitized-write': true, + notifications: false, + 'persistent-storage': true, + geolocation: false, + media: true }) + // Unanswered questions are not Orca's refusal, so no notice. + expect(browserManagerNotifyPermissionDeniedMock).not.toHaveBeenCalled() - // A subframe denial must name the requester, not its top-level embedder. - browserManagerNotifyPermissionDeniedMock.mockClear() - requestHandler(guestWc, 'geolocation', permissionCallback, { + // What the user cannot decide keeps the denial notice, naming the frame that asked. + requestHandler(guestWc, 'mediaKeySystem', permissionCallback, { requestingUrl: 'https://widget.example.net/embed', isMainFrame: false }) - await vi.waitFor(() => - expect(browserManagerNotifyPermissionDeniedMock).toHaveBeenCalledWith({ - guestWebContentsId: 401, - permission: 'geolocation', - rawUrl: 'https://widget.example.net/embed' - }) - ) + expect(browserManagerNotifyPermissionDeniedMock).toHaveBeenCalledWith({ + guestWebContentsId: 401, + permission: 'mediaKeySystem', + rawUrl: 'https://widget.example.net/embed' + }) // Missing or empty frame URLs fall back to the visible top-level page. browserManagerNotifyPermissionDeniedMock.mockClear() - requestHandler(guestWc, 'geolocation', permissionCallback, { isMainFrame: true }) - await vi.waitFor(() => - expect(browserManagerNotifyPermissionDeniedMock).toHaveBeenCalledWith({ - guestWebContentsId: 401, - permission: 'geolocation', - rawUrl: 'https://example.com/account' - }) - ) - - browserManagerNotifyPermissionDeniedMock.mockClear() - requestHandler(guestWc, 'geolocation', permissionCallback, { + requestHandler(guestWc, 'mediaKeySystem', permissionCallback, { isMainFrame: true }) + requestHandler(guestWc, 'mediaKeySystem', permissionCallback, { requestingUrl: '', isMainFrame: false }) - await vi.waitFor(() => - expect(browserManagerNotifyPermissionDeniedMock).toHaveBeenCalledWith({ - guestWebContentsId: 401, - permission: 'geolocation', - rawUrl: 'https://example.com/account' - }) - ) + expect( + browserManagerNotifyPermissionDeniedMock.mock.calls.map(([args]) => args.rawUrl) + ).toEqual(['https://example.com/account', 'https://example.com/account']) // Opaque frame URLs have no site Orca can name accurately. browserManagerNotifyPermissionDeniedMock.mockClear() - requestHandler(guestWc, 'geolocation', permissionCallback, { + requestHandler(guestWc, 'mediaKeySystem', permissionCallback, { requestingUrl: 'about:blank', isMainFrame: false }) - await vi.waitFor(() => - expect(browserManagerNotifyPermissionDeniedMock).toHaveBeenCalledWith({ - guestWebContentsId: 401, - permission: 'geolocation', - rawUrl: '' - }) - ) - expect( - browserManagerNotifyPermissionDeniedMock.mock.calls.map(([args]) => args.permission) - ).toEqual(['geolocation']) + expect(browserManagerNotifyPermissionDeniedMock).toHaveBeenCalledWith({ + guestWebContentsId: 401, + permission: 'mediaKeySystem', + rawUrl: '' + }) expect(checkHandler(null, 'fullscreen', '')).toBe(true) - expect(checkHandler(null, 'clipboard-read', '')).toBe(true) + expect(checkHandler(null, 'clipboard-read', 'https://example.com')).toBe(false) expect(checkHandler(null, 'clipboard-sanitized-write', '')).toBe(true) - expect(checkHandler(null, 'notifications', '')).toBe(true) + expect(checkHandler(null, 'notifications', 'https://example.com')).toBe(false) expect(checkHandler(null, 'persistent-storage', '')).toBe(true) - expect(checkHandler(null, 'geolocation', '')).toBe(false) - expect(checkHandler(null, 'media', '', { mediaType: 'video' })).toBe(true) + expect(checkHandler(null, 'geolocation', 'https://example.com')).toBe(false) + expect(checkHandler(guestWc, 'media', '', { mediaType: 'video' })).toBe(true) + expect(checkHandler(guestWc, 'media', '', { mediaType: 'audio' })).toBe(false) // Why: this session allows unpartitioned third-party cookies, so a cross-site frame already has // the access requestStorageAccess() would grant. Denying it protected nothing and only broke // sites taking the API's failure path. Red before the grant landed. - requestHandler(guestWc, 'storage-access', permissionCallback) + requestHandler(guestWc, 'storage-access', permissionCallback, {}) expect(permissionCallback).toHaveBeenLastCalledWith(true) expect(checkHandler(null, 'storage-access', '')).toBe(true) // Why: requestStorageAccessFor() is a different platform decision — Chromium consults Related // Website Sets and has no third-party-cookie auto-grant, and Orca has no such data source. This // pins the deliberate denial so a future blanket widening of the allow-set fails loudly. - requestHandler(guestWc, 'top-level-storage-access', permissionCallback) + requestHandler(guestWc, 'top-level-storage-access', permissionCallback, {}) expect(permissionCallback).toHaveBeenLastCalledWith(false) expect(checkHandler(null, 'top-level-storage-access', '')).toBe(false) @@ -349,7 +341,7 @@ describe('BrowserSessionRegistry persistence', () => { // storage-access must no longer raise one, while the deliberate top-level denial still does. expect( browserManagerNotifyPermissionDeniedMock.mock.calls.map(([args]) => args.permission) - ).toEqual(['geolocation', 'top-level-storage-access']) + ).toEqual(['mediaKeySystem', 'top-level-storage-access']) expect(defaultSession.setDisplayMediaRequestHandler).toHaveBeenCalled() const displayMediaHandler = defaultSession.setDisplayMediaRequestHandler.mock.calls[0][0] const displayMediaCallback = vi.fn() @@ -473,6 +465,8 @@ describe('BrowserSessionRegistry persistence', () => { profiles: [] }) + // The user allowed the site, so only the operating system refuses. + seedSitePermissions(fsState, { default: { 'https://example.com': { camera: 'allow' } } }) const { sessionFromPartitionMock, browserManagerNotifyPermissionDeniedMock, diff --git a/src/main/browser/browser-session-registry.test.ts b/src/main/browser/browser-session-registry.test.ts index a3e84b290f0..e84dacab9f0 100644 --- a/src/main/browser/browser-session-registry.test.ts +++ b/src/main/browser/browser-session-registry.test.ts @@ -1,3 +1,6 @@ +import { mkdtempSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' import { beforeEach, describe, expect, it, vi } from 'vitest' const { @@ -38,11 +41,17 @@ vi.mock('./browser-manager', () => ({ notifyPermissionDenied: vi.fn(), handleGuestWillDownload: vi.fn(), installCertificateRequestGuard: vi.fn(), - removeCertificateRequestGuard: removeCertificateRequestGuardMock + removeCertificateRequestGuard: removeCertificateRequestGuardMock, + resolvePagePromptTarget: vi.fn(() => null) } })) import { browserSessionRegistry } from './browser-session-registry' +import { + configureBrowserSitePermissionStore, + listBrowserSitePermissions, + setBrowserSitePermissionDecisions +} from './browser-site-permission-store' import { googleAuthUserAgent } from './browser-google-auth-ua' import { installBrowserSessionUserAgentPolicy } from './browser-session-ua' import { setBrowserNetworkProxySettingsResolver } from './browser-session-proxy' @@ -276,14 +285,17 @@ describe('BrowserSessionRegistry', () => { expect(updated!.source?.browserFamily).toBe('comet') }) - it('deletes a non-default profile', async () => { + it('deletes a non-default profile and its remembered site answers', async () => { + configureBrowserSitePermissionStore(mkdtempSync(join(tmpdir(), 'orca-site-permissions-'))) const profile = await browserSessionRegistry.createProfile('isolated', 'Delete Test') expect(profile).not.toBeNull() expect(browserSessionRegistry.isAllowedPartition(profile!.partition)).toBe(true) + setBrowserSitePermissionDecisions(profile!.id, 'https://example.com', ['camera'], 'allow') const deleted = await browserSessionRegistry.deleteProfile(profile!.id) expect(deleted).toBe(true) expect(browserSessionRegistry.isAllowedPartition(profile!.partition)).toBe(false) expect(browserSessionRegistry.getProfile(profile!.id)).toBeNull() + expect(listBrowserSitePermissions(profile!.id)).toEqual([]) }) it('retains session security policies when deleting a profile', async () => { @@ -445,25 +457,34 @@ describe('BrowserSessionRegistry', () => { expect(checkHandler(null, 'top-level-storage-access', '', {})).toBe(false) }) - it('routes media permission requests through macOS TCC for isolated partitions', async () => { - // Why: verify the parallel fix to the default partition — isolated/imported - // profiles must also defer media permission checks to macOS instead of - // denying outright, otherwise pages inside them still hit NotAllowedError - // after the user grants Camera/Microphone to Orca. - await browserSessionRegistry.createProfile('isolated', 'Media Test') + it('asks macOS for the camera only after the user allowed the site', async () => { + // Why: isolated/imported profiles must also defer to macOS, otherwise pages inside them still + // hit NotAllowedError after the user grants Camera to Orca. + configureBrowserSitePermissionStore(mkdtempSync(join(tmpdir(), 'orca-site-permissions-'))) + const profile = await browserSessionRegistry.createProfile('isolated', 'Media Test') + if (!profile) { + throw new Error('Expected the profile') + } + setBrowserSitePermissionDecisions(profile.id, 'https://example.com', ['camera'], 'allow') const mockSession = sessionFromPartitionMock.mock.results[0]?.value const requestHandler = mockSession.setPermissionRequestHandler.mock.calls[0][0] const checkHandler = mockSession.setPermissionCheckHandler.mock.calls[0][0] - const cb = vi.fn() - const guestWc = { id: 7, getURL: vi.fn(() => 'https://example.com/') } - requestHandler(guestWc, 'media', cb, { mediaTypes: ['video'] }) - await vi.waitFor(() => expect(cb).toHaveBeenCalledWith(true)) + const allowedSite = vi.fn() + const otherSite = vi.fn() + const allowedGuest = { id: 7, getURL: vi.fn(() => 'https://example.com/') } + const otherGuest = { id: 8, getURL: vi.fn(() => 'https://other.test/') } + requestHandler(allowedGuest, 'media', allowedSite, { mediaTypes: ['video'] }) + requestHandler(otherGuest, 'media', otherSite, { mediaTypes: ['video'] }) + await vi.waitFor(() => expect(allowedSite).toHaveBeenCalledWith(true)) + // No window to ask in, so a site nobody answered gets nothing. + await vi.waitFor(() => expect(otherSite).toHaveBeenCalledWith(false)) - expect(checkHandler(null, 'media', '', { mediaType: 'video' })).toBe(true) - expect(checkHandler(null, 'notifications', '', {})).toBe(true) + expect(checkHandler(allowedGuest, 'media', '', { mediaType: 'video' })).toBe(true) + expect(checkHandler(otherGuest, 'media', '', { mediaType: 'video' })).toBe(false) + expect(checkHandler(null, 'notifications', 'https://example.com', {})).toBe(false) expect(checkHandler(null, 'persistent-storage', '', {})).toBe(true) - expect(checkHandler(null, 'geolocation', '', {})).toBe(false) + expect(checkHandler(null, 'geolocation', 'https://example.com', {})).toBe(false) }) it('wires WebAuthn device selection for isolated partitions', async () => { diff --git a/src/main/browser/browser-session-registry.ts b/src/main/browser/browser-session-registry.ts index 94135153991..41ade0ffd50 100644 --- a/src/main/browser/browser-session-registry.ts +++ b/src/main/browser/browser-session-registry.ts @@ -41,6 +41,7 @@ import { retireFailedBrowserSessionProfile } from './browser-session-profile-ret import { cancelBrowserWebAuthnAccountRequestsForSession } from './browser-webauthn-account-picker' import { getCanonicalUserDataPath } from '../persistence/loading-store/user-data-path' import { markBrowserIdentityMigrationNoticePending } from './browser-identity-mode-store' +import { forgetBrowserSitePermissions } from './browser-site-permission-store' export type BrowserSessionRegistryProfileOptions = { orcaProfileId: string @@ -272,6 +273,7 @@ class BrowserSessionRegistry { } this.profiles.delete(profileId) this.persistProfiles() + forgetBrowserSitePermissions(profileId) const meta = this.loadPersistedMeta() const pendingCookieImports = { ...meta.pendingCookieImports } delete pendingCookieImports[profile.partition] diff --git a/src/main/browser/browser-session-route-policies.ts b/src/main/browser/browser-session-route-policies.ts index 6e550869c4c..a77937020c2 100644 --- a/src/main/browser/browser-session-route-policies.ts +++ b/src/main/browser/browser-session-route-policies.ts @@ -8,14 +8,15 @@ import { export function installBrowserRoutePartitionPolicies( profile: BrowserSessionProfile, - partition: string + partition: string, + { sitePermissions }: { sitePermissions?: 'saved' | 'session' } = {} ): Promise { if (!isBrowserRoutePartition(partition)) { throw new Error('browser_route_partition_profile_unavailable') } return installBrowserSessionPartitionPolicies( { ...profile, partition }, - { applyAppWideProxy: false } + { applyAppWideProxy: false, sitePermissions } ) } diff --git a/src/main/browser/browser-session-startup.ts b/src/main/browser/browser-session-startup.ts index 7fad135eeff..3959cd88f40 100644 --- a/src/main/browser/browser-session-startup.ts +++ b/src/main/browser/browser-session-startup.ts @@ -2,6 +2,7 @@ import { browserSessionRegistry } from './browser-session-registry' import type { BrowserSessionRegistryProfileOptions } from './browser-session-registry' import { collectOrphanedBrowserRoutePartitionStorage } from './browser-route-partition-storage-runtime' import { configureRouteSessionsForOrcaProfile } from './browser-route-session-runtime' +import { configureBrowserSitePermissionStore } from './browser-site-permission-store' import { configurePairedRuntimeBrowserClientHostsForOrcaProfile } from './paired-runtime-browser-client-host-runtime' let initialized = false @@ -17,6 +18,7 @@ export function initializeBrowserSessionsForApp( if (activeProfile) { browserSessionRegistry.configureForOrcaProfile(activeProfile) + configureBrowserSitePermissionStore(activeProfile.profileDirectory) configureRouteSessionsForOrcaProfile({ orcaProfileId: activeProfile.orcaProfileId, profileDirectory: activeProfile.profileDirectory diff --git a/src/main/browser/browser-site-permission-prompt.test.ts b/src/main/browser/browser-site-permission-prompt.test.ts new file mode 100644 index 00000000000..872d413b643 --- /dev/null +++ b/src/main/browser/browser-site-permission-prompt.test.ts @@ -0,0 +1,281 @@ +import { EventEmitter } from 'node:events' +import { mkdtempSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + resolvePagePromptTarget: vi.fn() +})) + +vi.mock('./browser-manager', () => ({ + browserManager: { + resolvePagePromptTarget: mocks.resolvePagePromptTarget + } +})) + +import type { BrowserPagePromptRequest } from '../../shared/browser-page-prompt' +import type { BrowserSitePermission } from '../../shared/browser-site-permission' +import { BROWSER_PAGE_PROMPT_TIMEOUT_MS, respondToBrowserPagePrompt } from './browser-page-prompts' +import { + decideBrowserSitePermissions, + isBrowserSitePermissionAllowed, + savedBrowserSitePermissions, + sessionBrowserSitePermissions, + type BrowserSitePermissionMemory +} from './browser-site-permission-prompt' +import { + configureBrowserSitePermissionStore, + listBrowserSitePermissions +} from './browser-site-permission-store' + +type MockGuest = Electron.WebContents & EventEmitter + +const guests: MockGuest[] = [] + +function mockGuest(pageUrl = 'https://meet.example.com/room'): MockGuest { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the prompt only reads id, isDestroyed, getURL and the input, navigation and destroyed events. + const guest = new EventEmitter() as MockGuest + Object.assign(guest, { + id: 100 + guests.length, + isDestroyed: () => false, + getURL: () => pageUrl + }) + guests.push(guest) + return guest +} + +function mockRenderer(): Electron.WebContents { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: prompts only read id, isDestroyed, send and subscribe to the window's lifecycle events. + return { + id: 10, + isDestroyed: () => false, + send: vi.fn(), + on: vi.fn(), + off: vi.fn() + } as unknown as Electron.WebContents +} + +function sentRequests( + renderer: Electron.WebContents +): BrowserPagePromptRequest<'site-permission'>[] { + return vi + .mocked(renderer.send) + .mock.calls.filter(([channel]) => channel === 'browser:page-prompt-requested') + .map(([, request]) => request) +} + +let renderer: Electron.WebContents + +function ask( + permissions: BrowserSitePermission[], + { + memory = savedBrowserSitePermissions('default'), + guest = mockGuest(), + frameOrigin = guest.getURL() + }: { memory?: BrowserSitePermissionMemory; guest?: MockGuest; frameOrigin?: string | null } = {} +): Promise { + return decideBrowserSitePermissions({ requester: guest, frameOrigin, memory, permissions }) +} + +/** What the page reads back, e.g. before Chromium shows a notification. */ +function allowedOnCheck( + permissions: BrowserSitePermission[], + { + memory = savedBrowserSitePermissions('default'), + frameOrigin = 'https://meet.example.com' + }: { memory?: BrowserSitePermissionMemory; frameOrigin?: string } = {} +): boolean { + return isBrowserSitePermissionAllowed({ + source: { pageUrl: 'https://meet.example.com/room', frameOrigin }, + memory, + permissions + }) +} + +/** Answers the newest prompt the window was sent. */ +async function answerLatest(value: unknown): Promise { + const request = sentRequests(renderer).at(-1) + if (!request) { + throw new Error('Expected a prompt') + } + await respondToBrowserPagePrompt(renderer, { + requestId: request.requestId, + answer: value + }) +} + +describe('browser site permission prompts', () => { + beforeEach(() => { + vi.useFakeTimers() + configureBrowserSitePermissionStore(mkdtempSync(join(tmpdir(), 'orca-site-permissions-'))) + renderer = mockRenderer() + mocks.resolvePagePromptTarget.mockReset() + mocks.resolvePagePromptTarget.mockReturnValue({ + browserPageId: 'page-1', + renderer + }) + }) + + afterEach(() => { + vi.advanceTimersByTime(BROWSER_PAGE_PROMPT_TIMEOUT_MS) + for (const guest of guests.splice(0)) { + guest.emit('destroyed') + } + vi.useRealTimers() + }) + + it('asks once for camera and microphone together, then remembers Allow', async () => { + const call = ask(['camera', 'microphone']) + expect(sentRequests(renderer)).toMatchObject([ + { + kind: 'site-permission', + details: { + origin: 'https://meet.example.com', + permissions: ['camera', 'microphone'] + } + } + ]) + await answerLatest('allow') + await expect(call).resolves.toBe(true) + + await expect(ask(['camera'])).resolves.toBe(true) + expect(sentRequests(renderer)).toHaveLength(1) + expect(allowedOnCheck(['camera', 'microphone'])).toBe(true) + }) + + it('remembers Block and refuses without asking again', async () => { + const call = ask(['location']) + await answerLatest('block') + await expect(call).resolves.toBe(false) + + await expect(ask(['location'])).resolves.toBe(false) + expect(sentRequests(renderer)).toHaveLength(1) + }) + + it('asks only for what is unanswered, and a remembered Block refuses the whole request', async () => { + const camera = ask(['camera']) + await answerLatest('allow') + await camera + const both = ask(['camera', 'microphone']) + expect(sentRequests(renderer).at(-1)?.details.permissions).toEqual(['microphone']) + await answerLatest('block') + await expect(both).resolves.toBe(false) + + await expect(ask(['camera', 'microphone'])).resolves.toBe(false) + expect(sentRequests(renderer)).toHaveLength(2) + }) + + it('forgets a dismissed prompt, so the site is asked again later', async () => { + const call = ask(['notifications']) + await answerLatest(null) + await expect(call).resolves.toBe(false) + + expect(listBrowserSitePermissions('default')).toEqual([]) + // A fresh page from the same site asks again; the dismissed one stays quiet until the user acts. + void ask(['notifications']) + expect(sentRequests(renderer)).toHaveLength(2) + }) + + it('keeps each browser profile to its own answers', async () => { + const call = ask(['clipboard'], { memory: savedBrowserSitePermissions('work') }) + await answerLatest('allow') + await call + + void ask(['clipboard'], { memory: savedBrowserSitePermissions('default') }) + expect(sentRequests(renderer)).toHaveLength(2) + }) + + it("keeps a paired server's answers for its session only, where checks still see them", async () => { + const pairedSession = sessionBrowserSitePermissions('persist:paired-a') + const call = ask(['notifications'], { memory: pairedSession }) + await answerLatest('allow') + await expect(call).resolves.toBe(true) + + // Chromium checks again before it shows a notification. + expect(allowedOnCheck(['notifications'], { memory: pairedSession })).toBe(true) + await expect(ask(['notifications'], { memory: pairedSession })).resolves.toBe(true) + expect(sentRequests(renderer)).toHaveLength(1) + expect(listBrowserSitePermissions('default')).toEqual([]) + // Another paired partition asks again. + void ask(['notifications'], { memory: sessionBrowserSitePermissions('persist:paired-b') }) + expect(sentRequests(renderer)).toHaveLength(2) + }) + + it("gives a frame the tab's answers for what the tab delegates", async () => { + const call = ask(['camera']) + await answerLatest('allow') + await call + + // The tab let the frame ask (Chromium enforces that), and the tab's answer decides, as in Chrome. + await expect(ask(['camera'], { frameOrigin: 'https://video.example.net' })).resolves.toBe(true) + expect(sentRequests(renderer)).toHaveLength(1) + }) + + it("gives another site's frame only its own notification answer, and never asks for it", async () => { + const tab = ask(['notifications']) + await answerLatest('allow') + await tab + // The widget's own site was allowed when the user visited it directly. + const widgetPage = ask(['notifications'], { guest: mockGuest('https://widget.example.net/') }) + await answerLatest('allow') + await widgetPage + + expect(allowedOnCheck(['notifications'], { frameOrigin: 'https://widget.example.net' })).toBe( + true + ) + await expect( + ask(['notifications'], { frameOrigin: 'https://widget.example.net' }) + ).resolves.toBe(true) + // The tab's Allow does not reach a frame from a site nobody answered, and that frame cannot ask. + expect(allowedOnCheck(['notifications'], { frameOrigin: 'https://ads.example.net' })).toBe( + false + ) + await expect(ask(['notifications'], { frameOrigin: 'https://ads.example.net' })).resolves.toBe( + false + ) + expect(sentRequests(renderer)).toHaveLength(2) + }) + + it('asks for a local file, and every local file shares the answer', async () => { + const call = ask(['camera'], { guest: mockGuest('file:///Users/me/lab/permission-lab.html') }) + expect(sentRequests(renderer).at(-1)?.details.origin).toBe('file://') + await answerLatest('allow') + await expect(call).resolves.toBe(true) + + await expect( + ask(['camera'], { guest: mockGuest('file:///Users/me/other/page.html') }) + ).resolves.toBe(true) + expect(sentRequests(renderer)).toHaveLength(1) + }) + + it('refuses an opaque page without asking', async () => { + await expect(ask(['camera'], { guest: mockGuest('data:text/html,

hi

') })).resolves.toBe( + false + ) + expect(sentRequests(renderer)).toEqual([]) + }) + + it('refuses without asking when no window can show the question', async () => { + mocks.resolvePagePromptTarget.mockReturnValue(null) + + await expect(ask(['camera'])).resolves.toBe(false) + expect(sentRequests(renderer)).toEqual([]) + }) + + it('remembers answers across a restart', async () => { + const directory = mkdtempSync(join(tmpdir(), 'orca-site-permissions-')) + configureBrowserSitePermissionStore(directory) + const call = ask(['midi']) + await answerLatest('allow') + await call + + // Reading another directory replaces the in-memory copy, so the next read comes from the file. + configureBrowserSitePermissionStore(mkdtempSync(join(tmpdir(), 'orca-site-permissions-'))) + expect(listBrowserSitePermissions('default')).toEqual([]) + configureBrowserSitePermissionStore(directory) + expect(listBrowserSitePermissions('default')).toEqual([ + { origin: 'https://meet.example.com', decisions: { midi: 'allow' } } + ]) + }) +}) diff --git a/src/main/browser/browser-site-permission-prompt.ts b/src/main/browser/browser-site-permission-prompt.ts new file mode 100644 index 00000000000..cc4120d802b --- /dev/null +++ b/src/main/browser/browser-site-permission-prompt.ts @@ -0,0 +1,204 @@ +import { + BROWSER_SITE_PERMISSIONS, + BROWSER_SITE_PERMISSION_LOCAL_FILES_ORIGIN, + isBrowserSitePermissionDecision, + type BrowserSitePermission, + type BrowserSitePermissionDecision +} from '../../shared/browser-site-permission' +import { browserManager } from './browser-manager' +import { browserPagePromptOrigin, openBrowserPagePrompt } from './browser-page-prompts' +import { + getBrowserSitePermissionDecision, + setBrowserSitePermissionDecisions +} from './browser-site-permission-store' + +const SITE_PERMISSION_BY_ELECTRON_PERMISSION: Partial> = { + geolocation: 'location', + notifications: 'notifications', + 'clipboard-read': 'clipboard', + midi: 'midi', + midiSysex: 'midi', + 'window-management': 'window-management', + 'idle-detection': 'idle-detection' +} + +const SITE_PERMISSION_BY_MEDIA_TYPE: Partial> = { + video: 'camera', + audio: 'microphone' +} + +/** + * What the user decides for an Electron permission: null when it is not theirs to decide. An empty + * list (media naming no device) is never granted. + */ +export function browserSitePermissionsFor( + permission: string, + mediaTypes: readonly string[] | undefined +): BrowserSitePermission[] | null { + if (permission === 'media') { + const requested = new Set((mediaTypes ?? []).map((type) => SITE_PERMISSION_BY_MEDIA_TYPE[type])) + // Why: the prompt lists camera before microphone whatever order Chromium reports them in. + return BROWSER_SITE_PERMISSIONS.filter((sitePermission) => requested.has(sitePermission)) + } + const sitePermission = SITE_PERMISSION_BY_ELECTRON_PERMISSION[permission] + return sitePermission ? [sitePermission] : null +} + +/** Where one browser session's answers live. */ +export type BrowserSitePermissionMemory = { + get(origin: string, permission: BrowserSitePermission): BrowserSitePermissionDecision | undefined + set( + origin: string, + permissions: readonly BrowserSitePermission[], + decision: BrowserSitePermissionDecision + ): void +} + +/** A browser profile's answers, saved and listed in settings. */ +export function savedBrowserSitePermissions(profileId: string): BrowserSitePermissionMemory { + return { + get: (origin, permission) => getBrowserSitePermissionDecision(profileId, origin, permission), + set: (origin, permissions, decision) => + setBrowserSitePermissionDecisions(profileId, origin, permissions, decision) + } +} + +const sessionMemories = new Map() + +/** + * Answers kept until Orca quits, for a paired server's profile, which settings here cannot list. + * Keyed by partition: closing its last page drops the handlers but must not forget the answers. + */ +export function sessionBrowserSitePermissions(partition: string): BrowserSitePermissionMemory { + const existing = sessionMemories.get(partition) + if (existing) { + return existing + } + const decisions = new Map() + const memory: BrowserSitePermissionMemory = { + get: (origin, permission) => decisions.get(`${origin} ${permission}`), + set: (origin, permissions, decision) => { + for (const permission of permissions) { + decisions.set(`${origin} ${permission}`, decision) + } + } + } + sessionMemories.set(partition, memory) + return memory +} + +/** The site a page's permissions belong to; null for opaque pages, which are never granted. */ +export function browserSitePermissionOrigin(rawUrl: string | null): string | null { + return rawUrl?.startsWith('file:') + ? BROWSER_SITE_PERMISSION_LOCAL_FILES_ORIGIN + : browserPagePromptOrigin(rawUrl) +} + +/** + * The asking frame's real origin. Request details carry only its URL, and an about:blank or srcdoc + * frame inherits its creator's origin, so read it off the frame. Null when frames at that URL differ. + */ +export function browserSitePermissionRequestingOrigin( + contents: Electron.WebContents, + details: { requestingUrl?: string; isMainFrame?: boolean } +): string | null { + if (details.isMainFrame !== false || !details.requestingUrl) { + return contents.getURL() + } + try { + const origins = new Set( + contents.mainFrame.framesInSubtree + .filter((frame) => frame.url === details.requestingUrl) + .map((frame) => frame.origin) + ) + return origins.size === 1 ? [...origins][0] : null + } catch { + // A frame disposed mid-walk throws on access. + return null + } +} + +/** The tab and the asking frame's real origin; null `frameOrigin` grants that frame nothing. */ +type SitePermissionSource = { pageUrl: string | null; frameOrigin: string | null } + +/** + * Whose answers apply (null grants nothing) and whether that site may be asked now. As in Chrome, + * a frame shares its tab's answers, except notifications, which belong to the frame's own site and + * are only asked for by the tab's site. + */ +function answeringSite( + { pageUrl, frameOrigin }: SitePermissionSource, + permissions: readonly BrowserSitePermission[] +): { origin: string | null; mayAsk: boolean } { + const pageOrigin = browserSitePermissionOrigin(pageUrl) + if (!permissions.includes('notifications')) { + return { origin: pageOrigin, mayAsk: true } + } + const origin = browserSitePermissionOrigin(frameOrigin) + return { origin, mayAsk: origin === pageOrigin } +} + +export function isBrowserSitePermissionAllowed(args: { + source: SitePermissionSource + memory: BrowserSitePermissionMemory + permissions: readonly BrowserSitePermission[] +}): boolean { + const { origin } = answeringSite(args.source, args.permissions) + return ( + origin !== null && + args.permissions.length > 0 && + args.permissions.every((permission) => args.memory.get(origin, permission) === 'allow') + ) +} + +/** + * Resolves true when the page may have every permission: each was allowed before, or the user + * allows the rest now. Any remembered Block refuses without asking. + */ +export function decideBrowserSitePermissions(args: { + requester: Electron.WebContents + frameOrigin: string | null + memory: BrowserSitePermissionMemory + permissions: readonly BrowserSitePermission[] +}): Promise { + const { requester, memory, permissions } = args + const { origin, mayAsk } = answeringSite( + { pageUrl: requester.getURL(), frameOrigin: args.frameOrigin }, + permissions + ) + if (origin === null || permissions.length === 0) { + return Promise.resolve(false) + } + const decisions = permissions.map((permission) => memory.get(origin, permission)) + if (decisions.includes('block')) { + return Promise.resolve(false) + } + const unanswered = permissions.filter((_, index) => decisions[index] === undefined) + if (unanswered.length === 0) { + return Promise.resolve(true) + } + if (!mayAsk) { + return Promise.resolve(false) + } + return new Promise((resolve) => { + const opening = openBrowserPagePrompt({ + kind: 'site-permission', + requester, + target: browserManager.resolvePagePromptTarget(requester), + details: () => ({ origin, permissions: unanswered }), + // Why: a page asking again for the same thing before the user answers gets one question. + coalesceKey: `${origin} ${unanswered.join(' ')}`, + parseAnswer: (value) => (isBrowserSitePermissionDecision(value) ? value : undefined), + settle: (decision) => { + if (decision) { + memory.set(origin, unanswered, decision) + } + resolve(decision === 'allow') + } + }) + // Why: no window to ask (a headless server), or the page is quiet or already asking. + if (opening !== 'prompting') { + resolve(false) + } + }) +} diff --git a/src/main/browser/browser-site-permission-store.ts b/src/main/browser/browser-site-permission-store.ts new file mode 100644 index 00000000000..119728358c8 --- /dev/null +++ b/src/main/browser/browser-site-permission-store.ts @@ -0,0 +1,152 @@ +import { app } from 'electron' +import { mkdirSync, readFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { durableWriteTempPath, writeFileDurableSync } from '../durable-file-write' +import { + isBrowserSitePermission, + isBrowserSitePermissionDecision, + type BrowserSitePermission, + type BrowserSitePermissionDecision, + type BrowserSitePermissionEntry +} from '../../shared/browser-site-permission' + +export const BROWSER_SITE_PERMISSIONS_FILE_NAME = 'browser-site-permissions.json' + +type SiteDecisions = BrowserSitePermissionEntry['decisions'] +/** Browser profile id → origin → remembered answers. */ +type StoredDecisions = Record> + +let filePathOverride: string | null = null +let cached: { path: string; decisions: StoredDecisions } | null = null + +// Why lazy: app.getPath('userData') throws before ready. +function storePath(): string { + return filePathOverride ?? join(app.getPath('userData'), BROWSER_SITE_PERMISSIONS_FILE_NAME) +} + +/** Answers belong to the Orca profile whose browser profiles they name. */ +export function configureBrowserSitePermissionStore(profileDirectory: string): void { + filePathOverride = join(profileDirectory, BROWSER_SITE_PERMISSIONS_FILE_NAME) +} + +function parseSiteDecisions(value: unknown): SiteDecisions { + const decisions: SiteDecisions = {} + if (value && typeof value === 'object') { + for (const [permission, decision] of Object.entries(value)) { + if (isBrowserSitePermission(permission) && isBrowserSitePermissionDecision(decision)) { + decisions[permission] = decision + } + } + } + return decisions +} + +function parseStoredDecisions(raw: string): StoredDecisions { + const parsed: unknown = JSON.parse(raw) + const stored: StoredDecisions = {} + if (!parsed || typeof parsed !== 'object' || !('profiles' in parsed)) { + return stored + } + const { profiles } = parsed + if (!profiles || typeof profiles !== 'object') { + return stored + } + for (const [profileId, sites] of Object.entries(profiles)) { + if (!sites || typeof sites !== 'object') { + continue + } + const parsedSites: Record = {} + for (const [origin, decisions] of Object.entries(sites)) { + const parsedDecisions = parseSiteDecisions(decisions) + if (Object.keys(parsedDecisions).length > 0) { + parsedSites[origin] = parsedDecisions + } + } + stored[profileId] = parsedSites + } + return stored +} + +function load(): StoredDecisions { + let path: string + try { + path = storePath() + } catch { + return {} + } + if (cached?.path === path) { + return cached.decisions + } + let decisions: StoredDecisions = {} + try { + decisions = parseStoredDecisions(readFileSync(path, 'utf-8')) + } catch { + // Missing or unreadable: every site is asked again. + } + cached = { path, decisions } + return decisions +} + +function persist(decisions: StoredDecisions): void { + try { + const path = storePath() + cached = { path, decisions } + mkdirSync(dirname(path), { recursive: true }) + writeFileDurableSync( + durableWriteTempPath(path), + path, + `${JSON.stringify({ version: 1, profiles: decisions }, null, 2)}\n` + ) + } catch (error) { + // Why: the answer still holds for this run; failing the page's request would punish the user. + console.error('[browser] Could not save site permissions:', error) + } +} + +export function getBrowserSitePermissionDecision( + profileId: string, + origin: string, + permission: BrowserSitePermission +): BrowserSitePermissionDecision | undefined { + return load()[profileId]?.[origin]?.[permission] +} + +/** Null forgets the answer, so the site is asked again. */ +export function setBrowserSitePermissionDecisions( + profileId: string, + origin: string, + permissions: readonly BrowserSitePermission[], + decision: BrowserSitePermissionDecision | null +): void { + const stored = load() + const sites = { ...stored[profileId] } + const decisions = { ...sites[origin] } + for (const permission of permissions) { + if (decision) { + decisions[permission] = decision + } else { + delete decisions[permission] + } + } + if (Object.keys(decisions).length > 0) { + sites[origin] = decisions + } else { + delete sites[origin] + } + persist({ ...stored, [profileId]: sites }) +} + +export function listBrowserSitePermissions(profileId: string): BrowserSitePermissionEntry[] { + return Object.entries(load()[profileId] ?? {}) + .map(([origin, decisions]) => ({ origin, decisions })) + .sort((a, b) => a.origin.localeCompare(b.origin)) +} + +export function forgetBrowserSitePermissions(profileId: string): void { + const stored = load() + if (!(profileId in stored)) { + return + } + const { [profileId]: _forgotten, ...rest } = stored + persist(rest) +} diff --git a/src/main/ipc/browser-preview-tool-authorization.test.ts b/src/main/ipc/browser-preview-tool-authorization.test.ts index aff2b194083..31413e7b73e 100644 --- a/src/main/ipc/browser-preview-tool-authorization.test.ts +++ b/src/main/ipc/browser-preview-tool-authorization.test.ts @@ -145,7 +145,10 @@ const BROWSER_PAGE_CHANNELS = [ 'browser:session:importFromBrowser', // Process-wide identity: reads/writes the host's own user-agent choice, never a viewed guest. 'browser:identity:get', - 'browser:identity:set' + 'browser:identity:set', + // Remembered site answers for a browser profile, never a viewed guest. + 'browser:sitePermissions:list', + 'browser:sitePermissions:set' ] type Handler = (event: { sender: Electron.WebContents }, args: unknown) => unknown diff --git a/src/main/ipc/browser-session-profile-ipc.ts b/src/main/ipc/browser-session-profile-ipc.ts index 0d05a079920..7abfbc9fd59 100644 --- a/src/main/ipc/browser-session-profile-ipc.ts +++ b/src/main/ipc/browser-session-profile-ipc.ts @@ -20,6 +20,15 @@ import { getBrowserIdentityModeStatus, setBrowserIdentityMode } from '../browser/browser-identity-mode-store' +import { + listBrowserSitePermissions, + setBrowserSitePermissionDecisions +} from '../browser/browser-site-permission-store' +import { + isBrowserSitePermission, + isBrowserSitePermissionDecision, + type BrowserSitePermissionEntry +} from '../../shared/browser-site-permission' export function registerBrowserSessionProfileHandlers(): void { ipcMain.removeHandler('browser:session:listProfiles') @@ -29,6 +38,8 @@ export function registerBrowserSessionProfileHandlers(): void { ipcMain.removeHandler('browser:identity:set') ipcMain.removeHandler('browser:session:importCookies') ipcMain.removeHandler('browser:session:resolvePartition') + ipcMain.removeHandler('browser:sitePermissions:list') + ipcMain.removeHandler('browser:sitePermissions:set') ipcMain.handle('browser:session:listProfiles', (event): BrowserSessionProfile[] => { if (!isTrustedBrowserRenderer(event.sender)) { @@ -120,6 +131,47 @@ export function registerBrowserSessionProfileHandlers(): void { } ) + ipcMain.handle( + 'browser:sitePermissions:list', + (event, args: { profileId: string }): BrowserSitePermissionEntry[] => { + if ( + !isTrustedBrowserRenderer(event.sender) || + !browserSessionRegistry.getProfile(args.profileId) + ) { + return [] + } + return listBrowserSitePermissions(args.profileId) + } + ) + + ipcMain.handle( + 'browser:sitePermissions:set', + ( + event, + args: { profileId: string; origin: string; permission: unknown; decision: unknown } + ): BrowserSitePermissionEntry[] => { + if ( + !isTrustedBrowserRenderer(event.sender) || + !browserSessionRegistry.getProfile(args.profileId) + ) { + return [] + } + if ( + !isBrowserSitePermission(args.permission) || + (args.decision !== null && !isBrowserSitePermissionDecision(args.decision)) + ) { + throw new Error('Unsupported site permission change') + } + setBrowserSitePermissionDecisions( + args.profileId, + args.origin, + [args.permission], + args.decision + ) + return listBrowserSitePermissions(args.profileId) + } + ) + ipcMain.removeHandler('browser:session:clearDefaultCookies') ipcMain.handle('browser:session:clearDefaultCookies', async (event): Promise => { diff --git a/src/preload/api/browser-api.ts b/src/preload/api/browser-api.ts index e040388fa82..70664a8f30d 100644 --- a/src/preload/api/browser-api.ts +++ b/src/preload/api/browser-api.ts @@ -17,6 +17,11 @@ import type { BrowserPagePromptResponse, BrowserPagePromptResult } from '../../shared/browser-page-prompt' +import type { + BrowserSitePermission, + BrowserSitePermissionDecision, + BrowserSitePermissionEntry +} from '../../shared/browser-site-permission' import type { BrowserSetGrabModeArgs, BrowserSetGrabModeResult, @@ -193,6 +198,15 @@ export type BrowserApi = { environmentId: string }) => Promise> sessionClearDefaultCookies: () => Promise + /** Remembered Allow and Block answers for one browser profile on this computer, by site. */ + sitePermissionsList: (args: { profileId: string }) => Promise + /** Null forgets the answer so the site is asked again; resolves the profile's updated list. */ + sitePermissionsSet: (args: { + profileId: string + origin: string + permission: BrowserSitePermission + decision: BrowserSitePermissionDecision | null + }) => Promise notifyActiveTabChanged: (args: { browserPageId: string }) => Promise } diff --git a/src/preload/api/browser-bridge-page-interaction-and-sessions.ts b/src/preload/api/browser-bridge-page-interaction-and-sessions.ts index bbfd15d1b6d..aae864be6af 100644 --- a/src/preload/api/browser-bridge-page-interaction-and-sessions.ts +++ b/src/preload/api/browser-bridge-page-interaction-and-sessions.ts @@ -2,6 +2,10 @@ import { ipcRenderer } from 'electron' import type { PreloadApi } from '../api-types' import type { BrowserUserAgentMode } from '../../shared/browser-user-agent-mode' import type { GrabIntent } from '../../shared/browser-grab-types' +import type { + BrowserSitePermission, + BrowserSitePermissionDecision +} from '../../shared/browser-site-permission' export const browserPageInteractionAndSessionsApi = { onContextMenuRequested: ( @@ -173,6 +177,14 @@ export const browserPageInteractionAndSessionsApi = { ipcRenderer.invoke('browser:session:clientRouteImportSources', args), sessionClearDefaultCookies: (): Promise => ipcRenderer.invoke('browser:session:clearDefaultCookies'), + sitePermissionsList: (args: { profileId: string }) => + ipcRenderer.invoke('browser:sitePermissions:list', args), + sitePermissionsSet: (args: { + profileId: string + origin: string + permission: BrowserSitePermission + decision: BrowserSitePermissionDecision | null + }) => ipcRenderer.invoke('browser:sitePermissions:set', args), notifyActiveTabChanged: (args: { browserPageId: string }): Promise => ipcRenderer.invoke('browser:activeTabChanged', args) } satisfies Partial diff --git a/src/renderer/src/components/browser-pane/browser-page-prompts.tsx b/src/renderer/src/components/browser-pane/browser-page-prompts.tsx index 6f1862002eb..f5be88c84e9 100644 --- a/src/renderer/src/components/browser-pane/browser-page-prompts.tsx +++ b/src/renderer/src/components/browser-pane/browser-page-prompts.tsx @@ -11,10 +11,11 @@ import { } from '@/components/confirmation-dialog-context' import { externalAppPrompt } from './browser-external-app-prompt' import { httpAuthPrompt } from './browser-http-auth-prompt' +import { sitePermissionPrompt } from './browser-site-permission-prompt' /** How the window asks one kind of page prompt. */ export type BrowserPagePromptHandler = { - /** Resolves the user's yes answer; null when they decline or `signal` aborts. */ + /** Resolves the user's answer; null when they decline or dismiss it, or `signal` aborts. */ ask: ( confirm: ConfirmationDialogContextValue, details: BrowserPagePromptDetails, @@ -26,7 +27,8 @@ export type BrowserPagePromptHandler = { const PROMPT_HANDLERS: { [K in BrowserPagePromptKind]: BrowserPagePromptHandler } = { 'external-app': externalAppPrompt, - 'http-auth': httpAuthPrompt + 'http-auth': httpAuthPrompt, + 'site-permission': sitePermissionPrompt } async function answerPrompt( diff --git a/src/renderer/src/components/browser-pane/browser-site-permission-copy.ts b/src/renderer/src/components/browser-pane/browser-site-permission-copy.ts new file mode 100644 index 00000000000..bee1bfe4cbb --- /dev/null +++ b/src/renderer/src/components/browser-pane/browser-site-permission-copy.ts @@ -0,0 +1,87 @@ +import { + BROWSER_SITE_PERMISSION_LOCAL_FILES_ORIGIN, + type BrowserSitePermission +} from '../../../../shared/browser-site-permission' +import { translate } from '@/i18n/i18n' + +/** The setting's name, e.g. in Settings → Browser → Site permissions. */ +export function browserSitePermissionLabel(permission: BrowserSitePermission): string { + switch (permission) { + case 'camera': + return translate('auto.components.browserPane.sitePermission.label.camera', 'Camera') + case 'microphone': + return translate('auto.components.browserPane.sitePermission.label.microphone', 'Microphone') + case 'location': + return translate('auto.components.browserPane.sitePermission.label.location', 'Location') + case 'notifications': + return translate( + 'auto.components.browserPane.sitePermission.label.notifications', + 'Notifications' + ) + case 'clipboard': + return translate('auto.components.browserPane.sitePermission.label.clipboard', 'Clipboard') + case 'midi': + return translate('auto.components.browserPane.sitePermission.label.midi', 'MIDI devices') + case 'window-management': + return translate( + 'auto.components.browserPane.sitePermission.label.windowManagement', + 'Window management' + ) + case 'idle-detection': + return translate( + 'auto.components.browserPane.sitePermission.label.idleDetection', + 'Device use' + ) + } +} + +/** What the site asks to do, finishing "{site} wants to". */ +export function browserSitePermissionRequestLine(permission: BrowserSitePermission): string { + switch (permission) { + case 'camera': + return translate('auto.components.browserPane.sitePermission.ask.camera', 'Use your camera') + case 'microphone': + return translate( + 'auto.components.browserPane.sitePermission.ask.microphone', + 'Use your microphone' + ) + case 'location': + return translate( + 'auto.components.browserPane.sitePermission.ask.location', + 'Know your location' + ) + case 'notifications': + return translate( + 'auto.components.browserPane.sitePermission.ask.notifications', + 'Show notifications' + ) + case 'clipboard': + return translate( + 'auto.components.browserPane.sitePermission.ask.clipboard', + 'See text and images copied to the clipboard' + ) + case 'midi': + return translate( + 'auto.components.browserPane.sitePermission.ask.midi', + 'Use your MIDI devices' + ) + case 'window-management': + return translate( + 'auto.components.browserPane.sitePermission.ask.windowManagement', + 'Manage windows on all your displays' + ) + case 'idle-detection': + return translate( + 'auto.components.browserPane.sitePermission.ask.idleDetection', + 'Know when you are actively using this device' + ) + } +} + +/** The origin as people read it: the host for https, the full origin otherwise. */ +export function browserSitePermissionSiteName(origin: string): string { + if (origin === BROWSER_SITE_PERMISSION_LOCAL_FILES_ORIGIN) { + return translate('auto.components.browserPane.sitePermission.localFiles', 'Local files') + } + return origin.startsWith('https://') ? origin.slice('https://'.length) : origin +} diff --git a/src/renderer/src/components/browser-pane/browser-site-permission-prompt.test.ts b/src/renderer/src/components/browser-pane/browser-site-permission-prompt.test.ts new file mode 100644 index 00000000000..95623ad1340 --- /dev/null +++ b/src/renderer/src/components/browser-pane/browser-site-permission-prompt.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it, vi } from 'vitest' +import type { ConfirmationDialogOptions } from '@/components/confirmation-dialog-context' + +import { sitePermissionPrompt } from './browser-site-permission-prompt' + +const signal = new AbortController().signal +const details = { + origin: 'https://meet.example.com', + permissions: ['camera' as const, 'microphone' as const] +} + +describe('browser site permission prompt', () => { + it('names the site and lists what it wants', async () => { + const confirm = vi.fn(async (_options: ConfirmationDialogOptions) => true) + + await expect(sitePermissionPrompt.ask(confirm, details, signal)).resolves.toBe('allow') + expect(confirm).toHaveBeenCalledWith( + expect.objectContaining({ + title: 'meet.example.com wants to', + description: 'Use your camera\nUse your microphone', + confirmLabel: 'Allow', + cancelLabel: 'Block' + }) + ) + }) + + it('calls a local file "this file"', async () => { + const confirm = vi.fn(async (_options: ConfirmationDialogOptions) => true) + + await sitePermissionPrompt.ask(confirm, { origin: 'file://', permissions: ['camera'] }, signal) + expect(confirm.mock.calls[0][0].title).toBe('This file wants to') + }) + + // Stands in for the dialog: Block runs onCancelClicked before resolving false; Esc only resolves. + it.each([ + ['Block', true, 'block'], + ['a dismissal', false, null] + ])('answers %s with %s', async (_name, pressedBlock, expected) => { + const confirm = vi.fn(async (options: ConfirmationDialogOptions) => { + if (pressedBlock) { + options.onCancelClicked?.() + } + return false + }) + + await expect(sitePermissionPrompt.ask(confirm, details, signal)).resolves.toBe(expected) + }) +}) diff --git a/src/renderer/src/components/browser-pane/browser-site-permission-prompt.ts b/src/renderer/src/components/browser-pane/browser-site-permission-prompt.ts new file mode 100644 index 00000000000..f82841d2019 --- /dev/null +++ b/src/renderer/src/components/browser-pane/browser-site-permission-prompt.ts @@ -0,0 +1,35 @@ +import { BROWSER_SITE_PERMISSION_LOCAL_FILES_ORIGIN } from '../../../../shared/browser-site-permission' +import type { BrowserPagePromptHandler } from './browser-page-prompts' +import { + browserSitePermissionRequestLine, + browserSitePermissionSiteName +} from './browser-site-permission-copy' +import { translate } from '@/i18n/i18n' + +export const sitePermissionPrompt: BrowserPagePromptHandler<'site-permission'> = { + ask: async (confirm, { origin, permissions }, signal) => { + let blocked = false + const allowed = await confirm({ + signal, + title: + origin === BROWSER_SITE_PERMISSION_LOCAL_FILES_ORIGIN + ? translate('auto.components.browserPane.sitePermission.fileTitle', 'This file wants to') + : translate('auto.components.browserPane.sitePermission.title', '{{site}} wants to', { + site: browserSitePermissionSiteName(origin) + }), + description: permissions.map(browserSitePermissionRequestLine).join('\n'), + confirmLabel: translate('auto.components.browserPane.sitePermission.allow', 'Allow'), + cancelLabel: translate('auto.components.browserPane.sitePermission.block', 'Block'), + // Why: the click or Enter that made the page ask must not answer for the user. + ignoresOpeningGesture: true, + onCancelClicked: () => { + blocked = true + } + }) + if (allowed) { + return 'allow' + } + // Why: Block is remembered for the site; Esc only declines this time, as in Chrome. + return blocked ? 'block' : null + } +} diff --git a/src/renderer/src/components/confirmation-dialog-context.ts b/src/renderer/src/components/confirmation-dialog-context.ts index b49419e5ed8..85679f2ab53 100644 --- a/src/renderer/src/components/confirmation-dialog-context.ts +++ b/src/renderer/src/components/confirmation-dialog-context.ts @@ -23,10 +23,12 @@ export type ConfirmationDialogOptions = { /** The short close-prompt look: small title, text and buttons. */ compact?: boolean /** Opened by a click or Enter: an outside press, a repeated Enter or a double-click's second - * click can't settle it; Cancel and Esc still do. */ + * click can't settle it on either button; Esc and a fresh press still do. */ ignoresOpeningGesture?: boolean /** Renders a "Don't ask again" checkbox. `onConfirmed` runs only when the user confirms with it checked. */ dontAskAgain?: { label?: string; onConfirmed: () => void } + /** Runs when the cancel button settles the prompt, unlike Esc, an outside press or an abort. */ + onCancelClicked?: () => void /** Renders text inputs; Enter in one confirms. `onConfirmed` gets their values, only on confirm. */ fields?: { inputs: ConfirmationDialogField[] diff --git a/src/renderer/src/components/confirmation-dialog.test.tsx b/src/renderer/src/components/confirmation-dialog.test.tsx index e9ea8532447..2b1aa8e1037 100644 --- a/src/renderer/src/components/confirmation-dialog.test.tsx +++ b/src/renderer/src/components/confirmation-dialog.test.tsx @@ -1,7 +1,7 @@ // @vitest-environment happy-dom import '@testing-library/jest-dom/vitest' -import { act, cleanup, render, screen, waitFor } from '@testing-library/react' +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { afterEach, describe, expect, it, vi } from 'vitest' @@ -69,6 +69,49 @@ describe('ConfirmationDialogProvider', () => { await waitFor(() => expect(onSettled).toHaveBeenCalledWith(false)) }) + it('tells the cancel button apart from Escape', async () => { + const onCancelClicked = vi.fn() + const { onSettled } = renderDialog({ title: 'Use your camera?', onCancelClicked }) + + await userEvent.click(screen.getByRole('button', { name: 'ask' })) + await screen.findByText('Use your camera?') + await userEvent.keyboard('{Escape}') + await waitFor(() => expect(onSettled).toHaveBeenCalledWith(false)) + expect(onCancelClicked).not.toHaveBeenCalled() + + await userEvent.click(screen.getByRole('button', { name: 'ask' })) + await userEvent.click(await screen.findByRole('button', { name: 'Cancel' })) + await waitFor(() => expect(onSettled).toHaveBeenCalledTimes(2)) + expect(onCancelClicked).toHaveBeenCalledOnce() + }) + + it('lets a held Enter or a double-click press neither button of a guarded prompt', async () => { + const onCancelClicked = vi.fn() + const { onSettled } = renderDialog({ + title: 'Use your camera?', + ignoresOpeningGesture: true, + onCancelClicked + }) + + await userEvent.click(screen.getByRole('button', { name: 'ask' })) + const cancel = await screen.findByRole('button', { name: 'Cancel' }) + await waitFor(() => expect(cancel).toHaveFocus()) + // The Enter that made the page ask is still held: its repeats must not press either button. + for (const name of ['Cancel', 'Confirm']) { + const button = screen.getByRole('button', { name }) + expect(fireEvent.keyDown(button, { key: 'Enter', repeat: true })).toBe(false) + } + // A double-click's first click opened the prompt in the page; its second lands here. + fireEvent.click(cancel, { detail: 2 }) + fireEvent.click(screen.getByRole('button', { name: 'Confirm' }), { detail: 2 }) + expect(onSettled).not.toHaveBeenCalled() + expect(onCancelClicked).not.toHaveBeenCalled() + + await userEvent.click(cancel) + await waitFor(() => expect(onSettled).toHaveBeenCalledWith(false)) + expect(onCancelClicked).toHaveBeenCalledOnce() + }) + it('keeps the default cancel focus for callers that do not opt in', async () => { renderDialog({ title: 'Delete artifact?', confirmVariant: 'destructive' }) await userEvent.click(screen.getByRole('button', { name: 'ask' })) diff --git a/src/renderer/src/components/confirmation-dialog.tsx b/src/renderer/src/components/confirmation-dialog.tsx index ca27a4283fb..ea7291ba45a 100644 --- a/src/renderer/src/components/confirmation-dialog.tsx +++ b/src/renderer/src/components/confirmation-dialog.tsx @@ -53,6 +53,13 @@ export function ConfirmationDialogProvider({ const compact = displayedRequest?.options.compact === true const buttonSize = compact ? 'sm' : 'default' const guarded = displayedRequest?.options.ignoresOpeningGesture === true + // Why: the gesture that opened the prompt may still be repeating or clicking, and either answer can stick. + const ignoreRepeatedKey = (event: React.KeyboardEvent): void => { + if (guarded && event.repeat) { + event.preventDefault() + } + } + const isFreshClick = (event: React.MouseEvent): boolean => !guarded || event.detail <= 1 useEffect(() => { // Why: this provider's dialog is not represented by activeModal. Block @@ -216,7 +223,14 @@ export function ConfirmationDialogProvider({ type="button" variant={displayedRequest?.options.cancelVariant ?? 'outline'} size={buttonSize} - onClick={() => settleActiveRequest(false)} + onKeyDown={ignoreRepeatedKey} + onClick={(event) => { + if (!isFreshClick(event)) { + return + } + activeRequestRef.current?.options.onCancelClicked?.() + settleActiveRequest(false) + }} > {displayedRequest?.options.cancelLabel ?? translate('auto.components.confirmation.dialog.56f5c60e0c', 'Cancel')} @@ -226,14 +240,9 @@ export function ConfirmationDialogProvider({ type="button" variant={displayedRequest?.options.confirmVariant ?? 'default'} size={buttonSize} - // Why: the gesture that opened the prompt may still be repeating or clicking. - onKeyDown={(event) => { - if (guarded && event.repeat) { - event.preventDefault() - } - }} + onKeyDown={ignoreRepeatedKey} onClick={(event) => { - if (!guarded || event.detail <= 1) { + if (isFreshClick(event)) { settleActiveRequest(true) } }} diff --git a/src/renderer/src/components/settings/BrowserPane.search-catalog.test.tsx b/src/renderer/src/components/settings/BrowserPane.search-catalog.test.tsx index 20517cf7276..e3521441613 100644 --- a/src/renderer/src/components/settings/BrowserPane.search-catalog.test.tsx +++ b/src/renderer/src/components/settings/BrowserPane.search-catalog.test.tsx @@ -75,6 +75,9 @@ vi.mock('./BrowserTerminalLinkActionsSetting', () => ({ vi.mock('./BrowserLocalhostWorktreeLabelsSetting', () => ({ BrowserLocalhostWorktreeLabelsSetting: () => Localhost row })) +vi.mock('./BrowserSitePermissionsSetting', () => ({ + BrowserSitePermissionsSetting: () => Site permissions row +})) vi.mock('./BrowserClientHostedRemoteSetting', () => ({ BrowserClientHostedRemoteSetting: () => Remote row })) diff --git a/src/renderer/src/components/settings/BrowserPane.tsx b/src/renderer/src/components/settings/BrowserPane.tsx index 283cfb081b7..e2decbe49b4 100644 --- a/src/renderer/src/components/settings/BrowserPane.tsx +++ b/src/renderer/src/components/settings/BrowserPane.tsx @@ -17,6 +17,7 @@ import { BrowserLocalhostWorktreeLabelsSetting } from './BrowserLocalhostWorktre import { BrowserClientHostedRemoteSetting } from './BrowserClientHostedRemoteSetting' import { BrowserSshWorkspaceRoutingSetting } from './BrowserSshWorkspaceRoutingSetting' import { BrowserUserAgentSetting } from './BrowserUserAgentSetting' +import { BrowserSitePermissionsSetting } from './BrowserSitePermissionsSetting' import { SettingsSubsectionHeader } from './SettingsFormControls' import { BrowserSessionCookiesSection } from './BrowserSessionCookiesSection' import { BrowserNewProfileDialog } from './BrowserNewProfileDialog' @@ -115,6 +116,7 @@ export function BrowserPane({ const showClientHostedRemote = matchesSettingsSearch(searchQuery, [browserSearchEntries[8]]) const showSshWorkspaceRouting = matchesSettingsSearch(searchQuery, [browserSearchEntries[9]]) const showUserAgent = matchesSettingsSearch(searchQuery, [browserSearchEntries[10]]) + const showSitePermissions = matchesSettingsSearch(searchQuery, [browserSearchEntries[11]]) const showBrowserUse = matchesSettingsSearch(searchQuery, getBrowserUsePaneSearchEntries()) const isMac = isMacUserAgent() const linkRoutingDescription = getBrowserLinkRoutingDescription( @@ -248,6 +250,8 @@ export function BrowserPane({ {showUserAgent ? : null} + {showSitePermissions ? : null} + {showLinkRouting ? ( { + const profile = (id: string, label: string): BrowserSessionProfile => ({ + id, + label, + scope: id === 'default' ? 'default' : 'isolated', + partition: `persist:${id}`, + source: null + }) + return { + settingsSearchQuery: '', + fetchBrowserSessionProfiles: async () => {}, + browserSessionProfilesByHostId: { + local: [profile('default', 'Default'), profile('work', 'Work')] + } + } +}) + +vi.mock('../../store', () => ({ + useAppStore: (selector: (state: typeof storeState) => unknown) => selector(storeState) +})) + +// Why: Radix Select needs pointer/layout APIs happy-dom lacks; a native select keeps value testable. +vi.mock('../ui/select', () => ({ + Select: ({ + value, + onValueChange, + children + }: { + value: string + onValueChange: (value: string) => void + children: React.ReactNode + }) => ( + + ), + SelectTrigger: () => null, + SelectValue: () => null, + SelectContent: ({ children }: { children: React.ReactNode }) => <>{children}, + SelectItem: ({ value, children }: { value: string; children: React.ReactNode }) => ( + + ) +})) + +import { BrowserSitePermissionsSetting } from './BrowserSitePermissionsSetting' + +function deferred(): { promise: Promise; resolve: (value: T) => void } { + let resolve: (value: T) => void = () => {} + const promise = new Promise((settle) => { + resolve = settle + }) + return { promise, resolve } +} + +const permissionSelects = (): HTMLElement[] => + screen.queryAllByRole('combobox').filter((select) => within(select).queryByText('Ask again')) + +afterEach(cleanup) + +it("never shows or edits one profile's sites under another", async () => { + const workList = deferred() + const defaultWrite = deferred() + const sitePermissionsList = vi.fn(({ profileId }: { profileId: string }) => + profileId === 'work' + ? workList.promise + : Promise.resolve([{ origin: 'https://a.test', decisions: { location: 'block' } }]) + ) + const sitePermissionsSet = vi.fn(({ profileId }: { profileId: string }) => + profileId === 'default' ? defaultWrite.promise : Promise.resolve([]) + ) + Object.defineProperty(window, 'api', { + configurable: true, + value: { browser: { sitePermissionsList, sitePermissionsSet } } + }) + render() + + expect(await screen.findByText('a.test')).toBeInTheDocument() + fireEvent.change(permissionSelects()[0], { target: { value: 'allow' } }) + expect(sitePermissionsSet).toHaveBeenLastCalledWith( + expect.objectContaining({ profileId: 'default', permission: 'location', decision: 'allow' }) + ) + + // Switch to Work while its sites are still loading, then let Default's reply land late. + const profileSelect = screen + .getAllByRole('combobox') + .find((select) => within(select).queryByText('Work')) + fireEvent.change(profileSelect!, { target: { value: 'work' } }) + expect(screen.queryByText('a.test')).not.toBeInTheDocument() + expect(permissionSelects()).toEqual([]) + await act(async () => { + defaultWrite.resolve([{ origin: 'https://a.test', decisions: { location: 'allow' } }]) + }) + expect(screen.queryByText('a.test')).not.toBeInTheDocument() + + await act(async () => { + workList.resolve([{ origin: 'https://b.test', decisions: { camera: 'allow' } }]) + }) + expect(screen.getByText('b.test')).toBeInTheDocument() + fireEvent.change(permissionSelects()[0], { target: { value: 'block' } }) + expect(sitePermissionsSet).toHaveBeenLastCalledWith( + expect.objectContaining({ profileId: 'work', permission: 'camera', decision: 'block' }) + ) +}) diff --git a/src/renderer/src/components/settings/BrowserSitePermissionsSetting.tsx b/src/renderer/src/components/settings/BrowserSitePermissionsSetting.tsx new file mode 100644 index 00000000000..486e53ba432 --- /dev/null +++ b/src/renderer/src/components/settings/BrowserSitePermissionsSetting.tsx @@ -0,0 +1,178 @@ +import { useEffect, useState } from 'react' +import type { BrowserSessionProfile } from '../../../../shared/browser-workspace-types' +import { + BROWSER_SITE_PERMISSIONS, + isBrowserSitePermissionDecision, + type BrowserSitePermission, + type BrowserSitePermissionEntry +} from '../../../../shared/browser-site-permission' +import { Label } from '../ui/label' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select' +import { + browserSitePermissionLabel, + browserSitePermissionSiteName +} from '../browser-pane/browser-site-permission-copy' +import { SearchableSetting } from './SearchableSetting' +import { SettingsRow } from './SettingsFormControls' +import { getBrowserSitePermissionsSearchEntry } from './browser-site-permissions-search' +import { LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host' +import { useAppStore } from '../../store' +import { translate } from '@/i18n/i18n' + +function SitePermissionRow({ + entry, + permission, + onChange +}: { + entry: BrowserSitePermissionEntry + permission: BrowserSitePermission + onChange: (permission: BrowserSitePermission, value: string) => void +}): React.JSX.Element { + const label = browserSitePermissionLabel(permission) + return ( + onChange(permission, value)} + > + + + + + + {translate('settings.browser.sitePermissions.allow', 'Allow')} + + + {translate('settings.browser.sitePermissions.block', 'Block')} + + + {translate('settings.browser.sitePermissions.ask', 'Ask again')} + + + + } + /> + ) +} + +const NO_PROFILES: readonly BrowserSessionProfile[] = [] + +/** Remembered answers live on this computer, so only its own browser profiles are listed. */ +export function BrowserSitePermissionsSetting(): React.JSX.Element { + const profiles = + useAppStore((s) => s.browserSessionProfilesByHostId[LOCAL_EXECUTION_HOST_ID]) ?? NO_PROFILES + const fetchBrowserSessionProfiles = useAppStore((s) => s.fetchBrowserSessionProfiles) + const [selectedProfileId, setSelectedProfileId] = useState('default') + // Why: a deleted profile falls back to Default rather than listing nothing. + const profileId = profiles.some((profile) => profile.id === selectedProfileId) + ? selectedProfileId + : 'default' + // Why: tagged with their profile so rows from the previous profile are never shown or edited. + const [loaded, setLoaded] = useState<{ + profileId: string + entries: BrowserSitePermissionEntry[] + } | null>(null) + const entries = loaded?.profileId === profileId ? loaded.entries : null + const searchEntry = getBrowserSitePermissionsSearchEntry() + + // Why: startup loads only the host Browser settings shows, which may be a server. + useEffect(() => { + void fetchBrowserSessionProfiles(LOCAL_EXECUTION_HOST_ID) + }, [fetchBrowserSessionProfiles]) + + useEffect(() => { + let disposed = false + void window.api.browser + .sitePermissionsList({ profileId }) + .then((nextEntries) => { + if (!disposed) { + setLoaded({ profileId, entries: nextEntries }) + } + }) + .catch(() => {}) + return () => { + disposed = true + } + }, [profileId]) + + const changeDecision = (origin: string, permission: BrowserSitePermission, value: string) => { + const writtenProfileId = profileId + void window.api.browser + .sitePermissionsSet({ + profileId: writtenProfileId, + origin, + permission, + decision: isBrowserSitePermissionDecision(value) ? value : null + }) + .then((nextEntries) => + // Why: a reply that lands after switching profiles must not replace the new profile's rows. + setLoaded((current) => + current?.profileId === writtenProfileId + ? { profileId: writtenProfileId, entries: nextEntries } + : current + ) + ) + .catch(() => {}) + } + + return ( + +
+
+ +

{searchEntry.description}

+
+ {profiles.length > 1 ? ( + + ) : null} +
+ + {entries?.length === 0 ? ( +

+ {translate( + 'settings.browser.sitePermissions.empty', + 'No sites have been allowed or blocked yet.' + )} +

+ ) : null} + {entries && entries.length > 0 ? ( +
+ {entries.map((entry) => ( +
+

+ {browserSitePermissionSiteName(entry.origin)} +

+ {BROWSER_SITE_PERMISSIONS.filter((permission) => entry.decisions[permission]).map( + (permission) => ( + changeDecision(entry.origin, changed, value)} + /> + ) + )} +
+ ))} +
+ ) : null} +
+ ) +} diff --git a/src/renderer/src/components/settings/browser-search.test.ts b/src/renderer/src/components/settings/browser-search.test.ts index 07321b120b9..b26a59d1219 100644 --- a/src/renderer/src/components/settings/browser-search.test.ts +++ b/src/renderer/src/components/settings/browser-search.test.ts @@ -106,7 +106,8 @@ describe('browser link routing modifier copy', () => { 'Session & Cookies', 'Remote server workspaces', 'SSH workspaces', - 'Browser identity' + 'Browser identity', + 'Site permissions' ]) }) diff --git a/src/renderer/src/components/settings/browser-search.ts b/src/renderer/src/components/settings/browser-search.ts index 71114696b77..24e2c5faa31 100644 --- a/src/renderer/src/components/settings/browser-search.ts +++ b/src/renderer/src/components/settings/browser-search.ts @@ -2,6 +2,7 @@ import type { SettingsSearchEntry } from './settings-search' import { translate } from '@/i18n/i18n' import { translateSearchKeyword } from './settings-search-keywords' import { getBrowserUserAgentSearchEntry } from './browser-user-agent-search' +import { getBrowserSitePermissionsSearchEntry } from './browser-site-permissions-search' import { getBrowserLinkRoutingDescription, getLinkRoutingModifierDescription, @@ -296,6 +297,7 @@ export function getBrowserPaneSearchEntries( ) ] }, - getBrowserUserAgentSearchEntry() + getBrowserUserAgentSearchEntry(), + getBrowserSitePermissionsSearchEntry() ] } diff --git a/src/renderer/src/components/settings/browser-site-permissions-search.ts b/src/renderer/src/components/settings/browser-site-permissions-search.ts new file mode 100644 index 00000000000..3b7ae9c426c --- /dev/null +++ b/src/renderer/src/components/settings/browser-site-permissions-search.ts @@ -0,0 +1,29 @@ +import type { SettingsSearchEntry } from './settings-search' +import { translate } from '@/i18n/i18n' +import { translateSearchKeyword } from './settings-search-keywords' +import { createLocalizedCatalog } from '@/i18n/localized-catalog' + +export const getBrowserSitePermissionsSearchEntry = createLocalizedCatalog( + (): SettingsSearchEntry => ({ + title: translate('settings.browser.sitePermissions.title', 'Site permissions'), + description: translate( + 'settings.browser.sitePermissions.description', + 'Sites ask before using your camera, microphone, location and more. Change what you allowed or blocked for each site.' + ), + keywords: [ + ...translateSearchKeyword('auto.components.settings.browser.search.2d2d995c58', 'browser'), + ...translateSearchKeyword( + 'settings.browser.sitePermissions.search.permissions', + 'permissions' + ), + ...translateSearchKeyword('settings.browser.sitePermissions.search.camera', 'camera'), + ...translateSearchKeyword('settings.browser.sitePermissions.search.microphone', 'microphone'), + ...translateSearchKeyword('settings.browser.sitePermissions.search.location', 'location'), + ...translateSearchKeyword( + 'settings.browser.sitePermissions.search.notifications', + 'notifications' + ), + ...translateSearchKeyword('settings.browser.sitePermissions.search.clipboard', 'clipboard') + ] + }) +) diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 35cacc80249..4ac2fbf3988 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -366,6 +366,23 @@ "optionNativeTooltip": "Keeps Electron's built-in identity for sites that reject the cleaned identity. Google sign-in is unavailable in Native mode.", "restartRequired": "Restart required", "unavailable": "Browser identity is unavailable." + }, + "sitePermissions": { + "allow": "Allow", + "block": "Block", + "ask": "Ask again", + "profile": "Browser profile", + "empty": "No sites have been allowed or blocked yet.", + "title": "Site permissions", + "description": "Sites ask before using your camera, microphone, location and more. Change what you allowed or blocked for each site.", + "search": { + "permissions": "permissions", + "camera": "camera", + "microphone": "microphone", + "location": "location", + "notifications": "notifications", + "clipboard": "clipboard" + } } }, "agents": { @@ -18386,6 +18403,33 @@ "password": "Password", "confirm": "Sign in", "cancel": "Cancel" + }, + "sitePermission": { + "label": { + "camera": "Camera", + "microphone": "Microphone", + "location": "Location", + "notifications": "Notifications", + "clipboard": "Clipboard", + "midi": "MIDI devices", + "windowManagement": "Window management", + "idleDetection": "Device use" + }, + "ask": { + "camera": "Use your camera", + "microphone": "Use your microphone", + "location": "Know your location", + "notifications": "Show notifications", + "clipboard": "See text and images copied to the clipboard", + "midi": "Use your MIDI devices", + "windowManagement": "Manage windows on all your displays", + "idleDetection": "Know when you are actively using this device" + }, + "title": "{{site}} wants to", + "allow": "Allow", + "block": "Block", + "localFiles": "Local files", + "fileTitle": "This file wants to" } }, "HostedReviewUnlinkMenuItem": { diff --git a/src/renderer/src/store/slices/browser-session-profiles.test.ts b/src/renderer/src/store/slices/browser-session-profiles.test.ts index b238d61c7a7..cb126274c1c 100644 --- a/src/renderer/src/store/slices/browser-session-profiles.test.ts +++ b/src/renderer/src/store/slices/browser-session-profiles.test.ts @@ -114,6 +114,21 @@ describe('createBrowserSlice runtime guard', () => { }) }) + it("loads this computer's profiles on request while a server is selected", async () => { + const store = createTestStore() + mockApi.browser.sessionListProfiles.mockResolvedValueOnce([ + { id: 'work', scope: 'isolated', partition: 'persist:work', label: 'Work', source: null } + ]) + store.setState({ settings: settingsWithRuntime('env-1'), browserSessionProfiles: [] }) + + await store.getState().fetchBrowserSessionProfiles('local') + + expect(runtimeEnvironmentCall).not.toHaveBeenCalled() + expect(store.getState().browserSessionProfilesByHostId.local?.[0]?.id).toBe('work') + // The server's list, which Browser settings is showing, stays as it was. + expect(store.getState().browserSessionProfiles).toEqual([]) + }) + it('keeps browser profile lists separate per host', async () => { const store = createTestStore() runtimeEnvironmentCall.mockResolvedValueOnce({ diff --git a/src/renderer/src/store/slices/browser/browser-profile-list-actions.ts b/src/renderer/src/store/slices/browser/browser-profile-list-actions.ts index bb5e84e9c6c..b1b5f4de9b0 100644 --- a/src/renderer/src/store/slices/browser/browser-profile-list-actions.ts +++ b/src/renderer/src/store/slices/browser/browser-profile-list-actions.ts @@ -5,6 +5,7 @@ import type { BrowserProfileDeleteResult, BrowserProfileListResult } from '../../../../../shared/runtime-types' +import { parseExecutionHostId } from '../../../../../shared/execution-host' import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' import { getBrowserProfilesForHost, @@ -22,9 +23,11 @@ export function createBrowserProfileListActions( 'fetchBrowserSessionProfiles' | 'createBrowserSessionProfile' | 'deleteBrowserSessionProfile' > { return { - fetchBrowserSessionProfiles: async () => { - const hostId = getBrowserSettingsHostId(get()) - const runtimeEnvironmentId = getBrowserSettingsRuntimeEnvironmentId(get()) + fetchBrowserSessionProfiles: async (requestedHostId) => { + const hostId = requestedHostId ?? getBrowserSettingsHostId(get()) + const parsedHostId = parseExecutionHostId(hostId) + const runtimeEnvironmentId = + parsedHostId?.kind === 'runtime' ? parsedHostId.environmentId : null if (runtimeEnvironmentId) { try { const result = await callRuntimeRpc( diff --git a/src/renderer/src/store/slices/browser/browser-slice-contract.ts b/src/renderer/src/store/slices/browser/browser-slice-contract.ts index 0fac09ff074..2d9fdd97ad4 100644 --- a/src/renderer/src/store/slices/browser/browser-slice-contract.ts +++ b/src/renderer/src/store/slices/browser/browser-slice-contract.ts @@ -232,7 +232,8 @@ export type BrowserSlice = { summary: BrowserCookieImportSummary | null error: string | null } | null - fetchBrowserSessionProfiles: () => Promise + /** Loads one host's profiles; defaults to the host Browser settings is showing. */ + fetchBrowserSessionProfiles: (hostId?: ExecutionHostId) => Promise createBrowserSessionProfile: ( scope: 'isolated' | 'imported', label: string diff --git a/src/renderer/src/web/preload-api/web-browser-api.ts b/src/renderer/src/web/preload-api/web-browser-api.ts index 01b9c70cb19..96fa2e39bfb 100644 --- a/src/renderer/src/web/preload-api/web-browser-api.ts +++ b/src/renderer/src/web/preload-api/web-browser-api.ts @@ -94,6 +94,8 @@ export function createBrowserApi(): NonNullable['browser']> ) }), sessionClearDefaultCookies: () => Promise.resolve(false), + sitePermissionsList: () => Promise.resolve([]), + sitePermissionsSet: () => Promise.resolve([]), notifyActiveTabChanged: () => Promise.resolve(false) } as unknown as NonNullable['browser']> } diff --git a/src/shared/browser-page-prompt.ts b/src/shared/browser-page-prompt.ts index 50a44ae1e8f..969157ee2ed 100644 --- a/src/shared/browser-page-prompt.ts +++ b/src/shared/browser-page-prompt.ts @@ -1,10 +1,15 @@ import type { BrowserExternalAppPrompt } from './browser-external-app' import type { BrowserHttpAuthCredentials, BrowserHttpAuthPrompt } from './browser-http-auth' +import type { + BrowserSitePermissionDecision, + BrowserSitePermissionPrompt +} from './browser-site-permission' -/** Every prompt a page in the built-in browser can raise: what it shows, and a yes answer. */ +/** Every prompt a page in the built-in browser can raise: what it shows, and an answer. */ export type BrowserPagePromptKinds = { 'external-app': { details: BrowserExternalAppPrompt; answer: true } 'http-auth': { details: BrowserHttpAuthPrompt; answer: BrowserHttpAuthCredentials } + 'site-permission': { details: BrowserSitePermissionPrompt; answer: BrowserSitePermissionDecision } } export type BrowserPagePromptKind = keyof BrowserPagePromptKinds @@ -21,7 +26,7 @@ export type BrowserPagePromptRequest> +} + +export function isBrowserSitePermission(value: unknown): value is BrowserSitePermission { + return BROWSER_SITE_PERMISSIONS.some((permission) => permission === value) +} + +export function isBrowserSitePermissionDecision( + value: unknown +): value is BrowserSitePermissionDecision { + return value === 'allow' || value === 'block' +}