diff --git a/Sources/Update/UpdateController.swift b/Sources/Update/UpdateController.swift index 3ac1394a911b..95e312ce661e 100644 --- a/Sources/Update/UpdateController.swift +++ b/Sources/Update/UpdateController.swift @@ -99,6 +99,7 @@ class UpdateController { /// Start the updater. If startup fails, the error is shown via the custom UI. func startUpdaterIfNeeded() { guard !didStartUpdater else { return } + cleanStaleInstallationCache() ensureSparkleInstallationCache() #if DEBUG // Keep the permission-related defaults resettable for UI tests even though the @@ -329,6 +330,29 @@ class UpdateController { #endif } + private func cleanStaleInstallationCache() { + guard let bundleIdentifier = Bundle.main.bundleIdentifier else { return } + guard let cachesURL = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first else { return } + + let installURL = cachesURL + .appendingPathComponent(bundleIdentifier) + .appendingPathComponent("org.sparkle-project.Sparkle") + .appendingPathComponent("Installation") + + let fm = FileManager.default + guard let contents = try? fm.contentsOfDirectory(at: installURL, includingPropertiesForKeys: nil), + !contents.isEmpty else { return } + + for item in contents { + do { + try fm.removeItem(at: item) + UpdateLogStore.shared.append("cleaned stale installation cache: \(item.lastPathComponent)") + } catch { + UpdateLogStore.shared.append("failed to clean installation cache item \(item.lastPathComponent): \(error)") + } + } + } + private func ensureSparkleInstallationCache() { guard let bundleIdentifier = Bundle.main.bundleIdentifier else { return } guard let cachesURL = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first else { return } diff --git a/Sources/Update/UpdateDriver.swift b/Sources/Update/UpdateDriver.swift index 289df890d98f..5cc4c17d40c5 100644 --- a/Sources/Update/UpdateDriver.swift +++ b/Sources/Update/UpdateDriver.swift @@ -9,6 +9,8 @@ class UpdateDriver: NSObject, SPUUserDriver { private var pendingCheckTransition: DispatchWorkItem? private var checkTimeoutWorkItem: DispatchWorkItem? private var lastFeedURLString: String? + private var extractionTimeoutWorkItem: DispatchWorkItem? + private var extractionStartDate: Date? init(viewModel: UpdateViewModel, hostBundle _: Bundle) { self.viewModel = viewModel @@ -117,21 +119,23 @@ class UpdateDriver: NSObject, SPUUserDriver { func showDownloadDidStartExtractingUpdate() { UpdateLogStore.shared.append("show extraction started") - setState(.extracting(.init(progress: 0))) + beginExtraction(progress: 0) } func showExtractionReceivedProgress(_ progress: Double) { UpdateLogStore.shared.append(String(format: "show extraction progress: %.2f", progress)) - setState(.extracting(.init(progress: progress))) + beginExtraction(progress: progress) } func showReady(toInstallAndRelaunch reply: @escaping @Sendable (SPUUserUpdateChoice) -> Void) { - UpdateLogStore.shared.append("show ready to install") + let elapsed = extractionStartDate.map { String(format: "%.1fs", Date().timeIntervalSince($0)) } ?? "unknown" + UpdateLogStore.shared.append("show ready to install (extractionElapsed=\(elapsed))") reply(.install) } func showInstallingUpdate(withApplicationTerminated applicationTerminated: Bool, retryTerminatingApplication: @escaping () -> Void) { - UpdateLogStore.shared.append("show installing update") + let elapsed = extractionStartDate.map { String(format: "%.1fs", Date().timeIntervalSince($0)) } ?? "unknown" + UpdateLogStore.shared.append("show installing update (appTerminated=\(applicationTerminated), extractionElapsed=\(elapsed))") setState(.installing(.init( retryTerminatingApplication: retryTerminatingApplication, dismiss: { [weak viewModel] in @@ -222,6 +226,9 @@ class UpdateDriver: NSObject, SPUUserDriver { checkTimeoutWorkItem?.cancel() checkTimeoutWorkItem = nil lastCheckStart = nil + extractionTimeoutWorkItem?.cancel() + extractionTimeoutWorkItem = nil + extractionStartDate = nil applyState(newState) } } @@ -236,6 +243,61 @@ class UpdateDriver: NSObject, SPUUserDriver { DispatchQueue.main.asyncAfter(deadline: .now() + UpdateTiming.checkTimeoutDuration, execute: workItem) } + private func beginExtraction(progress: Double) { + runOnMain { [weak self] in + guard let self else { return } + pendingCheckTransition?.cancel() + pendingCheckTransition = nil + checkTimeoutWorkItem?.cancel() + checkTimeoutWorkItem = nil + lastCheckStart = nil + extractionTimeoutWorkItem?.cancel() + extractionTimeoutWorkItem = nil + if extractionStartDate == nil { + extractionStartDate = Date() + } + let cancel: () -> Void = { [weak self] in + self?.cancelExtraction() + } + applyState(.extracting(.init(progress: progress, cancel: cancel))) + scheduleExtractionTimeout() + } + } + + private func scheduleExtractionTimeout() { + extractionTimeoutWorkItem?.cancel() + let workItem = DispatchWorkItem { [weak self] in + guard let self else { return } + guard case .extracting = self.viewModel.state else { return } + let elapsed = self.extractionStartDate.map { String(format: "%.0fs", Date().timeIntervalSince($0)) } ?? "unknown" + UpdateLogStore.shared.append("extraction timed out after \(elapsed)") + self.setState(.error(.init( + error: NSError( + domain: "cmux.update", + code: 2, + userInfo: [NSLocalizedDescriptionKey: String(localized: "update.error.extractionStalled", defaultValue: "The update appears to be stuck. Try checking for updates again.")] + ), + retry: { [weak viewModel = self.viewModel] in + viewModel?.state = .idle + DispatchQueue.main.async { + guard let delegate = NSApp.delegate as? AppDelegate else { return } + delegate.checkForUpdates(nil) + } + }, + dismiss: { [weak viewModel = self.viewModel] in + viewModel?.state = .idle + } + ))) + } + extractionTimeoutWorkItem = workItem + DispatchQueue.main.asyncAfter(deadline: .now() + UpdateTiming.extractionTimeoutDuration, execute: workItem) + } + + private func cancelExtraction() { + UpdateLogStore.shared.append("extraction cancelled by user") + setState(.idle) + } + private func applyState(_ newState: UpdateState) { viewModel.state = newState UpdateLogStore.shared.append("state -> \(describe(newState))") diff --git a/Sources/Update/UpdatePopoverView.swift b/Sources/Update/UpdatePopoverView.swift index 2361775d28e8..883dd396366f 100644 --- a/Sources/Update/UpdatePopoverView.swift +++ b/Sources/Update/UpdatePopoverView.swift @@ -26,7 +26,7 @@ struct UpdatePopoverView: View { DownloadingView(download: download, dismiss: dismiss) case .extracting(let extracting): - ExtractingView(extracting: extracting) + ExtractingView(extracting: extracting, dismiss: dismiss) case .installing(let installing): InstallingView(installing: installing, dismiss: dismiss) @@ -247,17 +247,30 @@ fileprivate struct DownloadingView: View { fileprivate struct ExtractingView: View { let extracting: UpdateState.Extracting + let dismiss: DismissAction var body: some View { - VStack(alignment: .leading, spacing: 8) { - Text(String(localized: "update.popover.preparingUpdate", defaultValue: "Preparing Update")) - .font(.system(size: 13, weight: .semibold)) + VStack(alignment: .leading, spacing: 16) { + VStack(alignment: .leading, spacing: 8) { + Text(String(localized: "update.popover.preparingUpdate", defaultValue: "Preparing Update")) + .font(.system(size: 13, weight: .semibold)) - VStack(alignment: .leading, spacing: 6) { - ProgressView(value: min(1, max(0, extracting.progress)), total: 1.0) - Text(String(format: "%.0f%%", min(1, max(0, extracting.progress)) * 100)) - .font(.system(size: 11)) - .foregroundColor(.secondary) + VStack(alignment: .leading, spacing: 6) { + ProgressView(value: min(1, max(0, extracting.progress)), total: 1.0) + Text(String(format: "%.0f%%", min(1, max(0, extracting.progress)) * 100)) + .font(.system(size: 11)) + .foregroundColor(.secondary) + } + } + + HStack { + Spacer() + Button(String(localized: "common.cancel", defaultValue: "Cancel")) { + extracting.cancel() + dismiss() + } + .keyboardShortcut(.cancelAction) + .controlSize(.small) } } .padding(16) diff --git a/Sources/Update/UpdateTiming.swift b/Sources/Update/UpdateTiming.swift index 4a2e7beee374..af17ae19f817 100644 --- a/Sources/Update/UpdateTiming.swift +++ b/Sources/Update/UpdateTiming.swift @@ -4,4 +4,5 @@ enum UpdateTiming { static let minimumCheckDisplayDuration: TimeInterval = 2.0 static let noUpdateDisplayDuration: TimeInterval = 5.0 static let checkTimeoutDuration: TimeInterval = 10.0 + static let extractionTimeoutDuration: TimeInterval = 300.0 } diff --git a/Sources/Update/UpdateViewModel.swift b/Sources/Update/UpdateViewModel.swift index 93b4b79991cf..d988d455b7b7 100644 --- a/Sources/Update/UpdateViewModel.swift +++ b/Sources/Update/UpdateViewModel.swift @@ -418,6 +418,8 @@ enum UpdateState: Equatable { available.reply(.dismiss) case .downloading(let downloading): downloading.cancel() + case .extracting(let extracting): + extracting.cancel() case .notFound(let notFound): notFound.acknowledgement() case .error(let err): @@ -568,6 +570,7 @@ enum UpdateState: Equatable { struct Extracting { let progress: Double + let cancel: () -> Void } struct Installing {