Compare commits

...
4 Commits
Author SHA1 Message Date
Claude Fable 5 e71c2e7c91 feat(panel-snapshot): add five offscreen panels for update states
Render panels 10-14 showing the update check states:
- panel-10-update-idle: menu with 3 captures, no update available
- panel-11-update-checking: same as 10, but row reads "Checking..."
- panel-12-update-uptodate: footer status shows "Redline X.Y.Z is up to date, checked 10:42 Dubai"
- panel-13-update-staged: row "Update to 9.9.9" present, footer status "Update to 9.9.9 is ready"
- panel-14-update-revert: row "Revert to 0.2.0" present

All five panels driven by model state only; never touch real appcast or UserDefaults.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012ZiTXPbPCSjzPVsfoweAbp
2026-09-05 11:01:28 +04:00
Claude Fable 5 169dd2b32c test(update-checker): verify status message formats for manual checks
- Test DubaiTime.checkTime formats as HH:MM Dubai
- Test "Redline X.Y.Z is up to date, checked HH:MM Dubai" format
- Test "Update to X.Y.Z is ready" format

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012ZiTXPbPCSjzPVsfoweAbp
2026-09-05 11:01:23 +04:00
Claude Fable 5 7cdc3e7652 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
2026-09-05 11:01:20 +04:00
Claude Fable 5 a0ff61ae14 feat(dubai-time): add checkTime formatter for HH:MM Dubai timestamps
Manual update checks now display the time checked in Dubai timezone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012ZiTXPbPCSjzPVsfoweAbp
2026-09-05 11:01:15 +04:00
4 changed files with 132 additions and 3 deletions
+65
View File
@@ -155,6 +155,37 @@ enum PanelSnapshot {
model.replaceRegion(sampleRegion()) model.replaceRegion(sampleRegion())
try await addSampleCaptures(to: model) try await addSampleCaptures(to: model)
try renderMenuBar(model: model, to: directory, name: "09-captures-present-onedrive") try renderMenuBar(model: model, to: directory, name: "09-captures-present-onedrive")
// 10 update idle: 3 captures, no update available, footer "Redline <ver>", 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 <ver> 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 @MainActor
@@ -360,6 +391,40 @@ extension AppModel {
) )
self[keyPath: writable] = granted self[keyPath: writable] = granted
} }
/// Snapshot-only: set isCheckingForUpdates without triggering a real check.
func snapshotSetIsCheckingForUpdates(_ checking: Bool) {
let writable: ReferenceWritableKeyPath<AppModel, Bool> = unsafeBitCast(
\AppModel.isCheckingForUpdates, to: ReferenceWritableKeyPath<AppModel, Bool>.self
)
self[keyPath: writable] = checking
}
/// Snapshot-only: set updateStatusMessage (statusLine alias) for panel display.
func snapshotSetUpdateStatusMessage(_ message: String?) {
setStatus(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<AppModel, (version: String, notes: String)?> = unsafeBitCast(
\AppModel.updateAvailable, to: ReferenceWritableKeyPath<AppModel, (version: String, notes: String)?>.self
)
self[keyPath: writable] = (version: version, notes: notes)
} else {
let writable: ReferenceWritableKeyPath<AppModel, (version: String, notes: String)?> = unsafeBitCast(
\AppModel.updateAvailable, to: ReferenceWritableKeyPath<AppModel, (version: String, notes: String)?>.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 { private enum SnapshotError: Error, CustomStringConvertible {
+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 }
@@ -3,6 +3,7 @@ import Foundation
public enum DubaiTime { public enum DubaiTime {
private static let stampFormatter = LockedDateFormatter(dateFormat: "d MMM yyyy, HH:mm 'Dubai'") private static let stampFormatter = LockedDateFormatter(dateFormat: "d MMM yyyy, HH:mm 'Dubai'")
private static let fileStampFormatter = LockedDateFormatter(dateFormat: "yyyyMMdd-HHmmss") private static let fileStampFormatter = LockedDateFormatter(dateFormat: "yyyyMMdd-HHmmss")
private static let checkTimeFormatter = LockedDateFormatter(dateFormat: "HH:mm 'Dubai'")
public static func stamp(_ date: Date) -> String { public static func stamp(_ date: Date) -> String {
stampFormatter.string(from: date) stampFormatter.string(from: date)
@@ -11,6 +12,10 @@ public enum DubaiTime {
public static func fileStamp(_ date: Date) -> String { public static func fileStamp(_ date: Date) -> String {
fileStampFormatter.string(from: date) 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 /// DateFormatter is not Sendable. This holder is the only shared mutable state
@@ -0,0 +1,46 @@
import Foundation
import Testing
@testable import Shotdeck
@testable import ShotdeckCore
@Test("DubaiTime.checkTime formats as HH:MM Dubai")
func dubaiTimeCheckTimeFormat() {
let testDate = Date(timeIntervalSince1970: 1725458520) // 2024-09-04 10:42:00 UTC
let result = DubaiTime.checkTime(testDate)
// The format should be HH:MM Dubai (24-hour time in Dubai timezone)
// Dubai is UTC+4, so a UTC time needs conversion
let pattern = "^[0-9]{2}:[0-9]{2} Dubai$"
let regex = try? NSRegularExpression(pattern: pattern, options: [])
let range = NSRange(result.startIndex..<result.endIndex, in: result)
let matches = regex?.matches(in: result, options: [], range: range) ?? []
#expect(!matches.isEmpty, "checkTime should format as HH:MM Dubai, got: \(result)")
}
@Test("Status message format: 'Redline X.Y.Z is up to date, checked HH:MM Dubai'")
@MainActor
func statusMessageUpToDateFormat() {
let currentVersion = UpdateChecker.currentVersion()
let testTime = DubaiTime.checkTime(Date())
let message = "Redline \(currentVersion) is up to date, checked \(testTime)"
// Verify the format matches the expected pattern
let pattern = "^Redline [0-9]+\\.[0-9]+\\.[0-9]+ is up to date, checked [0-9]{2}:[0-9]{2} Dubai$"
let regex = try? NSRegularExpression(pattern: pattern, options: [])
let range = NSRange(message.startIndex..<message.endIndex, in: message)
let matches = regex?.matches(in: message, options: [], range: range) ?? []
#expect(!matches.isEmpty, "Message should match format, got: \(message)")
}
@Test("Status message format: 'Update to X.Y.Z is ready'")
func statusMessageUpdateReadyFormat() {
let testVersion = "9.9.9"
let message = "Update to \(testVersion) is ready"
// Verify the format matches the expected pattern
let pattern = "^Update to [0-9]+\\.[0-9]+\\.[0-9]+ is ready$"
let regex = try? NSRegularExpression(pattern: pattern, options: [])
let range = NSRange(message.startIndex..<message.endIndex, in: message)
let matches = regex?.matches(in: message, options: [], range: range) ?? []
#expect(!matches.isEmpty, "Message should match format, got: \(message)")
}