Redline: timestamped result for the manual update check, update-state renders, and a duplicated-status fix (MMDB-2697) #27

Merged
kua-agent merged 6 commits from feat/check-updates-feedback-20260905 into main 2026-09-05 07:15:15 +00:00
5 changed files with 217 additions and 10 deletions
+5 -6
View File
@@ -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 }
+65
View File
@@ -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 <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
@@ -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<AppModel, Bool> = unsafeBitCast(
\AppModel.isCheckingForUpdates, to: ReferenceWritableKeyPath<AppModel, Bool>.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<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 {
+24 -4
View File
@@ -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<Void, Never>?
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)
}
@@ -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
@@ -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..<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: up-to-date manual check includes Dubai timestamp")
@MainActor
func manualCheckUpToDateIncludesTimestamp() async {
let checker = UpdateChecker()
// Inject a stub appcast showing the current version (no update available)
let currentVersion = UpdateChecker.currentVersion()
let stubAppcast = """
{
"version": "\(currentVersion)",
"zipURL": "https://example.com/dummy.zip",
"sha256": "0000000000000000000000000000000000000000000000000000000000000000"
}
"""
checker.testAppcastJSON = stubAppcast
// Capture the status message
var capturedStatus: String?
checker.onChecked = {
capturedStatus = checker.statusMessage
}
// Run the manual check
await checker.checkNow(manual: true)
// Verify the message matches the expected format and includes a timestamp
guard let status = capturedStatus else {
#expect(false, "statusMessage should not be nil for manual check finding no update")
return
}
// Message should be "Redline X.Y.Z is up to date, checked HH:MM Dubai"
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(status.startIndex..<status.endIndex, in: status)
let matches = regex?.matches(in: status, options: [], range: range) ?? []
#expect(!matches.isEmpty, "Manual check up-to-date message should match format, got: \(status)")
}
@Test("Status message: automatic check doesn't set message when up-to-date")
@MainActor
func automaticCheckUpToDateLeavesMessageNil() async {
let checker = UpdateChecker()
// Inject a stub appcast showing the current version (no update available)
let currentVersion = UpdateChecker.currentVersion()
let stubAppcast = """
{
"version": "\(currentVersion)",
"zipURL": "https://example.com/dummy.zip",
"sha256": "0000000000000000000000000000000000000000000000000000000000000000"
}
"""
checker.testAppcastJSON = stubAppcast
// Capture the status message
var capturedStatus: String?
checker.onChecked = {
capturedStatus = checker.statusMessage
}
// Run an AUTOMATIC check (manual: false)
await checker.checkNow(manual: false)
// For automatic checks finding no update, statusMessage should be nil
#expect(capturedStatus == nil,
"Automatic check finding no update should leave statusMessage nil, got: \(capturedStatus ?? "(nil)")")
}
@Test("Update status and general status are independent channels")
@MainActor
func statusChannelsAreIndependent() async throws {
let fm = FileManager.default
let appSupportRoot = fm.temporaryDirectory
.appendingPathComponent("update-checker-channel-test-\(UUID().uuidString)", isDirectory: true)
defer { try? fm.removeItem(at: appSupportRoot) }
// Create a real AppModel using the standard launch pattern
let model = AppDelegate.makeLaunchModel(appSupportRoot: appSupportRoot)
defer { model.hotkeys.unregisterAll() }
// Test 1: Setting statusLine should NOT affect updateStatusMessage
model.setStatus("General status: captured 3")
#expect(model.statusLine == "General status: captured 3", "statusLine should be set")
#expect(model.updateStatusMessage == nil, "updateStatusMessage should remain nil")
// Test 2: Setting updateStatus should NOT affect statusLine
model.setUpdateStatus("Update to 9.9.9 is ready")
#expect(model.statusLine == "General status: captured 3", "statusLine should remain unchanged")
#expect(model.updateStatusMessage == "Update to 9.9.9 is ready", "updateStatusMessage should be set")
// Test 3: Clearing statusLine leaves updateStatus intact
model.setStatus(nil)
#expect(model.statusLine == nil, "statusLine should be cleared")
#expect(model.updateStatusMessage == "Update to 9.9.9 is ready", "updateStatusMessage should persist")
// Test 4: Clearing updateStatus leaves other state unaffected
model.setUpdateStatus(nil)
#expect(model.updateStatusMessage == nil, "updateStatusMessage should be cleared")
}