feat(update-checker): give manual checks visible, timestamped feedback

- Manual check finds no newer version: status message becomes "Redline X.Y.Z is up to date, checked HH:MM Dubai"
- Manual check stages a newer version: status message becomes "Update to X.Y.Z is ready"
- Automatic (scheduled) checks keep original silent behaviour when up to date
- Add snapshot-only seams for testing: snapshotPreviousVersionOverride and snapshotUsesPreviousVersionOverride

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012ZiTXPbPCSjzPVsfoweAbp
This commit is contained in:
Claude Fable 5
2026-09-05 11:01:20 +04:00
parent a0ff61ae14
commit 7cdc3e7652
+16 -3
View File
@@ -2,6 +2,7 @@ import AppKit
import CryptoKit import CryptoKit
import Foundation import Foundation
import Security import Security
import ShotdeckCore
/// Built-in updater. Checks an appcast, stages a verified payload, and installs /// Built-in updater. Checks an appcast, stages a verified payload, and installs
/// only when the user clicks the menu row never automatically. /// only when the user clicks the menu row never automatically.
@@ -31,6 +32,10 @@ final class UpdateChecker {
private var repeatingTimer: Timer? private var repeatingTimer: Timer?
private var firstCheckTask: Task<Void, Never>? private var firstCheckTask: Task<Void, Never>?
private var stagingDirectory: URL? private var stagingDirectory: URL?
/// Snapshot-only override for previousVersion; when snapshotUsesPreviousVersionOverride is true,
/// this value (including nil) is returned instead of checking the file system.
var snapshotPreviousVersionOverride: String?
var snapshotUsesPreviousVersionOverride: Bool = false
init() { init() {
let config = URLSessionConfiguration.ephemeral let config = URLSessionConfiguration.ephemeral
@@ -85,7 +90,12 @@ final class UpdateChecker {
guard Self.isNewer(appcast.version, than: Self.currentVersion()) else { guard Self.isNewer(appcast.version, than: Self.currentVersion()) else {
clearOffer() clearOffer()
statusMessage = manual ? "Redline \(Self.currentVersion()) is up to date." : nil if manual {
let timestamp = DubaiTime.checkTime(lastCheckedAt ?? Date())
statusMessage = "Redline \(Self.currentVersion()) is up to date, checked \(timestamp)"
} else {
statusMessage = nil
}
onChecked?() onChecked?()
return return
} }
@@ -93,7 +103,7 @@ final class UpdateChecker {
do { do {
try await downloadAndStage(appcast) try await downloadAndStage(appcast)
availableUpdate = (version: appcast.version, notes: appcast.notes ?? "") availableUpdate = (version: appcast.version, notes: appcast.notes ?? "")
statusMessage = nil statusMessage = manual ? "Update to \(appcast.version) is ready" : nil
} catch UpdateCheckError.checksumMismatch { } catch UpdateCheckError.checksumMismatch {
discardStaging() discardStaging()
availableUpdate = nil availableUpdate = nil
@@ -226,8 +236,11 @@ final class UpdateChecker {
} }
/// The version recorded in `Redline.app.previous`'s Info.plist, or nil when no /// The version recorded in `Redline.app.previous`'s Info.plist, or nil when no
/// rollback copy exists. /// rollback copy exists. Respects the snapshot-only override for panel rendering.
func previousVersion(target: URL = UpdateChecker.defaultInstallTarget) -> String? { func previousVersion(target: URL = UpdateChecker.defaultInstallTarget) -> String? {
if snapshotUsesPreviousVersionOverride {
return snapshotPreviousVersionOverride
}
let previousURL = target.deletingLastPathComponent().appendingPathComponent("Redline.app.previous") let previousURL = target.deletingLastPathComponent().appendingPathComponent("Redline.app.previous")
let plistURL = previousURL.appendingPathComponent("Contents/Info.plist") let plistURL = previousURL.appendingPathComponent("Contents/Info.plist")
guard let plist = NSDictionary(contentsOf: plistURL) as? [String: Any] else { return nil } guard let plist = NSDictionary(contentsOf: plistURL) as? [String: Any] else { return nil }