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:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 "$(MacOsAudioCaptureSource)" -o "$(TargetDir)$(MacOsAudioCaptureOutputPath)"" />
|
||||
<Exec Command="/usr/bin/xcrun swiftc -parse-as-library -O -target $(MacOsAudioCaptureArchitecture)-apple-macos13.0 -framework AppKit -framework Carbon -framework WebKit "$(MacOsDesktopControlsSource)" -o "$(TargetDir)$(MacOsDesktopControlsOutputPath)"" />
|
||||
<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 "$(MacOsMeetingIntegrationsInfoPlist)" "$(MacOsMeetingIntegrationsSource)" -o "$(TargetDir)$(MacOsMeetingIntegrationsOutputPath)"" />
|
||||
</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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user