diff --git a/Sources/AgentNotch/App/AppDelegate.swift b/Sources/AgentNotch/App/AppDelegate.swift index dede9fc..24c9351 100644 --- a/Sources/AgentNotch/App/AppDelegate.swift +++ b/Sources/AgentNotch/App/AppDelegate.swift @@ -4,7 +4,7 @@ import AppKit final class AppDelegate: NSObject, NSApplicationDelegate { private let store = SessionStore() private let viewModel = NotchViewModel() - private let vercel = VercelMonitor() + private let vercel = VercelMonitor(notificationsEnabled: { Preferences.vercelNotificationsEnabled }) private var server: HookServer? private var panelController: NotchPanelController? private var statusMenu: StatusMenu? diff --git a/Sources/AgentNotch/App/Preferences.swift b/Sources/AgentNotch/App/Preferences.swift index 29a08cc..3b0a88c 100644 --- a/Sources/AgentNotch/App/Preferences.swift +++ b/Sources/AgentNotch/App/Preferences.swift @@ -13,6 +13,11 @@ enum Preferences { get { bool("soundOnApproval", true) } set { save(newValue, "soundOnApproval") } } + /// Notify when a Vercel deployment reaches a terminal state. Off by default: a build finishing is not your turn. + static var vercelNotificationsEnabled: Bool { + get { bool("vercelNotificationsEnabled", false) } + set { save(newValue, "vercelNotificationsEnabled") } + } /// 0...1, applied on top of the system volume. static var soundVolume: Float { get { Float(number("soundVolume", 0.3)) } diff --git a/Sources/AgentNotch/App/StatusMenu.swift b/Sources/AgentNotch/App/StatusMenu.swift index 170a7a4..1ed7add 100644 --- a/Sources/AgentNotch/App/StatusMenu.swift +++ b/Sources/AgentNotch/App/StatusMenu.swift @@ -18,6 +18,7 @@ final class StatusMenu: NSObject { private let languageItem = NSMenuItem(title: L("Language"), action: nil, keyEquivalent: "") private let vercelItem = NSMenuItem(title: L("Connect Vercel…"), action: nil, keyEquivalent: "") private let vercelStatusItem = NSMenuItem(title: "", action: nil, keyEquivalent: "") + private let vercelNotifyItem = NSMenuItem(title: L("Notify when a Vercel deployment finishes"), action: nil, keyEquivalent: "") private static let volumeChoices: [(title: String, value: Double)] = [(L("Quiet"), 0.15), (L("Normal"), 0.3), (L("Loud"), 0.6)] private static let sleepChoices: [(title: String, value: Double)] = [(L("30 seconds"), 30), (L("1 minute"), 60), (L("3 minutes"), 180)] @@ -50,7 +51,8 @@ final class StatusMenu: NSObject { let connect = NSMenuItem(title: L("Connect agents…"), action: #selector(connectAgents), keyEquivalent: "") connect.target = self vercelItem.target = self; vercelItem.action = #selector(connectVercel) - [connect, vercelItem, vercelStatusItem, .separator(), pauseItem, .separator(), soundItem, approvalSoundItem, volumeItem, .separator(), idleItem, sleepItem, + vercelNotifyItem.target = self; vercelNotifyItem.action = #selector(toggleVercelNotifications) + [connect, vercelItem, vercelStatusItem, .separator(), pauseItem, .separator(), soundItem, approvalSoundItem, volumeItem, vercelNotifyItem, .separator(), idleItem, sleepItem, yourTurnItem, .separator(), languageItem, loginItem, .separator(), quit].forEach(menu.addItem) menu.delegate = self item.menu = menu @@ -110,6 +112,8 @@ final class StatusMenu: NSObject { @objc private func toggleSound() { Preferences.soundEnabled.toggle() } + @objc private func toggleVercelNotifications() { Preferences.vercelNotificationsEnabled.toggle() } + @objc private func toggleLogin() { do { if SMAppService.mainApp.status == .enabled { @@ -171,6 +175,8 @@ extension StatusMenu: NSMenuDelegate { vercelItem.isHidden = !vercel.configured vercelStatusItem.isHidden = !vercel.configured || vercel.error == nil vercelStatusItem.title = vercel.error ?? "" + vercelNotifyItem.isHidden = !vercel.configured + vercelNotifyItem.state = Preferences.vercelNotificationsEnabled ? .on : .off soundItem.state = Preferences.soundEnabled ? .on : .off approvalSoundItem.state = Preferences.approvalSoundEnabled ? .on : .off idleItem.state = Preferences.showIdleMascot ? .on : .off diff --git a/Sources/AgentNotch/Infrastructure/VercelMonitor.swift b/Sources/AgentNotch/Infrastructure/VercelMonitor.swift index 5c3b049..8f993c9 100644 --- a/Sources/AgentNotch/Infrastructure/VercelMonitor.swift +++ b/Sources/AgentNotch/Infrastructure/VercelMonitor.swift @@ -22,8 +22,11 @@ final class VercelMonitor: ObservableObject { private let worker: URL? private let slug: String private let session = URLSession(configuration: .ephemeral, delegate: NoRedirects(), delegateQueue: nil) + /// Whether a finished deployment may post a notification. Injected by the app layer: this monitor reads no preferences. + private let notificationsEnabled: () -> Bool - init() { + init(notificationsEnabled: @escaping () -> Bool = { false }) { + self.notificationsEnabled = notificationsEnabled if let raw = Bundle.main.object(forInfoDictionaryKey: "VercelWorkerURL") as? String, let url = URL(string: raw), url.scheme == "https", url.host != nil { worker = url } else { worker = nil } @@ -114,11 +117,14 @@ final class VercelMonitor: ObservableObject { private func notify(_ deployment: Deployment) async { guard Bundle.main.bundleURL.pathExtension == "app" else { return } + guard notificationsEnabled() else { return } let center = UNUserNotificationCenter.current() - guard (try? await center.requestAuthorization(options: [.alert, .sound])) == true else { return } + guard (try? await center.requestAuthorization(options: [.alert])) == true else { return } let content = UNMutableNotificationContent() content.title = "Vercel · \(deployment.project)" content.body = [deployment.status.label, deployment.repository, deployment.commit, deployment.author].compactMap { $0 }.joined(separator: " · ") + // A build finishing is ambient news, not the user's turn: deliver it passively, with no sound or banner that demands attention. + content.interruptionLevel = .passive try? await center.add(UNNotificationRequest(identifier: "vercel-\(deployment.id)", content: content, trigger: nil)) } diff --git a/packaging/locales/de.lproj/Localizable.strings b/packaging/locales/de.lproj/Localizable.strings index 88dbf26..836596d 100644 --- a/packaging/locales/de.lproj/Localizable.strings +++ b/packaging/locales/de.lproj/Localizable.strings @@ -88,3 +88,4 @@ "Ready" = "Bereit"; "Canceled" = "Abgebrochen"; "Unknown" = "Unbekannt"; +"Notify when a Vercel deployment finishes" = "Benachrichtigen, wenn ein Vercel-Deployment fertig ist"; diff --git a/packaging/locales/en.lproj/Localizable.strings b/packaging/locales/en.lproj/Localizable.strings index 2421a2d..2811ea5 100644 --- a/packaging/locales/en.lproj/Localizable.strings +++ b/packaging/locales/en.lproj/Localizable.strings @@ -88,3 +88,4 @@ "Ready" = "Ready"; "Canceled" = "Canceled"; "Unknown" = "Unknown"; +"Notify when a Vercel deployment finishes" = "Notify when a Vercel deployment finishes"; diff --git a/packaging/locales/es.lproj/Localizable.strings b/packaging/locales/es.lproj/Localizable.strings index 1f5b4f8..be21440 100644 --- a/packaging/locales/es.lproj/Localizable.strings +++ b/packaging/locales/es.lproj/Localizable.strings @@ -88,3 +88,4 @@ "Ready" = "Listo"; "Canceled" = "Cancelado"; "Unknown" = "Desconocido"; +"Notify when a Vercel deployment finishes" = "Notificarme cuando termine un despliegue de Vercel";