diff --git a/Sources/Update/UpdateController.swift b/Sources/Update/UpdateController.swift index 59de5c09..b0ff9218 100644 --- a/Sources/Update/UpdateController.swift +++ b/Sources/Update/UpdateController.swift @@ -1,6 +1,7 @@ import Sparkle import Cocoa import Combine +import ObjectiveC.runtime import SwiftUI enum UpdateSettings { @@ -59,9 +60,15 @@ class UpdateController { private var readyCheckWorkItem: DispatchWorkItem? private var backgroundProbeTimer: Timer? private var didStartUpdater: Bool = false + private var latestItemProbe: UpdateLatestItemProbe? + private var latestItemProbeQueuedFollowUp: (() -> Void)? + private var isShowingLatestItemProbeCheckingState: Bool = false private let readyRetryDelay: TimeInterval = 0.25 private let readyRetryCount: Int = 20 private let backgroundProbeInterval: TimeInterval = UpdateSettings.scheduledCheckInterval +#if DEBUG + private var debugDeferredUpdateCandidate: DeferredUpdateCandidate? +#endif var viewModel: UpdateViewModel { userDriver.viewModel @@ -84,6 +91,13 @@ class UpdateController { userDriver: userDriver, delegate: userDriver ) +#if DEBUG + if let versionString = ProcessInfo.processInfo.environment["CMUX_UI_TEST_DEFERRED_UPDATE_VERSION"], + !versionString.isEmpty { + let displayVersionString = ProcessInfo.processInfo.environment["CMUX_UI_TEST_DEFERRED_UPDATE_DISPLAY_VERSION"] ?? versionString + self.debugDeferredUpdateCandidate = .init(versionString: versionString, displayVersionString: displayVersionString) + } +#endif installNoUpdateDismissObserver() } @@ -94,6 +108,7 @@ class UpdateController { noUpdateDismissWorkItem?.cancel() readyCheckWorkItem?.cancel() backgroundProbeTimer?.invalidate() + latestItemProbe?.cancel() } /// Start the updater. If startup fails, the error is shown via the custom UI. @@ -215,8 +230,15 @@ class UpdateController { private func performCheckForUpdates(presentation: UpdateUserInitiatedCheckPresentation) { startUpdaterIfNeeded() ensureSparkleInstallationCache() + refreshLatestUpdateSelectionIfNeeded(presentation: presentation) { [weak self] in + self?.performUserInitiatedCheck(presentation: presentation) + } + } + + private func performUserInitiatedCheck(presentation: UpdateUserInitiatedCheckPresentation) { userDriver.prepareForUserInitiatedCheck(presentation: presentation) - if viewModel.state == .idle { + if viewModel.state == .idle || isShowingLatestItemProbeCheckingState { + isShowingLatestItemProbeCheckingState = false updater.checkForUpdates() return } @@ -369,4 +391,439 @@ class UpdateController { UpdateLogStore.shared.append("Failed creating Sparkle installation cache: \(error)") } } + + private func refreshLatestUpdateSelectionIfNeeded( + presentation: UpdateUserInitiatedCheckPresentation, + completion: @escaping () -> Void + ) { + // Sparkle resumes cached deferred updates before fetching the feed again. + // Probe the latest appcast first so a manual check doesn't surface an older cached version. + guard latestItemProbe == nil else { + latestItemProbeQueuedFollowUp = completion + UpdateLogStore.shared.append( + "latest update probe already in progress; coalescing latest user check (presentation=\(presentation == .dialog ? "dialog" : "custom"))" + ) + return + } + guard let deferredUpdateCandidate = currentDeferredUpdateCandidate() else { + completion() + return + } + + if viewModel.state == .idle { + isShowingLatestItemProbeCheckingState = true + viewModel.state = .checking(.init(cancel: { [weak self] in + self?.cancelLatestItemProbe() + })) + } + + UpdateLogStore.shared.append( + "probing latest update before user check (deferred=\(deferredUpdateCandidate.displayVersionString), presentation=\(presentation == .dialog ? "dialog" : "custom"))" + ) + + let probe = UpdateLatestItemProbe() + latestItemProbe = probe + latestItemProbeQueuedFollowUp = nil + probe.start { [weak self] result in + guard let self else { return } + self.latestItemProbe = nil + let followUp = self.latestItemProbeQueuedFollowUp ?? completion + self.latestItemProbeQueuedFollowUp = nil + + if let latestValidUpdate = result.latestValidUpdate, + self.shouldReplaceDeferredUpdate( + deferredUpdateCandidate, + with: latestValidUpdate + ) { + self.clearDeferredUpdateCandidate() + UpdateLogStore.shared.append( + "cleared stale deferred update \(deferredUpdateCandidate.displayVersionString) in favor of \(latestValidUpdate.displayVersionString)" + ) + } else if let error = result.error { + UpdateLogStore.shared.append("latest update probe failed: \(self.userDriver.formatErrorForLog(error))") + } + + followUp() + } + } + + private func cancelLatestItemProbe() { + guard latestItemProbe != nil || latestItemProbeQueuedFollowUp != nil || isShowingLatestItemProbeCheckingState else { + return + } + + UpdateLogStore.shared.append("latest update probe canceled") + latestItemProbe?.cancel() + latestItemProbe = nil + latestItemProbeQueuedFollowUp = nil + + guard isShowingLatestItemProbeCheckingState else { return } + isShowingLatestItemProbeCheckingState = false + if case .checking = viewModel.state { + viewModel.state = .idle + } + } + + private func currentDeferredUpdateCandidate() -> DeferredUpdateCandidate? { +#if DEBUG + if let debugDeferredUpdateCandidate { + return debugDeferredUpdateCandidate + } +#endif + return SparkleResumableUpdateReflection.deferredUpdateCandidate(from: updater) + } + + private func clearDeferredUpdateCandidate() { +#if DEBUG + debugDeferredUpdateCandidate = nil +#endif + _ = SparkleResumableUpdateReflection.clearDeferredUpdate(from: updater) + } + + private func shouldReplaceDeferredUpdate( + _ deferredUpdateCandidate: DeferredUpdateCandidate, + with latestValidUpdate: DeferredUpdateCandidate + ) -> Bool { + let latestVersionString = effectiveVersionString(for: latestValidUpdate) + let deferredVersionString = effectiveVersionString(for: deferredUpdateCandidate) + guard !latestVersionString.isEmpty, !deferredVersionString.isEmpty else { + return false + } + let comparison = SUStandardVersionComparator.default.compareVersion( + deferredVersionString, + toVersion: latestVersionString + ) + return comparison == .orderedAscending + } + + private func effectiveVersionString(for deferredUpdateCandidate: DeferredUpdateCandidate) -> String { + deferredUpdateCandidate.versionString.isEmpty ? deferredUpdateCandidate.displayVersionString : deferredUpdateCandidate.versionString + } +} + +private struct DeferredUpdateCandidate { + let versionString: String + let displayVersionString: String + + init(versionString: String, displayVersionString: String) { + self.versionString = versionString + self.displayVersionString = displayVersionString + } + + init(item: SUAppcastItem) { + self.init(versionString: item.versionString, displayVersionString: item.displayVersionString) + } +} + +private enum SparkleResumableUpdateReflection { + private static let resumableUpdateIvarName = "_resumableUpdate" + + static func deferredUpdateCandidate(from updater: SPUUpdater) -> DeferredUpdateCandidate? { + guard let resumableUpdate = resumableUpdateObject(from: updater), + let updateItem = resumableUpdate.perform(NSSelectorFromString("updateItem"))?.takeUnretainedValue() as? SUAppcastItem else { + return nil + } + return DeferredUpdateCandidate(item: updateItem) + } + + @discardableResult + static func clearDeferredUpdate(from updater: SPUUpdater) -> Bool { + guard let resumableUpdateIvar = class_getInstanceVariable(SPUUpdater.self, resumableUpdateIvarName) else { + return false + } + object_setIvar(updater, resumableUpdateIvar, nil) + return true + } + + private static func resumableUpdateObject(from updater: SPUUpdater) -> NSObject? { + guard let resumableUpdateIvar = class_getInstanceVariable(SPUUpdater.self, resumableUpdateIvarName), + let resumableUpdate = object_getIvar(updater, resumableUpdateIvar) else { + return nil + } + return resumableUpdate as? NSObject + } +} + +private final class UpdateLatestItemProbe: NSObject { + struct Result { + let latestValidUpdate: DeferredUpdateCandidate? + let error: Error? + } + + private let stateLock = NSLock() + private var completionHandler: ((Result) -> Void)? + private var dataTask: URLSessionDataTask? + + func start(completion: @escaping (Result) -> Void) { + stateLock.lock() + completionHandler = completion + stateLock.unlock() + + guard let feedURLString = resolvedFeedURLString(), + let feedURL = URL(string: feedURLString) else { + deliver(.init(latestValidUpdate: nil, error: nil)) + return + } + +#if DEBUG + let env = ProcessInfo.processInfo.environment + if let override = env["CMUX_UI_TEST_FEED_URL"], !override.isEmpty { + UpdateTestURLProtocol.registerIfNeeded() + } +#endif + + let task = URLSession.shared.dataTask(with: feedURL) { [weak self] data, _, error in + let result: Result + if let error { + result = .init(latestValidUpdate: nil, error: error) + } else if let data { + do { + let parser = LatestAppcastFeedParser() + let items = try parser.parse(data: data) + result = .init( + latestValidUpdate: Self.pickLatestItem(from: items), + error: nil + ) + } catch { + result = .init(latestValidUpdate: nil, error: error) + } + } else { + result = .init(latestValidUpdate: nil, error: nil) + } + + self?.deliver(result) + } + stateLock.lock() + dataTask = task + stateLock.unlock() + task.resume() + } + + func cancel() { + let task: URLSessionDataTask? + stateLock.lock() + task = dataTask + dataTask = nil + completionHandler = nil + stateLock.unlock() + task?.cancel() + } + + private func deliver(_ result: Result) { + DispatchQueue.main.async { [weak self] in + guard let self else { return } + let completion = self.takeCompletion() + guard let completion else { return } + completion(result) + } + } + + private func takeCompletion() -> ((Result) -> Void)? { + stateLock.lock() + defer { stateLock.unlock() } + let completion = completionHandler + completionHandler = nil + dataTask = nil + return completion + } + + private func resolvedFeedURLString() -> String? { +#if DEBUG + let env = ProcessInfo.processInfo.environment + if let override = env["CMUX_UI_TEST_FEED_URL"], !override.isEmpty { + return override + } +#endif + let infoFeedURL = Bundle.main.object(forInfoDictionaryKey: "SUFeedURL") as? String + return UpdateFeedResolver.resolvedFeedURLString(infoFeedURL: infoFeedURL).url + } + + private static func pickLatestItem(from items: [DeferredUpdateCandidate]) -> DeferredUpdateCandidate? { + var latestItem: DeferredUpdateCandidate? + for item in items { + guard let currentLatest = latestItem else { + latestItem = item + continue + } + let comparison = SUStandardVersionComparator.default.compareVersion( + effectiveVersionString(for: currentLatest), + toVersion: effectiveVersionString(for: item) + ) + if comparison == .orderedAscending { + latestItem = item + } + } + return latestItem + } + + private static func effectiveVersionString(for item: DeferredUpdateCandidate) -> String { + item.versionString.isEmpty ? item.displayVersionString : item.versionString + } +} + +private final class LatestAppcastFeedParser: NSObject, XMLParserDelegate { + private struct ParsedItem { + var versionString = "" + var displayVersionString = "" + var channel: String? + var minimumSystemVersion: String? + var maximumSystemVersion: String? + var hasDownload = false + var hasInfoURL = false + } + + private var parsedItems: [DeferredUpdateCandidate] = [] + private var currentItem: ParsedItem? + private var currentText = "" + + func parse(data: Data) throws -> [DeferredUpdateCandidate] { + parsedItems = [] + currentItem = nil + currentText = "" + + let parser = XMLParser(data: data) + parser.delegate = self + if parser.parse() { + return parsedItems + } + + throw parser.parserError ?? NSError( + domain: "cmux.update", + code: 2, + userInfo: [NSLocalizedDescriptionKey: "Failed to parse update feed."] + ) + } + + func parser( + _ parser: XMLParser, + didStartElement elementName: String, + namespaceURI: String?, + qualifiedName qName: String?, + attributes attributeDict: [String: String] = [:] + ) { + let name = qName ?? elementName + currentText = "" + + switch name { + case "item": + currentItem = ParsedItem() + case "enclosure": + guard currentItem != nil else { return } + currentItem?.hasDownload = true + if let versionString = attributeDict["sparkle:version"], !versionString.isEmpty { + currentItem?.versionString = versionString + } + if let displayVersionString = attributeDict["sparkle:shortVersionString"], !displayVersionString.isEmpty { + currentItem?.displayVersionString = displayVersionString + } + default: + break + } + } + + func parser(_ parser: XMLParser, foundCharacters string: String) { + currentText.append(string) + } + + func parser( + _ parser: XMLParser, + didEndElement elementName: String, + namespaceURI: String?, + qualifiedName qName: String? + ) { + let name = qName ?? elementName + let text = currentText.trimmingCharacters(in: .whitespacesAndNewlines) + + guard currentItem != nil else { + currentText = "" + return + } + + switch name { + case "sparkle:version": + if !text.isEmpty { + currentItem?.versionString = text + } + case "sparkle:shortVersionString": + if !text.isEmpty { + currentItem?.displayVersionString = text + } + case "sparkle:channel": + currentItem?.channel = text.isEmpty ? nil : text + case "sparkle:minimumSystemVersion": + currentItem?.minimumSystemVersion = text.isEmpty ? nil : text + case "sparkle:maximumSystemVersion": + currentItem?.maximumSystemVersion = text.isEmpty ? nil : text + case "link": + if !text.isEmpty { + currentItem?.hasInfoURL = true + } + case "item": + finalizeCurrentItem() + default: + break + } + + currentText = "" + } + + private func finalizeCurrentItem() { + guard let currentItem else { return } + defer { self.currentItem = nil } + + guard currentItem.channel == nil else { return } + guard currentItem.hasDownload || currentItem.hasInfoURL else { return } + let versionString = currentItem.versionString + guard !versionString.isEmpty else { return } + guard passesMinimumSystemVersion(currentItem.minimumSystemVersion) else { return } + guard passesMaximumSystemVersion(currentItem.maximumSystemVersion) else { return } + + let displayVersionString = currentItem.displayVersionString.isEmpty + ? versionString + : currentItem.displayVersionString + parsedItems.append( + DeferredUpdateCandidate( + versionString: versionString, + displayVersionString: displayVersionString + ) + ) + } + + private func passesMinimumSystemVersion(_ versionString: String?) -> Bool { + guard let versionString, !versionString.isEmpty else { return true } + return compareSystemVersion(versionString, to: currentSystemVersionString) != .orderedDescending + } + + private func passesMaximumSystemVersion(_ versionString: String?) -> Bool { + guard let versionString, !versionString.isEmpty else { return true } + return compareSystemVersion(versionString, to: currentSystemVersionString) != .orderedAscending + } + + private var currentSystemVersionString: String { + let version = ProcessInfo.processInfo.operatingSystemVersion + return "\(version.majorVersion).\(version.minorVersion).\(version.patchVersion)" + } + + private func compareSystemVersion(_ lhs: String, to rhs: String) -> ComparisonResult { + let lhsComponents = versionComponents(from: lhs) + let rhsComponents = versionComponents(from: rhs) + let count = max(lhsComponents.count, rhsComponents.count) + for index in 0.. rhsValue { + return .orderedDescending + } + } + return .orderedSame + } + + private func versionComponents(from versionString: String) -> [Int] { + versionString + .split(separator: ".") + .compactMap { Int($0) } + } } diff --git a/Sources/Update/UpdateDriver.swift b/Sources/Update/UpdateDriver.swift index ecaa021f..eaee6e8e 100644 --- a/Sources/Update/UpdateDriver.swift +++ b/Sources/Update/UpdateDriver.swift @@ -85,10 +85,16 @@ class UpdateDriver: NSObject, SPUUserDriver { } func showUpdateReleaseNotes(with downloadData: SPUDownloadData) { + if usesStandardPresentation { + standard.showUpdateReleaseNotes(with: downloadData) + } // cmux uses Sparkle's UI for release notes links instead. } func showUpdateReleaseNotesFailedToDownloadWithError(_ error: any Error) { + if usesStandardPresentation { + standard.showUpdateReleaseNotesFailedToDownloadWithError(error) + } // Release notes are handled via link buttons. } @@ -103,6 +109,7 @@ class UpdateDriver: NSObject, SPUUserDriver { } return } + clearActiveUserInitiatedCheckPresentation() setStateAfterMinimumCheckDelay(.notFound(.init(acknowledgement: acknowledgement))) } @@ -118,6 +125,7 @@ class UpdateDriver: NSObject, SPUUserDriver { } return } + clearActiveUserInitiatedCheckPresentation() setState(.error(.init( error: error, retry: { [weak viewModel] in @@ -324,6 +332,7 @@ class UpdateDriver: NSObject, SPUUserDriver { let workItem = DispatchWorkItem { [weak self] in guard let self else { return } guard case .checking = self.viewModel.state else { return } + self.clearActiveUserInitiatedCheckPresentation() self.setState(.notFound(.init(acknowledgement: {}))) } checkTimeoutWorkItem = workItem @@ -376,6 +385,12 @@ class UpdateDriver: NSObject, SPUUserDriver { } } + private func clearActiveUserInitiatedCheckPresentation() { + runOnMain { [weak self] in + self?.activeUserInitiatedCheckPresentation = nil + } + } + private func describe(_ state: UpdateState) -> String { switch state { case .idle: diff --git a/cmuxUITests/SidebarHelpMenuUITests.swift b/cmuxUITests/SidebarHelpMenuUITests.swift index 5c9bb7b0..db5f3c61 100644 --- a/cmuxUITests/SidebarHelpMenuUITests.swift +++ b/cmuxUITests/SidebarHelpMenuUITests.swift @@ -17,6 +17,15 @@ private func sidebarHelpPollUntil( } } +private func configureEnglishLocale(_ app: XCUIApplication) { + app.launchArguments += [ + "-AppleLanguages", + "(en)", + "-AppleLocale", + "en_US", + ] +} + final class SidebarHelpMenuUITests: XCTestCase { override func setUp() { super.setUp() @@ -49,6 +58,7 @@ final class SidebarHelpMenuUITests: XCTestCase { func testHelpMenuCheckForUpdatesShowsSparkleDialogOnFirstAttempt() { let app = XCUIApplication() + configureEnglishLocale(app) app.launchEnvironment["CMUX_UI_TEST_MODE"] = "1" app.launchEnvironment["CMUX_UI_TEST_FEED_URL"] = "https://cmux.test/appcast.xml" app.launchEnvironment["CMUX_UI_TEST_FEED_MODE"] = "available" @@ -79,6 +89,41 @@ final class SidebarHelpMenuUITests: XCTestCase { XCTAssertTrue(app.staticTexts["99.0.0"].exists) } + func testHelpMenuCheckForUpdatesShowsLatestVersionWhenDeferredUpdateIsStale() { + let app = XCUIApplication() + configureEnglishLocale(app) + app.launchEnvironment["CMUX_UI_TEST_MODE"] = "1" + app.launchEnvironment["CMUX_UI_TEST_FEED_URL"] = "https://cmux.test/appcast.xml" + app.launchEnvironment["CMUX_UI_TEST_FEED_MODE"] = "available" + app.launchEnvironment["CMUX_UI_TEST_UPDATE_VERSION"] = "99.0.0" + app.launchEnvironment["CMUX_UI_TEST_DEFERRED_UPDATE_VERSION"] = "98.0.0" + app.launchEnvironment["CMUX_UI_TEST_AUTO_ALLOW_PERMISSION"] = "1" + launchAndActivate(app) + + XCTAssertTrue(waitForWindowCount(atLeast: 1, app: app, timeout: 6.0)) + + let helpButton = requireElement( + candidates: helpButtonCandidates(in: app), + timeout: 6.0, + description: "sidebar help button" + ) + helpButton.click() + + let checkForUpdatesItem = requireElement( + candidates: helpMenuItemCandidates(in: app, identifier: "SidebarHelpMenuOptionCheckForUpdates", title: "Check for Updates"), + timeout: 3.0, + description: "Check for Updates help menu item" + ) + checkForUpdatesItem.click() + + let installButton = app.buttons["Install Update"] + XCTAssertTrue(installButton.waitForExistence(timeout: 8.0)) + XCTAssertTrue(app.buttons["Remind Me Later"].exists) + XCTAssertTrue(app.buttons["Skip This Version"].exists) + XCTAssertTrue(app.staticTexts["99.0.0"].exists) + XCTAssertFalse(app.staticTexts["98.0.0"].exists) + } + func testHelpMenuSendFeedbackOpensComposerSheet() { let app = XCUIApplication() app.launchEnvironment["CMUX_UI_TEST_MODE"] = "1"