forked from Manuel/meeting-assistant
fix(macos): launch service with stable privacy identity
This commit is contained in:
@@ -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()
|
||||
{
|
||||
|
||||
@@ -30,6 +30,10 @@
|
||||
<MacOsMeetingIntegrationsSource>$(MSBuildProjectDirectory)/Native/MacOsMeetingIntegrations/main.swift</MacOsMeetingIntegrationsSource>
|
||||
<MacOsMeetingIntegrationsInfoPlist>$(MSBuildProjectDirectory)/Native/MacOsMeetingIntegrations/Info.plist</MacOsMeetingIntegrationsInfoPlist>
|
||||
<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>
|
||||
|
||||
<ItemGroup>
|
||||
@@ -90,6 +94,26 @@
|
||||
<Exec Command="/usr/bin/codesign --force --deep --sign - "$(TargetDir)$(MacOsAudioCaptureBundlePath)"" />
|
||||
<Exec Command="/usr/bin/xcrun swiftc -parse-as-library -O -target $(MacOsAudioCaptureArchitecture)-apple-macos13.0 -framework AppKit -framework Carbon -framework WebKit "$(MacOsDesktopControlsSource)" -o "$(TargetDir)$(MacOsDesktopControlsOutputPath)"" />
|
||||
<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 "$(MacOsMeetingIntegrationsInfoPlist)" "$(MacOsMeetingIntegrationsSource)" -o "$(TargetDir)$(MacOsMeetingIntegrationsOutputPath)"" />
|
||||
<Exec Command="/usr/bin/xcrun swiftc -parse-as-library -O -target $(MacOsAudioCaptureArchitecture)-apple-macos13.0 "$(MacOsLauncherSource)" -o "$(TargetDir)$(MacOsLauncherOutputPath)"" />
|
||||
</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 => identifier "cloud.schweigert.meeting-assistant"' "$(PublishDir)$(MacOsApplicationBundlePath)"" />
|
||||
</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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user