Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions Assets/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,9 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.1.0</string>
<string>1.1.1</string>
<key>CFBundleVersion</key>
<string>13</string>
<string>14</string>
<key>LSMinimumSystemVersion</key>
<string>14.0</string>
<key>LSUIElement</key>
Expand Down
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions Sources/EncajeApp/EncajeApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
1 change: 1 addition & 0 deletions Sources/EncajeApp/MenuBarPopover.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ final class MenuBarPopover: NSObject, NSPopoverDelegate {
close()
return
}
UpdateManager.shared.surfaceDidOpen()
previousApplication = NSWorkspace.shared.frontmostApplication
self.button = button
let content = MenuBarPopoverView(
Expand Down
2 changes: 1 addition & 1 deletion Sources/EncajeApp/UI/AboutView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions Sources/EncajeApp/UI/AboutWindowController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ final class AboutWindowController {
private var layoutObserver: AnyCancellable?

func show() {
UpdateManager.shared.surfaceDidOpen()
let target = window ?? makeWindow()
window = target
resize(target)
Expand Down
4 changes: 2 additions & 2 deletions Sources/EncajeApp/UI/UpdateViews.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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() }
}
}

Expand Down
149 changes: 137 additions & 12 deletions Sources/EncajeApp/UpdateManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand All @@ -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<Void, Never>?
private var backgroundCheckTimer: Timer?
private var wakeObserver: NSObjectProtocol?
private var lastBackgroundCheck: TimeInterval?

init(defaults: UserDefaults = AppLanguage.defaults) {
self.defaults = defaults
Expand Down Expand Up @@ -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() {
Expand All @@ -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
Expand All @@ -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() {
Expand All @@ -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 {
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading