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..