Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 25 additions & 1 deletion Magic Switch/AppDelegate/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
}

Expand Down Expand Up @@ -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
Expand Down
11 changes: 9 additions & 2 deletions Magic Switch/Manager/NotificationManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand All @@ -86,6 +92,7 @@ final class NotificationManager: NotificationManaging {
if let error = error {
print("Failed to show notification: \(error)")
}
delivered?(error)
}
}

Expand Down
44 changes: 44 additions & 0 deletions Magic Switch/Manager/UpdateChecker.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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()
}

Expand Down Expand Up @@ -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],
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading