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 \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.size, nil, &identifier) let owner = Unmanaged.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) } }