forked from Manuel/meeting-assistant
517 lines
21 KiB
Swift
517 lines
21 KiB
Swift
import AppKit
|
|
import Carbon
|
|
import Foundation
|
|
import WebKit
|
|
import UserNotifications
|
|
|
|
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
|
|
}
|
|
|
|
enum RecordingState: Int, Decodable {
|
|
case idle = 0
|
|
case recording = 1
|
|
case summarizing = 2
|
|
}
|
|
|
|
struct RecordingStatus: Decodable {
|
|
let isRecording: Bool
|
|
let state: RecordingState
|
|
let launchProfile: String?
|
|
}
|
|
|
|
@main
|
|
@MainActor
|
|
final class DesktopControls: NSObject, NSApplicationDelegate, NSMenuDelegate, UNUserNotificationCenterDelegate {
|
|
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?
|
|
private var independentAgentWindows: [String: NSWindow] = [:]
|
|
private var agentWindowState = DesktopAgentWindowState()
|
|
private var requests = DesktopUserRequestState()
|
|
private var fetchingRequests = false
|
|
private var fetchingWindows = false
|
|
private var authorizationInFlight = false
|
|
private var authorizationChecked = false
|
|
private var notificationsAuthorized = false
|
|
private lazy var notificationCenter = UNUserNotificationCenter.current()
|
|
|
|
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() {
|
|
if CommandLine.arguments.count == 2 && CommandLine.arguments[1] == "--self-test" {
|
|
runDesktopControlSelfTests()
|
|
return
|
|
}
|
|
// Notification activation after shutdown has no trusted service manifest.
|
|
if CommandLine.arguments.count == 1 {
|
|
fputs("Ignoring desktop notification activation without an active service manifest.\n", stderr)
|
|
return
|
|
}
|
|
guard Bundle.main.bundleIdentifier == "cloud.schweigert.meeting-assistant.desktop-controls" else {
|
|
fputs("Desktop controls must run from the signed MeetingAssistantDesktopControls.app bundle.\n", stderr)
|
|
exit(2)
|
|
}
|
|
let application = NSApplication.shared
|
|
let delegate = DesktopControls()
|
|
application.delegate = delegate
|
|
application.setActivationPolicy(.accessory)
|
|
application.run()
|
|
}
|
|
|
|
func applicationWillFinishLaunching(_ notification: Notification) {
|
|
notificationCenter.delegate = self
|
|
notificationCenter.removeAllPendingNotificationRequests()
|
|
notificationCenter.removeAllDeliveredNotifications()
|
|
}
|
|
|
|
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()
|
|
refresh()
|
|
timer = Timer.scheduledTimer(withTimeInterval: 2, repeats: true) { [weak self] _ in
|
|
DispatchQueue.main.async { self?.refresh() }
|
|
}
|
|
}
|
|
|
|
func applicationWillTerminate(_ notification: Notification) {
|
|
timer?.invalidate()
|
|
notificationCenter.removeAllPendingNotificationRequests()
|
|
notificationCenter.removeAllDeliveredNotifications()
|
|
}
|
|
|
|
func menuWillOpen(_ menu: NSMenu) {
|
|
removeNotifications(requests.reconcile(requests.pending, now: Date()))
|
|
rebuildMenu(menu)
|
|
refresh()
|
|
}
|
|
|
|
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())
|
|
let pending = NSMenuItem(title: "Pending requests (\(requests.pending.count))", action: nil, keyEquivalent: "")
|
|
let pendingMenu = NSMenu()
|
|
if requests.pending.isEmpty {
|
|
let empty = NSMenuItem(title: "No pending requests", action: nil, keyEquivalent: "")
|
|
empty.isEnabled = false
|
|
pendingMenu.addItem(empty)
|
|
}
|
|
for request in requests.pending {
|
|
let entry = NSMenuItem(title: request.title, action: nil, keyEquivalent: "")
|
|
entry.toolTip = request.message
|
|
let decisions = NSMenu()
|
|
let message = NSMenuItem(title: request.message, action: nil, keyEquivalent: "")
|
|
message.isEnabled = false
|
|
decisions.addItem(message)
|
|
decisions.addItem(.separator())
|
|
for action in request.actions {
|
|
let decision = item(action.label, #selector(answerRequest(_:)))
|
|
decision.representedObject = DesktopRequestDecision(request.id, action.id)
|
|
decision.isEnabled = !requests.isResponding(request.id)
|
|
decisions.addItem(decision)
|
|
}
|
|
entry.submenu = decisions
|
|
pendingMenu.addItem(entry)
|
|
}
|
|
pending.submenu = pendingMenu
|
|
menu.addItem(pending)
|
|
menu.addItem(.separator())
|
|
menu.addItem(item("Exit", #selector(exitApplication)))
|
|
}
|
|
|
|
private var tooltip: String {
|
|
let suffix = requests.pending.isEmpty ? "" : " — \(requests.pending.count) pending request(s)"
|
|
if status.isRecording {
|
|
return (status.launchProfile.map { "Meeting Assistant — recording (\($0))" }
|
|
?? "Meeting Assistant — recording") + suffix
|
|
}
|
|
return (status.state == .summarizing
|
|
? "Meeting Assistant — summarizing"
|
|
: "Meeting Assistant — idle") + suffix
|
|
}
|
|
|
|
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 url = URL(string: manifest.openAgentUrl) {
|
|
agentWindow = makeAgentWindow(url)
|
|
}
|
|
NSApplication.shared.activate(ignoringOtherApps: true)
|
|
agentWindow?.makeKeyAndOrderFront(nil)
|
|
}
|
|
|
|
private func makeAgentWindow(_ url: URL) -> NSWindow {
|
|
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))
|
|
return window
|
|
}
|
|
|
|
@objc private func answerRequest(_ sender: NSMenuItem) {
|
|
guard let decision = sender.representedObject as? DesktopRequestDecision else { return }
|
|
submitResponse(requestId: decision.requestId, action: decision.action)
|
|
}
|
|
|
|
private func submitResponse(requestId: String, action: String) {
|
|
guard let response = requests.beginResponse(baseUrl: manifest.baseUrl, requestId: requestId, action: action, now: Date()) else { return }
|
|
URLSession.shared.dataTask(with: response) { [weak self] _, response, error in
|
|
let accepted = error == nil && (response as? HTTPURLResponse).map { (200..<300).contains($0.statusCode) } == true
|
|
DispatchQueue.main.async {
|
|
guard let self else { return }
|
|
self.requests.completeResponse(requestId, accepted: accepted)
|
|
if accepted { self.removeNotifications([requestId]) }
|
|
if let error { desktopLog("User response failed: \(error.localizedDescription)") }
|
|
self.refresh()
|
|
}
|
|
}.resume()
|
|
}
|
|
|
|
nonisolated func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) {
|
|
completionHandler([.banner, .list, .sound])
|
|
}
|
|
|
|
nonisolated func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) {
|
|
// Closing the announcement is independent of deciding its menu request.
|
|
guard response.actionIdentifier != UNNotificationDismissActionIdentifier,
|
|
response.actionIdentifier != UNNotificationDefaultActionIdentifier,
|
|
let id = response.notification.request.content.userInfo["requestId"] as? String else {
|
|
completionHandler()
|
|
return
|
|
}
|
|
let action = response.actionIdentifier
|
|
DispatchQueue.main.async { [weak self] in self?.submitResponse(requestId: id, action: action) }
|
|
completionHandler()
|
|
}
|
|
|
|
@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 refresh() {
|
|
removeNotifications(requests.reconcile(requests.pending, now: Date()))
|
|
updateStatusItem()
|
|
refreshStatus()
|
|
refreshRequests()
|
|
refreshAgentWindows()
|
|
}
|
|
|
|
private func updateStatusItem() {
|
|
statusItem.button?.image = NSImage(
|
|
systemSymbolName: status.isRecording ? "record.circle.fill" : "waveform.circle",
|
|
accessibilityDescription: tooltip)
|
|
statusItem.button?.toolTip = tooltip
|
|
}
|
|
|
|
private func refreshRequests() {
|
|
guard !fetchingRequests, let url = URL(string: manifest.baseUrl + "/desktop/requests") else { return }
|
|
fetchingRequests = true
|
|
URLSession.shared.dataTask(with: url) { [weak self] data, response, error in
|
|
var snapshot: [DesktopUserRequest]?
|
|
if let data, error == nil, (response as? HTTPURLResponse)?.statusCode == 200 {
|
|
do { snapshot = try decodeDesktopRequests(data) }
|
|
catch { desktopLog("Cannot decode pending desktop requests: \(error)") }
|
|
}
|
|
DispatchQueue.main.async {
|
|
guard let self else { return }
|
|
self.fetchingRequests = false
|
|
guard let snapshot else { return }
|
|
self.removeNotifications(self.requests.reconcile(snapshot, now: Date()))
|
|
self.updateStatusItem()
|
|
self.announcePendingRequests()
|
|
}
|
|
}.resume()
|
|
}
|
|
|
|
private func removeNotifications(_ ids: [String]) {
|
|
guard !ids.isEmpty else { return }
|
|
let identifiers = ids.map { "desktop-request." + $0 }
|
|
notificationCenter.removePendingNotificationRequests(withIdentifiers: identifiers)
|
|
notificationCenter.removeDeliveredNotifications(withIdentifiers: identifiers)
|
|
}
|
|
|
|
private func announcePendingRequests() {
|
|
guard !requests.notificationsToAnnounce().isEmpty else { return }
|
|
if !authorizationChecked {
|
|
guard !authorizationInFlight else { return }
|
|
authorizationInFlight = true
|
|
notificationCenter.getNotificationSettings { [weak self] settings in
|
|
DispatchQueue.main.async {
|
|
guard let self else { return }
|
|
if settings.authorizationStatus == .notDetermined {
|
|
self.notificationCenter.requestAuthorization(options: [.alert, .sound]) { [weak self] granted, error in
|
|
DispatchQueue.main.async {
|
|
if let error { desktopLog("Notification authorization failed: \(error.localizedDescription)") }
|
|
self?.finishNotificationAuthorization(granted)
|
|
}
|
|
}
|
|
} else {
|
|
self.finishNotificationAuthorization(settings.authorizationStatus == .authorized || settings.authorizationStatus == .provisional)
|
|
}
|
|
}
|
|
}
|
|
return
|
|
}
|
|
guard notificationsAuthorized else { return }
|
|
notificationCenter.setNotificationCategories(Set(requests.pending.map { $0.notificationCategory() }))
|
|
for request in requests.notificationsToAnnounce() {
|
|
requests.markAnnounced(request.id)
|
|
notificationCenter.add(request.notificationRequest()) { error in
|
|
if let error { desktopLog("Cannot schedule desktop notification \(request.id): \(error.localizedDescription)") }
|
|
else { desktopLog("Scheduled desktop notification \(request.id)") }
|
|
}
|
|
}
|
|
}
|
|
|
|
private func finishNotificationAuthorization(_ granted: Bool) {
|
|
authorizationChecked = true
|
|
authorizationInFlight = false
|
|
notificationsAuthorized = granted
|
|
desktopLog(granted ? "Desktop notifications authorized." : "Desktop notifications unavailable; pending requests remain in the menu.")
|
|
removeNotifications(requests.reconcile(requests.pending, now: Date()))
|
|
announcePendingRequests()
|
|
}
|
|
|
|
private func refreshAgentWindows() {
|
|
guard !fetchingWindows, let url = URL(string: manifest.baseUrl + "/desktop/agent-windows") else { return }
|
|
fetchingWindows = true
|
|
URLSession.shared.dataTask(with: url) { [weak self] data, response, error in
|
|
let windows = data.flatMap { data in
|
|
error == nil && (response as? HTTPURLResponse)?.statusCode == 200
|
|
? try? JSONDecoder().decode([DesktopAgentWindow].self, from: data) : nil
|
|
}
|
|
DispatchQueue.main.async {
|
|
guard let self else { return }
|
|
self.fetchingWindows = false
|
|
guard let windows else { return }
|
|
for requested in self.agentWindowState.takeUnopened(windows) {
|
|
guard let url = URL(string: requested.url) else { continue }
|
|
let window = self.makeAgentWindow(url)
|
|
self.independentAgentWindows[requested.id] = window
|
|
NSApplication.shared.activate(ignoringOtherApps: true)
|
|
window.makeKeyAndOrderFront(nil)
|
|
desktopLog("Opened independent workflow agent window \(requested.id)")
|
|
}
|
|
}
|
|
}.resume()
|
|
}
|
|
|
|
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?.updateStatusItem()
|
|
}
|
|
}.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?.refresh()
|
|
}
|
|
}.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 final class DesktopRequestDecision: NSObject {
|
|
let requestId: String
|
|
let action: String
|
|
|
|
init(_ requestId: String, _ action: String) {
|
|
self.requestId = requestId
|
|
self.action = action
|
|
}
|
|
}
|
|
|
|
private func desktopLog(_ message: String) {
|
|
fputs("\(message)\n", stderr)
|
|
}
|
|
|
|
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) }
|
|
}
|