mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
feat(browser): ask before sites use camera, mic or location (#31158)
This commit is contained in:
@@ -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> => {
|
||||
|
||||
@@ -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')
|
||||
]
|
||||
})
|
||||
)
|
||||
@@ -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']>
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
Reference in New Issue
Block a user