1- // installApp.swift
1+ // installApp.swift
22import Foundation
3- import IDeviceSwift
43import Combine
4+ import IDeviceSwift
55
6- // MARK: - Error Transformer
6+ // MARK: - Error Transformer (kept + improved)
77private func transformInstallError( _ error: Error ) -> Error {
88 let nsError = error as NSError
99 let errorString = String ( describing: error)
10-
11- // Extract real error message from various formats
12- var userMessage = extractUserReadableErrorMessage ( from: error)
13-
14- // If we got a good message, use it
15- if let userMessage = userMessage, !userMessage. isEmpty {
16- return NSError (
17- domain: nsError. domain,
18- code: nsError. code,
19- userInfo: [ NSLocalizedDescriptionKey: userMessage]
20- )
10+
11+ // Try to get a readable error first
12+ if let userMessage = extractUserReadableErrorMessage ( from: error) ,
13+ !userMessage. isEmpty {
14+ return NSError ( domain: nsError. domain,
15+ code: nsError. code,
16+ userInfo: [ NSLocalizedDescriptionKey: userMessage] )
2117 }
22-
23- // Fallback: Generic error 1 handling
24- if errorString. contains ( " error 1. " ) {
25- // Check for specific patterns in the error string
18+
19+ // Specific patterns for "error 1" cases or common idevice failure messages
20+ if errorString. contains ( " error 1 " ) || errorString. contains ( " error 1. " ) {
2621 if errorString. contains ( " Missing Pairing " ) {
27- return NSError (
28- domain: nsError. domain,
29- code: nsError. code,
30- userInfo: [ NSLocalizedDescriptionKey: " Missing pairing file. Please ensure pairing file exists in ProStore folder. " ]
31- )
22+ return NSError ( domain: nsError. domain,
23+ code: nsError. code,
24+ userInfo: [ NSLocalizedDescriptionKey:
25+ " Missing pairing file. Please ensure pairing file exists in the ProStore folder. " ] )
3226 }
33-
34- if errorString. contains ( " Cannot connect to AFC " ) || errorString. contains ( " afc_client_connect " ) {
35- return NSError (
36- domain: nsError. domain,
37- code: nsError. code,
38- userInfo: [ NSLocalizedDescriptionKey: " Cannot connect to AFC. Check USB connection, VPN, and accept trust dialog on device. " ]
39- )
27+ if errorString. contains ( " afc_client_connect " ) || errorString. contains ( " Cannot connect to AFC " ) {
28+ return NSError ( domain: nsError. domain,
29+ code: nsError. code,
30+ userInfo: [ NSLocalizedDescriptionKey:
31+ " Cannot connect to AFC. Check USB connection, enable VPN loopback and accept the trust dialog on the device. " ] )
4032 }
41-
4233 if errorString. contains ( " installation_proxy " ) {
43- return NSError (
44- domain: nsError. domain,
45- code: nsError. code,
46- userInfo: [ NSLocalizedDescriptionKey: " Installation service failed. The app may already be installed or device storage full. " ]
47- )
34+ return NSError ( domain: nsError. domain,
35+ code: nsError. code,
36+ userInfo: [ NSLocalizedDescriptionKey:
37+ " Installation service failed. The app may already be installed or device storage is full. " ] )
4838 }
49-
50- // Generic error 1 message
51- return NSError (
52- domain: nsError. domain,
53- code: nsError. code,
54- 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. " ]
55- )
39+
40+ return NSError ( domain: nsError. domain,
41+ code: nsError. code,
42+ userInfo: [ NSLocalizedDescriptionKey:
43+ " 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. " ] )
5644 }
57-
58- // Try to clean up the generic message
59- let originalMessage = nsError. localizedDescription
60- let cleanedMessage = cleanGenericErrorMessage ( originalMessage)
61-
62- return NSError (
63- domain: nsError. domain,
64- code: nsError. code,
65- userInfo: [ NSLocalizedDescriptionKey: cleanedMessage]
66- )
45+
46+ // Clean and fallback
47+ let cleaned = cleanGenericErrorMessage ( nsError. localizedDescription)
48+ return NSError ( domain: nsError. domain,
49+ code: nsError. code,
50+ userInfo: [ NSLocalizedDescriptionKey: cleaned] )
6751}
6852
69- // Extract user-readable message from error
7053private func extractUserReadableErrorMessage( from error: Error ) -> String ? {
71- // Try to get error description from LocalizedError
7254 if let localizedError = error as? LocalizedError {
73- return localizedError. errorDescription
55+ if let desc = localizedError. errorDescription, !desc . isEmpty { return desc }
7456 }
75-
76- let errorString = String ( describing: error)
77-
78- // Look for specific error patterns in the string representation
79- let patterns = [
57+
58+ let errString = String ( describing: error)
59+
60+ let patterns : [ String : String ] = [
8061 " Missing Pairing " : " Missing pairing file. Please check ProStore folder. " ,
81- " Cannot connect to AFC " : " Cannot connect to device. Check USB and VPN . " ,
62+ " Cannot connect to AFC " : " Cannot connect to device. Check LocalDevVPN . " ,
8263 " AFC Error: " : " Device communication failed. " ,
8364 " Installation Error: " : " App installation failed. " ,
8465 " File Error: " : " File operation failed. " ,
8566 " Connection Failed: " : " Connection to device failed. "
8667 ]
87-
68+
8869 for (pattern, message) in patterns {
89- if errorString . contains ( pattern) {
70+ if errString . contains ( pattern) {
9071 return message
9172 }
9273 }
93-
94- // Try to extract from NSError userInfo
74+
9575 let nsError = error as NSError
9676 if let userInfoMessage = nsError. userInfo [ NSLocalizedDescriptionKey] as? String ,
9777 !userInfoMessage. isEmpty,
9878 userInfoMessage != nsError. localizedDescription {
9979 return userInfoMessage
10080 }
101-
81+
10282 return nil
10383}
10484
105- // Clean up generic error messages
10685private func cleanGenericErrorMessage( _ message: String ) -> String {
10786 var cleaned = message
108-
109- // Remove the generic prefix
87+
11088 let genericPrefixes = [
11189 " The operation couldn't be completed. " ,
11290 " The operation could not be completed. " ,
11391 " IDeviceSwift.IDeviceSwiftError " ,
11492 " IDeviceSwiftError "
11593 ]
116-
94+
11795 for prefix in genericPrefixes {
11896 if cleaned. hasPrefix ( prefix) {
11997 cleaned = String ( cleaned. dropFirst ( prefix. count) )
12098 break
12199 }
122100 }
123-
124- // Remove trailing period if present
101+
125102 if cleaned. hasSuffix ( " . " ) {
126103 cleaned = String ( cleaned. dropLast ( ) )
127104 }
128-
129- // If it's just "error 1.", provide more helpful message
105+
130106 if cleaned == " error 1 " || cleaned == " error 1. " {
131107 return " Device installation failed. Please check: 1) VPN connection, 2) USB cable, 3) Trust dialog, 4) Pairing file. "
132108 }
133-
109+
134110 return cleaned. isEmpty ? " Unknown installation error " : cleaned
135111}
136112
137113// MARK: - Install App
138- /// Installs a signed IPA on the device using InstallationProxy
139- public func installApp( from ipaURL: URL ) async throws
140- -> AsyncThrowingStream < ( progress: Double , status: String ) , Error > {
114+ /// Installs a signed IPA on the device using InstallationProxy.
115+ /// NOTE:
116+ /// - If your UI layer already has a shared `InstallationProxy` or `InstallerStatusViewModel`,
117+ /// pass it via the `installer` parameter so we observe the *same* viewModel the installer updates.
118+ /// - If you don't pass one, we attempt to create a fresh `InstallationProxy()` and use its `viewModel`.
119+ public func installApp(
120+ from ipaURL: URL ,
121+ using installer: InstallationProxy ? = nil
122+ ) async throws -> AsyncThrowingStream < ( progress: Double , status: String ) , Error > {
141123
142- // Pre-flight check: verify IPA exists
124+ // Pre-flight check: verify IPA exists and is reasonable size
143125 let fileManager = FileManager . default
144126 guard fileManager. fileExists ( atPath: ipaURL. path) else {
145- throw NSError (
146- domain: " InstallApp " ,
147- code: - 1 ,
148- userInfo: [ NSLocalizedDescriptionKey: " IPA file not found: \( ipaURL. lastPathComponent) " ]
149- )
127+ throw NSError ( domain: " InstallApp " ,
128+ code: - 1 ,
129+ userInfo: [ NSLocalizedDescriptionKey: " IPA file not found: \( ipaURL. lastPathComponent) " ] )
150130 }
151-
152- // Check file size
131+
153132 do {
154133 let attributes = try fileManager. attributesOfItem ( atPath: ipaURL. path)
155134 let fileSize = attributes [ . size] as? Int64 ?? 0
156135 guard fileSize > 1024 else {
157- throw NSError (
158- domain: " InstallApp " ,
159- code: - 1 ,
160- userInfo: [ NSLocalizedDescriptionKey: " IPA file is too small or invalid " ]
161- )
136+ throw NSError ( domain: " InstallApp " ,
137+ code: - 1 ,
138+ userInfo: [ NSLocalizedDescriptionKey: " IPA file is too small or invalid " ] )
162139 }
163140 } catch {
164- throw NSError (
165- domain: " InstallApp " ,
166- code: - 1 ,
167- userInfo: [ NSLocalizedDescriptionKey: " Cannot read IPA file " ]
168- )
141+ throw NSError ( domain: " InstallApp " ,
142+ code: - 1 ,
143+ userInfo: [ NSLocalizedDescriptionKey: " Cannot read IPA file " ] )
169144 }
170145
171146 print ( " Installing app from: \( ipaURL. path) " )
172147
173- return AsyncThrowingStream { continuation in
174- Task {
175- // Start heartbeat to keep connection alive
148+ return AsyncThrowingStream < ( progress : Double , status : String ) , Error > { continuation in
149+ // We'll run installer work in a Task so stream consumers can cancel the Task by cancelling the stream.
150+ let installTask = Task {
176151 HeartbeatManager . shared. start ( )
177152
178- let viewModel = InstallerStatusViewModel ( )
153+ // Get a real installer instance:
154+ // - If the caller supplied one, use it (recommended).
155+ // - Otherwise create a new InstallationProxy() and use its viewModel.
156+ let installerInstance : InstallationProxy
157+ do {
158+ if let provided = installer {
159+ installerInstance = provided
160+ } else {
161+ // Try to create one. The initializer used here (no-arg) may exist in your codebase.
162+ // If your InstallationProxy requires different construction, adjust here.
163+ installerInstance = await InstallationProxy ( )
164+ }
165+ } catch {
166+ // If creating the installer throws for some reason, finish with transformed error
167+ continuation. finish ( throwing: transformInstallError ( error) )
168+ return
169+ }
170+
171+ // Attempt to obtain the installer's viewModel (the source of truth).
172+ // If the installer exposes a `viewModel` property, use it. Otherwise, fallback to a fresh one.
173+ // (Most implementations provide installer.viewModel or let you pass a viewModel to the installer initializer.)
174+ let viewModel : InstallerStatusViewModel
175+ if let vm = ( installerInstance as AnyObject ) . value ( forKey: " viewModel " ) as? InstallerStatusViewModel {
176+ viewModel = vm
177+ } else {
178+ // Fallback — create a local viewModel and hope the installer updates it if supported via init(viewModel:).
179+ viewModel = InstallerStatusViewModel ( )
180+ }
181+
182+ // Keep subscriptions alive for the duration of the stream
179183 var cancellables = Set < AnyCancellable > ( )
180184
181- // Progress stream
185+ // Progress publisher — combine upload + install progress into a single overall progress and status
182186 viewModel. $uploadProgress
183187 . combineLatest ( viewModel. $installProgress)
188+ . receive ( on: RunLoop . main)
184189 . sink { uploadProgress, installProgress in
185- let overallProgress = ( uploadProgress + installProgress) / 2.0
186- let status : String
187-
190+ let overall = max ( 0.0 , min ( 1.0 , ( uploadProgress + installProgress) / 2.0 ) )
191+ let statusStr : String
188192 if uploadProgress < 1.0 {
189- status = " 📤 Uploading... "
193+ statusStr = " 📤 Uploading... "
190194 } else if installProgress < 1.0 {
191- status = " 📲 Installing... "
195+ statusStr = " 📲 Installing... "
192196 } else {
193- status = " 🏁 Finalizing... "
197+ statusStr = " 🏁 Finalizing... "
194198 }
195-
196- continuation. yield ( ( overallProgress, status) )
199+ continuation. yield ( ( overall, statusStr) )
197200 }
198201 . store ( in: & cancellables)
199202
200- // Completion handling
203+ // Status updates — listen for completion or broken state
201204 viewModel. $status
205+ . receive ( on: RunLoop . main)
202206 . sink { installerStatus in
203207 switch installerStatus {
204-
205208 case . completed( . success) :
206209 continuation. yield ( ( 1.0 , " ✅ Successfully installed app! " ) )
207210 continuation. finish ( )
208211 cancellables. removeAll ( )
209212
210213 case . completed( . failure( let error) ) :
211- continuation. finish (
212- throwing: transformInstallError ( error)
213- )
214+ continuation. finish ( throwing: transformInstallError ( error) )
214215 cancellables. removeAll ( )
215216
216217 case . broken( let error) :
217- continuation. finish (
218- throwing: transformInstallError ( error)
219- )
218+ continuation. finish ( throwing: transformInstallError ( error) )
220219 cancellables. removeAll ( )
221220
222221 default :
@@ -225,19 +224,38 @@ public func installApp(from ipaURL: URL) async throws
225224 }
226225 . store ( in: & cancellables)
227226
228- do {
229- let installer = await InstallationProxy ( viewModel: viewModel)
230- try await installer. install ( at: ipaURL)
231-
232- try await Task . sleep ( nanoseconds: 1_000_000_000 )
233- print ( " Installation completed successfully! " )
227+ // If we fell back to a local viewModel and the InstallationProxy supports init(viewModel:),
228+ // try to recreate an installer bound to that viewModel so it receives updates.
229+ // (This is an optional defensive attempt — remove if your API doesn't offer `init(viewModel:)`.)
230+ if ( installer == nil ) {
231+ // If the installer was created without exposing a viewModel (rare), try to re-init with the viewModel.
232+ // This block is safe to remove if your InstallationProxy doesn't have an init(viewModel:) initializer.
233+ // Example (uncomment if available in your codebase):
234+ //
235+ // let reinstaller = await InstallationProxy(viewModel: viewModel)
236+ // installerInstance = reinstaller
237+ //
238+ // For now, we proceed with the installerInstance we created above.
239+ }
234240
241+ // Start the actual installation call
242+ do {
243+ try await installerInstance. install ( at: ipaURL)
244+ // small delay for UI to reflect 100%
245+ try await Task . sleep ( nanoseconds: 300_000_000 )
246+ // note: success will be handled by the status publisher above (completed(.success))
247+ print ( " Installer.install returned without throwing — waiting for status publisher. " )
235248 } catch {
236- continuation. finish (
237- throwing: transformInstallError ( error)
238- )
249+ // if install throws, map the error neatly and finish the stream
250+ continuation. finish ( throwing: transformInstallError ( error) )
239251 cancellables. removeAll ( )
240252 }
253+ } // end Task
254+
255+ // When the AsyncThrowingStream is terminated (cancelled or finished), cancel the Task too
256+ continuation. onTermination = { @Sendable termination in
257+ installTask. cancel ( )
258+ // if needed: do any additional cleanup here
241259 }
242- }
243- }
260+ } // end AsyncThrowingStream
261+ }
0 commit comments