forked from Manuel/meeting-assistant
ci: consolidate platform pipelines and test prerequisites on macOS support
This commit is contained in:
1 parent
1b19b08f2e
commit
728e66dd72
76 files changed
+7477
-239
No files matched your search
@@ -0,0 +1,43 @@
|
||||
using MeetingAssistant.Notifications;
|
||||
|
||||
namespace MeetingAssistant.MacOs;
|
||||
|
||||
public static class DesktopCommunicationEndpoints
|
||||
{
|
||||
public static void MapDesktopCommunication(this WebApplication app)
|
||||
{
|
||||
app.MapGet("/desktop/requests", (DesktopUserRequestStore requests) => Results.Ok(requests.List()));
|
||||
app.MapPost("/desktop/requests/{id}/respond", async (
|
||||
string id, DesktopUserRequestResponse response, DesktopUserRequestStore requests) =>
|
||||
{
|
||||
if (string.IsNullOrEmpty(response.Action)) return Results.BadRequest();
|
||||
return await requests.RespondAsync(id, response.Action) switch
|
||||
{
|
||||
DesktopUserRequestRespondResult.Accepted => Results.Ok(new { status = "accepted" }),
|
||||
DesktopUserRequestRespondResult.InvalidAction => Results.BadRequest(new { status = "invalid-action" }),
|
||||
DesktopUserRequestRespondResult.NoLongerPending => Results.StatusCode(StatusCodes.Status410Gone),
|
||||
_ => Results.Problem("The user action could not be completed.")
|
||||
};
|
||||
});
|
||||
app.MapGet("/desktop/agent-windows", (MacOsWorkflowRulesEditorSessions sessions) =>
|
||||
Results.Ok(sessions.GetAgentWindows()));
|
||||
app.MapGet("/diagnostics/settings-and-logs", () =>
|
||||
Results.Content(MacOsWorkflowRulesEditorPage.Html, "text/html"));
|
||||
app.MapGet("/diagnostics/settings-and-logs/chat", IResult (
|
||||
string? session, MacOsWorkflowRulesEditorSessions sessions) =>
|
||||
{
|
||||
return sessions.TryGetSession(session, out var chat)
|
||||
? Results.Ok(chat.GetSnapshot())
|
||||
: Results.NotFound();
|
||||
});
|
||||
app.MapPost("/diagnostics/settings-and-logs/chat", async Task<IResult> (
|
||||
string? session, MacOsWorkflowRulesEditorChatRequest request,
|
||||
MacOsWorkflowRulesEditorSessions sessions, CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!sessions.TryGetSession(session, out var chat)) return Results.NotFound();
|
||||
return Results.Ok(await chat.SendAsync(request.Message, cancellationToken));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record DesktopUserRequestResponse(string? Action);
|
||||
@@ -0,0 +1,23 @@
|
||||
using MeetingAssistant.Notifications;
|
||||
|
||||
namespace MeetingAssistant.MacOs;
|
||||
|
||||
public sealed class MacOsAgentApprovalPromptService(DesktopUserRequestStore requests) : IAgentApprovalPromptService
|
||||
{
|
||||
public async Task<bool> ConfirmAsync(AgentApprovalPrompt prompt, CancellationToken cancellationToken)
|
||||
{
|
||||
var request = requests.Add(new DesktopUserRequestSpec(
|
||||
"agent-approval", prompt.Title, prompt.Message, prompt.Timeout,
|
||||
[new("yes", "Yes"), new("no", "No")]), cancellationToken);
|
||||
try
|
||||
{
|
||||
var response = await request.Decision.WaitAsync(cancellationToken);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
return response == "yes";
|
||||
}
|
||||
finally
|
||||
{
|
||||
requests.Dismiss(request.Id);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -33,7 +33,13 @@ public sealed class MacOsDesktopControlService : IHostedService, IDisposable
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
var helperPath = Path.Combine(AppContext.BaseDirectory, "Native", "macos-desktop-controls");
|
||||
var helperPath = Path.Combine(
|
||||
AppContext.BaseDirectory,
|
||||
"Native",
|
||||
"MeetingAssistantDesktopControls.app",
|
||||
"Contents",
|
||||
"MacOS",
|
||||
"macos-desktop-controls");
|
||||
if (!File.Exists(helperPath))
|
||||
{
|
||||
logger.LogWarning("macOS desktop controls helper was not found at {HelperPath}", helperPath);
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
using MeetingAssistant.Notifications;
|
||||
using MeetingAssistant.Recording;
|
||||
|
||||
namespace MeetingAssistant.MacOs;
|
||||
|
||||
public sealed class MacOsMeetingInactivityPromptService(DesktopUserRequestStore requests) : IMeetingInactivityPromptService
|
||||
{
|
||||
private const string Kind = "meeting-inactivity";
|
||||
|
||||
public Task ShowStopPromptAsync(MeetingInactivityPromptRequest request,
|
||||
Func<MeetingInactivityPromptResponse, CancellationToken, Task> handleResponseAsync,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
requests.Add(new DesktopUserRequestSpec(
|
||||
Kind, "Stop meeting?",
|
||||
$"No transcript text has arrived for {request.InactivityDuration.TotalMinutes:0.#} minutes.",
|
||||
TimeSpan.FromMinutes(1),
|
||||
[new("stop", "Stop"), new("continue", "Continue"), new("pause", "Pause transcription")]),
|
||||
cancellationToken,
|
||||
(action, token) => handleResponseAsync(MeetingInactivityPromptActions.ParseResponse(action), token));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task DismissAllAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
requests.DismissKind(Kind);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Text.Json;
|
||||
using MeetingAssistant.Calendar;
|
||||
using MeetingAssistant.Notifications;
|
||||
using MeetingAssistant.MeetingNotes;
|
||||
using MeetingAssistant.Screenshots;
|
||||
using MeetingAssistant.Transcription;
|
||||
@@ -362,33 +363,30 @@ internal sealed class MacOsCalendarMeetingProvider : ICalendarMeetingProvider
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class MacOsMeetingStartPromptService : IMeetingStartPromptService
|
||||
internal sealed class MacOsMeetingStartPromptService(
|
||||
DesktopUserRequestStore requests, TimeProvider timeProvider) : 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,
|
||||
public 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");
|
||||
}
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var timeout = request.Meeting.End - timeProvider.GetUtcNow();
|
||||
if (timeout <= TimeSpan.Zero) return Task.CompletedTask;
|
||||
if (timeout > TimeSpan.FromMinutes(5)) timeout = TimeSpan.FromMinutes(5);
|
||||
var actions = new List<DesktopRequestAction> { new("record", "Record"), new("skip", "Skip") };
|
||||
if (request.CanAttachToCurrentMeeting) actions.Add(new("attach-metadata", "Add metadata to current meeting"));
|
||||
requests.Add(new DesktopUserRequestSpec(
|
||||
"meeting-start", "Record meeting?",
|
||||
$"{request.Meeting.Subject} starts at {request.Meeting.Start.LocalDateTime:t}.",
|
||||
timeout, actions), cancellationToken,
|
||||
(action, token) => handleResponseAsync(action switch
|
||||
{
|
||||
"record" => MeetingStartPromptResponse.Record,
|
||||
"attach-metadata" => MeetingStartPromptResponse.AttachMetadataToCurrentMeeting,
|
||||
_ => MeetingStartPromptResponse.Skip
|
||||
}, token));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
using System.Collections.Concurrent;
|
||||
using MeetingAssistant.Workflow;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace MeetingAssistant.MacOs;
|
||||
|
||||
public sealed class MacOsWorkflowRulesEditorSessions
|
||||
{
|
||||
private readonly MacOsWorkflowRulesEditorSession defaultSession;
|
||||
private readonly ConcurrentDictionary<string, MacOsWorkflowRulesEditorSession> sessions = new(StringComparer.Ordinal);
|
||||
private readonly ConcurrentDictionary<string, MacOsAgentWindowRequest> windows = new(StringComparer.Ordinal);
|
||||
private readonly IWorkflowRulesEditorChatPipeline pipeline;
|
||||
private readonly string agentUrl;
|
||||
private readonly CancellationToken applicationStopping;
|
||||
private readonly ILogger<MacOsWorkflowRulesEditorSessions> logger;
|
||||
|
||||
public MacOsWorkflowRulesEditorSessions(
|
||||
IWorkflowRulesEditorChatPipeline pipeline,
|
||||
IOptions<MeetingAssistantOptions> options,
|
||||
IHostApplicationLifetime applicationLifetime,
|
||||
ILogger<MacOsWorkflowRulesEditorSessions> logger)
|
||||
{
|
||||
this.pipeline = pipeline;
|
||||
this.logger = logger;
|
||||
applicationStopping = applicationLifetime.ApplicationStopping;
|
||||
agentUrl = $"{options.Value.Api.PublicBaseUrl.TrimEnd('/')}/diagnostics/settings-and-logs";
|
||||
defaultSession = CreateSession();
|
||||
}
|
||||
|
||||
public bool TryGetSession(string? id, out MacOsWorkflowRulesEditorSession session)
|
||||
{
|
||||
if (string.IsNullOrEmpty(id))
|
||||
{
|
||||
session = defaultSession;
|
||||
return true;
|
||||
}
|
||||
return sessions.TryGetValue(id, out session!);
|
||||
}
|
||||
|
||||
public IReadOnlyList<MacOsAgentWindowRequest> GetAgentWindows() =>
|
||||
applicationStopping.IsCancellationRequested ? [] : windows.Values.ToArray();
|
||||
|
||||
public void OpenWithPrompt(string initialPrompt)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(initialPrompt);
|
||||
applicationStopping.ThrowIfCancellationRequested();
|
||||
var id = Guid.NewGuid().ToString("N");
|
||||
var session = CreateSession();
|
||||
sessions[id] = session;
|
||||
var task = session.SendAsync(initialPrompt, applicationStopping);
|
||||
windows[id] = new MacOsAgentWindowRequest(id, $"{agentUrl}?session={id}");
|
||||
_ = ObservePromptAsync(task, id);
|
||||
}
|
||||
|
||||
private MacOsWorkflowRulesEditorSession CreateSession() =>
|
||||
new(new WorkflowRulesEditorChatViewModel(pipeline), applicationStopping);
|
||||
|
||||
private async Task ObservePromptAsync(Task task, string id)
|
||||
{
|
||||
try
|
||||
{
|
||||
await task;
|
||||
}
|
||||
catch (OperationCanceledException) when (applicationStopping.IsCancellationRequested)
|
||||
{
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogError(exception, "Could not start approved workflow conversation {SessionId}", id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record MacOsAgentWindowRequest(string Id, string Url);
|
||||
@@ -7,10 +7,14 @@ namespace MeetingAssistant.MacOs;
|
||||
public sealed class MacOsWorkflowRulesEditorWindowService : IWorkflowRulesEditorWindowService
|
||||
{
|
||||
private readonly IOptions<MeetingAssistantOptions> options;
|
||||
private readonly Func<MacOsWorkflowRulesEditorSessions> getSessions;
|
||||
|
||||
public MacOsWorkflowRulesEditorWindowService(IOptions<MeetingAssistantOptions> options)
|
||||
public MacOsWorkflowRulesEditorWindowService(
|
||||
IOptions<MeetingAssistantOptions> options,
|
||||
Func<MacOsWorkflowRulesEditorSessions> getSessions)
|
||||
{
|
||||
this.options = options;
|
||||
this.getSessions = getSessions;
|
||||
}
|
||||
|
||||
public void Show()
|
||||
@@ -31,59 +35,82 @@ public sealed class MacOsWorkflowRulesEditorWindowService : IWorkflowRulesEditor
|
||||
|
||||
public void ShowWithPrompt(string initialPrompt)
|
||||
{
|
||||
throw new PlatformNotSupportedException(
|
||||
"Automatic workflow-change conversations require the Windows approval UI. Use the interactive macOS agent instead.");
|
||||
getSessions().OpenWithPrompt(initialPrompt);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class MacOsWorkflowRulesEditorSession
|
||||
{
|
||||
private readonly WorkflowRulesEditorChatViewModel viewModel;
|
||||
private readonly CancellationToken applicationStopping;
|
||||
private readonly SemaphoreSlim sendLock = new(1, 1);
|
||||
private MacOsWorkflowRulesEditorChatResponse snapshot = new([], false);
|
||||
|
||||
public MacOsWorkflowRulesEditorSession(WorkflowRulesEditorChatViewModel viewModel)
|
||||
public MacOsWorkflowRulesEditorSession(
|
||||
WorkflowRulesEditorChatViewModel viewModel,
|
||||
CancellationToken applicationStopping = default)
|
||||
{
|
||||
this.viewModel = viewModel;
|
||||
this.applicationStopping = applicationStopping;
|
||||
}
|
||||
|
||||
public async Task<MacOsWorkflowRulesEditorChatResponse> SendAsync(
|
||||
public Task<MacOsWorkflowRulesEditorChatResponse> SendAsync(
|
||||
string? message,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
if (string.IsNullOrWhiteSpace(message))
|
||||
{
|
||||
return Snapshot();
|
||||
return Task.FromResult(GetSnapshot());
|
||||
}
|
||||
|
||||
await sendLock.WaitAsync(cancellationToken);
|
||||
// The response connection controls only its wait; accepted agent work belongs to the application.
|
||||
return SendCoreAsync(message.Trim()).WaitAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public MacOsWorkflowRulesEditorChatResponse GetSnapshot() => Volatile.Read(ref snapshot);
|
||||
|
||||
private async Task<MacOsWorkflowRulesEditorChatResponse> SendCoreAsync(string message)
|
||||
{
|
||||
await sendLock.WaitAsync(applicationStopping);
|
||||
try
|
||||
{
|
||||
viewModel.Draft = message;
|
||||
await viewModel.SendAsync(cancellationToken);
|
||||
return Snapshot();
|
||||
applicationStopping.ThrowIfCancellationRequested();
|
||||
var messages = GetSnapshot().Messages
|
||||
.Append(new MacOsWorkflowRulesEditorMessage("user", message))
|
||||
.ToArray();
|
||||
Volatile.Write(ref snapshot, new MacOsWorkflowRulesEditorChatResponse(
|
||||
Array.AsReadOnly(messages), true));
|
||||
try
|
||||
{
|
||||
// Dispatch the agent away from the caller so opening a window or processing a decision stays responsive.
|
||||
await Task.Run(() => viewModel.SendPromptAsync(message, applicationStopping), applicationStopping);
|
||||
}
|
||||
finally
|
||||
{
|
||||
var completedMessages = viewModel.Messages
|
||||
.Where(item => item.Kind == WorkflowRulesEditorConversationItemKind.Message)
|
||||
.Select(item => new MacOsWorkflowRulesEditorMessage(
|
||||
item.Message!.Role.ToString().ToLowerInvariant(),
|
||||
item.Message.Content))
|
||||
.ToArray();
|
||||
Volatile.Write(ref snapshot, new MacOsWorkflowRulesEditorChatResponse(
|
||||
Array.AsReadOnly(completedMessages), false));
|
||||
}
|
||||
return GetSnapshot();
|
||||
}
|
||||
finally
|
||||
{
|
||||
sendLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private MacOsWorkflowRulesEditorChatResponse Snapshot()
|
||||
{
|
||||
return new MacOsWorkflowRulesEditorChatResponse(
|
||||
viewModel.Messages
|
||||
.Where(item => item.Kind == WorkflowRulesEditorConversationItemKind.Message)
|
||||
.Select(item => new MacOsWorkflowRulesEditorMessage(
|
||||
item.Message!.Role.ToString().ToLowerInvariant(),
|
||||
item.Message.Content))
|
||||
.ToArray());
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record MacOsWorkflowRulesEditorChatRequest(string? Message);
|
||||
|
||||
public sealed record MacOsWorkflowRulesEditorChatResponse(
|
||||
IReadOnlyList<MacOsWorkflowRulesEditorMessage> Messages);
|
||||
IReadOnlyList<MacOsWorkflowRulesEditorMessage> Messages,
|
||||
bool IsThinking = false);
|
||||
|
||||
public sealed record MacOsWorkflowRulesEditorMessage(string Role, string Content);
|
||||
|
||||
@@ -126,6 +153,13 @@ public static class MacOsWorkflowRulesEditorPage
|
||||
const prompt = document.querySelector('#prompt');
|
||||
const send = document.querySelector('#send');
|
||||
const messages = document.querySelector('#messages');
|
||||
const session = new URLSearchParams(location.search).get('session');
|
||||
const chatUrl = '/diagnostics/settings-and-logs/chat' + (session ? '?session=' + encodeURIComponent(session) : '');
|
||||
let submitting = false;
|
||||
let thinking = false;
|
||||
let refreshing = false;
|
||||
let available = true;
|
||||
let refreshTimer;
|
||||
const render = items => {
|
||||
messages.replaceChildren(...items.map(item => {
|
||||
const card = document.createElement('div');
|
||||
@@ -135,22 +169,57 @@ public static class MacOsWorkflowRulesEditorPage
|
||||
}));
|
||||
messages.scrollTop = messages.scrollHeight;
|
||||
};
|
||||
const update = body => {
|
||||
render(body.messages);
|
||||
thinking = !!body.isThinking;
|
||||
send.disabled = submitting || thinking;
|
||||
};
|
||||
const showError = text => {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'message agent';
|
||||
card.textContent = text;
|
||||
messages.append(card);
|
||||
};
|
||||
const refresh = async () => {
|
||||
if (refreshing || !available) return;
|
||||
clearTimeout(refreshTimer);
|
||||
refreshing = true;
|
||||
try {
|
||||
const response = await fetch(chatUrl, {cache: 'no-store'});
|
||||
if (!response.ok) throw new Error('This conversation is no longer available.');
|
||||
update(await response.json());
|
||||
} catch (error) {
|
||||
available = false;
|
||||
send.disabled = true;
|
||||
showError(error.message);
|
||||
} finally {
|
||||
refreshing = false;
|
||||
if (available && (thinking || submitting)) refreshTimer = setTimeout(refresh, 300);
|
||||
}
|
||||
};
|
||||
form.addEventListener('submit', async event => {
|
||||
event.preventDefault();
|
||||
const message = prompt.value.trim();
|
||||
if (!message) return;
|
||||
if (!message || submitting || thinking || !available) return;
|
||||
prompt.value = '';
|
||||
submitting = true;
|
||||
send.disabled = true;
|
||||
try {
|
||||
const response = await fetch('/diagnostics/settings-and-logs/chat', {
|
||||
const responseTask = fetch(chatUrl, {
|
||||
method: 'POST',
|
||||
headers: {'content-type': 'application/json'},
|
||||
body: JSON.stringify({message})
|
||||
});
|
||||
const body = await response.json();
|
||||
render(body.messages);
|
||||
void refresh();
|
||||
const response = await responseTask;
|
||||
if (!response.ok) throw new Error('The message could not be sent.');
|
||||
update(await response.json());
|
||||
} catch (error) {
|
||||
showError(error.message);
|
||||
} finally {
|
||||
send.disabled = false;
|
||||
submitting = false;
|
||||
send.disabled = !available || thinking;
|
||||
void refresh();
|
||||
prompt.focus();
|
||||
}
|
||||
});
|
||||
@@ -160,6 +229,7 @@ public static class MacOsWorkflowRulesEditorPage
|
||||
form.requestSubmit();
|
||||
}
|
||||
});
|
||||
void refresh();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -26,7 +26,11 @@
|
||||
<MacOsAudioCaptureArchitecture Condition="'$(MacOsAudioCaptureRid)' == 'osx-x64'">x86_64</MacOsAudioCaptureArchitecture>
|
||||
<MacOsAudioCaptureArchitecture Condition="'$(MacOsAudioCaptureArchitecture)' == ''">arm64</MacOsAudioCaptureArchitecture>
|
||||
<MacOsDesktopControlsSource>$(MSBuildProjectDirectory)/Native/MacOsDesktopControls/main.swift</MacOsDesktopControlsSource>
|
||||
<MacOsDesktopControlsOutputPath>Native/macos-desktop-controls</MacOsDesktopControlsOutputPath>
|
||||
<MacOsDesktopControlsRequestsSource>$(MSBuildProjectDirectory)/Native/MacOsDesktopControls/DesktopUserRequests.swift</MacOsDesktopControlsRequestsSource>
|
||||
<MacOsDesktopControlsSelfTestsSource>$(MSBuildProjectDirectory)/Native/MacOsDesktopControls/SelfTests.swift</MacOsDesktopControlsSelfTestsSource>
|
||||
<MacOsDesktopControlsInfoPlist>$(MSBuildProjectDirectory)/Native/MacOsDesktopControls/Info.plist</MacOsDesktopControlsInfoPlist>
|
||||
<MacOsDesktopControlsBundlePath>Native/MeetingAssistantDesktopControls.app</MacOsDesktopControlsBundlePath>
|
||||
<MacOsDesktopControlsOutputPath>$(MacOsDesktopControlsBundlePath)/Contents/MacOS/macos-desktop-controls</MacOsDesktopControlsOutputPath>
|
||||
<MacOsMeetingIntegrationsSource>$(MSBuildProjectDirectory)/Native/MacOsMeetingIntegrations/main.swift</MacOsMeetingIntegrationsSource>
|
||||
<MacOsMeetingIntegrationsInfoPlist>$(MSBuildProjectDirectory)/Native/MacOsMeetingIntegrations/Info.plist</MacOsMeetingIntegrationsInfoPlist>
|
||||
<MacOsMeetingIntegrationsOutputPath>Native/macos-meeting-integrations</MacOsMeetingIntegrationsOutputPath>
|
||||
@@ -92,7 +96,11 @@
|
||||
<Copy SourceFiles="$(MacOsAudioCaptureInfoPlist)" DestinationFiles="$(TargetDir)$(MacOsAudioCaptureBundlePath)/Contents/Info.plist" />
|
||||
<Exec Command="/usr/bin/xcrun swiftc -parse-as-library -O -target $(MacOsAudioCaptureArchitecture)-apple-macos13.0 -framework AVFoundation -framework CoreMedia -framework ScreenCaptureKit -Xlinker -sectcreate -Xlinker __TEXT -Xlinker __info_plist -Xlinker "$(MacOsAudioCaptureInfoPlist)" "$(MacOsAudioCaptureSource)" -o "$(TargetDir)$(MacOsAudioCaptureOutputPath)"" />
|
||||
<Exec Command="/usr/bin/codesign --force --deep --sign - "$(TargetDir)$(MacOsAudioCaptureBundlePath)"" />
|
||||
<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)"" />
|
||||
<MakeDir Directories="$(TargetDir)$(MacOsDesktopControlsBundlePath)/Contents/MacOS" />
|
||||
<Copy SourceFiles="$(MacOsDesktopControlsInfoPlist)" DestinationFiles="$(TargetDir)$(MacOsDesktopControlsBundlePath)/Contents/Info.plist" />
|
||||
<Exec Command="/usr/bin/xcrun swiftc -parse-as-library -O -target $(MacOsAudioCaptureArchitecture)-apple-macos13.0 -framework AppKit -framework Carbon -framework WebKit -framework UserNotifications "$(MacOsDesktopControlsSource)" "$(MacOsDesktopControlsRequestsSource)" "$(MacOsDesktopControlsSelfTestsSource)" -o "$(TargetDir)$(MacOsDesktopControlsOutputPath)"" />
|
||||
<Exec Command="/usr/bin/codesign --force --deep --sign - --requirements '=designated => identifier "cloud.schweigert.meeting-assistant.desktop-controls"' "$(TargetDir)$(MacOsDesktopControlsBundlePath)"" />
|
||||
<Exec Command=""$(TargetDir)$(MacOsDesktopControlsOutputPath)" --self-test" Condition="'$(MacOsAudioCaptureRid)' == '$(NETCoreSdkRuntimeIdentifier)'" />
|
||||
<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)"" />
|
||||
<Exec Command="/usr/bin/xcrun swiftc -parse-as-library -O -target $(MacOsAudioCaptureArchitecture)-apple-macos13.0 "$(MacOsLauncherSource)" -o "$(TargetDir)$(MacOsLauncherOutputPath)"" />
|
||||
</Target>
|
||||
@@ -125,7 +133,10 @@
|
||||
<Copy SourceFiles="$(TargetDir)$(MacOsAudioCaptureOutputPath)" DestinationFiles="$(PublishDir)$(MacOsAudioCaptureOutputPath)" />
|
||||
<Copy SourceFiles="$(MacOsAudioCaptureInfoPlist)" DestinationFiles="$(PublishDir)$(MacOsAudioCaptureBundlePath)/Contents/Info.plist" />
|
||||
<Exec Command="/usr/bin/codesign --force --deep --sign - "$(PublishDir)$(MacOsAudioCaptureBundlePath)"" />
|
||||
<Copy SourceFiles="$(TargetDir)$(MacOsDesktopControlsOutputPath)" DestinationFolder="$(PublishDir)Native" />
|
||||
<MakeDir Directories="$(PublishDir)$(MacOsDesktopControlsBundlePath)/Contents/MacOS" />
|
||||
<Copy SourceFiles="$(TargetDir)$(MacOsDesktopControlsOutputPath)" DestinationFiles="$(PublishDir)$(MacOsDesktopControlsOutputPath)" />
|
||||
<Copy SourceFiles="$(MacOsDesktopControlsInfoPlist)" DestinationFiles="$(PublishDir)$(MacOsDesktopControlsBundlePath)/Contents/Info.plist" />
|
||||
<Exec Command="/usr/bin/codesign --force --deep --sign - --requirements '=designated => identifier "cloud.schweigert.meeting-assistant.desktop-controls"' "$(PublishDir)$(MacOsDesktopControlsBundlePath)"" />
|
||||
<Copy SourceFiles="$(TargetDir)$(MacOsMeetingIntegrationsOutputPath)" DestinationFolder="$(PublishDir)Native" />
|
||||
</Target>
|
||||
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import Foundation
|
||||
import UserNotifications
|
||||
|
||||
struct DesktopUserAction: Decodable, Equatable {
|
||||
let id: String
|
||||
let label: String
|
||||
}
|
||||
|
||||
struct DesktopUserRequest: Decodable {
|
||||
let id: String
|
||||
let kind: String
|
||||
let title: String
|
||||
let message: String
|
||||
let createdAt: Date
|
||||
let expiresAt: Date
|
||||
let actions: [DesktopUserAction]
|
||||
}
|
||||
|
||||
struct DesktopAgentWindow: Decodable {
|
||||
let id: String
|
||||
let url: String
|
||||
}
|
||||
|
||||
func decodeDesktopRequests(_ data: Data) throws -> [DesktopUserRequest] {
|
||||
let decoder = JSONDecoder()
|
||||
decoder.dateDecodingStrategy = .custom { decoder in
|
||||
let value = try decoder.singleValueContainer().decode(String.self)
|
||||
let formatter = ISO8601DateFormatter()
|
||||
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
if let date = formatter.date(from: value) { return date }
|
||||
formatter.formatOptions = [.withInternetDateTime]
|
||||
if let date = formatter.date(from: value) { return date }
|
||||
throw DecodingError.dataCorruptedError(in: try decoder.singleValueContainer(), debugDescription: "Invalid request timestamp")
|
||||
}
|
||||
return try decoder.decode([DesktopUserRequest].self, from: data)
|
||||
}
|
||||
|
||||
struct DesktopUserRequestState {
|
||||
private(set) var pending: [DesktopUserRequest] = []
|
||||
|
||||
private var announced: Set<String> = []
|
||||
|
||||
private var responding: Set<String> = []
|
||||
private var completed: Set<String> = []
|
||||
|
||||
mutating func beginResponse(baseUrl: String, requestId: String, action: String, now: Date) -> URLRequest? {
|
||||
guard !responding.contains(requestId),
|
||||
let pendingRequest = pending.first(where: { $0.id == requestId }),
|
||||
pendingRequest.expiresAt > now,
|
||||
pendingRequest.actions.contains(where: { $0.id == action }),
|
||||
let id = requestId.addingPercentEncoding(withAllowedCharacters: .alphanumerics.union(CharacterSet(charactersIn: "-._~"))),
|
||||
let url = URL(string: baseUrl.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + "/desktop/requests/\(id)/respond"),
|
||||
let body = try? JSONEncoder().encode(["action": action]) else { return nil }
|
||||
var response = URLRequest(url: url)
|
||||
response.httpMethod = "POST"
|
||||
response.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
response.httpBody = body
|
||||
responding.insert(requestId)
|
||||
return response
|
||||
}
|
||||
|
||||
mutating func completeResponse(_ id: String, accepted: Bool) {
|
||||
responding.remove(id)
|
||||
if accepted { completed.insert(id); pending.removeAll { $0.id == id } }
|
||||
}
|
||||
|
||||
func isResponding(_ id: String) -> Bool { responding.contains(id) }
|
||||
|
||||
mutating func reconcile(_ requests: [DesktopUserRequest], now: Date) -> [String] {
|
||||
let previousIds = Set(pending.map(\.id))
|
||||
pending = requests.filter { $0.expiresAt > now && !completed.contains($0.id) }.sorted {
|
||||
$0.createdAt == $1.createdAt ? $0.id < $1.id : $0.createdAt < $1.createdAt
|
||||
}
|
||||
let currentIds = Set(pending.map(\.id))
|
||||
return previousIds.subtracting(currentIds).sorted()
|
||||
}
|
||||
|
||||
func notificationsToAnnounce() -> [DesktopUserRequest] {
|
||||
pending.filter { !announced.contains($0.id) && !responding.contains($0.id) }
|
||||
}
|
||||
|
||||
mutating func markAnnounced(_ id: String) {
|
||||
if pending.contains(where: { $0.id == id }) { announced.insert(id) }
|
||||
}
|
||||
}
|
||||
|
||||
extension DesktopUserRequest {
|
||||
var notificationIdentifier: String { "desktop-request." + id }
|
||||
|
||||
func notificationCategory() -> UNNotificationCategory {
|
||||
UNNotificationCategory(
|
||||
identifier: notificationIdentifier,
|
||||
actions: actions.map { UNNotificationAction(identifier: $0.id, title: $0.label, options: []) },
|
||||
intentIdentifiers: [],
|
||||
options: [.customDismissAction])
|
||||
}
|
||||
|
||||
func notificationRequest() -> UNNotificationRequest {
|
||||
let content = UNMutableNotificationContent()
|
||||
content.title = title
|
||||
content.body = message
|
||||
content.categoryIdentifier = notificationIdentifier
|
||||
content.userInfo = ["requestId": id]
|
||||
content.sound = .default
|
||||
return UNNotificationRequest(identifier: notificationIdentifier, content: content, trigger: nil)
|
||||
}
|
||||
}
|
||||
|
||||
struct DesktopAgentWindowState {
|
||||
private var opened: Set<String> = []
|
||||
|
||||
mutating func takeUnopened(_ windows: [DesktopAgentWindow]) -> [DesktopAgentWindow] {
|
||||
windows.filter { opened.insert($0.id).inserted }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?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>CFBundleExecutable</key>
|
||||
<string>macos-desktop-controls</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>cloud.schweigert.meeting-assistant.desktop-controls</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>Meeting Assistant</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Meeting Assistant</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>13.0</string>
|
||||
<key>LSUIElement</key>
|
||||
<true/>
|
||||
<key>NSPrincipalClass</key>
|
||||
<string>NSApplication</string>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,67 @@
|
||||
import Foundation
|
||||
|
||||
private enum SelfTestFailure: Error { case failed(String) }
|
||||
|
||||
private func expect(_ condition: @autoclosure () -> Bool, _ message: String) throws {
|
||||
if !condition() { throw SelfTestFailure.failed(message) }
|
||||
}
|
||||
|
||||
func runDesktopControlSelfTests() {
|
||||
do {
|
||||
let status = try JSONDecoder().decode(RecordingStatus.self, from: Data(#"{"isRecording":false,"state":2,"launchProfile":null}"#.utf8))
|
||||
try expect(String(describing: status.state).lowercased() == "summarizing", "numeric status 2 must show summarizing")
|
||||
print("PASS numeric recording status")
|
||||
let requests = try decodeDesktopRequests(Data(requestFixture.utf8))
|
||||
try expect(requests.count == 1 && requests[0].id == "approval-1", "pending request must decode")
|
||||
try expect(requests[0].actions == [DesktopUserAction(id: "yes", label: "Yes"), DesktopUserAction(id: "no", label: "No")], "offered actions must be preserved")
|
||||
try expect(requests[0].expiresAt.timeIntervalSince1970 == 1_791_022_260, "offset and fractional timestamps must decode")
|
||||
print("PASS pending request wire payload")
|
||||
var state = DesktopUserRequestState()
|
||||
let now = Date(timeIntervalSince1970: 1_791_022_220)
|
||||
_ = state.reconcile(requests, now: now)
|
||||
try expect(state.notificationsToAnnounce().map(\.id) == ["approval-1"], "new pending request must be announced")
|
||||
state.markAnnounced("approval-1")
|
||||
_ = state.reconcile(requests, now: now)
|
||||
try expect(state.notificationsToAnnounce().isEmpty && state.pending.count == 1, "repeated polling must not redeliver or hide menu requests")
|
||||
try expect(state.reconcile([], now: now) == ["approval-1"] && state.pending.isEmpty, "withdrawn request must remove native presentation")
|
||||
print("PASS notification reconciliation and menu fallback")
|
||||
_ = state.reconcile(requests, now: now)
|
||||
try expect(state.beginResponse(baseUrl: "http://127.0.0.1:5090", requestId: "approval-1", action: "not-offered", now: now) == nil, "invalid action must keep request pending")
|
||||
guard let response = state.beginResponse(baseUrl: "http://127.0.0.1:5090/", requestId: "approval-1", action: "yes", now: now) else { throw SelfTestFailure.failed("offered action must produce a response") }
|
||||
try expect(response.url?.absoluteString == "http://127.0.0.1:5090/desktop/requests/approval-1/respond" && response.httpMethod == "POST", "notification and menu response endpoint")
|
||||
let body = try JSONDecoder().decode([String: String].self, from: response.httpBody ?? Data())
|
||||
try expect(body == ["action": "yes"], "chosen action must use shared JSON contract")
|
||||
try expect(state.beginResponse(baseUrl: "http://127.0.0.1:5090", requestId: "approval-1", action: "yes", now: now) == nil, "same pending action must not submit twice")
|
||||
state.completeResponse("approval-1", accepted: false)
|
||||
try expect(!state.isResponding("approval-1") && state.pending.count == 1, "failed transport must leave menu fallback actionable")
|
||||
try expect(state.beginResponse(baseUrl: "http://127.0.0.1:5090", requestId: "approval-1", action: "yes", now: requests[0].expiresAt) == nil, "expired response must not submit")
|
||||
try expect(state.reconcile(requests, now: requests[0].expiresAt) == ["approval-1"] && state.pending.isEmpty, "expired request must be removed locally")
|
||||
try expect(state.beginResponse(baseUrl: "http://127.0.0.1:5090", requestId: "approval-1", action: "yes", now: now) == nil, "stale notification must not reconstruct request")
|
||||
print("PASS allowed once-only response and stale action rejection")
|
||||
_ = state.reconcile(requests, now: now)
|
||||
state.completeResponse("approval-1", accepted: true)
|
||||
_ = state.reconcile(requests, now: now)
|
||||
try expect(state.pending.isEmpty && state.notificationsToAnnounce().isEmpty, "old HTTP snapshot must not resurrect accepted request")
|
||||
print("PASS acknowledged response survives stale polling")
|
||||
let category = requests[0].notificationCategory()
|
||||
let notification = requests[0].notificationRequest()
|
||||
try expect(category.actions.map(\.identifier) == ["yes", "no"], "native notification must offer same actions as menu")
|
||||
try expect(category.actions.map(\.title) == ["Yes", "No"], "native action labels")
|
||||
try expect(notification.content.title == "Associate project?" && notification.content.body == "Add project Delta to this meeting?", "native notification must show pending request text")
|
||||
try expect(notification.content.categoryIdentifier == category.identifier && notification.content.userInfo["requestId"] as? String == "approval-1", "native actions must route original request id")
|
||||
try expect(category.options.contains(.customDismissAction), "notification dismissal must leave menu decision pending")
|
||||
print("PASS actionable native notification payload")
|
||||
let windows = try JSONDecoder().decode([DesktopAgentWindow].self, from: Data(#"[{"id":"workflow-1","url":"http://127.0.0.1:5090/desktop/agent-sessions/workflow-1"},{"id":"workflow-2","url":"http://127.0.0.1:5090/desktop/agent-sessions/workflow-2"}]"#.utf8))
|
||||
var windowState = DesktopAgentWindowState()
|
||||
try expect(windowState.takeUnopened(windows).map(\.id) == ["workflow-1", "workflow-2"], "two approved workflows must request independent windows")
|
||||
try expect(windowState.takeUnopened(windows).isEmpty, "polling must not reopen independent windows")
|
||||
print("PASS independent agent window wire payload and opening")
|
||||
} catch {
|
||||
fputs("FAIL desktop controls self-test: \(error)\n", stderr)
|
||||
exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
private let requestFixture = #"""
|
||||
[{"id":"approval-1","kind":"approval","title":"Associate project?","message":"Add project Delta to this meeting?","createdAt":"2026-10-03T12:10:00.1234567+02:00","expiresAt":"2026-10-03T12:11:00+02:00","actions":[{"id":"yes","label":"Yes"},{"id":"no","label":"No"}]}]
|
||||
"""#
|
||||
@@ -2,6 +2,7 @@ import AppKit
|
||||
import Carbon
|
||||
import Foundation
|
||||
import WebKit
|
||||
import UserNotifications
|
||||
|
||||
struct Manifest: Decodable {
|
||||
let baseUrl: String
|
||||
@@ -21,20 +22,36 @@ struct Hotkey: Decodable {
|
||||
let endpoint: String
|
||||
}
|
||||
|
||||
enum RecordingState: Int, Decodable {
|
||||
case idle = 0
|
||||
case recording = 1
|
||||
case summarizing = 2
|
||||
}
|
||||
|
||||
struct RecordingStatus: Decodable {
|
||||
let isRecording: Bool
|
||||
let state: String
|
||||
let state: RecordingState
|
||||
let launchProfile: String?
|
||||
}
|
||||
|
||||
@main
|
||||
final class DesktopControls: NSObject, NSApplicationDelegate, NSMenuDelegate {
|
||||
@MainActor
|
||||
final class DesktopControls: NSObject, NSApplicationDelegate, NSMenuDelegate, UNUserNotificationCenterDelegate {
|
||||
private let manifest: Manifest
|
||||
private var status = RecordingStatus(isRecording: false, state: "Idle", launchProfile: nil)
|
||||
private var status = RecordingStatus(isRecording: false, state: .idle, launchProfile: nil)
|
||||
private var statusItem: NSStatusItem!
|
||||
private var registeredHotkeys: [UInt32: Hotkey] = [:]
|
||||
private var timer: Timer?
|
||||
private var agentWindow: NSWindow?
|
||||
private var independentAgentWindows: [String: NSWindow] = [:]
|
||||
private var agentWindowState = DesktopAgentWindowState()
|
||||
private var requests = DesktopUserRequestState()
|
||||
private var fetchingRequests = false
|
||||
private var fetchingWindows = false
|
||||
private var authorizationInFlight = false
|
||||
private var authorizationChecked = false
|
||||
private var notificationsAuthorized = false
|
||||
private lazy var notificationCenter = UNUserNotificationCenter.current()
|
||||
|
||||
override init() {
|
||||
guard CommandLine.arguments.count == 2,
|
||||
@@ -48,6 +65,19 @@ final class DesktopControls: NSObject, NSApplicationDelegate, NSMenuDelegate {
|
||||
}
|
||||
|
||||
static func main() {
|
||||
if CommandLine.arguments.count == 2 && CommandLine.arguments[1] == "--self-test" {
|
||||
runDesktopControlSelfTests()
|
||||
return
|
||||
}
|
||||
// Notification activation after shutdown has no trusted service manifest.
|
||||
if CommandLine.arguments.count == 1 {
|
||||
fputs("Ignoring desktop notification activation without an active service manifest.\n", stderr)
|
||||
return
|
||||
}
|
||||
guard Bundle.main.bundleIdentifier == "cloud.schweigert.meeting-assistant.desktop-controls" else {
|
||||
fputs("Desktop controls must run from the signed MeetingAssistantDesktopControls.app bundle.\n", stderr)
|
||||
exit(2)
|
||||
}
|
||||
let application = NSApplication.shared
|
||||
let delegate = DesktopControls()
|
||||
application.delegate = delegate
|
||||
@@ -55,6 +85,12 @@ final class DesktopControls: NSObject, NSApplicationDelegate, NSMenuDelegate {
|
||||
application.run()
|
||||
}
|
||||
|
||||
func applicationWillFinishLaunching(_ notification: Notification) {
|
||||
notificationCenter.delegate = self
|
||||
notificationCenter.removeAllPendingNotificationRequests()
|
||||
notificationCenter.removeAllDeliveredNotifications()
|
||||
}
|
||||
|
||||
func applicationDidFinishLaunching(_ notification: Notification) {
|
||||
statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.squareLength)
|
||||
statusItem.button?.image = NSImage(
|
||||
@@ -64,19 +100,22 @@ final class DesktopControls: NSObject, NSApplicationDelegate, NSMenuDelegate {
|
||||
menu.delegate = self
|
||||
statusItem.menu = menu
|
||||
registerHotkeys()
|
||||
refreshStatus()
|
||||
refresh()
|
||||
timer = Timer.scheduledTimer(withTimeInterval: 2, repeats: true) { [weak self] _ in
|
||||
self?.refreshStatus()
|
||||
DispatchQueue.main.async { self?.refresh() }
|
||||
}
|
||||
}
|
||||
|
||||
func applicationWillTerminate(_ notification: Notification) {
|
||||
timer?.invalidate()
|
||||
notificationCenter.removeAllPendingNotificationRequests()
|
||||
notificationCenter.removeAllDeliveredNotifications()
|
||||
}
|
||||
|
||||
func menuWillOpen(_ menu: NSMenu) {
|
||||
removeNotifications(requests.reconcile(requests.pending, now: Date()))
|
||||
rebuildMenu(menu)
|
||||
refreshStatus()
|
||||
refresh()
|
||||
}
|
||||
|
||||
private func rebuildMenu(_ menu: NSMenu) {
|
||||
@@ -102,17 +141,45 @@ final class DesktopControls: NSObject, NSApplicationDelegate, NSMenuDelegate {
|
||||
}
|
||||
}
|
||||
menu.addItem(.separator())
|
||||
let pending = NSMenuItem(title: "Pending requests (\(requests.pending.count))", action: nil, keyEquivalent: "")
|
||||
let pendingMenu = NSMenu()
|
||||
if requests.pending.isEmpty {
|
||||
let empty = NSMenuItem(title: "No pending requests", action: nil, keyEquivalent: "")
|
||||
empty.isEnabled = false
|
||||
pendingMenu.addItem(empty)
|
||||
}
|
||||
for request in requests.pending {
|
||||
let entry = NSMenuItem(title: request.title, action: nil, keyEquivalent: "")
|
||||
entry.toolTip = request.message
|
||||
let decisions = NSMenu()
|
||||
let message = NSMenuItem(title: request.message, action: nil, keyEquivalent: "")
|
||||
message.isEnabled = false
|
||||
decisions.addItem(message)
|
||||
decisions.addItem(.separator())
|
||||
for action in request.actions {
|
||||
let decision = item(action.label, #selector(answerRequest(_:)))
|
||||
decision.representedObject = DesktopRequestDecision(request.id, action.id)
|
||||
decision.isEnabled = !requests.isResponding(request.id)
|
||||
decisions.addItem(decision)
|
||||
}
|
||||
entry.submenu = decisions
|
||||
pendingMenu.addItem(entry)
|
||||
}
|
||||
pending.submenu = pendingMenu
|
||||
menu.addItem(pending)
|
||||
menu.addItem(.separator())
|
||||
menu.addItem(item("Exit", #selector(exitApplication)))
|
||||
}
|
||||
|
||||
private var tooltip: String {
|
||||
let suffix = requests.pending.isEmpty ? "" : " — \(requests.pending.count) pending request(s)"
|
||||
if status.isRecording {
|
||||
return status.launchProfile.map { "Meeting Assistant — recording (\($0))" }
|
||||
?? "Meeting Assistant — recording"
|
||||
return (status.launchProfile.map { "Meeting Assistant — recording (\($0))" }
|
||||
?? "Meeting Assistant — recording") + suffix
|
||||
}
|
||||
return status.state.lowercased() == "summarizing"
|
||||
return (status.state == .summarizing
|
||||
? "Meeting Assistant — summarizing"
|
||||
: "Meeting Assistant — idle"
|
||||
: "Meeting Assistant — idle") + suffix
|
||||
}
|
||||
|
||||
private func item(_ title: String, _ action: Selector) -> NSMenuItem {
|
||||
@@ -122,24 +189,64 @@ final class DesktopControls: NSObject, NSApplicationDelegate, NSMenuDelegate {
|
||||
}
|
||||
|
||||
@objc private func openAgent() {
|
||||
if agentWindow == nil {
|
||||
let webView = WKWebView(frame: .zero)
|
||||
let window = NSWindow(
|
||||
contentRect: NSRect(x: 0, y: 0, width: 840, height: 760),
|
||||
styleMask: [.titled, .closable, .miniaturizable, .resizable],
|
||||
backing: .buffered,
|
||||
defer: false)
|
||||
window.title = "Meeting Summary Agent"
|
||||
window.contentView = webView
|
||||
window.center()
|
||||
window.isReleasedWhenClosed = false
|
||||
webView.load(URLRequest(url: URL(string: manifest.openAgentUrl)!))
|
||||
agentWindow = window
|
||||
if agentWindow == nil, let url = URL(string: manifest.openAgentUrl) {
|
||||
agentWindow = makeAgentWindow(url)
|
||||
}
|
||||
NSApplication.shared.activate(ignoringOtherApps: true)
|
||||
agentWindow?.makeKeyAndOrderFront(nil)
|
||||
}
|
||||
|
||||
private func makeAgentWindow(_ url: URL) -> NSWindow {
|
||||
let webView = WKWebView(frame: .zero)
|
||||
let window = NSWindow(
|
||||
contentRect: NSRect(x: 0, y: 0, width: 840, height: 760),
|
||||
styleMask: [.titled, .closable, .miniaturizable, .resizable],
|
||||
backing: .buffered,
|
||||
defer: false)
|
||||
window.title = "Meeting Summary Agent"
|
||||
window.contentView = webView
|
||||
window.center()
|
||||
window.isReleasedWhenClosed = false
|
||||
webView.load(URLRequest(url: url))
|
||||
return window
|
||||
}
|
||||
|
||||
@objc private func answerRequest(_ sender: NSMenuItem) {
|
||||
guard let decision = sender.representedObject as? DesktopRequestDecision else { return }
|
||||
submitResponse(requestId: decision.requestId, action: decision.action)
|
||||
}
|
||||
|
||||
private func submitResponse(requestId: String, action: String) {
|
||||
guard let response = requests.beginResponse(baseUrl: manifest.baseUrl, requestId: requestId, action: action, now: Date()) else { return }
|
||||
URLSession.shared.dataTask(with: response) { [weak self] _, response, error in
|
||||
let accepted = error == nil && (response as? HTTPURLResponse).map { (200..<300).contains($0.statusCode) } == true
|
||||
DispatchQueue.main.async {
|
||||
guard let self else { return }
|
||||
self.requests.completeResponse(requestId, accepted: accepted)
|
||||
if accepted { self.removeNotifications([requestId]) }
|
||||
if let error { desktopLog("User response failed: \(error.localizedDescription)") }
|
||||
self.refresh()
|
||||
}
|
||||
}.resume()
|
||||
}
|
||||
|
||||
nonisolated func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) {
|
||||
completionHandler([.banner, .list, .sound])
|
||||
}
|
||||
|
||||
nonisolated func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) {
|
||||
// Closing the announcement is independent of deciding its menu request.
|
||||
guard response.actionIdentifier != UNNotificationDismissActionIdentifier,
|
||||
response.actionIdentifier != UNNotificationDefaultActionIdentifier,
|
||||
let id = response.notification.request.content.userInfo["requestId"] as? String else {
|
||||
completionHandler()
|
||||
return
|
||||
}
|
||||
let action = response.actionIdentifier
|
||||
DispatchQueue.main.async { [weak self] in self?.submitResponse(requestId: id, action: action) }
|
||||
completionHandler()
|
||||
}
|
||||
|
||||
@objc private func startProfile(_ sender: NSMenuItem) {
|
||||
let profile = manifest.profiles[sender.tag]
|
||||
post(path: "/profiles/\(escaped(profile.name))/recording/start")
|
||||
@@ -160,6 +267,114 @@ final class DesktopControls: NSObject, NSApplicationDelegate, NSMenuDelegate {
|
||||
}
|
||||
}
|
||||
|
||||
private func refresh() {
|
||||
removeNotifications(requests.reconcile(requests.pending, now: Date()))
|
||||
updateStatusItem()
|
||||
refreshStatus()
|
||||
refreshRequests()
|
||||
refreshAgentWindows()
|
||||
}
|
||||
|
||||
private func updateStatusItem() {
|
||||
statusItem.button?.image = NSImage(
|
||||
systemSymbolName: status.isRecording ? "record.circle.fill" : "waveform.circle",
|
||||
accessibilityDescription: tooltip)
|
||||
statusItem.button?.toolTip = tooltip
|
||||
}
|
||||
|
||||
private func refreshRequests() {
|
||||
guard !fetchingRequests, let url = URL(string: manifest.baseUrl + "/desktop/requests") else { return }
|
||||
fetchingRequests = true
|
||||
URLSession.shared.dataTask(with: url) { [weak self] data, response, error in
|
||||
var snapshot: [DesktopUserRequest]?
|
||||
if let data, error == nil, (response as? HTTPURLResponse)?.statusCode == 200 {
|
||||
do { snapshot = try decodeDesktopRequests(data) }
|
||||
catch { desktopLog("Cannot decode pending desktop requests: \(error)") }
|
||||
}
|
||||
DispatchQueue.main.async {
|
||||
guard let self else { return }
|
||||
self.fetchingRequests = false
|
||||
guard let snapshot else { return }
|
||||
self.removeNotifications(self.requests.reconcile(snapshot, now: Date()))
|
||||
self.updateStatusItem()
|
||||
self.announcePendingRequests()
|
||||
}
|
||||
}.resume()
|
||||
}
|
||||
|
||||
private func removeNotifications(_ ids: [String]) {
|
||||
guard !ids.isEmpty else { return }
|
||||
let identifiers = ids.map { "desktop-request." + $0 }
|
||||
notificationCenter.removePendingNotificationRequests(withIdentifiers: identifiers)
|
||||
notificationCenter.removeDeliveredNotifications(withIdentifiers: identifiers)
|
||||
}
|
||||
|
||||
private func announcePendingRequests() {
|
||||
guard !requests.notificationsToAnnounce().isEmpty else { return }
|
||||
if !authorizationChecked {
|
||||
guard !authorizationInFlight else { return }
|
||||
authorizationInFlight = true
|
||||
notificationCenter.getNotificationSettings { [weak self] settings in
|
||||
DispatchQueue.main.async {
|
||||
guard let self else { return }
|
||||
if settings.authorizationStatus == .notDetermined {
|
||||
self.notificationCenter.requestAuthorization(options: [.alert, .sound]) { [weak self] granted, error in
|
||||
DispatchQueue.main.async {
|
||||
if let error { desktopLog("Notification authorization failed: \(error.localizedDescription)") }
|
||||
self?.finishNotificationAuthorization(granted)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
self.finishNotificationAuthorization(settings.authorizationStatus == .authorized || settings.authorizationStatus == .provisional)
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
guard notificationsAuthorized else { return }
|
||||
notificationCenter.setNotificationCategories(Set(requests.pending.map { $0.notificationCategory() }))
|
||||
for request in requests.notificationsToAnnounce() {
|
||||
requests.markAnnounced(request.id)
|
||||
notificationCenter.add(request.notificationRequest()) { error in
|
||||
if let error { desktopLog("Cannot schedule desktop notification \(request.id): \(error.localizedDescription)") }
|
||||
else { desktopLog("Scheduled desktop notification \(request.id)") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func finishNotificationAuthorization(_ granted: Bool) {
|
||||
authorizationChecked = true
|
||||
authorizationInFlight = false
|
||||
notificationsAuthorized = granted
|
||||
desktopLog(granted ? "Desktop notifications authorized." : "Desktop notifications unavailable; pending requests remain in the menu.")
|
||||
removeNotifications(requests.reconcile(requests.pending, now: Date()))
|
||||
announcePendingRequests()
|
||||
}
|
||||
|
||||
private func refreshAgentWindows() {
|
||||
guard !fetchingWindows, let url = URL(string: manifest.baseUrl + "/desktop/agent-windows") else { return }
|
||||
fetchingWindows = true
|
||||
URLSession.shared.dataTask(with: url) { [weak self] data, response, error in
|
||||
let windows = data.flatMap { data in
|
||||
error == nil && (response as? HTTPURLResponse)?.statusCode == 200
|
||||
? try? JSONDecoder().decode([DesktopAgentWindow].self, from: data) : nil
|
||||
}
|
||||
DispatchQueue.main.async {
|
||||
guard let self else { return }
|
||||
self.fetchingWindows = false
|
||||
guard let windows else { return }
|
||||
for requested in self.agentWindowState.takeUnopened(windows) {
|
||||
guard let url = URL(string: requested.url) else { continue }
|
||||
let window = self.makeAgentWindow(url)
|
||||
self.independentAgentWindows[requested.id] = window
|
||||
NSApplication.shared.activate(ignoringOtherApps: true)
|
||||
window.makeKeyAndOrderFront(nil)
|
||||
desktopLog("Opened independent workflow agent window \(requested.id)")
|
||||
}
|
||||
}
|
||||
}.resume()
|
||||
}
|
||||
|
||||
private func refreshStatus() {
|
||||
guard let url = URL(string: manifest.statusUrl) else { return }
|
||||
URLSession.shared.dataTask(with: url) { [weak self] data, _, _ in
|
||||
@@ -167,9 +382,7 @@ final class DesktopControls: NSObject, NSApplicationDelegate, NSMenuDelegate {
|
||||
let next = try? JSONDecoder().decode(RecordingStatus.self, from: data) else { return }
|
||||
DispatchQueue.main.async {
|
||||
self?.status = next
|
||||
self?.statusItem.button?.image = NSImage(
|
||||
systemSymbolName: next.isRecording ? "record.circle.fill" : "waveform.circle",
|
||||
accessibilityDescription: self?.tooltip)
|
||||
self?.updateStatusItem()
|
||||
}
|
||||
}.resume()
|
||||
}
|
||||
@@ -184,7 +397,7 @@ final class DesktopControls: NSObject, NSApplicationDelegate, NSMenuDelegate {
|
||||
request.httpMethod = "POST"
|
||||
URLSession.shared.dataTask(with: request) { [weak self] _, _, _ in
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
|
||||
self?.refreshStatus()
|
||||
self?.refresh()
|
||||
}
|
||||
}.resume()
|
||||
}
|
||||
@@ -239,6 +452,20 @@ final class DesktopControls: NSObject, NSApplicationDelegate, NSMenuDelegate {
|
||||
}
|
||||
}
|
||||
|
||||
private final class DesktopRequestDecision: NSObject {
|
||||
let requestId: String
|
||||
let action: String
|
||||
|
||||
init(_ requestId: String, _ action: String) {
|
||||
self.requestId = requestId
|
||||
self.action = action
|
||||
}
|
||||
}
|
||||
|
||||
private func desktopLog(_ message: String) {
|
||||
fputs("\(message)\n", stderr)
|
||||
}
|
||||
|
||||
private func tokens(_ shortcut: String) -> [String] {
|
||||
shortcut.uppercased().split(separator: "+").map(String.init)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
namespace MeetingAssistant.Notifications;
|
||||
|
||||
public sealed record DesktopRequestAction(string Id, string Label);
|
||||
public sealed record DesktopUserRequest(string Id, string Kind, string Title, string Message,
|
||||
DateTimeOffset CreatedAt, DateTimeOffset ExpiresAt, IReadOnlyList<DesktopRequestAction> Actions);
|
||||
public sealed record DesktopUserRequestSpec(string Kind, string Title, string Message,
|
||||
TimeSpan Timeout, IReadOnlyList<DesktopRequestAction> Actions);
|
||||
public sealed record PendingUserRequestRegistration(string Id, Task<string?> Decision);
|
||||
public enum DesktopUserRequestRespondResult { Accepted, InvalidAction, NoLongerPending, Failed }
|
||||
|
||||
public sealed class DesktopUserRequestStore : IDisposable
|
||||
{
|
||||
private readonly object gate = new();
|
||||
private readonly Dictionary<string, PendingRequest> pending = new(StringComparer.Ordinal);
|
||||
private readonly TimeProvider timeProvider;
|
||||
private readonly IHostApplicationLifetime applicationLifetime;
|
||||
private readonly ILogger<DesktopUserRequestStore> logger;
|
||||
private readonly CancellationTokenRegistration shutdownRegistration;
|
||||
private bool disposed;
|
||||
|
||||
public DesktopUserRequestStore(TimeProvider timeProvider, IHostApplicationLifetime applicationLifetime,
|
||||
ILogger<DesktopUserRequestStore> logger)
|
||||
{
|
||||
this.timeProvider = timeProvider;
|
||||
this.applicationLifetime = applicationLifetime;
|
||||
this.logger = logger;
|
||||
shutdownRegistration = applicationLifetime.ApplicationStopping.Register(Dispose);
|
||||
}
|
||||
|
||||
public PendingUserRequestRegistration Add(DesktopUserRequestSpec spec, CancellationToken lifetime,
|
||||
Func<string, CancellationToken, Task>? onResponse = null)
|
||||
{
|
||||
lifetime.ThrowIfCancellationRequested();
|
||||
var now = timeProvider.GetUtcNow();
|
||||
var request = new DesktopUserRequest(Guid.NewGuid().ToString("N"), spec.Kind,
|
||||
spec.Title, spec.Message, now, now.Add(spec.Timeout), spec.Actions.ToArray());
|
||||
var entry = new PendingRequest(request, lifetime, onResponse);
|
||||
lock (gate)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(disposed, this);
|
||||
pending.Add(request.Id, entry);
|
||||
}
|
||||
var registration = lifetime.Register(() => Dismiss(request.Id));
|
||||
var retained = false;
|
||||
lock (gate)
|
||||
{
|
||||
retained = pending.ContainsKey(request.Id);
|
||||
if (retained) entry.CancellationRegistration = registration;
|
||||
}
|
||||
if (!retained) registration.Dispose();
|
||||
var deadlineTimer = timeProvider.CreateTimer(_ => Dismiss(request.Id), null,
|
||||
spec.Timeout, Timeout.InfiniteTimeSpan);
|
||||
lock (gate)
|
||||
{
|
||||
retained = pending.ContainsKey(request.Id);
|
||||
if (retained) entry.DeadlineTimer = deadlineTimer;
|
||||
}
|
||||
if (!retained) deadlineTimer.Dispose();
|
||||
return new PendingUserRequestRegistration(request.Id, entry.Decision.Task);
|
||||
}
|
||||
|
||||
public IReadOnlyList<DesktopUserRequest> List()
|
||||
{
|
||||
PendingRequest[] expired;
|
||||
DesktopUserRequest[] snapshot;
|
||||
lock (gate)
|
||||
{
|
||||
var now = timeProvider.GetUtcNow();
|
||||
expired = pending.Values.Where(entry => entry.Request.ExpiresAt <= now || entry.Lifetime.IsCancellationRequested).ToArray();
|
||||
foreach (var entry in expired) pending.Remove(entry.Request.Id);
|
||||
snapshot = pending.Values.Select(entry => entry.Request).ToArray();
|
||||
}
|
||||
foreach (var entry in expired)
|
||||
{
|
||||
entry.Decision.TrySetResult(null);
|
||||
entry.Dispose();
|
||||
}
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
public async Task<DesktopUserRequestRespondResult> RespondAsync(string id, string action)
|
||||
{
|
||||
PendingRequest? entry;
|
||||
bool expired;
|
||||
lock (gate)
|
||||
{
|
||||
if (!pending.TryGetValue(id, out entry)) return DesktopUserRequestRespondResult.NoLongerPending;
|
||||
expired = entry.Request.ExpiresAt <= timeProvider.GetUtcNow();
|
||||
if (!expired && !entry.Request.Actions.Any(candidate => candidate.Id == action))
|
||||
return DesktopUserRequestRespondResult.InvalidAction;
|
||||
pending.Remove(id);
|
||||
}
|
||||
entry.Dispose();
|
||||
if (expired || entry.Lifetime.IsCancellationRequested)
|
||||
{
|
||||
entry.Decision.TrySetResult(null);
|
||||
return DesktopUserRequestRespondResult.NoLongerPending;
|
||||
}
|
||||
try
|
||||
{
|
||||
if (entry.OnResponse is not null) await entry.OnResponse(action, entry.Lifetime);
|
||||
entry.Decision.TrySetResult(action);
|
||||
return DesktopUserRequestRespondResult.Accepted;
|
||||
}
|
||||
catch (OperationCanceledException) when (entry.Lifetime.IsCancellationRequested)
|
||||
{
|
||||
entry.Decision.TrySetResult(null);
|
||||
return DesktopUserRequestRespondResult.NoLongerPending;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
entry.Decision.TrySetResult(null);
|
||||
logger.LogError(exception, "Desktop request {RequestId} action failed", id);
|
||||
return DesktopUserRequestRespondResult.Failed;
|
||||
}
|
||||
}
|
||||
|
||||
public void Dismiss(string id)
|
||||
{
|
||||
PendingRequest? entry;
|
||||
lock (gate)
|
||||
{
|
||||
if (!pending.Remove(id, out entry)) return;
|
||||
}
|
||||
entry.Decision.TrySetResult(null);
|
||||
entry.Dispose();
|
||||
}
|
||||
public void DismissKind(string kind)
|
||||
{
|
||||
PendingRequest[] removed;
|
||||
lock (gate)
|
||||
{
|
||||
removed = pending.Values.Where(entry => entry.Request.Kind == kind).ToArray();
|
||||
foreach (var entry in removed) pending.Remove(entry.Request.Id);
|
||||
}
|
||||
foreach (var entry in removed)
|
||||
{
|
||||
entry.Decision.TrySetResult(null);
|
||||
entry.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
PendingRequest[] removed;
|
||||
lock (gate)
|
||||
{
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
removed = pending.Values.ToArray();
|
||||
pending.Clear();
|
||||
}
|
||||
foreach (var entry in removed)
|
||||
{
|
||||
entry.Decision.TrySetResult(null);
|
||||
entry.Dispose();
|
||||
}
|
||||
shutdownRegistration.Dispose();
|
||||
}
|
||||
|
||||
private sealed class PendingRequest(DesktopUserRequest request, CancellationToken lifetime,
|
||||
Func<string, CancellationToken, Task>? onResponse) : IDisposable
|
||||
{
|
||||
public DesktopUserRequest Request { get; } = request;
|
||||
public CancellationToken Lifetime { get; } = lifetime;
|
||||
public CancellationTokenRegistration CancellationRegistration { get; set; }
|
||||
public ITimer? DeadlineTimer { get; set; }
|
||||
public void Dispose()
|
||||
{
|
||||
CancellationRegistration.Dispose();
|
||||
DeadlineTimer?.Dispose();
|
||||
}
|
||||
public Func<string, CancellationToken, Task>? OnResponse { get; } = onResponse;
|
||||
public TaskCompletionSource<string?> Decision { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
}
|
||||
}
|
||||
+27
-14
@@ -44,11 +44,20 @@ builder.Services.AddSingleton<IMeetingAudioSource>(services => new CompositeMeet
|
||||
services.GetRequiredService<ILogger<CompositeMeetingAudioSource>>()));
|
||||
builder.Services.AddSingleton<IMeetingMetadataProvider, OutlookClassicMeetingMetadataProvider>();
|
||||
#else
|
||||
builder.Services.AddSingleton(TimeProvider.System);
|
||||
builder.Services.AddSingleton<DesktopUserRequestStore>();
|
||||
PortableMeetingAudioRegistration.Add(builder.Services);
|
||||
MacOsMeetingIntegrationRegistration.Add(builder.Services, OperatingSystem.IsMacOS());
|
||||
builder.Services.AddHostedService<MacOsDesktopControlService>();
|
||||
builder.Services.AddSingleton<IMicrophoneSwitchPromptService, NoopMicrophoneSwitchPromptService>();
|
||||
builder.Services.AddSingleton<IAgentApprovalPromptService, NoopAgentApprovalPromptService>();
|
||||
if (OperatingSystem.IsMacOS())
|
||||
{
|
||||
builder.Services.AddSingleton<IAgentApprovalPromptService, MacOsAgentApprovalPromptService>();
|
||||
}
|
||||
else
|
||||
{
|
||||
builder.Services.AddSingleton<IAgentApprovalPromptService, NoopAgentApprovalPromptService>();
|
||||
}
|
||||
#endif
|
||||
builder.Services.AddSingleton<ITranscriptStore, VaultTranscriptStore>();
|
||||
builder.Services.AddSingleton<IMeetingNoteStore, MarkdownMeetingNoteStore>();
|
||||
@@ -66,7 +75,14 @@ builder.Services.AddSingleton<IMeetingInactivityPromptService>(services =>
|
||||
builder.Services.AddHostedService(services =>
|
||||
services.GetRequiredService<WindowsMeetingInactivityPromptService>());
|
||||
#else
|
||||
builder.Services.AddSingleton<IMeetingInactivityPromptService, NoopMeetingInactivityPromptService>();
|
||||
if (OperatingSystem.IsMacOS())
|
||||
{
|
||||
builder.Services.AddSingleton<IMeetingInactivityPromptService, MacOsMeetingInactivityPromptService>();
|
||||
}
|
||||
else
|
||||
{
|
||||
builder.Services.AddSingleton<IMeetingInactivityPromptService, NoopMeetingInactivityPromptService>();
|
||||
}
|
||||
#endif
|
||||
builder.Services.AddSingleton<IRecordingDictationWordProvider, RecordingDictationWordProvider>();
|
||||
builder.Services.AddSingleton<ICalendarPromptClock, SystemCalendarPromptClock>();
|
||||
@@ -147,8 +163,11 @@ builder.Services.AddSingleton(services => WorkflowRulesEditorMarkdownLinkResolve
|
||||
services.GetRequiredService<IOptions<MeetingAssistantOptions>>().Value));
|
||||
builder.Services.AddSingleton<IWorkflowRulesEditorWindowService, WpfWorkflowRulesEditorWindowService>();
|
||||
#else
|
||||
builder.Services.AddSingleton<MacOsWorkflowRulesEditorSession>();
|
||||
builder.Services.AddSingleton<IWorkflowRulesEditorWindowService, MacOsWorkflowRulesEditorWindowService>();
|
||||
builder.Services.AddSingleton<MacOsWorkflowRulesEditorSessions>();
|
||||
builder.Services.AddSingleton<IWorkflowRulesEditorWindowService>(services =>
|
||||
new MacOsWorkflowRulesEditorWindowService(
|
||||
services.GetRequiredService<IOptions<MeetingAssistantOptions>>(),
|
||||
() => services.GetRequiredService<MacOsWorkflowRulesEditorSessions>()));
|
||||
#endif
|
||||
builder.Services.AddSingleton<AgentApprovalService>();
|
||||
builder.Services.AddSingleton<WorkflowChangeRequestService>();
|
||||
@@ -205,6 +224,10 @@ app.MapGet("/diagnostics/platform-capabilities", (
|
||||
screenshotProvider,
|
||||
workflowEditorProvider)));
|
||||
|
||||
#if !WINDOWS
|
||||
app.MapDesktopCommunication();
|
||||
#endif
|
||||
|
||||
app.MapGet("/recording/status", (MeetingRecordingCoordinator coordinator) => Results.Ok(coordinator.CurrentStatus));
|
||||
app.MapGet("/profiles/{launchProfile}/recording/status", (
|
||||
string launchProfile,
|
||||
@@ -343,16 +366,6 @@ app.MapPost("/diagnostics/settings-and-logs/show", (
|
||||
rulesEditorWindow.Show();
|
||||
return Results.Accepted();
|
||||
});
|
||||
#if !WINDOWS
|
||||
app.MapGet("/diagnostics/settings-and-logs", () =>
|
||||
Results.Content(MacOsWorkflowRulesEditorPage.Html, "text/html"));
|
||||
app.MapPost("/diagnostics/settings-and-logs/chat", async (
|
||||
MacOsWorkflowRulesEditorChatRequest request,
|
||||
MacOsWorkflowRulesEditorSession session,
|
||||
CancellationToken cancellationToken) =>
|
||||
Results.Ok(await session.SendAsync(request.Message, cancellationToken)));
|
||||
#endif
|
||||
|
||||
static WorkflowConfigurationReloadResponse ReloadWorkflowConfiguration(IConfiguration configuration)
|
||||
{
|
||||
if (configuration is not IConfigurationRoot root)
|
||||
|
||||
Reference in new issue
Block a user