diff --git a/Magic Switch/AppDelegate/AppDelegate.swift b/Magic Switch/AppDelegate/AppDelegate.swift index 83e5436..95b1f94 100644 --- a/Magic Switch/AppDelegate/AppDelegate.swift +++ b/Magic Switch/AppDelegate/AppDelegate.swift @@ -2,9 +2,12 @@ import Cocoa import Combine import CoreBluetooth import SwiftUI +import UserNotifications /// Application delegate handling lifecycle and UI setup -final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { +final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate, + UNUserNotificationCenterDelegate +{ // MARK: - Dependencies private let networkStore = NetworkDeviceStore.shared @@ -200,6 +203,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { // MARK: - Setup Methods private func setupNotifications() { + // macOS only routes notification clicks — including one that relaunches + // the app — to a delegate installed before launch finishes. + UNUserNotificationCenter.current().delegate = self NotificationManager.requestAuthorizationIfNeeded() } @@ -954,6 +960,24 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { return NSWindowController(window: window) } + /// Clicking the update banner opens the release page — the same action as + /// the Update Available rows it points at. Every other Magic Switch + /// notification is informational and just dismisses. + func userNotificationCenter( + _ center: UNUserNotificationCenter, + didReceive response: UNNotificationResponse, + withCompletionHandler completionHandler: @escaping () -> Void + ) { + if response.actionIdentifier == UNNotificationDefaultActionIdentifier, + response.notification.request.identifier == UpdateChecker.updateNotificationIdentifier + { + // Delegate callbacks arrive on an internal queue; hop to main before + // touching NSWorkspace. + DispatchQueue.main.async { [weak self] in self?.openLatestReleasePage(nil) } + } + completionHandler() + } + /// Drops the app back to `.accessory` (no Dock icon) once the last normal /// window closes. SwiftUI's `Settings` scene typically reuses one window, /// but the loop is defensive against any other normal-level window we diff --git a/Magic Switch/Manager/NotificationManager.swift b/Magic Switch/Manager/NotificationManager.swift index eb3516e..0af89b9 100644 --- a/Magic Switch/Manager/NotificationManager.swift +++ b/Magic Switch/Manager/NotificationManager.swift @@ -15,7 +15,10 @@ protocol NotificationManaging { /// - identifier: Optional stable identifier. Re-posting with the same /// identifier replaces the previous notification rather than stacking, /// so rapid retries coalesce instead of flooding Notification Centre. - static func showNotification(title: String, body: String, identifier: String?) + /// - delivered: Called with the add-request error — nil when the + /// notification was accepted for delivery. + static func showNotification( + title: String, body: String, identifier: String?, delivered: ((Error?) -> Void)?) } final class NotificationManager: NotificationManaging { @@ -74,7 +77,10 @@ final class NotificationManager: NotificationManaging { center.removePendingNotificationRequests(withIdentifiers: [identifier]) } - static func showNotification(title: String, body: String, identifier: String? = nil) { + static func showNotification( + title: String, body: String, identifier: String? = nil, + delivered: ((Error?) -> Void)? = nil + ) { let content = createNotificationContent(title: title, body: body) let request = UNNotificationRequest( identifier: identifier ?? UUID().uuidString, @@ -86,6 +92,7 @@ final class NotificationManager: NotificationManaging { if let error = error { print("Failed to show notification: \(error)") } + delivered?(error) } } diff --git a/Magic Switch/Manager/UpdateChecker.swift b/Magic Switch/Manager/UpdateChecker.swift index dca3789..efe01a2 100644 --- a/Magic Switch/Manager/UpdateChecker.swift +++ b/Magic Switch/Manager/UpdateChecker.swift @@ -42,6 +42,12 @@ final class UpdateChecker: ObservableObject { /// Persisted state, namespaced like the rest of the app's UserDefaults keys. static let lastCheckedKey = "com.magicswitch.updatecheck.lastChecked" static let latestVersionKey = "com.magicswitch.updatecheck.latestVersion" + /// Last version announced via system notification — one banner per + /// version, however many checks rediscover it. + static let notifiedVersionKey = "com.magicswitch.updatecheck.notifiedVersion" + /// Stable notification identifier, so re-posts replace rather than stack + /// and a delivered banner can be retired once the update is installed. + static let updateNotificationID = "update-available" } // MARK: - Published State @@ -56,6 +62,10 @@ final class UpdateChecker: ObservableObject { /// malformed; callers guard on it. let releasePageURL = URL(string: Constants.latestReleasePage) + /// Stable identifier of the update notification, exposed so the + /// notification-click router in `AppDelegate` can match on it. + static var updateNotificationIdentifier: String { Constants.updateNotificationID } + /// True while a check is in flight; drives the "Checking…" state on the /// manual Check-for-Updates button, and guards against overlapping checks. /// Main-thread only. @@ -90,6 +100,12 @@ final class UpdateChecker: ObservableObject { // Surface the cached result immediately so the menu / Settings reflect the // last successful check without waiting for a network round trip. latestVersion = UserDefaults.standard.string(forKey: Constants.latestVersionKey) + if !updateAvailable { + // The cached "newer" version is usually the one now running — the user + // just updated — so retire a delivered update banner rather than leave + // it stale in Notification Centre. + NotificationManager.removeNotification(identifier: Constants.updateNotificationID) + } startPolling() } @@ -176,10 +192,38 @@ final class UpdateChecker: ObservableObject { UserDefaults.standard.set(Date(), forKey: Constants.lastCheckedKey) UserDefaults.standard.set(version, forKey: Constants.latestVersionKey) self.latestVersion = version + self.reconcileUpdateNotification(manual: manual) } }.resume() } + /// One notification per discovered version, and only from automatic checks — + /// a manual check's result is already on screen next to the button that + /// triggered it. The version is recorded as announced only once the banner + /// is accepted for delivery, so a post lost to the launch-time permission + /// race (or to denied notifications) stays eligible for the next automatic + /// check. A check that finds no update retires any delivered banner + /// (the update was installed, or GitHub stopped advertising it). Main-only, + /// called from `performCheck`'s completion. + private func reconcileUpdateNotification(manual: Bool) { + guard updateAvailable, let latest = latestVersion else { + NotificationManager.removeNotification(identifier: Constants.updateNotificationID) + return + } + guard !manual, + UserDefaults.standard.string(forKey: Constants.notifiedVersionKey) != latest + else { return } + NotificationManager.showNotification( + title: "Update Available", + body: + "Magic Switch v\(latest) is available (you have v\(currentVersion)). Click to open the download page.", + identifier: Constants.updateNotificationID + ) { error in + guard error == nil else { return } + UserDefaults.standard.set(latest, forKey: Constants.notifiedVersionKey) + } + } + /// Pull `tag_name` out of the `releases/latest` JSON without a model type. private static func parseTagName(from data: Data) -> String? { guard let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any], diff --git a/README.md b/README.md index 1479421..e30e1b3 100644 --- a/README.md +++ b/README.md @@ -120,7 +120,7 @@ Run the command on the Mac that should act. `direction=take` works even while th ## Updates -Magic Switch tells you when there's a new version — it never updates itself. About once a day it makes a single anonymous request to GitHub's public releases API for [this repo](https://github.com/MegaManSec/magic-switch/releases) and compares your installed version with the latest published release; no account, sign-in, or telemetry is involved. When a newer version exists, an **Update Available** notice (with the new version number) appears at the top of the right-click menu and in **Settings → Other** — clicking it opens the release page so you can download and install it yourself. A failed check (offline, rate-limited, etc.) is retried about hourly; otherwise checks happen at most once every 24 hours. Your installed version is always shown in **Settings → Other**. +Magic Switch tells you when there's a new version — it never updates itself. About once a day it makes a single anonymous request to GitHub's public releases API for [this repo](https://github.com/MegaManSec/magic-switch/releases) and compares your installed version with the latest published release; no account, sign-in, or telemetry is involved. When a newer version exists, an **Update Available** notice (with the new version number) appears at the top of the right-click menu and in **Settings → Other** — clicking it opens the release page so you can download and install it yourself. The first automatic check that spots a given version also posts a single system notification — click it to open the download page. It's posted once per version, so it won't nag (and only if notifications are allowed, see [Troubleshooting](#troubleshooting)). A failed check (offline, rate-limited, etc.) is retried about hourly; otherwise checks happen at most once every 24 hours. Your installed version is always shown in **Settings → Other**. ## Troubleshooting