Files
meeting-assistant/MeetingAssistant/MacOs/MacOsMeetingIntegrations.cs
T
dh 2af7933a1b
PR and Push Build/Test / build-and-test (pull_request) Failing after 19m49s
feat(macos): enable meeting integrations
2026-08-06 08:19:26 +02:00

448 lines
16 KiB
C#

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);
}
}