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
@@ -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