@@ -2,36 +2,29 @@ import Foundation
22import IDeviceSwift
33import Combine
44
5- // MARK: - Error Transformer (existing helpers kept)
5+ // MARK: - Error Transformer
66private func transformInstallError( _ error: Error ) -> Error {
77 let nsError = error as NSError
88 let errorString = String ( describing: error)
99
10- var userMessage = extractUserReadableErrorMessage ( from: error)
11-
12- if let userMessage = userMessage, !userMessage. isEmpty {
10+ if let userMessage = extractUserReadableErrorMessage ( from: error) , !userMessage. isEmpty {
1311 return NSError ( domain: nsError. domain, code: nsError. code, userInfo: [ NSLocalizedDescriptionKey: userMessage] )
1412 }
1513
1614 if errorString. contains ( " error 1. " ) {
1715 if errorString. contains ( " Missing Pairing " ) {
1816 return NSError ( domain: nsError. domain, code: nsError. code, userInfo: [ NSLocalizedDescriptionKey: " Missing pairing file. Please ensure pairing file exists in ProStore folder. " ] )
1917 }
20-
2118 if errorString. contains ( " Cannot connect to AFC " ) || errorString. contains ( " afc_client_connect " ) {
2219 return NSError ( domain: nsError. domain, code: nsError. code, userInfo: [ NSLocalizedDescriptionKey: " Cannot connect to AFC. Check USB connection, VPN, and accept trust dialog on device. " ] )
2320 }
24-
2521 if errorString. contains ( " installation_proxy " ) {
2622 return NSError ( domain: nsError. domain, code: nsError. code, userInfo: [ NSLocalizedDescriptionKey: " Installation service failed. The app may already be installed or device storage full. " ] )
2723 }
28-
2924 return NSError ( domain: nsError. domain, code: nsError. code, userInfo: [ NSLocalizedDescriptionKey: " Installation failed. Make sure: 1) VPN is on, 2) Device is connected via USB, 3) Trust dialog is accepted, 4) Pairing file is in ProStore folder. " ] )
3025 }
3126
32- let originalMessage = nsError. localizedDescription
33- let cleanedMessage = cleanGenericErrorMessage ( originalMessage)
34-
27+ let cleanedMessage = cleanGenericErrorMessage ( nsError. localizedDescription)
3528 return NSError ( domain: nsError. domain, code: nsError. code, userInfo: [ NSLocalizedDescriptionKey: cleanedMessage] )
3629}
3730
@@ -41,7 +34,6 @@ private func extractUserReadableErrorMessage(from error: Error) -> String? {
4134 }
4235
4336 let errorString = String ( describing: error)
44-
4537 let patterns = [
4638 " Missing Pairing " : " Missing pairing file. Please check ProStore folder. " ,
4739 " Cannot connect to AFC " : " Cannot connect to device. Check USB and VPN. " ,
@@ -51,10 +43,8 @@ private func extractUserReadableErrorMessage(from error: Error) -> String? {
5143 " Connection Failed: " : " Connection to device failed. "
5244 ]
5345
54- for (pattern, message) in patterns {
55- if errorString. contains ( pattern) {
56- return message
57- }
46+ for (pattern, message) in patterns where errorString. contains ( pattern) {
47+ return message
5848 }
5949
6050 let nsError = error as NSError
@@ -69,44 +59,34 @@ private func extractUserReadableErrorMessage(from error: Error) -> String? {
6959
7060private func cleanGenericErrorMessage( _ message: String ) -> String {
7161 var cleaned = message
72-
7362 let genericPrefixes = [
7463 " The operation couldn't be completed. " ,
7564 " The operation could not be completed. " ,
7665 " IDeviceSwift.IDeviceSwiftError " ,
7766 " IDeviceSwiftError "
7867 ]
79-
80- for prefix in genericPrefixes {
81- if cleaned. hasPrefix ( prefix) {
82- cleaned = String ( cleaned. dropFirst ( prefix. count) )
83- break
84- }
85- }
86-
87- if cleaned. hasSuffix ( " . " ) {
88- cleaned = String ( cleaned. dropLast ( ) )
68+ for prefix in genericPrefixes where cleaned. hasPrefix ( prefix) {
69+ cleaned = String ( cleaned. dropFirst ( prefix. count) )
70+ break
8971 }
90-
72+ if cleaned . hasSuffix ( " . " ) { cleaned = String ( cleaned . dropLast ( ) ) }
9173 if cleaned == " error 1 " || cleaned == " error 1. " {
9274 return " Device installation failed. Please check: 1) VPN connection, 2) USB cable, 3) Trust dialog, 4) Pairing file. "
9375 }
94-
9576 return cleaned. isEmpty ? " Unknown installation error " : cleaned
9677}
9778
9879// MARK: - Install App
9980/// Installs a signed IPA on the device using InstallationProxy
100- public func installApp( from ipaURL: URL ) async throws
101- -> AsyncThrowingStream < ( progress: Double , status: String ) , Error > {
81+ public func installApp( from ipaURL: URL ) async throws -> AsyncThrowingStream < ( progress: Double , status: String ) , Error > {
10282
103- // Pre-flight check: verify IPA exists
83+ // Pre-flight IPA check
10484 let fileManager = FileManager . default
10585 guard fileManager. fileExists ( atPath: ipaURL. path) else {
10686 throw NSError ( domain: " InstallApp " , code: - 1 , userInfo: [ NSLocalizedDescriptionKey: " IPA file not found: \( ipaURL. lastPathComponent) " ] )
10787 }
10888
109- // Check file size
89+ // Validate file size
11090 do {
11191 let attributes = try fileManager. attributesOfItem ( atPath: ipaURL. path)
11292 let fileSize = attributes [ . size] as? Int64 ?? 0
@@ -119,109 +99,65 @@ public func installApp(from ipaURL: URL) async throws
11999
120100 print ( " Installing app from: \( ipaURL. path) " )
121101
122- // === IMPORTANT: explicitly specify element and failure types so the compiler selects
123- // the continuation-style initializer (the one that passes `continuation` to the closure).
124102 typealias InstallUpdate = ( progress: Double , status: String )
125103 typealias StreamContinuation = AsyncThrowingStream < InstallUpdate , Error > . Continuation
126104
127105 return AsyncThrowingStream < InstallUpdate , Error > { continuation in
128106 var cancellables = Set < AnyCancellable > ( )
129107 var installTask : Task < Void , Never > ?
130108
131- // Explicitly annotate the termination parameter type so compiler is happy
132- continuation. onTermination = { @Sendable ( reason: StreamContinuation . Termination) in
109+ continuation. onTermination = { @Sendable reason in
133110 print ( " Install stream terminated: \( reason) " )
134111 cancellables. removeAll ( )
135- // cancel running install task if still active
136112 installTask? . cancel ( )
137113 }
138114
139115 installTask = Task {
140- // Start heartbeat to keep connection alive
141116 HeartbeatManager . shared. start ( )
142-
143- // initialize view model same as UI code (important)
144117 let isIdevice = UserDefaults . standard. integer ( forKey: " Feather.installationMethod " ) == 1
145118 let viewModel = InstallerStatusViewModel ( isIdevice: isIdevice)
146119
147- // Debug logging for status changes
148- // Watch status for completion / errors (replace the previous $isCompleted sink)
149- viewModel. $status
150- . sink { newStatus in
151- // If the view model exposes a computed Bool isCompleted, use it (safe regardless of enum shape)
152- if viewModel. isCompleted {
153- print ( " [Installer] detected completion via viewModel.isCompleted " )
154- continuation. yield ( ( 1.0 , " ✅ Successfully installed app! " ) )
155- continuation. finish ( )
156- cancellables. removeAll ( )
157- return
158- }
159-
160- // If the status enum contains an error / broken case, finish with that error
161- // This covers the case where broken carries an associated Error
162- if case . broken( let error) = newStatus {
163- print ( " [Installer] detected broken status -> " , error)
164- continuation. finish ( throwing: transformInstallError ( error) )
165- cancellables. removeAll ( )
166- return
167- }
168-
169- // Optional: handle a status that carries failure inside .completed (if that variant exists)
170- // e.g. if your enum had `.completed(.failure(let e))` then add handling here.
171- }
172- . store ( in: & cancellables)
120+ // Status updates
121+ viewModel. $status
122+ . sink { newStatus in
123+ if viewModel. isCompleted {
124+ print ( " [Installer] detected completion via isCompleted " )
125+ continuation. yield ( ( 1.0 , " ✅ Successfully installed app! " ) )
126+ continuation. finish ( )
127+ cancellables. removeAll ( )
128+ }
129+ if case . broken( let error) = newStatus {
130+ continuation. finish ( throwing: transformInstallError ( error) )
131+ cancellables. removeAll ( )
132+ }
133+ }
134+ . store ( in: & cancellables)
173135
174- // Progress stream (combine upload & install progress )
136+ // Progress stream (upload + install)
175137 viewModel. $uploadProgress
176138 . combineLatest ( viewModel. $installProgress)
177- . sink { uploadProgress , installProgress in
178- let overall = ( uploadProgress + installProgress ) / 2.0
139+ . sink { upload , install in
140+ let overall = ( upload + install ) / 2
179141 let statusText : String
180- if uploadProgress < 1.0 {
181- statusText = " 📤 Uploading... "
182- } else if installProgress < 1.0 {
183- statusText = " 📲 Installing... "
184- } else {
185- statusText = " 🏁 Finalizing... "
186- }
187- print ( " [Installer] progress upload: \( uploadProgress) install: \( installProgress) overall: \( overall) " )
142+ if upload < 1.0 { statusText = " 📤 Uploading... " }
143+ else if install < 1.0 { statusText = " 📲 Installing... " }
144+ else { statusText = " 🏁 Finalizing... " }
145+ print ( " [Installer] progress upload: \( upload) install: \( install) overall: \( overall) " )
188146 continuation. yield ( ( overall, statusText) )
189147 }
190148 . store ( in: & cancellables)
191149
192- // Robust completion detection: watch isCompleted
193- viewModel. $isCompleted
194- . sink { completed in
195- if completed {
196- print ( " [Installer] detected completion via isCompleted=true " )
197- continuation. yield ( ( 1.0 , " ✅ Successfully installed app! " ) )
198- continuation. finish ( )
199- cancellables. removeAll ( )
200- }
201- }
202- . store ( in: & cancellables)
203-
204150 do {
205151 let installer = await InstallationProxy ( viewModel: viewModel)
206-
207- // If your UI calls install(at: suspend:) when updating itself,
208- // replicate that logic here if you need that behaviour.
209152 try await installer. install ( at: ipaURL)
210-
211- // small delay to let final progress propagate
212153 try await Task . sleep ( nanoseconds: 500_000_000 )
213-
214154 print ( " Installation call returned — waiting for viewModel to report completion. " )
215-
216- // Note: we intentionally don't call continuation.finish() here -
217- // we rely on viewModel.$isCompleted to finish the stream so the
218- // installer has its normal lifecycle.
219-
155+ // Stream finishes when viewModel.isCompleted becomes true or status reports broken/error
220156 } catch {
221157 print ( " [Installer] install threw error -> " , error)
222158 continuation. finish ( throwing: transformInstallError ( error) )
223159 cancellables. removeAll ( )
224160 }
225161 }
226162 }
227- }
163+ }
0 commit comments