Skip to content
This repository was archived by the owner on Mar 7, 2026. It is now read-only.

Commit 3ce7238

Browse files
authored
Enhance error handling in installApp function
Improved error handling and installation process in installApp function.
1 parent 32df3a3 commit 3ce7238

File tree

1 file changed

+147
-129
lines changed

1 file changed

+147
-129
lines changed
Lines changed: 147 additions & 129 deletions
Original file line numberDiff line numberDiff line change
@@ -1,222 +1,221 @@
1-
// installApp.swift
1+
// installApp.swift
22
import Foundation
3-
import IDeviceSwift
43
import Combine
4+
import IDeviceSwift
55

6-
// MARK: - Error Transformer
6+
// MARK: - Error Transformer (kept + improved)
77
private 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
7053
private 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
10685
private 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

Comments
 (0)