feat(macos): add native Meeting Assistant support #39

Open
Daniel wants to merge 13 commits from Daniel/meeting-assistant:codex/macos-support into main
6 changed files with 160 additions and 0 deletions
Showing only changes of commit 0e2aa3830f - Show all commits
@@ -15,6 +15,44 @@ namespace MeetingAssistant.Tests;
public sealed class MacOsMeetingAudioSourceTests public sealed class MacOsMeetingAudioSourceTests
{ {
[Fact]
public void MacOsServiceLauncherDefinesStablePrivacyIdentity()
{
if (!OperatingSystem.IsMacOS())
{
return;
}
var projectDirectory = Path.GetFullPath(Path.Combine(
AppContext.BaseDirectory,
"..",
"..",
"..",
"..",
"MeetingAssistant"));
var launcherDirectory = Path.Combine(projectDirectory, "Native", "MacOsLauncher");
var infoPlistPath = Path.Combine(launcherDirectory, "Info.plist");
var launcherSourcePath = Path.Combine(launcherDirectory, "main.swift");
Assert.True(File.Exists(infoPlistPath), $"Expected launcher Info.plist at '{infoPlistPath}'.");
Assert.True(File.Exists(launcherSourcePath), $"Expected launcher source at '{launcherSourcePath}'.");
var infoPlist = File.ReadAllText(infoPlistPath);
Assert.Contains("cloud.schweigert.meeting-assistant", infoPlist, StringComparison.Ordinal);
Assert.Contains("NSMicrophoneUsageDescription", infoPlist, StringComparison.Ordinal);
Assert.Contains("NSCalendarsFullAccessUsageDescription", infoPlist, StringComparison.Ordinal);
var launcherSource = File.ReadAllText(launcherSourcePath);
Assert.Contains("set -a", launcherSource, StringComparison.Ordinal);
Assert.Contains("Contents/Resources/app", launcherSource, StringComparison.Ordinal);
var projectFile = File.ReadAllText(Path.Combine(projectDirectory, "MeetingAssistant.csproj"));
Assert.Contains(
"designated => identifier "cloud.schweigert.meeting-assistant"",
projectFile,
StringComparison.Ordinal);
}
[Fact] [Fact]
public void NativeAudioCaptureIsPackagedAsSignedMacOsAppForPersistentPrivacyGrant() public void NativeAudioCaptureIsPackagedAsSignedMacOsAppForPersistentPrivacyGrant()
{ {
+24
View File
@@ -30,6 +30,10 @@
<MacOsMeetingIntegrationsSource>$(MSBuildProjectDirectory)/Native/MacOsMeetingIntegrations/main.swift</MacOsMeetingIntegrationsSource> <MacOsMeetingIntegrationsSource>$(MSBuildProjectDirectory)/Native/MacOsMeetingIntegrations/main.swift</MacOsMeetingIntegrationsSource>
<MacOsMeetingIntegrationsInfoPlist>$(MSBuildProjectDirectory)/Native/MacOsMeetingIntegrations/Info.plist</MacOsMeetingIntegrationsInfoPlist> <MacOsMeetingIntegrationsInfoPlist>$(MSBuildProjectDirectory)/Native/MacOsMeetingIntegrations/Info.plist</MacOsMeetingIntegrationsInfoPlist>
<MacOsMeetingIntegrationsOutputPath>Native/macos-meeting-integrations</MacOsMeetingIntegrationsOutputPath> <MacOsMeetingIntegrationsOutputPath>Native/macos-meeting-integrations</MacOsMeetingIntegrationsOutputPath>
<MacOsLauncherSource>$(MSBuildProjectDirectory)/Native/MacOsLauncher/main.swift</MacOsLauncherSource>
<MacOsLauncherInfoPlist>$(MSBuildProjectDirectory)/Native/MacOsLauncher/Info.plist</MacOsLauncherInfoPlist>
<MacOsLauncherOutputPath>Native/macos-meeting-assistant-launcher</MacOsLauncherOutputPath>
<MacOsApplicationBundlePath>MeetingAssistant.app</MacOsApplicationBundlePath>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
@@ -90,6 +94,26 @@
<Exec Command="/usr/bin/codesign --force --deep --sign - &quot;$(TargetDir)$(MacOsAudioCaptureBundlePath)&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 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;" /> <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;" />
<Exec Command="/usr/bin/xcrun swiftc -parse-as-library -O -target $(MacOsAudioCaptureArchitecture)-apple-macos13.0 &quot;$(MacOsLauncherSource)&quot; -o &quot;$(TargetDir)$(MacOsLauncherOutputPath)&quot;" />
</Target>
<Target
Name="PackageMacOsApplication"
AfterTargets="Publish"
DependsOnTargets="CopyMeetingAssistantConfigToPublishRoot;CopyMacOsNativeHelpersToPublish"
Condition="'$(MacOsNativeHelpersEnabled)' == 'true'">
<RemoveDir Directories="$(PublishDir)$(MacOsApplicationBundlePath)" />
<MakeDir Directories="$(PublishDir)$(MacOsApplicationBundlePath)/Contents/MacOS" />
<MakeDir Directories="$(PublishDir)$(MacOsApplicationBundlePath)/Contents/Resources/app" />
<ItemGroup>
<MacOsPublishedRuntimeFiles Include="$(PublishDir)**/*" Exclude="$(PublishDir)$(MacOsApplicationBundlePath)/**/*" />
</ItemGroup>
<Copy
SourceFiles="@(MacOsPublishedRuntimeFiles)"
DestinationFiles="@(MacOsPublishedRuntimeFiles->'$(PublishDir)$(MacOsApplicationBundlePath)/Contents/Resources/app/%(RecursiveDir)%(Filename)%(Extension)')" />
<Copy SourceFiles="$(TargetDir)$(MacOsLauncherOutputPath)" DestinationFiles="$(PublishDir)$(MacOsApplicationBundlePath)/Contents/MacOS/MeetingAssistant" />
<Copy SourceFiles="$(MacOsLauncherInfoPlist)" DestinationFiles="$(PublishDir)$(MacOsApplicationBundlePath)/Contents/Info.plist" />
<Exec Command="/usr/bin/codesign --force --deep --sign - --requirements '=designated =&gt; identifier &quot;cloud.schweigert.meeting-assistant&quot;' &quot;$(PublishDir)$(MacOsApplicationBundlePath)&quot;" />
</Target> </Target>
<Target <Target
@@ -0,0 +1,24 @@
<?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>
<key>CFBundleExecutable</key>
<string>MeetingAssistant</string>
<key>CFBundleIdentifier</key>
<string>cloud.schweigert.meeting-assistant</string>
<key>CFBundleName</key>
<string>Meeting Assistant</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>NSCalendarsFullAccessUsageDescription</key>
<string>Meeting Assistant reads calendar metadata for meeting notes and recording prompts.</string>
<key>NSMicrophoneUsageDescription</key>
<string>Meeting Assistant records microphone audio for live meeting transcription.</string>
</dict>
</plist>
@@ -0,0 +1,70 @@
import Darwin
import Dispatch
import Foundation
@main
private struct MeetingAssistantLauncher {
private static let serviceDirectoryName = "MeetingAssistant"
static func main() {
let runtimeDirectory = Bundle.main.bundleURL
.appendingPathComponent("Contents/Resources/app", isDirectory: true)
let assemblyPath = runtimeDirectory.appendingPathComponent("MeetingAssistant.dll").path
let environmentFile = FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent("Library/Application Support")
.appendingPathComponent(serviceDirectoryName)
.appendingPathComponent("config/meeting-assistant.env")
guard FileManager.default.fileExists(atPath: assemblyPath) else {
fail("Meeting Assistant runtime not found at \(assemblyPath)")
}
guard FileManager.default.fileExists(atPath: environmentFile.path) else {
fail("Meeting Assistant environment file not found at \(environmentFile.path)")
}
let process = Process()
process.executableURL = URL(fileURLWithPath: "/bin/zsh")
process.arguments = [
"-c",
"set -a; source \"$1\"; set +a; exec /usr/local/share/dotnet/dotnet \"$2\" --contentRoot \"$3\"",
"meeting-assistant-launcher",
environmentFile.path,
assemblyPath,
runtimeDirectory.path
]
process.currentDirectoryURL = runtimeDirectory
signal(SIGTERM, SIG_IGN)
signal(SIGINT, SIG_IGN)
let terminationSignal = DispatchSource.makeSignalSource(signal: SIGTERM, queue: .global())
terminationSignal.setEventHandler {
if process.isRunning {
process.terminate()
}
}
terminationSignal.resume()
let interruptSignal = DispatchSource.makeSignalSource(signal: SIGINT, queue: .global())
interruptSignal.setEventHandler {
if process.isRunning {
kill(process.processIdentifier, SIGINT)
}
}
interruptSignal.resume()
do {
try process.run()
process.waitUntilExit()
Darwin.exit(process.terminationStatus)
} catch {
fail("Meeting Assistant launcher failed: \(error)")
}
}
private static func fail(_ message: String) -> Never {
FileHandle.standardError.write(Data("\(message)\n".utf8))
Darwin.exit(1)
}
}
+2
View File
@@ -157,6 +157,8 @@ The Windows tray and macOS menu-bar menus expose `Open agent`, which opens the `
At startup, Meeting Assistant detects whether the host is Windows or macOS and includes that immutable runtime context in the interactive settings/logs assistant instructions. On Windows the assistant uses Windows commands and concepts such as PowerShell, Windows paths, services, and Task Manager. On macOS it uses zsh, POSIX paths, launchd/LaunchAgents, and Activity Monitor. This guidance is also appended when a custom interactive-agent prompt is configured. macOS audio capture, menu-bar controls, global hotkeys, EventKit calendar enrichment/prompts, active-window screenshots, screenshot OCR, speaker identification, FunASR, local diarization, and the AppKit/WebKit workflow editor are available in the portable build. Outlook Classic COM and Windows toast notifications remain Windows-specific implementations. At startup, Meeting Assistant detects whether the host is Windows or macOS and includes that immutable runtime context in the interactive settings/logs assistant instructions. On Windows the assistant uses Windows commands and concepts such as PowerShell, Windows paths, services, and Task Manager. On macOS it uses zsh, POSIX paths, launchd/LaunchAgents, and Activity Monitor. This guidance is also appended when a custom interactive-agent prompt is configured. macOS audio capture, menu-bar controls, global hotkeys, EventKit calendar enrichment/prompts, active-window screenshots, screenshot OCR, speaker identification, FunASR, local diarization, and the AppKit/WebKit workflow editor are available in the portable build. Outlook Classic COM and Windows toast notifications remain Windows-specific implementations.
The macOS publish output includes a signed `MeetingAssistant.app` bundle. Install that bundle in `/Applications` and launch the background service through its native executable so macOS microphone, calendar, and Screen/System Audio privacy grants are attributed to the stable Meeting Assistant application identity.
Detailed workflow syntax and extension guidance live in `docs/meeting-workflow-engine.md`. Detailed workflow syntax and extension guidance live in `docs/meeting-workflow-engine.md`.
## Development And CI ## Development And CI
+2
View File
@@ -45,6 +45,8 @@ On macOS, Meeting Assistant SHALL capture the default microphone through AVFound
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 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 background service SHALL be launched through a signed application bundle with the stable bundle identifier `cloud.schweigert.meeting-assistant` plus microphone and calendar usage descriptions, with the managed runtime nested under that bundle so macOS attributes privacy-sensitive child processes to the Meeting Assistant application identity. The bundle SHALL use a stable designated code requirement for that identifier so routine deployments remain compatible with its macOS privacy grant.
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 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. The macOS capture adapter SHALL stop its native capture process when the recording capture token is cancelled.