From 430838abef1322faf0a06af33a86f1a9d23ed088 Mon Sep 17 00:00:00 2001 From: StevenACZ <51928039+StevenACZ@users.noreply.github.com> Date: Fri, 18 Sep 2026 10:53:19 -0500 Subject: [PATCH 1/6] fix(updates): retry stops at ready to install from every stage --- CHANGELOG.md | 5 ++ Sources/EncajeApp/UI/AboutView.swift | 2 +- Sources/EncajeApp/UI/UpdateViews.swift | 4 +- Sources/EncajeApp/UpdateManager.swift | 30 +++++++- Tests/EncajeAppTests/UpdateManagerTests.swift | 73 +++++++++++++++++++ 5 files changed, 110 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e23948..05b9cee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,11 @@ All notable changes to this project are documented here. ## [Unreleased] +### Fixed + +- Retry after a failed update now downloads again and stops at "Ready to install", + asking before it installs. + ## [1.1.0] - 2026-09-18 ### Added diff --git a/Sources/EncajeApp/UI/AboutView.swift b/Sources/EncajeApp/UI/AboutView.swift index c75523a..7db9cfe 100644 --- a/Sources/EncajeApp/UI/AboutView.swift +++ b/Sources/EncajeApp/UI/AboutView.swift @@ -126,7 +126,7 @@ struct AboutView: View { progressCapsule(text: localized("Installing and restarting…", "Instalando y reiniciando…")) case .failed: Button { - updates.installNow() + updates.retryPendingUpdate() } label: { capsule( symbol: "exclamationmark.arrow.circlepath", diff --git a/Sources/EncajeApp/UI/UpdateViews.swift b/Sources/EncajeApp/UI/UpdateViews.swift index 11700bc..d89248d 100644 --- a/Sources/EncajeApp/UI/UpdateViews.swift +++ b/Sources/EncajeApp/UI/UpdateViews.swift @@ -98,7 +98,7 @@ struct UpdateActionView: View { systemImage: "arrow.triangle.2.circlepath") case .failed: Button(localized("Update failed. Retry", "La actualización falló. Reintentar")) { - updates.installNow() + updates.retryPendingUpdate() } } }.font(.callout).frame(maxWidth: .infinity, alignment: .leading) @@ -216,7 +216,7 @@ struct UpdateCardView: View { case .installing: ProgressView().progressViewStyle(.linear).tint(.teal) case .failed: - prominentButton(localized("Retry", "Reintentar")) { updates.installNow() } + prominentButton(localized("Retry", "Reintentar")) { updates.retryPendingUpdate() } } } diff --git a/Sources/EncajeApp/UpdateManager.swift b/Sources/EncajeApp/UpdateManager.swift index 1bd5c18..376854b 100644 --- a/Sources/EncajeApp/UpdateManager.swift +++ b/Sources/EncajeApp/UpdateManager.swift @@ -48,6 +48,7 @@ final class UpdateManager: ObservableObject { private(set) var installRequested = false private(set) var installNowRequested = false + private(set) var retryRequested = false private(set) var resumeCheckPending = false private var pendingInstallReply: ((SPUUserUpdateChoice) -> Void)? private var pendingIsInformationOnly = false @@ -112,6 +113,7 @@ final class UpdateManager: ObservableObject { func installNow() { guard phase != .installing else { return } + retryRequested = false if let reply = pendingInstallReply { pendingInstallReply = nil installRequested = true @@ -123,10 +125,17 @@ final class UpdateManager: ObservableObject { handleInstallNowRequested() } + func retryPendingUpdate() { + guard phase != .installing else { return } + guard updater != nil else { return } + handleRetryRequested() + } + func installLater() { guard let reply = pendingInstallReply else { return } pendingInstallReply = nil installRequested = false + retryRequested = false reply(.dismiss) } @@ -156,6 +165,15 @@ final class UpdateManager: ObservableObject { startInstallNowCheck(attempt: 0) } + func handleRetryRequested() { + installRequested = true + installNowRequested = false + retryRequested = true + resumeCheckPending = true + phase = .downloading(fraction: nil) + startInstallNowCheck(attempt: 0) + } + func startInstallNowCheck(attempt: Int) { guard resumeCheckPending else { return } if let updater, updater.sessionInProgress == false { @@ -166,6 +184,7 @@ final class UpdateManager: ObservableObject { guard attempt < Self.installNowCheckRetryLimit else { installRequested = false installNowRequested = false + retryRequested = false resumeCheckPending = false phase = .failed(version: pendingVersion ?? "") return @@ -190,12 +209,17 @@ final class UpdateManager: ObservableObject { finishManualCheck(status: .idle) let prepared = stage != .notDownloaded - if !informationOnly, installRequested || (prepared && installNowRequested) { + if !informationOnly, retryRequested, !prepared { + phase = .downloading(fraction: nil) + return .install + } + if !informationOnly, !retryRequested, installRequested || (prepared && installNowRequested) { phase = prepared ? .installing : .downloading(fraction: nil) return .install } installRequested = false installNowRequested = false + retryRequested = false phase = prepared ? .readyToInstall(version: version) : .available(version: version) return .dismiss } @@ -229,6 +253,7 @@ final class UpdateManager: ObservableObject { reply(.install) return } + retryRequested = false pendingInstallReply = reply phase = .readyToInstall(version: pendingVersion ?? "") } @@ -244,6 +269,7 @@ final class UpdateManager: ObservableObject { } installRequested = false installNowRequested = false + retryRequested = false pendingInstallReply = nil pendingVersion = nil pendingIsInformationOnly = false @@ -272,6 +298,7 @@ final class UpdateManager: ObservableObject { } installRequested = false installNowRequested = false + retryRequested = false pendingInstallReply = nil } @@ -282,6 +309,7 @@ final class UpdateManager: ObservableObject { } installRequested = false installNowRequested = false + retryRequested = false pendingInstallReply = nil switch phase { case .installing, .readyToInstall: diff --git a/Tests/EncajeAppTests/UpdateManagerTests.swift b/Tests/EncajeAppTests/UpdateManagerTests.swift index f137107..38b0f2a 100644 --- a/Tests/EncajeAppTests/UpdateManagerTests.swift +++ b/Tests/EncajeAppTests/UpdateManagerTests.swift @@ -470,4 +470,77 @@ final class UpdateManagerTests: XCTestCase { XCTAssertFalse(manager.installNowRequested) } + func testRetryOnANotDownloadedStageStopsAtTheReadyCard() { + let manager = makeManager() + _ = manager.handleUpdateFound( + version: "9.9.9", releasePage: nil, informationOnly: false, stage: .notDownloaded) + manager.handleRetryRequested() + + let choice = manager.handleUpdateFound( + version: "9.9.9", releasePage: nil, informationOnly: false, stage: .notDownloaded) + var choices: [SPUUserUpdateChoice] = [] + manager.handleReadyToInstall { choices.append($0) } + + XCTAssertEqual(choice, .install) + XCTAssertTrue(choices.isEmpty) + XCTAssertEqual(manager.phase, .readyToInstall(version: "9.9.9")) + XCTAssertFalse(manager.installNowRequested) + } + + func testRetryOnADownloadedStageStopsAtTheReadyCard() { + let manager = makeManager() + _ = manager.handleUpdateFound( + version: "9.9.9", releasePage: nil, informationOnly: false, stage: .downloaded) + manager.handleRetryRequested() + + let choice = manager.handleUpdateFound( + version: "9.9.9", releasePage: nil, informationOnly: false, stage: .downloaded) + var choices: [SPUUserUpdateChoice] = [] + manager.handleReadyToInstall { choices.append($0) } + + XCTAssertEqual(choice, .dismiss) + XCTAssertTrue(choices.isEmpty) + XCTAssertEqual(manager.phase, .readyToInstall(version: "9.9.9")) + XCTAssertFalse(manager.retryRequested) + XCTAssertFalse(manager.installNowRequested) + } + + func testRetryOnAnInstallingStageStopsAtTheReadyCard() { + let manager = makeManager() + _ = manager.handleUpdateFound( + version: "9.9.9", releasePage: nil, informationOnly: false, stage: .installing) + manager.handleRetryRequested() + + let choice = manager.handleUpdateFound( + version: "9.9.9", releasePage: nil, informationOnly: false, stage: .installing) + var choices: [SPUUserUpdateChoice] = [] + manager.handleReadyToInstall { choices.append($0) } + + XCTAssertEqual(choice, .dismiss) + XCTAssertTrue(choices.isEmpty) + XCTAssertEqual(manager.phase, .readyToInstall(version: "9.9.9")) + XCTAssertFalse(manager.retryRequested) + XCTAssertFalse(manager.installNowRequested) + } + + func testRetryThenInstallNowStillInstalls() { + let manager = makeManager() + _ = manager.handleUpdateFound( + version: "9.9.9", releasePage: nil, informationOnly: false, stage: .notDownloaded) + manager.handleRetryRequested() + XCTAssertEqual( + manager.handleUpdateFound( + version: "9.9.9", releasePage: nil, informationOnly: false, stage: .downloaded), + .dismiss) + XCTAssertEqual(manager.phase, .readyToInstall(version: "9.9.9")) + + manager.handleInstallNowRequested() + let choice = manager.handleUpdateFound( + version: "9.9.9", releasePage: nil, informationOnly: false, stage: .downloaded) + + XCTAssertEqual(choice, .install) + XCTAssertEqual(manager.phase, .installing) + XCTAssertFalse(manager.retryRequested) + } + } From 3e0e0218f7d5d2253b3b44aa44f62be37e4b81b5 Mon Sep 17 00:00:00 2001 From: StevenACZ <51928039+StevenACZ@users.noreply.github.com> Date: Fri, 18 Sep 2026 11:18:12 -0500 Subject: [PATCH 2/6] fix(updates): a prepared update installs only on Install now, ready card survives the resume watchdog --- CHANGELOG.md | 3 +- Sources/EncajeApp/UpdateManager.swift | 34 ++++----- Tests/EncajeAppTests/UpdateManagerTests.swift | 75 +++++++++++++++++-- 3 files changed, 85 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 05b9cee..927c08b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,8 +6,7 @@ All notable changes to this project are documented here. ### Fixed -- Retry after a failed update now downloads again and stops at "Ready to install", - asking before it installs. +- Retry and Update never install on their own: a prepared update always stops at "Ready to install" and waits for "Install now". ## [1.1.0] - 2026-09-18 diff --git a/Sources/EncajeApp/UpdateManager.swift b/Sources/EncajeApp/UpdateManager.swift index 376854b..8d02cf8 100644 --- a/Sources/EncajeApp/UpdateManager.swift +++ b/Sources/EncajeApp/UpdateManager.swift @@ -48,8 +48,11 @@ final class UpdateManager: ObservableObject { private(set) var installRequested = false private(set) var installNowRequested = false - private(set) var retryRequested = false private(set) var resumeCheckPending = false + var hasLiveUpdater: @MainActor (UpdateManager) -> Bool = { $0.updater != nil } + var resumeCheckStarter: @MainActor (UpdateManager) -> Void = { + $0.startInstallNowCheck(attempt: 0) + } private var pendingInstallReply: ((SPUUserUpdateChoice) -> Void)? private var pendingIsInformationOnly = false private var expectedDownloadBytes: UInt64 = 0 @@ -113,7 +116,6 @@ final class UpdateManager: ObservableObject { func installNow() { guard phase != .installing else { return } - retryRequested = false if let reply = pendingInstallReply { pendingInstallReply = nil installRequested = true @@ -121,13 +123,17 @@ final class UpdateManager: ObservableObject { reply(.install) return } - guard updater != nil else { return } + guard hasLiveUpdater(self) else { return } handleInstallNowRequested() } func retryPendingUpdate() { guard phase != .installing else { return } - guard updater != nil else { return } + if pendingInstallReply != nil { + phase = .readyToInstall(version: pendingVersion ?? "") + return + } + guard hasLiveUpdater(self) else { return } handleRetryRequested() } @@ -135,7 +141,6 @@ final class UpdateManager: ObservableObject { guard let reply = pendingInstallReply else { return } pendingInstallReply = nil installRequested = false - retryRequested = false reply(.dismiss) } @@ -162,16 +167,15 @@ final class UpdateManager: ObservableObject { installNowRequested = true resumeCheckPending = true phase = .installing - startInstallNowCheck(attempt: 0) + resumeCheckStarter(self) } func handleRetryRequested() { installRequested = true installNowRequested = false - retryRequested = true resumeCheckPending = true phase = .downloading(fraction: nil) - startInstallNowCheck(attempt: 0) + resumeCheckStarter(self) } func startInstallNowCheck(attempt: Int) { @@ -184,7 +188,6 @@ final class UpdateManager: ObservableObject { guard attempt < Self.installNowCheckRetryLimit else { installRequested = false installNowRequested = false - retryRequested = false resumeCheckPending = false phase = .failed(version: pendingVersion ?? "") return @@ -209,17 +212,12 @@ final class UpdateManager: ObservableObject { finishManualCheck(status: .idle) let prepared = stage != .notDownloaded - if !informationOnly, retryRequested, !prepared { - phase = .downloading(fraction: nil) - return .install - } - if !informationOnly, !retryRequested, installRequested || (prepared && installNowRequested) { + if !informationOnly, prepared ? installNowRequested : installRequested { phase = prepared ? .installing : .downloading(fraction: nil) return .install } installRequested = false installNowRequested = false - retryRequested = false phase = prepared ? .readyToInstall(version: version) : .available(version: version) return .dismiss } @@ -246,14 +244,13 @@ final class UpdateManager: ObservableObject { } func handleReadyToInstall(reply: @escaping (SPUUserUpdateChoice) -> Void) { + resumeCheckPending = false if installNowRequested { installNowRequested = false - resumeCheckPending = false phase = .installing reply(.install) return } - retryRequested = false pendingInstallReply = reply phase = .readyToInstall(version: pendingVersion ?? "") } @@ -269,7 +266,6 @@ final class UpdateManager: ObservableObject { } installRequested = false installNowRequested = false - retryRequested = false pendingInstallReply = nil pendingVersion = nil pendingIsInformationOnly = false @@ -298,7 +294,6 @@ final class UpdateManager: ObservableObject { } installRequested = false installNowRequested = false - retryRequested = false pendingInstallReply = nil } @@ -309,7 +304,6 @@ final class UpdateManager: ObservableObject { } installRequested = false installNowRequested = false - retryRequested = false pendingInstallReply = nil switch phase { case .installing, .readyToInstall: diff --git a/Tests/EncajeAppTests/UpdateManagerTests.swift b/Tests/EncajeAppTests/UpdateManagerTests.swift index 38b0f2a..464e868 100644 --- a/Tests/EncajeAppTests/UpdateManagerTests.swift +++ b/Tests/EncajeAppTests/UpdateManagerTests.swift @@ -501,7 +501,6 @@ final class UpdateManagerTests: XCTestCase { XCTAssertEqual(choice, .dismiss) XCTAssertTrue(choices.isEmpty) XCTAssertEqual(manager.phase, .readyToInstall(version: "9.9.9")) - XCTAssertFalse(manager.retryRequested) XCTAssertFalse(manager.installNowRequested) } @@ -519,28 +518,94 @@ final class UpdateManagerTests: XCTestCase { XCTAssertEqual(choice, .dismiss) XCTAssertTrue(choices.isEmpty) XCTAssertEqual(manager.phase, .readyToInstall(version: "9.9.9")) - XCTAssertFalse(manager.retryRequested) XCTAssertFalse(manager.installNowRequested) } + func testRetryButtonArmsACheckWithoutTheInstallNowIntent() { + let manager = makeManager() + var checks = 0 + manager.hasLiveUpdater = { _ in true } + manager.resumeCheckStarter = { _ in checks += 1 } + _ = manager.handleUpdateFound( + version: "9.9.9", releasePage: nil, informationOnly: false, stage: .notDownloaded) + + manager.retryPendingUpdate() + + XCTAssertEqual(checks, 1) + XCTAssertTrue(manager.installRequested) + XCTAssertFalse(manager.installNowRequested) + XCTAssertTrue(manager.resumeCheckPending) + XCTAssertEqual(manager.phase, .downloading(fraction: nil)) + } + + func testRetryButtonWithAHeldReplyKeepsTheCardAndTheReply() { + let manager = makeManager() + var checks = 0 + manager.hasLiveUpdater = { _ in true } + manager.resumeCheckStarter = { _ in checks += 1 } + _ = manager.handleUpdateFound( + version: "9.9.9", releasePage: nil, informationOnly: false, stage: .notDownloaded) + var choices: [SPUUserUpdateChoice] = [] + manager.handleReadyToInstall { choices.append($0) } + + manager.retryPendingUpdate() + + XCTAssertEqual(checks, 0) + XCTAssertTrue(choices.isEmpty) + XCTAssertEqual(manager.phase, .readyToInstall(version: "9.9.9")) + + manager.installNow() + + XCTAssertEqual(choices, [.install]) + XCTAssertEqual(manager.phase, .installing) + } + func testRetryThenInstallNowStillInstalls() { let manager = makeManager() + var checks = 0 + manager.hasLiveUpdater = { _ in true } + manager.resumeCheckStarter = { _ in checks += 1 } _ = manager.handleUpdateFound( version: "9.9.9", releasePage: nil, informationOnly: false, stage: .notDownloaded) - manager.handleRetryRequested() + + manager.retryPendingUpdate() XCTAssertEqual( manager.handleUpdateFound( version: "9.9.9", releasePage: nil, informationOnly: false, stage: .downloaded), .dismiss) XCTAssertEqual(manager.phase, .readyToInstall(version: "9.9.9")) + manager.installLater() + XCTAssertEqual(manager.phase, .readyToInstall(version: "9.9.9")) - manager.handleInstallNowRequested() + manager.installNow() let choice = manager.handleUpdateFound( version: "9.9.9", releasePage: nil, informationOnly: false, stage: .downloaded) + XCTAssertEqual(checks, 2) XCTAssertEqual(choice, .install) XCTAssertEqual(manager.phase, .installing) - XCTAssertFalse(manager.retryRequested) + } + + func testReadyDuringAResumeCheckKeepsTheCardAndInstallsOnce() { + let manager = makeManager() + manager.resumeCheckStarter = { _ in } + _ = manager.handleUpdateFound( + version: "9.9.9", releasePage: nil, informationOnly: false, stage: .notDownloaded) + manager.handleRetryRequested() + var choices: [SPUUserUpdateChoice] = [] + manager.handleReadyToInstall { choices.append($0) } + + manager.startInstallNowCheck(attempt: UpdateManager.installNowCheckRetryLimit) + + XCTAssertFalse(manager.resumeCheckPending) + XCTAssertTrue(choices.isEmpty) + XCTAssertEqual(manager.phase, .readyToInstall(version: "9.9.9")) + + manager.installNow() + manager.installNow() + + XCTAssertEqual(choices, [.install]) + XCTAssertEqual(manager.phase, .installing) } } From 3205719ab9f32807e5c7c5dcb753cb051141a7ca Mon Sep 17 00:00:00 2001 From: StevenACZ <51928039+StevenACZ@users.noreply.github.com> Date: Fri, 18 Sep 2026 12:14:20 -0500 Subject: [PATCH 3/6] feat(updates): find new versions within minutes with quiet checks, manual check and Update click never swallowed --- CHANGELOG.md | 6 + Sources/EncajeApp/EncajeApp.swift | 1 + Sources/EncajeApp/MenuBarPopover.swift | 1 + .../EncajeApp/UI/AboutWindowController.swift | 1 + Sources/EncajeApp/UpdateManager.swift | 119 ++++++- Tests/EncajeAppTests/UpdateManagerTests.swift | 308 ++++++++++++++++++ 6 files changed, 428 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 927c08b..0f4aaf3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,9 +4,15 @@ All notable changes to this project are documented here. ## [Unreleased] +### Changed + +- New versions show up on their own within minutes: Encaje checks quietly when you open the menu bar panel, after waking and every 30 minutes (only while automatic checks are on). + ### Fixed - Retry and Update never install on their own: a prepared update always stops at "Ready to install" and waits for "Install now". +- "Check for updates" always answers now: if a quiet check is still running it shows "Checking…" and runs your check as soon as that one ends. +- Pressing Update right after a quiet check no longer does nothing: the download starts as soon as that check finishes and still stops at "Ready to install". ## [1.1.0] - 2026-09-18 diff --git a/Sources/EncajeApp/EncajeApp.swift b/Sources/EncajeApp/EncajeApp.swift index de67ce7..0aeb2f6 100644 --- a/Sources/EncajeApp/EncajeApp.swift +++ b/Sources/EncajeApp/EncajeApp.swift @@ -213,6 +213,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate, NSMe @objc private func showSettings() { menuPopover.close(restoreFocus: false) + UpdateManager.shared.surfaceDidOpen() model.refreshLoginState() if settingsWindow == nil { let view = SettingsView( diff --git a/Sources/EncajeApp/MenuBarPopover.swift b/Sources/EncajeApp/MenuBarPopover.swift index 414f0a1..4345af0 100644 --- a/Sources/EncajeApp/MenuBarPopover.swift +++ b/Sources/EncajeApp/MenuBarPopover.swift @@ -20,6 +20,7 @@ final class MenuBarPopover: NSObject, NSPopoverDelegate { close() return } + UpdateManager.shared.surfaceDidOpen() previousApplication = NSWorkspace.shared.frontmostApplication self.button = button let content = MenuBarPopoverView( diff --git a/Sources/EncajeApp/UI/AboutWindowController.swift b/Sources/EncajeApp/UI/AboutWindowController.swift index 2d9fd9b..a2191c7 100644 --- a/Sources/EncajeApp/UI/AboutWindowController.swift +++ b/Sources/EncajeApp/UI/AboutWindowController.swift @@ -8,6 +8,7 @@ final class AboutWindowController { private var layoutObserver: AnyCancellable? func show() { + UpdateManager.shared.surfaceDidOpen() let target = window ?? makeWindow() window = target resize(target) diff --git a/Sources/EncajeApp/UpdateManager.swift b/Sources/EncajeApp/UpdateManager.swift index 8d02cf8..ecd9a68 100644 --- a/Sources/EncajeApp/UpdateManager.swift +++ b/Sources/EncajeApp/UpdateManager.swift @@ -27,6 +27,8 @@ final class UpdateManager: ObservableObject { static let autoCheckDefaultsKey = "autoUpdateCheckEnabled" static let installNowCheckRetryLimit = 40 + static let backgroundCheckInterval: TimeInterval = 30 * 60 + static let backgroundCheckThrottle: TimeInterval = 5 * 60 @Published private(set) var phase: Phase = .idle @Published private(set) var releasePageURL: URL? @@ -53,12 +55,30 @@ final class UpdateManager: ObservableObject { var resumeCheckStarter: @MainActor (UpdateManager) -> Void = { $0.startInstallNowCheck(attempt: 0) } + var manualCheckStarter: @MainActor (UpdateManager) -> Void = { $0.startManualCheck(attempt: 0) } + var backgroundCheckStarter: @MainActor (UpdateManager) -> Void = { + $0.updater?.checkForUpdatesInBackground() + } + var userCheckStarter: @MainActor (UpdateManager) -> Void = { $0.updater?.checkForUpdates() } + var backgroundCheckIntervalProvider: @MainActor () -> TimeInterval = { + UpdateManager.backgroundCheckInterval + } + var isSessionInProgress: @MainActor (UpdateManager) -> Bool = { + $0.updater?.sessionInProgress == true + } + var monotonicClock: @MainActor () -> TimeInterval = { + TimeInterval(clock_gettime_nsec_np(CLOCK_MONOTONIC_RAW)) / 1_000_000_000 + } private var pendingInstallReply: ((SPUUserUpdateChoice) -> Void)? private var pendingIsInformationOnly = false private var expectedDownloadBytes: UInt64 = 0 private var receivedDownloadBytes: UInt64 = 0 private var manualCheckPending = false + private var manualCheckWaiting = false private var manualCheckResetTask: Task? + private var backgroundCheckTimer: Timer? + private var wakeObserver: NSObjectProtocol? + private var lastBackgroundCheck: TimeInterval? init(defaults: UserDefaults = AppLanguage.defaults) { self.defaults = defaults @@ -95,23 +115,80 @@ final class UpdateManager: ObservableObject { self.driver = driver self.updaterDelegate = updaterDelegate self.updater = updater + if autoCheckEnabled { startBackgroundDiscovery() } } func setAutoCheckEnabled(_ enabled: Bool) { autoCheckEnabled = enabled defaults.set(enabled, forKey: Self.autoCheckDefaultsKey) updater?.automaticallyChecksForUpdates = enabled + if enabled { + startBackgroundDiscovery() + } else { + stopBackgroundDiscovery() + } + } + + var backgroundDiscoveryArmed: Bool { backgroundCheckTimer != nil } + + func startBackgroundDiscovery() { + guard backgroundCheckTimer == nil else { return } + let interval = backgroundCheckIntervalProvider() + let timer = Timer(timeInterval: interval, repeats: true) { [weak self] _ in + Task { @MainActor in self?.requestBackgroundCheck() } + } + timer.tolerance = interval / 10 + RunLoop.main.add(timer, forMode: .common) + backgroundCheckTimer = timer + wakeObserver = NSWorkspace.shared.notificationCenter.addObserver( + forName: NSWorkspace.didWakeNotification, object: nil, queue: .main + ) { [weak self] _ in + MainActor.assumeIsolated { self?.requestBackgroundCheck() } + } + } + + func stopBackgroundDiscovery() { + backgroundCheckTimer?.invalidate() + backgroundCheckTimer = nil + if let wakeObserver { + NSWorkspace.shared.notificationCenter.removeObserver(wakeObserver) + self.wakeObserver = nil + } + } + + func requestBackgroundCheck() { + guard autoCheckEnabled, phase == .idle, !isSessionInProgress(self) else { return } + let now = monotonicClock() + if let lastBackgroundCheck, now - lastBackgroundCheck < Self.backgroundCheckThrottle { return } + lastBackgroundCheck = now + backgroundCheckStarter(self) + } + + func surfaceDidOpen() { + requestBackgroundCheck() } func installPendingUpdate() { - guard let updater else { return } + guard hasLiveUpdater(self) else { return } if pendingIsInformationOnly { openReleasePage() return } - guard updater.sessionInProgress == false else { return } - handleInstallRequested() - updater.checkForUpdates() + if resumeCheckPending { + installRequested = true + installNowRequested = false + phase = .downloading(fraction: nil) + return + } + if isSessionInProgress(self) { + switch phase { + case .downloading, .installing: + return + case .idle, .available, .readyToInstall, .failed: + break + } + } + handleRetryRequested() } func installNow() { @@ -145,11 +222,37 @@ final class UpdateManager: ObservableObject { } func checkForUpdatesManually() { - guard let updater, updater.sessionInProgress == false else { return } + guard hasLiveUpdater(self) else { return } manualCheckResetTask?.cancel() manualCheckPending = true manualCheckStatus = .checking - updater.checkForUpdates() + guard isSessionInProgress(self) else { + manualCheckWaiting = false + userCheckStarter(self) + return + } + guard !manualCheckWaiting else { return } + manualCheckWaiting = true + manualCheckStarter(self) + } + + func startManualCheck(attempt: Int) { + guard manualCheckWaiting else { return } + guard attempt < Self.installNowCheckRetryLimit else { + manualCheckWaiting = false + finishManualCheck(status: .idle) + return + } + guard isSessionInProgress(self) else { + manualCheckWaiting = false + userCheckStarter(self) + return + } + Task { [weak self] in + try? await Task.sleep(nanoseconds: 250_000_000) + guard let self, !Task.isCancelled else { return } + self.startManualCheck(attempt: attempt + 1) + } } func openReleasePage() { @@ -180,9 +283,9 @@ final class UpdateManager: ObservableObject { func startInstallNowCheck(attempt: Int) { guard resumeCheckPending else { return } - if let updater, updater.sessionInProgress == false { + if hasLiveUpdater(self), !isSessionInProgress(self) { resumeCheckPending = false - updater.checkForUpdates() + userCheckStarter(self) return } guard attempt < Self.installNowCheckRetryLimit else { diff --git a/Tests/EncajeAppTests/UpdateManagerTests.swift b/Tests/EncajeAppTests/UpdateManagerTests.swift index 464e868..bef8b88 100644 --- a/Tests/EncajeAppTests/UpdateManagerTests.swift +++ b/Tests/EncajeAppTests/UpdateManagerTests.swift @@ -1,3 +1,4 @@ +import AppKit import Sparkle import XCTest @@ -608,4 +609,311 @@ final class UpdateManagerTests: XCTestCase { XCTAssertEqual(manager.phase, .installing) } + private var discoveryNow: TimeInterval = 0 + private var backgroundChecks = 0 + + private func makeDiscoveryManager() -> UpdateManager { + let manager = makeManager() + discoveryNow = 0 + backgroundChecks = 0 + manager.setAutoCheckEnabled(true) + manager.stopBackgroundDiscovery() + manager.monotonicClock = { [unowned self] in self.discoveryNow } + manager.backgroundCheckStarter = { [unowned self] _ in self.backgroundChecks += 1 } + manager.isSessionInProgress = { _ in false } + manager.hasLiveUpdater = { _ in true } + return manager + } + + func testOpeningASurfaceAsksForASilentCheck() { + let manager = makeDiscoveryManager() + + manager.surfaceDidOpen() + + XCTAssertEqual(backgroundChecks, 1) + XCTAssertEqual(manager.phase, .idle) + XCTAssertEqual(manager.manualCheckStatus, .idle) + } + + func testEverySurfaceCallsTheDiscoveryHook() throws { + let root = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + + for surface in [ + "Sources/EncajeApp/MenuBarPopover.swift", + "Sources/EncajeApp/UI/AboutWindowController.swift", + "Sources/EncajeApp/EncajeApp.swift", + ] { + let source = try String( + contentsOf: root.appendingPathComponent(surface), encoding: .utf8) + XCTAssertTrue(source.contains("UpdateManager.shared.surfaceDidOpen()"), surface) + } + } + + func testTheDiscoveryTimerRunsOnTheRunLoopAndAsksForACheck() { + let manager = makeDiscoveryManager() + let fired = expectation(description: "the discovery timer asked for a silent check") + fired.assertForOverFulfill = false + manager.backgroundCheckStarter = { [unowned self] _ in + self.backgroundChecks += 1 + fired.fulfill() + } + manager.backgroundCheckIntervalProvider = { 0.05 } + + manager.startBackgroundDiscovery() + + XCTAssertTrue(manager.backgroundDiscoveryArmed) + waitForExpectations(timeout: 5) + manager.stopBackgroundDiscovery() + XCTAssertEqual(backgroundChecks, 1) + } + + func testWakeNotificationAsksForASilentCheck() { + let manager = makeDiscoveryManager() + manager.startBackgroundDiscovery() + defer { manager.stopBackgroundDiscovery() } + + NSWorkspace.shared.notificationCenter.post(name: NSWorkspace.didWakeNotification, object: nil) + + XCTAssertTrue(manager.backgroundDiscoveryArmed) + XCTAssertEqual(backgroundChecks, 1) + } + + func testAllTriggersShareTheFiveMinuteThrottle() { + let manager = makeDiscoveryManager() + + manager.surfaceDidOpen() + discoveryNow = UpdateManager.backgroundCheckThrottle - 1 + manager.requestBackgroundCheck() + + XCTAssertEqual(backgroundChecks, 1) + + discoveryNow = UpdateManager.backgroundCheckThrottle + manager.requestBackgroundCheck() + + XCTAssertEqual(backgroundChecks, 2) + } + + func testBackgroundCheckIsSkippedWhileASessionIsInProgress() { + let manager = makeDiscoveryManager() + manager.isSessionInProgress = { _ in true } + + manager.requestBackgroundCheck() + + XCTAssertEqual(backgroundChecks, 0) + } + + func testBackgroundCheckIsSkippedWhileACardIsShowing() { + let manager = makeDiscoveryManager() + _ = manager.handleUpdateFound( + version: "9.9.9", releasePage: nil, informationOnly: false, stage: .notDownloaded) + + manager.requestBackgroundCheck() + + XCTAssertEqual(backgroundChecks, 0) + XCTAssertEqual(manager.phase, .available(version: "9.9.9")) + } + + func testDisabledAutoChecksFireNoTrigger() { + let manager = makeDiscoveryManager() + manager.startBackgroundDiscovery() + XCTAssertTrue(manager.backgroundDiscoveryArmed) + + manager.setAutoCheckEnabled(false) + manager.surfaceDidOpen() + + XCTAssertEqual(backgroundChecks, 0) + XCTAssertFalse(manager.backgroundDiscoveryArmed) + + manager.setAutoCheckEnabled(true) + defer { manager.stopBackgroundDiscovery() } + + XCTAssertTrue(manager.backgroundDiscoveryArmed) + } + + func testSilentCheckThatFindsNothingChangesNoVisibleState() { + let manager = makeDiscoveryManager() + manager.requestBackgroundCheck() + + manager.handleNotFound() + + XCTAssertEqual(manager.phase, .idle) + XCTAssertEqual(manager.manualCheckStatus, .idle) + XCTAssertNil(manager.pendingVersion) + } + + func testSilentCheckThatFailsChangesNoVisibleState() { + let manager = makeDiscoveryManager() + manager.requestBackgroundCheck() + + manager.handleError("offline") + + XCTAssertEqual(manager.phase, .idle) + XCTAssertEqual(manager.manualCheckStatus, .idle) + } + + func testSilentCheckOnAPreparedUpdateStillWaitsForInstallNow() { + let manager = makeDiscoveryManager() + manager.requestBackgroundCheck() + + let choice = manager.handleUpdateFound( + version: "9.9.9", releasePage: nil, informationOnly: false, stage: .downloaded) + + XCTAssertEqual(choice, .dismiss) + XCTAssertEqual(manager.phase, .readyToInstall(version: "9.9.9")) + } + + func testManualCheckIsNeverThrottled() { + let manager = makeDiscoveryManager() + var userChecks = 0 + manager.userCheckStarter = { _ in userChecks += 1 } + manager.requestBackgroundCheck() + + manager.checkForUpdatesManually() + manager.checkForUpdatesManually() + + XCTAssertEqual(userChecks, 2) + XCTAssertEqual(manager.manualCheckStatus, .checking) + } + + func testManualCheckDuringASilentSessionRunsWhenTheSessionEnds() { + let manager = makeDiscoveryManager() + var sessionInProgress = true + var userChecks = 0 + manager.isSessionInProgress = { _ in sessionInProgress } + manager.userCheckStarter = { _ in userChecks += 1 } + manager.manualCheckStarter = { _ in } + + manager.checkForUpdatesManually() + + XCTAssertEqual(manager.manualCheckStatus, .checking) + XCTAssertEqual(userChecks, 0) + + sessionInProgress = false + manager.startManualCheck(attempt: 1) + + XCTAssertEqual(userChecks, 1) + XCTAssertEqual(manager.manualCheckStatus, .checking) + + manager.handleNotFound() + + XCTAssertEqual(manager.manualCheckStatus, .upToDate) + } + + func testManualCheckGivesUpQuietlyWhenTheSessionNeverEnds() { + let manager = makeDiscoveryManager() + var userChecks = 0 + manager.isSessionInProgress = { _ in true } + manager.userCheckStarter = { _ in userChecks += 1 } + manager.manualCheckStarter = { _ in } + + manager.checkForUpdatesManually() + XCTAssertEqual(manager.manualCheckStatus, .checking) + + manager.startManualCheck(attempt: UpdateManager.installNowCheckRetryLimit) + + XCTAssertEqual(userChecks, 0) + XCTAssertEqual(manager.manualCheckStatus, .idle) + XCTAssertEqual(manager.phase, .idle) + } + + func testUpdateClickDuringTheSilentSessionTeardownStillDownloads() { + let manager = makeDiscoveryManager() + var sessionInProgress = true + var userChecks = 0 + manager.isSessionInProgress = { _ in sessionInProgress } + manager.userCheckStarter = { _ in userChecks += 1 } + manager.resumeCheckStarter = { _ in } + _ = manager.handleUpdateFound( + version: "9.9.9", releasePage: nil, informationOnly: false, stage: .notDownloaded) + + manager.installPendingUpdate() + + XCTAssertEqual(manager.phase, .downloading(fraction: nil)) + XCTAssertEqual(userChecks, 0) + + sessionInProgress = false + manager.startInstallNowCheck(attempt: 1) + + XCTAssertEqual(userChecks, 1) + XCTAssertFalse(manager.resumeCheckPending) + XCTAssertFalse(manager.installNowRequested) + + let choice = manager.handleUpdateFound( + version: "9.9.9", releasePage: nil, informationOnly: false, stage: .downloaded) + var choices: [SPUUserUpdateChoice] = [] + manager.handleReadyToInstall { choices.append($0) } + + XCTAssertEqual(choice, .dismiss) + XCTAssertTrue(choices.isEmpty) + XCTAssertEqual(manager.phase, .readyToInstall(version: "9.9.9")) + } + + func testUpdateClickDuringARunningDownloadIsIgnored() { + let manager = makeDiscoveryManager() + var userChecks = 0 + var resumeStarts = 0 + manager.isSessionInProgress = { _ in true } + manager.userCheckStarter = { _ in userChecks += 1 } + manager.resumeCheckStarter = { _ in resumeStarts += 1 } + _ = manager.handleUpdateFound( + version: "9.9.9", releasePage: nil, informationOnly: false, stage: .notDownloaded) + manager.installPendingUpdate() + _ = manager.handleUpdateFound( + version: "9.9.9", releasePage: nil, informationOnly: false, stage: .notDownloaded) + manager.handleDownloadInitiated() + manager.handleDownloadExpectedLength(1_000) + manager.handleDownloadReceived(bytes: 400) + XCTAssertEqual(manager.phase, .downloading(fraction: 0.4)) + XCTAssertEqual(resumeStarts, 1) + + manager.installPendingUpdate() + + XCTAssertEqual(manager.phase, .downloading(fraction: 0.4)) + XCTAssertFalse(manager.resumeCheckPending) + XCTAssertEqual(resumeStarts, 1) + XCTAssertEqual(userChecks, 0) + } + + func testUpdateClickWhileInstallingIsIgnored() { + let manager = makeDiscoveryManager() + var resumeStarts = 0 + manager.isSessionInProgress = { _ in true } + manager.resumeCheckStarter = { _ in resumeStarts += 1 } + _ = manager.handleUpdateFound( + version: "9.9.9", releasePage: nil, informationOnly: false, stage: .notDownloaded) + manager.installPendingUpdate() + _ = manager.handleUpdateFound( + version: "9.9.9", releasePage: nil, informationOnly: false, stage: .notDownloaded) + manager.handleExtractionStarted() + + manager.installPendingUpdate() + + XCTAssertEqual(manager.phase, .installing) + XCTAssertFalse(manager.resumeCheckPending) + XCTAssertEqual(resumeStarts, 1) + } + + func testRetryAfterAFailedDownloadStillStartsTheResumePath() { + let manager = makeDiscoveryManager() + var resumeStarts = 0 + manager.isSessionInProgress = { _ in true } + manager.resumeCheckStarter = { _ in resumeStarts += 1 } + _ = manager.handleUpdateFound( + version: "9.9.9", releasePage: nil, informationOnly: false, stage: .notDownloaded) + manager.installPendingUpdate() + _ = manager.handleUpdateFound( + version: "9.9.9", releasePage: nil, informationOnly: false, stage: .notDownloaded) + manager.handleError("download died") + XCTAssertEqual(manager.phase, .failed(version: "9.9.9")) + + manager.retryPendingUpdate() + + XCTAssertEqual(manager.phase, .downloading(fraction: nil)) + XCTAssertTrue(manager.resumeCheckPending) + XCTAssertEqual(resumeStarts, 2) + } + } From 80c2b130c27ceba9844e2ef94c18e79480ebea8b Mon Sep 17 00:00:00 2001 From: StevenACZ <51928039+StevenACZ@users.noreply.github.com> Date: Fri, 18 Sep 2026 12:32:35 -0500 Subject: [PATCH 4/6] test(updates): surface hooks pinned to their open path, one shared defaults suite cleared per test --- Tests/EncajeAppTests/UpdateManagerTests.swift | 47 +++++++++++++++---- 1 file changed, 39 insertions(+), 8 deletions(-) diff --git a/Tests/EncajeAppTests/UpdateManagerTests.swift b/Tests/EncajeAppTests/UpdateManagerTests.swift index bef8b88..cad7766 100644 --- a/Tests/EncajeAppTests/UpdateManagerTests.swift +++ b/Tests/EncajeAppTests/UpdateManagerTests.swift @@ -7,8 +7,20 @@ import XCTest @MainActor final class UpdateManagerTests: XCTestCase { + private static let suiteName = "Encaje.UpdaterTests" + private func makeManager() -> UpdateManager { - UpdateManager(defaults: UserDefaults(suiteName: "Encaje.UpdaterTests." + UUID().uuidString)!) + UpdateManager(defaults: UserDefaults(suiteName: Self.suiteName)!) + } + + override func setUp() { + super.setUp() + UserDefaults.standard.removePersistentDomain(forName: Self.suiteName) + } + + override func tearDown() { + UserDefaults.standard.removePersistentDomain(forName: Self.suiteName) + super.tearDown() } func testScheduledFoundUpdateIsDismissedAndSurfaced() { @@ -641,14 +653,33 @@ final class UpdateManagerTests: XCTestCase { .deletingLastPathComponent() .deletingLastPathComponent() - for surface in [ - "Sources/EncajeApp/MenuBarPopover.swift", - "Sources/EncajeApp/UI/AboutWindowController.swift", - "Sources/EncajeApp/EncajeApp.swift", - ] { + let surfaces: [(path: String, openBranch: String, openEnd: String)] = [ + ( + "Sources/EncajeApp/MenuBarPopover.swift", "close()\n return\n }", + "previousApplication = NSWorkspace.shared.frontmostApplication" + ), + ( + "Sources/EncajeApp/UI/AboutWindowController.swift", "func show() {", + "let target = window ?? makeWindow()" + ), + ( + "Sources/EncajeApp/EncajeApp.swift", "@objc private func showSettings() {", + "model.refreshLoginState()" + ), + ] + + for surface in surfaces { let source = try String( - contentsOf: root.appendingPathComponent(surface), encoding: .utf8) - XCTAssertTrue(source.contains("UpdateManager.shared.surfaceDidOpen()"), surface) + contentsOf: root.appendingPathComponent(surface.path), encoding: .utf8) + let openBranch = try XCTUnwrap(source.range(of: surface.openBranch), surface.path) + let openEnd = try XCTUnwrap( + source.range(of: surface.openEnd, range: openBranch.upperBound.. Date: Fri, 18 Sep 2026 12:45:40 -0500 Subject: [PATCH 5/6] chore(release): 1.1.1 --- Assets/Info.plist | 4 ++-- CHANGELOG.md | 2 ++ 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/Assets/Info.plist b/Assets/Info.plist index 82e8520..9a71f2e 100644 --- a/Assets/Info.plist +++ b/Assets/Info.plist @@ -22,9 +22,9 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.1.0 + 1.1.1 CFBundleVersion - 13 + 14 LSMinimumSystemVersion 14.0 LSUIElement diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f4aaf3..6873c94 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ All notable changes to this project are documented here. ## [Unreleased] +## [1.1.1] - 2026-09-18 + ### Changed - New versions show up on their own within minutes: Encaje checks quietly when you open the menu bar panel, after waking and every 30 minutes (only while automatic checks are on). From b3454c2ab1d53e631968d9e9a2591abcd2a2bd39 Mon Sep 17 00:00:00 2001 From: StevenACZ <51928039+StevenACZ@users.noreply.github.com> Date: Fri, 18 Sep 2026 13:06:49 -0500 Subject: [PATCH 6/6] test(updates): defaults suite name readable off the main actor --- Tests/EncajeAppTests/UpdateManagerTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tests/EncajeAppTests/UpdateManagerTests.swift b/Tests/EncajeAppTests/UpdateManagerTests.swift index cad7766..9f231f7 100644 --- a/Tests/EncajeAppTests/UpdateManagerTests.swift +++ b/Tests/EncajeAppTests/UpdateManagerTests.swift @@ -7,7 +7,7 @@ import XCTest @MainActor final class UpdateManagerTests: XCTestCase { - private static let suiteName = "Encaje.UpdaterTests" + nonisolated private static let suiteName = "Encaje.UpdaterTests" private func makeManager() -> UpdateManager { UpdateManager(defaults: UserDefaults(suiteName: Self.suiteName)!)