Author SHA1 Message Date
dh bf6e1e7560 Merge remote-tracking branch 'refs/remotes/fork/codex/macos-support' into codex/macos-support
# Conflicts:
#	MeetingAssistant/Program.cs
#	README.md
2026-09-20 12:44:03 +02:00
dh d1d4b53ee8 ci: validate portable tests on macos 2026-09-16 08:30:56 +02:00
dh 0e2aa3830f fix(macos): launch service with stable privacy identity 2026-09-11 20:59:56 +02:00
dh d77187e9ec fix(macos): bundle audio capture for launchd privacy 2026-09-11 19:27:21 +02:00
dh 6adf6f726b Merge remote-tracking branch 'origin/main' into codex/macos-support
# Conflicts:
#	MeetingAssistant/Program.cs
#	README.md
#	docs/meeting-assistant-configuration.md
2026-09-11 19:16:53 +02:00
15 changed files with 327 additions and 39 deletions
@@ -90,3 +90,61 @@ jobs:
run: |
find MeetingAssistant.Tests -type d -name TestResults -print || true
find MeetingAssistant.Tests -type f -path "*/TestResults/*" -maxdepth 5 -print || true
macos-build-and-test:
runs-on: macos
env:
DOTNET_SKIP_FIRST_TIME_EXPERIENCE: "1"
DOTNET_NOLOGO: "1"
steps:
- name: Checkout repository
uses: actions/checkout@v7
- name: Verify native macOS runner
run: |
if [ "$(uname -s)" != "Darwin" ]; then
echo "The macos job requires a native Gitea runner registered with the macos:host label."
exit 1
fi
- name: Setup .NET
uses: actions/setup-dotnet@v6
with:
dotnet-version: "10.0.x"
- name: Restore (portable target)
run: |
dotnet restore MeetingAssistant.Tests/MeetingAssistant.Tests.csproj \
-p:EnableWindowsTargeting=true
- name: Build (portable target and macOS helpers)
run: |
dotnet build MeetingAssistant.Tests/MeetingAssistant.Tests.csproj \
-c Release \
-f net10.0 \
--no-restore \
-p:EnableWindowsTargeting=true \
--nologo
- name: Verify macOS native helpers
run: |
test -x MeetingAssistant/bin/Release/net10.0/Native/MeetingAssistantAudioCapture.app/Contents/MacOS/macos-meeting-audio-capture
test -x MeetingAssistant/bin/Release/net10.0/Native/macos-desktop-controls
test -x MeetingAssistant/bin/Release/net10.0/Native/macos-meeting-integrations
test -x MeetingAssistant/bin/Release/net10.0/Native/macos-meeting-assistant-launcher
- name: Run tests on macOS
run: |
dotnet test MeetingAssistant.Tests/MeetingAssistant.Tests.csproj \
-c Release \
-f net10.0 \
--no-build \
-p:EnableWindowsTargeting=true \
--nologo
- name: Show test output folder on failure
if: failure()
run: |
find MeetingAssistant.Tests -type d -name TestResults -print || true
find MeetingAssistant.Tests -type f -path "*/TestResults/*" -maxdepth 5 -print || true
@@ -110,6 +110,11 @@ public sealed class AzureSpeechStreamingTranscriptionProviderTests
[Fact]
public void ResolveKeyFallsBackToUserEnvironment()
{
if (!OperatingSystem.IsWindows())
{
return;
}
var keyEnv = $"MEETING_ASSISTANT_AZURE_KEY_{Guid.NewGuid():N}";
try
{
@@ -17,7 +17,45 @@ namespace MeetingAssistant.Tests;
public sealed class MacOsMeetingAudioSourceTests
{
[Fact]
public void NativeMicrophoneHelperDeclaresMacOsPrivacyMetadata()
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()
{
if (!OperatingSystem.IsMacOS())
{
@@ -26,7 +64,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,
"..",
"..",
@@ -37,11 +75,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]
@@ -206,7 +206,7 @@ public sealed class MeetingNoteStoreTests
{
var link = MeetingNoteActionLinks.CreateSummaryRetryLink(
"http://localhost:5090/",
"C:\\Vault\\Meetings\\Summaries\\summary with spaces.md");
Path.Combine(Path.GetTempPath(), "summary with spaces.md"));
Assert.Equal(
"[Retry summary generation](http://localhost:5090/meetings/summary/retry?summaryPath=summary%20with%20spaces.md)",
@@ -448,9 +448,14 @@ public sealed class RecordingCoordinatorTests
[Fact]
public async Task StartCreatesMeetingNoteLinkedToTranscriptAndOpensIt()
{
var vaultRoot = Path.Combine(Path.GetTempPath(), "meeting-assistant-vault");
var transcriptPath = Path.Combine(vaultRoot, "Meetings", "Transcripts", "20260519-transcript.md");
var meetingNotePath = Path.Combine(vaultRoot, "Meetings", "Notes", "20260519-meeting.md");
var assistantContextFolder = Path.Combine(vaultRoot, "Meetings", "Assistant Context");
var summariesFolder = Path.Combine(vaultRoot, "Meetings", "Summaries");
var audioSource = new ControlledAudioSource();
var transcriptStore = new InMemoryTranscriptStore("C:\\Vault\\Meetings\\Transcripts\\20260519-transcript.md");
var noteStore = new InMemoryMeetingNoteStore("C:\\Vault\\Meetings\\Notes\\20260519-meeting.md");
var transcriptStore = new InMemoryTranscriptStore(transcriptPath);
var noteStore = new InMemoryMeetingNoteStore(meetingNotePath);
var clock = new ManualMeetingInactivityClock(DateTimeOffset.Parse("2026-05-19T10:03:42+02:00"));
var noteOpener = new CapturingMeetingNoteOpener();
var artifactStore = new InMemoryMeetingArtifactStore();
@@ -468,25 +473,27 @@ public sealed class RecordingCoordinatorTests
{
Vault = new VaultOptions
{
AssistantContextFolder = "C:\\Vault\\Meetings\\Assistant Context",
SummariesFolder = "C:\\Vault\\Meetings\\Summaries"
AssistantContextFolder = assistantContextFolder,
SummariesFolder = summariesFolder
}
}),
NullLogger<MeetingRecordingCoordinator>.Instance,
inactivityClock: clock);
var status = await coordinator.StartAsync(CancellationToken.None);
var assistantContextPath = Path.Combine(assistantContextFolder, "20260519-1003-context.md");
var summaryPath = Path.Combine(summariesFolder, "20260519-1003-summary.md");
Assert.True(status.IsRecording);
Assert.Equal("C:\\Vault\\Meetings\\Notes\\20260519-meeting.md", status.MeetingNotePath);
Assert.Equal("C:\\Vault\\Meetings\\Transcripts\\20260519-transcript.md", noteStore.SavedNote?.Frontmatter.Transcript);
Assert.Equal("C:\\Vault\\Meetings\\Assistant Context\\20260519-1003-context.md", noteStore.SavedNote?.Frontmatter.AssistantContext);
Assert.Equal("C:\\Vault\\Meetings\\Summaries\\20260519-1003-summary.md", noteStore.SavedNote?.Frontmatter.Summary);
Assert.Equal("C:\\Vault\\Meetings\\Notes\\20260519-meeting.md", noteOpener.OpenedPath);
Assert.Equal("C:\\Vault\\Meetings\\Notes\\20260519-meeting.md", artifactStore.CreatedArtifacts?.MeetingNotePath);
Assert.Equal("C:\\Vault\\Meetings\\Transcripts\\20260519-transcript.md", artifactStore.CreatedArtifacts?.TranscriptPath);
Assert.Equal("C:\\Vault\\Meetings\\Assistant Context\\20260519-1003-context.md", artifactStore.CreatedArtifacts?.AssistantContextPath);
Assert.Equal("C:\\Vault\\Meetings\\Summaries\\20260519-1003-summary.md", artifactStore.CreatedArtifacts?.SummaryPath);
Assert.Equal(meetingNotePath, status.MeetingNotePath);
Assert.Equal(transcriptPath, noteStore.SavedNote?.Frontmatter.Transcript);
Assert.Equal(assistantContextPath, noteStore.SavedNote?.Frontmatter.AssistantContext);
Assert.Equal(summaryPath, noteStore.SavedNote?.Frontmatter.Summary);
Assert.Equal(meetingNotePath, noteOpener.OpenedPath);
Assert.Equal(meetingNotePath, artifactStore.CreatedArtifacts?.MeetingNotePath);
Assert.Equal(transcriptPath, artifactStore.CreatedArtifacts?.TranscriptPath);
Assert.Equal(assistantContextPath, artifactStore.CreatedArtifacts?.AssistantContextPath);
Assert.Equal(summaryPath, artifactStore.CreatedArtifacts?.SummaryPath);
Assert.Equal(noteStore.SavedNote?.Frontmatter.Title, artifactStore.ContextMeetingNote?.Frontmatter.Title);
Assert.Equal(noteStore.SavedNote?.Frontmatter.StartTime, artifactStore.ContextMeetingNote?.Frontmatter.StartTime);
@@ -181,6 +181,11 @@ public sealed class TaskbarIconTests
[SupportedOSPlatform("windows")]
public void TaskbarIconGlyphsAreVisuallyCentered()
{
if (!OperatingSystem.IsWindows())
{
return;
}
foreach (var state in new[]
{
RecordingProcessState.Idle,
+9 -8
View File
@@ -7,16 +7,16 @@ public sealed class VaultPathTests
[Fact]
public void ResolveUsesVaultBaseFolderForRelativePaths()
{
var baseFolder = Path.Combine(Path.GetTempPath(), "meeting-assistant-vault");
var vault = new VaultOptions
{
BaseFolder = @"C:\Users\masc3\OpenCloud\Persönlich\Vault\Exxeta"
BaseFolder = baseFolder
};
var resolved = VaultPath.Resolve(vault, @"Meetings\Transcripts");
var relativePath = Path.Combine("Meetings", "Transcripts");
var resolved = VaultPath.Resolve(vault, relativePath);
Assert.Equal(
@"C:\Users\masc3\OpenCloud\Persönlich\Vault\Exxeta\Meetings\Transcripts",
resolved);
Assert.Equal(Path.Combine(baseFolder, relativePath), resolved);
}
[Fact]
@@ -24,11 +24,12 @@ public sealed class VaultPathTests
{
var vault = new VaultOptions
{
BaseFolder = @"C:\Users\masc3\OpenCloud\Persönlich\Vault\Exxeta"
BaseFolder = Path.Combine(Path.GetTempPath(), "meeting-assistant-vault")
};
var absolutePath = Path.Combine(Path.GetTempPath(), "meeting-assistant-other");
var resolved = VaultPath.Resolve(vault, @"C:\Other\Path");
var resolved = VaultPath.Resolve(vault, absolutePath);
Assert.Equal(@"C:\Other\Path", resolved);
Assert.Equal(absolutePath, resolved);
}
}
@@ -71,7 +71,9 @@ public sealed class VenvResemblyzerVoiceEncoderTests
command => command.Arguments.Any(
argument => argument.EndsWith("encode.py", StringComparison.Ordinal)));
Assert.EndsWith(
Path.Combine("Scripts", "python.exe"),
OperatingSystem.IsWindows()
? Path.Combine("Scripts", "python.exe")
: Path.Combine("bin", "python"),
encodingCommand.FileName,
StringComparison.OrdinalIgnoreCase);
Assert.Equal(2, encodingCommand.Arguments.Count);
+33 -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>
@@ -29,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>
@@ -83,9 +88,32 @@
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;" />
<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
@@ -93,7 +121,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>
@@ -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,11 +2,21 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleIdentifier</key>
<string>cloud.schweigert.meeting-assistant.audio-capture</string>
<key>CFBundleName</key>
<string>Meeting Assistant Audio Capture</string>
<key>NSMicrophoneUsageDescription</key>
<string>Meeting Assistant records microphone audio for live meeting transcription.</string>
<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>
</plist>
@@ -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(
+3 -1
View File
@@ -157,13 +157,15 @@ 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
Behavior changes are OpenSpec-driven and test-first: update the relevant requirement/scenario, add a failing public behavior test, implement the smallest passing change, run focused tests and then the justified broader suite, and validate the active change with `openspec validate <change-id> --strict`. Documentation-only maintenance does not need a new OpenSpec change.
The Gitea workflow runs for pull requests, pushes, and manual dispatch. It builds the Windows target on an Ubuntu runner, installs Wine plus a matching Windows .NET SDK, and runs the test project through the Windows host under Wine. It validates source; it does not publish or deploy the workstation application.
The Gitea workflow runs for pull requests, pushes, and manual dispatch. Its Linux job builds the Windows target, installs Wine plus a matching Windows .NET SDK, and runs the test project through the Windows host under Wine. A separate `runs-on: macos` job builds the portable target and bundled Swift helpers, verifies the native helper executables, and runs the complete portable unit-test suite on macOS. The Gitea runner serving that job must run natively on a Mac and expose the `macos:host` label; Docker-OSX is not used because it requires nested KVM that is normally unavailable on CI runners. CI validates source; it does not publish or deploy the workstation application.
## Operations And Limitations
+10
View File
@@ -43,6 +43,10 @@ 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 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.
@@ -154,6 +158,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