Skip to content

Commit e9b8ac1

Browse files
committed
Show update availability in menu
1 parent 5104221 commit e9b8ac1

4 files changed

Lines changed: 111 additions & 11 deletions

File tree

Sources/AgentTally/App/AppDelegate.swift

Lines changed: 41 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,14 +14,17 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
1414
private var lastSuccessfulAgentData: [AgentKind: AgentRawData] = [:]
1515
private var lastUsageDataFingerprints: [AgentKind: UsageDataFingerprint] = [:]
1616
private let loginItemManager = LoginItemManager()
17-
private let updaterController = SPUStandardUpdaterController(
17+
private lazy var updaterController = SPUStandardUpdaterController(
1818
startingUpdater: true,
19-
updaterDelegate: nil,
20-
userDriverDelegate: nil
19+
updaterDelegate: self,
20+
userDriverDelegate: self
2121
)
2222
private var startAtLoginViewState = StartAtLoginViewState.make(status: .notRegistered)
23+
private var softwareUpdateViewState = SoftwareUpdateViewState.idle
2324

2425
func applicationDidFinishLaunching(_ notification: Notification) {
26+
_ = updaterController
27+
2528
let statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
2629
self.statusItem = statusItem
2730

@@ -237,6 +240,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
237240
let rows = MenuRowsBuilder.rows(
238241
for: state,
239242
startAtLogin: startAtLoginViewState,
243+
softwareUpdate: softwareUpdateViewState,
240244
appVersion: appVersion()
241245
)
242246
MenuRenderer.render(menu: menu, rows: rows, target: self, selectorProvider: selector)
@@ -270,4 +274,38 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
270274
return #selector(quitMenuItemSelected)
271275
}
272276
}
277+
278+
private func noteAvailableUpdate(version: String) {
279+
softwareUpdateViewState = SoftwareUpdateViewState(availableVersion: version)
280+
refreshMenuIfNeeded()
281+
}
282+
}
283+
284+
extension AppDelegate: SPUUpdaterDelegate {
285+
func updater(_ updater: SPUUpdater, didFindValidUpdate item: SUAppcastItem) {
286+
noteAvailableUpdate(version: item.displayVersionString)
287+
}
288+
289+
func updaterDidNotFindUpdate(_ updater: SPUUpdater, error: Error) {
290+
softwareUpdateViewState = .idle
291+
refreshMenuIfNeeded()
292+
}
293+
294+
func updater(_ updater: SPUUpdater, didAbortWithError error: Error) {
295+
softwareUpdateViewState = .idle
296+
refreshMenuIfNeeded()
297+
}
298+
}
299+
300+
extension AppDelegate: SPUStandardUserDriverDelegate {
301+
nonisolated func standardUserDriverShouldHandleShowingScheduledUpdate(
302+
_ update: SUAppcastItem,
303+
andInImmediateFocus immediateFocus: Bool
304+
) -> Bool {
305+
let version = update.displayVersionString
306+
Task { @MainActor [weak self] in
307+
self?.noteAvailableUpdate(version: version)
308+
}
309+
return false
310+
}
273311
}

Sources/AgentTally/Presentation/MenuRowsBuilder.swift

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,14 +24,26 @@ public enum MenuRowsBuilder {
2424
public static func rows(
2525
for state: AppState,
2626
startAtLogin: StartAtLoginViewState,
27+
softwareUpdate: SoftwareUpdateViewState = .idle,
2728
appVersion: String? = nil,
2829
now: Date = Date()
2930
) -> [MenuRow] {
3031
var rows: [MenuRow] = [
31-
.disabled(headerTitle(appVersion: appVersion)),
32-
.disabled("Last refreshed: \(StatusPresenter.lastRefreshedLabel(for: state, now: now))"),
32+
.disabled(headerTitle(appVersion: appVersion))
3333
]
3434

35+
rows.append(
36+
.action(
37+
title: softwareUpdate.menuTitle,
38+
kind: .checkForUpdates,
39+
keyEquivalent: "",
40+
state: .off
41+
)
42+
)
43+
44+
rows.append(
45+
.disabled("Last refreshed: \(StatusPresenter.lastRefreshedLabel(for: state, now: now))"))
46+
3547
if state.isRefreshing {
3648
rows.append(.disabled("Refreshing ..."))
3749
} else {
@@ -91,9 +103,6 @@ public enum MenuRowsBuilder {
91103
}
92104

93105
rows.append(.separator)
94-
rows.append(
95-
.action(title: "Check for Updates...", kind: .checkForUpdates, keyEquivalent: "", state: .off)
96-
)
97106
rows.append(
98107
.action(
99108
title: "Open at Login",
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import Foundation
2+
3+
public struct SoftwareUpdateViewState: Equatable, Sendable {
4+
public static let idle = SoftwareUpdateViewState()
5+
6+
public let availableVersion: String?
7+
8+
public init(availableVersion: String? = nil) {
9+
let trimmedVersion = availableVersion?.trimmingCharacters(in: .whitespacesAndNewlines)
10+
self.availableVersion = trimmedVersion?.isEmpty == false ? trimmedVersion : nil
11+
}
12+
13+
public var menuTitle: String {
14+
guard let availableVersion else {
15+
return "Check for Updates..."
16+
}
17+
18+
if availableVersion.lowercased().hasPrefix("v") {
19+
return "Update Available: \(availableVersion)..."
20+
}
21+
22+
return "Update Available: v\(availableVersion)..."
23+
}
24+
}

Tests/MenuRowsHarness.swift

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,11 +39,22 @@ func testMenuRowsBuilder() throws {
3939
"menu should start with versioned header"
4040
)
4141
let headerIndex = rows.firstIndex(of: .disabled("AgentTally v0.1"))!
42-
if case .disabled(let label) = rows[headerIndex + 1] {
42+
try expect(
43+
rows[headerIndex + 1]
44+
== .action(
45+
title: "Check for Updates...",
46+
kind: .checkForUpdates,
47+
keyEquivalent: "",
48+
state: .off
49+
),
50+
"row after header should be update action"
51+
)
52+
if case .disabled(let label) = rows[headerIndex + 2] {
4353
try expect(
44-
label.hasPrefix("Last refreshed:"), "last updated should appear directly below the header")
54+
label.hasPrefix("Last refreshed:"), "last refreshed should appear below the update action")
4555
} else {
46-
throw TestFailure(description: "row after header should be 'Last refreshed:' disabled item")
56+
throw TestFailure(
57+
description: "row after update action should be 'Last refreshed:' disabled item")
4758
}
4859
try expect(
4960
rows.contains(.disabled("Today: $49")),
@@ -118,6 +129,24 @@ func testMenuRowsBuilder() throws {
118129
),
119130
"menu should contain update check action"
120131
)
132+
let updateAvailableRows = MenuRowsBuilder.rows(
133+
for: AppState(),
134+
startAtLogin: .make(status: .enabled),
135+
softwareUpdate: SoftwareUpdateViewState(availableVersion: "0.8"),
136+
appVersion: "0.7",
137+
now: now
138+
)
139+
try expect(
140+
updateAvailableRows.prefix(2).contains(
141+
.action(
142+
title: "Update Available: v0.8...",
143+
kind: .checkForUpdates,
144+
keyEquivalent: "",
145+
state: .off
146+
)
147+
),
148+
"available updates should be visible directly below the header"
149+
)
121150
try expect(
122151
rows.contains(
123152
.action(

0 commit comments

Comments
 (0)