diff --git a/Assets/Info.plist b/Assets/Info.plist
index 9a71f2e..b785329 100644
--- a/Assets/Info.plist
+++ b/Assets/Info.plist
@@ -22,9 +22,9 @@
CFBundlePackageType
APPL
CFBundleShortVersionString
- 1.1.1
+ 1.1.2
CFBundleVersion
- 14
+ 15
LSMinimumSystemVersion
14.0
LSUIElement
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6873c94..b5f2b42 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,13 @@ All notable changes to this project are documented here.
## [Unreleased]
+## [1.1.2] - 2026-09-18
+
+### Fixed
+
+- Quiet checks keep running while an update is on offer and after a failed update, so a newer version shows up without restarting Encaje. The card only changes when a newer version is actually found, a failed update keeps its Retry, and nothing is ever installed without "Install now".
+- Pressing Update while a slow check is still running no longer ends in a failed update: the download waits for that check to finish and still stops at "Ready to install".
+
## [1.1.1] - 2026-09-18
### Changed
diff --git a/Sources/EncajeApp/UpdateManager.swift b/Sources/EncajeApp/UpdateManager.swift
index ecd9a68..db15b92 100644
--- a/Sources/EncajeApp/UpdateManager.swift
+++ b/Sources/EncajeApp/UpdateManager.swift
@@ -26,7 +26,8 @@ final class UpdateManager: ObservableObject {
}
static let autoCheckDefaultsKey = "autoUpdateCheckEnabled"
- static let installNowCheckRetryLimit = 40
+ static let installNowCheckRetryLimit = 300
+ static let installNowCheckRetryDelay: TimeInterval = 0.25
static let backgroundCheckInterval: TimeInterval = 30 * 60
static let backgroundCheckThrottle: TimeInterval = 5 * 60
@@ -131,6 +132,23 @@ final class UpdateManager: ObservableObject {
var backgroundDiscoveryArmed: Bool { backgroundCheckTimer != nil }
+ var phaseAllowsQuietCheck: Bool {
+ guard !installRequested, !installNowRequested, !resumeCheckPending,
+ !manualCheckPending, pendingInstallReply == nil
+ else { return false }
+ switch phase {
+ case .idle, .available, .failed:
+ return true
+ case .downloading, .readyToInstall, .installing:
+ return false
+ }
+ }
+
+ private var sessionIsUserDriven: Bool {
+ installRequested || installNowRequested || resumeCheckPending
+ || (manualCheckPending && !manualCheckWaiting)
+ }
+
func startBackgroundDiscovery() {
guard backgroundCheckTimer == nil else { return }
let interval = backgroundCheckIntervalProvider()
@@ -157,7 +175,9 @@ final class UpdateManager: ObservableObject {
}
func requestBackgroundCheck() {
- guard autoCheckEnabled, phase == .idle, !isSessionInProgress(self) else { return }
+ guard autoCheckEnabled, phaseAllowsQuietCheck, hasLiveUpdater(self),
+ !isSessionInProgress(self)
+ else { return }
let now = monotonicClock()
if let lastBackgroundCheck, now - lastBackgroundCheck < Self.backgroundCheckThrottle { return }
lastBackgroundCheck = now
@@ -296,7 +316,8 @@ final class UpdateManager: ObservableObject {
return
}
Task { [weak self] in
- try? await Task.sleep(nanoseconds: 250_000_000)
+ try? await Task.sleep(
+ nanoseconds: UInt64(Self.installNowCheckRetryDelay * 1_000_000_000))
guard let self, !Task.isCancelled else { return }
self.startInstallNowCheck(attempt: attempt + 1)
}
@@ -308,6 +329,11 @@ final class UpdateManager: ObservableObject {
informationOnly: Bool,
stage: SPUUserUpdateStage
) -> SPUUserUpdateChoice {
+ if !sessionIsUserDriven, phase != .idle, let pendingVersion,
+ !Self.isNewerVersion(version, than: pendingVersion)
+ {
+ return .dismiss
+ }
resumeCheckPending = false
pendingVersion = version
pendingIsInformationOnly = informationOnly
@@ -418,8 +444,13 @@ final class UpdateManager: ObservableObject {
}
}
+ private static func isNewerVersion(_ version: String, than current: String) -> Bool {
+ SUStandardVersionComparator.default.compareVersion(version, toVersion: current)
+ == .orderedDescending
+ }
+
private func finishManualCheck(status: ManualCheckStatus) {
- guard manualCheckPending else { return }
+ guard manualCheckPending, !manualCheckWaiting else { return }
manualCheckPending = false
manualCheckStatus = status
guard status != .idle else { return }
diff --git a/Tests/EncajeAppTests/PermissionLifecycleTests.swift b/Tests/EncajeAppTests/PermissionLifecycleTests.swift
index 438a648..a99b209 100644
--- a/Tests/EncajeAppTests/PermissionLifecycleTests.swift
+++ b/Tests/EncajeAppTests/PermissionLifecycleTests.swift
@@ -4,8 +4,9 @@ import XCTest
final class PermissionLifecycleTests: XCTestCase {
@MainActor func testDismissPreservesPendingSetupAcrossCoordinatorInstances() {
- let name = "Encaje.Tests.\(UUID().uuidString)"
+ let name = "Encaje.PermissionLifecycleTests"
let defaults = UserDefaults(suiteName: name)!
+ defaults.removePersistentDomain(forName: name)
defer { defaults.removePersistentDomain(forName: name) }
defaults.set(true, forKey: "permissionSetupPending")
defaults.set(true, forKey: "welcomeComplete")
diff --git a/Tests/EncajeAppTests/UpdateManagerTests.swift b/Tests/EncajeAppTests/UpdateManagerTests.swift
index 9f231f7..0056d6c 100644
--- a/Tests/EncajeAppTests/UpdateManagerTests.swift
+++ b/Tests/EncajeAppTests/UpdateManagerTests.swift
@@ -736,15 +736,18 @@ final class UpdateManagerTests: XCTestCase {
XCTAssertEqual(backgroundChecks, 0)
}
- func testBackgroundCheckIsSkippedWhileACardIsShowing() {
+ func testBackgroundCheckIsSkippedWhileADownloadRuns() {
let manager = makeDiscoveryManager()
+ manager.resumeCheckStarter = { _ in }
_ = manager.handleUpdateFound(
version: "9.9.9", releasePage: nil, informationOnly: false, stage: .notDownloaded)
+ manager.installPendingUpdate()
+ manager.handleDownloadInitiated()
manager.requestBackgroundCheck()
XCTAssertEqual(backgroundChecks, 0)
- XCTAssertEqual(manager.phase, .available(version: "9.9.9"))
+ XCTAssertEqual(manager.phase, .downloading(fraction: nil))
}
func testDisabledAutoChecksFireNoTrigger() {
@@ -947,4 +950,328 @@ final class UpdateManagerTests: XCTestCase {
XCTAssertEqual(resumeStarts, 2)
}
+ private func armLaterState(_ manager: UpdateManager) {
+ _ = 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: 1_000)
+ manager.handleReadyToInstall { _ in }
+ manager.installLater()
+ }
+
+ private func armFailedCard(_ manager: UpdateManager) {
+ manager.resumeCheckStarter = { _ in }
+ _ = 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")
+ }
+
+ func testRestingCardsAllowAQuietCheck() {
+ let idle = makeDiscoveryManager()
+ XCTAssertTrue(idle.phaseAllowsQuietCheck)
+
+ let available = makeDiscoveryManager()
+ _ = available.handleUpdateFound(
+ version: "9.9.9", releasePage: nil, informationOnly: false, stage: .notDownloaded)
+ XCTAssertEqual(available.phase, .available(version: "9.9.9"))
+ XCTAssertTrue(available.phaseAllowsQuietCheck)
+
+ let failed = makeDiscoveryManager()
+ armFailedCard(failed)
+ XCTAssertEqual(failed.phase, .failed(version: "9.9.9"))
+ XCTAssertTrue(failed.phaseAllowsQuietCheck)
+ }
+
+ func testWorkingPhasesBlockAQuietCheck() {
+ let downloading = makeDiscoveryManager()
+ downloading.handleDownloadInitiated()
+ XCTAssertFalse(downloading.phaseAllowsQuietCheck)
+
+ let installing = makeDiscoveryManager()
+ installing.handleExtractionStarted()
+ XCTAssertFalse(installing.phaseAllowsQuietCheck)
+
+ let held = makeDiscoveryManager()
+ _ = held.handleUpdateFound(
+ version: "9.9.9", releasePage: nil, informationOnly: false, stage: .notDownloaded)
+ held.handleReadyToInstall { _ in }
+ XCTAssertEqual(held.phase, .readyToInstall(version: "9.9.9"))
+ XCTAssertFalse(held.phaseAllowsQuietCheck)
+
+ let afterLater = makeDiscoveryManager()
+ armLaterState(afterLater)
+ XCTAssertEqual(afterLater.phase, .readyToInstall(version: "9.9.9"))
+ XCTAssertFalse(afterLater.phaseAllowsQuietCheck)
+ }
+
+ func testPendingIntentsBlockAQuietCheck() {
+ let manualCheck = makeDiscoveryManager()
+ manualCheck.userCheckStarter = { _ in }
+ manualCheck.checkForUpdatesManually()
+ XCTAssertEqual(manualCheck.phase, .idle)
+ XCTAssertFalse(manualCheck.phaseAllowsQuietCheck)
+
+ let resume = makeDiscoveryManager()
+ resume.resumeCheckStarter = { _ in }
+ _ = resume.handleUpdateFound(
+ version: "9.9.9", releasePage: nil, informationOnly: false, stage: .notDownloaded)
+ resume.installPendingUpdate()
+ XCTAssertTrue(resume.installRequested)
+ XCTAssertTrue(resume.resumeCheckPending)
+ XCTAssertFalse(resume.phaseAllowsQuietCheck)
+
+ let installNow = makeDiscoveryManager()
+ installNow.resumeCheckStarter = { _ in }
+ armLaterState(installNow)
+ installNow.installNow()
+ XCTAssertTrue(installNow.installNowRequested)
+ XCTAssertFalse(installNow.phaseAllowsQuietCheck)
+ }
+
+ func testAQuietCheckNeedsALiveUpdaterAndKeepsTheThrottleUnused() {
+ let manager = makeDiscoveryManager()
+ manager.hasLiveUpdater = { _ in false }
+
+ manager.requestBackgroundCheck()
+
+ XCTAssertEqual(backgroundChecks, 0)
+
+ manager.hasLiveUpdater = { _ in true }
+ manager.requestBackgroundCheck()
+
+ XCTAssertEqual(backgroundChecks, 1)
+ }
+
+ func testAQuietCheckRunsFromAFailedCard() {
+ let manager = makeDiscoveryManager()
+ armFailedCard(manager)
+ XCTAssertEqual(manager.phase, .failed(version: "9.9.9"))
+
+ manager.requestBackgroundCheck()
+
+ XCTAssertEqual(backgroundChecks, 1)
+ XCTAssertEqual(manager.phase, .failed(version: "9.9.9"))
+ }
+
+ func testAnUnattendedSameVersionKeepsTheFailedCard() {
+ let manager = makeDiscoveryManager()
+ armFailedCard(manager)
+
+ let choice = manager.handleUpdateFound(
+ version: "9.9.9", releasePage: nil, informationOnly: false, stage: .notDownloaded)
+
+ XCTAssertEqual(choice, .dismiss)
+ XCTAssertEqual(manager.phase, .failed(version: "9.9.9"))
+ XCTAssertEqual(manager.pendingVersion, "9.9.9")
+ XCTAssertEqual(backgroundChecks, 0)
+ }
+
+ func testAnUnattendedSameVersionKeepsTheAvailableCard() {
+ let manager = makeDiscoveryManager()
+ _ = manager.handleUpdateFound(
+ version: "9.9.9", releasePage: URL(string: "https://example.com/release"),
+ informationOnly: false, stage: .notDownloaded)
+
+ let choice = manager.handleUpdateFound(
+ version: "9.9.9", releasePage: nil, informationOnly: false, stage: .notDownloaded)
+
+ XCTAssertEqual(choice, .dismiss)
+ XCTAssertEqual(manager.phase, .available(version: "9.9.9"))
+ XCTAssertEqual(manager.releasePageURL?.absoluteString, "https://example.com/release")
+ }
+
+ func testTheStagedUpdateReofferedAfterLaterKeepsTheReadyCard() {
+ let manager = makeDiscoveryManager()
+ armLaterState(manager)
+
+ let choice = manager.handleUpdateFound(
+ version: "9.9.9", releasePage: nil, informationOnly: false, stage: .installing)
+
+ XCTAssertEqual(choice, .dismiss)
+ XCTAssertEqual(manager.phase, .readyToInstall(version: "9.9.9"))
+ XCTAssertFalse(manager.installRequested)
+ XCTAssertFalse(manager.installNowRequested)
+ }
+
+ func testAnUnattendedOlderVersionKeepsTheCard() {
+ let manager = makeDiscoveryManager()
+ _ = manager.handleUpdateFound(
+ version: "9.9.9", releasePage: nil, informationOnly: false, stage: .notDownloaded)
+
+ let choice = manager.handleUpdateFound(
+ version: "9.9.8", releasePage: nil, informationOnly: false, stage: .notDownloaded)
+
+ XCTAssertEqual(choice, .dismiss)
+ XCTAssertEqual(manager.phase, .available(version: "9.9.9"))
+ XCTAssertEqual(manager.pendingVersion, "9.9.9")
+ }
+
+ func testAnUnattendedNewerVersionReplacesTheAvailableCard() {
+ let manager = makeDiscoveryManager()
+ _ = manager.handleUpdateFound(
+ version: "9.9.9", releasePage: nil, informationOnly: false, stage: .notDownloaded)
+
+ let choice = manager.handleUpdateFound(
+ version: "9.9.10", releasePage: nil, informationOnly: false, stage: .notDownloaded)
+
+ XCTAssertEqual(choice, .dismiss)
+ XCTAssertEqual(manager.phase, .available(version: "9.9.10"))
+ XCTAssertFalse(manager.installNowRequested)
+ }
+
+ func testAnUnattendedNewerVersionReplacesTheFailedCard() {
+ let manager = makeDiscoveryManager()
+ armFailedCard(manager)
+
+ let choice = manager.handleUpdateFound(
+ version: "9.9.10", releasePage: nil, informationOnly: false, stage: .notDownloaded)
+
+ XCTAssertEqual(choice, .dismiss)
+ XCTAssertEqual(manager.phase, .available(version: "9.9.10"))
+ XCTAssertFalse(manager.installRequested)
+ XCTAssertFalse(manager.installNowRequested)
+ }
+
+ func testAnUnattendedPreparedUpdateNeverInstallsItself() {
+ let manager = makeDiscoveryManager()
+ _ = manager.handleUpdateFound(
+ version: "9.9.9", releasePage: nil, informationOnly: false, stage: .notDownloaded)
+
+ let choice = manager.handleUpdateFound(
+ version: "9.9.10", 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.10"))
+ }
+
+ func testAQuietCheckWithoutAnyCallbackLeavesAManualCheckUsable() {
+ let manager = makeDiscoveryManager()
+ var userChecks = 0
+ manager.userCheckStarter = { _ in userChecks += 1 }
+
+ manager.requestBackgroundCheck()
+ XCTAssertEqual(backgroundChecks, 1)
+
+ manager.checkForUpdatesManually()
+ XCTAssertEqual(userChecks, 1)
+ XCTAssertEqual(manager.manualCheckStatus, .checking)
+
+ manager.handleNotFound()
+
+ XCTAssertEqual(manager.manualCheckStatus, .upToDate)
+ XCTAssertEqual(manager.phase, .idle)
+ }
+
+ func testAQuietCheckWithoutAnyCallbackLeavesTheUpdateClickUsable() {
+ let manager = makeDiscoveryManager()
+ var resumeStarts = 0
+ manager.resumeCheckStarter = { _ in resumeStarts += 1 }
+ _ = manager.handleUpdateFound(
+ version: "9.9.9", releasePage: nil, informationOnly: false, stage: .notDownloaded)
+ manager.requestBackgroundCheck()
+ XCTAssertEqual(backgroundChecks, 1)
+
+ manager.installPendingUpdate()
+
+ XCTAssertEqual(manager.phase, .downloading(fraction: nil))
+ XCTAssertEqual(resumeStarts, 1)
+ }
+
+ func testAManualCheckQueuedBehindAQuietSessionKeepsItsSpinner() {
+ let manager = makeDiscoveryManager()
+ var sessionInProgress = false
+ var userChecks = 0
+ manager.isSessionInProgress = { _ in sessionInProgress }
+ manager.userCheckStarter = { _ in userChecks += 1 }
+ manager.manualCheckStarter = { _ in }
+ _ = manager.handleUpdateFound(
+ version: "9.9.9", releasePage: nil, informationOnly: false, stage: .notDownloaded)
+ manager.requestBackgroundCheck()
+ XCTAssertEqual(backgroundChecks, 1)
+ sessionInProgress = true
+
+ manager.checkForUpdatesManually()
+ XCTAssertEqual(manager.manualCheckStatus, .checking)
+ XCTAssertEqual(userChecks, 0)
+
+ let quiet = manager.handleUpdateFound(
+ version: "9.9.9", releasePage: nil, informationOnly: false, stage: .notDownloaded)
+
+ XCTAssertEqual(quiet, .dismiss)
+ XCTAssertEqual(manager.manualCheckStatus, .checking)
+
+ sessionInProgress = false
+ manager.startManualCheck(attempt: 1)
+ XCTAssertEqual(userChecks, 1)
+ XCTAssertEqual(manager.manualCheckStatus, .checking)
+
+ let found = manager.handleUpdateFound(
+ version: "9.9.9", releasePage: nil, informationOnly: false, stage: .notDownloaded)
+
+ XCTAssertEqual(found, .dismiss)
+ XCTAssertEqual(manager.manualCheckStatus, .idle)
+ XCTAssertEqual(manager.phase, .available(version: "9.9.9"))
+ }
+
+ func testAManualCheckFromAFailedCardShowsTheSameVersionAgain() {
+ let manager = makeDiscoveryManager()
+ armFailedCard(manager)
+ manager.userCheckStarter = { _ in }
+
+ manager.checkForUpdatesManually()
+ let choice = manager.handleUpdateFound(
+ version: "9.9.9", releasePage: nil, informationOnly: false, stage: .notDownloaded)
+
+ XCTAssertEqual(choice, .dismiss)
+ XCTAssertEqual(manager.phase, .available(version: "9.9.9"))
+ XCTAssertEqual(manager.manualCheckStatus, .idle)
+ }
+
+ func testInstallNowAfterLaterStillInstalls() {
+ let manager = makeDiscoveryManager()
+ var resumeStarts = 0
+ manager.resumeCheckStarter = { _ in resumeStarts += 1 }
+ armLaterState(manager)
+
+ manager.installNow()
+ XCTAssertEqual(manager.phase, .installing)
+ XCTAssertEqual(resumeStarts, 2)
+
+ let choice = manager.handleUpdateFound(
+ version: "9.9.9", releasePage: nil, informationOnly: false, stage: .downloaded)
+
+ XCTAssertEqual(choice, .install)
+ XCTAssertEqual(manager.phase, .installing)
+ }
+
+ func testLaterDropsTheConsentForTheSameVersion() {
+ let manager = makeDiscoveryManager()
+ manager.resumeCheckStarter = { _ in }
+ armLaterState(manager)
+
+ let choice = manager.handleUpdateFound(
+ version: "9.9.9", releasePage: nil, informationOnly: false, stage: .notDownloaded)
+
+ XCTAssertEqual(choice, .dismiss)
+ XCTAssertFalse(manager.installRequested)
+ XCTAssertEqual(manager.phase, .readyToInstall(version: "9.9.9"))
+ }
+
+ func testTheResumeWindowOutlastsASlowSession() {
+ let window =
+ Double(UpdateManager.installNowCheckRetryLimit) * UpdateManager.installNowCheckRetryDelay
+
+ XCTAssertGreaterThanOrEqual(window, 70)
+ }
+
}