Public Access
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:
@@ -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>
|
||||
""";
|
||||
}
|
||||
@@ -16,13 +16,15 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup Condition="$([MSBuild]::IsOSPlatform('OSX')) and $([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) != 'windows'">
|
||||
<MacOsAudioCaptureEnabled>true</MacOsAudioCaptureEnabled>
|
||||
<MacOsNativeHelpersEnabled>true</MacOsNativeHelpersEnabled>
|
||||
<MacOsAudioCaptureSource>$(MSBuildProjectDirectory)/Native/MacOsMeetingAudioCapture/main.swift</MacOsAudioCaptureSource>
|
||||
<MacOsAudioCaptureOutputPath>Native/macos-meeting-audio-capture</MacOsAudioCaptureOutputPath>
|
||||
<MacOsAudioCaptureRid Condition="'$(RuntimeIdentifier)' != ''">$(RuntimeIdentifier)</MacOsAudioCaptureRid>
|
||||
<MacOsAudioCaptureRid Condition="'$(MacOsAudioCaptureRid)' == ''">$(NETCoreSdkRuntimeIdentifier)</MacOsAudioCaptureRid>
|
||||
<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>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
@@ -72,19 +74,21 @@
|
||||
</Target>
|
||||
|
||||
<Target
|
||||
Name="BuildMacOsAudioCaptureHelper"
|
||||
Name="BuildMacOsNativeHelpers"
|
||||
AfterTargets="Build"
|
||||
Condition="'$(MacOsAudioCaptureEnabled)' == 'true'">
|
||||
Condition="'$(MacOsNativeHelpersEnabled)' == 'true'">
|
||||
<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)"" />
|
||||
</Target>
|
||||
|
||||
<Target
|
||||
Name="CopyMacOsAudioCaptureHelperToPublish"
|
||||
Name="CopyMacOsNativeHelpersToPublish"
|
||||
AfterTargets="Publish"
|
||||
Condition="'$(MacOsAudioCaptureEnabled)' == 'true'">
|
||||
Condition="'$(MacOsNativeHelpersEnabled)' == 'true'">
|
||||
<MakeDir Directories="$(PublishDir)Native" />
|
||||
<Copy SourceFiles="$(TargetDir)$(MacOsAudioCaptureOutputPath)" DestinationFolder="$(PublishDir)Native" />
|
||||
<Copy SourceFiles="$(TargetDir)$(MacOsDesktopControlsOutputPath)" DestinationFolder="$(PublishDir)Native" />
|
||||
</Target>
|
||||
|
||||
<ItemGroup Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) != 'windows'">
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
import AppKit
|
||||
import Carbon
|
||||
import Foundation
|
||||
import WebKit
|
||||
|
||||
struct Manifest: Decodable {
|
||||
let baseUrl: String
|
||||
let statusUrl: String
|
||||
let openAgentUrl: String
|
||||
let profiles: [Profile]
|
||||
let hotkeys: [Hotkey]
|
||||
}
|
||||
|
||||
struct Profile: Decodable {
|
||||
let name: String
|
||||
let toggleShortcut: String
|
||||
}
|
||||
|
||||
struct Hotkey: Decodable {
|
||||
let shortcut: String
|
||||
let endpoint: String
|
||||
}
|
||||
|
||||
struct RecordingStatus: Decodable {
|
||||
let isRecording: Bool
|
||||
let state: String
|
||||
let launchProfile: String?
|
||||
}
|
||||
|
||||
@main
|
||||
final class DesktopControls: NSObject, NSApplicationDelegate, NSMenuDelegate {
|
||||
private let manifest: Manifest
|
||||
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?
|
||||
|
||||
override init() {
|
||||
guard CommandLine.arguments.count == 2,
|
||||
let data = try? Data(contentsOf: URL(fileURLWithPath: CommandLine.arguments[1])),
|
||||
let decoded = try? JSONDecoder().decode(Manifest.self, from: data) else {
|
||||
fputs("Usage: macos-desktop-controls <manifest.json>\n", stderr)
|
||||
exit(2)
|
||||
}
|
||||
manifest = decoded
|
||||
super.init()
|
||||
}
|
||||
|
||||
static func main() {
|
||||
let application = NSApplication.shared
|
||||
let delegate = DesktopControls()
|
||||
application.delegate = delegate
|
||||
application.setActivationPolicy(.accessory)
|
||||
application.run()
|
||||
}
|
||||
|
||||
func applicationDidFinishLaunching(_ notification: Notification) {
|
||||
statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.squareLength)
|
||||
statusItem.button?.image = NSImage(
|
||||
systemSymbolName: "waveform.circle",
|
||||
accessibilityDescription: "Meeting Assistant")
|
||||
let menu = NSMenu()
|
||||
menu.delegate = self
|
||||
statusItem.menu = menu
|
||||
registerHotkeys()
|
||||
refreshStatus()
|
||||
timer = Timer.scheduledTimer(withTimeInterval: 2, repeats: true) { [weak self] _ in
|
||||
self?.refreshStatus()
|
||||
}
|
||||
}
|
||||
|
||||
func applicationWillTerminate(_ notification: Notification) {
|
||||
timer?.invalidate()
|
||||
}
|
||||
|
||||
func menuWillOpen(_ menu: NSMenu) {
|
||||
rebuildMenu(menu)
|
||||
refreshStatus()
|
||||
}
|
||||
|
||||
private func rebuildMenu(_ menu: NSMenu) {
|
||||
menu.removeAllItems()
|
||||
let stateItem = NSMenuItem(title: tooltip, action: nil, keyEquivalent: "")
|
||||
stateItem.isEnabled = false
|
||||
menu.addItem(stateItem)
|
||||
menu.addItem(.separator())
|
||||
menu.addItem(item("Open agent", #selector(openAgent)))
|
||||
|
||||
if status.isRecording {
|
||||
menu.addItem(item("Stop meeting recording and transcribe", #selector(stopRecording)))
|
||||
menu.addItem(item("Cancel meeting recording and discard", #selector(abortRecording)))
|
||||
} else {
|
||||
for (index, profile) in manifest.profiles.enumerated() {
|
||||
let entry = item(
|
||||
"Start meeting recording (\(profile.name))",
|
||||
#selector(startProfile(_:)))
|
||||
entry.tag = index
|
||||
entry.keyEquivalent = menuKey(from: profile.toggleShortcut)
|
||||
entry.keyEquivalentModifierMask = menuModifiers(from: profile.toggleShortcut)
|
||||
menu.addItem(entry)
|
||||
}
|
||||
}
|
||||
menu.addItem(.separator())
|
||||
menu.addItem(item("Exit", #selector(exitApplication)))
|
||||
}
|
||||
|
||||
private var tooltip: String {
|
||||
if status.isRecording {
|
||||
return status.launchProfile.map { "Meeting Assistant — recording (\($0))" }
|
||||
?? "Meeting Assistant — recording"
|
||||
}
|
||||
return status.state.lowercased() == "summarizing"
|
||||
? "Meeting Assistant — summarizing"
|
||||
: "Meeting Assistant — idle"
|
||||
}
|
||||
|
||||
private func item(_ title: String, _ action: Selector) -> NSMenuItem {
|
||||
let result = NSMenuItem(title: title, action: action, keyEquivalent: "")
|
||||
result.target = self
|
||||
return result
|
||||
}
|
||||
|
||||
@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
|
||||
}
|
||||
NSApplication.shared.activate(ignoringOtherApps: true)
|
||||
agentWindow?.makeKeyAndOrderFront(nil)
|
||||
}
|
||||
|
||||
@objc private func startProfile(_ sender: NSMenuItem) {
|
||||
let profile = manifest.profiles[sender.tag]
|
||||
post(path: "/profiles/\(escaped(profile.name))/recording/start")
|
||||
}
|
||||
|
||||
@objc private func stopRecording() {
|
||||
post(path: "/recording/stop")
|
||||
}
|
||||
|
||||
@objc private func abortRecording() {
|
||||
post(path: "/recording/abort")
|
||||
}
|
||||
|
||||
@objc private func exitApplication() {
|
||||
post(path: "/application/exit")
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
|
||||
NSApplication.shared.terminate(nil)
|
||||
}
|
||||
}
|
||||
|
||||
private func refreshStatus() {
|
||||
guard let url = URL(string: manifest.statusUrl) else { return }
|
||||
URLSession.shared.dataTask(with: url) { [weak self] data, _, _ in
|
||||
guard let data,
|
||||
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)
|
||||
}
|
||||
}.resume()
|
||||
}
|
||||
|
||||
private func post(path: String) {
|
||||
post(url: manifest.baseUrl + path)
|
||||
}
|
||||
|
||||
private func post(url value: String) {
|
||||
guard let url = URL(string: value) else { return }
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "POST"
|
||||
URLSession.shared.dataTask(with: request) { [weak self] _, _, _ in
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
|
||||
self?.refreshStatus()
|
||||
}
|
||||
}.resume()
|
||||
}
|
||||
|
||||
private func registerHotkeys() {
|
||||
var eventType = EventTypeSpec(
|
||||
eventClass: OSType(kEventClassKeyboard),
|
||||
eventKind: OSType(kEventHotKeyPressed))
|
||||
InstallEventHandler(
|
||||
GetApplicationEventTarget(),
|
||||
{ _, event, context in
|
||||
guard let event, let context else { return noErr }
|
||||
var identifier = EventHotKeyID()
|
||||
GetEventParameter(
|
||||
event,
|
||||
EventParamName(kEventParamDirectObject),
|
||||
EventParamType(typeEventHotKeyID),
|
||||
nil,
|
||||
MemoryLayout<EventHotKeyID>.size,
|
||||
nil,
|
||||
&identifier)
|
||||
let owner = Unmanaged<DesktopControls>.fromOpaque(context).takeUnretainedValue()
|
||||
if let hotkey = owner.registeredHotkeys[identifier.id] {
|
||||
owner.post(path: hotkey.endpoint)
|
||||
}
|
||||
return noErr
|
||||
},
|
||||
1,
|
||||
&eventType,
|
||||
Unmanaged.passUnretained(self).toOpaque(),
|
||||
nil)
|
||||
|
||||
for (index, hotkey) in manifest.hotkeys.enumerated() {
|
||||
guard let keyCode = carbonKeyCode(from: hotkey.shortcut) else { continue }
|
||||
let identifier = EventHotKeyID(signature: fourCC("MTGA"), id: UInt32(index + 1))
|
||||
var reference: EventHotKeyRef?
|
||||
let result = RegisterEventHotKey(
|
||||
keyCode,
|
||||
carbonModifiers(from: hotkey.shortcut),
|
||||
identifier,
|
||||
GetApplicationEventTarget(),
|
||||
0,
|
||||
&reference)
|
||||
if result == noErr {
|
||||
registeredHotkeys[identifier.id] = hotkey
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func escaped(_ value: String) -> String {
|
||||
value.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? value
|
||||
}
|
||||
}
|
||||
|
||||
private func tokens(_ shortcut: String) -> [String] {
|
||||
shortcut.uppercased().split(separator: "+").map(String.init)
|
||||
}
|
||||
|
||||
private func carbonModifiers(from shortcut: String) -> UInt32 {
|
||||
let parts = tokens(shortcut)
|
||||
var result: UInt32 = 0
|
||||
if parts.contains("CTRL") || parts.contains("CONTROL") { result |= UInt32(controlKey) }
|
||||
if parts.contains("ALT") || parts.contains("OPTION") { result |= UInt32(optionKey) }
|
||||
if parts.contains("SHIFT") { result |= UInt32(shiftKey) }
|
||||
if parts.contains("WIN") || parts.contains("WINDOWS") || parts.contains("CMD") || parts.contains("COMMAND") {
|
||||
result |= UInt32(cmdKey)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private func menuModifiers(from shortcut: String) -> NSEvent.ModifierFlags {
|
||||
let parts = tokens(shortcut)
|
||||
var result: NSEvent.ModifierFlags = []
|
||||
if parts.contains("CTRL") || parts.contains("CONTROL") { result.insert(.control) }
|
||||
if parts.contains("ALT") || parts.contains("OPTION") { result.insert(.option) }
|
||||
if parts.contains("SHIFT") { result.insert(.shift) }
|
||||
if parts.contains("WIN") || parts.contains("WINDOWS") || parts.contains("CMD") || parts.contains("COMMAND") {
|
||||
result.insert(.command)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private func menuKey(from shortcut: String) -> String {
|
||||
tokens(shortcut).last?.lowercased() ?? ""
|
||||
}
|
||||
|
||||
private func carbonKeyCode(from shortcut: String) -> UInt32? {
|
||||
guard let key = tokens(shortcut).last, key.count == 1, let scalar = key.unicodeScalars.first else {
|
||||
return nil
|
||||
}
|
||||
let codes: [Character: UInt32] = [
|
||||
"A": 0, "S": 1, "D": 2, "F": 3, "H": 4, "G": 5, "Z": 6, "X": 7,
|
||||
"C": 8, "V": 9, "B": 11, "Q": 12, "W": 13, "E": 14, "R": 15,
|
||||
"Y": 16, "T": 17, "M": 46, "L": 37, "N": 45, "P": 35, "O": 31,
|
||||
"I": 34, "U": 32, "J": 38, "K": 40
|
||||
]
|
||||
return codes[Character(String(scalar))]
|
||||
}
|
||||
|
||||
private func fourCC(_ value: String) -> OSType {
|
||||
value.utf8.reduce(0) { ($0 << 8) + OSType($1) }
|
||||
}
|
||||
@@ -3,6 +3,7 @@ using MeetingAssistant.Calendar;
|
||||
using MeetingAssistant.Hotkeys;
|
||||
using MeetingAssistant.LaunchProfiles;
|
||||
using MeetingAssistant.Logging;
|
||||
using MeetingAssistant.MacOs;
|
||||
using MeetingAssistant.MeetingNotes;
|
||||
using MeetingAssistant.Recording;
|
||||
using MeetingAssistant.Screenshots;
|
||||
@@ -38,6 +39,7 @@ builder.Services.AddSingleton<IMeetingMetadataProvider, OutlookClassicMeetingMet
|
||||
#else
|
||||
PortableMeetingAudioRegistration.Add(builder.Services);
|
||||
builder.Services.AddSingleton<IMeetingMetadataProvider, NoopMeetingMetadataProvider>();
|
||||
builder.Services.AddHostedService<MacOsDesktopControlService>();
|
||||
#endif
|
||||
builder.Services.AddSingleton<ITranscriptStore, VaultTranscriptStore>();
|
||||
builder.Services.AddSingleton<IMeetingNoteStore, MarkdownMeetingNoteStore>();
|
||||
@@ -111,7 +113,8 @@ builder.Services.AddSingleton(services => WorkflowRulesEditorMarkdownLinkResolve
|
||||
services.GetRequiredService<IOptions<MeetingAssistantOptions>>().Value));
|
||||
builder.Services.AddSingleton<IWorkflowRulesEditorWindowService, WpfWorkflowRulesEditorWindowService>();
|
||||
#else
|
||||
builder.Services.AddSingleton<IWorkflowRulesEditorWindowService, NoopWorkflowRulesEditorWindowService>();
|
||||
builder.Services.AddSingleton<MacOsWorkflowRulesEditorSession>();
|
||||
builder.Services.AddSingleton<IWorkflowRulesEditorWindowService, MacOsWorkflowRulesEditorWindowService>();
|
||||
#endif
|
||||
builder.Services.AddSingleton<AsrDiagnosticService>();
|
||||
builder.Services.AddSingleton<ICommandRunner, ProcessCommandRunner>();
|
||||
@@ -152,6 +155,11 @@ app.MapGet("/recording/status", (MeetingRecordingCoordinator coordinator) => Res
|
||||
app.MapGet("/profiles/{launchProfile}/recording/status", (
|
||||
string launchProfile,
|
||||
MeetingRecordingCoordinator coordinator) => Results.Ok(coordinator.CurrentStatus));
|
||||
app.MapPost("/application/exit", (IHostApplicationLifetime lifetime) =>
|
||||
{
|
||||
lifetime.StopApplication();
|
||||
return Results.Accepted();
|
||||
});
|
||||
app.MapGet("/diagnostics/outlook/current-meeting", async (
|
||||
IMeetingMetadataProvider metadataProvider,
|
||||
IOptions<MeetingAssistantOptions> options,
|
||||
@@ -281,6 +289,15 @@ 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)
|
||||
{
|
||||
@@ -323,6 +340,11 @@ app.MapPost("/profiles/{launchProfile}/recording/abort", async (
|
||||
MeetingRecordingCoordinator coordinator,
|
||||
CancellationToken cancellationToken) =>
|
||||
Results.Ok(await coordinator.AbortAsync(cancellationToken)));
|
||||
app.MapPost("/profiles/{launchProfile}/meetings/screenshot/capture", async (
|
||||
string launchProfile,
|
||||
MeetingRecordingCoordinator coordinator,
|
||||
CancellationToken cancellationToken) =>
|
||||
Results.Ok(await coordinator.CaptureScreenshotAsync(launchProfile, cancellationToken)));
|
||||
app.MapPost("/meetings/current/summary/run", async (
|
||||
MeetingRecordingCoordinator coordinator,
|
||||
IMeetingSummaryPipeline summaryPipeline,
|
||||
|
||||
Reference in New Issue
Block a user