feat(macos): add native desktop controls and hotkey integration

- Introduced MacOsDesktopControlManifest to manage desktop control configurations.
- Implemented MacOsDesktopControlService to handle the lifecycle of the macOS helper process.
- Created MacOsWorkflowRulesEditorWindowService for managing workflow rules through a native window.
- Developed a Swift helper for macOS that integrates with AppKit and Carbon for menu-bar controls and global hotkeys.
- Added support for recording lifecycle actions via HTTP endpoints, maintaining consistency with existing Windows functionality.
- Enhanced user experience by allowing macOS users to start/stop recordings and access settings through a menu-bar interface.
- Documented design decisions, implementation evidence, and verification tasks for the new macOS features.
This commit is contained in:
dh
2026-08-06 08:18:52 +02:00
parent 5f39923c27
commit 9542c05ea4
20 changed files with 981 additions and 11 deletions
@@ -0,0 +1,47 @@
using MeetingAssistant.LaunchProfiles;
namespace MeetingAssistant.MacOs;
public sealed record MacOsDesktopControlManifest(
string BaseUrl,
string StatusUrl,
string OpenAgentUrl,
IReadOnlyList<MacOsDesktopProfile> Profiles,
IReadOnlyList<MacOsDesktopHotkey> Hotkeys)
{
public static MacOsDesktopControlManifest Create(
string baseUrl,
IReadOnlyList<LaunchProfile> profiles,
IReadOnlyList<LaunchProfileHotkey> hotkeys)
{
var normalizedBaseUrl = baseUrl.TrimEnd('/');
return new MacOsDesktopControlManifest(
normalizedBaseUrl,
$"{normalizedBaseUrl}/recording/status",
$"{normalizedBaseUrl}/diagnostics/settings-and-logs",
profiles
.Select(profile => new MacOsDesktopProfile(
profile.Name,
profile.Options.Hotkey.Toggle))
.ToArray(),
hotkeys.Select(CreateHotkey).ToArray());
}
private static MacOsDesktopHotkey CreateHotkey(LaunchProfileHotkey hotkey)
{
var endpoint = hotkey.Action switch
{
LaunchProfileHotkeyAction.ToggleRecording => "recording/toggle",
LaunchProfileHotkeyAction.AbortRecording => "recording/abort",
LaunchProfileHotkeyAction.CaptureScreenshot => "meetings/screenshot/capture",
_ => throw new ArgumentOutOfRangeException(nameof(hotkey))
};
return new MacOsDesktopHotkey(
hotkey.Hotkey,
$"/profiles/{Uri.EscapeDataString(hotkey.ProfileName)}/{endpoint}");
}
}
public sealed record MacOsDesktopProfile(string Name, string ToggleShortcut);
public sealed record MacOsDesktopHotkey(string Shortcut, string Endpoint);
@@ -0,0 +1,107 @@
using System.Diagnostics;
using System.Text.Json;
using MeetingAssistant.LaunchProfiles;
using Microsoft.Extensions.Options;
namespace MeetingAssistant.MacOs;
public sealed class MacOsDesktopControlService : IHostedService, IDisposable
{
private readonly ILaunchProfileOptionsProvider launchProfiles;
private readonly IOptions<MeetingAssistantOptions> options;
private readonly IHostApplicationLifetime applicationLifetime;
private readonly ILogger<MacOsDesktopControlService> logger;
private Process? helper;
private string? manifestPath;
public MacOsDesktopControlService(
ILaunchProfileOptionsProvider launchProfiles,
IOptions<MeetingAssistantOptions> options,
IHostApplicationLifetime applicationLifetime,
ILogger<MacOsDesktopControlService> logger)
{
this.launchProfiles = launchProfiles;
this.options = options;
this.applicationLifetime = applicationLifetime;
this.logger = logger;
}
public Task StartAsync(CancellationToken cancellationToken)
{
if (!OperatingSystem.IsMacOS())
{
return Task.CompletedTask;
}
var helperPath = Path.Combine(AppContext.BaseDirectory, "Native", "macos-desktop-controls");
if (!File.Exists(helperPath))
{
logger.LogWarning("macOS desktop controls helper was not found at {HelperPath}", helperPath);
return Task.CompletedTask;
}
var manifest = MacOsDesktopControlManifest.Create(
options.Value.Api.PublicBaseUrl,
launchProfiles.GetProfiles(),
launchProfiles.GetHotkeys());
manifestPath = Path.Combine(
Path.GetTempPath(),
$"meeting-assistant-desktop-{Environment.ProcessId}.json");
File.WriteAllText(
manifestPath,
JsonSerializer.Serialize(
manifest,
new JsonSerializerOptions(JsonSerializerDefaults.Web)));
helper = Process.Start(new ProcessStartInfo
{
FileName = helperPath,
ArgumentList = { manifestPath },
UseShellExecute = false,
CreateNoWindow = true
});
if (helper is null)
{
throw new InvalidOperationException("Could not start the macOS desktop controls helper.");
}
helper.EnableRaisingEvents = true;
helper.Exited += (_, _) =>
{
if (!applicationLifetime.ApplicationStopping.IsCancellationRequested)
{
logger.LogWarning("macOS desktop controls helper exited unexpectedly");
}
};
logger.LogInformation("Started macOS menu-bar controls with {HotkeyCount} global hotkeys", manifest.Hotkeys.Count);
return Task.CompletedTask;
}
public Task StopAsync(CancellationToken cancellationToken)
{
if (helper is { HasExited: false })
{
helper.Kill();
helper.WaitForExit(TimeSpan.FromSeconds(2));
}
DeleteManifest();
return Task.CompletedTask;
}
public void Dispose()
{
helper?.Dispose();
DeleteManifest();
}
private void DeleteManifest()
{
if (manifestPath is not null && File.Exists(manifestPath))
{
File.Delete(manifestPath);
manifestPath = null;
}
}
}
@@ -0,0 +1,161 @@
using System.Diagnostics;
using MeetingAssistant.Workflow;
using Microsoft.Extensions.Options;
namespace MeetingAssistant.MacOs;
public sealed class MacOsWorkflowRulesEditorWindowService : IWorkflowRulesEditorWindowService
{
private readonly IOptions<MeetingAssistantOptions> options;
public MacOsWorkflowRulesEditorWindowService(IOptions<MeetingAssistantOptions> options)
{
this.options = options;
}
public void Show()
{
if (!OperatingSystem.IsMacOS())
{
return;
}
var url = $"{options.Value.Api.PublicBaseUrl.TrimEnd('/')}/diagnostics/settings-and-logs";
Process.Start(new ProcessStartInfo
{
FileName = "/usr/bin/open",
ArgumentList = { url },
UseShellExecute = false
});
}
}
public sealed class MacOsWorkflowRulesEditorSession
{
private readonly WorkflowRulesEditorChatViewModel viewModel;
private readonly SemaphoreSlim sendLock = new(1, 1);
public MacOsWorkflowRulesEditorSession(WorkflowRulesEditorChatViewModel viewModel)
{
this.viewModel = viewModel;
}
public async Task<MacOsWorkflowRulesEditorChatResponse> SendAsync(
string? message,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(message))
{
return Snapshot();
}
await sendLock.WaitAsync(cancellationToken);
try
{
viewModel.Draft = message;
await viewModel.SendAsync(cancellationToken);
return Snapshot();
}
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);
public sealed record MacOsWorkflowRulesEditorMessage(string Role, string Content);
public static class MacOsWorkflowRulesEditorPage
{
public const string Html = """
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Meeting Summary Agent</title>
<style>
:root { color-scheme: dark; font: 15px -apple-system, BlinkMacSystemFont, sans-serif; }
* { box-sizing: border-box; }
body { margin: 0; background: #171719; color: #f5f5f7; }
main { height: 100vh; display: grid; grid-template-rows: auto 1fr auto; padding: 18px; gap: 14px; }
h1 { margin: 0; font-size: 18px; }
#messages { overflow: auto; display: flex; flex-direction: column; gap: 10px; }
.message { max-width: 82%; padding: 10px 12px; border: 1px solid #414147; border-radius: 10px; white-space: pre-wrap; }
.user { align-self: flex-end; background: #293650; }
.agent { align-self: flex-start; background: #232326; }
form { display: grid; grid-template-columns: 1fr auto; gap: 10px; }
textarea { min-height: 78px; resize: vertical; border-radius: 9px; border: 1px solid #55555d; background: #222225; color: inherit; padding: 10px; font: inherit; }
button { min-width: 96px; border: 0; border-radius: 9px; background: #0a84ff; color: white; font-weight: 600; }
button:disabled { opacity: .55; }
</style>
</head>
<body>
<main>
<h1>Meeting Summary Agent</h1>
<section id="messages"></section>
<form id="chat">
<textarea id="prompt" placeholder="ask me what I can do for you"></textarea>
<button id="send">Send</button>
</form>
</main>
<script>
const form = document.querySelector('#chat');
const prompt = document.querySelector('#prompt');
const send = document.querySelector('#send');
const messages = document.querySelector('#messages');
const render = items => {
messages.replaceChildren(...items.map(item => {
const card = document.createElement('div');
card.className = `message ${item.role}`;
card.textContent = item.content;
return card;
}));
messages.scrollTop = messages.scrollHeight;
};
form.addEventListener('submit', async event => {
event.preventDefault();
const message = prompt.value.trim();
if (!message) return;
prompt.value = '';
send.disabled = true;
try {
const response = await fetch('/diagnostics/settings-and-logs/chat', {
method: 'POST',
headers: {'content-type': 'application/json'},
body: JSON.stringify({message})
});
const body = await response.json();
render(body.messages);
} finally {
send.disabled = false;
prompt.focus();
}
});
prompt.addEventListener('keydown', event => {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
form.requestSubmit();
}
});
</script>
</body>
</html>
""";
}