diff --git a/src/main/browser/browser-media-access.ts b/src/main/browser/browser-media-access.ts new file mode 100644 index 000000000..c1138d6ac --- /dev/null +++ b/src/main/browser/browser-media-access.ts @@ -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 { + 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 +} diff --git a/src/main/browser/browser-session-registry.test.ts b/src/main/browser/browser-session-registry.test.ts index 822ecd884..6a51a95c1 100644 --- a/src/main/browser/browser-session-registry.test.ts +++ b/src/main/browser/browser-session-registry.test.ts @@ -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() diff --git a/src/main/browser/browser-session-registry.ts b/src/main/browser/browser-session-registry.ts index 10272c1cb..cf6d3fffa 100644 --- a/src/main/browser/browser-session-registry.ts +++ b/src/main/browser/browser-session-registry.ts @@ -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) => { diff --git a/src/main/window/attach-main-window-services.test.ts b/src/main/window/attach-main-window-services.test.ts index a6b279e66..1a11ddd2f 100644 --- a/src/main/window/attach-main-window-services.test.ts +++ b/src/main/window/attach-main-window-services.test.ts @@ -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' diff --git a/src/main/window/attach-main-window-services.ts b/src/main/window/attach-main-window-services.ts index 99be52813..d45b4bec7 100644 --- a/src/main/window/attach-main-window-services.ts +++ b/src/main/window/attach-main-window-services.ts @@ -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 { - 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