fix(macos): bundle audio capture for launchd privacy

This commit is contained in:
dh
2026-09-11 19:27:21 +02:00
parent 6adf6f726b
commit d77187e9ec
5 changed files with 65 additions and 15 deletions
@@ -16,7 +16,7 @@ namespace MeetingAssistant.Tests;
public sealed class MacOsMeetingAudioSourceTests
{
[Fact]
public void NativeMicrophoneHelperDeclaresMacOsPrivacyMetadata()
public void NativeAudioCaptureIsPackagedAsSignedMacOsAppForPersistentPrivacyGrant()
{
if (!OperatingSystem.IsMacOS())
{
@@ -25,7 +25,7 @@ public sealed class MacOsMeetingAudioSourceTests
var configuration = new DirectoryInfo(AppContext.BaseDirectory)
.Parent?.Name ?? "Debug";
var helperPath = Path.GetFullPath(Path.Combine(
var appPath = Path.GetFullPath(Path.Combine(
AppContext.BaseDirectory,
"..",
"..",
@@ -36,11 +36,30 @@ public sealed class MacOsMeetingAudioSourceTests
configuration,
"net10.0",
"Native",
"macos-meeting-audio-capture"));
var helperImage = System.Text.Encoding.UTF8.GetString(File.ReadAllBytes(helperPath));
"MeetingAssistantAudioCapture.app"));
var infoPlistPath = Path.Combine(appPath, "Contents", "Info.plist");
var helperPath = Path.Combine(appPath, "Contents", "MacOS", "macos-meeting-audio-capture");
Assert.Contains("cloud.schweigert.meeting-assistant.audio-capture", helperImage, StringComparison.Ordinal);
Assert.Contains("NSMicrophoneUsageDescription", helperImage, StringComparison.Ordinal);
Assert.True(Directory.Exists(appPath), $"Expected macOS audio capture app at '{appPath}'.");
Assert.True(File.Exists(infoPlistPath), $"Expected Info.plist at '{infoPlistPath}'.");
Assert.True(File.Exists(helperPath), $"Expected native helper at '{helperPath}'.");
var infoPlist = File.ReadAllText(infoPlistPath);
Assert.Contains("cloud.schweigert.meeting-assistant.audio-capture", infoPlist, StringComparison.Ordinal);
Assert.Contains("NSMicrophoneUsageDescription", infoPlist, StringComparison.Ordinal);
using var verification = System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo
{
FileName = "/usr/bin/codesign",
ArgumentList = { "--verify", "--deep", "--strict", appPath },
RedirectStandardError = true,
UseShellExecute = false
});
Assert.NotNull(verification);
verification.WaitForExit();
Assert.True(
verification.ExitCode == 0,
$"Expected a valid app-bundle signature: {verification.StandardError.ReadToEnd()}");
}
[Fact]
+9 -2
View File
@@ -19,7 +19,8 @@
<MacOsNativeHelpersEnabled>true</MacOsNativeHelpersEnabled>
<MacOsAudioCaptureSource>$(MSBuildProjectDirectory)/Native/MacOsMeetingAudioCapture/main.swift</MacOsAudioCaptureSource>
<MacOsAudioCaptureInfoPlist>$(MSBuildProjectDirectory)/Native/MacOsMeetingAudioCapture/Info.plist</MacOsAudioCaptureInfoPlist>
<MacOsAudioCaptureOutputPath>Native/macos-meeting-audio-capture</MacOsAudioCaptureOutputPath>
<MacOsAudioCaptureBundlePath>Native/MeetingAssistantAudioCapture.app</MacOsAudioCaptureBundlePath>
<MacOsAudioCaptureOutputPath>$(MacOsAudioCaptureBundlePath)/Contents/MacOS/macos-meeting-audio-capture</MacOsAudioCaptureOutputPath>
<MacOsAudioCaptureRid Condition="'$(RuntimeIdentifier)' != ''">$(RuntimeIdentifier)</MacOsAudioCaptureRid>
<MacOsAudioCaptureRid Condition="'$(MacOsAudioCaptureRid)' == ''">$(NETCoreSdkRuntimeIdentifier)</MacOsAudioCaptureRid>
<MacOsAudioCaptureArchitecture Condition="'$(MacOsAudioCaptureRid)' == 'osx-x64'">x86_64</MacOsAudioCaptureArchitecture>
@@ -83,7 +84,10 @@
AfterTargets="Build"
Condition="'$(MacOsNativeHelpersEnabled)' == 'true'">
<MakeDir Directories="$(TargetDir)Native" />
<MakeDir Directories="$(TargetDir)$(MacOsAudioCaptureBundlePath)/Contents/MacOS" />
<Copy SourceFiles="$(MacOsAudioCaptureInfoPlist)" DestinationFiles="$(TargetDir)$(MacOsAudioCaptureBundlePath)/Contents/Info.plist" />
<Exec Command="/usr/bin/xcrun swiftc -parse-as-library -O -target $(MacOsAudioCaptureArchitecture)-apple-macos13.0 -framework AVFoundation -framework CoreMedia -framework ScreenCaptureKit -Xlinker -sectcreate -Xlinker __TEXT -Xlinker __info_plist -Xlinker &quot;$(MacOsAudioCaptureInfoPlist)&quot; &quot;$(MacOsAudioCaptureSource)&quot; -o &quot;$(TargetDir)$(MacOsAudioCaptureOutputPath)&quot;" />
<Exec Command="/usr/bin/codesign --force --deep --sign - &quot;$(TargetDir)$(MacOsAudioCaptureBundlePath)&quot;" />
<Exec Command="/usr/bin/xcrun swiftc -parse-as-library -O -target $(MacOsAudioCaptureArchitecture)-apple-macos13.0 -framework AppKit -framework Carbon -framework WebKit &quot;$(MacOsDesktopControlsSource)&quot; -o &quot;$(TargetDir)$(MacOsDesktopControlsOutputPath)&quot;" />
<Exec Command="/usr/bin/xcrun swiftc -parse-as-library -O -target $(MacOsAudioCaptureArchitecture)-apple-macos13.0 -framework AppKit -framework CoreGraphics -framework EventKit -framework ImageIO -framework UniformTypeIdentifiers -Xlinker -sectcreate -Xlinker __TEXT -Xlinker __info_plist -Xlinker &quot;$(MacOsMeetingIntegrationsInfoPlist)&quot; &quot;$(MacOsMeetingIntegrationsSource)&quot; -o &quot;$(TargetDir)$(MacOsMeetingIntegrationsOutputPath)&quot;" />
</Target>
@@ -93,7 +97,10 @@
AfterTargets="Publish"
Condition="'$(MacOsNativeHelpersEnabled)' == 'true'">
<MakeDir Directories="$(PublishDir)Native" />
<Copy SourceFiles="$(TargetDir)$(MacOsAudioCaptureOutputPath)" DestinationFolder="$(PublishDir)Native" />
<MakeDir Directories="$(PublishDir)$(MacOsAudioCaptureBundlePath)/Contents/MacOS" />
<Copy SourceFiles="$(TargetDir)$(MacOsAudioCaptureOutputPath)" DestinationFiles="$(PublishDir)$(MacOsAudioCaptureOutputPath)" />
<Copy SourceFiles="$(MacOsAudioCaptureInfoPlist)" DestinationFiles="$(PublishDir)$(MacOsAudioCaptureBundlePath)/Contents/Info.plist" />
<Exec Command="/usr/bin/codesign --force --deep --sign - &quot;$(PublishDir)$(MacOsAudioCaptureBundlePath)&quot;" />
<Copy SourceFiles="$(TargetDir)$(MacOsDesktopControlsOutputPath)" DestinationFolder="$(PublishDir)Native" />
<Copy SourceFiles="$(TargetDir)$(MacOsMeetingIntegrationsOutputPath)" DestinationFolder="$(PublishDir)Native" />
</Target>
@@ -2,10 +2,20 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleExecutable</key>
<string>macos-meeting-audio-capture</string>
<key>CFBundleIdentifier</key>
<string>cloud.schweigert.meeting-assistant.audio-capture</string>
<key>CFBundleName</key>
<string>Meeting Assistant Audio Capture</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>LSUIElement</key>
<true/>
<key>NSMicrophoneUsageDescription</key>
<string>Meeting Assistant records microphone audio for live meeting transcription.</string>
</dict>
@@ -199,7 +199,13 @@ internal sealed class MacOsAudioCaptureProcessFactory : IMacOsAudioCaptureProces
throw new PlatformNotSupportedException("The macOS audio capture helper can only run on macOS.");
}
var helperPath = Path.Combine(AppContext.BaseDirectory, "Native", "macos-meeting-audio-capture");
var helperPath = Path.Combine(
AppContext.BaseDirectory,
"Native",
"MeetingAssistantAudioCapture.app",
"Contents",
"MacOS",
"macos-meeting-audio-capture");
if (!File.Exists(helperPath))
{
throw new FileNotFoundException(
+8
View File
@@ -43,6 +43,8 @@ On Windows, Meeting Assistant SHALL retain the existing NAudio microphone and WA
On macOS, Meeting Assistant SHALL capture the default microphone through AVFoundation and computer output through ScreenCaptureKit without requiring a virtual audio device.
The macOS native audio helper SHALL be packaged and launched from a signed application bundle with the stable bundle identifier `cloud.schweigert.meeting-assistant.audio-capture` and a microphone usage description so macOS privacy grants apply to background LaunchAgent capture and persist across deployments.
The macOS capture adapter SHALL convert both native sources to signed 16-bit PCM using the active run's configured sample rate and channel count before passing chunks to the existing managed mixing pipeline.
The macOS capture adapter SHALL stop its native capture process when the recording capture token is cancelled.
@@ -154,6 +156,12 @@ When no runtime microphone override is selected, the checked microphone SHALL be
- **AND** captures computer output through ScreenCaptureKit
- **AND** passes both signed 16-bit PCM streams through the existing mixer
#### Scenario: macOS background capture uses a stable privacy identity
- **GIVEN** Meeting Assistant runs as a macOS LaunchAgent
- **WHEN** it starts the native audio helper
- **THEN** it launches the executable from the signed Meeting Assistant audio-capture application bundle
- **AND** macOS evaluates Microphone and Screen Recording/System Audio access against the bundle identifier `cloud.schweigert.meeting-assistant.audio-capture`
#### Scenario: macOS capture uses run audio format
- **GIVEN** an active macOS recording configures a sample rate and channel count
- **WHEN** the native capture helpers start