ci: consolidate platform pipelines and test prerequisites on macOS support
PR and Push Build/Test / windows-build-and-test (push) Failing after 38m25s
PR and Push Build/Test / portable-build-and-test (push) Successful in 8m9s
PR and Push Build/Test / macos-native-full (push) Skipped

This commit is contained in:
dh committed 2026-10-06 09:02:19 +02:00
1 parent 1b19b08f2e
commit 728e66dd72
76 files changed
+7477 -239

No files matched your search

@@ -0,0 +1,115 @@
import Foundation
import UserNotifications
struct DesktopUserAction: Decodable, Equatable {
let id: String
let label: String
}
struct DesktopUserRequest: Decodable {
let id: String
let kind: String
let title: String
let message: String
let createdAt: Date
let expiresAt: Date
let actions: [DesktopUserAction]
}
struct DesktopAgentWindow: Decodable {
let id: String
let url: String
}
func decodeDesktopRequests(_ data: Data) throws -> [DesktopUserRequest] {
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .custom { decoder in
let value = try decoder.singleValueContainer().decode(String.self)
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
if let date = formatter.date(from: value) { return date }
formatter.formatOptions = [.withInternetDateTime]
if let date = formatter.date(from: value) { return date }
throw DecodingError.dataCorruptedError(in: try decoder.singleValueContainer(), debugDescription: "Invalid request timestamp")
}
return try decoder.decode([DesktopUserRequest].self, from: data)
}
struct DesktopUserRequestState {
private(set) var pending: [DesktopUserRequest] = []
private var announced: Set<String> = []
private var responding: Set<String> = []
private var completed: Set<String> = []
mutating func beginResponse(baseUrl: String, requestId: String, action: String, now: Date) -> URLRequest? {
guard !responding.contains(requestId),
let pendingRequest = pending.first(where: { $0.id == requestId }),
pendingRequest.expiresAt > now,
pendingRequest.actions.contains(where: { $0.id == action }),
let id = requestId.addingPercentEncoding(withAllowedCharacters: .alphanumerics.union(CharacterSet(charactersIn: "-._~"))),
let url = URL(string: baseUrl.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + "/desktop/requests/\(id)/respond"),
let body = try? JSONEncoder().encode(["action": action]) else { return nil }
var response = URLRequest(url: url)
response.httpMethod = "POST"
response.setValue("application/json", forHTTPHeaderField: "Content-Type")
response.httpBody = body
responding.insert(requestId)
return response
}
mutating func completeResponse(_ id: String, accepted: Bool) {
responding.remove(id)
if accepted { completed.insert(id); pending.removeAll { $0.id == id } }
}
func isResponding(_ id: String) -> Bool { responding.contains(id) }
mutating func reconcile(_ requests: [DesktopUserRequest], now: Date) -> [String] {
let previousIds = Set(pending.map(\.id))
pending = requests.filter { $0.expiresAt > now && !completed.contains($0.id) }.sorted {
$0.createdAt == $1.createdAt ? $0.id < $1.id : $0.createdAt < $1.createdAt
}
let currentIds = Set(pending.map(\.id))
return previousIds.subtracting(currentIds).sorted()
}
func notificationsToAnnounce() -> [DesktopUserRequest] {
pending.filter { !announced.contains($0.id) && !responding.contains($0.id) }
}
mutating func markAnnounced(_ id: String) {
if pending.contains(where: { $0.id == id }) { announced.insert(id) }
}
}
extension DesktopUserRequest {
var notificationIdentifier: String { "desktop-request." + id }
func notificationCategory() -> UNNotificationCategory {
UNNotificationCategory(
identifier: notificationIdentifier,
actions: actions.map { UNNotificationAction(identifier: $0.id, title: $0.label, options: []) },
intentIdentifiers: [],
options: [.customDismissAction])
}
func notificationRequest() -> UNNotificationRequest {
let content = UNMutableNotificationContent()
content.title = title
content.body = message
content.categoryIdentifier = notificationIdentifier
content.userInfo = ["requestId": id]
content.sound = .default
return UNNotificationRequest(identifier: notificationIdentifier, content: content, trigger: nil)
}
}
struct DesktopAgentWindowState {
private var opened: Set<String> = []
mutating func takeUnopened(_ windows: [DesktopAgentWindow]) -> [DesktopAgentWindow] {
windows.filter { opened.insert($0.id).inserted }
}
}
@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleExecutable</key>
<string>macos-desktop-controls</string>
<key>CFBundleIdentifier</key>
<string>cloud.schweigert.meeting-assistant.desktop-controls</string>
<key>CFBundleName</key>
<string>Meeting Assistant</string>
<key>CFBundleDisplayName</key>
<string>Meeting Assistant</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>LSMinimumSystemVersion</key>
<string>13.0</string>
<key>LSUIElement</key>
<true/>
<key>NSPrincipalClass</key>
<string>NSApplication</string>
</dict>
</plist>
@@ -0,0 +1,67 @@
import Foundation
private enum SelfTestFailure: Error { case failed(String) }
private func expect(_ condition: @autoclosure () -> Bool, _ message: String) throws {
if !condition() { throw SelfTestFailure.failed(message) }
}
func runDesktopControlSelfTests() {
do {
let status = try JSONDecoder().decode(RecordingStatus.self, from: Data(#"{"isRecording":false,"state":2,"launchProfile":null}"#.utf8))
try expect(String(describing: status.state).lowercased() == "summarizing", "numeric status 2 must show summarizing")
print("PASS numeric recording status")
let requests = try decodeDesktopRequests(Data(requestFixture.utf8))
try expect(requests.count == 1 && requests[0].id == "approval-1", "pending request must decode")
try expect(requests[0].actions == [DesktopUserAction(id: "yes", label: "Yes"), DesktopUserAction(id: "no", label: "No")], "offered actions must be preserved")
try expect(requests[0].expiresAt.timeIntervalSince1970 == 1_791_022_260, "offset and fractional timestamps must decode")
print("PASS pending request wire payload")
var state = DesktopUserRequestState()
let now = Date(timeIntervalSince1970: 1_791_022_220)
_ = state.reconcile(requests, now: now)
try expect(state.notificationsToAnnounce().map(\.id) == ["approval-1"], "new pending request must be announced")
state.markAnnounced("approval-1")
_ = state.reconcile(requests, now: now)
try expect(state.notificationsToAnnounce().isEmpty && state.pending.count == 1, "repeated polling must not redeliver or hide menu requests")
try expect(state.reconcile([], now: now) == ["approval-1"] && state.pending.isEmpty, "withdrawn request must remove native presentation")
print("PASS notification reconciliation and menu fallback")
_ = state.reconcile(requests, now: now)
try expect(state.beginResponse(baseUrl: "http://127.0.0.1:5090", requestId: "approval-1", action: "not-offered", now: now) == nil, "invalid action must keep request pending")
guard let response = state.beginResponse(baseUrl: "http://127.0.0.1:5090/", requestId: "approval-1", action: "yes", now: now) else { throw SelfTestFailure.failed("offered action must produce a response") }
try expect(response.url?.absoluteString == "http://127.0.0.1:5090/desktop/requests/approval-1/respond" && response.httpMethod == "POST", "notification and menu response endpoint")
let body = try JSONDecoder().decode([String: String].self, from: response.httpBody ?? Data())
try expect(body == ["action": "yes"], "chosen action must use shared JSON contract")
try expect(state.beginResponse(baseUrl: "http://127.0.0.1:5090", requestId: "approval-1", action: "yes", now: now) == nil, "same pending action must not submit twice")
state.completeResponse("approval-1", accepted: false)
try expect(!state.isResponding("approval-1") && state.pending.count == 1, "failed transport must leave menu fallback actionable")
try expect(state.beginResponse(baseUrl: "http://127.0.0.1:5090", requestId: "approval-1", action: "yes", now: requests[0].expiresAt) == nil, "expired response must not submit")
try expect(state.reconcile(requests, now: requests[0].expiresAt) == ["approval-1"] && state.pending.isEmpty, "expired request must be removed locally")
try expect(state.beginResponse(baseUrl: "http://127.0.0.1:5090", requestId: "approval-1", action: "yes", now: now) == nil, "stale notification must not reconstruct request")
print("PASS allowed once-only response and stale action rejection")
_ = state.reconcile(requests, now: now)
state.completeResponse("approval-1", accepted: true)
_ = state.reconcile(requests, now: now)
try expect(state.pending.isEmpty && state.notificationsToAnnounce().isEmpty, "old HTTP snapshot must not resurrect accepted request")
print("PASS acknowledged response survives stale polling")
let category = requests[0].notificationCategory()
let notification = requests[0].notificationRequest()
try expect(category.actions.map(\.identifier) == ["yes", "no"], "native notification must offer same actions as menu")
try expect(category.actions.map(\.title) == ["Yes", "No"], "native action labels")
try expect(notification.content.title == "Associate project?" && notification.content.body == "Add project Delta to this meeting?", "native notification must show pending request text")
try expect(notification.content.categoryIdentifier == category.identifier && notification.content.userInfo["requestId"] as? String == "approval-1", "native actions must route original request id")
try expect(category.options.contains(.customDismissAction), "notification dismissal must leave menu decision pending")
print("PASS actionable native notification payload")
let windows = try JSONDecoder().decode([DesktopAgentWindow].self, from: Data(#"[{"id":"workflow-1","url":"http://127.0.0.1:5090/desktop/agent-sessions/workflow-1"},{"id":"workflow-2","url":"http://127.0.0.1:5090/desktop/agent-sessions/workflow-2"}]"#.utf8))
var windowState = DesktopAgentWindowState()
try expect(windowState.takeUnopened(windows).map(\.id) == ["workflow-1", "workflow-2"], "two approved workflows must request independent windows")
try expect(windowState.takeUnopened(windows).isEmpty, "polling must not reopen independent windows")
print("PASS independent agent window wire payload and opening")
} catch {
fputs("FAIL desktop controls self-test: \(error)\n", stderr)
exit(1)
}
}
private let requestFixture = #"""
[{"id":"approval-1","kind":"approval","title":"Associate project?","message":"Add project Delta to this meeting?","createdAt":"2026-10-03T12:10:00.1234567+02:00","expiresAt":"2026-10-03T12:11:00+02:00","actions":[{"id":"yes","label":"Yes"},{"id":"no","label":"No"}]}]
"""#
@@ -2,6 +2,7 @@ import AppKit
import Carbon
import Foundation
import WebKit
import UserNotifications
struct Manifest: Decodable {
let baseUrl: String
@@ -21,20 +22,36 @@ struct Hotkey: Decodable {
let endpoint: String
}
enum RecordingState: Int, Decodable {
case idle = 0
case recording = 1
case summarizing = 2
}
struct RecordingStatus: Decodable {
let isRecording: Bool
let state: String
let state: RecordingState
let launchProfile: String?
}
@main
final class DesktopControls: NSObject, NSApplicationDelegate, NSMenuDelegate {
@MainActor
final class DesktopControls: NSObject, NSApplicationDelegate, NSMenuDelegate, UNUserNotificationCenterDelegate {
private let manifest: Manifest
private var status = RecordingStatus(isRecording: false, state: "Idle", launchProfile: nil)
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,
@@ -48,6 +65,19 @@ final class DesktopControls: NSObject, NSApplicationDelegate, NSMenuDelegate {
}
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
@@ -55,6 +85,12 @@ final class DesktopControls: NSObject, NSApplicationDelegate, NSMenuDelegate {
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(
@@ -64,19 +100,22 @@ final class DesktopControls: NSObject, NSApplicationDelegate, NSMenuDelegate {
menu.delegate = self
statusItem.menu = menu
registerHotkeys()
refreshStatus()
refresh()
timer = Timer.scheduledTimer(withTimeInterval: 2, repeats: true) { [weak self] _ in
self?.refreshStatus()
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)
refreshStatus()
refresh()
}
private func rebuildMenu(_ menu: NSMenu) {
@@ -102,17 +141,45 @@ final class DesktopControls: NSObject, NSApplicationDelegate, NSMenuDelegate {
}
}
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"
return (status.launchProfile.map { "Meeting Assistant — recording (\($0))" }
?? "Meeting Assistant — recording") + suffix
}
return status.state.lowercased() == "summarizing"
return (status.state == .summarizing
? "Meeting Assistant — summarizing"
: "Meeting Assistant — idle"
: "Meeting Assistant — idle") + suffix
}
private func item(_ title: String, _ action: Selector) -> NSMenuItem {
@@ -122,24 +189,64 @@ final class DesktopControls: NSObject, NSApplicationDelegate, NSMenuDelegate {
}
@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
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")
@@ -160,6 +267,114 @@ final class DesktopControls: NSObject, NSApplicationDelegate, NSMenuDelegate {
}
}
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
@@ -167,9 +382,7 @@ final class DesktopControls: NSObject, NSApplicationDelegate, NSMenuDelegate {
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)
self?.updateStatusItem()
}
}.resume()
}
@@ -184,7 +397,7 @@ final class DesktopControls: NSObject, NSApplicationDelegate, NSMenuDelegate {
request.httpMethod = "POST"
URLSession.shared.dataTask(with: request) { [weak self] _, _, _ in
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
self?.refreshStatus()
self?.refresh()
}
}.resume()
}
@@ -239,6 +452,20 @@ final class DesktopControls: NSObject, NSApplicationDelegate, NSMenuDelegate {
}
}
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)
}