Compare commits

..
7 Commits
Author SHA1 Message Date
kua-agent 821e113f34 Merge pull request 'Redline: timestamped result for the manual update check, update-state renders, and a duplicated-status fix (MMDB-2697)' (#27) from feat/check-updates-feedback-20260905 into main 2026-09-05 07:15:15 +00:00
Claude Fable 5 942e848dde test(update-checker): rewrite tests to exercise real production code
REPLACED: three worthless tests that only tested test code, not production:
- statusMessageUpToDateFormat built the expected string locally and matched it
- statusMessageUpdateReadyFormat same self-referential test
- statusChannelsAreIndependent was literally #expect(true, ...)

ADDED: four real tests that drive production code:
- dubaiTimeCheckTimeFormat: assert DubaiTime.checkTime() formats as HH:MM Dubai
- manualCheckUpToDateIncludesTimestamp: inject stub appcast via testAppcastJSON seam,
  drive UpdateChecker.checkNow(manual: true), verify statusMessage matches exact format
- automaticCheckUpToDateLeavesMessageNil: verify automatic check (manual: false) leaves
  statusMessage nil when up-to-date
- statusChannelsAreIndependent: construct real AppModel via AppDelegate.makeLaunchModel(),
  assert setStatus() does NOT affect updateStatusMessage, setUpdateStatus() does NOT
  affect statusLine, and vice versa. PROVES the defect is caught: test fails with 3 issues
  if updateStatusMessage is reverted to an alias of statusLine.

ADDED: testAppcastJSON seam to UpdateChecker for test injection of appcast data.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012ZiTXPbPCSjzPVsfoweAbp
2026-09-05 11:13:55 +04:00
Claude Fable 5 905a019823 fix(app-model): separate status channels — update messages no longer hide capture summary
Root cause: updateStatusMessage was an alias to statusLine, so update feedback appeared
both in the header (where capture summary belongs) and footer (intended). Both MenuBarView
header and footer rendered the same value, creating duplication and information loss.

Fix: introduce updateStatus property separate from statusLine. Route UpdateChecker.statusMessage
into setUpdateStatus(), not setStatus(). Header now shows capture summary uninterrupted;
footer-only shows update feedback. Both channels now independent.

- Add public updateStatus property to AppModel
- Add setUpdateStatus() mutator
- Change updateStatusMessage property to return updateStatus instead of statusLine
- Route onChecked callback to setUpdateStatus, not setStatus
- Update PanelSnapshot helper to use setUpdateStatus
- Add test asserting channel independence

Panel-12 and panel-13 now render correctly: capture summary in header, update status only
in footer; no duplication or information loss.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012ZiTXPbPCSjzPVsfoweAbp
2026-09-05 11:05:14 +04:00
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
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")
}