diff --git a/build/entitlements.mac.plist b/build/entitlements.mac.plist new file mode 100644 index 000000000..4373cf428 --- /dev/null +++ b/build/entitlements.mac.plist @@ -0,0 +1,16 @@ + + + + + + com.apple.security.cs.allow-jit + + com.apple.security.cs.allow-unsigned-executable-memory + + com.apple.security.cs.disable-library-validation + + + com.apple.security.device.audio-input + + + diff --git a/docs/release-runbook.md b/docs/release-runbook.md index d6a0dfdb3..0316e9aed 100644 --- a/docs/release-runbook.md +++ b/docs/release-runbook.md @@ -65,6 +65,19 @@ Signed, prerelease and tag builds always archive and validate the unpacked Windo The packaged smoke runs a separate temporary copy and waits for its process tree to exit. This cleanup is independent of archive input and does not prove the original failure was a file lock. Inspect the reported packing/validation stage and original cause on failure. A successful archive does not establish signing or installer verification success. +## Microphone permissions and voice-input qualification + +The permission baseline follows the official Desktop at [f3803c9](https://github.com/deepseek-ai/deepseek-harness/tree/f3803c96974ad4a94195fb97e5c637e9c2a3afe3/apps/desktop): its [microphone handlers](https://github.com/deepseek-ai/deepseek-harness/blob/f3803c96974ad4a94195fb97e5c637e9c2a3afe3/apps/desktop/src/microphone-permissions.ts), [entitlements](https://github.com/deepseek-ai/deepseek-harness/blob/f3803c96974ad4a94195fb97e5c637e9c2a3afe3/apps/desktop/scripts/macos-entitlements.plist), and [builder configuration](https://github.com/deepseek-ai/deepseek-harness/blob/f3803c96974ad4a94195fb97e5c637e9c2a3afe3/apps/desktop/scripts/electron-builder-config.mjs). Desktop grants only audio-only media requests from the secured window's trusted main frame. The legacy loopback renderer must match that window's current origin, including its runtime port. macOS permission checks also require system authorization; requests call `systemPreferences.askForMediaAccess('microphone')` and return false on denial or failure. Other permissions retain our explicit clipboard-write/notifications whitelist rather than upstream's permissive non-media fallback. Camera, screen capture, external pages, subframes, and other session contents are not authorized. The custom shell protocol already has secure-context support and needs no extra privileges. + +macOS requires all three layers: Electron permission handlers, a nonempty `NSMicrophoneUsageDescription`, and `com.apple.security.device.audio-input` in the Hardened Runtime signature. Both main-app and helper signing use `build/entitlements.mac.plist`, preserving the pre-existing JIT/native-module flags without adding other device entitlements. The signed notarization path checks the final main and Electron helper signatures plus the generated usage description before submission; missing permissions fail the build. This check does not record audio or prove speech recognition works. + +Qualify a newly signed build using an isolated development profile or test machine, without resetting the production profile or system privacy database: + +1. On macOS, start with microphone permission not yet decided, click voice input and confirm the OS prompt appears. Allow access, speak, stop, and verify text appears in the draft. Separately deny access and verify the UI reports permission failure; re-enable access in System Settings and retry after restarting if required by macOS. +2. On Windows, verify audio capture with the OS microphone privacy setting allowed and denied. No macOS authorization API is called there. Native Windows validation cannot be replaced by the policy unit tests. +3. Confirm camera/mixed requests, external origins, subframes and another window sharing the session remain denied. Changing the loopback port must not grant the previous origin access. +4. If recording works but no text appears, check input-device selection and speech-provider/model preparation separately. A native permission grant, a successful recording, and a successful transcript are distinct acceptance stages. + ## macOS app notarization retries App notarization runs in `scripts/notarize-macos.mjs`, an `afterSign` hook that finishes before DMG/ZIP creation. The built-in electron-builder notarization is disabled to avoid duplicate submissions. The hook uses the existing `@electron/notarize` API for submission and acceptance, then recovers stapling failures and validation failures 65/68/69 with up to 6 fresh calls, waiting 10/20/30/40/50/60 seconds and limiting each call to 60 seconds. Retry subprocess timeouts are also retried. Rejected notarization and permanent errors fail immediately; successful stapling must pass `stapler validate`. The later DMG stapling and validation in the release workflow use progressive backoff (up to 6 attempts with 15s/10s increments) across both Apple Silicon and Intel to accommodate Apple CloudKit global replication delays. A network error talking to Apple CloudKit during stapling therefore no longer fails the release job prematurely. diff --git a/package.json b/package.json index 504096c38..afcc8cc9e 100644 --- a/package.json +++ b/package.json @@ -495,6 +495,11 @@ "category": "public.app-category.developer-tools", "icon": "build/icon.icns", "hardenedRuntime": true, + "entitlements": "build/entitlements.mac.plist", + "entitlementsInherit": "build/entitlements.mac.plist", + "extendInfo": { + "NSMicrophoneUsageDescription": "DSH Desktop uses your microphone to transcribe speech into message drafts." + }, "gatekeeperAssess": false, "signIgnore": [ "office-runtime/.*\\.(py|pyi|pyc|h|c|f90|f|txt|md|csv|pxd|pxi|json|webp|gif|png|xml|tcl|enc|tm)$", diff --git a/scripts/notarize-macos.mjs b/scripts/notarize-macos.mjs index d0ede445b..47cefaced 100644 --- a/scripts/notarize-macos.mjs +++ b/scripts/notarize-macos.mjs @@ -3,6 +3,7 @@ import { promisify } from 'node:util' import { join } from 'node:path' import { setTimeout } from 'node:timers/promises' import { notarize } from '@electron/notarize' +import { verifyMacOSMicrophonePermissions } from './verify-macos-permissions.mjs' const exec = promisify(execFile) @@ -91,5 +92,6 @@ export default async function afterSign(context) { if (!appleApiKey && !appleApiKeyId && !appleApiIssuer) return if (!appleApiKey || !appleApiKeyId || !appleApiIssuer) throw new Error('All Apple API notarization credentials are required') const appPath = join(context.appOutDir, `${context.packager.appInfo.productFilename}.app`) + await verifyMacOSMicrophonePermissions(appPath) await notarizeWithStapleRetry({ appPath, appleApiKey, appleApiKeyId, appleApiIssuer }) } diff --git a/scripts/verify-macos-permissions.mjs b/scripts/verify-macos-permissions.mjs new file mode 100644 index 000000000..6837be80a --- /dev/null +++ b/scripts/verify-macos-permissions.mjs @@ -0,0 +1,26 @@ +import { execFile } from 'node:child_process' +import { readdir } from 'node:fs/promises' +import { join } from 'node:path' +import { promisify } from 'node:util' + +const exec = promisify(execFile) + +/** Inspect the signed bundle, not the source plist, before publishing or notarizing it. */ +export async function verifyMacOSMicrophonePermissions(appPath, { run = exec, list = readdir } = {}) { + const { stdout: description } = await run('/usr/libexec/PlistBuddy', [ + '-c', 'Print :NSMicrophoneUsageDescription', join(appPath, 'Contents', 'Info.plist') + ], { timeout: 10_000 }) + if (!description.trim()) throw new Error('macOS microphone usage description is empty') + + const frameworks = join(appPath, 'Contents', 'Frameworks') + const helpers = (await list(frameworks)).filter(name => name.endsWith(' Helper.app') + || name.includes(' Helper (') && name.endsWith('.app')) + if (helpers.length === 0) throw new Error('macOS microphone verification found no Electron helpers') + for (const bundle of [appPath, ...helpers.map(name => join(frameworks, name))]) { + // Newer macOS versions default to a human-readable dictionary unless XML is explicit. + const { stdout: xml } = await run('/usr/bin/codesign', ['-d', '--entitlements', '-', '--xml', bundle], { timeout: 10_000 }) + if (!/com\.apple\.security\.device\.audio-input<\/key>\s*/u.test(xml)) { + throw new Error('macOS signed bundle is missing audio-input entitlement: ' + bundle) + } + } +} diff --git a/src/main/index.ts b/src/main/index.ts index 450e55acb..f1f2c8554 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1192,7 +1192,7 @@ function createWindow(): BrowserWindow { appendRendererPluginFailureLog(details.message) }) installPluginRecoveryNavigation(window) - secureWindow(window) + secureWindow(window, true) installContextMenu(window, harnessLocale) installMainWindowRendererRecovery(window) window.on('closed', () => { diff --git a/src/main/security-policy.ts b/src/main/security-policy.ts index 46d6fad35..12e74a2b6 100644 --- a/src/main/security-policy.ts +++ b/src/main/security-policy.ts @@ -25,12 +25,13 @@ export function isTrustedAppUrl(rawUrl: string): boolean { export function canGrantWindowPermission( permission: string, requestingUrl: string | undefined, - isMainFrame: boolean + isMainFrame: boolean, + mediaTypes: readonly string[] = [] ): boolean { return ( (permission === 'clipboard-sanitized-write' || permission === 'notifications' || - permission === 'media') && + (permission === 'media' && mediaTypes.length === 1 && mediaTypes[0] === 'audio')) && isMainFrame && requestingUrl !== undefined && (isHarnessUrl(requestingUrl) || isHarnessShellUrl(requestingUrl)) diff --git a/src/main/security.ts b/src/main/security.ts index d753eaa1b..52272603c 100644 --- a/src/main/security.ts +++ b/src/main/security.ts @@ -1,7 +1,8 @@ import { shell, type BrowserWindow } from 'electron' -import { canGrantWindowPermission, isTrustedAppUrl } from './security-policy' +import { isTrustedAppUrl } from './security-policy' +import { installWindowPermissions } from './window-permissions' -export function secureWindow(window: Pick): void { +export function secureWindow(window: Pick, allowMicrophone = false): void { window.webContents.setWindowOpenHandler(({ url }) => { if (isTrustedAppUrl(url)) return { action: 'allow' } if (url.startsWith('https://') || url.startsWith('http://')) void shell.openExternal(url) @@ -15,19 +16,5 @@ export function secureWindow(window: Pick): void { }) window.webContents.on('will-attach-webview', (event) => event.preventDefault()) - window.webContents.session.setPermissionCheckHandler( - (_webContents, permission, requestingOrigin, details) => - canGrantWindowPermission( - permission, - details.requestingUrl ?? requestingOrigin, - details.isMainFrame - ) - ) - window.webContents.session.setPermissionRequestHandler( - (_webContents, permission, callback, details) => { - callback( - canGrantWindowPermission(permission, details.requestingUrl, details.isMainFrame) - ) - } - ) + installWindowPermissions(window.webContents.session, window.webContents, allowMicrophone) } diff --git a/src/main/window-permissions.ts b/src/main/window-permissions.ts new file mode 100644 index 000000000..700d2681a --- /dev/null +++ b/src/main/window-permissions.ts @@ -0,0 +1,57 @@ +import { systemPreferences, type Session, type WebContents } from 'electron' +import { canGrantWindowPermission } from './security-policy' + +type PermissionSession = Pick +type PermissionOwner = Pick +const sessionWindows = new WeakMap>() + +/** Session handlers are shared: securing a dialog must not replace the main window's policy. */ +export function installWindowPermissions( + session: PermissionSession, + owner: PermissionOwner, + allowMicrophone = false +): void { + const registered = sessionWindows.get(session) + if (registered) { registered.set(owner, allowMicrophone); return } + const windows = new WeakMap() + windows.set(owner, allowMicrophone) + sessionWindows.set(session, windows) + + function ownsRequest(contents: WebContents | null, requestingUrl: string, permission: string): boolean { + if (!contents || !windows.has(contents) || contents.isDestroyed()) return false + if (permission === 'media' && !windows.get(contents)) return false + try { + const requested = new URL(requestingUrl) + const current = new URL(contents.getURL()) + // Compare protocol/host explicitly: custom-scheme URL.origin can be "null". + return requested.protocol === current.protocol && requested.host === current.host + && requested.username === '' && requested.password === '' + } catch { + return false + } + } + + session.setPermissionCheckHandler((contents, permission, origin, details) => { + const requestingUrl = details.requestingUrl ?? origin + if (!ownsRequest(contents, requestingUrl, permission) + || !canGrantWindowPermission(permission, requestingUrl, details.isMainFrame, + details.mediaType ? [details.mediaType] : [])) return false + return permission !== 'media' || process.platform !== 'darwin' + || systemPreferences.getMediaAccessStatus('microphone') === 'granted' + }) + + session.setPermissionRequestHandler((contents, permission, callback, details) => { + const allowed = () => ownsRequest(contents, details.requestingUrl, permission) + && canGrantWindowPermission(permission, details.requestingUrl, details.isMainFrame, + 'mediaTypes' in details ? details.mediaTypes : []) + if (!allowed()) { callback(false); return } + if (permission !== 'media' || process.platform !== 'darwin') { callback(true); return } + void systemPreferences.askForMediaAccess('microphone').then( + granted => callback(granted && allowed()), + error => { + console.warn('Microphone permission request failed', error) + callback(false) + } + ) + }) +} diff --git a/test/macos-permissions.test.mjs b/test/macos-permissions.test.mjs new file mode 100644 index 000000000..d55e96bb2 --- /dev/null +++ b/test/macos-permissions.test.mjs @@ -0,0 +1,98 @@ +import { execFile } from 'node:child_process' +import { copyFile, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { promisify } from 'node:util' +import { describe, expect, it, vi } from 'vitest' +import { verifyMacOSMicrophonePermissions } from '../scripts/verify-macos-permissions.mjs' + +const exec = promisify(execFile) +const root = path.resolve(import.meta.dirname, '..') +const require = createRequire(import.meta.url) + +async function signingOptions() { + const { build } = JSON.parse(await readFile(path.join(root, 'package.json'), 'utf8')) + // Exercise the pinned builder's real main/helper entitlement selection. + require('app-builder-lib') + const { MacTargetHelper } = require('app-builder-lib/out/mac/MacTargetHelper.js') + const helper = new MacTargetHelper({ resourceList: Promise.resolve([]), platformSpecificBuildOptions: build.mac }) + return { mac: build.mac, options: await helper.getOptionsForFile('/fixture/DSH Desktop.app', false, build.mac) } +} + +const audio = 'com.apple.security.device.audio-input' + +describe('macOS microphone packaging', () => { + it('configures the usage description and main/helper entitlements without broadening device access', async () => { + const { mac, options } = await signingOptions() + expect(mac.hardenedRuntime).toBe(true) + expect(mac.extendInfo.NSMicrophoneUsageDescription).toMatch(/microphone.*transcribe/i) + for (const target of ['/fixture/DSH Desktop.app', '/fixture/DSH Desktop.app/Contents/Frameworks/DSH Desktop Helper.app']) { + const selected = options(target) + expect(selected.hardenedRuntime).toBe(true) + expect(selected.entitlements).toBe('build/entitlements.mac.plist') + const plist = await readFile(path.join(root, selected.entitlements), 'utf8') + for (const entitlement of ['cs.allow-jit', 'cs.allow-unsigned-executable-memory', 'cs.disable-library-validation', 'device.audio-input']) { + expect(plist).toContain('com.apple.security.' + entitlement + '') + } + expect(plist).not.toMatch(/device\.(camera|bluetooth|usb)|app-sandbox|network\.server/) + } + const dev = require('../electron-builder.dev.cjs') + expect(dev.mac).toEqual(mac) + }) + + it('checks signed main and helper bundles, rejecting missing audio capability or description', async () => { + const list = vi.fn(async () => ['Electron Framework.framework', 'DSH Desktop Helper.app', 'DSH Desktop Helper (Renderer).app']) + const run = vi.fn(async command => ({ stdout: command.endsWith('PlistBuddy') ? 'Microphone for speech input' : audio })) + await verifyMacOSMicrophonePermissions('/fixture/DSH Desktop.app', { run, list }) + expect(run.mock.calls.filter(([command]) => command.endsWith('codesign'))).toHaveLength(3) + run.mockImplementation(async (command, args) => ({ stdout: command.endsWith('PlistBuddy') + ? 'Microphone for speech input' + : args.at(-1).endsWith('Helper.app') ? audio.replace('', '') : audio })) + await expect(verifyMacOSMicrophonePermissions('/fixture/DSH Desktop.app', { run, list })).rejects.toThrow('Helper.app') + run.mockResolvedValue({ stdout: '' }) + await expect(verifyMacOSMicrophonePermissions('/fixture/DSH Desktop.app', { run, list })).rejects.toThrow('description is empty') + run.mockRejectedValue(new Error('codesign failed')) + await expect(verifyMacOSMicrophonePermissions('/fixture/DSH Desktop.app', { run, list })).rejects.toThrow('codesign failed') + run.mockResolvedValue({ stdout: 'description' }) + await expect(verifyMacOSMicrophonePermissions('/fixture/DSH Desktop.app', { run, list: async () => [] })).rejects.toThrow('no Electron helpers') + }) + + it.runIf(process.platform === 'darwin')('verifies real Hardened Runtime signatures produced with the configured entitlement files', async () => { + const directory = await mkdtemp(path.join(tmpdir(), 'dsh-microphone-signing-')) + try { + const { mac, options } = await signingOptions() + const source = path.join(directory, 'probe.c') + const executable = path.join(directory, 'probe') + await writeFile(source, 'int main(void) { return 0; }\n') + await exec('/usr/bin/clang', [source, '-o', executable]) + const app = path.join(directory, 'DSH Desktop.app') + const helpers = path.join(app, 'Contents', 'Frameworks') + for (const [index, bundle] of [app, path.join(helpers, 'DSH Desktop Helper.app'), path.join(helpers, 'DSH Desktop Helper (Renderer).app')].entries()) { + await mkdir(path.join(bundle, 'Contents', 'MacOS'), { recursive: true }) + await writeFile(path.join(bundle, 'Contents', 'Info.plist'), + '' + + 'CFBundleExecutableprobeCFBundleIdentifierio.dsh.permission-probe.' + index + '' + + 'CFBundlePackageTypeAPPLCFBundleVersion1' + + 'NSMicrophoneUsageDescription' + mac.extendInfo.NSMicrophoneUsageDescription + '') + await copyFile(executable, path.join(bundle, 'Contents', 'MacOS', 'probe')) + } + // Sign inside out, just as the actual application packaging does. + for (const [bundle, target] of [ + [path.join(helpers, 'DSH Desktop Helper.app'), '/fixture/helper.app'], + [path.join(helpers, 'DSH Desktop Helper (Renderer).app'), '/fixture/renderer.app'], + [app, '/fixture/DSH Desktop.app'] + ]) { + await exec('/usr/bin/codesign', ['--force', '--sign', '-', '--options', 'runtime', '--timestamp=none', '--entitlements', path.join(root, options(target).entitlements), bundle]) + } + await verifyMacOSMicrophonePermissions(app) + // The same gate must catch the regression in an actual signed helper. + const noAudio = path.join(directory, 'no-audio.plist') + await writeFile(noAudio, '') + await exec('/usr/bin/codesign', ['--force', '--sign', '-', '--options', 'runtime', '--timestamp=none', '--entitlements', noAudio, path.join(helpers, 'DSH Desktop Helper.app')]) + await expect(verifyMacOSMicrophonePermissions(app)).rejects.toThrow('Helper.app') + } finally { + await rm(directory, { recursive: true, force: true }) + } + }) +}) diff --git a/test/notarize-macos.test.mjs b/test/notarize-macos.test.mjs index bd34e40b7..bd80cdb2b 100644 --- a/test/notarize-macos.test.mjs +++ b/test/notarize-macos.test.mjs @@ -1,6 +1,9 @@ import { describe, expect, it, vi } from 'vitest' import { readFile } from 'node:fs/promises' import afterSign, { notarizeWithStapleRetry } from '../scripts/notarize-macos.mjs' +import { verifyMacOSMicrophonePermissions } from '../scripts/verify-macos-permissions.mjs' + +vi.mock('../scripts/verify-macos-permissions.mjs', () => ({ verifyMacOSMicrophonePermissions: vi.fn() })) const options = { appPath: '/fixture/DSH Desktop.app' } const stapleError = new Error('Failed to staple your application with code: 68\nThe network connection was lost.') @@ -135,6 +138,21 @@ describe('macOS app notarization', () => { } }) + it('rejects a signed bundle missing microphone permissions before Apple submission', async () => { + const saved = { ...process.env } + try { + process.env.APPLE_API_KEY = '/fixture/key.p8' + process.env.APPLE_API_KEY_ID = 'fixture-key' + process.env.APPLE_API_ISSUER = 'fixture-issuer' + vi.mocked(verifyMacOSMicrophonePermissions).mockRejectedValueOnce(new Error('missing audio-input')) + await expect(afterSign({ + electronPlatformName: 'darwin', appOutDir: '/fixture', + packager: { appInfo: { productFilename: 'DSH Desktop' } } + })).rejects.toThrow('missing audio-input') + expect(verifyMacOSMicrophonePermissions).toHaveBeenCalledWith('/fixture/DSH Desktop.app') + } finally { process.env = saved } + }) + it('uses the explicit hook with built-in notarization disabled to avoid duplicate submissions', async () => { const pkg = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8')) expect(pkg.build.mac.notarize).toBe(false) diff --git a/test/window-permissions.test.mjs b/test/window-permissions.test.mjs new file mode 100644 index 000000000..79abfa599 --- /dev/null +++ b/test/window-permissions.test.mjs @@ -0,0 +1,171 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { canGrantWindowPermission } from '../src/main/security-policy' +import { installWindowPermissions } from '../src/main/window-permissions' + +const os = vi.hoisted(() => ({ status: vi.fn(), request: vi.fn() })) +vi.mock('electron', () => ({ systemPreferences: { + getMediaAccessStatus: os.status, askForMediaAccess: os.request +} })) + +const shellUrl = 'dsh-desktop://harness/' + +function setup(url = shellUrl) { + let check + let request + const owner = { getURL: vi.fn(() => url), isDestroyed: vi.fn(() => false) } + const session = { + setPermissionCheckHandler: vi.fn(handler => { check = handler }), + setPermissionRequestHandler: vi.fn(handler => { request = handler }) + } + installWindowPermissions(session, owner, true) + return { + owner, + session, + check: (overrides = {}) => { + const { contents = owner, permission = 'media', origin = url, ...details } = overrides + return check(contents, permission, origin, { isMainFrame: true, mediaType: 'audio', ...details }) + }, + request: async (overrides = {}) => { + const { contents = owner, permission = 'media', ...details } = overrides + const callback = vi.fn() + request(contents, permission, callback, { + requestingUrl: url, isMainFrame: true, mediaTypes: ['audio'], ...details + }) + await Promise.resolve() + expect(callback).toHaveBeenCalledTimes(1) + return callback.mock.calls[0][0] + } + } +} + +beforeEach(() => { + vi.stubGlobal('process', { ...process, platform: 'darwin' }) + os.status.mockReset().mockReturnValue('granted') + os.request.mockReset().mockResolvedValue(true) +}) +afterEach(() => vi.unstubAllGlobals()) + +describe('audio-only window permission policy', () => { + it('requires explicit audio types for media while preserving existing permissions', () => { + for (const url of [shellUrl, 'http://127.0.0.1:43127/session', 'http://localhost:43127/']) { + expect(canGrantWindowPermission('media', url, true, ['audio'])).toBe(true) + for (const types of [[], ['video'], ['audio', 'video'], ['unknown'], ['audio', 'audio']]) { + expect(canGrantWindowPermission('media', url, true, types)).toBe(false) + } + expect(canGrantWindowPermission('media', url, true)).toBe(false) + for (const permission of ['notifications', 'clipboard-sanitized-write']) { + expect(canGrantWindowPermission(permission, url, true)).toBe(true) + } + } + for (const permission of ['clipboard-read', 'display-capture', 'geolocation', 'midi', 'usb', 'serial']) { + expect(canGrantWindowPermission(permission, shellUrl, true, ['audio'])).toBe(false) + } + }) + + it('checks system authorization and requests the microphone on macOS', async () => { + const fixture = setup() + expect(fixture.check()).toBe(true) + expect(os.status).toHaveBeenCalledExactlyOnceWith('microphone') + os.status.mockReturnValue('not-determined') + expect(fixture.check()).toBe(false) + expect(await fixture.request()).toBe(true) + expect(os.request).toHaveBeenCalledExactlyOnceWith('microphone') + }) + + it('rejects other windows, subframes, untrusted URLs and other loopback ports', async () => { + const fixture = setup() + for (const overrides of [ + { contents: null }, { contents: {} }, { isMainFrame: false }, + { requestingUrl: 'https://example.com/' }, + { requestingUrl: 'file:///app/index.html' }, + { requestingUrl: 'dsh-desktop://desktop/safe-mode.html' }, + { requestingUrl: 'dsh-desktop://user:pass@harness/' }, + { requestingUrl: 'http://127.0.0.1:43127/' }, + { requestingUrl: 'invalid-url' } + ]) { + expect(fixture.check(overrides)).toBe(false) + expect(await fixture.request(overrides)).toBe(false) + } + const legacy = setup('http://127.0.0.1:43127/session') + expect(legacy.check()).toBe(true) + expect(await legacy.request()).toBe(true) + expect(legacy.check({ requestingUrl: 'http://127.0.0.1:43128/' })).toBe(false) + expect(await legacy.request({ requestingUrl: 'http://127.0.0.1:43128/' })).toBe(false) + expect(os.request).toHaveBeenCalledTimes(1) + }) + + it('rejects camera, mixed, unknown and omitted media types before asking the OS', async () => { + const fixture = setup() + for (const mediaType of ['video', 'unknown', undefined]) { + expect(fixture.check({ mediaType })).toBe(false) + } + for (const mediaTypes of [[], ['video'], ['audio', 'video'], ['unknown'], undefined]) { + expect(await fixture.request({ mediaTypes })).toBe(false) + } + expect(os.request).not.toHaveBeenCalled() + }) + + it('returns OS denial and handles failed authorization without an unhandled rejection', async () => { + const fixture = setup() + os.status.mockReturnValue('denied') + expect(fixture.check()).toBe(false) + os.request.mockResolvedValue(false) + expect(await fixture.request()).toBe(false) + const warning = vi.spyOn(console, 'warn').mockImplementation(() => {}) + try { + os.request.mockRejectedValue(new Error('permission service unavailable')) + expect(await fixture.request()).toBe(false) + expect(warning).toHaveBeenCalledWith('Microphone permission request failed', expect.any(Error)) + } finally { warning.mockRestore() } + }) + + it('does not grant a request after the owner closes or navigates during the OS prompt', async () => { + for (const change of ['close', 'navigate']) { + const fixture = setup() + os.request.mockImplementation(async () => { + if (change === 'close') fixture.owner.isDestroyed.mockReturnValue(true) + else fixture.owner.getURL.mockReturnValue('https://example.com/') + return true + }) + expect(await fixture.request()).toBe(false) + } + }) + + it('keeps the main window authorized when a dialog shares its session, and handles a recreated main window', async () => { + const fixture = setup() + const dialog = { getURL: () => shellUrl, isDestroyed: () => false } + installWindowPermissions(fixture.session, dialog) + expect(await fixture.request()).toBe(true) + expect(await fixture.request({ contents: dialog })).toBe(false) + expect(await fixture.request({ contents: dialog, permission: 'notifications' })).toBe(true) + expect(fixture.session.setPermissionRequestHandler).toHaveBeenCalledTimes(1) + expect(fixture.session.setPermissionCheckHandler).toHaveBeenCalledTimes(1) + fixture.owner.isDestroyed.mockReturnValue(true) + expect(await fixture.request()).toBe(false) + const replacement = { getURL: () => shellUrl, isDestroyed: () => false } + installWindowPermissions(fixture.session, replacement, true) + expect(await fixture.request({ contents: replacement })).toBe(true) + }) + + it('allows valid audio on Windows without calling macOS APIs', async () => { + vi.stubGlobal('process', { ...process, platform: 'win32' }) + const fixture = setup() + expect(fixture.check()).toBe(true) + expect(await fixture.request()).toBe(true) + expect(os.status).not.toHaveBeenCalled() + expect(os.request).not.toHaveBeenCalled() + }) + + it('preserves the non-media whitelist instead of copying the upstream allow-all fallback', async () => { + const fixture = setup() + for (const permission of ['notifications', 'clipboard-sanitized-write']) { + expect(fixture.check({ permission })).toBe(true) + expect(await fixture.request({ permission })).toBe(true) + } + for (const permission of ['clipboard-read', 'display-capture', 'geolocation', 'usb']) { + expect(fixture.check({ permission })).toBe(false) + expect(await fixture.request({ permission })).toBe(false) + } + expect(os.request).not.toHaveBeenCalled() + }) +}) diff --git a/vitest.windows.config.ts b/vitest.windows.config.ts index e3b4e2aa7..c9da3ccff 100644 --- a/vitest.windows.config.ts +++ b/vitest.windows.config.ts @@ -22,6 +22,7 @@ export default defineConfig({ 'test/office-templates.test.mjs', 'test/node-pty-*.test.{ts,js,mjs}', 'test/runtime.test.ts', + 'test/window-permissions.test.mjs', 'test/harness-node-entry.test.ts', 'test/launch-root.test.ts', 'test/desktop-plugin-closure.test.ts',