Public Access
feat(macos): enable meeting integrations
PR and Push Build/Test / build-and-test (pull_request) Failing after 19m49s
PR and Push Build/Test / build-and-test (pull_request) Failing after 19m49s
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user