Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions build/entitlements.mac.plist
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- Preserve electron-builder's existing runtime/native-module signing requirements. -->
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
<key>com.apple.security.cs.disable-library-validation</key>
<true/>
<!-- Hardened Runtime requires this in addition to the macOS microphone prompt. -->
<key>com.apple.security.device.audio-input</key>
<true/>
</dict>
</plist>
13 changes: 13 additions & 0 deletions docs/release-runbook.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
5 changes: 5 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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)$",
Expand Down
2 changes: 2 additions & 0 deletions scripts/notarize-macos.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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 })
}
26 changes: 26 additions & 0 deletions scripts/verify-macos-permissions.mjs
Original file line number Diff line number Diff line change
@@ -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 (!/<key>com\.apple\.security\.device\.audio-input<\/key>\s*<true\s*\/>/u.test(xml)) {
throw new Error('macOS signed bundle is missing audio-input entitlement: ' + bundle)
}
}
}
2 changes: 1 addition & 1 deletion src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
5 changes: 3 additions & 2 deletions src/main/security-policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
21 changes: 4 additions & 17 deletions src/main/security.ts
Original file line number Diff line number Diff line change
@@ -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<BrowserWindow, 'webContents'>): void {
export function secureWindow(window: Pick<BrowserWindow, 'webContents'>, allowMicrophone = false): void {
window.webContents.setWindowOpenHandler(({ url }) => {
if (isTrustedAppUrl(url)) return { action: 'allow' }
if (url.startsWith('https://') || url.startsWith('http://')) void shell.openExternal(url)
Expand All @@ -15,19 +16,5 @@ export function secureWindow(window: Pick<BrowserWindow, 'webContents'>): 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)
}
57 changes: 57 additions & 0 deletions src/main/window-permissions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { systemPreferences, type Session, type WebContents } from 'electron'
import { canGrantWindowPermission } from './security-policy'

type PermissionSession = Pick<Session, 'setPermissionCheckHandler' | 'setPermissionRequestHandler'>
type PermissionOwner = Pick<WebContents, 'getURL' | 'isDestroyed'>
const sessionWindows = new WeakMap<PermissionSession, WeakMap<PermissionOwner, boolean>>()

/** 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<PermissionOwner, boolean>()
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)
}
)
})
}
98 changes: 98 additions & 0 deletions test/macos-permissions.test.mjs
Original file line number Diff line number Diff line change
@@ -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 = '<?xml version="1.0"?><plist version="1.0"><dict><key>com.apple.security.device.audio-input</key><true/></dict></plist>'

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('<key>com.apple.security.' + entitlement + '</key>')
}
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('<true/>', '<false/>') : 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'),
'<?xml version="1.0"?><plist version="1.0"><dict>'
+ '<key>CFBundleExecutable</key><string>probe</string><key>CFBundleIdentifier</key><string>io.dsh.permission-probe.' + index + '</string>'
+ '<key>CFBundlePackageType</key><string>APPL</string><key>CFBundleVersion</key><string>1</string>'
+ '<key>NSMicrophoneUsageDescription</key><string>' + mac.extendInfo.NSMicrophoneUsageDescription + '</string></dict></plist>')
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, '<?xml version="1.0"?><plist version="1.0"><dict/></plist>')
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 })
}
})
})
Loading
Loading