fix(browser): route in-app webcam/mic requests through macOS TCC (#1291)
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
caee6f91f8
commit
dcf46e367e
|
|
@ -0,0 +1,63 @@
|
|||
import { systemPreferences } from 'electron'
|
||||
import type { MediaAccessPermissionRequest } from 'electron'
|
||||
|
||||
// Why: macOS gates all camera/microphone access at the app-process level via
|
||||
// TCC. Electron's per-session permission handlers run inside that envelope:
|
||||
// if we call callback(true) but macOS has not granted the parent app, the
|
||||
// stream is still empty. Conversely, if we deny at the session handler, pages
|
||||
// never see the stream even when macOS has granted — which is the bug the user
|
||||
// hit inside the in-app browser (#1273 only fixed Settings → Permissions, not
|
||||
// the actual runtime getUserMedia() path).
|
||||
//
|
||||
// These helpers let both the main window session and the browser-tab sessions
|
||||
// consult the same macOS-aware logic, so once a user has granted Camera or
|
||||
// Microphone to Orca (via Settings → Permissions or directly in System
|
||||
// Settings), a page inside an in-app browser tab actually receives the stream.
|
||||
|
||||
export function requestedMediaTypes(
|
||||
details: MediaAccessPermissionRequest | undefined
|
||||
): Set<'audio' | 'video'> {
|
||||
return new Set(details?.mediaTypes ?? [])
|
||||
}
|
||||
|
||||
export function hasSystemMediaAccess(mediaType: string | undefined): boolean {
|
||||
if (process.platform !== 'darwin') {
|
||||
return true
|
||||
}
|
||||
if (mediaType === 'audio') {
|
||||
return systemPreferences.getMediaAccessStatus('microphone') === 'granted'
|
||||
}
|
||||
if (mediaType === 'video') {
|
||||
return systemPreferences.getMediaAccessStatus('camera') === 'granted'
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export async function requestSystemMediaAccess(
|
||||
details: MediaAccessPermissionRequest | undefined
|
||||
): Promise<boolean> {
|
||||
if (process.platform !== 'darwin') {
|
||||
return true
|
||||
}
|
||||
|
||||
const mediaTypes = requestedMediaTypes(details)
|
||||
if (mediaTypes.size === 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (mediaTypes.has('audio')) {
|
||||
// Why: macOS only shows the TCC prompt from the app process, so Chromium's
|
||||
// media grant is paired with the OS-level request at the actual media ask.
|
||||
const granted = await systemPreferences.askForMediaAccess('microphone')
|
||||
if (!granted) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if (mediaTypes.has('video')) {
|
||||
const granted = await systemPreferences.askForMediaAccess('camera')
|
||||
if (!granted) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
|
@ -1,12 +1,20 @@
|
|||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { sessionFromPartitionMock } = vi.hoisted(() => ({
|
||||
sessionFromPartitionMock: vi.fn()
|
||||
}))
|
||||
const { sessionFromPartitionMock, askForMediaAccessMock, getMediaAccessStatusMock } = vi.hoisted(
|
||||
() => ({
|
||||
sessionFromPartitionMock: vi.fn(),
|
||||
askForMediaAccessMock: vi.fn(),
|
||||
getMediaAccessStatusMock: vi.fn()
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
session: {
|
||||
fromPartition: sessionFromPartitionMock
|
||||
},
|
||||
systemPreferences: {
|
||||
askForMediaAccess: askForMediaAccessMock,
|
||||
getMediaAccessStatus: getMediaAccessStatusMock
|
||||
}
|
||||
}))
|
||||
|
||||
|
|
@ -24,6 +32,10 @@ import { ORCA_BROWSER_PARTITION } from '../../shared/constants'
|
|||
describe('BrowserSessionRegistry', () => {
|
||||
beforeEach(() => {
|
||||
sessionFromPartitionMock.mockReset()
|
||||
askForMediaAccessMock.mockReset()
|
||||
getMediaAccessStatusMock.mockReset()
|
||||
askForMediaAccessMock.mockResolvedValue(true)
|
||||
getMediaAccessStatusMock.mockReturnValue('granted')
|
||||
sessionFromPartitionMock.mockReturnValue({
|
||||
setPermissionRequestHandler: vi.fn(),
|
||||
setPermissionCheckHandler: vi.fn(),
|
||||
|
|
@ -147,6 +159,25 @@ describe('BrowserSessionRegistry', () => {
|
|||
expect(mockSession?.setPermissionCheckHandler).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
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.
|
||||
browserSessionRegistry.createProfile('isolated', 'Media Test')
|
||||
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))
|
||||
|
||||
expect(checkHandler(null, 'media', '', { mediaType: 'video' })).toBe(true)
|
||||
expect(checkHandler(null, 'notifications', '', {})).toBe(false)
|
||||
})
|
||||
|
||||
describe('setupClientHintsOverride', () => {
|
||||
it('overrides sec-ch-ua headers for Edge UA', () => {
|
||||
const onBeforeSendHeaders = vi.fn()
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import { join } from 'node:path'
|
|||
import { ORCA_BROWSER_PARTITION } from '../../shared/constants'
|
||||
import type { BrowserSessionProfile, BrowserSessionProfileScope } from '../../shared/types'
|
||||
import { browserManager } from './browser-manager'
|
||||
import { hasSystemMediaAccess, requestSystemMediaAccess } from './browser-media-access'
|
||||
import { cleanElectronUserAgent, setupClientHintsOverride } from './browser-session-ua'
|
||||
|
||||
type BrowserSessionMeta = {
|
||||
|
|
@ -358,7 +359,39 @@ class BrowserSessionRegistry {
|
|||
// clipboard commands to work. Without these, navigator.clipboard.writeText/readText
|
||||
// throws NotAllowedError even when invoked via CDP with userGesture:true.
|
||||
const autoGranted = new Set(['fullscreen', 'clipboard-read', 'clipboard-sanitized-write'])
|
||||
sess.setPermissionRequestHandler((webContents, permission, callback) => {
|
||||
sess.setPermissionRequestHandler((webContents, permission, callback, details) => {
|
||||
// Why: `media` (camera/mic) must defer to macOS TCC instead of being
|
||||
// denied outright. Denying at the session layer would make pages inside
|
||||
// isolated browser profiles throw NotAllowedError even after the user
|
||||
// granted Camera/Microphone to Orca — the same bug we fixed for the
|
||||
// default partition. macOS TCC still gates the actual stream, so
|
||||
// granting here only forwards what the OS has already authorized.
|
||||
if (permission === 'media') {
|
||||
void requestSystemMediaAccess(
|
||||
details as Electron.MediaAccessPermissionRequest | undefined
|
||||
).then(
|
||||
(granted) => {
|
||||
if (!granted) {
|
||||
browserManager.notifyPermissionDenied({
|
||||
guestWebContentsId: webContents.id,
|
||||
permission,
|
||||
rawUrl: webContents.getURL()
|
||||
})
|
||||
}
|
||||
callback(granted)
|
||||
},
|
||||
(error: unknown) => {
|
||||
console.error('[permissions] Browser media access failed:', error)
|
||||
browserManager.notifyPermissionDenied({
|
||||
guestWebContentsId: webContents.id,
|
||||
permission,
|
||||
rawUrl: webContents.getURL()
|
||||
})
|
||||
callback(false)
|
||||
}
|
||||
)
|
||||
return
|
||||
}
|
||||
const allowed = autoGranted.has(permission)
|
||||
if (!allowed) {
|
||||
browserManager.notifyPermissionDenied({
|
||||
|
|
@ -369,7 +402,10 @@ class BrowserSessionRegistry {
|
|||
}
|
||||
callback(allowed)
|
||||
})
|
||||
sess.setPermissionCheckHandler((_webContents, permission) => {
|
||||
sess.setPermissionCheckHandler((_webContents, permission, _origin, details) => {
|
||||
if (permission === 'media') {
|
||||
return hasSystemMediaAccess(details?.mediaType)
|
||||
}
|
||||
return autoGranted.has(permission)
|
||||
})
|
||||
sess.setDisplayMediaRequestHandler((_request, callback) => {
|
||||
|
|
|
|||
|
|
@ -196,7 +196,7 @@ describe('attachMainWindowServices', () => {
|
|||
}
|
||||
})
|
||||
|
||||
it('denies browser-session permissions, display capture, and downloads by default', () => {
|
||||
it('denies browser-session permissions, display capture, and downloads by default', async () => {
|
||||
const browserSessionOnMock = vi.fn()
|
||||
sessionFromPartitionMock.mockReturnValue({
|
||||
setPermissionRequestHandler: setPermissionRequestHandlerMock,
|
||||
|
|
@ -214,35 +214,39 @@ describe('attachMainWindowServices', () => {
|
|||
const browserPermissionHandler = setPermissionRequestHandlerMock.mock.calls[1][0] as (
|
||||
wc: unknown,
|
||||
permission: string,
|
||||
callback: (allowed: boolean) => void
|
||||
callback: (allowed: boolean) => void,
|
||||
details?: unknown
|
||||
) => void
|
||||
const permissionCallback = vi.fn()
|
||||
const guestWebContents = { id: 401, getURL: vi.fn(() => 'https://example.com/account') }
|
||||
browserPermissionHandler(guestWebContents, 'fullscreen', permissionCallback)
|
||||
browserPermissionHandler(guestWebContents, 'media', permissionCallback)
|
||||
|
||||
expect(permissionCallback.mock.calls).toEqual([[true], [false]])
|
||||
const cb = vi.fn()
|
||||
const guestWc = { id: 401, getURL: vi.fn(() => 'https://example.com/account') }
|
||||
browserPermissionHandler(guestWc, 'fullscreen', cb)
|
||||
browserPermissionHandler(guestWc, 'notifications', cb)
|
||||
// Why: `media` routes through macOS TCC instead of being denied outright,
|
||||
// so pages inside the in-app browser can use camera/mic once Orca has been
|
||||
// granted Camera/Microphone at the OS level.
|
||||
browserPermissionHandler(guestWc, 'media', cb, { mediaTypes: ['video'] })
|
||||
await vi.waitFor(() => expect(cb.mock.calls).toEqual([[true], [false], [true]]))
|
||||
expect(browserManagerNotifyPermissionDeniedMock).toHaveBeenCalledTimes(1)
|
||||
expect(browserManagerNotifyPermissionDeniedMock).toHaveBeenCalledWith({
|
||||
guestWebContentsId: 401,
|
||||
permission: 'media',
|
||||
permission: 'notifications',
|
||||
rawUrl: 'https://example.com/account'
|
||||
})
|
||||
|
||||
const browserPermissionCheckHandler = setPermissionCheckHandlerMock.mock.calls[1][0] as (
|
||||
const browserCheckHandler = setPermissionCheckHandlerMock.mock.calls[1][0] as (
|
||||
wc: unknown,
|
||||
permission: string
|
||||
permission: string,
|
||||
origin: string,
|
||||
details?: { mediaType?: 'video' | 'audio' | 'unknown' }
|
||||
) => boolean
|
||||
expect(browserPermissionCheckHandler(null, 'fullscreen')).toBe(true)
|
||||
expect(browserPermissionCheckHandler(null, 'notifications')).toBe(false)
|
||||
expect(browserCheckHandler(null, 'fullscreen', '')).toBe(true)
|
||||
expect(browserCheckHandler(null, 'notifications', '')).toBe(false)
|
||||
expect(browserCheckHandler(null, 'media', '', { mediaType: 'video' })).toBe(true)
|
||||
|
||||
const displayMediaHandler = setDisplayMediaRequestHandlerMock.mock.calls[0][0] as (
|
||||
request: unknown,
|
||||
callback: (streams: { video: null; audio: null }) => void
|
||||
) => void
|
||||
const displayCallback = vi.fn()
|
||||
displayMediaHandler(null, displayCallback)
|
||||
expect(displayCallback).toHaveBeenCalledWith({ video: undefined, audio: undefined })
|
||||
const displayMediaHandler = setDisplayMediaRequestHandlerMock.mock.calls[0][0]
|
||||
const displayCb = vi.fn()
|
||||
displayMediaHandler(null, displayCb)
|
||||
expect(displayCb).toHaveBeenCalledWith({ video: undefined, audio: undefined })
|
||||
|
||||
const willDownloadHandler = browserSessionOnMock.mock.calls.find(
|
||||
([eventName]) => eventName === 'will-download'
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import fs from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
|
||||
import { app, clipboard, ipcMain, nativeImage, session, systemPreferences } from 'electron'
|
||||
import type { BrowserWindow, MediaAccessPermissionRequest } from 'electron'
|
||||
import { app, clipboard, ipcMain, nativeImage, session } from 'electron'
|
||||
import type { BrowserWindow } from 'electron'
|
||||
import type { Store } from '../persistence'
|
||||
import type { CreateWorktreeResult } from '../../shared/types'
|
||||
import { ORCA_BROWSER_PARTITION } from '../../shared/constants'
|
||||
|
|
@ -11,6 +11,7 @@ import { registerWorktreeHandlers } from '../ipc/worktrees'
|
|||
import { registerPtyHandlers } from '../ipc/pty'
|
||||
import { registerSshHandlers } from '../ipc/ssh'
|
||||
import { browserManager } from '../browser/browser-manager'
|
||||
import { hasSystemMediaAccess, requestSystemMediaAccess } from '../browser/browser-media-access'
|
||||
import type { OrcaRuntimeService } from '../runtime/orca-runtime'
|
||||
import {
|
||||
checkForUpdatesFromMenu,
|
||||
|
|
@ -106,10 +107,42 @@ export function attachMainWindowServices(
|
|||
)
|
||||
|
||||
const browserSession = session.fromPartition(ORCA_BROWSER_PARTITION)
|
||||
browserSession.setPermissionRequestHandler((webContents, permission, callback) => {
|
||||
browserSession.setPermissionRequestHandler((webContents, permission, callback, details) => {
|
||||
// Why: the in-app browser is for dev previews and lightweight browsing, not
|
||||
// trusted desktop-app privileges. Denying by default keeps arbitrary sites
|
||||
// from silently escalating into camera/mic/notification prompts inside Orca.
|
||||
// Why `media` is allowed through: camera/mic are still gated by macOS TCC
|
||||
// at the app-process level, so granting here only *permits* Chromium to
|
||||
// use whatever the OS has already authorized for Orca. Denying at this
|
||||
// layer would make pages inside the in-app browser throw NotAllowedError
|
||||
// even after the user granted Camera/Microphone via Settings → Permissions
|
||||
// or System Settings — the bug #1273 partially addressed.
|
||||
if (permission === 'media') {
|
||||
void requestSystemMediaAccess(
|
||||
details as Electron.MediaAccessPermissionRequest | undefined
|
||||
).then(
|
||||
(granted) => {
|
||||
if (!granted) {
|
||||
browserManager.notifyPermissionDenied({
|
||||
guestWebContentsId: webContents.id,
|
||||
permission,
|
||||
rawUrl: webContents.getURL()
|
||||
})
|
||||
}
|
||||
callback(granted)
|
||||
},
|
||||
(error: unknown) => {
|
||||
console.error('[permissions] Browser media access failed:', error)
|
||||
browserManager.notifyPermissionDenied({
|
||||
guestWebContentsId: webContents.id,
|
||||
permission,
|
||||
rawUrl: webContents.getURL()
|
||||
})
|
||||
callback(false)
|
||||
}
|
||||
)
|
||||
return
|
||||
}
|
||||
const allowed = permission === 'fullscreen'
|
||||
if (!allowed) {
|
||||
browserManager.notifyPermissionDenied({
|
||||
|
|
@ -120,8 +153,14 @@ export function attachMainWindowServices(
|
|||
}
|
||||
callback(allowed)
|
||||
})
|
||||
browserSession.setPermissionCheckHandler((_webContents, permission) => {
|
||||
return permission === 'fullscreen'
|
||||
browserSession.setPermissionCheckHandler((_webContents, permission, _origin, details) => {
|
||||
if (permission === 'fullscreen') {
|
||||
return true
|
||||
}
|
||||
if (permission === 'media') {
|
||||
return hasSystemMediaAccess(details?.mediaType)
|
||||
}
|
||||
return false
|
||||
})
|
||||
browserSession.setDisplayMediaRequestHandler((_request, callback) => {
|
||||
// Why: arbitrary sites inside Orca should never be able to capture the
|
||||
|
|
@ -147,54 +186,6 @@ export function attachMainWindowServices(
|
|||
})
|
||||
}
|
||||
|
||||
function requestedMediaTypes(
|
||||
details: MediaAccessPermissionRequest | undefined
|
||||
): Set<'audio' | 'video'> {
|
||||
return new Set(details?.mediaTypes ?? [])
|
||||
}
|
||||
|
||||
function hasSystemMediaAccess(mediaType: string | undefined): boolean {
|
||||
if (process.platform !== 'darwin') {
|
||||
return true
|
||||
}
|
||||
if (mediaType === 'audio') {
|
||||
return systemPreferences.getMediaAccessStatus('microphone') === 'granted'
|
||||
}
|
||||
if (mediaType === 'video') {
|
||||
return systemPreferences.getMediaAccessStatus('camera') === 'granted'
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
async function requestSystemMediaAccess(
|
||||
details: MediaAccessPermissionRequest | undefined
|
||||
): Promise<boolean> {
|
||||
if (process.platform !== 'darwin') {
|
||||
return true
|
||||
}
|
||||
|
||||
const mediaTypes = requestedMediaTypes(details)
|
||||
if (mediaTypes.size === 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (mediaTypes.has('audio')) {
|
||||
// Why: macOS only shows the TCC prompt from the app process, so Chromium's
|
||||
// media grant is paired with the OS-level request at the actual media ask.
|
||||
const granted = await systemPreferences.askForMediaAccess('microphone')
|
||||
if (!granted) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if (mediaTypes.has('video')) {
|
||||
const granted = await systemPreferences.askForMediaAccess('camera')
|
||||
if (!granted) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function registerRuntimeWindowLifecycle(
|
||||
mainWindow: BrowserWindow,
|
||||
runtime: OrcaRuntimeService
|
||||
|
|
|
|||
Loading…
Reference in New Issue