|
1 | 1 | import AppKit |
2 | 2 | import KestrelBarLib |
3 | 3 | import ServiceManagement |
| 4 | +import UserNotifications |
4 | 5 |
|
5 | 6 | class AppDelegate: NSObject, NSApplicationDelegate { |
6 | 7 | private var statusItem: NSStatusItem! |
@@ -58,6 +59,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { |
58 | 59 | updateIcon() |
59 | 60 | buildMenu() |
60 | 61 | startCore() |
| 62 | + UNUserNotificationCenter.current().requestAuthorization(options: [.alert]) { _, _ in } |
61 | 63 | } |
62 | 64 |
|
63 | 65 | func applicationWillTerminate(_ notification: Notification) { |
@@ -318,6 +320,11 @@ class AppDelegate: NSObject, NSApplicationDelegate { |
318 | 320 | settingsItem.submenu = settingsSub |
319 | 321 | menu.addItem(settingsItem) |
320 | 322 |
|
| 323 | + // --- Export --- |
| 324 | + let exportItem = NSMenuItem(title: "Export Session Log...", action: #selector(exportLog), keyEquivalent: "e") |
| 325 | + exportItem.target = self |
| 326 | + menu.addItem(exportItem) |
| 327 | + |
321 | 328 | menu.addItem(NSMenuItem.separator()) |
322 | 329 | menu.addItem(NSMenuItem(title: "Quit Kestrel", action: #selector(quit), keyEquivalent: "q")) |
323 | 330 |
|
@@ -608,6 +615,42 @@ class AppDelegate: NSObject, NSApplicationDelegate { |
608 | 615 | } |
609 | 616 | } |
610 | 617 |
|
| 618 | + private func sendNotification(sensor: String, from: String, to: String) { |
| 619 | + let label = sensorInfo[sensor]?.label ?? sensor |
| 620 | + let content = UNMutableNotificationContent() |
| 621 | + content.title = "Kestrel: \(label) \(to)" |
| 622 | + content.body = "\(label) changed from \(from) to \(to)." |
| 623 | + |
| 624 | + let request = UNNotificationRequest( |
| 625 | + identifier: "kestrel-\(sensor)-\(Date().timeIntervalSince1970)", |
| 626 | + content: content, |
| 627 | + trigger: nil |
| 628 | + ) |
| 629 | + UNUserNotificationCenter.current().add(request) |
| 630 | + } |
| 631 | + |
| 632 | + @objc private func exportLog() { |
| 633 | + let panel = NSSavePanel() |
| 634 | + panel.nameFieldStringValue = "kestrel-session.jsonl" |
| 635 | + panel.allowedContentTypes = [.json] |
| 636 | + |
| 637 | + guard panel.runModal() == .OK, let url = panel.url else { return } |
| 638 | + |
| 639 | + let lines = sensorStates.compactMap { sensor, state -> String? in |
| 640 | + guard let data = try? JSONSerialization.data(withJSONObject: [ |
| 641 | + "sensor": sensor, |
| 642 | + "value": state.value, |
| 643 | + "state": state.state, |
| 644 | + "valid": state.valid, |
| 645 | + "exported": ISO8601DateFormatter().string(from: Date()), |
| 646 | + ]) else { return nil } |
| 647 | + return String(data: data, encoding: .utf8) |
| 648 | + } |
| 649 | + |
| 650 | + let output = lines.joined(separator: "\n") + "\n" |
| 651 | + try? output.write(to: url, atomically: true, encoding: .utf8) |
| 652 | + } |
| 653 | + |
611 | 654 | // MARK: - Icon |
612 | 655 |
|
613 | 656 | private func updateIcon() { |
@@ -831,6 +874,9 @@ class AppDelegate: NSObject, NSApplicationDelegate { |
831 | 874 | display.state = to |
832 | 875 | sensorStates[sensor] = display |
833 | 876 |
|
| 877 | + // Notify on state changes |
| 878 | + sendNotification(sensor: sensor, from: from, to: to) |
| 879 | + |
834 | 880 | // Track resolution timing |
835 | 881 | if to != "OK" && degradedSince[sensor] == nil { |
836 | 882 | degradedSince[sensor] = Date() |
|
0 commit comments