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) + } + }