Compare commits
4
Commits
c3cae010a4
...
e71c2e7c91
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e71c2e7c91 | ||
|
|
169dd2b32c | ||
|
|
7cdc3e7652 | ||
|
|
a0ff61ae14 |
@@ -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 {
|
||||||
|
|||||||
@@ -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)")
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user