feat(browser): ask before sites use camera, mic or location (#31158)

This commit is contained in:
Kelvin Amoaba
2026-10-21 14:45:15 +00:00
committed by GitHub
parent fd8b2dc3a7
commit 6970cad0d3
39 changed files with 1768 additions and 172 deletions
+1 -1
View File
@@ -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
@@ -12,6 +12,7 @@ type RegistryMock = ReturnType<typeof vi.fn>
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', () => ({
@@ -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)
},
@@ -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<Electron.PermissionRequest>
) => 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<string, boolean>()
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<Electron.PermissionRequest>
) => 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<Electron.PermissionCheckHandlerHandlerDetails>
) => 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<boolean> =>
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 () => {
@@ -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<void> {
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<boolean> => {
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)
@@ -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',
@@ -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<string, boolean>()
const request = (
permission: string,
details: Partial<Electron.PermissionRequest & Electron.MediaAccessPermissionRequest> = {}
): 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,
@@ -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 () => {
@@ -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]
@@ -8,14 +8,15 @@ import {
export function installBrowserRoutePartitionPolicies(
profile: BrowserSessionProfile,
partition: string
partition: string,
{ sitePermissions }: { sitePermissions?: 'saved' | 'session' } = {}
): Promise<void> {
if (!isBrowserRoutePartition(partition)) {
throw new Error('browser_route_partition_profile_unavailable')
}
return installBrowserSessionPartitionPolicies(
{ ...profile, partition },
{ applyAppWideProxy: false }
{ applyAppWideProxy: false, sitePermissions }
)
}
@@ -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
@@ -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<boolean> {
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<void> {
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,<p>hi</p>') })).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' } }
])
})
})
@@ -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<Record<string, BrowserSitePermission>> = {
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<Record<string, BrowserSitePermission>> = {
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<string, BrowserSitePermissionMemory>()
/**
* 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<string, BrowserSitePermissionDecision>()
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<boolean> {
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)
}
})
}
@@ -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<string, Record<string, SiteDecisions>>
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<string, SiteDecisions> = {}
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)
}
@@ -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
@@ -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<boolean> => {
+14
View File
@@ -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<Record<string, BrowserSessionProfileSource>>
sessionClearDefaultCookies: () => Promise<boolean>
/** Remembered Allow and Block answers for one browser profile on this computer, by site. */
sitePermissionsList: (args: { profileId: string }) => Promise<BrowserSitePermissionEntry[]>
/** 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<BrowserSitePermissionEntry[]>
notifyActiveTabChanged: (args: { browserPageId: string }) => Promise<boolean>
}
@@ -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<boolean> =>
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<boolean> =>
ipcRenderer.invoke('browser:activeTabChanged', args)
} satisfies Partial<PreloadApi['browser']>
@@ -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<K extends BrowserPagePromptKind> = {
/** 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<K>,
@@ -26,7 +27,8 @@ export type BrowserPagePromptHandler<K extends BrowserPagePromptKind> = {
const PROMPT_HANDLERS: { [K in BrowserPagePromptKind]: BrowserPagePromptHandler<K> } = {
'external-app': externalAppPrompt,
'http-auth': httpAuthPrompt
'http-auth': httpAuthPrompt,
'site-permission': sitePermissionPrompt
}
async function answerPrompt<K extends BrowserPagePromptKind>(
@@ -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
}
@@ -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)
})
})
@@ -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
}
}
@@ -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[]
@@ -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' }))
@@ -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)
}
}}
@@ -75,6 +75,9 @@ vi.mock('./BrowserTerminalLinkActionsSetting', () => ({
vi.mock('./BrowserLocalhostWorktreeLabelsSetting', () => ({
BrowserLocalhostWorktreeLabelsSetting: () => <span>Localhost row</span>
}))
vi.mock('./BrowserSitePermissionsSetting', () => ({
BrowserSitePermissionsSetting: () => <span>Site permissions row</span>
}))
vi.mock('./BrowserClientHostedRemoteSetting', () => ({
BrowserClientHostedRemoteSetting: () => <span>Remote row</span>
}))
@@ -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 ? <BrowserUserAgentSetting hostId={settingsFocusedHostId} /> : null}
{showSitePermissions ? <BrowserSitePermissionsSetting /> : null}
{showLinkRouting ? (
<BrowserLinkRoutingSetting
settings={settings}
@@ -0,0 +1,111 @@
// @vitest-environment happy-dom
import '@testing-library/jest-dom/vitest'
import { act, cleanup, fireEvent, render, screen, within } from '@testing-library/react'
import { afterEach, expect, it, vi } from 'vitest'
import type { BrowserSitePermissionEntry } from '../../../../shared/browser-site-permission'
import type { BrowserSessionProfile } from '../../../../shared/browser-workspace-types'
const storeState = vi.hoisted(() => {
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
}) => (
<select value={value} onChange={(event) => onValueChange(event.currentTarget.value)}>
{children}
</select>
),
SelectTrigger: () => null,
SelectValue: () => null,
SelectContent: ({ children }: { children: React.ReactNode }) => <>{children}</>,
SelectItem: ({ value, children }: { value: string; children: React.ReactNode }) => (
<option value={value}>{children}</option>
)
}))
import { BrowserSitePermissionsSetting } from './BrowserSitePermissionsSetting'
function deferred<T>(): { promise: Promise<T>; resolve: (value: T) => void } {
let resolve: (value: T) => void = () => {}
const promise = new Promise<T>((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<BrowserSitePermissionEntry[]>()
const defaultWrite = deferred<BrowserSitePermissionEntry[]>()
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(<BrowserSitePermissionsSetting />)
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' })
)
})
@@ -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 (
<SettingsRow
label={label}
control={
<Select
value={entry.decisions[permission]}
onValueChange={(value) => onChange(permission, value)}
>
<SelectTrigger size="sm" className="w-28" aria-label={label}>
<SelectValue />
</SelectTrigger>
<SelectContent align="end">
<SelectItem value="allow">
{translate('settings.browser.sitePermissions.allow', 'Allow')}
</SelectItem>
<SelectItem value="block">
{translate('settings.browser.sitePermissions.block', 'Block')}
</SelectItem>
<SelectItem value="ask">
{translate('settings.browser.sitePermissions.ask', 'Ask again')}
</SelectItem>
</SelectContent>
</Select>
}
/>
)
}
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 (
<SearchableSetting id="browser-site-permissions" {...searchEntry} className="space-y-3 py-2">
<div className="flex items-center justify-between gap-3">
<div className="space-y-0.5">
<Label>{searchEntry.title}</Label>
<p className="text-xs text-muted-foreground">{searchEntry.description}</p>
</div>
{profiles.length > 1 ? (
<Select value={profileId} onValueChange={setSelectedProfileId}>
<SelectTrigger
size="sm"
className="max-w-48 shrink-0"
aria-label={translate('settings.browser.sitePermissions.profile', 'Browser profile')}
>
<SelectValue />
</SelectTrigger>
<SelectContent align="end">
{profiles.map((profile) => (
<SelectItem key={profile.id} value={profile.id}>
{profile.label}
</SelectItem>
))}
</SelectContent>
</Select>
) : null}
</div>
{entries?.length === 0 ? (
<p className="text-xs text-muted-foreground">
{translate(
'settings.browser.sitePermissions.empty',
'No sites have been allowed or blocked yet.'
)}
</p>
) : null}
{entries && entries.length > 0 ? (
<div className="space-y-2">
{entries.map((entry) => (
<div key={entry.origin} className="rounded-md border border-border/70 px-3 py-1">
<p className="truncate pt-2 text-sm font-medium" title={entry.origin}>
{browserSitePermissionSiteName(entry.origin)}
</p>
{BROWSER_SITE_PERMISSIONS.filter((permission) => entry.decisions[permission]).map(
(permission) => (
<SitePermissionRow
key={permission}
entry={entry}
permission={permission}
onChange={(changed, value) => changeDecision(entry.origin, changed, value)}
/>
)
)}
</div>
))}
</div>
) : null}
</SearchableSetting>
)
}
@@ -106,7 +106,8 @@ describe('browser link routing modifier copy', () => {
'Session & Cookies',
'Remote server workspaces',
'SSH workspaces',
'Browser identity'
'Browser identity',
'Site permissions'
])
})
@@ -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()
]
}
@@ -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')
]
})
)
+44
View File
@@ -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": {
@@ -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({
@@ -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<BrowserProfileListResult>(
@@ -232,7 +232,8 @@ export type BrowserSlice = {
summary: BrowserCookieImportSummary | null
error: string | null
} | null
fetchBrowserSessionProfiles: () => Promise<void>
/** Loads one host's profiles; defaults to the host Browser settings is showing. */
fetchBrowserSessionProfiles: (hostId?: ExecutionHostId) => Promise<void>
createBrowserSessionProfile: (
scope: 'isolated' | 'imported',
label: string
@@ -94,6 +94,8 @@ export function createBrowserApi(): NonNullable<Partial<PreloadApi>['browser']>
)
}),
sessionClearDefaultCookies: () => Promise.resolve(false),
sitePermissionsList: () => Promise.resolve([]),
sitePermissionsSet: () => Promise.resolve([]),
notifyActiveTabChanged: () => Promise.resolve(false)
} as unknown as NonNullable<Partial<PreloadApi>['browser']>
}
+7 -2
View File
@@ -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<K extends BrowserPagePromptKind = BrowserPa
export type BrowserPagePromptResponse = {
requestId: string
/** Null: the user declined. */
/** Null: the user declined or dismissed it. */
answer: unknown
}
+42
View File
@@ -0,0 +1,42 @@
/** What a site in the built-in browser can ask for, in the order prompts and settings list them. */
export const BROWSER_SITE_PERMISSIONS = [
'camera',
'microphone',
'location',
'notifications',
'clipboard',
'midi',
'window-management',
'idle-detection'
] as const
export type BrowserSitePermission = (typeof BROWSER_SITE_PERMISSIONS)[number]
/** Every local file shares one site, as in Chrome; a file page's web origin is opaque. */
export const BROWSER_SITE_PERMISSION_LOCAL_FILES_ORIGIN = 'file://'
/** A remembered answer; a permission with none is asked again. */
export type BrowserSitePermissionDecision = 'allow' | 'block'
/** A site asks for one or more permissions at once, e.g. camera and microphone for a call. */
export type BrowserSitePermissionPrompt = {
/** Origin of the page in the tab, e.g. `https://meet.google.com`. */
origin: string
permissions: BrowserSitePermission[]
}
/** Every remembered answer for one site in one browser profile. */
export type BrowserSitePermissionEntry = {
origin: string
decisions: Partial<Record<BrowserSitePermission, BrowserSitePermissionDecision>>
}
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'
}