diff --git a/Sources/Shotdeck/AppModel.swift b/Sources/Shotdeck/AppModel.swift index 13d67c1..a8afad2 100644 --- a/Sources/Shotdeck/AppModel.swift +++ b/Sources/Shotdeck/AppModel.swift @@ -28,6 +28,7 @@ public final class AppModel { public private(set) var allReturns: [ReturnedDocument] = [] public private(set) var commentedReturns: [ReturnedDocument] = [] public private(set) var statusLine: String? + public private(set) var updateStatus: String? public private(set) var isCapturing: Bool = false public private(set) var isSending: Bool = false public private(set) var outboxDisplayName: String @@ -106,9 +107,7 @@ public final class AppModel { self.updateChecker.onChecked = { [weak self] in guard let self else { return } self.updateAvailable = self.updateChecker.availableUpdate - if let message = self.updateChecker.statusMessage { - self.setStatus(message) - } + self.setUpdateStatus(self.updateChecker.statusMessage) } self.updateChecker.onCheckingChanged = { [weak self] checking in self?.isCheckingForUpdates = checking @@ -119,13 +118,13 @@ public final class AppModel { public var appVersion: String { UpdateChecker.currentVersion() } /// Version recorded in the app-managed rollback copy, when one exists. public var previousVersion: String? { updateChecker.previousVersion() } - /// Most recent status text — shared with the general status line by design - /// (Redline has one status channel, not a separate update-only one). - public var updateStatusMessage: String? { statusLine } + /// Update-related status text (checked time, staged update, errors). Displayed only in the footer. + public var updateStatusMessage: String? { updateStatus } // MARK: Seam mutators — the only way a WP-4b/4c extension changes state. func setStatus(_ text: String?) { statusLine = text } + func setUpdateStatus(_ text: String?) { updateStatus = text } func setSending(_ value: Bool) { isSending = value } func setCapturing(_ value: Bool) { isCapturing = value } func replaceSession(_ new: CaptureSession) { session = new } diff --git a/Sources/Shotdeck/PanelSnapshot.swift b/Sources/Shotdeck/PanelSnapshot.swift index b5a7640..ab43d42 100644 --- a/Sources/Shotdeck/PanelSnapshot.swift +++ b/Sources/Shotdeck/PanelSnapshot.swift @@ -155,6 +155,37 @@ enum PanelSnapshot { model.replaceRegion(sampleRegion()) try await addSampleCaptures(to: model) try renderMenuBar(model: model, to: directory, name: "09-captures-present-onedrive") + + // 10 — update idle: 3 captures, no update available, footer "Redline ", row "Check for updates". + let (updateModel, updateRoot) = try makeIsolatedModel() + defer { try? FileManager.default.removeItem(at: updateRoot) } + updateModel.snapshotSetScreenRecordingGranted(true) + updateModel.replaceRegion(sampleRegion()) + try await addSampleCaptures(to: updateModel) + // No update set, updateChecker in idle state, no previous version + updateModel.snapshotSetPreviousVersion(nil) + try renderMenuBar(model: updateModel, to: directory, name: "10-update-idle") + + // 11 — update checking: same as 10 but isCheckingForUpdates = true. + updateModel.snapshotSetIsCheckingForUpdates(true) + try renderMenuBar(model: updateModel, to: directory, name: "11-update-checking") + updateModel.snapshotSetIsCheckingForUpdates(false) + + // 12 — update up-to-date: footer status line reads "Redline is up to date, checked 10:42 Dubai". + let upToDateMessage = "Redline \(updateModel.appVersion) is up to date, checked 10:42 Dubai" + updateModel.snapshotSetUpdateStatusMessage(upToDateMessage) + try renderMenuBar(model: updateModel, to: directory, name: "12-update-uptodate") + + // 13 — update staged: row "Update to 9.9.9" present, footer status "Update to 9.9.9 is ready". + updateModel.snapshotSetUpdateAvailable(version: "9.9.9", notes: "Test release") + updateModel.snapshotSetUpdateStatusMessage("Update to 9.9.9 is ready") + try renderMenuBar(model: updateModel, to: directory, name: "13-update-staged") + + // 14 — update revert: row "Revert to 0.2.0" present. + updateModel.snapshotSetUpdateAvailable(version: nil, notes: nil) // Clear the staged update + updateModel.snapshotSetUpdateStatusMessage(nil) + updateModel.snapshotSetPreviousVersion("0.2.0") + try renderMenuBar(model: updateModel, to: directory, name: "14-update-revert") } @MainActor @@ -360,6 +391,40 @@ extension AppModel { ) self[keyPath: writable] = granted } + + /// Snapshot-only: set isCheckingForUpdates without triggering a real check. + func snapshotSetIsCheckingForUpdates(_ checking: Bool) { + let writable: ReferenceWritableKeyPath = unsafeBitCast( + \AppModel.isCheckingForUpdates, to: ReferenceWritableKeyPath.self + ) + self[keyPath: writable] = checking + } + + /// Snapshot-only: set updateStatusMessage for panel display. + func snapshotSetUpdateStatusMessage(_ message: String?) { + setUpdateStatus(message) + } + + /// Snapshot-only: set updateAvailable without triggering a real download. + func snapshotSetUpdateAvailable(version: String?, notes: String?) { + if let version = version, let notes = notes { + let writable: ReferenceWritableKeyPath = unsafeBitCast( + \AppModel.updateAvailable, to: ReferenceWritableKeyPath.self + ) + self[keyPath: writable] = (version: version, notes: notes) + } else { + let writable: ReferenceWritableKeyPath = unsafeBitCast( + \AppModel.updateAvailable, to: ReferenceWritableKeyPath.self + ) + self[keyPath: writable] = nil + } + } + + /// Snapshot-only: set a fake previousVersion for the revert panel. + func snapshotSetPreviousVersion(_ version: String?) { + updateChecker.snapshotPreviousVersionOverride = version + updateChecker.snapshotUsesPreviousVersionOverride = true + } } private enum SnapshotError: Error, CustomStringConvertible { diff --git a/Sources/Shotdeck/UpdateChecker.swift b/Sources/Shotdeck/UpdateChecker.swift index 18ff2d0..4ccc003 100644 --- a/Sources/Shotdeck/UpdateChecker.swift +++ b/Sources/Shotdeck/UpdateChecker.swift @@ -2,6 +2,7 @@ import AppKit import CryptoKit import Foundation import Security +import ShotdeckCore /// Built-in updater. Checks an appcast, stages a verified payload, and installs /// only when the user clicks the menu row — never automatically. @@ -31,6 +32,12 @@ final class UpdateChecker { private var repeatingTimer: Timer? private var firstCheckTask: Task? 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 + /// Test seam: override appcast JSON. When set, returns this instead of fetching from URL. + var testAppcastJSON: String? init() { let config = URLSessionConfiguration.ephemeral @@ -85,7 +92,12 @@ final class UpdateChecker { guard Self.isNewer(appcast.version, than: Self.currentVersion()) else { 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?() return } @@ -93,7 +105,7 @@ final class UpdateChecker { do { try await downloadAndStage(appcast) availableUpdate = (version: appcast.version, notes: appcast.notes ?? "") - statusMessage = nil + statusMessage = manual ? "Update to \(appcast.version) is ready" : nil } catch UpdateCheckError.checksumMismatch { discardStaging() availableUpdate = nil @@ -226,8 +238,11 @@ final class UpdateChecker { } /// 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? { + if snapshotUsesPreviousVersionOverride { + return snapshotPreviousVersionOverride + } let previousURL = target.deletingLastPathComponent().appendingPathComponent("Redline.app.previous") let plistURL = previousURL.appendingPathComponent("Contents/Info.plist") guard let plist = NSDictionary(contentsOf: plistURL) as? [String: Any] else { return nil } @@ -346,7 +361,12 @@ final class UpdateChecker { } private func fetchAppcast() async throws -> Appcast { - let data = try await fetchData(from: Self.resolvedAppcastURL()) + let data: Data + if let testJSON = testAppcastJSON { + data = testJSON.data(using: .utf8) ?? Data() + } else { + data = try await fetchData(from: Self.resolvedAppcastURL()) + } return try JSONDecoder().decode(Appcast.self, from: data) } diff --git a/Sources/ShotdeckCore/Support/DubaiTime.swift b/Sources/ShotdeckCore/Support/DubaiTime.swift index 77313ac..2923f1f 100644 --- a/Sources/ShotdeckCore/Support/DubaiTime.swift +++ b/Sources/ShotdeckCore/Support/DubaiTime.swift @@ -3,6 +3,7 @@ import Foundation public enum DubaiTime { private static let stampFormatter = LockedDateFormatter(dateFormat: "d MMM yyyy, HH:mm 'Dubai'") private static let fileStampFormatter = LockedDateFormatter(dateFormat: "yyyyMMdd-HHmmss") + private static let checkTimeFormatter = LockedDateFormatter(dateFormat: "HH:mm 'Dubai'") public static func stamp(_ date: Date) -> String { stampFormatter.string(from: date) @@ -11,6 +12,10 @@ public enum DubaiTime { public static func fileStamp(_ date: Date) -> String { fileStampFormatter.string(from: date) } + + public static func checkTime(_ date: Date) -> String { + checkTimeFormatter.string(from: date) + } } /// DateFormatter is not Sendable. This holder is the only shared mutable state diff --git a/Tests/ShotdeckTests/UpdateCheckerTests.swift b/Tests/ShotdeckTests/UpdateCheckerTests.swift new file mode 100644 index 0000000..7b31ed5 --- /dev/null +++ b/Tests/ShotdeckTests/UpdateCheckerTests.swift @@ -0,0 +1,118 @@ +import Foundation +import Testing +@testable import Shotdeck +@testable import ShotdeckCore + +@Test("DubaiTime.checkTime formats as HH:MM Dubai") +func dubaiTimeCheckTimeFormat() { + let now = Date() + let result = DubaiTime.checkTime(now) + + // Dubai timezone format: HH:MM Dubai + let pattern = "^[0-9]{2}:[0-9]{2} Dubai$" + let regex = try? NSRegularExpression(pattern: pattern, options: []) + let range = NSRange(result.startIndex..