Skip to content

Commit

Permalink
Implement update notifications
Browse files Browse the repository at this point in the history
  • Loading branch information
jordanbaird committed Oct 1, 2024
1 parent 3771a66 commit 7c784e2
Show file tree
Hide file tree
Showing 5 changed files with 183 additions and 18 deletions.
7 changes: 1 addition & 6 deletions Ice/ControlItem/ControlItem.swift
Original file line number Diff line number Diff line change
Expand Up @@ -533,14 +533,9 @@ final class ControlItem {

/// Opens the settings window and checks for app updates.
@objc private func checkForUpdates() {
guard
let appState,
let appDelegate = appState.appDelegate
else {
guard let appState else {
return
}
// Open the settings window in case an alert needs to be displayed.
appDelegate.openSettingsWindow()
appState.updatesManager.checkForUpdates()
}

Expand Down
11 changes: 8 additions & 3 deletions Ice/Main/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -27,15 +27,18 @@ final class AppState: ObservableObject {
/// Manager for the app's settings.
private(set) lazy var settingsManager = SettingsManager(appState: self)

/// Manager for app updates.
private(set) lazy var updatesManager = UpdatesManager(appState: self)

/// Manager for user notifications.
private(set) lazy var userNotificationManager = UserNotificationManager(appState: self)

/// Global cache for menu bar item images.
private(set) lazy var imageCache = MenuBarItemImageCache(appState: self)

/// Manager for menu bar item spacing.
let spacingManager = MenuBarItemSpacingManager()

/// Manager for app updates.
let updatesManager = UpdatesManager()

/// Model for app-wide navigation.
let navigationState = AppNavigationState()

Expand Down Expand Up @@ -176,6 +179,8 @@ final class AppState: ObservableObject {
settingsManager.performSetup()
itemManager.performSetup()
imageCache.performSetup()
updatesManager.performSetup()
userNotificationManager.performSetup()
}

/// Assigns the app delegate to the app state.
Expand Down
84 changes: 75 additions & 9 deletions Ice/Updates/UpdatesManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,23 @@ import Sparkle
import SwiftUI

/// Manager for app updates.
final class UpdatesManager: ObservableObject {
@MainActor
final class UpdatesManager: NSObject, ObservableObject {
/// A Boolean value that indicates whether the user can check for updates.
@Published var canCheckForUpdates = false

/// The date of the last update check.
@Published var lastUpdateCheckDate: Date?

/// The shared app state.
private(set) weak var appState: AppState?

/// The underlying updater controller.
let updaterController: SPUStandardUpdaterController
private(set) lazy var updaterController = SPUStandardUpdaterController(
startingUpdater: true,
updaterDelegate: self,
userDriverDelegate: self
)

/// The underlying updater.
var updater: SPUUpdater {
Expand Down Expand Up @@ -44,13 +52,15 @@ final class UpdatesManager: ObservableObject {
}
}

/// Creates an updates manager.
init() {
self.updaterController = SPUStandardUpdaterController(
startingUpdater: true,
updaterDelegate: nil,
userDriverDelegate: nil
)
/// Creates an updates manager with the given app state.
init(appState: AppState) {
self.appState = appState
super.init()
}

/// Sets up the manager.
func performSetup() {
_ = updaterController
configureCancellables()
}

Expand All @@ -70,9 +80,65 @@ final class UpdatesManager: ObservableObject {
alert.messageText = "Checking for updates is not supported in debug mode."
alert.runModal()
#else
guard let appState else {
return
}
// Activate the app in case an alert needs to be displayed.
appState.activate(withPolicy: .regular)
updater.checkForUpdates()
#endif
}
}

// MARK: UpdatesManager: SPUUpdaterDelegate
extension UpdatesManager: @preconcurrency SPUUpdaterDelegate {
func updater(_ updater: SPUUpdater, willScheduleUpdateCheckAfterDelay delay: TimeInterval) {
guard let appState else {
return
}
appState.userNotificationManager.requestAuthorization()
}
}

// MARK: UpdatesManager: SPUStandardUserDriverDelegate
extension UpdatesManager: @preconcurrency SPUStandardUserDriverDelegate {
var supportsGentleScheduledUpdateReminders: Bool { true }

func standardUserDriverShouldHandleShowingScheduledUpdate(
_ update: SUAppcastItem,
andInImmediateFocus immediateFocus: Bool
) -> Bool {
if NSApp.isActive {
return immediateFocus
} else {
return false
}
}

func standardUserDriverWillHandleShowingUpdate(
_ handleShowingUpdate: Bool,
forUpdate update: SUAppcastItem,
state: SPUUserUpdateState
) {
guard let appState else {
return
}
if !state.userInitiated {
appState.userNotificationManager.addRequest(
with: .updateCheck,
title: "A new update is available",
body: "Version \(update.displayVersionString) is now available"
)
}
}

func standardUserDriverDidReceiveUserAttention(forUpdate update: SUAppcastItem) {
guard let appState else {
return
}
appState.userNotificationManager.removeDeliveredNotifications(with: [.updateCheck])
}
}

// MARK: UpdatesManager: BindingExposable
extension UpdatesManager: BindingExposable { }
9 changes: 9 additions & 0 deletions Ice/UserNotifications/UserNotificationIdentifier.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
//
// UserNotificationIdentifier.swift
// Ice
//

/// An identifier for a user notification.
enum UserNotificationIdentifier: String {
case updateCheck = "UpdateCheck"
}
90 changes: 90 additions & 0 deletions Ice/UserNotifications/UserNotificationManager.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
//
// UserNotificationManager.swift
// Ice
//

import UserNotifications

/// Manager for user notifications.
@MainActor
final class UserNotificationManager: NSObject {
/// The shared app state.
private(set) weak var appState: AppState?

/// The current notification center.
var notificationCenter: UNUserNotificationCenter { .current() }

/// Creates a user notification manager with the given app state.
init(appState: AppState) {
self.appState = appState
super.init()
}

/// Sets up the manager.
func performSetup() {
notificationCenter.delegate = self
}

/// Requests authorization to allow user notifications for the app.
func requestAuthorization() {
Task {
do {
try await notificationCenter.requestAuthorization(options: [.badge, .alert, .sound])
} catch {
Logger.userNotifications.error("Failed to request authorization for notifications: \(error)")
}
}
}

/// Schedules the delivery of a local notification.
func addRequest(with identifier: UserNotificationIdentifier, title: String, body: String) {
let content = UNMutableNotificationContent()
content.title = title
content.body = body

let request = UNNotificationRequest(
identifier: identifier.rawValue,
content: content,
trigger: nil
)

notificationCenter.add(request)
}

/// Removes the notifications from Notification Center that match the given identifiers.
func removeDeliveredNotifications(with identifiers: [UserNotificationIdentifier]) {
notificationCenter.removeDeliveredNotifications(withIdentifiers: identifiers.map { $0.rawValue })
}
}

// MARK: UserNotificationManager: UNUserNotificationCenterDelegate
extension UserNotificationManager: @preconcurrency UNUserNotificationCenterDelegate {
func userNotificationCenter(
_ center: UNUserNotificationCenter,
didReceive response: UNNotificationResponse,
withCompletionHandler completionHandler: @escaping () -> Void
) {
defer {
completionHandler()
}

guard let appState else {
return
}

switch UserNotificationIdentifier(rawValue: response.notification.request.identifier) {
case .updateCheck:
guard response.actionIdentifier == UNNotificationDefaultActionIdentifier else {
break
}
appState.updatesManager.checkForUpdates()
case nil:
break
}
}
}

// MARK: - Logger
private extension Logger {
static let userNotifications = Logger(category: "UserNotifications")
}

0 comments on commit 7c784e2

Please sign in to comment.