From c8e6c6cc81b706b081365cfb5128667b053c1415 Mon Sep 17 00:00:00 2001 From: Joshua Rogers Date: Fri, 7 Aug 2026 00:39:06 +0200 Subject: [PATCH 1/2] feat: announce a newly available version with a one-time notification MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Update Available notice lives at the top of the right-click menu and in Settings → Other, so a user who rarely opens either can miss a release for weeks. Post a system notification the first time an automatic check discovers a given version — once per version, keyed in UserDefaults, so the daily re-check never re-nags. Manual checks stay silent (their result is already on screen), and the banner is retired once the update is installed, both at the next check and at launch. --- Magic Switch/Manager/UpdateChecker.swift | 35 ++++++++++++++++++++++++ README.md | 2 +- 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/Magic Switch/Manager/UpdateChecker.swift b/Magic Switch/Manager/UpdateChecker.swift index dca3789..6815f61 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 @@ -90,6 +96,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 +188,33 @@ 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. 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 } + UserDefaults.standard.set(latest, forKey: Constants.notifiedVersionKey) + NotificationManager.showNotification( + title: "Update Available", + body: + "Magic Switch v\(latest) is available (you have v\(currentVersion)). The Update Available notice in the menu opens the download page.", + identifier: Constants.updateNotificationID + ) + } + /// 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..4ea38d3 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 — 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 From 9378cb441f70994803c92c6d66fce9e96fb4c306 Mon Sep 17 00:00:00 2001 From: Joshua Rogers Date: Fri, 7 Aug 2026 01:10:22 +0200 Subject: [PATCH 2/2] feat: open the release page when the update notification is clicked MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Install a UNUserNotificationCenterDelegate at launch and route the update banner's stable identifier to openLatestReleasePage; the body text becomes just "Click to open the download page." - Record the announced version only once the banner is accepted for delivery, so the launch-time permission race (or denied notifications) can't permanently swallow a version's announcement — the next automatic check retries instead. - README documents the click action. --- Magic Switch/AppDelegate/AppDelegate.swift | 26 ++++++++++++++++++- .../Manager/NotificationManager.swift | 11 ++++++-- Magic Switch/Manager/UpdateChecker.swift | 17 +++++++++--- README.md | 2 +- 4 files changed, 48 insertions(+), 8 deletions(-) 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 6815f61..efe01a2 100644 --- a/Magic Switch/Manager/UpdateChecker.swift +++ b/Magic Switch/Manager/UpdateChecker.swift @@ -62,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. @@ -195,7 +199,10 @@ final class UpdateChecker: ObservableObject { /// 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. A check that finds no update retires any delivered banner + /// 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) { @@ -206,13 +213,15 @@ final class UpdateChecker: ObservableObject { guard !manual, UserDefaults.standard.string(forKey: Constants.notifiedVersionKey) != latest else { return } - UserDefaults.standard.set(latest, forKey: Constants.notifiedVersionKey) NotificationManager.showNotification( title: "Update Available", body: - "Magic Switch v\(latest) is available (you have v\(currentVersion)). The Update Available notice in the menu opens the download page.", + "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. diff --git a/README.md b/README.md index 4ea38d3..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. The first automatic check that spots a given version also posts a single system notification — 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**. +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