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 1e23948..6873c94 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,18 @@ 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). + +### 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 ### Added 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/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/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/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..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? @@ -49,12 +51,34 @@ final class UpdateManager: ObservableObject { private(set) var installRequested = false private(set) var installNowRequested = false private(set) var resumeCheckPending = false + var hasLiveUpdater: @MainActor (UpdateManager) -> Bool = { $0.updater != nil } + 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 @@ -91,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() { @@ -119,10 +200,20 @@ 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 } + if pendingInstallReply != nil { + phase = .readyToInstall(version: pendingVersion ?? "") + return + } + guard hasLiveUpdater(self) else { return } + handleRetryRequested() + } + func installLater() { guard let reply = pendingInstallReply else { return } pendingInstallReply = nil @@ -131,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() { @@ -153,14 +270,22 @@ final class UpdateManager: ObservableObject { installNowRequested = true resumeCheckPending = true phase = .installing - startInstallNowCheck(attempt: 0) + resumeCheckStarter(self) + } + + func handleRetryRequested() { + installRequested = true + installNowRequested = false + resumeCheckPending = true + phase = .downloading(fraction: nil) + resumeCheckStarter(self) } 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 { @@ -190,7 +315,7 @@ final class UpdateManager: ObservableObject { finishManualCheck(status: .idle) let prepared = stage != .notDownloaded - if !informationOnly, installRequested || (prepared && installNowRequested) { + if !informationOnly, prepared ? installNowRequested : installRequested { phase = prepared ? .installing : .downloading(fraction: nil) return .install } @@ -222,9 +347,9 @@ final class UpdateManager: ObservableObject { } func handleReadyToInstall(reply: @escaping (SPUUserUpdateChoice) -> Void) { + resumeCheckPending = false if installNowRequested { installNowRequested = false - resumeCheckPending = false phase = .installing reply(.install) return diff --git a/Tests/EncajeAppTests/UpdateManagerTests.swift b/Tests/EncajeAppTests/UpdateManagerTests.swift index f137107..9f231f7 100644 --- a/Tests/EncajeAppTests/UpdateManagerTests.swift +++ b/Tests/EncajeAppTests/UpdateManagerTests.swift @@ -1,3 +1,4 @@ +import AppKit import Sparkle import XCTest @@ -6,8 +7,20 @@ import XCTest @MainActor final class UpdateManagerTests: XCTestCase { + nonisolated 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() { @@ -470,4 +483,468 @@ 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.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.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.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.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) + } + + 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) + } + + 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() + + 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.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..