diff --git a/build/entitlements.mac.plist b/build/entitlements.mac.plist index 9d534f4f4bf..7a294e849fb 100644 --- a/build/entitlements.mac.plist +++ b/build/entitlements.mac.plist @@ -2,6 +2,8 @@ + com.apple.security.device.audio-input + com.apple.security.cs.allow-jit com.apple.security.cs.allow-unsigned-executable-memory diff --git a/package.json b/package.json index d3afc918c8f..a0c0c44d77b 100644 --- a/package.json +++ b/package.json @@ -302,6 +302,9 @@ "gatekeeperAssess": false, "entitlements": "build/entitlements.mac.plist", "entitlementsInherit": "build/entitlements.mac.plist", + "extendInfo": { + "NSMicrophoneUsageDescription": "Xum uses your microphone for voice input when you allow access." + }, "notarize": true }, "linux": { diff --git a/src/browser/hooks/useVoiceInput.test.tsx b/src/browser/hooks/useVoiceInput.test.tsx index 1ce23a6be18..dbbd3edbda0 100644 --- a/src/browser/hooks/useVoiceInput.test.tsx +++ b/src/browser/hooks/useVoiceInput.test.tsx @@ -128,17 +128,19 @@ function renderVoiceInput(useRecordingKeybinds = false) { }) ); const onSend = mock(() => undefined); + const onError = mock((_message: string) => undefined); const hook = renderHook(() => useVoiceInput({ useRecordingKeybinds, onSend, + onError, api: { voice: { transcribe } } as unknown as APIClient, isTranscriptionAvailable: true, onTranscript: mock(() => undefined), }) ); - return { ...hook, transcribe, onSend }; + return { ...hook, transcribe, onSend, onError }; } describe("useVoiceInput", () => { @@ -278,6 +280,28 @@ describe("useVoiceInput", () => { await waitFor(() => expect(onSend).toHaveBeenCalledTimes(1)); }); + test.each(["NotAllowedError", "NotReadableError"])( + "returns to idle after %s and permits another recording attempt", + async (name) => { + const { result, transcribe, onError } = renderVoiceInput(); + getUserMedia.mockRejectedValueOnce(new DOMException("Microphone unavailable", name)); + act(() => result.current.start()); + await waitFor(() => expect(onError).toHaveBeenCalledTimes(1)); + expect(result.current.state).toBe("idle"); + expect(result.current.mediaRecorder).toBeNull(); + expect(transcribe).not.toHaveBeenCalled(); + + act(() => result.current.start()); + await waitFor(() => expect(result.current.state).toBe("recording")); + expect(getUserMedia).toHaveBeenCalledTimes(2); + expect(getUserMedia.mock.calls[1][0]).toEqual({ audio: true }); + act(() => result.current.cancel()); + await waitFor(() => expect(result.current.state).toBe("idle")); + expect(stopTrack).toHaveBeenCalled(); + expect(transcribe).not.toHaveBeenCalled(); + } + ); + test("does not transcribe a silent recording", async () => { const { result, transcribe } = renderVoiceInput(); diff --git a/src/desktop/main.ts b/src/desktop/main.ts index c5e44fe8336..491e558b996 100644 --- a/src/desktop/main.ts +++ b/src/desktop/main.ts @@ -44,6 +44,7 @@ if (process.platform === "darwin") { import { DesktopWindowManager } from "./desktopWindowManager"; import { RemoteConnectionManager } from "./remoteConnectionManager"; +import { createRemoteMicrophonePermission } from "./remoteMicrophonePermission"; import { REMOTE_CONNECTION_CHANNELS, REMOTE_CONNECTION_RETURN_ACCELERATOR, @@ -77,6 +78,7 @@ import { nativeTheme, screen, shell, + systemPreferences, } from "electron"; const getXumEnv = (suffix: string): string | undefined => @@ -434,6 +436,12 @@ function timestamp(): string { function initializeRemoteConnections(): void { const manager = new RemoteConnectionManager({ createWindow: (options) => new BrowserWindow(options), + requestMicrophoneAccess: createRemoteMicrophonePermission({ + platform: process.platform, + showMessageBox: (window, options) => dialog.showMessageBox(window, options), + getMediaAccessStatus: (mediaType) => systemPreferences.getMediaAccessStatus(mediaType), + askForMediaAccess: (mediaType) => systemPreferences.askForMediaAccess(mediaType), + }), onConnected: () => mainWindow?.hide(), onDisconnected: () => { if (!isQuitting) openXumFromTray(); diff --git a/src/desktop/remoteConnectionManager.test.ts b/src/desktop/remoteConnectionManager.test.ts index 0c5a1376836..e7a29b2a84e 100644 --- a/src/desktop/remoteConnectionManager.test.ts +++ b/src/desktop/remoteConnectionManager.test.ts @@ -13,22 +13,27 @@ class TestWindow extends EventEmitter { loading = Promise.resolve(); webContents = Object.assign(new EventEmitter(), { getURL: () => this.url, + isDestroyed: () => this.destroyed, paste: mock(() => undefined), executeJavaScriptInIsolatedWorld: mock< (worldId: number, scripts: Array<{ code: string }>) => Promise >(() => Promise.resolve(true)), session: { - setPermissionRequestHandler: - mock< - ( - handler: ( - contents: unknown, - permission: string, - callback: (allow: boolean) => void, - details: { isMainFrame: boolean; requestingUrl: string } - ) => void + setPermissionRequestHandler: mock< + ( + handler: ( + contents: unknown, + permission: string, + callback: (allow: boolean) => void, + details: { + isMainFrame: boolean; + requestingUrl: string; + mediaTypes?: readonly string[]; + securityOrigin?: string; + } ) => void - >(), + ) => void + >(), setPermissionCheckHandler: mock<(handler: () => boolean) => void>(), }, setWindowOpenHandler: mock< @@ -74,6 +79,9 @@ function setup(loading = Promise.resolve()) { const onDisconnected = mock(() => undefined); const onStateChanged = mock<(state: RemoteConnectionState) => void>(); const openExternal = mock<(url: string) => void>(); + const requestMicrophoneAccess = mock< + (window: BrowserWindow, serverUrl: string, signal: AbortSignal) => Promise + >(() => Promise.resolve(true)); const manager = new RemoteConnectionManager({ createWindow: (windowOptions) => { const window = new TestWindow(); @@ -87,6 +95,7 @@ function setup(loading = Promise.resolve()) { onDisconnected, onStateChanged, openExternal, + requestMicrophoneAccess, }); managers.push(manager); return { @@ -97,6 +106,7 @@ function setup(loading = Promise.resolve()) { onDisconnected, onStateChanged, openExternal, + requestMicrophoneAccess, setLoading: (promise: Promise) => { loading = promise; }, @@ -113,7 +123,197 @@ function deferred() { return { promise, resolve, reject }; } +function requestAudio( + host: TestWindow, + requester = host, + overrides: Partial<{ + isMainFrame: boolean; + requestingUrl: string; + mediaTypes: readonly string[]; + securityOrigin: string; + }> = {} +): Promise { + const request = host.webContents.session.setPermissionRequestHandler.mock.calls[0][0]; + return new Promise((resolve) => { + request(requester.webContents, "media", resolve, { + isMainFrame: true, + requestingUrl: requester.url, + mediaTypes: ["audio"], + securityOrigin: "https://example.com/", + ...overrides, + }); + }); +} + describe("RemoteConnectionManager", () => { + test("prompts for each audio request and allows retry after denial or OS failure", async () => { + const { manager, windows, requestMicrophoneAccess } = setup(); + await manager.connect("https://example.com/?token=private#secret"); + const window = windows[0]; + requestMicrophoneAccess.mockResolvedValueOnce(false); + expect(await requestAudio(window)).toBe(false); + requestMicrophoneAccess.mockRejectedValueOnce(new Error("OS permission unavailable")); + expect(await requestAudio(window)).toBe(false); + expect(await requestAudio(window)).toBe(true); + expect(await requestAudio(window)).toBe(true); + expect(requestMicrophoneAccess).toHaveBeenCalledTimes(4); + expect(requestMicrophoneAccess.mock.calls[0][0]).toBe(window as unknown as BrowserWindow); + expect(requestMicrophoneAccess.mock.calls[0][1]).toBe("https://example.com"); + expect(window.webContents.session.setPermissionCheckHandler.mock.calls[0][0]()).toBe(false); + expect(window.webContents.listenerCount("did-start-navigation")).toBe(0); + }); + + test.each([ + { mediaTypes: ["video"] }, + { mediaTypes: ["audio", "video"] }, + { mediaTypes: [] }, + { mediaTypes: undefined }, + { mediaTypes: ["unknown"] }, + { mediaTypes: ["audio", "audio"] }, + { isMainFrame: false }, + { securityOrigin: "https://other.example.com" }, + { securityOrigin: "null" }, + { securityOrigin: undefined }, + { requestingUrl: "https://other.example.com/" }, + { requestingUrl: "https://example.com/another-page" }, + ])("denies unsafe media details without prompting: %j", async (details) => { + const { manager, windows, requestMicrophoneAccess } = setup(); + await manager.connect("https://example.com/"); + expect(await requestAudio(windows[0], windows[0], details)).toBe(false); + expect(requestMicrophoneAccess).not.toHaveBeenCalled(); + }); + + test("limits microphone requests to focused app windows within the connected app base", async () => { + const { manager, windows, requestMicrophoneAccess } = setup(); + const base = "https://example.com/@user/workspace/apps/xum/"; + await manager.connect(base); + const window = windows[0]; + window.focused = false; + expect(await requestAudio(window)).toBe(false); + window.focused = true; + for (const url of [ + "https://example.com/login", + "https://example.com/@user/workspace/apps/other/", + "https://other.example.com/", + "blob:https://example.com/attachment", + "invalid", + ]) { + window.url = url; + expect(await requestAudio(window)).toBe(false); + } + window.url = base; + const unrelated = new TestWindow(); + unrelated.url = base; + expect(await requestAudio(window, unrelated)).toBe(false); + for (const url of ["about:blank", "blob:https://example.com/attachment"]) { + const popup = new TestWindow(); + window.webContents.emit("did-create-window", popup, { url }); + // Even an auth popup that returns to the app must not acquire microphone access. + popup.url = base; + expect(await requestAudio(window, popup)).toBe(false); + } + expect(requestMicrophoneAccess).not.toHaveBeenCalled(); + const app = new TestWindow(); + app.url = base + "terminal.html"; + window.webContents.emit("did-create-window", app, { url: app.url }); + expect(await requestAudio(window, app)).toBe(true); + expect(requestMicrophoneAccess.mock.calls[0][0]).toBe(app as unknown as BrowserWindow); + }); + + test("does not prompt before the connection completes", async () => { + const loading = deferred(); + const { manager, windows, requestMicrophoneAccess } = setup(loading.promise); + const connected = manager.connect("https://example.com/"); + expect(await requestAudio(windows[0])).toBe(false); + expect(requestMicrophoneAccess).not.toHaveBeenCalled(); + loading.resolve(); + await connected; + expect(await requestAudio(windows[0])).toBe(true); + }); + + test("rejects concurrent prompts without caching their denial", async () => { + const { manager, windows, requestMicrophoneAccess } = setup(); + await manager.connect("https://example.com/"); + const prompt = deferred(); + requestMicrophoneAccess.mockImplementationOnce(async () => { + await prompt.promise; + return true; + }); + const first = requestAudio(windows[0]); + expect(await requestAudio(windows[0])).toBe(false); + expect(requestMicrophoneAccess).toHaveBeenCalledTimes(1); + prompt.resolve(); + expect(await first).toBe(true); + expect(await requestAudio(windows[0])).toBe(true); + }); + + test.each([ + "reload", + "navigate", + "commit", + "crash", + "close", + "disconnect", + "reconnect", + "dispose", + ])("cancels a pending microphone request promptly on %s", async (action) => { + const { manager, windows, requestMicrophoneAccess } = setup(); + await manager.connect("https://example.com/"); + const host = windows[0]; + const popup = new TestWindow(); + popup.url = "https://example.com/terminal.html"; + host.webContents.emit("did-create-window", popup, { url: popup.url }); + const prompt = deferred(); + requestMicrophoneAccess.mockImplementationOnce(async () => { + await prompt.promise; + return true; + }); + const pending = requestAudio(host, popup); + const signal = requestMicrophoneAccess.mock.calls[0][2]; + if (action === "reload" || action === "navigate") { + popup.webContents.emit("did-start-navigation", { isMainFrame: true }); + if (action === "navigate") popup.url = "https://example.com/login"; + } else if (action === "commit") popup.webContents.emit("did-navigate"); + else if (action === "crash") popup.webContents.emit("render-process-gone"); + else if (action === "close") popup.close(); + else if (action === "dispose") manager.dispose(); + else { + manager.disconnect(); + if (action === "reconnect") await manager.connect("https://example.com/"); + } + expect(signal.aborted).toBe(true); + // The native OS prompt can outlive the request. Its result must not keep the callback pending. + expect(await pending).toBe(false); + prompt.resolve(); + await prompt.promise; + expect(popup.webContents.listenerCount("did-start-navigation")).toBe(0); + expect(popup.webContents.listenerCount("did-navigate")).toBe(0); + expect(popup.webContents.listenerCount("render-process-gone")).toBe(0); + if (action === "disconnect" || action === "reconnect" || action === "dispose") { + const handlers = host.webContents.session.setPermissionRequestHandler.mock.calls; + const result = new Promise((resolve) => { + handlers[handlers.length - 1][0](host.webContents, "media", resolve, { + isMainFrame: true, + requestingUrl: host.url, + mediaTypes: ["audio"], + }); + }); + expect(await result).toBe(false); + expect(await requestAudio(host)).toBe(false); + } + }); + + test.each(["focus", "url"])("rechecks the document after approval changes %s", async (change) => { + const { manager, windows, requestMicrophoneAccess } = setup(); + await manager.connect("https://example.com/"); + requestMicrophoneAccess.mockImplementationOnce(() => { + if (change === "focus") windows[0].focused = false; + else windows[0].url = "https://example.com/login"; + return Promise.resolve(true); + }); + expect(await requestAudio(windows[0])).toBe(change === "focus"); + }); + test("keeps the local window until load completes and shares duplicate connections", async () => { const load = deferred(); const { manager, windows, onConnected, onStateChanged } = setup(load.promise); diff --git a/src/desktop/remoteConnectionManager.ts b/src/desktop/remoteConnectionManager.ts index 89149bd5dfb..3d62d970102 100644 --- a/src/desktop/remoteConnectionManager.ts +++ b/src/desktop/remoteConnectionManager.ts @@ -1,4 +1,4 @@ -import type { BrowserWindow, BrowserWindowConstructorOptions, Event } from "electron"; +import type { BrowserWindow, BrowserWindowConstructorOptions, Event, Session } from "electron"; import { createHash } from "node:crypto"; import { getAppProxyBasePathFromPathname } from "@/common/appProxyBasePath"; import { @@ -18,11 +18,13 @@ type RemotePopupKind = "app" | "attachment" | "auth"; interface RemoteWindowEntry { window: BrowserWindow; + session: Session; serverUrl: string; abort: AbortController; loaded: Promise; authPopup: BrowserWindow | "opening" | null; popups: Map; + microphoneRequests: Set; } interface RemoteWindowOptions { @@ -31,6 +33,11 @@ interface RemoteWindowOptions { onDisconnected(): void; onStateChanged(state: RemoteConnectionState): void; openExternal(url: string): void; + requestMicrophoneAccess( + window: BrowserWindow, + serverUrl: string, + signal: AbortSignal + ): Promise; } const REMOTE_WEB_PREFERENCES = { @@ -114,11 +121,13 @@ export class RemoteConnectionManager { }); const entry: RemoteWindowEntry = { window, + session: window.webContents.session, serverUrl, abort: new AbortController(), loaded: Promise.resolve(), authPopup: null, popups: new Map(), + microphoneRequests: new Set(), }; this.entry = entry; this.setState({ status: "connecting", serverUrl }); @@ -130,6 +139,7 @@ export class RemoteConnectionManager { private guardWindow(entry: RemoteWindowEntry): void { const contents = entry.window.webContents; + // Do not cache grants. Each capture request needs consent, including requests after a denial. contents.session.setPermissionCheckHandler(() => false); contents.session.setPermissionRequestHandler((requester, permission, callback, details) => { const window = @@ -138,13 +148,29 @@ export class RemoteConnectionManager { : [...entry.popups].find( ([popup, kind]) => kind === "app" && popup.webContents === requester )?.[0]; - if (permission !== "clipboard-sanitized-write" || !window || !details.isMainFrame) { + if (!window || !details.isMainFrame) { callback(false); return; } - this.allowClipboardWrite(entry, window, details.requestingUrl).then(callback, () => - callback(false) - ); + if (permission === "clipboard-sanitized-write") { + this.allowClipboardWrite(entry, window, details.requestingUrl).then(callback, () => + callback(false) + ); + } else if ( + permission === "media" && + "mediaTypes" in details && + details.mediaTypes?.length === 1 && + details.mediaTypes[0] === "audio" + ) { + this.allowMicrophoneAccess( + entry, + window, + details.requestingUrl, + details.securityOrigin + ).then(callback, () => callback(false)); + } else { + callback(false); + } }); this.guardAppWindow(entry, entry.window); contents.on("render-process-gone", () => { @@ -263,6 +289,60 @@ export class RemoteConnectionManager { return activated === true && isActiveRequest(); } + private async allowMicrophoneAccess( + entry: RemoteWindowEntry, + window: BrowserWindow, + requestingUrl: string, + securityOrigin: string | undefined + ): Promise { + const contents = window.webContents; + const isCurrentRequest = (): boolean => + this.entry === entry && + this.state.status === "connected" && + (window === entry.window || entry.popups.get(window) === "app") && + !window.isDestroyed() && + !contents.isDestroyed() && + contents.getURL() === requestingUrl; + if ( + !isCurrentRequest() || + !window.isFocused() || + entry.microphoneRequests.has(window) || + !isRemoteAppUrl(entry.serverUrl, requestingUrl) || + securityOrigin == null || + new URL(securityOrigin).origin !== new URL(entry.serverUrl).origin + ) { + return false; + } + + // A URL can survive a reload. Cancel consent when the requesting document leaves instead. + const abort = new AbortController(); + const cancel = (): void => abort.abort(); + const onNavigation = (event: { isMainFrame: boolean }): void => { + if (event.isMainFrame) cancel(); + }; + const signal = AbortSignal.any([entry.abort.signal, abort.signal]); + entry.microphoneRequests.add(window); + contents.on("did-start-navigation", onNavigation); + // A request can arrive after navigation starts but before the new document commits. + contents.on("did-navigate", cancel); + contents.on("render-process-gone", cancel); + window.on("closed", cancel); + try { + // Chromium still enforces secure contexts. This grants only the remote page's audio request. + const result = await raceWithAbortAndTimeout( + this.options.requestMicrophoneAccess(window, entry.serverUrl, signal), + { signal } + ); + return result.kind === "ok" && result.value && !signal.aborted && isCurrentRequest(); + } finally { + contents.removeListener("did-start-navigation", onNavigation); + contents.removeListener("did-navigate", cancel); + contents.removeListener("render-process-gone", cancel); + window.removeListener("closed", cancel); + entry.microphoneRequests.delete(window); + } + } + private registerPopup( entry: RemoteWindowEntry, source: BrowserWindow, @@ -342,6 +422,11 @@ export class RemoteConnectionManager { if (this.entry !== entry) return; this.entry = null; entry.abort.abort(); + // Persistent sessions must stay closed after disconnect, without retaining the old entry. + entry.session.setPermissionCheckHandler(() => false); + entry.session.setPermissionRequestHandler((_requester, _permission, callback) => + callback(false) + ); for (const popup of entry.popups.keys()) { if (!popup.isDestroyed()) popup.destroy(); } diff --git a/src/desktop/remoteMicrophonePermission.test.ts b/src/desktop/remoteMicrophonePermission.test.ts new file mode 100644 index 00000000000..64890c3c69a --- /dev/null +++ b/src/desktop/remoteMicrophonePermission.test.ts @@ -0,0 +1,257 @@ +import { describe, expect, mock, test } from "bun:test"; +import type { BrowserWindow, MessageBoxReturnValue } from "electron"; +import { createRemoteMicrophonePermission } from "./remoteMicrophonePermission"; + +type Dependencies = Parameters[0]; + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((res) => { + resolve = res; + }); + return { promise, resolve }; +} + +function setup(platform: NodeJS.Platform = "darwin") { + const state = { destroyed: false, contentsDestroyed: false, focused: true }; + // Keep the Electron boundary local so other tests do not inherit module mocks. + const window = { + isDestroyed: () => state.destroyed, + isFocused: () => state.focused, + webContents: { isDestroyed: () => state.contentsDestroyed }, + } as unknown as BrowserWindow; + const abort = new AbortController(); + const deps = { + platform, + showMessageBox: mock(() => + Promise.resolve({ response: 1, checkboxChecked: false }) + ), + getMediaAccessStatus: mock(() => "granted"), + askForMediaAccess: mock(() => Promise.resolve(true)), + }; + const request = createRemoteMicrophonePermission(deps); + return { + state, + window, + abort, + deps, + request: (serverUrl = "https://example.com/remote") => request(window, serverUrl, abort.signal), + }; +} + +describe("remote microphone approval", () => { + test("requires native consent with Deny as the default and cancel action", async () => { + const { request, deps, window, abort } = setup(); + deps.showMessageBox.mockResolvedValue({ response: 0, checkboxChecked: false }); + expect(await request("https://example.com/remote?token=secret#secret")).toBe(false); + const [parent, options] = deps.showMessageBox.mock.calls[0]; + expect(parent).toBe(window); + expect(options.signal).toBe(abort.signal); + expect(options.detail).toBe("https://example.com/remote"); + expect(options.defaultId).toBe(0); + expect(options.cancelId).toBe(0); + expect(deps.getMediaAccessStatus).not.toHaveBeenCalled(); + expect(deps.askForMediaAccess).not.toHaveBeenCalled(); + }); + + test.each(["https://user:password@example.com", "invalid", "file:///tmp/app"])( + "does not display an invalid or credential-bearing server URL: %s", + async (serverUrl) => { + const { request, deps } = setup(); + expect(await request(serverUrl)).toBe(false); + expect(deps.showMessageBox).not.toHaveBeenCalled(); + expect(deps.getMediaAccessStatus).not.toHaveBeenCalled(); + } + ); + + test("waits for native consent before checking OS access", async () => { + const { request, deps } = setup(); + const consent = deferred(); + deps.showMessageBox.mockReturnValue(consent.promise); + const result = request(); + expect(deps.getMediaAccessStatus).not.toHaveBeenCalled(); + expect(deps.askForMediaAccess).not.toHaveBeenCalled(); + consent.resolve({ response: 1, checkboxChecked: false }); + expect(await result).toBe(true); + expect(deps.getMediaAccessStatus).toHaveBeenCalledWith("microphone"); + }); + + test.each(["destroyed", "contentsDestroyed", "focused"] as const)( + "rejects an inactive window before consent: %s", + async (field) => { + const { request, deps, state } = setup(); + state[field] = field !== "focused"; + expect(await request()).toBe(false); + expect(deps.showMessageBox).not.toHaveBeenCalled(); + } + ); + + test.each(["destroyed", "contentsDestroyed"] as const)( + "rechecks the window after consent before OS access: %s", + async (field) => { + const { request, deps, state } = setup(); + deps.showMessageBox.mockImplementation(() => { + state[field] = true; + return Promise.resolve({ response: 1, checkboxChecked: false }); + }); + expect(await request()).toBe(false); + expect(deps.getMediaAccessStatus).not.toHaveBeenCalled(); + expect(deps.askForMediaAccess).not.toHaveBeenCalled(); + } + ); + + test.each(["consent", "OS"])( + "accepts explicit approval when the %s dialog takes focus", + async (stage) => { + const { request, deps, state } = setup(); + deps.getMediaAccessStatus.mockReturnValue("not-determined"); + deps.showMessageBox.mockImplementation(() => { + if (stage === "consent") state.focused = false; + return Promise.resolve({ response: 1, checkboxChecked: false }); + }); + deps.askForMediaAccess.mockImplementation(() => { + if (stage === "OS") state.focused = false; + return Promise.resolve(true); + }); + expect(await request()).toBe(true); + expect(state.focused).toBe(false); + } + ); + + test("does not prompt for an aborted request", async () => { + const { request, deps, abort } = setup(); + abort.abort(); + expect(await request()).toBe(false); + expect(deps.showMessageBox).not.toHaveBeenCalled(); + }); + + test("aborts pending native consent and ignores a late Allow response", async () => { + const { request, deps, abort } = setup(); + const consent = deferred(); + deps.showMessageBox.mockReturnValue(consent.promise); + const result = request(); + abort.abort(); + expect(await result).toBe(false); + consent.resolve({ response: 1, checkboxChecked: false }); + await consent.promise; + expect(deps.getMediaAccessStatus).not.toHaveBeenCalled(); + expect(deps.askForMediaAccess).not.toHaveBeenCalled(); + }); + + test("denies access when the native dialog fails", async () => { + const { request, deps } = setup(); + deps.showMessageBox.mockRejectedValue(new Error("Dialog unavailable")); + expect(await request()).toBe(false); + expect(deps.getMediaAccessStatus).not.toHaveBeenCalled(); + }); + + test.each(["denied", "restricted", "unknown"] as const)( + "denies macOS access without an OS prompt for status %s", + async (status) => { + const { request, deps } = setup(); + deps.getMediaAccessStatus.mockReturnValue(status); + expect(await request()).toBe(false); + expect(deps.askForMediaAccess).not.toHaveBeenCalled(); + } + ); + + test("accepts granted macOS access without another OS prompt", async () => { + const { request, deps } = setup(); + expect(await request()).toBe(true); + expect(deps.askForMediaAccess).not.toHaveBeenCalled(); + }); + + test.each([true, false])("uses the macOS microphone prompt result: %s", async (allowed) => { + const { request, deps } = setup(); + deps.getMediaAccessStatus.mockReturnValue("not-determined"); + deps.askForMediaAccess.mockResolvedValue(allowed); + expect(await request()).toBe(allowed); + expect(deps.askForMediaAccess).toHaveBeenCalledWith("microphone"); + }); + + test("checks macOS settings again after a denial", async () => { + const { request, deps } = setup(); + deps.getMediaAccessStatus.mockReturnValueOnce("denied").mockReturnValue("granted"); + expect(await request()).toBe(false); + expect(await request()).toBe(true); + expect(deps.showMessageBox).toHaveBeenCalledTimes(2); + expect(deps.getMediaAccessStatus).toHaveBeenCalledTimes(2); + }); + + test("checks macOS settings again after an OS prompt denial", async () => { + const { request, deps } = setup(); + deps.getMediaAccessStatus.mockReturnValueOnce("not-determined").mockReturnValue("granted"); + deps.askForMediaAccess.mockResolvedValue(false); + expect(await request()).toBe(false); + expect(await request()).toBe(true); + expect(deps.askForMediaAccess).toHaveBeenCalledTimes(1); + }); + + test.each(["darwin", "win32"] as const)( + "denies access when %s status lookup fails", + async (platform) => { + const { request, deps } = setup(platform); + deps.getMediaAccessStatus.mockImplementation(() => { + throw new Error("Status unavailable"); + }); + expect(await request()).toBe(false); + expect(deps.askForMediaAccess).not.toHaveBeenCalled(); + } + ); + + test("denies access when the macOS prompt fails", async () => { + const { request, deps } = setup(); + deps.getMediaAccessStatus.mockReturnValue("not-determined"); + deps.askForMediaAccess.mockRejectedValue(new Error("Access unavailable")); + expect(await request()).toBe(false); + }); + + test("aborts pending OS access and ignores a late grant", async () => { + const { request, deps, abort } = setup(); + const access = deferred(); + const started = deferred(); + deps.getMediaAccessStatus.mockReturnValue("not-determined"); + deps.askForMediaAccess.mockImplementation(() => { + started.resolve(); + return access.promise; + }); + const result = request(); + await started.promise; + abort.abort(); + expect(await result).toBe(false); + access.resolve(true); + await access.promise; + }); + + test.each(["destroyed", "contentsDestroyed"] as const)( + "rejects a stale OS grant after the window changes: %s", + async (field) => { + const { request, deps, state } = setup(); + deps.getMediaAccessStatus.mockReturnValue("not-determined"); + deps.askForMediaAccess.mockImplementation(() => { + state[field] = true; + return Promise.resolve(true); + }); + expect(await request()).toBe(false); + } + ); + + test.each(["denied", "restricted", "granted", "not-determined", "unknown"] as const)( + "uses Windows OS status without a macOS prompt: %s", + async (status) => { + const { request, deps } = setup("win32"); + deps.getMediaAccessStatus.mockReturnValue(status); + expect(await request()).toBe(status !== "denied" && status !== "restricted"); + expect(deps.getMediaAccessStatus).toHaveBeenCalledWith("microphone"); + expect(deps.askForMediaAccess).not.toHaveBeenCalled(); + } + ); + + test("allows Linux access after native consent without OS APIs", async () => { + const { request, deps } = setup("linux"); + expect(await request()).toBe(true); + expect(deps.showMessageBox).toHaveBeenCalledTimes(1); + expect(deps.getMediaAccessStatus).not.toHaveBeenCalled(); + expect(deps.askForMediaAccess).not.toHaveBeenCalled(); + }); +}); diff --git a/src/desktop/remoteMicrophonePermission.ts b/src/desktop/remoteMicrophonePermission.ts new file mode 100644 index 00000000000..4a2061a24bf --- /dev/null +++ b/src/desktop/remoteMicrophonePermission.ts @@ -0,0 +1,76 @@ +import type { + BrowserWindow, + MessageBoxOptions, + MessageBoxReturnValue, + SystemPreferences, +} from "electron"; +import { getRemoteConnectionServerUrl } from "@/common/types/remoteConnection"; +import { log } from "@/node/services/log"; +import { raceWithAbortAndTimeout } from "@/node/utils/concurrency/withTimeout"; + +interface RemoteMicrophonePermissionDependencies { + platform: NodeJS.Platform; + showMessageBox(window: BrowserWindow, options: MessageBoxOptions): Promise; + getMediaAccessStatus( + mediaType: "microphone" + ): ReturnType; + askForMediaAccess(mediaType: "microphone"): Promise; +} + +/** Require native consent before remote content can request OS microphone access. */ +export function createRemoteMicrophonePermission(deps: RemoteMicrophonePermissionDependencies) { + return async ( + window: BrowserWindow, + serverUrl: string, + signal: AbortSignal + ): Promise => { + // Native and OS dialogs can own focus. Recheck identity and liveness, not focus, after consent. + const isRequestCurrent = () => + !signal.aborted && !window.isDestroyed() && !window.webContents.isDestroyed(); + + try { + if (!isRequestCurrent() || !window.isFocused()) return false; + // A remote page cannot prove a user gesture. Require consent in trusted native UI. + const consent = await raceWithAbortAndTimeout( + deps.showMessageBox(window, { + type: "question", + title: "Remote microphone access", + message: "Allow this remote server to use your microphone?", + detail: getRemoteConnectionServerUrl(serverUrl), + buttons: ["Deny", "Allow"], + defaultId: 0, + cancelId: 0, + noLink: true, + signal, + }), + { signal } + ); + if (consent.kind !== "ok" || consent.value.response !== 1 || !isRequestCurrent()) + return false; + + // Recheck OS settings on every request so a previous denial does not block retries. + switch (deps.platform) { + case "darwin": { + const status = deps.getMediaAccessStatus("microphone"); + if (status === "granted") return isRequestCurrent(); + if (status !== "not-determined") return false; + const access = await raceWithAbortAndTimeout(deps.askForMediaAccess("microphone"), { + signal, + }); + return access.kind === "ok" && access.value && isRequestCurrent(); + } + case "win32": { + const status = deps.getMediaAccessStatus("microphone"); + return status !== "denied" && status !== "restricted" && isRequestCurrent(); + } + case "linux": + return isRequestCurrent(); + default: + return false; + } + } catch { + if (!signal.aborted) log.warn("Cannot approve remote microphone access."); + return false; + } + }; +} diff --git a/tests/e2e/electronTest.ts b/tests/e2e/electronTest.ts index b435dc0ecfb..9cac4d3cadc 100644 --- a/tests/e2e/electronTest.ts +++ b/tests/e2e/electronTest.ts @@ -15,6 +15,7 @@ interface WorkspaceHarness { } interface ElectronFixtures { + fakeMediaDevices: boolean; app: ElectronApplication; page: Page; workspace: WorkspaceHarness; @@ -154,6 +155,7 @@ function buildTarget(target: string): void { } export const electronTest = base.extend({ + fakeMediaDevices: [false, { option: true }], workspace: async ({}, use, testInfo) => { const originalXumRoot = process.env.XUM_ROOT; const originalMuxRoot = process.env.MUX_ROOT; @@ -204,7 +206,7 @@ export const electronTest = base.extend({ } } }, - app: async ({ workspace }, use, testInfo) => { + app: async ({ workspace, fakeMediaDevices }, use, testInfo) => { const { configRoot } = workspace; const devServerPort = BASE_DEV_SERVER_PORT + testInfo.workerIndex; @@ -308,6 +310,10 @@ export const electronTest = base.extend({ // present but not configured correctly (setuid root). In that case Electron hard-fails // unless we disable sandboxing. const launchArgs = ["."]; + if (fakeMediaDevices) { + // Fake devices preserve the real permission flow. Do not enable fake media UI. + launchArgs.unshift("--use-fake-device-for-media-stream"); + } if (process.platform === "linux" || process.getuid?.() === 0) { launchArgs.unshift("--no-sandbox"); } diff --git a/tests/e2e/scenarios/remoteConnection.spec.ts b/tests/e2e/scenarios/remoteConnection.spec.ts index 6140f2735ca..ce118d45d1a 100644 --- a/tests/e2e/scenarios/remoteConnection.spec.ts +++ b/tests/e2e/scenarios/remoteConnection.spec.ts @@ -1,5 +1,14 @@ import assert from "node:assert/strict"; -import type { BrowserWindow, Clipboard } from "electron"; +import type { + BaseWindow, + BrowserWindow, + Clipboard, + Dialog, + MessageBoxOptions, + MessageBoxReturnValue, + SystemPreferences, +} from "electron"; +import type { ElectronApplication, Frame, JSHandle, Page } from "playwright"; import { once } from "node:events"; import { createServer } from "node:http"; import { electronTest, electronExpect as expect } from "../electronTest"; @@ -347,3 +356,470 @@ test("remote app popups and blob attachments retain isolation and close on disco await returned; expect(await page.evaluate(() => window.localStorage.getItem("popout-auth"))).toBeNull(); }); + +interface NativeMediaState { + prompts: Array<{ + windowId: number; + signal: AbortSignal | undefined; + respond: (allow: boolean) => void; + }>; + status: ReturnType; + statusFailure: boolean; + osApproval: boolean; + statusRequests: string[]; + osRequests: string[]; + restore: () => void; +} + +const microphoneTest = test.extend<{ nativeMedia: JSHandle }>({ + nativeMedia: async ({ app }, use) => { + const state = await app.evaluateHandle( + ({ dialog, systemPreferences }: { dialog: Dialog; systemPreferences: SystemPreferences }) => { + const originalDialog = dialog.showMessageBox; + const originalStatus = systemPreferences.getMediaAccessStatus; + const originalRequest = systemPreferences.askForMediaAccess; + const state: NativeMediaState = { + prompts: [], + status: "granted", + statusFailure: false, + osApproval: true, + statusRequests: [], + osRequests: [], + restore: () => { + for (const prompt of state.prompts) prompt.respond(false); + dialog.showMessageBox = originalDialog; + systemPreferences.getMediaAccessStatus = originalStatus; + systemPreferences.askForMediaAccess = originalRequest; + }, + }; + // Stub native boundaries only. Chromium still runs the installed permission handlers. + dialog.showMessageBox = ( + owner: BaseWindow | MessageBoxOptions, + options?: MessageBoxOptions + ): Promise => { + if (!("id" in owner) || !options || options.buttons?.length !== 2) { + throw new Error("Microphone consent needs a parent window and two buttons"); + } + if (options.cancelId !== 0 || options.defaultId !== 0) { + throw new Error("Microphone consent must default to denial"); + } + return new Promise((resolve) => { + state.prompts.push({ + windowId: owner.id, + signal: options.signal, + // Leave aborted dialogs pending to test late native responses. + respond: (allow) => resolve({ response: allow ? 1 : 0, checkboxChecked: false }), + }); + }); + }; + systemPreferences.getMediaAccessStatus = (mediaType) => { + state.statusRequests.push(mediaType); + if (state.statusFailure) throw new Error("Test OS status failure"); + return state.status; + }; + systemPreferences.askForMediaAccess = (mediaType) => { + state.osRequests.push(mediaType); + return Promise.resolve(state.osApproval); + }; + return state; + } + ); + try { + await use(state); + } finally { + await state.evaluate((state) => state.restore()); + await state.dispose(); + } + }, +}); + +async function connectForMicrophone( + app: ElectronApplication, + local: Page, + url: string +): Promise { + await local.waitForFunction(() => Boolean(window.api?.remoteConnection)); + const opened = app.waitForEvent("window"); + await local.evaluate((url) => window.api!.remoteConnection!.connect(url), url); + const remote = await opened; + await expect(remote.getByRole("heading", { name: "Remote server" })).toBeVisible(); + await focusNativeWindow(app, remote); + return remote; +} + +async function focusNativeWindow(app: ElectronApplication, page: Page): Promise { + const window = await app.browserWindow(page); + try { + await window.evaluate((window) => window.focus()); + await expect.poll(() => window.evaluate((window) => window.isFocused())).toBe(true); + } finally { + await window.dispose(); + } +} + +async function requestMedia(target: Page | Frame, constraints: MediaStreamConstraints) { + return target.evaluate(async (constraints) => { + try { + const stream = await navigator.mediaDevices.getUserMedia(constraints); + Reflect.set(window, "__testMicrophoneStream", stream); + return { + allowed: true, + tracks: stream.getTracks().map((track) => ({ kind: track.kind, state: track.readyState })), + error: null, + }; + } catch (error) { + return { + allowed: false, + tracks: [], + error: error instanceof Error ? error.name : String(error), + }; + } + }, constraints); +} + +async function stopMicrophone(page: Page): Promise { + expect( + await page.evaluate(() => { + const stream: unknown = Reflect.get(window, "__testMicrophoneStream"); + if (!(stream instanceof MediaStream)) throw new Error("No active test microphone stream"); + for (const track of stream.getTracks()) track.stop(); + return stream.getTracks().map((track) => track.readyState); + }) + ).toEqual(["ended"]); +} + +async function respondToMicrophone( + state: JSHandle, + index: number, + allow: boolean +): Promise { + await expect.poll(() => state.evaluate((state) => state.prompts.length)).toBe(index + 1); + await state.evaluate((state, response) => state.prompts[response.index].respond(response.allow), { + index, + allow, + }); +} + +microphoneTest.describe("remote microphone permissions", () => { + microphoneTest.use({ fakeMediaDevices: true }); + + microphoneTest( + "non-loopback HTTP keeps microphone access unavailable", + async ({ app, page, nativeMedia }) => { + const url = "http://microphone.invalid/"; + // Intercept HTTP content without changing Chromium's secure-context rules or host flags. + await app.context().route(url, (route) => + route.fulfill({ + contentType: "text/html", + body: "

Remote server

", + }) + ); + try { + const remote = await connectForMicrophone(app, page, url); + expect(remote.url()).toBe(url); + expect(await remote.evaluate(() => window.isSecureContext)).toBe(false); + expect(await requestMedia(remote, { audio: true })).toMatchObject({ allowed: false }); + expect(await nativeMedia.evaluate((state) => state.prompts.length)).toBe(0); + } finally { + await app.context().unroute(url); + } + } + ); + + microphoneTest( + "audio needs consent on approval, denial, and retry; local access remains available", + async ({ app, page, remoteServer, nativeMedia }) => { + expect(await requestMedia(page, { audio: true })).toMatchObject({ allowed: true }); + await stopMicrophone(page); + const remote = await connectForMicrophone(app, page, remoteServer.url); + for (const [index, allow] of [true, false, true].entries()) { + await focusNativeWindow(app, remote); + const previousAccessRequests = await nativeMedia.evaluate( + (state) => state.statusRequests.length + state.osRequests.length + ); + const capture = requestMedia(remote, { audio: true }); + await expect + .poll(() => nativeMedia.evaluate((state) => state.prompts.length)) + .toBe(index + 1); + // OS access starts only after the user approves the native dialog. + expect( + await nativeMedia.evaluate( + (state) => state.statusRequests.length + state.osRequests.length + ) + ).toBe(previousAccessRequests); + await respondToMicrophone(nativeMedia, index, allow); + if (allow) { + expect(await capture).toEqual({ + allowed: true, + tracks: [{ kind: "audio", state: "live" }], + error: null, + }); + // Permission checks never expose a reusable grant, even during active capture. + expect( + await remote.evaluate(async () => { + const permission = await navigator.permissions.query({ + name: "microphone" as PermissionName, + }); + return permission.state; + }) + ).toBe("denied"); + await stopMicrophone(remote); + } else { + expect(await capture).toMatchObject({ allowed: false, error: "NotAllowedError" }); + expect( + await nativeMedia.evaluate( + (state) => state.statusRequests.length + state.osRequests.length + ) + ).toBe(previousAccessRequests); + } + } + await remote.close(); + await expect + .poll(() => page.evaluate(() => window.api!.remoteConnection!.getState())) + .toMatchObject({ status: "disconnected" }); + expect(await requestMedia(page, { audio: true })).toMatchObject({ allowed: true }); + await stopMicrophone(page); + expect(await nativeMedia.evaluate((state) => state.prompts.length)).toBe(3); + expect(await page.evaluate(() => Boolean(window.__ORPC_CLIENT__))).toBe(true); + } + ); + + microphoneTest( + "camera, mixed media, subframes, auth, and attachments cannot request microphone consent", + async ({ app, page, remoteServer, nativeMedia }) => { + const base = remoteServer.url + "/@user/workspace/apps/xum/"; + const remote = await connectForMicrophone(app, page, base); + for (const constraints of [{ video: true }, { audio: true, video: true }]) { + expect(await requestMedia(remote, constraints)).toMatchObject({ + allowed: false, + error: "NotAllowedError", + }); + } + await remote.evaluate(async (url) => { + const frame = document.createElement("iframe"); + frame.name = "microphone-subframe"; + frame.allow = "microphone; camera"; + frame.src = url; + const loaded = new Promise((resolve) => + frame.addEventListener("load", () => resolve(), { once: true }) + ); + document.body.append(frame); + await loaded; + }, base + "embedded"); + const frame = remote.frame("microphone-subframe"); + assert(frame); + expect(await requestMedia(frame, { audio: true })).toMatchObject({ + allowed: false, + error: "NotAllowedError", + }); + const authOpened = app.waitForEvent("window"); + await remote.getByRole("button", { name: "Sign in", exact: true }).click(); + const auth = await authOpened; + await expect(auth.getByRole("heading", { name: "Auth callback" })).toBeVisible(); + await focusNativeWindow(app, auth); + expect(await requestMedia(auth, { audio: true })).toMatchObject({ + allowed: false, + error: "NotAllowedError", + }); + // Auth windows stay ineligible after a redirect into the selected app. + await auth.goto(base + "callback"); + expect(await requestMedia(auth, { audio: true })).toMatchObject({ + allowed: false, + error: "NotAllowedError", + }); + await auth.close(); + await focusNativeWindow(app, remote); + const attachmentOpened = app.waitForEvent("window"); + await remote.evaluate(() => { + const url = URL.createObjectURL( + new Blob(["Microphone attachment"], { type: "text/plain" }) + ); + window.open(url, "_blank"); + }); + const attachment = await attachmentOpened; + await expect(attachment.locator("body")).toContainText("Microphone attachment"); + await focusNativeWindow(app, attachment); + expect(await requestMedia(attachment, { audio: true })).toMatchObject({ + allowed: false, + error: "NotAllowedError", + }); + await attachment.close(); + // A same-origin login page is not part of the selected path-mounted app. + await remote.goto(remoteServer.url + "/login"); + await focusNativeWindow(app, remote); + expect(await requestMedia(remote, { audio: true })).toMatchObject({ + allowed: false, + error: "NotAllowedError", + }); + expect(await nativeMedia.evaluate((state) => state.prompts.length)).toBe(0); + } + ); + + microphoneTest( + "only an initially focused app popup can request audio", + async ({ app, page, remoteServer, nativeMedia }) => { + const remote = await connectForMicrophone(app, page, remoteServer.url); + const opened = app.waitForEvent("window"); + await remote.evaluate(() => window.open("/terminal.html?terminalId=microphone", "_blank")); + const popup = await opened; + await expect(popup.getByRole("heading", { name: "Remote server" })).toBeVisible(); + await focusNativeWindow(app, popup); + expect(await requestMedia(remote, { audio: true })).toMatchObject({ + allowed: false, + error: "NotAllowedError", + }); + const capture = requestMedia(popup, { audio: true }); + await expect.poll(() => nativeMedia.evaluate((state) => state.prompts.length)).toBe(1); + // Native consent can transfer focus away from the requesting window. + const popupWindow = await app.browserWindow(popup); + await popupWindow.evaluate((window) => window.blur()); + await expect.poll(() => popupWindow.evaluate((window) => window.isFocused())).toBe(false); + await respondToMicrophone(nativeMedia, 0, true); + expect(await capture).toMatchObject({ + allowed: true, + tracks: [{ kind: "audio", state: "live" }], + }); + expect(await nativeMedia.evaluate((state) => state.prompts[0].windowId)).toBe( + await popupWindow.evaluate((window) => window.id) + ); + await popupWindow.dispose(); + await stopMicrophone(popup); + } + ); + + microphoneTest( + "navigation and same-URL reload abort stale consent", + async ({ app, page, remoteServer, nativeMedia }) => { + const remote = await connectForMicrophone(app, page, remoteServer.url); + for (const [index, reload] of [true, false].entries()) { + const promptIndex = index * 2; + const pending = requestMedia(remote, { audio: true }).catch(() => null); + await expect + .poll(() => nativeMedia.evaluate((state) => state.prompts.length)) + .toBe(promptIndex + 1); + const previousUrl = remote.url(); + if (reload) { + await remote.reload(); + expect(remote.url()).toBe(previousUrl); + } else { + await remote.goto(remoteServer.url + "/workspaces/other"); + } + await expect + .poll(() => + nativeMedia.evaluate( + (state, index) => state.prompts[index].signal?.aborted, + promptIndex + ) + ) + .toBe(true); + await respondToMicrophone(nativeMedia, promptIndex, true); + expect((await pending)?.allowed).not.toBe(true); + await focusNativeWindow(app, remote); + const fresh = requestMedia(remote, { audio: true }); + await respondToMicrophone(nativeMedia, promptIndex + 1, true); + expect(await fresh).toMatchObject({ allowed: true }); + await stopMicrophone(remote); + } + } + ); + + microphoneTest( + "disconnect closes active capture and aborts pending consent before reconnect", + async ({ app, page, remoteServer, nativeMedia }) => { + const remote = await connectForMicrophone(app, page, remoteServer.url); + const active = requestMedia(remote, { audio: true }); + await respondToMicrophone(nativeMedia, 0, true); + expect(await active).toMatchObject({ + allowed: true, + tracks: [{ kind: "audio", state: "live" }], + }); + const pending = requestMedia(remote, { audio: true }).catch(() => null); + await expect.poll(() => nativeMedia.evaluate((state) => state.prompts.length)).toBe(2); + const closed = remote.waitForEvent("close"); + const remoteWindow = await app.browserWindow(remote); + await page.evaluate(() => window.api!.remoteConnection!.disconnect()); + await closed; + expect(await remoteWindow.evaluate((window) => window.isDestroyed())).toBe(true); + await remoteWindow.dispose(); + await expect + .poll(() => nativeMedia.evaluate((state) => state.prompts[1].signal?.aborted)) + .toBe(true); + await respondToMicrophone(nativeMedia, 1, true); + expect((await pending)?.allowed).not.toBe(true); + expect(app.windows()).toEqual([page]); + const reconnected = await connectForMicrophone(app, page, remoteServer.url); + const fresh = requestMedia(reconnected, { audio: true }); + await respondToMicrophone(nativeMedia, 2, true); + expect(await fresh).toMatchObject({ allowed: true }); + await stopMicrophone(reconnected); + } + ); + + for (const lifecycle of ["close", "crash"] as const) { + microphoneTest( + lifecycle + " aborts pending microphone consent", + async ({ app, page, remoteServer, nativeMedia }) => { + const remote = await connectForMicrophone(app, page, remoteServer.url); + const pending = requestMedia(remote, { audio: true }).catch(() => null); + await expect.poll(() => nativeMedia.evaluate((state) => state.prompts.length)).toBe(1); + if (lifecycle === "close") { + await remote.close(); + } else { + const window = await app.browserWindow(remote); + await window.evaluate((window) => { + // Terminate the renderer without waiting for its event loop or debugger. + process.kill(window.webContents.getOSProcessId(), "SIGKILL"); + }); + await window.dispose(); + } + await expect + .poll(() => nativeMedia.evaluate((state) => state.prompts[0].signal?.aborted)) + .toBe(true); + await respondToMicrophone(nativeMedia, 0, true); + expect((await pending)?.allowed).not.toBe(true); + await expect + .poll(() => page.evaluate(() => window.api!.remoteConnection!.getState())) + .toMatchObject({ status: "disconnected" }); + } + ); + } + + microphoneTest.describe("platform microphone checks", () => { + microphoneTest.skip( + process.platform === "linux", + "Linux does not expose microphone OS consent" + ); + microphoneTest( + "OS denial and status failures cannot grant remote audio", + async ({ app, page, remoteServer, nativeMedia }) => { + const remote = await connectForMicrophone(app, page, remoteServer.url); + for (const [index, status] of (["denied", "restricted", "granted"] as const).entries()) { + await nativeMedia.evaluate((state, status) => { + state.status = status; + state.statusFailure = status === "granted"; + }, status); + const capture = requestMedia(remote, { audio: true }); + await respondToMicrophone(nativeMedia, index, true); + expect(await capture).toMatchObject({ allowed: false, error: "NotAllowedError" }); + } + expect(await nativeMedia.evaluate((state) => state.statusRequests)).toEqual([ + "microphone", + "microphone", + "microphone", + ]); + expect(await nativeMedia.evaluate((state) => state.osRequests)).toEqual([]); + if (process.platform === "darwin") { + await nativeMedia.evaluate((state) => { + state.status = "not-determined"; + state.statusFailure = false; + state.osApproval = false; + }); + const capture = requestMedia(remote, { audio: true }); + await respondToMicrophone(nativeMedia, 3, true); + expect(await capture).toMatchObject({ allowed: false, error: "NotAllowedError" }); + expect(await nativeMedia.evaluate((state) => state.osRequests)).toEqual(["microphone"]); + } + } + ); + }); +});