diff --git a/.github/workflows/desktop-release.yml b/.github/workflows/desktop-release.yml index b70e80b62dd..8e50e2ce9f0 100644 --- a/.github/workflows/desktop-release.yml +++ b/.github/workflows/desktop-release.yml @@ -347,7 +347,7 @@ jobs: find "$rg_dir" -type f -name 'rg' -path '*-darwin/*' -exec \ codesign --force --sign "$APPLE_SIGNING_IDENTITY" \ --options runtime --timestamp \ - --entitlements src-tauri/Entitlements.plist {} + + {} + else echo "::warning::Ripgrep vendor directory not found at $rg_dir; no ripgrep binaries signed." fi @@ -356,7 +356,7 @@ jobs: if [ -f "$node_bin" ]; then codesign --force --sign "$APPLE_SIGNING_IDENTITY" \ --options runtime --timestamp \ - --entitlements src-tauri/Entitlements.plist "$node_bin" + --entitlements src-tauri/NodeEntitlements.plist "$node_bin" else echo "::warning::Node.js runtime binary not found at $node_bin; no Node.js binary signed." fi @@ -387,6 +387,10 @@ jobs: app="$(find packages/desktop-shell/src-tauri/target/${{ matrix.rust_target }}/release/bundle/macos -maxdepth 1 -name '*.app' -print -quit)" codesign --verify --deep --strict --verbose=2 "$app" spctl --assess --type execute --verbose=2 "$app" + entitlements="$(mktemp)" + trap 'rm -f "$entitlements"' EXIT + codesign -d --entitlements - --xml "$app" > "$entitlements" 2>/dev/null + test "$(/usr/libexec/PlistBuddy -c 'Print :com.apple.security.device.audio-input' "$entitlements")" = true - name: 'Verify Windows signature' if: "runner.os == 'Windows' && inputs.dry_run == false" @@ -427,6 +431,8 @@ jobs: run: | set -euo pipefail executable="$(find src-tauri/target/${{ matrix.rust_target }}/release/bundle/macos -path '*.app/Contents/MacOS/*' -type f -perm -111 -print -quit)" + info_plist="$(dirname "$(dirname "$executable")")/Info.plist" + /usr/libexec/PlistBuddy -c 'Print :NSMicrophoneUsageDescription' "$info_plist" >/dev/null npm run smoke:packaged -- "$executable" - name: 'Smoke packaged application' diff --git a/packages/desktop-shell/scripts/test-release.js b/packages/desktop-shell/scripts/test-release.js index e250137a963..26dd4590738 100755 --- a/packages/desktop-shell/scripts/test-release.js +++ b/packages/desktop-shell/scripts/test-release.js @@ -111,10 +111,61 @@ function testDesktopReleaseSigningWorkflow() { ), 'Unsigned Windows installers are only allowed when no signing config exists', ); + const ripgrepStart = workflow.indexOf('# ripgrep vendor binaries'); + const ripgrepEnd = workflow.indexOf('# Node.js runtime binary'); assert.ok( - workflow.includes('--entitlements src-tauri/Entitlements.plist {} +'), + ripgrepStart !== -1 && ripgrepEnd > ripgrepStart, + 'the vendor signing step must keep its ripgrep/Node section markers', + ); + const ripgrepSigningBlock = workflow.slice(ripgrepStart, ripgrepEnd); + assert.doesNotMatch( + ripgrepSigningBlock, + /--entitlements/, + 'ripgrep must not inherit the app entitlements', + ); + assert.match( + workflow, + /--options runtime --timestamp \\\n\s+\{\} \+/, 'ripgrep codesign failures must fail the signing step', ); + assert.ok( + workflow.includes( + '--entitlements src-tauri/NodeEntitlements.plist "$node_bin"', + ), + 'Node.js must use its minimal helper entitlements', + ); + const nodeEntitlements = fs.readFileSync( + path.join(packageDir, 'src-tauri', 'NodeEntitlements.plist'), + 'utf8', + ); + const appEntitlements = fs.readFileSync( + path.join(packageDir, 'src-tauri', 'Entitlements.plist'), + 'utf8', + ); + assert.match( + appEntitlements, + /com\.apple\.security\.device\.audio-input<\/key>\s*/, + 'the app bundle must keep microphone access for voice dictation', + ); + const infoPlist = fs.readFileSync( + path.join(packageDir, 'src-tauri', 'Info.plist'), + 'utf8', + ); + assert.match( + infoPlist, + /NSMicrophoneUsageDescription<\/key>\s*.+<\/string>/, + 'the app bundle must declare a non-empty microphone usage description', + ); + assert.match( + nodeEntitlements, + /com\.apple\.security\.cs\.allow-jit<\/key>\s*/, + 'the bundled Node.js runtime must keep its JIT entitlement', + ); + assert.doesNotMatch( + nodeEntitlements, + /com\.apple\.security\.device\.audio-input/, + 'Node.js must not receive microphone access', + ); assert.match( workflow, /Ripgrep vendor directory not found at \$rg_dir/, @@ -125,6 +176,16 @@ function testDesktopReleaseSigningWorkflow() { /Node\.js runtime binary not found at \$node_bin/, 'missing Node.js runtime binary must be visible in release logs', ); + assert.match( + workflow, + /Print :com\.apple\.security\.device\.audio-input/, + 'the macOS signature check must keep verifying the audio-input entitlement', + ); + assert.match( + workflow, + /Print :NSMicrophoneUsageDescription/, + 'the packaged smoke must keep verifying the microphone usage description', + ); assert.ok( workflow.indexOf("name: 'Prepare bundled runtime'") < workflow.indexOf("name: 'Sign bundled vendor binaries (macOS)'"), diff --git a/packages/desktop-shell/src-tauri/Entitlements.plist b/packages/desktop-shell/src-tauri/Entitlements.plist index b49b6618def..af81c06a43a 100644 --- a/packages/desktop-shell/src-tauri/Entitlements.plist +++ b/packages/desktop-shell/src-tauri/Entitlements.plist @@ -4,6 +4,8 @@ com.apple.security.cs.allow-jit + com.apple.security.device.audio-input + com.apple.security.network.client com.apple.security.network.server diff --git a/packages/desktop-shell/src-tauri/Info.plist b/packages/desktop-shell/src-tauri/Info.plist new file mode 100644 index 00000000000..5e0d6613fa5 --- /dev/null +++ b/packages/desktop-shell/src-tauri/Info.plist @@ -0,0 +1,8 @@ + + + + + NSMicrophoneUsageDescription + Qwen Code uses the microphone for voice dictation in the prompt composer. + + diff --git a/packages/desktop-shell/src-tauri/NodeEntitlements.plist b/packages/desktop-shell/src-tauri/NodeEntitlements.plist new file mode 100644 index 00000000000..86611d6bc73 --- /dev/null +++ b/packages/desktop-shell/src-tauri/NodeEntitlements.plist @@ -0,0 +1,8 @@ + + + + + com.apple.security.cs.allow-jit + + +