feat(macos): enable meeting integrations

This commit is contained in:
dh
2026-08-06 08:19:26 +02:00
parent 9542c05ea4
commit 2af7933a1b
27 changed files with 1999 additions and 227 deletions
@@ -2,8 +2,6 @@ using System.Net;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using System.Drawing;
using System.Drawing.Imaging;
using MeetingAssistant.Screenshots;
using Microsoft.Extensions.Logging.Abstractions;
@@ -284,15 +282,7 @@ public sealed class LiteLlmScreenshotOcrClientTests
private static byte[] CreatePngBytes(int width, int height)
{
using var bitmap = new Bitmap(width, height);
using (var graphics = Graphics.FromImage(bitmap))
{
graphics.Clear(Color.White);
}
using var stream = new MemoryStream();
bitmap.Save(stream, ImageFormat.Png);
return stream.ToArray();
return PngTestFixture.Solid(width, height);
}
private static string CreateNonStreamingTextResponse(string text)
@@ -0,0 +1,436 @@
using System.Net.Http.Json;
using MeetingAssistant.Calendar;
using MeetingAssistant.MacOs;
using MeetingAssistant.MeetingNotes;
using MeetingAssistant.Screenshots;
using MeetingAssistant.Transcription;
using MeetingAssistant.Workflow;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
namespace MeetingAssistant.Tests;
public sealed class MacOsMeetingIntegrationTests
{
[Fact]
public void PortableRegistrationUsesRealMacOsProvidersOnlyOnMacOs()
{
var macServices = new ServiceCollection().AddLogging();
var unsupportedServices = new ServiceCollection().AddLogging();
MacOsMeetingIntegrationRegistration.Add(macServices, isMacOs: true);
MacOsMeetingIntegrationRegistration.Add(unsupportedServices, isMacOs: false);
using var macProvider = macServices.BuildServiceProvider();
using var unsupportedProvider = unsupportedServices.BuildServiceProvider();
Assert.IsType<MacOsMeetingMetadataProvider>(
macProvider.GetRequiredService<IMeetingMetadataProvider>());
Assert.IsType<MacOsCalendarMeetingProvider>(
macProvider.GetRequiredService<ICalendarMeetingProvider>());
Assert.IsType<MacOsMeetingStartPromptService>(
macProvider.GetRequiredService<IMeetingStartPromptService>());
Assert.IsType<MacOsDesktopIntegrationClient>(
macProvider.GetRequiredService<IActiveWindowScreenshotCapture>());
Assert.IsType<MacOsDesktopIntegrationClient>(
macProvider.GetRequiredService<IScreenshotImageCropper>());
Assert.IsType<NoopMeetingMetadataProvider>(
unsupportedProvider.GetRequiredService<IMeetingMetadataProvider>());
Assert.IsType<NoopCalendarMeetingProvider>(
unsupportedProvider.GetRequiredService<ICalendarMeetingProvider>());
Assert.IsType<NoopMeetingStartPromptService>(
unsupportedProvider.GetRequiredService<IMeetingStartPromptService>());
Assert.IsType<UnavailableActiveWindowScreenshotCapture>(
unsupportedProvider.GetRequiredService<IActiveWindowScreenshotCapture>());
Assert.IsType<UnavailableScreenshotImageCropper>(
unsupportedProvider.GetRequiredService<IScreenshotImageCropper>());
}
[Fact]
public async Task MacOsCalendarProvidersReturnTeamsMetadataAndPromptCandidate()
{
var startedAt = new DateTimeOffset(2026, 7, 25, 10, 5, 0, TimeSpan.FromHours(2));
var calendarEvent = new MacOsCalendarEvent(
"event-1",
"Project sync",
startedAt.AddMinutes(-5),
startedAt.AddMinutes(55),
"Microsoft Teams",
"Discuss launch readiness.\nJoin Microsoft Teams Meeting",
"https://teams.microsoft.com/l/meetup-join/example",
["Ada Lovelace <ada@example.com>", "Grace Hopper <grace@example.com>"],
IsCanceled: false);
var client = new StubMacOsDesktopIntegrationClient(calendarEvent);
IMeetingMetadataProvider metadataProvider = new MacOsMeetingMetadataProvider(
client,
NullLogger<MacOsMeetingMetadataProvider>.Instance);
ICalendarMeetingProvider calendarProvider = new MacOsCalendarMeetingProvider(
client,
NullLogger<MacOsCalendarMeetingProvider>.Instance);
var metadata = await metadataProvider.GetCurrentMeetingAsync(
startedAt,
CancellationToken.None);
var candidates = await calendarProvider.GetRecordingPromptCandidatesForDayAsync(
DateOnly.FromDateTime(startedAt.Date),
CancellationToken.None);
Assert.NotNull(metadata);
Assert.Equal("Project sync", metadata.Title);
Assert.Equal(
["Ada Lovelace <ada@example.com>", "Grace Hopper <grace@example.com>"],
metadata.Attendees);
Assert.Equal("Discuss launch readiness.", metadata.Agenda);
Assert.Equal(calendarEvent.End, metadata.ScheduledEnd);
var candidate = Assert.Single(candidates);
Assert.Equal("event-1", candidate.Id);
Assert.Equal("Project sync", candidate.Subject);
Assert.NotNull(candidate.Metadata);
Assert.Equal(metadata.Title, candidate.Metadata.Title);
Assert.Equal(metadata.Attendees, candidate.Metadata.Attendees);
Assert.Equal(metadata.Agenda, candidate.Metadata.Agenda);
Assert.Equal(metadata.ScheduledEnd, candidate.Metadata.ScheduledEnd);
}
[Fact]
public async Task MacOsPromptAndScreenshotUseNativeClientResults()
{
var screenshot = new ActiveWindowScreenshot([137, 80, 78, 71], "Meeting deck");
var client = new StubMacOsDesktopIntegrationClient(
screenshot: screenshot,
promptResponse: MeetingStartPromptResponse.Record);
IMeetingStartPromptService prompt = new MacOsMeetingStartPromptService(
client,
NullLogger<MacOsMeetingStartPromptService>.Instance);
IActiveWindowScreenshotCapture capture = client;
IScreenshotImageCropper cropper = client;
MeetingStartPromptResponse? callbackResponse = null;
var request = new MeetingStartPromptRequest(new CalendarMeeting(
"event-1",
"Project sync",
DateTimeOffset.Now,
DateTimeOffset.Now.AddHours(1)));
await prompt.ShowPromptAsync(
request,
(response, _) =>
{
callbackResponse = response;
return Task.CompletedTask;
},
CancellationToken.None);
var captured = await capture.CaptureAsync(CancellationToken.None);
var croppedPath = await cropper.SaveCroppedScreenshotAsync(
"meeting.png",
new ScreenshotCropCoordinates(1, 2, 3, 4),
CancellationToken.None);
Assert.Equal(MeetingStartPromptResponse.Record, callbackResponse);
Assert.Equal(screenshot, captured);
Assert.Equal("meeting.png.cropped.png", croppedPath);
}
[Fact]
public async Task MacOsCapabilityEndpointReportsEnabledRealProviders()
{
if (!OperatingSystem.IsMacOS())
{
return;
}
await using var factory = new WebApplicationFactory<Program>();
using var client = factory.CreateClient();
var report = await client.GetFromJsonAsync<PlatformCapabilityReport>(
"/diagnostics/platform-capabilities");
Assert.NotNull(report);
Assert.Equal("macOS", report.Host);
Assert.Equal(nameof(MacOsMeetingMetadataProvider), report.MeetingMetadataProvider);
Assert.Equal(nameof(MacOsCalendarMeetingProvider), report.CalendarMeetingProvider);
Assert.Equal(nameof(MacOsMeetingStartPromptService), report.MeetingStartPromptProvider);
Assert.Equal(nameof(MacOsDesktopIntegrationClient), report.ScreenshotProvider);
Assert.Equal(nameof(MacOsWorkflowRulesEditorWindowService), report.WorkflowEditorProvider);
Assert.True(report.WorkflowEditorAvailable);
Assert.True(report.CalendarRecordingPromptsEnabled);
Assert.True(report.SpeakerIdentificationEnabled);
Assert.True(report.ScreenshotOcrEnabled);
Assert.True(report.FunAsrBackendEnabled);
Assert.True(report.FunAsrDiarizationEnabled);
Assert.True(report.WhisperLocalDiarizationEnabled);
}
[Fact]
public async Task NativeHelperAdvertisesCalendarPromptAndScreenshotFeatures()
{
if (!OperatingSystem.IsMacOS())
{
return;
}
var runner = new ProcessCommandRunner(
NullLogger<ProcessCommandRunner>.Instance);
var result = await runner.RunAsync(
GetNativeHelperPath(),
["self-test"],
CancellationToken.None);
Assert.Equal(0, result.ExitCode);
using var json = System.Text.Json.JsonDocument.Parse(result.StandardOutput);
Assert.Equal("ok", json.RootElement.GetProperty("status").GetString());
Assert.Equal(
["calendar", "prompt", "screenshot", "crop"],
json.RootElement.GetProperty("features")
.EnumerateArray()
.Select(feature => feature.GetString()!)
.ToArray());
}
[Fact]
public async Task NativeHelperCropsPngUsingOcrPixelCoordinates()
{
if (!OperatingSystem.IsMacOS())
{
return;
}
var testRoot = Path.Combine(
Path.GetTempPath(),
$"meeting-assistant-native-crop-{Guid.NewGuid():N}");
Directory.CreateDirectory(testRoot);
var bitmapPath = Path.Combine(testRoot, "input.bmp");
var inputPath = Path.Combine(testRoot, "input.png");
var outputPath = Path.Combine(testRoot, "output.png");
var outputBitmapPath = Path.Combine(testRoot, "output.bmp");
try
{
await File.WriteAllBytesAsync(
bitmapPath,
CreateTopRedBottomBlueBitmap());
var runner = new ProcessCommandRunner(
NullLogger<ProcessCommandRunner>.Instance);
var inputConversion = await runner.RunAsync(
"/usr/bin/sips",
["-s", "format", "png", bitmapPath, "--out", inputPath],
CancellationToken.None);
Assert.Equal(0, inputConversion.ExitCode);
var result = await runner.RunAsync(
GetNativeHelperPath(),
["crop", inputPath, outputPath, "0", "0", "2", "1"],
CancellationToken.None);
Assert.Equal(0, result.ExitCode);
var png = await File.ReadAllBytesAsync(outputPath);
Assert.Equal((2, 1), PngTestFixture.ReadDimensions(png));
var outputConversion = await runner.RunAsync(
"/usr/bin/sips",
["-s", "format", "bmp", outputPath, "--out", outputBitmapPath],
CancellationToken.None);
Assert.Equal(0, outputConversion.ExitCode);
Assert.Equal(
(Red: (byte)255, Green: (byte)0, Blue: (byte)0),
ReadFirstBitmapPixel(await File.ReadAllBytesAsync(outputBitmapPath)));
}
finally
{
Directory.Delete(testRoot, recursive: true);
}
}
[Fact]
public async Task CalendarProviderUsesActualLocalMidnightsAcrossDaylightSavingTransition()
{
if (TimeZoneInfo.Local.Id != "Europe/Berlin")
{
return;
}
var client = new StubMacOsDesktopIntegrationClient();
var provider = new MacOsCalendarMeetingProvider(
client,
NullLogger<MacOsCalendarMeetingProvider>.Instance);
await provider.GetRecordingPromptCandidatesForDayAsync(
new DateOnly(2026, 3, 29),
CancellationToken.None);
Assert.Equal(
DateTimeOffset.Parse("2026-03-29T00:00:00+01:00"),
client.CalendarFrom);
Assert.Equal(
DateTimeOffset.Parse("2026-03-30T00:00:00+02:00"),
client.CalendarTo);
Assert.Equal(TimeSpan.FromHours(23), client.CalendarTo - client.CalendarFrom);
}
[Fact]
public async Task CalendarClientFallsBackToCalendarAutomationWhenEventKitIsDenied()
{
if (!OperatingSystem.IsMacOS())
{
return;
}
var runner = new SequenceCommandRunner(
new CommandResult(
MacOsIntegrationException.CalendarPermissionDeniedExitCode,
"",
"Calendar Full Access permission is required."),
new CommandResult(
0,
"""
[{"id":"fallback-event","subject":"Project sync","start":"2026-07-25T08:00:00Z","end":"2026-07-25T09:00:00Z","location":"Microsoft Teams","notes":"","url":"https://teams.microsoft.com/example","attendees":[],"isCanceled":false}]
""",
""));
IMacOsCalendarClient client = new MacOsDesktopIntegrationClient(
runner,
Path.GetDirectoryName(GetNativeHelperPath())!);
var events = await client.GetCalendarEventsAsync(
DateTimeOffset.Parse("2026-07-25T00:00:00Z"),
DateTimeOffset.Parse("2026-07-26T00:00:00Z"),
CancellationToken.None);
var calendarEvent = Assert.Single(events);
Assert.Equal("fallback-event", calendarEvent.Id);
Assert.Collection(
runner.Commands,
command => Assert.Equal("calendar", command.Arguments[0]),
command =>
{
Assert.Equal("/usr/bin/osascript", command.FileName);
Assert.Equal(["-l", "JavaScript"], command.Arguments.Take(2));
});
}
private static string GetNativeHelperPath()
{
var configuration = new DirectoryInfo(AppContext.BaseDirectory)
.Parent?.Name ?? "Debug";
return Path.GetFullPath(Path.Combine(
AppContext.BaseDirectory,
"..",
"..",
"..",
"..",
"MeetingAssistant",
"bin",
configuration,
"net10.0",
"Native",
"macos-meeting-integrations"));
}
private static byte[] CreateTopRedBottomBlueBitmap()
{
var bitmap = new byte[70];
bitmap[0] = (byte)'B';
bitmap[1] = (byte)'M';
BitConverter.GetBytes(bitmap.Length).CopyTo(bitmap, 2);
BitConverter.GetBytes(54).CopyTo(bitmap, 10);
BitConverter.GetBytes(40).CopyTo(bitmap, 14);
BitConverter.GetBytes(2).CopyTo(bitmap, 18);
BitConverter.GetBytes(2).CopyTo(bitmap, 22);
BitConverter.GetBytes((short)1).CopyTo(bitmap, 26);
BitConverter.GetBytes((short)24).CopyTo(bitmap, 28);
BitConverter.GetBytes(16).CopyTo(bitmap, 34);
// BMP rows are bottom-up and padded to four-byte boundaries.
bitmap[54] = 255;
bitmap[57] = 255;
bitmap[64] = 255;
bitmap[67] = 255;
return bitmap;
}
private static (byte Red, byte Green, byte Blue) ReadFirstBitmapPixel(byte[] bitmap)
{
var pixelOffset = BitConverter.ToInt32(bitmap, 10);
return (
Red: bitmap[pixelOffset + 2],
Green: bitmap[pixelOffset + 1],
Blue: bitmap[pixelOffset]);
}
private sealed class StubMacOsDesktopIntegrationClient :
IMacOsCalendarClient,
IMacOsMeetingPromptClient,
IActiveWindowScreenshotCapture,
IScreenshotImageCropper
{
private readonly IReadOnlyList<MacOsCalendarEvent> events;
private readonly ActiveWindowScreenshot screenshot;
private readonly MeetingStartPromptResponse promptResponse;
public StubMacOsDesktopIntegrationClient(
MacOsCalendarEvent? calendarEvent = null,
ActiveWindowScreenshot? screenshot = null,
MeetingStartPromptResponse promptResponse = MeetingStartPromptResponse.Skip)
{
events = calendarEvent is null ? [] : [calendarEvent];
this.screenshot = screenshot ?? new ActiveWindowScreenshot([], null);
this.promptResponse = promptResponse;
}
public Task<IReadOnlyList<MacOsCalendarEvent>> GetCalendarEventsAsync(
DateTimeOffset from,
DateTimeOffset to,
CancellationToken cancellationToken)
{
CalendarFrom = from;
CalendarTo = to;
return Task.FromResult(events);
}
public DateTimeOffset CalendarFrom { get; private set; }
public DateTimeOffset CalendarTo { get; private set; }
public Task<MeetingStartPromptResponse> ShowMeetingStartPromptAsync(
MeetingStartPromptRequest request,
CancellationToken cancellationToken)
{
return Task.FromResult(promptResponse);
}
public Task<ActiveWindowScreenshot> CaptureAsync(CancellationToken cancellationToken)
{
return Task.FromResult(screenshot);
}
public Task<string?> SaveCroppedScreenshotAsync(
string screenshotPath,
ScreenshotCropCoordinates crop,
CancellationToken cancellationToken)
{
return Task.FromResult<string?>($"{screenshotPath}.cropped.png");
}
}
private sealed class SequenceCommandRunner : ICommandRunner
{
private readonly Queue<CommandResult> results;
public SequenceCommandRunner(params CommandResult[] results)
{
this.results = new Queue<CommandResult>(results);
}
public List<(string FileName, IReadOnlyList<string> Arguments)> Commands { get; } = [];
public Task<CommandResult> RunAsync(
string fileName,
IReadOnlyList<string> arguments,
CancellationToken cancellationToken,
IReadOnlyDictionary<string, string>? environment = null)
{
Commands.Add((fileName, arguments));
return Task.FromResult(results.Dequeue());
}
}
}
@@ -4,8 +4,6 @@ using MeetingAssistant.Speakers;
using MeetingAssistant.Workflow;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using System.Drawing;
using System.Drawing.Imaging;
#pragma warning disable CA1416
@@ -117,11 +115,7 @@ public sealed class MeetingScreenshotServiceTests
var croppedPath = Assert.Single(Directory.GetFiles(
Path.Combine(Path.GetDirectoryName(fixture.Artifacts.AssistantContextPath)!, "Attachments"),
"*-cropped.png"));
using (var cropped = new Bitmap(croppedPath))
{
Assert.Equal(2, cropped.Width);
Assert.Equal(2, cropped.Height);
}
Assert.Equal((2, 2), PngTestFixture.ReadDimensions(await File.ReadAllBytesAsync(croppedPath)));
var context = await File.ReadAllTextAsync(fixture.Artifacts.AssistantContextPath);
var screenshotIndex = context.IndexOf("![Screenshot 00:00:10]", StringComparison.Ordinal);
@@ -133,6 +127,38 @@ public sealed class MeetingScreenshotServiceTests
Assert.Contains("Shared screen text", context);
}
[Fact]
public async Task CaptureKeepsSuccessfulOcrTextWhenCropperRejectsCoordinates()
{
var fixture = await ScreenshotFixture.CreateAsync(options =>
{
options.Screenshots.Ocr.Enabled = true;
});
var ocr = new CapturingScreenshotOcrClient(
"Readable text survives.",
new ScreenshotCropCoordinates(99, 99, 10, 10));
var service = fixture.CreateService(
new FixedScreenshotCapture(CreatePngBytes(4, 4)),
ocr,
screenshotImageCropper: new RejectingScreenshotImageCropper());
await service.CaptureAsync(
fixture.Artifacts,
DateTimeOffset.Parse("2026-05-26T10:00:00+02:00"),
DateTimeOffset.Parse("2026-05-26T10:00:10+02:00"),
fixture.Options,
CancellationToken.None);
await service.WaitForPendingOcrAsync(
fixture.Artifacts,
TimeSpan.FromSeconds(5),
CancellationToken.None);
var context = await File.ReadAllTextAsync(fixture.Artifacts.AssistantContextPath);
Assert.Contains("Readable text survives.", context);
Assert.DoesNotContain("_OCR failed", context);
Assert.DoesNotContain("![Cropped screenshot]", context);
}
[Fact]
public async Task CaptureAddsOcrAttendeesToMeetingNoteThroughCanonicalizer()
{
@@ -454,7 +480,8 @@ public sealed class MeetingScreenshotServiceTests
IActiveWindowScreenshotCapture capture,
IScreenshotOcrClient ocrClient,
ISpeakerIdentityAttendeeCanonicalizer? attendeeCanonicalizer = null,
IMeetingWorkflowEngine? meetingWorkflowEngine = null)
IMeetingWorkflowEngine? meetingWorkflowEngine = null,
IScreenshotImageCropper? screenshotImageCropper = null)
{
return new MeetingScreenshotService(
capture,
@@ -462,12 +489,28 @@ public sealed class MeetingScreenshotServiceTests
NoteStore,
attendeeCanonicalizer ?? PassthroughSpeakerIdentityAttendeeCanonicalizer.Instance,
ocrClient,
screenshotImageCropper ??
capture as IScreenshotImageCropper ??
new UnavailableScreenshotImageCropper(),
NullLogger<MeetingScreenshotService>.Instance,
meetingWorkflowEngine);
}
}
private sealed class FixedScreenshotCapture : IActiveWindowScreenshotCapture
private sealed class RejectingScreenshotImageCropper : IScreenshotImageCropper
{
public Task<string?> SaveCroppedScreenshotAsync(
string screenshotPath,
ScreenshotCropCoordinates crop,
CancellationToken cancellationToken)
{
throw new InvalidDataException("The crop is outside image bounds.");
}
}
private sealed class FixedScreenshotCapture :
IActiveWindowScreenshotCapture,
IScreenshotImageCropper
{
private readonly byte[] bytes;
@@ -480,6 +523,21 @@ public sealed class MeetingScreenshotServiceTests
{
return Task.FromResult(new ActiveWindowScreenshot(bytes, "Demo Window"));
}
public async Task<string?> SaveCroppedScreenshotAsync(
string screenshotPath,
ScreenshotCropCoordinates crop,
CancellationToken cancellationToken)
{
var croppedPath = Path.Combine(
Path.GetDirectoryName(screenshotPath)!,
$"{Path.GetFileNameWithoutExtension(screenshotPath)}-cropped.png");
await File.WriteAllBytesAsync(
croppedPath,
PngTestFixture.Solid(2, 2),
cancellationToken);
return croppedPath;
}
}
private sealed class CapturingScreenshotOcrClient : IScreenshotOcrClient
@@ -614,15 +672,7 @@ public sealed class MeetingScreenshotServiceTests
private static byte[] CreatePngBytes(int width, int height)
{
using var bitmap = new Bitmap(width, height);
using (var graphics = Graphics.FromImage(bitmap))
{
graphics.Clear(Color.White);
}
using var stream = new MemoryStream();
bitmap.Save(stream, ImageFormat.Png);
return stream.ToArray();
return PngTestFixture.Solid(width, height);
}
private static string ExtractScreenshotOcrId(string context)
+38
View File
@@ -0,0 +1,38 @@
namespace MeetingAssistant.Tests;
internal static class PngTestFixture
{
private const string Solid2X2 =
"iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAYAAABytg0kAAAAAXNSR0IArs4c6QAAADhlWElmTU0AKgAAAAgAAYdpAAQAAAABAAAAGgAAAAAAAqACAAQAAAABAAAAAqADAAQAAAABAAAAAgAAAADO0J6QAAAAEklEQVQIHWP8DwQMQMAEIkAAAD34BACALvQ5AAAAAElFTkSuQmCC";
private const string Solid4X4 =
"iVBORw0KGgoAAAANSUhEUgAAAAQAAAAECAYAAACp8Z5+AAAAAXNSR0IArs4c6QAAADhlWElmTU0AKgAAAAgAAYdpAAQAAAABAAAAGgAAAAAAAqACAAQAAAABAAAABKADAAQAAAABAAAABAAAAADmpNw4AAAAFUlEQVQIHWP8DwQMSIAJiQ1mEhYAAAZdBAQjjGwcAAAAAElFTkSuQmCC";
private const string Solid8X6 =
"iVBORw0KGgoAAAANSUhEUgAAAAgAAAAGCAYAAAD+Bd/7AAAAAXNSR0IArs4c6QAAADhlWElmTU0AKgAAAAgAAYdpAAQAAAABAAAAGgAAAAAAAqACAAQAAAABAAAACKADAAQAAAABAAAABgAAAAAJfOcJAAAAFklEQVQIHWP8DwQMeAATHjmw1GBQAAAQiQQIVmhjoQAAAABJRU5ErkJggg==";
public static byte[] Solid(int width, int height)
{
var encoded = (width, height) switch
{
(2, 2) => Solid2X2,
(4, 4) => Solid4X4,
(8, 6) => Solid8X6,
_ => throw new ArgumentOutOfRangeException(
nameof(width),
$"No deterministic PNG fixture exists for {width}x{height}.")
};
return Convert.FromBase64String(encoded);
}
public static (int Width, int Height) ReadDimensions(byte[] png)
{
if (!png.AsSpan(0, 8).SequenceEqual(
new byte[] { 137, 80, 78, 71, 13, 10, 26, 10 }))
{
throw new InvalidDataException("The fixture is not a PNG.");
}
return (
System.Buffers.Binary.BinaryPrimitives.ReadInt32BigEndian(png.AsSpan(16, 4)),
System.Buffers.Binary.BinaryPrimitives.ReadInt32BigEndian(png.AsSpan(20, 4)));
}
}
@@ -52,14 +52,14 @@ public sealed class CalendarRecordingPromptScheduler : BackgroundService
cachedMeetingDay = today;
nextSyncAt = now.Add(GetSyncInterval(promptOptions));
logger.LogInformation(
"Synced {MeetingCount} Outlook Teams calendar meetings for {Day}; next sync at {NextSyncAt}",
"Synced {MeetingCount} Teams calendar meetings for {Day}; next sync at {NextSyncAt}",
cachedMeetings.Count,
today,
nextSyncAt);
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
logger.LogWarning(exception, "Could not read today's Outlook Teams meetings for recording prompts");
logger.LogWarning(exception, "Could not read today's Teams calendar meetings for recording prompts");
nextSyncAt = now.Add(GetSyncInterval(promptOptions));
return;
}
@@ -0,0 +1,447 @@
using System.Text.Json;
using MeetingAssistant.Calendar;
using MeetingAssistant.MeetingNotes;
using MeetingAssistant.Screenshots;
using MeetingAssistant.Transcription;
using Microsoft.Extensions.DependencyInjection.Extensions;
namespace MeetingAssistant.MacOs;
internal sealed record MacOsCalendarEvent(
string Id,
string Subject,
DateTimeOffset Start,
DateTimeOffset End,
string Location,
string Notes,
string Url,
IReadOnlyList<string> Attendees,
bool IsCanceled);
internal interface IMacOsCalendarClient
{
Task<IReadOnlyList<MacOsCalendarEvent>> GetCalendarEventsAsync(
DateTimeOffset from,
DateTimeOffset to,
CancellationToken cancellationToken);
}
internal interface IMacOsMeetingPromptClient
{
Task<MeetingStartPromptResponse> ShowMeetingStartPromptAsync(
MeetingStartPromptRequest request,
CancellationToken cancellationToken);
}
internal sealed class MacOsDesktopIntegrationClient :
IMacOsCalendarClient,
IMacOsMeetingPromptClient,
IActiveWindowScreenshotCapture,
IScreenshotImageCropper
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
private readonly ICommandRunner commandRunner;
private readonly string nativeDirectory;
public MacOsDesktopIntegrationClient(ICommandRunner commandRunner)
: this(commandRunner, Path.Combine(AppContext.BaseDirectory, "Native"))
{
}
internal MacOsDesktopIntegrationClient(
ICommandRunner commandRunner,
string nativeDirectory)
{
this.commandRunner = commandRunner;
this.nativeDirectory = nativeDirectory;
}
public async Task<IReadOnlyList<MacOsCalendarEvent>> GetCalendarEventsAsync(
DateTimeOffset from,
DateTimeOffset to,
CancellationToken cancellationToken)
{
var fromValue = from.ToString("O", System.Globalization.CultureInfo.InvariantCulture);
var toValue = to.ToString("O", System.Globalization.CultureInfo.InvariantCulture);
CommandResult result;
try
{
result = await RunHelperAsync(
["calendar", fromValue, toValue],
cancellationToken);
}
catch (MacOsIntegrationException exception)
when (exception.ExitCode == MacOsIntegrationException.CalendarPermissionDeniedExitCode)
{
result = await RunCalendarAutomationFallbackAsync(
fromValue,
toValue,
cancellationToken);
}
return JsonSerializer.Deserialize<MacOsCalendarEvent[]>(result.StandardOutput, JsonOptions) ?? [];
}
public async Task<MeetingStartPromptResponse> ShowMeetingStartPromptAsync(
MeetingStartPromptRequest request,
CancellationToken cancellationToken)
{
var requestPath = Path.Combine(
Path.GetTempPath(),
$"meeting-assistant-prompt-{Guid.NewGuid():N}.json");
try
{
await File.WriteAllTextAsync(
requestPath,
JsonSerializer.Serialize(new
{
subject = request.Meeting.Subject,
start = request.Meeting.Start
}, JsonOptions),
cancellationToken);
var result = await RunHelperAsync(["prompt", requestPath], cancellationToken);
var response = JsonSerializer.Deserialize<MacOsPromptResult>(
result.StandardOutput,
JsonOptions);
return string.Equals(response?.Response, "record", StringComparison.OrdinalIgnoreCase)
? MeetingStartPromptResponse.Record
: MeetingStartPromptResponse.Skip;
}
finally
{
File.Delete(requestPath);
}
}
public async Task<ActiveWindowScreenshot> CaptureAsync(
CancellationToken cancellationToken)
{
var outputPath = Path.Combine(
Path.GetTempPath(),
$"meeting-assistant-screenshot-{Guid.NewGuid():N}.png");
try
{
var result = await RunHelperAsync(["screenshot", outputPath], cancellationToken);
var response = JsonSerializer.Deserialize<MacOsScreenshotResult>(
result.StandardOutput,
JsonOptions);
return new ActiveWindowScreenshot(
await File.ReadAllBytesAsync(outputPath, cancellationToken),
response?.WindowTitle);
}
finally
{
File.Delete(outputPath);
}
}
public async Task<string?> SaveCroppedScreenshotAsync(
string screenshotPath,
ScreenshotCropCoordinates crop,
CancellationToken cancellationToken)
{
var croppedPath = Path.Combine(
Path.GetDirectoryName(screenshotPath)!,
$"{Path.GetFileNameWithoutExtension(screenshotPath)}-cropped.png");
try
{
await RunHelperAsync(
[
"crop",
screenshotPath,
croppedPath,
crop.X.ToString(System.Globalization.CultureInfo.InvariantCulture),
crop.Y.ToString(System.Globalization.CultureInfo.InvariantCulture),
crop.Width.ToString(System.Globalization.CultureInfo.InvariantCulture),
crop.Height.ToString(System.Globalization.CultureInfo.InvariantCulture)
],
cancellationToken);
}
catch (MacOsIntegrationException exception)
{
throw new InvalidDataException(
$"The screenshot crop {crop.X},{crop.Y},{crop.Width},{crop.Height} could not be applied.",
exception);
}
return croppedPath;
}
private async Task<CommandResult> RunHelperAsync(
IReadOnlyList<string> arguments,
CancellationToken cancellationToken)
{
if (!OperatingSystem.IsMacOS())
{
throw new PlatformNotSupportedException(
"The native macOS desktop integrations can only run on macOS.");
}
var helperPath = Path.Combine(nativeDirectory, "macos-meeting-integrations");
if (!File.Exists(helperPath))
{
throw new FileNotFoundException(
$"The macOS meeting integrations helper was not found at '{helperPath}'. Build or publish Meeting Assistant on macOS.",
helperPath);
}
var result = await commandRunner.RunAsync(helperPath, arguments, cancellationToken);
if (result.ExitCode != 0)
{
var detail = string.IsNullOrWhiteSpace(result.StandardError)
? "The helper did not provide an error message."
: result.StandardError.Trim();
throw new MacOsIntegrationException(
result.ExitCode,
$"macOS meeting integration '{arguments[0]}' failed with exit code {result.ExitCode}. {detail}");
}
return result;
}
private async Task<CommandResult> RunCalendarAutomationFallbackAsync(
string from,
string to,
CancellationToken cancellationToken)
{
var scriptPath = Path.Combine(nativeDirectory, "macos-calendar-events.js");
if (!File.Exists(scriptPath))
{
throw new FileNotFoundException(
$"The macOS Calendar automation fallback was not found at '{scriptPath}'.",
scriptPath);
}
var result = await commandRunner.RunAsync(
"/usr/bin/osascript",
["-l", "JavaScript", scriptPath, from, to],
cancellationToken);
if (result.ExitCode != 0)
{
var detail = string.IsNullOrWhiteSpace(result.StandardError)
? "Calendar automation did not provide an error message."
: result.StandardError.Trim();
throw new InvalidOperationException(
$"macOS calendar access failed through both EventKit and Calendar automation. {detail}");
}
return result;
}
private sealed record MacOsPromptResult(string Response);
private sealed record MacOsScreenshotResult(string? WindowTitle);
}
internal sealed class MacOsIntegrationException : InvalidOperationException
{
public const int CalendarPermissionDeniedExitCode = 77;
public MacOsIntegrationException(int exitCode, string message)
: base(message)
{
ExitCode = exitCode;
}
public int ExitCode { get; }
}
internal sealed class MacOsMeetingMetadataProvider :
IMeetingMetadataProvider,
IMeetingMetadataDiagnosticProvider
{
private readonly IMacOsCalendarClient client;
private readonly ILogger<MacOsMeetingMetadataProvider> logger;
public MacOsMeetingMetadataProvider(
IMacOsCalendarClient client,
ILogger<MacOsMeetingMetadataProvider> logger)
{
this.client = client;
this.logger = logger;
}
public async Task<MeetingMetadata?> GetCurrentMeetingAsync(
DateTimeOffset startedAt,
CancellationToken cancellationToken)
{
try
{
return await GetCurrentMeetingCoreAsync(startedAt, cancellationToken);
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
logger.LogDebug(exception, "macOS calendar meeting metadata was not available");
return null;
}
}
public async Task<MeetingMetadataDiagnosticResult> DiagnoseCurrentMeetingAsync(
DateTimeOffset startedAt,
TimeSpan timeout,
CancellationToken cancellationToken)
{
return await MeetingMetadataDiagnosticRunner.RunAsync(
nameof(MacOsMeetingMetadataProvider),
startedAt,
timeout,
token => GetCurrentMeetingCoreAsync(startedAt, token),
"No matching Teams calendar event was found.",
cancellationToken);
}
private async Task<MeetingMetadata?> GetCurrentMeetingCoreAsync(
DateTimeOffset startedAt,
CancellationToken cancellationToken)
{
var events = await client.GetCalendarEventsAsync(
startedAt.AddHours(-4),
startedAt.AddMinutes(5),
cancellationToken);
var candidates = events
.Where(MacOsCalendarEventMapper.IsTeamsEvent)
.Where(calendarEvent => !calendarEvent.IsCanceled)
.ToList();
var selected = OutlookMeetingCandidateSelector.Select(
candidates,
startedAt.LocalDateTime,
calendarEvent => calendarEvent.Start.LocalDateTime,
calendarEvent => calendarEvent.End.LocalDateTime);
return selected is null ? null : MacOsCalendarEventMapper.CreateMetadata(selected);
}
}
internal sealed class MacOsCalendarMeetingProvider : ICalendarMeetingProvider
{
private readonly IMacOsCalendarClient client;
private readonly ILogger<MacOsCalendarMeetingProvider> logger;
public MacOsCalendarMeetingProvider(
IMacOsCalendarClient client,
ILogger<MacOsCalendarMeetingProvider> logger)
{
this.client = client;
this.logger = logger;
}
public async Task<IReadOnlyList<CalendarMeeting>> GetRecordingPromptCandidatesForDayAsync(
DateOnly day,
CancellationToken cancellationToken)
{
var dayStart = AtLocalMidnight(day);
var dayEnd = AtLocalMidnight(day.AddDays(1));
try
{
var events = await client.GetCalendarEventsAsync(dayStart, dayEnd, cancellationToken);
return events
.Where(calendarEvent => !calendarEvent.IsCanceled)
.Where(MacOsCalendarEventMapper.IsTeamsEvent)
.Where(calendarEvent => calendarEvent.Start >= dayStart && calendarEvent.Start < dayEnd)
.OrderBy(calendarEvent => calendarEvent.Start)
.Select(calendarEvent => new CalendarMeeting(
calendarEvent.Id,
CalendarAppointmentMetadata.ReadSubject(calendarEvent.Subject),
calendarEvent.Start,
calendarEvent.End,
MacOsCalendarEventMapper.CreateMetadata(calendarEvent),
calendarEvent.IsCanceled))
.ToList();
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
logger.LogDebug(exception, "macOS calendar meetings were not available");
return [];
}
}
private static DateTimeOffset AtLocalMidnight(DateOnly day)
{
var localMidnight = day.ToDateTime(TimeOnly.MinValue);
return new DateTimeOffset(
localMidnight,
TimeZoneInfo.Local.GetUtcOffset(localMidnight));
}
}
internal sealed class MacOsMeetingStartPromptService : IMeetingStartPromptService
{
private readonly IMacOsMeetingPromptClient client;
private readonly ILogger<MacOsMeetingStartPromptService> logger;
public MacOsMeetingStartPromptService(
IMacOsMeetingPromptClient client,
ILogger<MacOsMeetingStartPromptService> logger)
{
this.client = client;
this.logger = logger;
}
public async Task ShowPromptAsync(
MeetingStartPromptRequest request,
Func<MeetingStartPromptResponse, CancellationToken, Task> handleResponseAsync,
CancellationToken cancellationToken)
{
try
{
var response = await client.ShowMeetingStartPromptAsync(request, cancellationToken);
await handleResponseAsync(response, cancellationToken);
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
logger.LogError(exception, "Failed to display native macOS meeting-start prompt");
}
}
}
public static class MacOsMeetingIntegrationRegistration
{
public static IServiceCollection Add(
IServiceCollection services,
bool isMacOs)
{
if (isMacOs)
{
services.TryAddSingleton<ICommandRunner, ProcessCommandRunner>();
services.TryAddSingleton<MacOsDesktopIntegrationClient>();
services.TryAddSingleton<IMacOsCalendarClient>(services =>
services.GetRequiredService<MacOsDesktopIntegrationClient>());
services.TryAddSingleton<IMacOsMeetingPromptClient>(services =>
services.GetRequiredService<MacOsDesktopIntegrationClient>());
services.AddSingleton<IMeetingMetadataProvider, MacOsMeetingMetadataProvider>();
services.AddSingleton<ICalendarMeetingProvider, MacOsCalendarMeetingProvider>();
services.AddSingleton<IMeetingStartPromptService, MacOsMeetingStartPromptService>();
services.AddSingleton<IActiveWindowScreenshotCapture>(services =>
services.GetRequiredService<MacOsDesktopIntegrationClient>());
services.AddSingleton<IScreenshotImageCropper>(services =>
services.GetRequiredService<MacOsDesktopIntegrationClient>());
return services;
}
services.AddSingleton<IMeetingMetadataProvider, NoopMeetingMetadataProvider>();
services.AddSingleton<ICalendarMeetingProvider, NoopCalendarMeetingProvider>();
services.AddSingleton<IMeetingStartPromptService, NoopMeetingStartPromptService>();
services.AddSingleton<IActiveWindowScreenshotCapture, UnavailableActiveWindowScreenshotCapture>();
services.AddSingleton<IScreenshotImageCropper, UnavailableScreenshotImageCropper>();
return services;
}
}
internal static class MacOsCalendarEventMapper
{
public static bool IsTeamsEvent(MacOsCalendarEvent calendarEvent)
{
return CalendarAppointmentMetadata.IsTeamsAppointment(
calendarEvent.Subject,
calendarEvent.Location,
calendarEvent.Notes,
calendarEvent.Url);
}
public static MeetingMetadata CreateMetadata(MacOsCalendarEvent calendarEvent)
{
return CalendarAppointmentMetadata.Create(
calendarEvent.Subject,
calendarEvent.Attendees,
calendarEvent.Notes,
calendarEvent.End);
}
}
+6
View File
@@ -25,6 +25,9 @@
<MacOsAudioCaptureArchitecture Condition="'$(MacOsAudioCaptureArchitecture)' == ''">arm64</MacOsAudioCaptureArchitecture>
<MacOsDesktopControlsSource>$(MSBuildProjectDirectory)/Native/MacOsDesktopControls/main.swift</MacOsDesktopControlsSource>
<MacOsDesktopControlsOutputPath>Native/macos-desktop-controls</MacOsDesktopControlsOutputPath>
<MacOsMeetingIntegrationsSource>$(MSBuildProjectDirectory)/Native/MacOsMeetingIntegrations/main.swift</MacOsMeetingIntegrationsSource>
<MacOsMeetingIntegrationsInfoPlist>$(MSBuildProjectDirectory)/Native/MacOsMeetingIntegrations/Info.plist</MacOsMeetingIntegrationsInfoPlist>
<MacOsMeetingIntegrationsOutputPath>Native/macos-meeting-integrations</MacOsMeetingIntegrationsOutputPath>
</PropertyGroup>
<ItemGroup>
@@ -59,6 +62,7 @@
<None Include="..\docs\meeting-assistant-configuration.md" Link="docs\meeting-assistant-configuration.md" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
<None Include="Content\Project-AGENTS.md" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
<None Include="..\openspec\specs\**\*.md" Link="openspec\specs\%(RecursiveDir)%(Filename)%(Extension)" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
<None Include="Native\MacOsMeetingIntegrations\calendar-events.js" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" TargetPath="Native\macos-calendar-events.js" />
</ItemGroup>
<ItemGroup Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'windows'">
@@ -80,6 +84,7 @@
<MakeDir Directories="$(TargetDir)Native" />
<Exec Command="/usr/bin/xcrun swiftc -parse-as-library -O -target $(MacOsAudioCaptureArchitecture)-apple-macos13.0 -framework AVFoundation -framework CoreMedia -framework ScreenCaptureKit &quot;$(MacOsAudioCaptureSource)&quot; -o &quot;$(TargetDir)$(MacOsAudioCaptureOutputPath)&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>
<Target
@@ -89,6 +94,7 @@
<MakeDir Directories="$(PublishDir)Native" />
<Copy SourceFiles="$(TargetDir)$(MacOsAudioCaptureOutputPath)" DestinationFolder="$(PublishDir)Native" />
<Copy SourceFiles="$(TargetDir)$(MacOsDesktopControlsOutputPath)" DestinationFolder="$(PublishDir)Native" />
<Copy SourceFiles="$(TargetDir)$(MacOsMeetingIntegrationsOutputPath)" DestinationFolder="$(PublishDir)Native" />
</Target>
<ItemGroup Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) != 'windows'">
@@ -0,0 +1,117 @@
using MeetingAssistant.Calendar;
namespace MeetingAssistant.MeetingNotes;
internal static class CalendarAppointmentMetadata
{
public static MeetingMetadata Create(
string? subject,
IEnumerable<string> attendees,
string? body,
DateTimeOffset end)
{
return new MeetingMetadata(
ReadSubject(subject),
NormalizeAttendees(attendees),
ExtractAgenda(body ?? ""),
end);
}
public static string ReadSubject(string? subject)
{
var trimmed = subject?.Trim();
return string.IsNullOrWhiteSpace(trimmed) ? "Teams meeting" : trimmed;
}
public static bool IsTeamsAppointment(
string? subject,
string? location,
string? body,
string? url = null)
{
return TeamsMeetingMarkerDetector.IsTeamsAppointment(
subject ?? "",
location ?? "",
string.Join(Environment.NewLine, body, url));
}
public static string ExtractAgenda(string body)
{
if (string.IsNullOrWhiteSpace(body))
{
return "";
}
var lines = body
.Replace("\r\n", "\n", StringComparison.Ordinal)
.Replace('\r', '\n')
.Split('\n');
var agendaLines = new List<string>();
foreach (var line in lines)
{
if (IsTeamsSeparator(line) || TeamsMeetingMarkerDetector.ContainsTeamsMarker(line))
{
break;
}
agendaLines.Add(line);
}
return string.Join(Environment.NewLine, agendaLines).Trim();
}
public static IReadOnlyList<string> NormalizeAttendees(IEnumerable<string> attendees)
{
return attendees
.Select(NormalizeAttendee)
.Where(attendee => !string.IsNullOrWhiteSpace(attendee))
.GroupBy(GetAttendeeDeduplicationKey, StringComparer.OrdinalIgnoreCase)
.Select(group => group
.OrderByDescending(attendee => attendee.Contains('<', StringComparison.Ordinal))
.ThenBy(attendee => attendee.Length)
.First())
.ToList();
}
private static bool IsTeamsSeparator(string line)
{
var trimmed = line.Trim();
return trimmed.Length >= 8 &&
trimmed.All(character => character is '_' or '-' or '*' or ' ');
}
private static string NormalizeAttendee(string attendee)
{
var normalized = attendee.Trim()
.Replace("mailto:", "", StringComparison.OrdinalIgnoreCase);
while (normalized.Contains(" ", StringComparison.Ordinal))
{
normalized = normalized.Replace(" ", " ", StringComparison.Ordinal);
}
var emailStart = normalized.IndexOf('<', StringComparison.Ordinal);
var emailEnd = normalized.LastIndexOf('>');
if (emailStart > 0 && emailEnd > emailStart)
{
var name = normalized[..emailStart].Trim();
var email = normalized[(emailStart + 1)..emailEnd].Trim();
if (name.Equals(email, StringComparison.OrdinalIgnoreCase))
{
return email;
}
return $"{name} <{email}>";
}
return normalized;
}
private static string GetAttendeeDeduplicationKey(string attendee)
{
var emailStart = attendee.IndexOf('<', StringComparison.Ordinal);
var emailEnd = attendee.LastIndexOf('>');
return emailStart > 0 && emailEnd > emailStart
? attendee[(emailStart + 1)..emailEnd].Trim()
: attendee.Trim();
}
}
@@ -0,0 +1,58 @@
using System.Diagnostics;
namespace MeetingAssistant.MeetingNotes;
internal static class MeetingMetadataDiagnosticRunner
{
public static async Task<MeetingMetadataDiagnosticResult> RunAsync(
string provider,
DateTimeOffset startedAt,
TimeSpan timeout,
Func<CancellationToken, Task<MeetingMetadata?>> lookupAsync,
string noMatchError,
CancellationToken cancellationToken)
{
var stopwatch = Stopwatch.StartNew();
try
{
var lookup = lookupAsync(cancellationToken);
var metadata = timeout > TimeSpan.Zero
? await lookup.WaitAsync(timeout, cancellationToken)
: await lookup.WaitAsync(cancellationToken);
return new MeetingMetadataDiagnosticResult(
provider,
startedAt,
metadata is not null,
false,
stopwatch.ElapsedMilliseconds,
metadata,
metadata is null ? noMatchError : null);
}
catch (TimeoutException exception)
{
return new MeetingMetadataDiagnosticResult(
provider,
startedAt,
false,
true,
stopwatch.ElapsedMilliseconds,
null,
exception.Message);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch (Exception exception)
{
return new MeetingMetadataDiagnosticResult(
provider,
startedAt,
false,
false,
stopwatch.ElapsedMilliseconds,
null,
exception.Message);
}
}
}
@@ -7,18 +7,17 @@ internal static class OutlookClassicAppointmentMetadata
{
public static MeetingMetadata CreateMetadata(object appointment, DateTime end)
{
var title = ReadSubject(appointment);
return new MeetingMetadata(
title,
return CalendarAppointmentMetadata.Create(
Convert.ToString(OutlookClassicCom.GetProperty(appointment, "Subject")),
ReadAttendees(appointment),
ExtractAgenda(Convert.ToString(OutlookClassicCom.GetProperty(appointment, "Body")) ?? ""),
Convert.ToString(OutlookClassicCom.GetProperty(appointment, "Body")),
OutlookClassicCom.ToLocalOffset(end));
}
public static string ReadSubject(object appointment)
{
var subject = Convert.ToString(OutlookClassicCom.GetProperty(appointment, "Subject"))?.Trim();
return string.IsNullOrWhiteSpace(subject) ? "Teams meeting" : subject;
return CalendarAppointmentMetadata.ReadSubject(
Convert.ToString(OutlookClassicCom.GetProperty(appointment, "Subject")));
}
public static bool IsTeamsAppointment(object appointment)
@@ -26,29 +25,12 @@ internal static class OutlookClassicAppointmentMetadata
var subject = Convert.ToString(OutlookClassicCom.GetProperty(appointment, "Subject")) ?? "";
var location = Convert.ToString(OutlookClassicCom.GetProperty(appointment, "Location")) ?? "";
var body = Convert.ToString(OutlookClassicCom.GetProperty(appointment, "Body")) ?? "";
return TeamsMeetingMarkerDetector.IsTeamsAppointment(subject, location, body);
return CalendarAppointmentMetadata.IsTeamsAppointment(subject, location, body);
}
public static string ExtractAgenda(string body)
{
if (string.IsNullOrWhiteSpace(body))
{
return "";
}
var lines = body.Replace("\r\n", "\n", StringComparison.Ordinal).Replace('\r', '\n').Split('\n');
var agendaLines = new List<string>();
foreach (var line in lines)
{
if (IsTeamsSeparator(line) || IsTeamsJoinLine(line))
{
break;
}
agendaLines.Add(line);
}
return string.Join(Environment.NewLine, agendaLines).Trim();
return CalendarAppointmentMetadata.ExtractAgenda(body);
}
public static IReadOnlyList<string> ReadAttendees(object appointment)
@@ -93,66 +75,7 @@ internal static class OutlookClassicAppointmentMetadata
public static IReadOnlyList<string> NormalizeAttendees(IEnumerable<string> attendees)
{
return attendees
.Select(NormalizeAttendee)
.Where(attendee => !string.IsNullOrWhiteSpace(attendee))
.GroupBy(GetAttendeeDeduplicationKey, StringComparer.OrdinalIgnoreCase)
.Select(group => group
.OrderByDescending(attendee => attendee.Contains('<', StringComparison.Ordinal))
.ThenBy(attendee => attendee.Length)
.First())
.ToList();
}
private static bool IsTeamsSeparator(string line)
{
var trimmed = line.Trim();
return trimmed.Length >= 8 &&
trimmed.All(character => character is '_' or '-' or '*' or ' ');
}
private static bool IsTeamsJoinLine(string line)
{
return TeamsMeetingMarkerDetector.ContainsTeamsMarker(line);
}
private static string NormalizeAttendee(string attendee)
{
var normalized = attendee.Trim();
var mailtoPrefix = "mailto:";
normalized = normalized.Replace(mailtoPrefix, "", StringComparison.OrdinalIgnoreCase);
while (normalized.Contains(" ", StringComparison.Ordinal))
{
normalized = normalized.Replace(" ", " ", StringComparison.Ordinal);
}
var emailStart = normalized.IndexOf('<', StringComparison.Ordinal);
var emailEnd = normalized.LastIndexOf('>');
if (emailStart > 0 && emailEnd > emailStart)
{
var name = normalized[..emailStart].Trim();
var email = normalized[(emailStart + 1)..emailEnd].Trim();
if (name.Equals(email, StringComparison.OrdinalIgnoreCase))
{
return email;
}
return $"{name} <{email}>";
}
return normalized;
}
private static string GetAttendeeDeduplicationKey(string attendee)
{
var emailStart = attendee.IndexOf('<', StringComparison.Ordinal);
var emailEnd = attendee.LastIndexOf('>');
if (emailStart > 0 && emailEnd > emailStart)
{
return attendee[(emailStart + 1)..emailEnd].Trim();
}
return attendee.Trim();
return CalendarAppointmentMetadata.NormalizeAttendees(attendees);
}
private static string FormatRecipient(object recipient)
@@ -1,4 +1,3 @@
using System.Diagnostics;
using MeetingAssistant.Calendar;
namespace MeetingAssistant.MeetingNotes;
@@ -36,54 +35,16 @@ public sealed class OutlookClassicMeetingMetadataProvider : IMeetingMetadataProv
TimeSpan timeout,
CancellationToken cancellationToken)
{
var stopwatch = Stopwatch.StartNew();
try
{
var lookup = OutlookClassicCom.RunStaAsync(
return await MeetingMetadataDiagnosticRunner.RunAsync(
nameof(OutlookClassicMeetingMetadataProvider),
startedAt,
timeout,
token => OutlookClassicCom.RunStaAsync(
() => GetCurrentMeeting(startedAt),
cancellationToken,
"Meeting Assistant Outlook COM Lookup");
var metadata = timeout > TimeSpan.Zero
? await lookup.WaitAsync(timeout, cancellationToken)
: await lookup.WaitAsync(cancellationToken);
stopwatch.Stop();
return new MeetingMetadataDiagnosticResult(
nameof(OutlookClassicMeetingMetadataProvider),
startedAt,
metadata is not null,
false,
stopwatch.ElapsedMilliseconds,
metadata,
metadata is null ? "No matching Teams appointment was found." : null);
}
catch (TimeoutException exception)
{
stopwatch.Stop();
return new MeetingMetadataDiagnosticResult(
nameof(OutlookClassicMeetingMetadataProvider),
startedAt,
false,
true,
stopwatch.ElapsedMilliseconds,
null,
exception.Message);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch (Exception exception)
{
stopwatch.Stop();
return new MeetingMetadataDiagnosticResult(
nameof(OutlookClassicMeetingMetadataProvider),
startedAt,
false,
false,
stopwatch.ElapsedMilliseconds,
null,
exception.Message);
}
token,
"Meeting Assistant Outlook COM Lookup"),
"No matching Teams appointment was found.",
cancellationToken);
}
private MeetingMetadata? GetCurrentMeeting(DateTimeOffset startedAt)
@@ -0,0 +1,14 @@
<?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>CFBundleIdentifier</key>
<string>cloud.schweigert.meeting-assistant.integrations</string>
<key>CFBundleName</key>
<string>Meeting Assistant Integrations</string>
<key>NSCalendarsUsageDescription</key>
<string>Meeting Assistant reads calendar events to enrich meeting notes and offer recording prompts.</string>
<key>NSCalendarsFullAccessUsageDescription</key>
<string>Meeting Assistant reads calendar events to enrich meeting notes and offer recording prompts.</string>
</dict>
</plist>
@@ -0,0 +1,68 @@
function safe(read, fallback) {
try {
const value = read();
return value === null || value === undefined ? fallback : value;
} catch (_) {
return fallback;
}
}
function participantText(participant) {
const name = String(safe(() => participant.displayName(), "")).trim();
const email = String(safe(() => participant.email(), "")).trim();
if (!name) {
return email;
}
if (!email || name.toLowerCase() === email.toLowerCase()) {
return name;
}
return `${name} <${email}>`;
}
function run(argv) {
if (argv.length !== 2) {
throw new Error("Usage: calendar-events.js <from-iso8601> <to-iso8601>");
}
const from = new Date(argv[0]);
const to = new Date(argv[1]);
const calendar = Application("Calendar");
const result = [];
for (const sourceCalendar of calendar.calendars()) {
const events = sourceCalendar.events.whose({
_and: [
{ startDate: { _lessThan: to } },
{ endDate: { _greaterThan: from } }
]
})();
for (const event of events) {
const start = event.startDate();
const end = event.endDate();
const attendees = safe(() => event.attendees(), [])
.map(participantText)
.filter(value => value.length > 0);
const organizer = String(safe(() => event.organizer(), "")).trim();
if (organizer && !attendees.includes(organizer)) {
attendees.unshift(organizer);
}
result.push({
id: String(safe(() => event.uid(), `${start.toISOString()}|${event.summary()}`)),
subject: String(safe(() => event.summary(), "")),
start: start.toISOString(),
end: end.toISOString(),
location: String(safe(() => event.location(), "")),
notes: String(safe(() => event.description(), "")),
url: String(safe(() => event.url(), "")),
attendees: attendees,
isCanceled: String(safe(() => event.status(), ""))
.toLowerCase()
.includes("cancel")
});
}
}
result.sort((left, right) => left.start.localeCompare(right.start));
return JSON.stringify(result);
}
@@ -0,0 +1,330 @@
import AppKit
import CoreGraphics
import EventKit
import Foundation
import ImageIO
import UniformTypeIdentifiers
private struct CalendarEventOutput: Encodable {
let id: String
let subject: String
let start: Date
let end: Date
let location: String
let notes: String
let url: String
let attendees: [String]
let isCanceled: Bool
}
private struct PromptRequest: Decodable {
let subject: String
let start: Date
}
private struct PromptOutput: Encodable {
let response: String
}
private struct ScreenshotOutput: Encodable {
let windowTitle: String?
}
private struct SelfTestOutput: Encodable {
let status: String
let features: [String]
}
private enum IntegrationError: LocalizedError {
case usage
case invalidDate(String)
case calendarPermission
case screenRecordingPermission
case noFrontmostApplication
case noActiveWindow
case screenshotCapture
case screenshotWrite
var errorDescription: String? {
switch self {
case .usage:
return "Usage: macos-meeting-integrations self-test | calendar <from-iso8601> <to-iso8601> | prompt <request.json> | screenshot <output.png> | crop <input.png> <output.png> <x> <y> <width> <height>"
case .invalidDate(let value):
return "Invalid ISO-8601 date: \(value)"
case .calendarPermission:
return "Calendar Full Access permission is required. Enable Meeting Assistant under System Settings > Privacy & Security > Calendars."
case .screenRecordingPermission:
return "Screen & System Audio Recording permission is required. Enable Meeting Assistant under System Settings > Privacy & Security."
case .noFrontmostApplication:
return "Could not determine the frontmost macOS application."
case .noActiveWindow:
return "Could not find an on-screen window for the frontmost macOS application."
case .screenshotCapture:
return "macOS could not capture the active application window."
case .screenshotWrite:
return "macOS could not write the active-window screenshot as PNG."
}
}
}
@main
private enum MacOsMeetingIntegrations {
@MainActor
static func main() async {
do {
let arguments = Array(CommandLine.arguments.dropFirst())
guard let command = arguments.first else {
throw IntegrationError.usage
}
switch command {
case "self-test":
try writeJson(SelfTestOutput(
status: "ok",
features: ["calendar", "prompt", "screenshot", "crop"]))
case "calendar":
guard arguments.count == 3 else {
throw IntegrationError.usage
}
let from = try parseDate(arguments[1])
let to = try parseDate(arguments[2])
try await writeCalendar(from: from, to: to)
case "prompt":
guard arguments.count == 2 else {
throw IntegrationError.usage
}
try showPrompt(requestPath: arguments[1])
case "screenshot":
guard arguments.count == 2 else {
throw IntegrationError.usage
}
try captureScreenshot(outputPath: arguments[1])
case "crop":
guard arguments.count == 7,
let x = Int(arguments[3]),
let y = Int(arguments[4]),
let width = Int(arguments[5]),
let height = Int(arguments[6]) else {
throw IntegrationError.usage
}
try cropScreenshot(
inputPath: arguments[1],
outputPath: arguments[2],
x: x,
y: y,
width: width,
height: height)
default:
throw IntegrationError.usage
}
} catch {
fputs("\(error.localizedDescription)\n", stderr)
if let integrationError = error as? IntegrationError,
case .calendarPermission = integrationError {
exit(77)
}
exit(1)
}
}
private static func parseDate(_ value: String) throws -> Date {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
if let date = formatter.date(from: value) {
return date
}
formatter.formatOptions = [.withInternetDateTime]
guard let date = formatter.date(from: value) else {
throw IntegrationError.invalidDate(value)
}
return date
}
private static func writeCalendar(from: Date, to: Date) async throws {
let store = EKEventStore()
guard try await requestCalendarAccess(store) else {
throw IntegrationError.calendarPermission
}
let predicate = store.predicateForEvents(withStart: from, end: to, calendars: nil)
let events = store.events(matching: predicate)
.map { event in
CalendarEventOutput(
id: event.eventIdentifier ?? "\(event.startDate.timeIntervalSince1970)|\(event.title ?? "")",
subject: event.title ?? "",
start: event.startDate,
end: event.endDate,
location: event.location ?? "",
notes: event.notes ?? "",
url: event.url?.absoluteString ?? "",
attendees: attendees(from: event),
isCanceled: event.status == .canceled)
}
.sorted { $0.start < $1.start }
try writeJson(events)
}
private static func requestCalendarAccess(_ store: EKEventStore) async throws -> Bool {
if #available(macOS 14.0, *) {
return try await store.requestFullAccessToEvents()
}
return try await withCheckedThrowingContinuation { continuation in
store.requestAccess(to: .event) { granted, error in
if let error {
continuation.resume(throwing: error)
} else {
continuation.resume(returning: granted)
}
}
}
}
private static func attendees(from event: EKEvent) -> [String] {
var values: [String] = []
if let organizer = event.organizer,
let value = participantText(organizer) {
values.append(value)
}
for participant in event.attendees ?? [] {
if let value = participantText(participant) {
values.append(value)
}
}
return Array(NSOrderedSet(array: values)) as? [String] ?? values
}
private static func participantText(_ participant: EKParticipant) -> String? {
let name = participant.name?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let email = participant.url
.absoluteString
.replacingOccurrences(of: "mailto:", with: "", options: .caseInsensitive)
if name.isEmpty {
return email.isEmpty ? nil : email
}
if email.isEmpty || name.caseInsensitiveCompare(email) == .orderedSame {
return name
}
return "\(name) <\(email)>"
}
@MainActor
private static func showPrompt(requestPath: String) throws {
let data = try Data(contentsOf: URL(fileURLWithPath: requestPath))
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
let request = try decoder.decode(PromptRequest.self, from: data)
let application = NSApplication.shared
application.setActivationPolicy(.accessory)
application.activate(ignoringOtherApps: true)
let alert = NSAlert()
alert.messageText = "Record meeting?"
alert.informativeText = "\(request.subject) starts at \(request.start.formatted(date: .omitted, time: .shortened))."
alert.alertStyle = .informational
alert.addButton(withTitle: "Record")
alert.addButton(withTitle: "Skip")
let response = alert.runModal() == .alertFirstButtonReturn ? "record" : "skip"
try writeJson(PromptOutput(response: response))
}
private static func captureScreenshot(outputPath: String) throws {
guard CGPreflightScreenCaptureAccess() || CGRequestScreenCaptureAccess() else {
throw IntegrationError.screenRecordingPermission
}
guard let frontmost = NSWorkspace.shared.frontmostApplication else {
throw IntegrationError.noFrontmostApplication
}
guard let windows = CGWindowListCopyWindowInfo(
[.optionOnScreenOnly, .excludeDesktopElements],
kCGNullWindowID) as? [[String: Any]] else {
throw IntegrationError.noActiveWindow
}
let candidate = windows.first { window in
let ownerPid = window[kCGWindowOwnerPID as String] as? Int32
let layer = window[kCGWindowLayer as String] as? Int
let alpha = window[kCGWindowAlpha as String] as? Double
guard ownerPid == frontmost.processIdentifier,
layer == 0,
(alpha ?? 1) > 0,
let bounds = window[kCGWindowBounds as String] as? [String: Any],
let width = bounds["Width"] as? Double,
let height = bounds["Height"] as? Double else {
return false
}
return width > 1 && height > 1
}
guard let candidate,
let windowNumber = candidate[kCGWindowNumber as String] as? UInt32 else {
throw IntegrationError.noActiveWindow
}
guard let image = CGWindowListCreateImage(
.null,
.optionIncludingWindow,
CGWindowID(windowNumber),
[.boundsIgnoreFraming, .bestResolution]) else {
throw IntegrationError.screenshotCapture
}
try writePng(image, to: outputPath)
try writeJson(ScreenshotOutput(
windowTitle: candidate[kCGWindowName as String] as? String))
}
private static func cropScreenshot(
inputPath: String,
outputPath: String,
x: Int,
y: Int,
width: Int,
height: Int
) throws {
guard x >= 0,
y >= 0,
width > 0,
height > 0,
let source = CGImageSourceCreateWithURL(
URL(fileURLWithPath: inputPath) as CFURL,
nil),
let image = CGImageSourceCreateImageAtIndex(source, 0, nil),
x + width <= image.width,
y + height <= image.height,
let cropped = image.cropping(to: CGRect(
x: x,
y: y,
width: width,
height: height)) else {
throw IntegrationError.screenshotCapture
}
try writePng(cropped, to: outputPath)
try writeJson(["status": "ok"])
}
private static func writePng(_ image: CGImage, to outputPath: String) throws {
let outputUrl = URL(fileURLWithPath: outputPath) as CFURL
guard let destination = CGImageDestinationCreateWithURL(
outputUrl,
UTType.png.identifier as CFString,
1,
nil) else {
throw IntegrationError.screenshotWrite
}
CGImageDestinationAddImage(destination, image, nil)
guard CGImageDestinationFinalize(destination) else {
throw IntegrationError.screenshotWrite
}
}
private static func writeJson<T: Encodable>(_ value: T) throws {
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
encoder.outputFormatting = [.sortedKeys]
FileHandle.standardOutput.write(try encoder.encode(value))
FileHandle.standardOutput.write(Data([0x0A]))
}
}
@@ -0,0 +1,52 @@
using MeetingAssistant.Calendar;
using MeetingAssistant.MeetingNotes;
using MeetingAssistant.Screenshots;
using MeetingAssistant.Workflow;
namespace MeetingAssistant;
public sealed record PlatformCapabilityReport(
string Host,
string MeetingMetadataProvider,
string CalendarMeetingProvider,
string MeetingStartPromptProvider,
string ScreenshotProvider,
string WorkflowEditorProvider,
bool WorkflowEditorAvailable,
bool CalendarRecordingPromptsEnabled,
bool SpeakerIdentificationEnabled,
bool ScreenshotOcrEnabled,
bool FunAsrBackendEnabled,
bool FunAsrDiarizationEnabled,
bool WhisperLocalDiarizationEnabled)
{
public static PlatformCapabilityReport Create(
HostOperatingSystem host,
MeetingAssistantOptions options,
IMeetingMetadataProvider meetingMetadataProvider,
ICalendarMeetingProvider calendarMeetingProvider,
IMeetingStartPromptService meetingStartPromptProvider,
IActiveWindowScreenshotCapture screenshotProvider,
IWorkflowRulesEditorWindowService workflowEditorProvider)
{
return new PlatformCapabilityReport(
host.Kind switch
{
HostOperatingSystemKind.Windows => "Windows",
HostOperatingSystemKind.MacOS => "macOS",
_ => "Unsupported"
},
meetingMetadataProvider.GetType().Name,
calendarMeetingProvider.GetType().Name,
meetingStartPromptProvider.GetType().Name,
screenshotProvider.GetType().Name,
workflowEditorProvider.GetType().Name,
workflowEditorProvider is not NoopWorkflowRulesEditorWindowService,
options.CalendarRecordingPrompts.Enabled,
options.SpeakerIdentification.Enabled,
options.Screenshots.Ocr.Enabled,
options.FunAsr.Backend.Enabled,
options.FunAsr.Diarization.Enabled,
options.WhisperLocal.Diarization.Enabled);
}
}
+18 -8
View File
@@ -38,7 +38,7 @@ builder.Services.AddSingleton<IMeetingAudioSource>(services => new CompositeMeet
builder.Services.AddSingleton<IMeetingMetadataProvider, OutlookClassicMeetingMetadataProvider>();
#else
PortableMeetingAudioRegistration.Add(builder.Services);
builder.Services.AddSingleton<IMeetingMetadataProvider, NoopMeetingMetadataProvider>();
MacOsMeetingIntegrationRegistration.Add(builder.Services, OperatingSystem.IsMacOS());
builder.Services.AddHostedService<MacOsDesktopControlService>();
#endif
builder.Services.AddSingleton<ITranscriptStore, VaultTranscriptStore>();
@@ -69,14 +69,8 @@ builder.Services.AddSingleton<IMeetingStartPromptService>(services =>
services.GetRequiredService<WindowsMeetingStartPromptService>());
builder.Services.AddHostedService(services =>
services.GetRequiredService<WindowsMeetingStartPromptService>());
#else
builder.Services.AddSingleton<ICalendarMeetingProvider, NoopCalendarMeetingProvider>();
builder.Services.AddSingleton<IMeetingStartPromptService, NoopMeetingStartPromptService>();
#endif
#if WINDOWS
builder.Services.AddSingleton<IActiveWindowScreenshotCapture, ActiveWindowScreenshotCapture>();
#else
builder.Services.AddSingleton<IActiveWindowScreenshotCapture, UnavailableActiveWindowScreenshotCapture>();
builder.Services.AddSingleton<IScreenshotImageCropper, WindowsScreenshotImageCropper>();
#endif
builder.Services.AddSingleton<IScreenshotOcrClient, LiteLlmScreenshotOcrClient>();
builder.Services.AddSingleton<MeetingScreenshotService>();
@@ -150,6 +144,22 @@ app.MapGet("/health", () => Results.Ok(new
service = "meeting-assistant",
status = "ok"
}));
app.MapGet("/diagnostics/platform-capabilities", (
HostOperatingSystem host,
IOptions<MeetingAssistantOptions> options,
IMeetingMetadataProvider meetingMetadataProvider,
ICalendarMeetingProvider calendarMeetingProvider,
IMeetingStartPromptService meetingStartPromptProvider,
IActiveWindowScreenshotCapture screenshotProvider,
IWorkflowRulesEditorWindowService workflowEditorProvider) =>
Results.Ok(PlatformCapabilityReport.Create(
host,
options.Value,
meetingMetadataProvider,
calendarMeetingProvider,
meetingStartPromptProvider,
screenshotProvider,
workflowEditorProvider)));
app.MapGet("/recording/status", (MeetingRecordingCoordinator coordinator) => Results.Ok(coordinator.CurrentStatus));
app.MapGet("/profiles/{launchProfile}/recording/status", (
@@ -1,7 +1,4 @@
using System.Collections.Concurrent;
using System.Drawing;
using System.Drawing.Imaging;
using System.Runtime.InteropServices;
using System.Text.RegularExpressions;
using MeetingAssistant.MeetingNotes;
using MeetingAssistant.Speakers;
@@ -46,6 +43,25 @@ public sealed record ScreenshotOcrResult
public sealed record ScreenshotCropCoordinates(int X, int Y, int Width, int Height);
public interface IScreenshotImageCropper
{
Task<string?> SaveCroppedScreenshotAsync(
string screenshotPath,
ScreenshotCropCoordinates crop,
CancellationToken cancellationToken);
}
internal sealed class UnavailableScreenshotImageCropper : IScreenshotImageCropper
{
public Task<string?> SaveCroppedScreenshotAsync(
string screenshotPath,
ScreenshotCropCoordinates crop,
CancellationToken cancellationToken)
{
return Task.FromResult<string?>(null);
}
}
public interface IMeetingScreenshotService
{
Task<MeetingScreenshotCaptureResult> CaptureAsync(
@@ -147,6 +163,7 @@ public sealed partial class MeetingScreenshotService :
IMeetingNoteImageOcrService
{
private readonly IActiveWindowScreenshotCapture screenshotCapture;
private readonly IScreenshotImageCropper screenshotImageCropper;
private readonly IMeetingArtifactStore artifactStore;
private readonly IMeetingNoteStore meetingNoteStore;
private readonly ISpeakerIdentityAttendeeCanonicalizer attendeeCanonicalizer;
@@ -162,6 +179,7 @@ public sealed partial class MeetingScreenshotService :
IMeetingNoteStore meetingNoteStore,
ISpeakerIdentityAttendeeCanonicalizer attendeeCanonicalizer,
IScreenshotOcrClient ocrClient,
IScreenshotImageCropper screenshotImageCropper,
ILogger<MeetingScreenshotService> logger,
IMeetingWorkflowEngine? meetingWorkflowEngine = null)
{
@@ -171,6 +189,7 @@ public sealed partial class MeetingScreenshotService :
this.attendeeCanonicalizer = attendeeCanonicalizer;
this.meetingWorkflowEngine = meetingWorkflowEngine ?? NoopMeetingWorkflowEngine.Instance;
this.ocrClient = ocrClient;
this.screenshotImageCropper = screenshotImageCropper;
this.logger = logger;
}
@@ -639,17 +658,20 @@ public sealed partial class MeetingScreenshotService :
return "";
}
if (!OperatingSystem.IsWindows())
{
logger.LogWarning(
"Ignoring screenshot crop coordinates for {ScreenshotPath} because image cropping is only supported on Windows",
screenshotPath);
return "";
}
try
{
var croppedPath = await SaveCroppedScreenshotAsync(screenshotPath, crop, cancellationToken);
var croppedPath = await screenshotImageCropper.SaveCroppedScreenshotAsync(
screenshotPath,
crop,
cancellationToken);
if (croppedPath is null)
{
logger.LogWarning(
"Ignoring screenshot crop coordinates for {ScreenshotPath} because no image cropper is available on this host",
screenshotPath);
return "";
}
var relativePath = ToMarkdownPath(Path.GetRelativePath(
Path.GetDirectoryName(assistantContextPath)!,
croppedPath));
@@ -657,7 +679,7 @@ public sealed partial class MeetingScreenshotService :
Environment.NewLine +
Environment.NewLine;
}
catch (Exception exception) when (exception is InvalidDataException or ArgumentException or ExternalException)
catch (Exception exception) when (exception is InvalidDataException or ArgumentException)
{
logger.LogWarning(
exception,
@@ -667,39 +689,6 @@ public sealed partial class MeetingScreenshotService :
}
}
#pragma warning disable CA1416
private static Task<string> SaveCroppedScreenshotAsync(
string screenshotPath,
ScreenshotCropCoordinates crop,
CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
using var original = new Bitmap(screenshotPath);
ValidateCrop(crop, original.Width, original.Height);
var cropRectangle = new Rectangle(crop.X, crop.Y, crop.Width, crop.Height);
using var cropped = original.Clone(cropRectangle, original.PixelFormat);
var croppedPath = Path.Combine(
Path.GetDirectoryName(screenshotPath)!,
$"{Path.GetFileNameWithoutExtension(screenshotPath)}-cropped.png");
cropped.Save(croppedPath, ImageFormat.Png);
return Task.FromResult(croppedPath);
}
#pragma warning restore CA1416
private static void ValidateCrop(ScreenshotCropCoordinates crop, int imageWidth, int imageHeight)
{
if (crop.X < 0 ||
crop.Y < 0 ||
crop.Width <= 0 ||
crop.Height <= 0 ||
crop.X + crop.Width > imageWidth ||
crop.Y + crop.Height > imageHeight)
{
throw new InvalidDataException(
$"Screenshot crop {crop.X},{crop.Y},{crop.Width},{crop.Height} is outside image bounds {imageWidth}x{imageHeight}.");
}
}
private void TrackOcr(
MeetingSessionArtifacts artifacts,
CancellationTokenSource cancellation,
@@ -0,0 +1,55 @@
#if WINDOWS
using System.Drawing;
using System.Drawing.Imaging;
using System.Runtime.InteropServices;
namespace MeetingAssistant.Screenshots;
internal sealed class WindowsScreenshotImageCropper : IScreenshotImageCropper
{
#pragma warning disable CA1416
public Task<string?> SaveCroppedScreenshotAsync(
string screenshotPath,
ScreenshotCropCoordinates crop,
CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
try
{
using var original = new Bitmap(screenshotPath);
ValidateCrop(crop, original.Width, original.Height);
var cropRectangle = new Rectangle(crop.X, crop.Y, crop.Width, crop.Height);
using var cropped = original.Clone(cropRectangle, original.PixelFormat);
var croppedPath = Path.Combine(
Path.GetDirectoryName(screenshotPath)!,
$"{Path.GetFileNameWithoutExtension(screenshotPath)}-cropped.png");
cropped.Save(croppedPath, ImageFormat.Png);
return Task.FromResult<string?>(croppedPath);
}
catch (ExternalException exception)
{
throw new InvalidDataException(
$"The screenshot crop {crop.X},{crop.Y},{crop.Width},{crop.Height} could not be applied.",
exception);
}
}
#pragma warning restore CA1416
private static void ValidateCrop(
ScreenshotCropCoordinates crop,
int imageWidth,
int imageHeight)
{
if (crop.X < 0 ||
crop.Y < 0 ||
crop.Width <= 0 ||
crop.Height <= 0 ||
crop.X + crop.Width > imageWidth ||
crop.Y + crop.Height > imageHeight)
{
throw new InvalidDataException(
$"Screenshot crop {crop.X},{crop.Y},{crop.Width},{crop.Height} is outside image bounds {imageWidth}x{imageHeight}.");
}
}
}
#endif
+6 -3
View File
@@ -32,7 +32,7 @@ dotnet build MeetingAssistant/MeetingAssistant.csproj -f net10.0
dotnet run --project MeetingAssistant/MeetingAssistant.csproj -f net10.0
```
The macOS build compiles bundled Swift helpers into `Native/macos-meeting-audio-capture` and `Native/macos-desktop-controls`. On first recording, allow both **Microphone** and **Screen & System Audio Recording** under System Settings > Privacy & Security. The menu-bar icon and global hotkeys use the same configured bindings as Windows and call the local service surface.
The macOS build compiles bundled Swift helpers into `Native/macos-meeting-audio-capture`, `Native/macos-desktop-controls`, and `Native/macos-meeting-integrations`. On first use, allow **Microphone**, **Screen & System Audio Recording**, and **Calendar Full Access** under System Settings > Privacy & Security. The menu-bar icon and global hotkeys use the same configured bindings as Windows and call the local service surface.
For the durable local background instance, use the snippets restart helper:
@@ -75,6 +75,7 @@ The main local endpoints are:
- `POST /diagnostics/workflow/reload`
- `POST /diagnostics/settings-and-logs/show`
- `POST /diagnostics/workflow/rules-editor/show`
- `GET /diagnostics/platform-capabilities`
- `POST /meetings/current/summary/run`
- `POST` or `GET /meetings/summary/retry`
- `POST` or `GET /meetings/screenshot-ocr/retry`
@@ -115,7 +116,7 @@ Important settings:
- `Recording:InactivitySafeguard`: prompts and then auto-stops forgotten silent recordings without aborting artifacts.
- `LaunchProfiles`: overlays named profile settings onto the default profile; profile hotkeys must be distinct.
- `Automation:RulesPath`: points to the local YAML workflow-rules file. The default `meeting-rules.local.yaml` is ignored by git.
- `CalendarRecordingPrompts`: enables Outlook Classic Teams-start prompts on Windows.
- `CalendarRecordingPrompts`: enables Teams-start prompts from Outlook Classic on Windows or EventKit calendars on macOS.
- `Screenshots`: controls the capture hotkey, attachment folder, and optional OCR/vision model.
- `Agent`: configures the OpenAI-compatible summary/project agent endpoint, model, retries, output limits, and compaction.
- `WorkflowRulesEditor`: optionally overrides the agent settings for the tray-launched assistant.
@@ -132,6 +133,8 @@ See `docs/meeting-assistant-configuration.md` for the full configuration referen
- **Obsidian vault**: primary durable store for notes, transcripts, summaries, assistant context, project files, and generated links.
- **Outlook Classic on Windows**: optional COM metadata lookup and scheduled Teams-meeting start prompts.
- **EventKit on macOS**: metadata lookup and native recording prompts for Teams events in calendars available to macOS, including Outlook-synced calendars.
- **Native active-window capture**: Windows foreground-window capture or macOS frontmost-application window capture, feeding the same screenshot/OCR pipeline.
- **Azure AI Speech**: default checked-in ASR path, live diarized conversation transcription, and speaker identity matching.
- **FunASR**: optional WebSocket streaming ASR. When managed backend startup is enabled, the app pulls and runs the configured Docker image as `meeting-assistant-funasr` on port `10095`.
- **Whisper.NET plus pyannote**: optional local Whisper fallback and Docker-backed final diarization.
@@ -144,7 +147,7 @@ Meeting-specific automation lives in a local YAML file, not in committed persona
The Windows tray and macOS menu-bar menus expose `Open agent`, which opens the `Meeting Summary Agent` window. It can edit workflow rules with validation, inspect logs and health/status, manage speaker identities and samples, run ASR diagnostics, and read/write scoped meeting/project artifacts through explicit tools. Windows renders the chat with WPF; macOS renders the same agent pipeline in a native AppKit window backed by WebKit.
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, and global hotkeys are available in the portable build; Outlook, Windows notifications, and active-window screenshot capture remain Windows-only.
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.
Detailed workflow syntax and extension guidance live in `docs/meeting-workflow-engine.md`.
+10 -6
View File
@@ -131,13 +131,13 @@ The default profile is always named `default`. Non-default profile hotkeys are r
During recording, Meeting Assistant captures microphone and system loopback separately, buffers both streams to align samples, cleans the microphone stream through a local adaptive echo canceller using loopback as the far-end reference, then mixes the cleaned microphone and system streams into the normal 16 kHz mono PCM chunks. If one source is quiet beyond the alignment timeout, the available source is mixed with synthetic silence so microphone-only speech and system-only playback keep flowing to transcription.
On macOS, the portable target captures the default microphone through AVFoundation and computer output through ScreenCaptureKit. The build compiles `Native/MacOsMeetingAudioCapture/main.swift` into the application output and publish `Native` folder; build or publish on macOS with Xcode Command Line Tools installed. The first capture requires both **Microphone** and **Screen & System Audio Recording** permissions under System Settings > Privacy & Security. Restart the application after granting a newly requested permission. `Recording:MicrophoneDeviceId` and runtime microphone selection remain Windows-only; macOS follows the system default input device.
On macOS, the portable target captures the default microphone through AVFoundation and computer output through ScreenCaptureKit. The build compiles the Swift audio and desktop-integration helpers into the application output and publish `Native` folder; build or publish on macOS with Xcode Command Line Tools installed. The first capture requires both **Microphone** and **Screen & System Audio Recording** permissions under System Settings > Privacy & Security. Restart the application after granting a newly requested permission. `Recording:MicrophoneDeviceId` and runtime microphone selection remain Windows-only; macOS follows the system default input device.
On Windows, `Recording:MicrophoneDeviceId` can pin capture to a specific active microphone endpoint id. Leave it blank to follow the Windows default capture endpoint. The tray icon menu also exposes `Microphone`, listing active microphone endpoints with the effective endpoint checked. Selecting a microphone there overrides the configured/default microphone for later recording starts until another microphone is selected or the process exits.
`Recording:MicrophoneMixGain` and `Recording:SystemAudioMixGain` are applied during the final mix and default to `1`. `Recording:TemporaryRecordingsFolder` controls where the temporary mixed WAV is written while the run is active. Temporary WAV files are deleted after the run completes, and stale temporary recordings from interrupted runs are deleted when the application starts. If an Azure Speech meeting cannot drain transcription before `Recording:StopProcessingTimeout`, Meeting Assistant keeps the WAV and writes a durable backlog item under `TemporaryRecordingsFolder\offline-transcription-backlog`. The background backlog worker retries those queued meetings, replays each WAV through a fresh speech pipeline, rewrites the original transcript, completes meeting metadata and summary generation, then removes the backlog item and WAV.
`Recording:MaxMetadataAttendeeImportCount` limits how many attendees Outlook metadata enrichment imports into meeting-note frontmatter. The default is `30`; when an appointment has more attendees than that, Meeting Assistant still imports title, agenda, and scheduled end time, but leaves attendees empty because large invites are usually presentation-style meetings.
`Recording:MaxMetadataAttendeeImportCount` limits how many attendees calendar metadata enrichment imports into meeting-note frontmatter. The default is `30`; when an appointment has more attendees than that, Meeting Assistant still imports title, agenda, and scheduled end time, but leaves attendees empty because large invites are usually presentation-style meetings. Windows reads Outlook Classic through COM. macOS reads EventKit calendars, including Outlook accounts synchronized into macOS Calendar, and requires **Calendar Full Access**.
`Recording:InactivitySafeguard` watches active recordings for long periods without transcript text. The timer starts at meeting start and resets whenever a live transcript segment with text arrives. By default the app asks whether to stop after 2, 5, and 10 minutes of inactivity through native Windows app notifications with action buttons, requests reminder-style toast behavior, keeps each stop reminder actionable for 1 minute, and automatically stops normally after 30 minutes. Ignoring a notification does not block later checks or auto-stop. Safeguard stops are not aborts: transcription drain, speaker processing, screenshots, and summary generation continue through the normal stop flow. When the safeguard stops a run, the meeting end time is inferred as the last transcript segment timestamp plus `InferredEndPadding`; if no transcript text arrived, it uses meeting start plus the same padding.
@@ -305,20 +305,22 @@ See `docs/meeting-workflow-engine.md` for the detailed YAML format, supported va
## Calendar Recording Prompts
`CalendarRecordingPrompts` controls optional Outlook Classic calendar prompts for starting recordings. It is enabled by default on Windows.
`CalendarRecordingPrompts` controls optional calendar prompts for starting recordings. It is enabled by default on Windows and macOS.
When enabled on Windows, Meeting Assistant periodically syncs today's Outlook Classic calendar appointments through COM, filters all non-canceled Teams meeting markers for the day into an in-memory cache, and schedules prompt checks from that cache. It does not query Outlook for every prompt. The notification asks `Record meeting?` with `Yes` and `No` actions when a cached Teams meeting reaches its start window, requests reminder-style toast behavior, and remains actionable for 5 minutes. Accepting starts a recording. If another recording is active, Meeting Assistant stops that recording through the normal stop path first, then starts the new recording, so the usual empty/too-short cleanup and summary handoff rules still apply.
Meeting Assistant periodically syncs today's calendar appointments, filters all non-canceled Teams meeting markers for the day into an in-memory cache, and schedules prompt checks from that cache. Windows reads Outlook Classic through COM and displays a Windows app notification. macOS prefers EventKit and displays a native AppKit `Record`/`Skip` alert; the first read requests Calendar Full Access. When EventKit access is denied, it falls back to Calendar application automation so an already-authorized local Calendar setup remains usable. It does not query the calendar for every prompt. Accepting starts a recording. If another recording is active, Meeting Assistant stops that recording through the normal stop path first, then starts the new recording, so the usual empty/too-short cleanup and summary handoff rules still apply.
| Setting | Purpose |
| --- | --- |
| `Enabled` | Enables scheduled Outlook Teams recording-start prompts. |
| `SyncInterval` | How often Meeting Assistant refreshes today's Outlook calendar cache. Defaults to 30 minutes when unset or invalid. |
| `Enabled` | Enables scheduled Teams recording-start prompts from the platform calendar. |
| `SyncInterval` | How often Meeting Assistant refreshes today's calendar cache. Defaults to 30 minutes when unset or invalid. |
| `PromptWindow` | How long after a Teams appointment's scheduled start it remains eligible for the one-time prompt. Defaults to 5 minutes when unset or invalid. |
## Screenshots
`Screenshots:Hotkey` configures a global hotkey that captures the currently active window during an active meeting. Screenshots are written under `Screenshots:AttachmentsFolder`, which defaults to an `Attachments` folder beside the assistant context note, and each capture appends a timestamped markdown image link to the assistant context.
Windows captures the foreground window through the existing Win32 provider. macOS captures the foremost on-screen window belonging to the frontmost application and requires **Screen & System Audio Recording** permission. Both paths feed the same attachment, OCR, crop, attendee-enrichment, retry, and summary-wait pipeline.
`Screenshots:Ocr` optionally enables vision extraction for screenshots. Blank `Endpoint` or `Model` values fall back to the summary `Agent` endpoint and model. `Key` or `KeyEnv` can be set specifically for OCR; otherwise the summary agent key configuration is used. Screenshot OCR also inherits `Agent:UseStreaming`, using the Responses SSE transport when it is `true` and the non-streaming Responses transport when it is `false`. Automatic summarization scans the meeting note for user-added Obsidian image embeds such as `![[whiteboard.png]]` and Markdown image embeds such as `![Diagram](attachments/diagram.png)`, adds resolvable local images to the assistant context without copying them or changing the meeting note, runs OCR without crop or attendee updates, and waits for all pending OCR work to complete or hit `Timeout` before the assistant context moves to `summarizing`. Failed or timed-out screenshot OCR writes a retry link that targets `/meetings/screenshot-ocr/retry` with the exact saved screenshot and OCR block id.
| Setting | Purpose |
@@ -391,3 +393,5 @@ On startup it rotates the previous current file to `meeting-assistant.log.1` and
## API
`Api:PublicBaseUrl` is used to build clickable local links, such as summary retry links in generated failure notes.
`GET /diagnostics/platform-capabilities` reports the detected host, concrete metadata/calendar/prompt/screenshot/workflow-editor provider names, explicit workflow-editor availability, and effective enabled switches for calendar prompts, speaker identification, screenshot OCR, the FunASR backend, FunASR diarization, and Whisper-local diarization. It is read-only and is the quickest way to confirm that a platform is not using no-op providers.
@@ -0,0 +1,37 @@
## Context
The macOS build already bundles Swift helpers for audio and menu-bar controls. Calendar and screenshot integrations are currently replaced with no-op providers at compile time, while the ASR, diarization, speaker, OCR, and workflow-agent implementations themselves are portable.
## Decisions
### EventKit is the preferred macOS calendar source
A bundled Swift helper reads events through EventKit. This covers calendars exposed to macOS, including Outlook accounts synchronized into Calendar, without linking Windows COM into the portable target. If Calendar Full Access is denied, the managed client falls back to the already-authorized Calendar application's automation dictionary. The helper uses a stable permission-denied exit code rather than coupling the fallback to human-readable error text. The managed providers retain the existing Teams-marker and current-meeting selection rules.
### Native AppKit owns the recording prompt
The same helper displays a record/skip AppKit alert and returns the selected response to the existing `IMeetingStartPromptService` callback. Recording policy remains in the existing scheduler/controller.
### CoreGraphics captures the active app window
The helper resolves the frontmost application and captures its foremost on-screen window as PNG. The managed screenshot service continues to own attachment naming, note updates, OCR, cropping, attendee enrichment, retries, and waits.
### Portable managed features stay managed
Screenshot OCR, speaker identity, Docker-backed FunASR, FunASR diarization, Whisper-local pyannote diarization, and the AppKit/WebKit workflow editor use the same managed implementations on macOS and Windows. Platform branching is limited to the native input providers.
### Diagnostics report effective behavior
A read-only endpoint returns the host platform, concrete provider names, and effective enabled switches. This supplies deterministic proof without starting a recording or mutating meeting artifacts.
## Windows compatibility
The Windows target keeps its existing conditional registrations and does not compile or invoke the macOS helper. Verification must build the Windows target in addition to the portable target and tests.
## Verification
- Regression tests prove macOS resolves real providers and reports enabled capabilities.
- Provider behavior tests prove EventKit JSON maps to calendar metadata, native prompt responses reach the callback, and PNG screenshot output reaches the screenshot interface.
- The Swift helper provides deterministic self-test output.
- A live macOS instance proves health, provider diagnostics, native helper packaging, calendar access, and screenshot capture.
- A Windows-target build proves the existing Windows client still compiles with its native providers.
@@ -0,0 +1,47 @@
## Verification evidence
Verified on macOS 26 on 2026-07-25.
### Automated checks
- Focused macOS/calendar/screenshot/OCR tests: 35 passed, 0 failed.
- Full portable test project: 441 passed, 6 failed, 447 total. The six failures are existing macOS-incompatible tests for Windows path assumptions, GDI+ taskbar rendering, and user-environment fallback; none cover this change.
- Portable `net10.0` build: succeeded.
- Windows `net10.0-windows10.0.19041.0` target build with Windows targeting enabled: succeeded with 0 warnings and 0 errors.
- `openspec validate add-macos-meeting-integrations --strict`: valid.
- Sequential DRY, SOLID, and KISS reviews were completed and their findings implemented.
- Final independent standards and spec reviews reported no findings.
### Live macOS installation
The recording status was idle before deployment. The repository updater published the portable build, installed it into `~/Library/Application Support/MeetingAssistant/app`, restarted the LaunchAgent, and passed its health check.
`GET /diagnostics/platform-capabilities` returned:
```json
{
"host": "macOS",
"meetingMetadataProvider": "MacOsMeetingMetadataProvider",
"calendarMeetingProvider": "MacOsCalendarMeetingProvider",
"meetingStartPromptProvider": "MacOsMeetingStartPromptService",
"screenshotProvider": "MacOsDesktopIntegrationClient",
"workflowEditorProvider": "MacOsWorkflowRulesEditorWindowService",
"workflowEditorAvailable": true,
"calendarRecordingPromptsEnabled": true,
"speakerIdentificationEnabled": true,
"screenshotOcrEnabled": true,
"funAsrBackendEnabled": true,
"funAsrDiarizationEnabled": true,
"whisperLocalDiarizationEnabled": true
}
```
The installed arm64 Mach-O helper returned:
```json
{"features":["calendar","prompt","screenshot","crop"],"status":"ok"}
```
Direct native screenshot capture selected the foreground ChatGPT window and produced a valid 3258 by 1920 PNG. The asymmetric native crop regression test also proves OCR top-left pixel coordinates are preserved.
The live calendar diagnostic returned `provider=MacOsMeetingMetadataProvider`, `timedOut=false`, and a normal no-matching-event result. EventKit reported missing Full Calendar Access with stable exit code 77, after which the Calendar automation fallback completed; the scheduler logged `Synced 0 Teams calendar meetings`.
@@ -0,0 +1,29 @@
## Why
The portable macOS application can capture meeting audio and expose menu-bar controls, but it still registers unavailable/no-op implementations for calendar metadata, recording prompts, and active-window screenshots. The local macOS environment also disables screenshot OCR, speaker identification, the managed FunASR backend, FunASR diarization, and Whisper-local diarization even though their managed implementations are portable.
## What Changes
- Read Teams meeting metadata and prompt candidates from calendars available through macOS EventKit, including Outlook-synced calendars.
- Show a native macOS record/skip prompt for due calendar meetings.
- Capture the foreground macOS application window through a bundled native helper so screenshot OCR can use the existing portable OCR pipeline.
- Register the real macOS integrations instead of no-op/unavailable providers.
- Enable the portable OCR, speaker identification, FunASR, and diarization configuration in the macOS runtime environment.
- Expose a diagnostic capability surface that proves the effective platform providers and enabled feature switches.
- Preserve all Windows-specific COM, notification, screenshot, tray, hotkey, and audio registrations.
## Capabilities
### Modified Capabilities
- `meeting-session`: calendar enrichment, recording prompts, workflow editor access, and capability diagnostics work on macOS.
- `meeting-summary`: active-window screenshots and screenshot OCR work on macOS.
- `meeting-transcription`: portable speaker identification, FunASR, and local diarization can be enabled on macOS.
## Impact
- portable service registration in `MeetingAssistant/Program.cs`
- a bundled native macOS desktop-integration helper
- calendar, meeting-metadata, screenshot, and diagnostic providers
- macOS runtime configuration and operational documentation
- macOS behavior tests plus Windows-target build verification
@@ -0,0 +1,41 @@
## MODIFIED Requirements
### Requirement: Meeting metadata is captured when available
Meeting Assistant SHALL enrich a meeting from a matching calendar appointment when platform calendar metadata is available.
On Windows, Meeting Assistant SHALL retain Outlook Classic COM metadata lookup.
On macOS, Meeting Assistant SHALL read calendars available through EventKit, including Outlook-synced calendars, and SHALL apply the existing Teams-marker and current-meeting selection rules.
#### Scenario: macOS meeting uses calendar metadata
- **GIVEN** an EventKit calendar contains a Teams appointment spanning the meeting start time
- **WHEN** Meeting Assistant enriches the meeting on macOS
- **THEN** it applies the appointment title, attendees, agenda, and scheduled end time
#### Scenario: Windows metadata provider remains unchanged
- **GIVEN** Meeting Assistant is compiled for the Windows target
- **WHEN** meeting metadata is registered
- **THEN** it uses the Outlook Classic COM provider
- **AND** does not invoke or require the macOS native helper
### Requirement: Meeting Assistant can prompt to record scheduled Teams meetings
Meeting Assistant SHALL optionally inspect the host calendar for scheduled Teams meetings and show a native record-or-skip prompt when a meeting enters the configured prompt window.
On Windows, Meeting Assistant SHALL retain Outlook Classic COM lookup and Windows app notifications.
On macOS, Meeting Assistant SHALL use EventKit calendar events and a native AppKit record-or-skip prompt.
#### Scenario: macOS calendar meeting becomes due
- **GIVEN** calendar recording prompts are enabled on macOS
- **AND** an EventKit Teams appointment enters the prompt window
- **WHEN** the scheduler checks due meetings
- **THEN** Meeting Assistant shows the native macOS prompt
- **AND** starts recording with the appointment metadata when the user chooses record
### Requirement: Platform capabilities are diagnosable
Meeting Assistant SHALL expose a read-only diagnostic response containing the detected host, effective integration provider names, and enabled state of calendar prompts, speaker identification, screenshot OCR, FunASR backend, FunASR diarization, Whisper-local diarization, and workflow editor availability.
#### Scenario: macOS reports real integration providers
- **WHEN** the capability diagnostic is requested on macOS
- **THEN** it reports real macOS calendar metadata, calendar prompt, screenshot, and workflow editor providers
- **AND** does not report the no-op or unavailable provider names
@@ -0,0 +1,23 @@
## MODIFIED Requirements
### Requirement: Meeting screenshots are captured into assistant context
Meeting Assistant SHALL capture the active application window on supported Windows and macOS hosts and append it to the active meeting assistant context.
On Windows, Meeting Assistant SHALL retain the existing foreground-window capture implementation.
On macOS, Meeting Assistant SHALL capture the foremost on-screen window belonging to the frontmost application through native macOS APIs.
The existing screenshot OCR, crop, attendee enrichment, retry, and summary-wait behavior SHALL apply unchanged to macOS screenshots.
#### Scenario: macOS captures the active window for OCR
- **GIVEN** an active meeting on macOS and Screen Recording permission
- **WHEN** the screenshot hotkey or endpoint is invoked
- **THEN** Meeting Assistant saves a PNG of the frontmost application window
- **AND** appends it to assistant context
- **AND** starts OCR when screenshot OCR is enabled
#### Scenario: Windows screenshot provider remains unchanged
- **GIVEN** Meeting Assistant is compiled for the Windows target
- **WHEN** active-window screenshot capture is registered
- **THEN** it uses the existing Windows foreground-window provider
- **AND** does not invoke or require the macOS native helper
@@ -0,0 +1,18 @@
## MODIFIED Requirements
### Requirement: Portable transcription backends and speaker processing are available on macOS
Meeting Assistant SHALL allow speaker identification, managed FunASR startup, FunASR diarization, and Whisper-local pyannote diarization to run on macOS using the same managed implementations and configuration contracts as Windows.
These capabilities SHALL remain independently configurable and SHALL report actionable dependency errors when Docker, model files, API credentials, or model-access credentials are missing.
#### Scenario: macOS enables configured portable processing
- **GIVEN** the portable build is running on macOS
- **AND** speaker identification, managed FunASR, FunASR diarization, or Whisper-local diarization is enabled
- **WHEN** the corresponding transcription path runs
- **THEN** Meeting Assistant invokes the existing managed implementation
- **AND** does not disable the capability solely because the host is macOS
#### Scenario: Windows processing behavior remains unchanged
- **GIVEN** Meeting Assistant is compiled for the Windows target
- **WHEN** portable transcription and speaker-processing services are registered
- **THEN** it retains the existing managed implementations and configuration behavior
@@ -0,0 +1,26 @@
## 1. Contract and regression coverage
- [x] 1.1 Define macOS integration requirements and design.
- [x] 1.2 Add failing registration and capability-diagnostic tests.
- [x] 1.3 Add failing calendar, prompt, screenshot, and helper-contract tests.
## 2. Native and managed implementation
- [x] 2.1 Add and package the native macOS calendar/prompt/screenshot helper.
- [x] 2.2 Add managed macOS calendar metadata and prompt providers.
- [x] 2.3 Add managed macOS active-window screenshot provider.
- [x] 2.4 Register the real providers on macOS while preserving unsupported-host no-ops.
- [x] 2.5 Add the read-only platform-capability diagnostic endpoint.
## 3. Configuration and documentation
- [x] 3.1 Enable the portable OCR, speaker, FunASR, and diarization switches in the macOS runtime.
- [x] 3.2 Document macOS permissions, calendar source behavior, and diagnostics.
## 4. Verification and closeout
- [x] 4.1 Run narrow tests and portable build.
- [x] 4.2 Run the full test project and strict OpenSpec validation.
- [x] 4.3 Build the Windows target.
- [x] 4.4 Verify the live macOS application through direct operational surfaces.
- [x] 4.5 Perform required DRY, SOLID, KISS, and final code reviews.