Skip to content

Commit df900b9

Browse files
committed
Add error/timeout logic to appstore downloads.
1 parent 9ab4124 commit df900b9

File tree

2 files changed

+178
-86
lines changed

2 files changed

+178
-86
lines changed

Pearcleaner/Logic/AppsUpdater/AppStoreUpdater.swift

Lines changed: 135 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -10,102 +10,172 @@ import CommerceKit
1010
import StoreFoundation
1111
import AlinFoundation
1212

13-
class AppStoreUpdater: NSObject, @unchecked Sendable {
14-
static let shared = AppStoreUpdater()
15-
16-
private let callbackQueue = DispatchQueue(label: "com.pearcleaner.appstoreupdater")
17-
private var progressCallbacks: [UInt64: (Double, String) -> Void] = [:]
18-
19-
nonisolated func updateApp(adamID: UInt64, progress: @escaping (Double, String) -> Void) async {
20-
callbackQueue.sync {
21-
progressCallbacks[adamID] = progress
22-
}
13+
// MARK: - Error Types
2314

24-
// Create SSPurchase for downloading
25-
let purchase = await SSPurchase(adamID: adamID, purchasing: false)
15+
enum AppStoreUpdateError: Error {
16+
case noDownloads
17+
case downloadFailed(String)
18+
case downloadCancelled
19+
case networkError(Error)
20+
}
2621

27-
// Start observing download queue
28-
_ = CKDownloadQueue.shared().add(self)
22+
// MARK: - AppStoreUpdater
2923

30-
// Perform purchase/update - bridge to async context
31-
await withCheckedContinuation { (continuation: CheckedContinuation<Void, Never>) in
32-
CKPurchaseController.shared().perform(purchase, withOptions: 0) { [weak self] _, _, error, response in
33-
if let error = error {
34-
progress(0.0, "Error: \(error.localizedDescription)")
35-
continuation.resume()
36-
return
37-
}
24+
class AppStoreUpdater {
25+
static let shared = AppStoreUpdater()
3826

39-
if response?.downloads?.isEmpty == false {
40-
progress(0.0, "Starting download...")
41-
} else {
42-
progress(1.0, "Already up to date")
43-
_ = self?.callbackQueue.sync {
44-
self?.progressCallbacks.removeValue(forKey: adamID)
27+
private init() {}
28+
29+
/// Update an app from the App Store with progress tracking
30+
/// - Parameters:
31+
/// - adamID: The App Store ID of the app
32+
/// - progress: Progress callback (percent: 0.0-1.0, status message)
33+
/// - attemptCount: Number of retry attempts for network errors (default: 3)
34+
func updateApp(
35+
adamID: UInt64,
36+
progress: @escaping @Sendable (Double, String) -> Void,
37+
attemptCount: UInt32 = 3
38+
) async throws {
39+
do {
40+
// Create SSPurchase for downloading (purchasing: false = update existing app)
41+
let purchase = await SSPurchase(adamID: adamID, purchasing: false)
42+
43+
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
44+
CKPurchaseController.shared().perform(purchase, withOptions: 0) { _, _, error, response in
45+
if let error = error {
46+
continuation.resume(throwing: error)
47+
} else if response?.downloads?.isEmpty == false {
48+
// Download started - create observer to track it
49+
Task {
50+
do {
51+
let observer = AppStoreDownloadObserver(adamID: adamID, progress: progress)
52+
try await observer.observeDownloadQueue()
53+
continuation.resume()
54+
} catch {
55+
continuation.resume(throwing: error)
56+
}
57+
}
58+
} else {
59+
// No downloads means already up to date
60+
progress(1.0, "Already up to date")
61+
continuation.resume()
4562
}
4663
}
47-
continuation.resume()
4864
}
65+
} catch {
66+
// Retry logic for network errors (like mas does)
67+
guard attemptCount > 1 else {
68+
throw error
69+
}
70+
71+
// Only retry network errors
72+
guard (error as NSError).domain == NSURLErrorDomain else {
73+
throw error
74+
}
75+
76+
let remainingAttempts = attemptCount - 1
77+
try await updateApp(adamID: adamID, progress: progress, attemptCount: remainingAttempts)
4978
}
5079
}
5180
}
5281

53-
// CKDownloadQueueObserver implementation
54-
extension AppStoreUpdater: CKDownloadQueueObserver {
55-
nonisolated func downloadQueue(_ queue: CKDownloadQueue, changedWithAddition download: SSDownload) {
56-
// Download was added to queue
82+
// MARK: - AppStoreDownloadObserver
83+
84+
/// Per-download observer that tracks a single App Store download/update
85+
/// This matches the architecture used by mas CLI tool
86+
private final class AppStoreDownloadObserver: NSObject, CKDownloadQueueObserver {
87+
private let adamID: UInt64
88+
private let progressCallback: @Sendable (Double, String) -> Void
89+
private var completionHandler: (() -> Void)?
90+
private var errorHandler: ((Error) -> Void)?
91+
92+
init(adamID: UInt64, progress: @escaping @Sendable (Double, String) -> Void) {
93+
self.adamID = adamID
94+
self.progressCallback = progress
95+
super.init()
5796
}
5897

59-
nonisolated func downloadQueue(_ queue: CKDownloadQueue, changedWithRemoval download: SSDownload) {
60-
// Download was removed from queue - cleanup
61-
if let adamID = download.metadata?.itemIdentifier {
62-
_ = callbackQueue.sync {
63-
progressCallbacks.removeValue(forKey: adamID)
98+
/// Observe the download queue until this download completes
99+
/// Uses defer to ensure observer is always removed when done
100+
func observeDownloadQueue(_ queue: CKDownloadQueue = .shared()) async throws {
101+
let observerID = queue.add(self)
102+
defer {
103+
queue.removeObserver(observerID)
104+
}
105+
106+
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
107+
completionHandler = { [weak self] in
108+
self?.completionHandler = nil
109+
self?.errorHandler = nil
110+
continuation.resume()
111+
}
112+
errorHandler = { [weak self] error in
113+
self?.completionHandler = nil
114+
self?.errorHandler = nil
115+
continuation.resume(throwing: error)
64116
}
65117
}
66118
}
67119

68-
nonisolated func downloadQueue(_ queue: CKDownloadQueue, statusChangedFor download: SSDownload) {
69-
guard let adamID = download.metadata?.itemIdentifier else {
120+
// MARK: - CKDownloadQueueObserver Delegate Methods
121+
122+
func downloadQueue(_ queue: CKDownloadQueue, changedWithAddition download: SSDownload) {
123+
// Download was added to queue - no action needed
124+
}
125+
126+
func downloadQueue(_ queue: CKDownloadQueue, changedWithRemoval download: SSDownload) {
127+
guard let metadata = download.metadata,
128+
metadata.itemIdentifier == adamID,
129+
let status = download.status else {
70130
return
71131
}
72132

73-
let callback = callbackQueue.sync { progressCallbacks[adamID] }
74-
guard let callback = callback else { return }
133+
// This is the official completion signal from CommerceKit
134+
if status.isFailed {
135+
let error = status.error ?? AppStoreUpdateError.downloadFailed("Download failed")
136+
errorHandler?(error)
137+
} else if status.isCancelled {
138+
errorHandler?(AppStoreUpdateError.downloadCancelled)
139+
} else {
140+
// Success!
141+
progressCallback(1.0, "Completed")
142+
completionHandler?()
143+
}
144+
}
75145

76-
guard let status = download.status,
77-
let activePhase = status.activePhase else { return }
146+
func downloadQueue(_ queue: CKDownloadQueue, statusChangedFor download: SSDownload) {
147+
guard let metadata = download.metadata,
148+
metadata.itemIdentifier == adamID,
149+
let status = download.status,
150+
let activePhase = status.activePhase else {
151+
return
152+
}
78153

79154
let phaseType = activePhase.phaseType
80-
81-
// Read progress from status (statusChangedFor fires frequently during download)
82155
let percentComplete = status.percentComplete // Float: 0.0 to 1.0
83156
let progress = max(0.0, min(1.0, Double(percentComplete)))
84157

85-
printOS("🔄 App Store status changed for adamID \(adamID) - phaseType: \(phaseType), progress: \(String(format: "%.1f", progress * 100))%")
158+
// Report progress based on phase
159+
// Special case: at 100%, always show "Installing..." (CommerceKit sometimes resets to phase 0 at completion)
160+
if progress >= 1.0 {
161+
progressCallback(progress, "Installing...")
162+
} else {
163+
switch phaseType {
164+
case 0: // Downloading
165+
progressCallback(progress, "Downloading...")
86166

87-
switch phaseType {
88-
case 0: // Downloading
89-
printOS(" 📥 Downloading...")
90-
callback(progress, "Downloading...")
167+
case 1: // Installing
168+
progressCallback(progress, "Installing...")
91169

92-
case 1: // Installing
93-
printOS(" 📦 Installing...")
94-
callback(progress, "Installing...")
170+
case 4: // Initial/Preparing
171+
progressCallback(progress, "Preparing...")
95172

96-
case 5: // Complete
97-
printOS(" ✅ Complete")
173+
case 5: // Downloaded (not complete yet - wait for changedWithRemoval)
174+
progressCallback(progress, "Installing...")
98175

99-
// Cleanup callback
100-
_ = callbackQueue.sync {
101-
progressCallbacks.removeValue(forKey: adamID)
176+
default:
177+
progressCallback(progress, "Processing...")
102178
}
103-
104-
callback(1.0, "Completed")
105-
106-
default:
107-
printOS(" ❓ Unknown phase type: \(phaseType)")
108-
callback(progress, "Processing...")
109179
}
110180
}
111181
}

Pearcleaner/Logic/AppsUpdater/UpdateManager.swift

Lines changed: 43 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -342,31 +342,53 @@ class UpdateManager: ObservableObject {
342342
updatesBySource[.appStore] = apps
343343
}
344344

345-
// Perform update
346-
await AppStoreUpdater.shared.updateApp(adamID: adamID) { [weak self] progress, status in
347-
Task { @MainActor in
348-
guard let self = self else { return }
349-
if var apps = self.updatesBySource[.appStore],
350-
let index = apps.firstIndex(where: { $0.id == app.id }) {
351-
apps[index].progress = progress
352-
353-
// Update status based on App Store phase
354-
if status.contains("Installing") {
355-
// Phase 1: App Store is removing old bundle and installing new one
356-
apps[index].status = .installing
357-
self.updatesBySource[.appStore] = apps
358-
} else if status.contains("Completed") {
359-
// Phase 5: CommerceKit finished - remove from list and refresh
360-
Task {
361-
await self.removeFromUpdatesList(appID: app.id, source: .appStore)
362-
await self.refreshApps(updatedApp: app.appInfo)
345+
// Perform update (new API throws errors)
346+
do {
347+
try await AppStoreUpdater.shared.updateApp(adamID: adamID) { [weak self] progress, status in
348+
Task { @MainActor in
349+
guard let self = self else { return }
350+
if var apps = self.updatesBySource[.appStore],
351+
let index = apps.firstIndex(where: { $0.id == app.id }) {
352+
apps[index].progress = progress
353+
354+
// Update status based on App Store phase
355+
if status.contains("Downloading") || status.contains("Preparing") {
356+
// Phase 0 or 4: Downloading or preparing
357+
apps[index].status = .downloading
358+
self.updatesBySource[.appStore] = apps
359+
} else if status.contains("Installing") {
360+
// Phase 1: Installing
361+
apps[index].status = .installing
362+
self.updatesBySource[.appStore] = apps
363+
} else if status.contains("Completed") || status.contains("Already up to date") {
364+
// Phase 5 or no download needed: Complete - remove from list and refresh
365+
Task {
366+
await self.removeFromUpdatesList(appID: app.id, source: .appStore)
367+
await self.refreshApps(updatedApp: app.appInfo)
368+
}
369+
} else {
370+
// Other phases: Keep updating progress but maintain current status
371+
self.updatesBySource[.appStore] = apps
363372
}
364-
} else {
365-
// Phase 0 or other: Keep current status or set to downloading
366-
self.updatesBySource[.appStore] = apps
367373
}
368374
}
369375
}
376+
377+
// Update succeeded - refresh happens via completion callback above
378+
printOS("✅ App Store update completed for adamID \(adamID)")
379+
380+
} catch {
381+
// Handle errors from the new throwing API
382+
let message = error.localizedDescription
383+
printOS("❌ App Store update failed for adamID \(adamID): \(message)")
384+
385+
// Update UI to show error (matching Sparkle's error display pattern)
386+
if var apps = updatesBySource[.appStore],
387+
let index = apps.firstIndex(where: { $0.id == app.id }) {
388+
apps[index].status = .failed(message)
389+
apps[index].progress = 0.0
390+
updatesBySource[.appStore] = apps
391+
}
370392
}
371393
}
372394

0 commit comments

Comments
 (0)