1- // installApp.swift
21import Foundation
32import IDeviceSwift
43import Combine
@@ -7,75 +6,34 @@ import Combine
76private func transformInstallError( _ error: Error ) -> Error {
87 let nsError = error as NSError
98 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- )
9+
10+ if let userMessage = extractUserReadableErrorMessage ( from: error) , !userMessage. isEmpty {
11+ return NSError ( domain: nsError. domain, code: nsError. code, userInfo: [ NSLocalizedDescriptionKey: userMessage] )
2112 }
22-
23- // Fallback: Generic error 1 handling
13+
2414 if errorString. contains ( " error 1. " ) {
25- // Check for specific patterns in the error string
2615 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- )
16+ return NSError ( domain: nsError. domain, code: nsError. code, userInfo: [ NSLocalizedDescriptionKey: " Missing pairing file. Please ensure pairing file exists in ProStore folder. " ] )
3217 }
33-
3418 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- )
19+ return NSError ( domain: nsError. domain, code: nsError. code, userInfo: [ NSLocalizedDescriptionKey: " Cannot connect to AFC. Check USB connection, VPN, and accept trust dialog on device. " ] )
4020 }
41-
4221 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- )
22+ return NSError ( domain: nsError. domain, code: nsError. code, userInfo: [ NSLocalizedDescriptionKey: " Installation service failed. The app may already be installed or device storage full. " ] )
4823 }
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- )
24+ 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. " ] )
5625 }
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- )
26+
27+ let cleanedMessage = cleanGenericErrorMessage ( nsError. localizedDescription)
28+ return NSError ( domain: nsError. domain, code: nsError. code, userInfo: [ NSLocalizedDescriptionKey: cleanedMessage] )
6729}
6830
69- // Extract user-readable message from error
7031private func extractUserReadableErrorMessage( from error: Error ) -> String ? {
71- // Try to get error description from LocalizedError
7232 if let localizedError = error as? LocalizedError {
7333 return localizedError. errorDescription
7434 }
75-
35+
7636 let errorString = String ( describing: error)
77-
78- // Look for specific error patterns in the string representation
7937 let patterns = [
8038 " Missing Pairing " : " Missing pairing file. Please check ProStore folder. " ,
8139 " Cannot connect to AFC " : " Cannot connect to device. Check USB and VPN. " ,
@@ -84,158 +42,120 @@ private func extractUserReadableErrorMessage(from error: Error) -> String? {
8442 " File Error: " : " File operation failed. " ,
8543 " Connection Failed: " : " Connection to device failed. "
8644 ]
87-
88- for (pattern, message) in patterns {
89- if errorString. contains ( pattern) {
90- return message
91- }
45+
46+ for (pattern, message) in patterns where errorString. contains ( pattern) {
47+ return message
9248 }
93-
94- // Try to extract from NSError userInfo
49+
9550 let nsError = error as NSError
9651 if let userInfoMessage = nsError. userInfo [ NSLocalizedDescriptionKey] as? String ,
9752 !userInfoMessage. isEmpty,
9853 userInfoMessage != nsError. localizedDescription {
9954 return userInfoMessage
10055 }
101-
56+
10257 return nil
10358}
10459
105- // Clean up generic error messages
10660private func cleanGenericErrorMessage( _ message: String ) -> String {
10761 var cleaned = message
108-
109- // Remove the generic prefix
11062 let genericPrefixes = [
11163 " The operation couldn't be completed. " ,
11264 " The operation could not be completed. " ,
11365 " IDeviceSwift.IDeviceSwiftError " ,
11466 " IDeviceSwiftError "
11567 ]
116-
117- for prefix in genericPrefixes {
118- if cleaned. hasPrefix ( prefix) {
119- cleaned = String ( cleaned. dropFirst ( prefix. count) )
120- break
121- }
122- }
123-
124- // Remove trailing period if present
125- if cleaned. hasSuffix ( " . " ) {
126- cleaned = String ( cleaned. dropLast ( ) )
68+ for prefix in genericPrefixes where cleaned. hasPrefix ( prefix) {
69+ cleaned = String ( cleaned. dropFirst ( prefix. count) )
70+ break
12771 }
128-
129- // If it's just "error 1.", provide more helpful message
72+ if cleaned. hasSuffix ( " . " ) { cleaned = String ( cleaned. dropLast ( ) ) }
13073 if cleaned == " error 1 " || cleaned == " error 1. " {
13174 return " Device installation failed. Please check: 1) VPN connection, 2) USB cable, 3) Trust dialog, 4) Pairing file. "
13275 }
133-
13476 return cleaned. isEmpty ? " Unknown installation error " : cleaned
13577}
13678
13779// MARK: - Install App
13880/// 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 > {
81+ public func installApp( from ipaURL: URL ) async throws -> AsyncThrowingStream < ( progress: Double , status: String ) , Error > {
14182
142- // Pre-flight check: verify IPA exists
83+ // Pre-flight IPA check
14384 let fileManager = FileManager . default
14485 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- )
86+ throw NSError ( domain: " InstallApp " , code: - 1 , userInfo: [ NSLocalizedDescriptionKey: " IPA file not found: \( ipaURL. lastPathComponent) " ] )
15087 }
151-
152- // Check file size
88+
89+ // Validate file size
15390 do {
15491 let attributes = try fileManager. attributesOfItem ( atPath: ipaURL. path)
15592 let fileSize = attributes [ . size] as? Int64 ?? 0
15693 guard fileSize > 1024 else {
157- throw NSError (
158- domain: " InstallApp " ,
159- code: - 1 ,
160- userInfo: [ NSLocalizedDescriptionKey: " IPA file is too small or invalid " ]
161- )
94+ throw NSError ( domain: " InstallApp " , code: - 1 , userInfo: [ NSLocalizedDescriptionKey: " IPA file is too small or invalid " ] )
16295 }
16396 } catch {
164- throw NSError (
165- domain: " InstallApp " ,
166- code: - 1 ,
167- userInfo: [ NSLocalizedDescriptionKey: " Cannot read IPA file " ]
168- )
97+ throw NSError ( domain: " InstallApp " , code: - 1 , userInfo: [ NSLocalizedDescriptionKey: " Cannot read IPA file " ] )
16998 }
17099
171100 print ( " Installing app from: \( ipaURL. path) " )
172101
173- return AsyncThrowingStream { continuation in
174- Task {
175- // Start heartbeat to keep connection alive
176- HeartbeatManager . shared. start ( )
102+ typealias InstallUpdate = ( progress: Double , status: String )
103+ typealias StreamContinuation = AsyncThrowingStream < InstallUpdate , Error > . Continuation
177104
178- let viewModel = InstallerStatusViewModel ( )
179- var cancellables = Set < AnyCancellable > ( )
105+ return AsyncThrowingStream < InstallUpdate , Error > { continuation in
106+ var cancellables = Set < AnyCancellable > ( )
107+ var installTask : Task < Void , Never > ?
180108
181- // Progress stream
182- viewModel. $uploadProgress
183- . combineLatest ( viewModel. $installProgress)
184- . sink { uploadProgress, installProgress in
185- let overallProgress = ( uploadProgress + installProgress) / 2.0
186- let status : String
187-
188- if uploadProgress < 1.0 {
189- status = " 📤 Uploading... "
190- } else if installProgress < 1.0 {
191- status = " 📲 Installing... "
192- } else {
193- status = " 🏁 Finalizing... "
194- }
109+ continuation. onTermination = { @Sendable reason in
110+ print ( " Install stream terminated: \( reason) " )
111+ cancellables. removeAll ( )
112+ installTask? . cancel ( )
113+ }
195114
196- continuation. yield ( ( overallProgress, status) )
197- }
198- . store ( in: & cancellables)
115+ installTask = Task {
116+ HeartbeatManager . shared. start ( )
117+ let isIdevice = UserDefaults . standard. integer ( forKey: " ProStore.installationMethod " ) == 1
118+ let viewModel = InstallerStatusViewModel ( isIdevice: isIdevice)
199119
200- // Completion handling
120+ // Status updates
201121 viewModel. $status
202- . sink { installerStatus in
203- switch installerStatus {
204-
205- case . completed( . success) :
122+ . sink { newStatus in
123+ if viewModel. isCompleted {
124+ print ( " [Installer] detected completion via isCompleted " )
206125 continuation. yield ( ( 1.0 , " ✅ Successfully installed app! " ) )
207126 continuation. finish ( )
208127 cancellables. removeAll ( )
209-
210- case . completed( . failure( let error) ) :
211- continuation. finish (
212- throwing: transformInstallError ( error)
213- )
214- cancellables. removeAll ( )
215-
216- case . broken( let error) :
217- continuation. finish (
218- throwing: transformInstallError ( error)
219- )
128+ }
129+ if case . broken( let error) = newStatus {
130+ continuation. finish ( throwing: transformInstallError ( error) )
220131 cancellables. removeAll ( )
221-
222- default :
223- break
224132 }
225133 }
226134 . store ( in: & cancellables)
227135
136+ // Progress stream (upload + install)
137+ viewModel. $uploadProgress
138+ . combineLatest ( viewModel. $installProgress)
139+ . sink { upload, install in
140+ let overall = ( upload + install) / 2
141+ let statusText : String
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) " )
146+ continuation. yield ( ( overall, statusText) )
147+ }
148+ . store ( in: & cancellables)
149+
228150 do {
229151 let installer = await InstallationProxy ( viewModel: viewModel)
230152 try await installer. install ( at: ipaURL)
231-
232- try await Task . sleep ( nanoseconds: 1_000_000_000 )
233- print ( " Installation completed successfully! " )
234-
153+ try await Task . sleep ( nanoseconds: 500_000_000 )
154+ print ( " Installation call returned — waiting for viewModel to report completion. " )
155+ // Stream finishes when viewModel.isCompleted becomes true or status reports broken/error
235156 } catch {
236- continuation. finish (
237- throwing: transformInstallError ( error)
238- )
157+ print ( " [Installer] install threw error -> " , error)
158+ continuation. finish ( throwing: transformInstallError ( error) )
239159 cancellables. removeAll ( )
240160 }
241161 }
0 commit comments