From e3cd4bd7c1c7ad3f320819ae98c619ff0ba3c424 Mon Sep 17 00:00:00 2001 From: pantafive <56078241+pantafive@users.noreply.github.com> Date: Thu, 11 Jun 2026 23:44:49 +0200 Subject: [PATCH] feat: auto-install a staged update at the next quiet moment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Auto-update staged the build but only relaunched if the app was already backgrounded and silent at the instant staging finished — a one-shot check. Miss it (on a call, popover open) and the update stranded on a manual "Restart" click, against the intent of "update automatically". Re-evaluate the same guard whenever a quiet window can open: the app resigning active and the output device falling idle, for an instant reaction — backed by a low-frequency timer so correctness never rests on those signals firing (the sibling per-process running property is a known non-deliverer on macOS 26). Every watcher exists only while an update waits and is torn down on relaunch; nothing runs in steady state. The audio guard stays — a relaunch mid-playback would blip per-app taps to unity. Co-Authored-By: Claude Opus 4.8 (1M context) --- Fader/UpdateController.swift | 87 +++++++++++++++++++++++++++++++++--- 1 file changed, 81 insertions(+), 6 deletions(-) diff --git a/Fader/UpdateController.swift b/Fader/UpdateController.swift index 34a9bbb..052b00f 100644 --- a/Fader/UpdateController.swift +++ b/Fader/UpdateController.swift @@ -42,6 +42,13 @@ final class UpdateController { @ObservationIgnored private let bridge = SparkleBridge() @ObservationIgnored private var relaunchHandler: (() -> Void)? + // Event sources that re-open a quiet window for a held-back staged + // update. Armed only while one waits, torn down on relaunch. + @ObservationIgnored private var resignObserver: NSObjectProtocol? + @ObservationIgnored private var runningListener: HALListener? + @ObservationIgnored private var defaultDeviceListener: HALListener? + @ObservationIgnored private var retryTask: Task? + init() { controller = SPUStandardUpdaterController( startingUpdater: false, @@ -99,24 +106,92 @@ final class UpdateController { availableVersion = nil } - /// The update is downloaded and staged. Relaunch into it right away - /// while nobody is looking — app in the background, output device idle. - /// Otherwise surface "Restart to Update" and let the user pick the - /// moment; Sparkle installs on quit regardless. + /// The update is downloaded and staged. Install it the moment it would go + /// unnoticed — app in the background, output device idle. If that moment + /// isn't here yet, wait for it (see `armQuietRelaunch`) rather than + /// stranding the user on a manual click; the "Restart to Update" row and + /// install-on-quit remain as the backstop either way. fileprivate func updateStaged(version: String, relaunch: @escaping () -> Void) { Self.log.info("update staged: \(version, privacy: .public)") relaunchHandler = relaunch stagedVersion = version - if !NSApp.isActive, !Self.audioIsPlaying() { - self.relaunch() + if !tryQuietRelaunch() { armQuietRelaunch() } + } + + /// Silently install the staged update iff nobody would notice. Returns + /// true once it commits to relaunching, so the caller knows to stop + /// waiting. A relaunch mid-playback would blip per-app taps to unity, so + /// the audio guard is non-negotiable. + @discardableResult + private func tryQuietRelaunch() -> Bool { + guard relaunchHandler != nil, !Self.isRelaunchingForUpdate else { return false } + guard !NSApp.isActive, !Self.audioIsPlaying() else { return false } + relaunch() + return true + } + + /// The quiet moment wasn't here when the update staged. Re-check on the + /// two events that can open one — the app resigning active (popover + /// dismissed, user switched away) and the output device falling idle + /// (playback or a call ended) — for an instant reaction when they fire. + /// Those HAL/AppKit signals are unproven on this OS (the sibling + /// per-process running property is a documented non-deliverer), so a + /// low-frequency timer backs them so correctness never rests on them. + /// All three exist only while an update waits and die on relaunch — + /// nothing runs in steady state. + private func armQuietRelaunch() { + if resignObserver == nil { + resignObserver = NotificationCenter.default.addObserver( + forName: NSApplication.didResignActiveNotification, object: nil, queue: .main + ) { [weak self] _ in + Task { @MainActor in self?.tryQuietRelaunch() } + } + } + armRunningListener() + // The default output can switch while we wait; re-arm onto the new + // device and re-check — it may already be idle. + defaultDeviceListener = AudioObjectID.system.listen(kAudioHardwarePropertyDefaultOutputDevice) { + Task { @MainActor [weak self] in + self?.armRunningListener() + self?.tryQuietRelaunch() + } + } + if retryTask == nil { + retryTask = Task { @MainActor [weak self] in + while !Task.isCancelled { + try? await Task.sleep(for: .seconds(15)) + guard let self, !Task.isCancelled else { break } + if tryQuietRelaunch() { break } + } + } + } + } + + private func armRunningListener() { + runningListener = (try? AudioObjectID.readDefaultOutputDevice()).map { device in + device.listen(kAudioDevicePropertyDeviceIsRunningSomewhere) { + Task { @MainActor [weak self] in self?.tryQuietRelaunch() } + } } } private func relaunch() { Self.isRelaunchingForUpdate = true + teardownQuietWatch() relaunchHandler?() } + private func teardownQuietWatch() { + if let resignObserver { + NotificationCenter.default.removeObserver(resignObserver) + self.resignObserver = nil + } + runningListener = nil + defaultDeviceListener = nil + retryTask?.cancel() + retryTask = nil + } + private static func audioIsPlaying() -> Bool { guard let device = try? AudioObjectID.readDefaultOutputDevice() else { return false } return device.readDeviceIsRunningSomewhere()