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 Attendees, bool IsCanceled); internal interface IMacOsCalendarClient { Task> GetCalendarEventsAsync( DateTimeOffset from, DateTimeOffset to, CancellationToken cancellationToken); } internal interface IMacOsMeetingPromptClient { Task 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> 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(result.StandardOutput, JsonOptions) ?? []; } public async Task 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( result.StandardOutput, JsonOptions); return string.Equals(response?.Response, "record", StringComparison.OrdinalIgnoreCase) ? MeetingStartPromptResponse.Record : MeetingStartPromptResponse.Skip; } finally { File.Delete(requestPath); } } public async Task 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( result.StandardOutput, JsonOptions); return new ActiveWindowScreenshot( await File.ReadAllBytesAsync(outputPath, cancellationToken), response?.WindowTitle); } finally { File.Delete(outputPath); } } public async Task 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 RunHelperAsync( IReadOnlyList 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 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 logger; public MacOsMeetingMetadataProvider( IMacOsCalendarClient client, ILogger logger) { this.client = client; this.logger = logger; } public async Task 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 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 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 logger; public MacOsCalendarMeetingProvider( IMacOsCalendarClient client, ILogger logger) { this.client = client; this.logger = logger; } public async Task> 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 logger; public MacOsMeetingStartPromptService( IMacOsMeetingPromptClient client, ILogger logger) { this.client = client; this.logger = logger; } public async Task ShowPromptAsync( MeetingStartPromptRequest request, Func 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(); services.TryAddSingleton(); services.TryAddSingleton(services => services.GetRequiredService()); services.TryAddSingleton(services => services.GetRequiredService()); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(services => services.GetRequiredService()); services.AddSingleton(services => services.GetRequiredService()); return services; } services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); 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); } }