forked from Manuel/meeting-assistant
448 lines
16 KiB
C#
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);
|
|
}
|
|
}
|