diff --git a/MeetingAssistant.Tests/MacOsMeetingAudioSourceTests.cs b/MeetingAssistant.Tests/MacOsMeetingAudioSourceTests.cs index 2d6cbb7..38ac124 100644 --- a/MeetingAssistant.Tests/MacOsMeetingAudioSourceTests.cs +++ b/MeetingAssistant.Tests/MacOsMeetingAudioSourceTests.cs @@ -15,6 +15,44 @@ namespace MeetingAssistant.Tests; 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] public void NativeAudioCaptureIsPackagedAsSignedMacOsAppForPersistentPrivacyGrant() { diff --git a/MeetingAssistant/MeetingAssistant.csproj b/MeetingAssistant/MeetingAssistant.csproj index 2eb4c0c..792e04f 100644 --- a/MeetingAssistant/MeetingAssistant.csproj +++ b/MeetingAssistant/MeetingAssistant.csproj @@ -30,6 +30,10 @@ $(MSBuildProjectDirectory)/Native/MacOsMeetingIntegrations/main.swift $(MSBuildProjectDirectory)/Native/MacOsMeetingIntegrations/Info.plist Native/macos-meeting-integrations + $(MSBuildProjectDirectory)/Native/MacOsLauncher/main.swift + $(MSBuildProjectDirectory)/Native/MacOsLauncher/Info.plist + Native/macos-meeting-assistant-launcher + MeetingAssistant.app @@ -90,6 +94,26 @@ + + + + + + + + + + + + + + + + + + CFBundleExecutable + MeetingAssistant + CFBundleIdentifier + cloud.schweigert.meeting-assistant + CFBundleName + Meeting Assistant + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + LSUIElement + + NSCalendarsFullAccessUsageDescription + Meeting Assistant reads calendar metadata for meeting notes and recording prompts. + NSMicrophoneUsageDescription + Meeting Assistant records microphone audio for live meeting transcription. + + diff --git a/MeetingAssistant/Native/MacOsLauncher/main.swift b/MeetingAssistant/Native/MacOsLauncher/main.swift new file mode 100644 index 0000000..51f5476 --- /dev/null +++ b/MeetingAssistant/Native/MacOsLauncher/main.swift @@ -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) + } +} diff --git a/README.md b/README.md index 51d2fed..c1d5915 100644 --- a/README.md +++ b/README.md @@ -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. +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`. ## Development And CI diff --git a/openspec/specs/meeting-recording/spec.md b/openspec/specs/meeting-recording/spec.md index c7b222e..8492aa4 100644 --- a/openspec/specs/meeting-recording/spec.md +++ b/openspec/specs/meeting-recording/spec.md @@ -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 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 stop its native capture process when the recording capture token is cancelled.