forked from Manuel/meeting-assistant
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,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>
|
||||||
|
|
||||||
<PropertyGroup Condition="$([MSBuild]::IsOSPlatform('OSX')) and $([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) != 'windows'">
|
<PropertyGroup Condition="$([MSBuild]::IsOSPlatform('OSX')) and $([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) != 'windows'">
|
||||||
<MacOsAudioCaptureEnabled>true</MacOsAudioCaptureEnabled>
|
<MacOsNativeHelpersEnabled>true</MacOsNativeHelpersEnabled>
|
||||||
<MacOsAudioCaptureSource>$(MSBuildProjectDirectory)/Native/MacOsMeetingAudioCapture/main.swift</MacOsAudioCaptureSource>
|
<MacOsAudioCaptureSource>$(MSBuildProjectDirectory)/Native/MacOsMeetingAudioCapture/main.swift</MacOsAudioCaptureSource>
|
||||||
<MacOsAudioCaptureOutputPath>Native/macos-meeting-audio-capture</MacOsAudioCaptureOutputPath>
|
<MacOsAudioCaptureOutputPath>Native/macos-meeting-audio-capture</MacOsAudioCaptureOutputPath>
|
||||||
<MacOsAudioCaptureRid Condition="'$(RuntimeIdentifier)' != ''">$(RuntimeIdentifier)</MacOsAudioCaptureRid>
|
<MacOsAudioCaptureRid Condition="'$(RuntimeIdentifier)' != ''">$(RuntimeIdentifier)</MacOsAudioCaptureRid>
|
||||||
<MacOsAudioCaptureRid Condition="'$(MacOsAudioCaptureRid)' == ''">$(NETCoreSdkRuntimeIdentifier)</MacOsAudioCaptureRid>
|
<MacOsAudioCaptureRid Condition="'$(MacOsAudioCaptureRid)' == ''">$(NETCoreSdkRuntimeIdentifier)</MacOsAudioCaptureRid>
|
||||||
<MacOsAudioCaptureArchitecture Condition="'$(MacOsAudioCaptureRid)' == 'osx-x64'">x86_64</MacOsAudioCaptureArchitecture>
|
<MacOsAudioCaptureArchitecture Condition="'$(MacOsAudioCaptureRid)' == 'osx-x64'">x86_64</MacOsAudioCaptureArchitecture>
|
||||||
<MacOsAudioCaptureArchitecture Condition="'$(MacOsAudioCaptureArchitecture)' == ''">arm64</MacOsAudioCaptureArchitecture>
|
<MacOsAudioCaptureArchitecture Condition="'$(MacOsAudioCaptureArchitecture)' == ''">arm64</MacOsAudioCaptureArchitecture>
|
||||||
|
<MacOsDesktopControlsSource>$(MSBuildProjectDirectory)/Native/MacOsDesktopControls/main.swift</MacOsDesktopControlsSource>
|
||||||
|
<MacOsDesktopControlsOutputPath>Native/macos-desktop-controls</MacOsDesktopControlsOutputPath>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
@@ -72,19 +74,21 @@
|
|||||||
</Target>
|
</Target>
|
||||||
|
|
||||||
<Target
|
<Target
|
||||||
Name="BuildMacOsAudioCaptureHelper"
|
Name="BuildMacOsNativeHelpers"
|
||||||
AfterTargets="Build"
|
AfterTargets="Build"
|
||||||
Condition="'$(MacOsAudioCaptureEnabled)' == 'true'">
|
Condition="'$(MacOsNativeHelpersEnabled)' == 'true'">
|
||||||
<MakeDir Directories="$(TargetDir)Native" />
|
<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 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>
|
||||||
|
|
||||||
<Target
|
<Target
|
||||||
Name="CopyMacOsAudioCaptureHelperToPublish"
|
Name="CopyMacOsNativeHelpersToPublish"
|
||||||
AfterTargets="Publish"
|
AfterTargets="Publish"
|
||||||
Condition="'$(MacOsAudioCaptureEnabled)' == 'true'">
|
Condition="'$(MacOsNativeHelpersEnabled)' == 'true'">
|
||||||
<MakeDir Directories="$(PublishDir)Native" />
|
<MakeDir Directories="$(PublishDir)Native" />
|
||||||
<Copy SourceFiles="$(TargetDir)$(MacOsAudioCaptureOutputPath)" DestinationFolder="$(PublishDir)Native" />
|
<Copy SourceFiles="$(TargetDir)$(MacOsAudioCaptureOutputPath)" DestinationFolder="$(PublishDir)Native" />
|
||||||
|
<Copy SourceFiles="$(TargetDir)$(MacOsDesktopControlsOutputPath)" DestinationFolder="$(PublishDir)Native" />
|
||||||
</Target>
|
</Target>
|
||||||
|
|
||||||
<ItemGroup Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) != 'windows'">
|
<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.Hotkeys;
|
||||||
using MeetingAssistant.LaunchProfiles;
|
using MeetingAssistant.LaunchProfiles;
|
||||||
using MeetingAssistant.Logging;
|
using MeetingAssistant.Logging;
|
||||||
|
using MeetingAssistant.MacOs;
|
||||||
using MeetingAssistant.MeetingNotes;
|
using MeetingAssistant.MeetingNotes;
|
||||||
using MeetingAssistant.Recording;
|
using MeetingAssistant.Recording;
|
||||||
using MeetingAssistant.Screenshots;
|
using MeetingAssistant.Screenshots;
|
||||||
@@ -38,6 +39,7 @@ builder.Services.AddSingleton<IMeetingMetadataProvider, OutlookClassicMeetingMet
|
|||||||
#else
|
#else
|
||||||
PortableMeetingAudioRegistration.Add(builder.Services);
|
PortableMeetingAudioRegistration.Add(builder.Services);
|
||||||
builder.Services.AddSingleton<IMeetingMetadataProvider, NoopMeetingMetadataProvider>();
|
builder.Services.AddSingleton<IMeetingMetadataProvider, NoopMeetingMetadataProvider>();
|
||||||
|
builder.Services.AddHostedService<MacOsDesktopControlService>();
|
||||||
#endif
|
#endif
|
||||||
builder.Services.AddSingleton<ITranscriptStore, VaultTranscriptStore>();
|
builder.Services.AddSingleton<ITranscriptStore, VaultTranscriptStore>();
|
||||||
builder.Services.AddSingleton<IMeetingNoteStore, MarkdownMeetingNoteStore>();
|
builder.Services.AddSingleton<IMeetingNoteStore, MarkdownMeetingNoteStore>();
|
||||||
@@ -111,7 +113,8 @@ builder.Services.AddSingleton(services => WorkflowRulesEditorMarkdownLinkResolve
|
|||||||
services.GetRequiredService<IOptions<MeetingAssistantOptions>>().Value));
|
services.GetRequiredService<IOptions<MeetingAssistantOptions>>().Value));
|
||||||
builder.Services.AddSingleton<IWorkflowRulesEditorWindowService, WpfWorkflowRulesEditorWindowService>();
|
builder.Services.AddSingleton<IWorkflowRulesEditorWindowService, WpfWorkflowRulesEditorWindowService>();
|
||||||
#else
|
#else
|
||||||
builder.Services.AddSingleton<IWorkflowRulesEditorWindowService, NoopWorkflowRulesEditorWindowService>();
|
builder.Services.AddSingleton<MacOsWorkflowRulesEditorSession>();
|
||||||
|
builder.Services.AddSingleton<IWorkflowRulesEditorWindowService, MacOsWorkflowRulesEditorWindowService>();
|
||||||
#endif
|
#endif
|
||||||
builder.Services.AddSingleton<AsrDiagnosticService>();
|
builder.Services.AddSingleton<AsrDiagnosticService>();
|
||||||
builder.Services.AddSingleton<ICommandRunner, ProcessCommandRunner>();
|
builder.Services.AddSingleton<ICommandRunner, ProcessCommandRunner>();
|
||||||
@@ -152,6 +155,11 @@ app.MapGet("/recording/status", (MeetingRecordingCoordinator coordinator) => Res
|
|||||||
app.MapGet("/profiles/{launchProfile}/recording/status", (
|
app.MapGet("/profiles/{launchProfile}/recording/status", (
|
||||||
string launchProfile,
|
string launchProfile,
|
||||||
MeetingRecordingCoordinator coordinator) => Results.Ok(coordinator.CurrentStatus));
|
MeetingRecordingCoordinator coordinator) => Results.Ok(coordinator.CurrentStatus));
|
||||||
|
app.MapPost("/application/exit", (IHostApplicationLifetime lifetime) =>
|
||||||
|
{
|
||||||
|
lifetime.StopApplication();
|
||||||
|
return Results.Accepted();
|
||||||
|
});
|
||||||
app.MapGet("/diagnostics/outlook/current-meeting", async (
|
app.MapGet("/diagnostics/outlook/current-meeting", async (
|
||||||
IMeetingMetadataProvider metadataProvider,
|
IMeetingMetadataProvider metadataProvider,
|
||||||
IOptions<MeetingAssistantOptions> options,
|
IOptions<MeetingAssistantOptions> options,
|
||||||
@@ -281,6 +289,15 @@ app.MapPost("/diagnostics/settings-and-logs/show", (
|
|||||||
rulesEditorWindow.Show();
|
rulesEditorWindow.Show();
|
||||||
return Results.Accepted();
|
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)
|
static WorkflowConfigurationReloadResponse ReloadWorkflowConfiguration(IConfiguration configuration)
|
||||||
{
|
{
|
||||||
@@ -323,6 +340,11 @@ app.MapPost("/profiles/{launchProfile}/recording/abort", async (
|
|||||||
MeetingRecordingCoordinator coordinator,
|
MeetingRecordingCoordinator coordinator,
|
||||||
CancellationToken cancellationToken) =>
|
CancellationToken cancellationToken) =>
|
||||||
Results.Ok(await coordinator.AbortAsync(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 (
|
app.MapPost("/meetings/current/summary/run", async (
|
||||||
MeetingRecordingCoordinator coordinator,
|
MeetingRecordingCoordinator coordinator,
|
||||||
IMeetingSummaryPipeline summaryPipeline,
|
IMeetingSummaryPipeline summaryPipeline,
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ This repository owns the application source, tests, OpenSpec requirements, confi
|
|||||||
The app is intentionally local-first:
|
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.
|
- 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.
|
- 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.
|
- 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`.
|
- 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
|
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:
|
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
|
## 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+M`: toggle the default recording profile.
|
||||||
- `Ctrl+Alt+L`: toggle or switch to the configured `english` launch 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.
|
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`.
|
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
|
- **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
|
- **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
|
- **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