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

- Introduced MacOsDesktopControlManifest to manage desktop control configurations.
- Implemented MacOsDesktopControlService to handle the lifecycle of the macOS helper process.
- Created MacOsWorkflowRulesEditorWindowService for managing workflow rules through a native window.
- Developed a Swift helper for macOS that integrates with AppKit and Carbon for menu-bar controls and global hotkeys.
- Added support for recording lifecycle actions via HTTP endpoints, maintaining consistency with existing Windows functionality.
- Enhanced user experience by allowing macOS users to start/stop recordings and access settings through a menu-bar interface.
- Documented design decisions, implementation evidence, and verification tasks for the new macOS features.
This commit is contained in:
dh
2026-08-06 08:18:52 +02:00
parent 5f39923c27
commit 9542c05ea4
20 changed files with 981 additions and 11 deletions
@@ -0,0 +1,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) }
}