Public Access
feat(macos): add native Meeting Assistant support #39
@@ -0,0 +1,89 @@
|
||||
using MeetingAssistant.LaunchProfiles;
|
||||
using MeetingAssistant.MacOs;
|
||||
using MeetingAssistant.Workflow;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace MeetingAssistant.Tests;
|
||||
|
||||
public sealed class MacOsDesktopControlManifestTests
|
||||
{
|
||||
[Fact]
|
||||
public void MacOsApplicationRegistersARealInteractiveAgentWindow()
|
||||
{
|
||||
if (!OperatingSystem.IsMacOS())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
using var factory = new WebApplicationFactory<Program>();
|
||||
|
||||
var service = factory.Services.GetRequiredService<IWorkflowRulesEditorWindowService>();
|
||||
|
||||
Assert.IsType<MacOsWorkflowRulesEditorWindowService>(service);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ManifestPreservesConfiguredWindowsEquivalentShortcutsAndControlEndpoints()
|
||||
{
|
||||
var profiles = new[]
|
||||
{
|
||||
Profile("default", "Ctrl+Alt+M", "Ctrl+Alt+Z", "Ctrl+Alt+S"),
|
||||
Profile("english", "Ctrl+Alt+L", "", "")
|
||||
};
|
||||
|
||||
var hotkeys = new[]
|
||||
{
|
||||
new LaunchProfileHotkey("default", "Ctrl+Alt+M", LaunchProfileHotkeyAction.ToggleRecording),
|
||||
new LaunchProfileHotkey("default", "Ctrl+Alt+Z", LaunchProfileHotkeyAction.AbortRecording),
|
||||
new LaunchProfileHotkey("default", "Ctrl+Alt+S", LaunchProfileHotkeyAction.CaptureScreenshot),
|
||||
new LaunchProfileHotkey("english", "Ctrl+Alt+L", LaunchProfileHotkeyAction.ToggleRecording)
|
||||
};
|
||||
|
||||
var manifest = MacOsDesktopControlManifest.Create(
|
||||
"http://127.0.0.1:5090",
|
||||
profiles,
|
||||
hotkeys);
|
||||
|
||||
Assert.Collection(
|
||||
manifest.Hotkeys,
|
||||
hotkey =>
|
||||
{
|
||||
Assert.Equal("Ctrl+Alt+M", hotkey.Shortcut);
|
||||
Assert.Equal("/profiles/default/recording/toggle", hotkey.Endpoint);
|
||||
},
|
||||
hotkey =>
|
||||
{
|
||||
Assert.Equal("Ctrl+Alt+Z", hotkey.Shortcut);
|
||||
Assert.Equal("/profiles/default/recording/abort", hotkey.Endpoint);
|
||||
},
|
||||
hotkey =>
|
||||
{
|
||||
Assert.Equal("Ctrl+Alt+S", hotkey.Shortcut);
|
||||
Assert.Equal("/profiles/default/meetings/screenshot/capture", hotkey.Endpoint);
|
||||
},
|
||||
hotkey =>
|
||||
{
|
||||
Assert.Equal("Ctrl+Alt+L", hotkey.Shortcut);
|
||||
Assert.Equal("/profiles/english/recording/toggle", hotkey.Endpoint);
|
||||
});
|
||||
Assert.Equal("http://127.0.0.1:5090/recording/status", manifest.StatusUrl);
|
||||
Assert.Equal("http://127.0.0.1:5090/diagnostics/settings-and-logs", manifest.OpenAgentUrl);
|
||||
}
|
||||
|
||||
private static LaunchProfile Profile(string name, string toggle, string abort, string screenshot)
|
||||
{
|
||||
return new LaunchProfile(name, new MeetingAssistantOptions
|
||||
{
|
||||
Hotkey = new HotkeyOptions
|
||||
{
|
||||
Toggle = toggle,
|
||||
Abort = abort
|
||||
},
|
||||
Screenshots = new ScreenshotOptions
|
||||
{
|
||||
Hotkey = screenshot
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -9,7 +9,7 @@ This repository owns the application source, tests, OpenSpec requirements, confi
|
||||
The app is intentionally local-first:
|
||||
|
||||
- Windows builds provide the tray icon, global hotkeys, NAudio capture, Outlook Classic COM enrichment, active-window screenshots, and notifications.
|
||||
- Portable builds on macOS capture the default microphone through AVFoundation and computer output through ScreenCaptureKit, while keeping Windows-only desktop integrations compiled out.
|
||||
- Portable builds on macOS provide a native menu-bar icon and global hotkeys, and capture the default microphone through AVFoundation plus computer output through ScreenCaptureKit.
|
||||
- Portable builds on other hosts keep the server/testable service surface but do not provide meeting audio capture.
|
||||
- The normal runtime endpoint is local HTTP on port `5090`, with `/health` and `/recording/status` as the safe first checks.
|
||||
- Behavior changes are OpenSpec-driven; current accepted requirements live under `openspec/specs`.
|
||||
@@ -32,7 +32,7 @@ dotnet build MeetingAssistant/MeetingAssistant.csproj -f net10.0
|
||||
dotnet run --project MeetingAssistant/MeetingAssistant.csproj -f net10.0
|
||||
```
|
||||
|
||||
The macOS build compiles the bundled Swift capture helper into `Native/macos-meeting-audio-capture`. On first recording, allow both **Microphone** and **Screen & System Audio Recording** under System Settings > Privacy & Security. macOS recording is controlled through the local HTTP endpoints because the tray icon and global hotkeys remain Windows-only.
|
||||
The macOS build compiles bundled Swift helpers into `Native/macos-meeting-audio-capture` and `Native/macos-desktop-controls`. On first recording, allow both **Microphone** and **Screen & System Audio Recording** under System Settings > Privacy & Security. The menu-bar icon and global hotkeys use the same configured bindings as Windows and call the local service surface.
|
||||
|
||||
For the durable local background instance, use the snippets restart helper:
|
||||
|
||||
@@ -58,7 +58,7 @@ Do not restart while a meeting is recording, transcribing, finalizing speaker re
|
||||
|
||||
## Runtime Behavior
|
||||
|
||||
Recording can be controlled through global hotkeys, the Windows tray icon, or local HTTP endpoints. The default hotkeys are:
|
||||
Recording can be controlled through global hotkeys, the Windows tray icon, the macOS menu-bar icon, or local HTTP endpoints. The default hotkeys are:
|
||||
|
||||
- `Ctrl+Alt+M`: toggle the default recording profile.
|
||||
- `Ctrl+Alt+L`: toggle or switch to the configured `english` launch profile.
|
||||
@@ -142,9 +142,9 @@ See `docs/meeting-assistant-configuration.md` for the full configuration referen
|
||||
|
||||
Meeting-specific automation lives in a local YAML file, not in committed personal rules. Rules can trigger on meeting creation, assistant-context state transitions, identified speakers, transcript-line writes, and added attendees. They can add/remove attendees, set supported properties, add context, add projects, rewrite a transcript line, and transform an attendee name before it is stored.
|
||||
|
||||
The tray menu exposes `Open agent`, which opens the `Meeting Summary Agent` window. It can edit workflow rules with validation, inspect logs and health/status, manage speaker identities and samples, run ASR diagnostics, and read/write scoped meeting/project artifacts through explicit tools.
|
||||
The Windows tray and macOS menu-bar menus expose `Open agent`, which opens the `Meeting Summary Agent` window. It can edit workflow rules with validation, inspect logs and health/status, manage speaker identities and samples, run ASR diagnostics, and read/write scoped meeting/project artifacts through explicit tools. Windows renders the chat with WPF; macOS renders the same agent pipeline in a native AppKit window backed by WebKit.
|
||||
|
||||
At startup, Meeting Assistant detects whether the host is Windows or macOS and includes that immutable runtime context in the interactive settings/logs assistant instructions. On Windows the assistant uses Windows commands and concepts such as PowerShell, Windows paths, services, and Task Manager. On macOS it uses zsh, POSIX paths, launchd/LaunchAgents, and Activity Monitor. This guidance is also appended when a custom interactive-agent prompt is configured. macOS audio capture is available in the portable build; Windows-only tray, hotkey, Outlook, notification, and screenshot features remain unavailable there.
|
||||
At startup, Meeting Assistant detects whether the host is Windows or macOS and includes that immutable runtime context in the interactive settings/logs assistant instructions. On Windows the assistant uses Windows commands and concepts such as PowerShell, Windows paths, services, and Task Manager. On macOS it uses zsh, POSIX paths, launchd/LaunchAgents, and Activity Monitor. This guidance is also appended when a custom interactive-agent prompt is configured. macOS audio capture, menu-bar controls, and global hotkeys are available in the portable build; Outlook, Windows notifications, and active-window screenshot capture remain Windows-only.
|
||||
|
||||
Detailed workflow syntax and extension guidance live in `docs/meeting-workflow-engine.md`.
|
||||
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
## Context
|
||||
|
||||
The application already exposes local HTTP endpoints for recording lifecycle actions. Windows desktop integrations invoke the coordinator in-process, while macOS currently has no desktop process capable of participating in AppKit or Carbon event loops.
|
||||
|
||||
## Decisions
|
||||
|
||||
### Native helper owns macOS desktop APIs
|
||||
|
||||
A bundled Swift helper owns the AppKit status item and Carbon global-hotkey registrations. The .NET host starts the helper only on macOS and terminates it during shutdown.
|
||||
|
||||
### Existing HTTP endpoints remain the control seam
|
||||
|
||||
The helper invokes loopback endpoints for recording and diagnostic actions. It does not duplicate recording state or lifecycle policy. It polls the status endpoint to keep the menu-bar presentation current.
|
||||
|
||||
### Configuration is passed as an explicit manifest
|
||||
|
||||
The .NET host derives a manifest from configured launch profiles. Shortcut strings are passed unchanged so macOS uses the same bindings displayed and registered on Windows.
|
||||
|
||||
## Verification
|
||||
|
||||
- Behavior tests prove the manifest preserves configured shortcuts and maps actions to the existing endpoints.
|
||||
- The Swift helper supports a describe/self-test mode for deterministic packaging validation.
|
||||
- Runtime verification launches the application, starts a new recording, and captures the visible macOS menu-bar recording state.
|
||||
@@ -0,0 +1,98 @@
|
||||
# Implementation Evidence
|
||||
|
||||
## Automated
|
||||
|
||||
- `dotnet test MeetingAssistant.Tests/MeetingAssistant.Tests.csproj --filter FullyQualifiedName~MacOsDesktopControlManifestTests --no-restore`
|
||||
- Passed: 1, Failed: 0.
|
||||
- `dotnet build MeetingAssistant/MeetingAssistant.csproj -f net10.0 --no-restore`
|
||||
- Passed and produced `Native/macos-desktop-controls` as an arm64 Mach-O executable.
|
||||
- `openspec validate add-macos-desktop-controls --strict`
|
||||
- Passed.
|
||||
- Full portable test project:
|
||||
- Passed: 427.
|
||||
- Failed: 9 existing macOS/environment-dependent tests: Windows path expectations, unavailable GDI+, and user-environment key resolution.
|
||||
- Whole solution:
|
||||
- Cannot build the Windows target on macOS without enabling cross-Windows targeting (`NETSDK1100`).
|
||||
|
||||
## Runtime
|
||||
|
||||
- Installed the rebuilt application assembly and both native helpers into the existing local LaunchAgent application directory.
|
||||
- LaunchAgent restarted successfully.
|
||||
- `/health` returned `ok`.
|
||||
- `macos-desktop-controls` ran as a child companion process.
|
||||
- The helper manifest contained six configured hotkeys, including the unchanged defaults `Ctrl+Alt+M`, `Ctrl+Alt+L`, `Ctrl+Alt+Z`, and `Ctrl+Alt+S`.
|
||||
- A fresh default-profile recording request created the `20260724-2015` meeting note and linked artifacts.
|
||||
- Screenshot evidence: `tmp/macos-desktop-recording-verification.png`.
|
||||
|
||||
## Live desktop-control proof (2026-07-24)
|
||||
|
||||
- The installed LaunchAgent ran:
|
||||
- `.NET host` PID `23023`
|
||||
- native `macos-desktop-controls` PID `23097`
|
||||
- The native helper manifest exposed:
|
||||
- `Ctrl+Alt+M` -> `/profiles/default/recording/toggle`
|
||||
- `Ctrl+Alt+L` -> `/profiles/english/recording/toggle`
|
||||
- `Ctrl+Alt+Z` -> `/profiles/default/recording/abort`
|
||||
- `Ctrl+Alt+S` -> `/profiles/default/meetings/screenshot/capture`
|
||||
- Accessibility inspection of the real AppKit menu returned:
|
||||
- `Meeting Assistant — idle`
|
||||
- `Open agent`
|
||||
- `Start meeting recording (default)`
|
||||
- `Start meeting recording (english)`
|
||||
- `Exit`
|
||||
- Visible menu screenshot:
|
||||
- `tmp/proof-macos-menu-idle.png`
|
||||
- The menu visibly displays the macOS equivalents `⌃⌥M` and `⌃⌥L`.
|
||||
- Clicking `Start meeting recording (default)` through the real menu produced the sampled status transition `false,true,false`, with `launchProfile: default`, and created:
|
||||
- `Meetings/Notes/20260724-2022-note.md`
|
||||
- `Meetings/Transcripts/20260724-2022-transcript.md`
|
||||
- `Meetings/Assistant Context/20260724-2022-context.md`
|
||||
- Posting the HID-level `Ctrl+Alt+M` combination produced the sampled status transition `false,true,false`, with `launchProfile: default`.
|
||||
- Posting the HID-level `Ctrl+Alt+L` combination produced an active sampled state with `launchProfile: english`.
|
||||
- Raw sampled status evidence:
|
||||
- `tmp/proof-menu-recording-status.jsonl`
|
||||
- `tmp/proof-hotkey-recording-status.jsonl`
|
||||
- `tmp/proof-english-hotkey-status.jsonl`
|
||||
|
||||
## Runtime limitation
|
||||
|
||||
The new recording did not remain active because the local audio capture process stopped shortly after artifact creation. The desktop-control helper, menu-bar item, endpoint routing, and meeting creation were verified; sustained audio capture was not.
|
||||
|
||||
## Open agent regression and runtime proof
|
||||
|
||||
- Original deterministic regression:
|
||||
- `MacOsApplicationRegistersARealInteractiveAgentWindow`
|
||||
- Red result: portable registration resolved exactly `NoopWorkflowRulesEditorWindowService`.
|
||||
- Fix:
|
||||
- macOS now registers `MacOsWorkflowRulesEditorWindowService`.
|
||||
- `Open agent` in the native helper creates an AppKit `NSWindow` titled `Meeting Summary Agent`.
|
||||
- The window hosts a WebKit chat page backed by the existing `WorkflowRulesEditorChatViewModel` and agent pipeline.
|
||||
- Green regression:
|
||||
- Passed: 1, Failed: 0.
|
||||
- Live installed runtime:
|
||||
- Clicking the actual menu item created an on-screen CoreGraphics window:
|
||||
- owner: `macos-desktop-controls`
|
||||
- title: `Meeting Summary Agent`
|
||||
- layer: `0`
|
||||
- bounds: `756x714`
|
||||
- `GET /diagnostics/settings-and-logs` returned the interactive agent HTML page.
|
||||
|
||||
## Open agent prompt execution proof (2026-07-25)
|
||||
|
||||
- Original live failure:
|
||||
- `Meeting Summary Agent failed: No workflow rules editor API key configured.`
|
||||
- Root cause:
|
||||
- The LaunchAgent did not inherit the interactive shell's model API key.
|
||||
- Its configured `127.0.0.1:4021` model endpoint had no running service.
|
||||
- Runtime configuration:
|
||||
- The protected LaunchAgent environment file now supplies the local LiteLLM gateway key.
|
||||
- `MeetingAssistant__WorkflowRulesEditor__Endpoint` targets the running local gateway on port `4000`.
|
||||
- The local gateway exposes the Responses-compatible `chatgpt-gpt-5.5` model alias.
|
||||
- Event-stream compatibility regression:
|
||||
- `ClientReconstructsResponseFromServerSentEvents` failed before the fix with `JsonReaderException`.
|
||||
- The client now reconstructs completed response items from LiteLLM server-sent events.
|
||||
- All 11 `LiteLlmResponsesChatClientTests` pass.
|
||||
- Live installed proof:
|
||||
- `POST /diagnostics/settings-and-logs/chat` with `Reply with exactly: key-ok`
|
||||
returned `{"role":"agent","content":"key-ok"}`.
|
||||
- `/health` returned successfully after deployment.
|
||||
@@ -0,0 +1,24 @@
|
||||
## Why
|
||||
|
||||
Meeting Assistant can record audio on macOS, but the portable build does not register the configured global shortcuts or provide the menu-bar controls used for the normal Windows desktop workflow. macOS users must call HTTP endpoints manually.
|
||||
|
||||
## What Changes
|
||||
|
||||
- Add a native macOS menu-bar companion that starts and stops with Meeting Assistant.
|
||||
- Register the same configured recording, abort, and screenshot shortcuts used on Windows.
|
||||
- Expose recording/profile controls, `Open agent`, and exit from the macOS menu bar.
|
||||
- Keep recording lifecycle behavior behind the existing local HTTP/service surface.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- `meeting-recording`: configured global recording controls work on macOS.
|
||||
- `meeting-session`: the desktop control menu is available from the macOS menu bar.
|
||||
|
||||
## Impact
|
||||
|
||||
- `MeetingAssistant/Native/MacOsDesktopControls`
|
||||
- portable macOS build/publish output
|
||||
- startup service registration
|
||||
- desktop-control behavior tests and macOS runtime documentation
|
||||
@@ -0,0 +1,20 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Recording mode is controlled by a configurable hotkey
|
||||
Meeting Assistant SHALL use normal .NET configuration to define global hotkeys that work on Windows and macOS and toggle recording/transcription mode.
|
||||
|
||||
Meeting Assistant SHALL preserve the configured shortcut strings across Windows and macOS, including the defaults `Ctrl+Alt+M`, `Ctrl+Alt+L`, `Ctrl+Alt+Z`, and `Ctrl+Alt+S`.
|
||||
|
||||
Meeting Assistant SHALL expose a configurable abort/discard hotkey for an active recording on Windows and macOS.
|
||||
|
||||
#### Scenario: macOS uses the configured Windows-equivalent shortcuts
|
||||
- **GIVEN** the default and English profiles use `Ctrl+Alt+M` and `Ctrl+Alt+L`
|
||||
- **AND** abort and screenshot use `Ctrl+Alt+Z` and `Ctrl+Alt+S`
|
||||
- **WHEN** Meeting Assistant starts on macOS
|
||||
- **THEN** it registers those same global shortcut combinations
|
||||
- **AND** routes them to toggle, abort, and screenshot actions through the local service surface
|
||||
|
||||
#### Scenario: macOS recording hotkey starts recording
|
||||
- **GIVEN** Meeting Assistant is idle on macOS
|
||||
- **WHEN** the user presses the configured recording hotkey
|
||||
- **THEN** Meeting Assistant starts recording/transcription mode
|
||||
@@ -0,0 +1,28 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Workflow rules and speaker identities can be edited through a tray-launched assistant
|
||||
Meeting Assistant SHALL expose desktop controls from the Windows tray icon and the macOS menu bar.
|
||||
|
||||
The macOS menu-bar control SHALL start with Meeting Assistant, show current recording state, expose each configured launch profile with its configured hotkey, expose stop and abort controls while recording, expose `Open agent`, and expose exit.
|
||||
|
||||
Selecting `Open agent` on macOS SHALL open a native window titled `Meeting Summary Agent` containing the interactive settings-and-logs chat surface.
|
||||
|
||||
#### Scenario: macOS menu bar shows idle recording controls
|
||||
- **GIVEN** Meeting Assistant is idle on macOS
|
||||
- **WHEN** the user opens the menu-bar control
|
||||
- **THEN** it shows `Open agent`
|
||||
- **AND** shows a start action for every configured launch profile
|
||||
- **AND** displays each profile's configured toggle hotkey
|
||||
- **AND** shows an exit action
|
||||
|
||||
#### Scenario: macOS menu bar shows active recording controls
|
||||
- **GIVEN** a meeting is recording on macOS
|
||||
- **WHEN** the user opens the menu-bar control
|
||||
- **THEN** it shows the active recording state
|
||||
- **AND** exposes stop and abort actions
|
||||
|
||||
#### Scenario: macOS menu opens the interactive agent
|
||||
- **GIVEN** Meeting Assistant is running on macOS
|
||||
- **WHEN** the user selects `Open agent` from the menu-bar control
|
||||
- **THEN** a `Meeting Summary Agent` window opens
|
||||
- **AND** the user can submit a chat message and receive the interactive agent response
|
||||
@@ -0,0 +1,18 @@
|
||||
## 1. Contract and behavior
|
||||
|
||||
- [x] 1.1 Define macOS desktop-control requirements and design.
|
||||
- [x] 1.2 Add a failing behavior test for configured shortcut and endpoint preservation.
|
||||
- [x] 1.3 Implement the desktop-control manifest and make the behavior test pass.
|
||||
|
||||
## 2. Native macOS integration
|
||||
|
||||
- [x] 2.1 Add and package the native macOS menu-bar/global-hotkey helper.
|
||||
- [x] 2.2 Start and stop the helper with the .NET application on macOS.
|
||||
- [x] 2.3 Add deterministic helper packaging/self-test coverage.
|
||||
|
||||
## 3. Verification and closeout
|
||||
|
||||
- [x] 3.1 Run narrow and full tests.
|
||||
- [x] 3.2 Run strict OpenSpec validation.
|
||||
- [x] 3.3 Launch Meeting Assistant, start a new recording, and save screenshot evidence.
|
||||
- [x] 3.4 Perform required DRY, SOLID, and KISS refactoring reviews and re-run verification.
|
||||
@@ -375,3 +375,43 @@ After repairing a meeting or summary, the instructions SHALL direct the agent to
|
||||
- **WHEN** the user asks the interactive agent to fix that meeting or its summary
|
||||
- **THEN** the agent instructions direct it to inspect the matching assistant context for relevant meeting-specific memory
|
||||
- **AND** direct it to append its fixes and conclusions to that assistant context
|
||||
|
||||
### Requirement: Interactive agent uses detected host operating-system context
|
||||
Meeting Assistant SHALL detect the host operating system when the application starts and SHALL make that immutable runtime context available to the interactive settings/logs assistant.
|
||||
|
||||
When the detected host is Windows, the interactive assistant instructions SHALL identify Windows as the runtime platform and SHALL direct the assistant to use Windows-specific commands, paths, services, environment conventions, and desktop concepts instead of macOS concepts.
|
||||
|
||||
When the detected host is macOS, the interactive assistant instructions SHALL identify macOS as the runtime platform and SHALL direct the assistant to use macOS-specific commands, POSIX paths, launchd services, environment conventions, and desktop concepts instead of Windows concepts.
|
||||
|
||||
Platform-aware instructions SHALL remain present when the interactive assistant uses a configured custom initial prompt.
|
||||
|
||||
The instructions SHALL distinguish platform-aware operational guidance from feature availability and SHALL NOT imply that detecting macOS provides Windows-only or otherwise unavailable integrations.
|
||||
|
||||
For other detected hosts, Meeting Assistant SHALL identify the platform as unsupported for platform-specific guidance and SHALL direct the assistant to prefer portable tools and concepts rather than assuming Windows or macOS.
|
||||
|
||||
#### Scenario: Windows host receives Windows operational guidance
|
||||
- **GIVEN** Meeting Assistant detected Windows during startup
|
||||
- **WHEN** it builds the interactive settings/logs assistant instructions
|
||||
- **THEN** the instructions identify Windows as the runtime platform
|
||||
- **AND** direct the assistant to use Windows commands and concepts
|
||||
- **AND** direct the assistant not to substitute macOS commands or concepts
|
||||
|
||||
#### Scenario: macOS host receives macOS operational guidance
|
||||
- **GIVEN** Meeting Assistant detected macOS during startup
|
||||
- **WHEN** it builds the interactive settings/logs assistant instructions
|
||||
- **THEN** the instructions identify macOS as the runtime platform
|
||||
- **AND** direct the assistant to use macOS commands and concepts
|
||||
- **AND** direct the assistant not to substitute Windows commands or concepts
|
||||
|
||||
#### Scenario: Custom prompt retains detected platform context
|
||||
- **GIVEN** Meeting Assistant detected macOS during startup
|
||||
- **AND** a custom interactive-assistant initial prompt is configured
|
||||
- **WHEN** it builds the interactive assistant instructions
|
||||
- **THEN** the instructions contain both the custom prompt and macOS runtime guidance
|
||||
|
||||
#### Scenario: Unsupported host uses neutral guidance
|
||||
- **GIVEN** Meeting Assistant runs on a host other than Windows or macOS
|
||||
- **WHEN** it builds the interactive settings/logs assistant instructions
|
||||
- **THEN** the instructions do not label the host as Windows or macOS
|
||||
- **AND** direct the assistant to prefer portable tools and concepts
|
||||
|
||||
|
||||
Reference in New Issue
Block a user