Skip to content

Commit 7c784e2

Browse files
committed
Implement update notifications
1 parent 3771a66 commit 7c784e2

File tree

5 files changed

+183
-18
lines changed

5 files changed

+183
-18
lines changed

Ice/ControlItem/ControlItem.swift

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -533,14 +533,9 @@ final class ControlItem {
533533

534534
/// Opens the settings window and checks for app updates.
535535
@objc private func checkForUpdates() {
536-
guard
537-
let appState,
538-
let appDelegate = appState.appDelegate
539-
else {
536+
guard let appState else {
540537
return
541538
}
542-
// Open the settings window in case an alert needs to be displayed.
543-
appDelegate.openSettingsWindow()
544539
appState.updatesManager.checkForUpdates()
545540
}
546541

Ice/Main/AppState.swift

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,15 +27,18 @@ final class AppState: ObservableObject {
2727
/// Manager for the app's settings.
2828
private(set) lazy var settingsManager = SettingsManager(appState: self)
2929

30+
/// Manager for app updates.
31+
private(set) lazy var updatesManager = UpdatesManager(appState: self)
32+
33+
/// Manager for user notifications.
34+
private(set) lazy var userNotificationManager = UserNotificationManager(appState: self)
35+
3036
/// Global cache for menu bar item images.
3137
private(set) lazy var imageCache = MenuBarItemImageCache(appState: self)
3238

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

36-
/// Manager for app updates.
37-
let updatesManager = UpdatesManager()
38-
3942
/// Model for app-wide navigation.
4043
let navigationState = AppNavigationState()
4144

@@ -176,6 +179,8 @@ final class AppState: ObservableObject {
176179
settingsManager.performSetup()
177180
itemManager.performSetup()
178181
imageCache.performSetup()
182+
updatesManager.performSetup()
183+
userNotificationManager.performSetup()
179184
}
180185

181186
/// Assigns the app delegate to the app state.

Ice/Updates/UpdatesManager.swift

Lines changed: 75 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,23 @@ import Sparkle
77
import SwiftUI
88

99
/// Manager for app updates.
10-
final class UpdatesManager: ObservableObject {
10+
@MainActor
11+
final class UpdatesManager: NSObject, ObservableObject {
1112
/// A Boolean value that indicates whether the user can check for updates.
1213
@Published var canCheckForUpdates = false
1314

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

18+
/// The shared app state.
19+
private(set) weak var appState: AppState?
20+
1721
/// The underlying updater controller.
18-
let updaterController: SPUStandardUpdaterController
22+
private(set) lazy var updaterController = SPUStandardUpdaterController(
23+
startingUpdater: true,
24+
updaterDelegate: self,
25+
userDriverDelegate: self
26+
)
1927

2028
/// The underlying updater.
2129
var updater: SPUUpdater {
@@ -44,13 +52,15 @@ final class UpdatesManager: ObservableObject {
4452
}
4553
}
4654

47-
/// Creates an updates manager.
48-
init() {
49-
self.updaterController = SPUStandardUpdaterController(
50-
startingUpdater: true,
51-
updaterDelegate: nil,
52-
userDriverDelegate: nil
53-
)
55+
/// Creates an updates manager with the given app state.
56+
init(appState: AppState) {
57+
self.appState = appState
58+
super.init()
59+
}
60+
61+
/// Sets up the manager.
62+
func performSetup() {
63+
_ = updaterController
5464
configureCancellables()
5565
}
5666

@@ -70,9 +80,65 @@ final class UpdatesManager: ObservableObject {
7080
alert.messageText = "Checking for updates is not supported in debug mode."
7181
alert.runModal()
7282
#else
83+
guard let appState else {
84+
return
85+
}
86+
// Activate the app in case an alert needs to be displayed.
87+
appState.activate(withPolicy: .regular)
7388
updater.checkForUpdates()
7489
#endif
7590
}
7691
}
7792

93+
// MARK: UpdatesManager: SPUUpdaterDelegate
94+
extension UpdatesManager: @preconcurrency SPUUpdaterDelegate {
95+
func updater(_ updater: SPUUpdater, willScheduleUpdateCheckAfterDelay delay: TimeInterval) {
96+
guard let appState else {
97+
return
98+
}
99+
appState.userNotificationManager.requestAuthorization()
100+
}
101+
}
102+
103+
// MARK: UpdatesManager: SPUStandardUserDriverDelegate
104+
extension UpdatesManager: @preconcurrency SPUStandardUserDriverDelegate {
105+
var supportsGentleScheduledUpdateReminders: Bool { true }
106+
107+
func standardUserDriverShouldHandleShowingScheduledUpdate(
108+
_ update: SUAppcastItem,
109+
andInImmediateFocus immediateFocus: Bool
110+
) -> Bool {
111+
if NSApp.isActive {
112+
return immediateFocus
113+
} else {
114+
return false
115+
}
116+
}
117+
118+
func standardUserDriverWillHandleShowingUpdate(
119+
_ handleShowingUpdate: Bool,
120+
forUpdate update: SUAppcastItem,
121+
state: SPUUserUpdateState
122+
) {
123+
guard let appState else {
124+
return
125+
}
126+
if !state.userInitiated {
127+
appState.userNotificationManager.addRequest(
128+
with: .updateCheck,
129+
title: "A new update is available",
130+
body: "Version \(update.displayVersionString) is now available"
131+
)
132+
}
133+
}
134+
135+
func standardUserDriverDidReceiveUserAttention(forUpdate update: SUAppcastItem) {
136+
guard let appState else {
137+
return
138+
}
139+
appState.userNotificationManager.removeDeliveredNotifications(with: [.updateCheck])
140+
}
141+
}
142+
143+
// MARK: UpdatesManager: BindingExposable
78144
extension UpdatesManager: BindingExposable { }
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
//
2+
// UserNotificationIdentifier.swift
3+
// Ice
4+
//
5+
6+
/// An identifier for a user notification.
7+
enum UserNotificationIdentifier: String {
8+
case updateCheck = "UpdateCheck"
9+
}
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
//
2+
// UserNotificationManager.swift
3+
// Ice
4+
//
5+
6+
import UserNotifications
7+
8+
/// Manager for user notifications.
9+
@MainActor
10+
final class UserNotificationManager: NSObject {
11+
/// The shared app state.
12+
private(set) weak var appState: AppState?
13+
14+
/// The current notification center.
15+
var notificationCenter: UNUserNotificationCenter { .current() }
16+
17+
/// Creates a user notification manager with the given app state.
18+
init(appState: AppState) {
19+
self.appState = appState
20+
super.init()
21+
}
22+
23+
/// Sets up the manager.
24+
func performSetup() {
25+
notificationCenter.delegate = self
26+
}
27+
28+
/// Requests authorization to allow user notifications for the app.
29+
func requestAuthorization() {
30+
Task {
31+
do {
32+
try await notificationCenter.requestAuthorization(options: [.badge, .alert, .sound])
33+
} catch {
34+
Logger.userNotifications.error("Failed to request authorization for notifications: \(error)")
35+
}
36+
}
37+
}
38+
39+
/// Schedules the delivery of a local notification.
40+
func addRequest(with identifier: UserNotificationIdentifier, title: String, body: String) {
41+
let content = UNMutableNotificationContent()
42+
content.title = title
43+
content.body = body
44+
45+
let request = UNNotificationRequest(
46+
identifier: identifier.rawValue,
47+
content: content,
48+
trigger: nil
49+
)
50+
51+
notificationCenter.add(request)
52+
}
53+
54+
/// Removes the notifications from Notification Center that match the given identifiers.
55+
func removeDeliveredNotifications(with identifiers: [UserNotificationIdentifier]) {
56+
notificationCenter.removeDeliveredNotifications(withIdentifiers: identifiers.map { $0.rawValue })
57+
}
58+
}
59+
60+
// MARK: UserNotificationManager: UNUserNotificationCenterDelegate
61+
extension UserNotificationManager: @preconcurrency UNUserNotificationCenterDelegate {
62+
func userNotificationCenter(
63+
_ center: UNUserNotificationCenter,
64+
didReceive response: UNNotificationResponse,
65+
withCompletionHandler completionHandler: @escaping () -> Void
66+
) {
67+
defer {
68+
completionHandler()
69+
}
70+
71+
guard let appState else {
72+
return
73+
}
74+
75+
switch UserNotificationIdentifier(rawValue: response.notification.request.identifier) {
76+
case .updateCheck:
77+
guard response.actionIdentifier == UNNotificationDefaultActionIdentifier else {
78+
break
79+
}
80+
appState.updatesManager.checkForUpdates()
81+
case nil:
82+
break
83+
}
84+
}
85+
}
86+
87+
// MARK: - Logger
88+
private extension Logger {
89+
static let userNotifications = Logger(category: "UserNotifications")
90+
}

0 commit comments

Comments
 (0)