@@ -10,102 +10,172 @@ import CommerceKit
1010import StoreFoundation
1111import 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}
0 commit comments