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

Commit 2f65385

Browse files
authored
Fix another bug
1 parent 83bdc95 commit 2f65385

1 file changed

Lines changed: 37 additions & 101 deletions

File tree

Sources/prostore/install/installApp.swift

Lines changed: 37 additions & 101 deletions
Original file line numberDiff line numberDiff line change
@@ -2,36 +2,29 @@ import Foundation
22
import IDeviceSwift
33
import Combine
44

5-
// MARK: - Error Transformer (existing helpers kept)
5+
// MARK: - Error Transformer
66
private 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

7060
private 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

Comments
 (0)