From 3ce7238fd609e18f92443fd45dba022db73295f7 Mon Sep 17 00:00:00 2001 From: NovaDev404 <223823408+NovaDev404@users.noreply.github.com> Date: Sat, 20 Dec 2025 11:41:19 +1030 Subject: [PATCH 1/8] Enhance error handling in installApp function Improved error handling and installation process in installApp function. --- Sources/prostore/install/installApp.swift | 276 ++++++++++++---------- 1 file changed, 147 insertions(+), 129 deletions(-) diff --git a/Sources/prostore/install/installApp.swift b/Sources/prostore/install/installApp.swift index f849429..df95196 100644 --- a/Sources/prostore/install/installApp.swift +++ b/Sources/prostore/install/installApp.swift @@ -1,222 +1,221 @@ -// installApp.swift +// installApp.swift import Foundation -import IDeviceSwift import Combine +import IDeviceSwift -// MARK: - Error Transformer +// MARK: - Error Transformer (kept + improved) private func transformInstallError(_ error: Error) -> Error { let nsError = error as NSError let errorString = String(describing: error) - - // Extract real error message from various formats - var userMessage = extractUserReadableErrorMessage(from: error) - - // If we got a good message, use it - if let userMessage = userMessage, !userMessage.isEmpty { - return NSError( - domain: nsError.domain, - code: nsError.code, - userInfo: [NSLocalizedDescriptionKey: userMessage] - ) + + // Try to get a readable error first + if let userMessage = extractUserReadableErrorMessage(from: error), + !userMessage.isEmpty { + return NSError(domain: nsError.domain, + code: nsError.code, + userInfo: [NSLocalizedDescriptionKey: userMessage]) } - - // Fallback: Generic error 1 handling - if errorString.contains("error 1.") { - // Check for specific patterns in the error string + + // Specific patterns for "error 1" cases or common idevice failure messages + if errorString.contains("error 1") || errorString.contains("error 1.") { if errorString.contains("Missing Pairing") { - return NSError( - domain: nsError.domain, - code: nsError.code, - userInfo: [NSLocalizedDescriptionKey: "Missing pairing file. Please ensure pairing file exists in ProStore folder."] - ) + return NSError(domain: nsError.domain, + code: nsError.code, + userInfo: [NSLocalizedDescriptionKey: + "Missing pairing file. Please ensure pairing file exists in the ProStore folder."]) } - - if errorString.contains("Cannot connect to AFC") || errorString.contains("afc_client_connect") { - return NSError( - domain: nsError.domain, - code: nsError.code, - userInfo: [NSLocalizedDescriptionKey: "Cannot connect to AFC. Check USB connection, VPN, and accept trust dialog on device."] - ) + if errorString.contains("afc_client_connect") || errorString.contains("Cannot connect to AFC") { + return NSError(domain: nsError.domain, + code: nsError.code, + userInfo: [NSLocalizedDescriptionKey: + "Cannot connect to AFC. Check USB connection, enable VPN loopback and accept the trust dialog on the device."]) } - if errorString.contains("installation_proxy") { - return NSError( - domain: nsError.domain, - code: nsError.code, - userInfo: [NSLocalizedDescriptionKey: "Installation service failed. The app may already be installed or device storage full."] - ) + return NSError(domain: nsError.domain, + code: nsError.code, + userInfo: [NSLocalizedDescriptionKey: + "Installation service failed. The app may already be installed or device storage is full."]) } - - // Generic error 1 message - 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."] - ) + + 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."]) } - - // Try to clean up the generic message - let originalMessage = nsError.localizedDescription - let cleanedMessage = cleanGenericErrorMessage(originalMessage) - - return NSError( - domain: nsError.domain, - code: nsError.code, - userInfo: [NSLocalizedDescriptionKey: cleanedMessage] - ) + + // Clean and fallback + let cleaned = cleanGenericErrorMessage(nsError.localizedDescription) + return NSError(domain: nsError.domain, + code: nsError.code, + userInfo: [NSLocalizedDescriptionKey: cleaned]) } -// Extract user-readable message from error private func extractUserReadableErrorMessage(from error: Error) -> String? { - // Try to get error description from LocalizedError if let localizedError = error as? LocalizedError { - return localizedError.errorDescription + if let desc = localizedError.errorDescription, !desc.isEmpty { return desc } } - - let errorString = String(describing: error) - - // Look for specific error patterns in the string representation - let patterns = [ + + let errString = String(describing: error) + + let patterns: [String: String] = [ "Missing Pairing": "Missing pairing file. Please check ProStore folder.", - "Cannot connect to AFC": "Cannot connect to device. Check USB and VPN.", + "Cannot connect to AFC": "Cannot connect to device. Check LocalDevVPN.", "AFC Error:": "Device communication failed.", "Installation Error:": "App installation failed.", "File Error:": "File operation failed.", "Connection Failed:": "Connection to device failed." ] - + for (pattern, message) in patterns { - if errorString.contains(pattern) { + if errString.contains(pattern) { return message } } - - // Try to extract from NSError userInfo + let nsError = error as NSError if let userInfoMessage = nsError.userInfo[NSLocalizedDescriptionKey] as? String, !userInfoMessage.isEmpty, userInfoMessage != nsError.localizedDescription { return userInfoMessage } - + return nil } -// Clean up generic error messages private func cleanGenericErrorMessage(_ message: String) -> String { var cleaned = message - - // Remove the generic prefix + let genericPrefixes = [ "The operation couldn't be completed. ", "The operation could not be completed. ", "IDeviceSwift.IDeviceSwiftError ", "IDeviceSwiftError " ] - + for prefix in genericPrefixes { if cleaned.hasPrefix(prefix) { cleaned = String(cleaned.dropFirst(prefix.count)) break } } - - // Remove trailing period if present + if cleaned.hasSuffix(".") { cleaned = String(cleaned.dropLast()) } - - // If it's just "error 1.", provide more helpful message + if cleaned == "error 1" || cleaned == "error 1." { return "Device installation failed. Please check: 1) VPN connection, 2) USB cable, 3) Trust dialog, 4) Pairing file." } - + return cleaned.isEmpty ? "Unknown installation error" : cleaned } // MARK: - Install App -/// Installs a signed IPA on the device using InstallationProxy -public func installApp(from ipaURL: URL) async throws --> AsyncThrowingStream<(progress: Double, status: String), Error> { +/// Installs a signed IPA on the device using InstallationProxy. +/// NOTE: +/// - If your UI layer already has a shared `InstallationProxy` or `InstallerStatusViewModel`, +/// pass it via the `installer` parameter so we observe the *same* viewModel the installer updates. +/// - If you don't pass one, we attempt to create a fresh `InstallationProxy()` and use its `viewModel`. +public func installApp( + from ipaURL: URL, + using installer: InstallationProxy? = nil +) async throws -> AsyncThrowingStream<(progress: Double, status: String), Error> { - // Pre-flight check: verify IPA exists + // Pre-flight check: verify IPA exists and is reasonable size let fileManager = FileManager.default guard fileManager.fileExists(atPath: ipaURL.path) else { - throw NSError( - domain: "InstallApp", - code: -1, - userInfo: [NSLocalizedDescriptionKey: "IPA file not found: \(ipaURL.lastPathComponent)"] - ) + throw NSError(domain: "InstallApp", + code: -1, + userInfo: [NSLocalizedDescriptionKey: "IPA file not found: \(ipaURL.lastPathComponent)"]) } - - // Check file size + do { let attributes = try fileManager.attributesOfItem(atPath: ipaURL.path) let fileSize = attributes[.size] as? Int64 ?? 0 guard fileSize > 1024 else { - throw NSError( - domain: "InstallApp", - code: -1, - userInfo: [NSLocalizedDescriptionKey: "IPA file is too small or invalid"] - ) + throw NSError(domain: "InstallApp", + code: -1, + userInfo: [NSLocalizedDescriptionKey: "IPA file is too small or invalid"]) } } catch { - throw NSError( - domain: "InstallApp", - code: -1, - userInfo: [NSLocalizedDescriptionKey: "Cannot read IPA file"] - ) + throw NSError(domain: "InstallApp", + code: -1, + userInfo: [NSLocalizedDescriptionKey: "Cannot read IPA file"]) } print("Installing app from: \(ipaURL.path)") - return AsyncThrowingStream { continuation in - Task { - // Start heartbeat to keep connection alive + return AsyncThrowingStream<(progress: Double, status: String), Error> { continuation in + // We'll run installer work in a Task so stream consumers can cancel the Task by cancelling the stream. + let installTask = Task { HeartbeatManager.shared.start() - let viewModel = InstallerStatusViewModel() + // Get a real installer instance: + // - If the caller supplied one, use it (recommended). + // - Otherwise create a new InstallationProxy() and use its viewModel. + let installerInstance: InstallationProxy + do { + if let provided = installer { + installerInstance = provided + } else { + // Try to create one. The initializer used here (no-arg) may exist in your codebase. + // If your InstallationProxy requires different construction, adjust here. + installerInstance = await InstallationProxy() + } + } catch { + // If creating the installer throws for some reason, finish with transformed error + continuation.finish(throwing: transformInstallError(error)) + return + } + + // Attempt to obtain the installer's viewModel (the source of truth). + // If the installer exposes a `viewModel` property, use it. Otherwise, fallback to a fresh one. + // (Most implementations provide installer.viewModel or let you pass a viewModel to the installer initializer.) + let viewModel: InstallerStatusViewModel + if let vm = (installerInstance as AnyObject).value(forKey: "viewModel") as? InstallerStatusViewModel { + viewModel = vm + } else { + // Fallback — create a local viewModel and hope the installer updates it if supported via init(viewModel:). + viewModel = InstallerStatusViewModel() + } + + // Keep subscriptions alive for the duration of the stream var cancellables = Set() - // Progress stream + // Progress publisher — combine upload + install progress into a single overall progress and status viewModel.$uploadProgress .combineLatest(viewModel.$installProgress) + .receive(on: RunLoop.main) .sink { uploadProgress, installProgress in - let overallProgress = (uploadProgress + installProgress) / 2.0 - let status: String - + let overall = max(0.0, min(1.0, (uploadProgress + installProgress) / 2.0)) + let statusStr: String if uploadProgress < 1.0 { - status = "📤 Uploading..." + statusStr = "📤 Uploading..." } else if installProgress < 1.0 { - status = "📲 Installing..." + statusStr = "📲 Installing..." } else { - status = "🏁 Finalizing..." + statusStr = "🏁 Finalizing..." } - - continuation.yield((overallProgress, status)) + continuation.yield((overall, statusStr)) } .store(in: &cancellables) - // Completion handling + // Status updates — listen for completion or broken state viewModel.$status + .receive(on: RunLoop.main) .sink { installerStatus in switch installerStatus { - case .completed(.success): continuation.yield((1.0, "✅ Successfully installed app!")) continuation.finish() cancellables.removeAll() case .completed(.failure(let error)): - continuation.finish( - throwing: transformInstallError(error) - ) + continuation.finish(throwing: transformInstallError(error)) cancellables.removeAll() case .broken(let error): - continuation.finish( - throwing: transformInstallError(error) - ) + continuation.finish(throwing: transformInstallError(error)) cancellables.removeAll() default: @@ -225,19 +224,38 @@ public func installApp(from ipaURL: URL) async throws } .store(in: &cancellables) - do { - let installer = await InstallationProxy(viewModel: viewModel) - try await installer.install(at: ipaURL) - - try await Task.sleep(nanoseconds: 1_000_000_000) - print("Installation completed successfully!") + // If we fell back to a local viewModel and the InstallationProxy supports init(viewModel:), + // try to recreate an installer bound to that viewModel so it receives updates. + // (This is an optional defensive attempt — remove if your API doesn't offer `init(viewModel:)`.) + if (installer == nil) { + // If the installer was created without exposing a viewModel (rare), try to re-init with the viewModel. + // This block is safe to remove if your InstallationProxy doesn't have an init(viewModel:) initializer. + // Example (uncomment if available in your codebase): + // + // let reinstaller = await InstallationProxy(viewModel: viewModel) + // installerInstance = reinstaller + // + // For now, we proceed with the installerInstance we created above. + } + // Start the actual installation call + do { + try await installerInstance.install(at: ipaURL) + // small delay for UI to reflect 100% + try await Task.sleep(nanoseconds: 300_000_000) + // note: success will be handled by the status publisher above (completed(.success)) + print("Installer.install returned without throwing — waiting for status publisher.") } catch { - continuation.finish( - throwing: transformInstallError(error) - ) + // if install throws, map the error neatly and finish the stream + continuation.finish(throwing: transformInstallError(error)) cancellables.removeAll() } + } // end Task + + // When the AsyncThrowingStream is terminated (cancelled or finished), cancel the Task too + continuation.onTermination = { @Sendable termination in + installTask.cancel() + // if needed: do any additional cleanup here } - } -} \ No newline at end of file + } // end AsyncThrowingStream +} From 1cc097b060e985967c1f50650159cc18ccf77d80 Mon Sep 17 00:00:00 2001 From: NovaDev404 <223823408+NovaDev404@users.noreply.github.com> Date: Sat, 20 Dec 2025 11:41:38 +1030 Subject: [PATCH 2/8] Update project.yml --- project.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/project.yml b/project.yml index 06e479c..951cb15 100644 --- a/project.yml +++ b/project.yml @@ -36,8 +36,8 @@ targets: properties: CFBundleDisplayName: "ProStore" CFBundleName: "prostore" - CFBundleVersion: "70" - CFBundleShortVersionString: "1.2.8" + CFBundleVersion: "71" + CFBundleShortVersionString: "1.2.9" UILaunchStoryboardName: "LaunchScreen" NSPrincipalClass: "UIApplication" NSAppTransportSecurity: @@ -59,3 +59,4 @@ targets: - package: IDeviceKit product: IDeviceSwift + From d98e100be7dee93dff300872fd8322cc652572f0 Mon Sep 17 00:00:00 2001 From: NovaDev404 <223823408+NovaDev404@users.noreply.github.com> Date: Sat, 20 Dec 2025 11:51:41 +1030 Subject: [PATCH 3/8] Refactor error handling and installation process Refactor error handling and installation logic in installApp function. --- Sources/prostore/install/installApp.swift | 231 ++++++++-------------- 1 file changed, 87 insertions(+), 144 deletions(-) diff --git a/Sources/prostore/install/installApp.swift b/Sources/prostore/install/installApp.swift index df95196..6d57ea5 100644 --- a/Sources/prostore/install/installApp.swift +++ b/Sources/prostore/install/installApp.swift @@ -1,65 +1,50 @@ -// installApp.swift import Foundation -import Combine import IDeviceSwift +import Combine -// MARK: - Error Transformer (kept + improved) +// MARK: - Error Transformer (existing helpers kept) private func transformInstallError(_ error: Error) -> Error { let nsError = error as NSError let errorString = String(describing: error) - // Try to get a readable error first - if let userMessage = extractUserReadableErrorMessage(from: error), - !userMessage.isEmpty { - return NSError(domain: nsError.domain, - code: nsError.code, - userInfo: [NSLocalizedDescriptionKey: userMessage]) + var userMessage = extractUserReadableErrorMessage(from: error) + + if let userMessage = userMessage, !userMessage.isEmpty { + return NSError(domain: nsError.domain, code: nsError.code, userInfo: [NSLocalizedDescriptionKey: userMessage]) } - // Specific patterns for "error 1" cases or common idevice failure messages - if errorString.contains("error 1") || errorString.contains("error 1.") { + if errorString.contains("error 1.") { if errorString.contains("Missing Pairing") { - return NSError(domain: nsError.domain, - code: nsError.code, - userInfo: [NSLocalizedDescriptionKey: - "Missing pairing file. Please ensure pairing file exists in the ProStore folder."]) + return NSError(domain: nsError.domain, code: nsError.code, userInfo: [NSLocalizedDescriptionKey: "Missing pairing file. Please ensure pairing file exists in ProStore folder."]) } - if errorString.contains("afc_client_connect") || errorString.contains("Cannot connect to AFC") { - return NSError(domain: nsError.domain, - code: nsError.code, - userInfo: [NSLocalizedDescriptionKey: - "Cannot connect to AFC. Check USB connection, enable VPN loopback and accept the trust dialog on the device."]) + + if errorString.contains("Cannot connect to AFC") || errorString.contains("afc_client_connect") { + return NSError(domain: nsError.domain, code: nsError.code, userInfo: [NSLocalizedDescriptionKey: "Cannot connect to AFC. Check USB connection, VPN, and accept trust dialog on device."]) } + if errorString.contains("installation_proxy") { - return NSError(domain: nsError.domain, - code: nsError.code, - userInfo: [NSLocalizedDescriptionKey: - "Installation service failed. The app may already be installed or device storage is full."]) + return NSError(domain: nsError.domain, code: nsError.code, userInfo: [NSLocalizedDescriptionKey: "Installation service failed. The app may already be installed or device storage full."]) } - 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."]) + 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."]) } - // Clean and fallback - let cleaned = cleanGenericErrorMessage(nsError.localizedDescription) - return NSError(domain: nsError.domain, - code: nsError.code, - userInfo: [NSLocalizedDescriptionKey: cleaned]) + let originalMessage = nsError.localizedDescription + let cleanedMessage = cleanGenericErrorMessage(originalMessage) + + return NSError(domain: nsError.domain, code: nsError.code, userInfo: [NSLocalizedDescriptionKey: cleanedMessage]) } private func extractUserReadableErrorMessage(from error: Error) -> String? { if let localizedError = error as? LocalizedError { - if let desc = localizedError.errorDescription, !desc.isEmpty { return desc } + return localizedError.errorDescription } - let errString = String(describing: error) + let errorString = String(describing: error) - let patterns: [String: String] = [ + let patterns = [ "Missing Pairing": "Missing pairing file. Please check ProStore folder.", - "Cannot connect to AFC": "Cannot connect to device. Check LocalDevVPN.", + "Cannot connect to AFC": "Cannot connect to device. Check USB and VPN.", "AFC Error:": "Device communication failed.", "Installation Error:": "App installation failed.", "File Error:": "File operation failed.", @@ -67,7 +52,7 @@ private func extractUserReadableErrorMessage(from error: Error) -> String? { ] for (pattern, message) in patterns { - if errString.contains(pattern) { + if errorString.contains(pattern) { return message } } @@ -111,151 +96,109 @@ private func cleanGenericErrorMessage(_ message: String) -> String { } // MARK: - Install App -/// Installs a signed IPA on the device using InstallationProxy. -/// NOTE: -/// - If your UI layer already has a shared `InstallationProxy` or `InstallerStatusViewModel`, -/// pass it via the `installer` parameter so we observe the *same* viewModel the installer updates. -/// - If you don't pass one, we attempt to create a fresh `InstallationProxy()` and use its `viewModel`. -public func installApp( - from ipaURL: URL, - using installer: InstallationProxy? = nil -) async throws -> AsyncThrowingStream<(progress: Double, status: String), Error> { - - // Pre-flight check: verify IPA exists and is reasonable size +/// Installs a signed IPA on the device using InstallationProxy +public func installApp(from ipaURL: URL) async throws +-> AsyncThrowingStream<(progress: Double, status: String), Error> { + + // Pre-flight check: verify IPA exists let fileManager = FileManager.default guard fileManager.fileExists(atPath: ipaURL.path) else { - throw NSError(domain: "InstallApp", - code: -1, - userInfo: [NSLocalizedDescriptionKey: "IPA file not found: \(ipaURL.lastPathComponent)"]) + throw NSError(domain: "InstallApp", code: -1, userInfo: [NSLocalizedDescriptionKey: "IPA file not found: \(ipaURL.lastPathComponent)"]) } + // Check file size do { let attributes = try fileManager.attributesOfItem(atPath: ipaURL.path) let fileSize = attributes[.size] as? Int64 ?? 0 guard fileSize > 1024 else { - throw NSError(domain: "InstallApp", - code: -1, - userInfo: [NSLocalizedDescriptionKey: "IPA file is too small or invalid"]) + throw NSError(domain: "InstallApp", code: -1, userInfo: [NSLocalizedDescriptionKey: "IPA file is too small or invalid"]) } } catch { - throw NSError(domain: "InstallApp", - code: -1, - userInfo: [NSLocalizedDescriptionKey: "Cannot read IPA file"]) + throw NSError(domain: "InstallApp", code: -1, userInfo: [NSLocalizedDescriptionKey: "Cannot read IPA file"]) } print("Installing app from: \(ipaURL.path)") - return AsyncThrowingStream<(progress: Double, status: String), Error> { continuation in - // We'll run installer work in a Task so stream consumers can cancel the Task by cancelling the stream. - let installTask = Task { + return AsyncThrowingStream { continuation in + // Keep track of subscriptions & a cancellation token + var cancellables = Set() + var installTask: Task? + + // Ensure cleanup when the stream ends for whatever reason + continuation.onTermination = { @Sendable _ in + print("Install stream terminated — cleaning up.") + cancellables.removeAll() + HeartbeatManager.shared.stop() + // cancel the installation task if still running + installTask?.cancel() + } + + // Start the async work on a Task so we can await inside + installTask = Task { + // Start heartbeat to keep connection alive HeartbeatManager.shared.start() - // Get a real installer instance: - // - If the caller supplied one, use it (recommended). - // - Otherwise create a new InstallationProxy() and use its viewModel. - let installerInstance: InstallationProxy - do { - if let provided = installer { - installerInstance = provided - } else { - // Try to create one. The initializer used here (no-arg) may exist in your codebase. - // If your InstallationProxy requires different construction, adjust here. - installerInstance = await InstallationProxy() - } - } catch { - // If creating the installer throws for some reason, finish with transformed error - continuation.finish(throwing: transformInstallError(error)) - return - } + // initialize view model the same way UI does (important) + let isIdevice = UserDefaults.standard.integer(forKey: "Feather.installationMethod") == 1 + let viewModel = InstallerStatusViewModel(isIdevice: isIdevice) - // Attempt to obtain the installer's viewModel (the source of truth). - // If the installer exposes a `viewModel` property, use it. Otherwise, fallback to a fresh one. - // (Most implementations provide installer.viewModel or let you pass a viewModel to the installer initializer.) - let viewModel: InstallerStatusViewModel - if let vm = (installerInstance as AnyObject).value(forKey: "viewModel") as? InstallerStatusViewModel { - viewModel = vm - } else { - // Fallback — create a local viewModel and hope the installer updates it if supported via init(viewModel:). - viewModel = InstallerStatusViewModel() - } + // Log useful updates to console (debug) + viewModel.$status.sink { status in + print("[Installer] status ->", status) + }.store(in: &cancellables) - // Keep subscriptions alive for the duration of the stream - var cancellables = Set() - - // Progress publisher — combine upload + install progress into a single overall progress and status viewModel.$uploadProgress .combineLatest(viewModel.$installProgress) - .receive(on: RunLoop.main) .sink { uploadProgress, installProgress in - let overall = max(0.0, min(1.0, (uploadProgress + installProgress) / 2.0)) - let statusStr: String + let overall = (uploadProgress + installProgress) / 2.0 + let status: String if uploadProgress < 1.0 { - statusStr = "📤 Uploading..." + status = "📤 Uploading..." } else if installProgress < 1.0 { - statusStr = "📲 Installing..." + status = "📲 Installing..." } else { - statusStr = "🏁 Finalizing..." + status = "🏁 Finalizing..." } - continuation.yield((overall, statusStr)) + // debug + print("[Installer] progress upload:\(uploadProgress) install:\(installProgress) overall:\(overall)") + continuation.yield((overall, status)) } .store(in: &cancellables) - // Status updates — listen for completion or broken state - viewModel.$status - .receive(on: RunLoop.main) - .sink { installerStatus in - switch installerStatus { - case .completed(.success): + // Watch for completion via published isCompleted (robust across enum shapes) + viewModel.$isCompleted + .sink { completed in + if completed { + print("[Installer] detected completion via isCompleted=true") continuation.yield((1.0, "✅ Successfully installed app!")) continuation.finish() cancellables.removeAll() - - case .completed(.failure(let error)): - continuation.finish(throwing: transformInstallError(error)) - cancellables.removeAll() - - case .broken(let error): - continuation.finish(throwing: transformInstallError(error)) - cancellables.removeAll() - - default: - break } } .store(in: &cancellables) - // If we fell back to a local viewModel and the InstallationProxy supports init(viewModel:), - // try to recreate an installer bound to that viewModel so it receives updates. - // (This is an optional defensive attempt — remove if your API doesn't offer `init(viewModel:)`.) - if (installer == nil) { - // If the installer was created without exposing a viewModel (rare), try to re-init with the viewModel. - // This block is safe to remove if your InstallationProxy doesn't have an init(viewModel:) initializer. - // Example (uncomment if available in your codebase): - // - // let reinstaller = await InstallationProxy(viewModel: viewModel) - // installerInstance = reinstaller - // - // For now, we proceed with the installerInstance we created above. - } - - // Start the actual installation call do { - try await installerInstance.install(at: ipaURL) - // small delay for UI to reflect 100% - try await Task.sleep(nanoseconds: 300_000_000) - // note: success will be handled by the status publisher above (completed(.success)) - print("Installer.install returned without throwing — waiting for status publisher.") + // Create the installer tied to the view model + let installer = await InstallationProxy(viewModel: viewModel) + + // If you need same behaviour as UI, pass the suspend flag like the UI does: + // let suspend = (Bundle.main.bundleIdentifier == someIdentifier) // adapt as needed + // try await installer.install(at: ipaURL, suspend: suspend) + + // For now, call the simpler signature – change to include 'suspend:' if needed + try await installer.install(at: ipaURL) + + // tiny pause so progress updates propagate + try await Task.sleep(nanoseconds: 500_000_000) + + print("Installation call returned without throwing — waiting for viewModel to report completion.") + // Don't force finish here: wait for the published isCompleted to fire (above) + } catch { - // if install throws, map the error neatly and finish the stream + print("[Installer] install threw error ->", error) continuation.finish(throwing: transformInstallError(error)) cancellables.removeAll() } - } // end Task - - // When the AsyncThrowingStream is terminated (cancelled or finished), cancel the Task too - continuation.onTermination = { @Sendable termination in - installTask.cancel() - // if needed: do any additional cleanup here } - } // end AsyncThrowingStream + } } From fee7f5ddd8d40dca564d4c6050e6eefb1c06de41 Mon Sep 17 00:00:00 2001 From: NovaDev404 <223823408+NovaDev404@users.noreply.github.com> Date: Sat, 20 Dec 2025 11:55:23 +1030 Subject: [PATCH 4/8] Update installApp.swift --- Sources/prostore/install/installApp.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/prostore/install/installApp.swift b/Sources/prostore/install/installApp.swift index 6d57ea5..23a33fb 100644 --- a/Sources/prostore/install/installApp.swift +++ b/Sources/prostore/install/installApp.swift @@ -128,7 +128,6 @@ public func installApp(from ipaURL: URL) async throws continuation.onTermination = { @Sendable _ in print("Install stream terminated — cleaning up.") cancellables.removeAll() - HeartbeatManager.shared.stop() // cancel the installation task if still running installTask?.cancel() } @@ -202,3 +201,4 @@ public func installApp(from ipaURL: URL) async throws } } } + From 223a18c26c03fdfb4215f0408622db9d0e70f748 Mon Sep 17 00:00:00 2001 From: NovaDev404 <223823408+NovaDev404@users.noreply.github.com> Date: Sat, 20 Dec 2025 12:02:26 +1030 Subject: [PATCH 5/8] Refactor error handling and installation logic --- Sources/prostore/install/installApp.swift | 61 ++++++++++++----------- 1 file changed, 32 insertions(+), 29 deletions(-) diff --git a/Sources/prostore/install/installApp.swift b/Sources/prostore/install/installApp.swift index 23a33fb..c28fbec 100644 --- a/Sources/prostore/install/installApp.swift +++ b/Sources/prostore/install/installApp.swift @@ -119,52 +119,57 @@ public func installApp(from ipaURL: URL) async throws print("Installing app from: \(ipaURL.path)") - return AsyncThrowingStream { continuation in - // Keep track of subscriptions & a cancellation token + // === IMPORTANT: explicitly specify element and failure types so the compiler selects + // the continuation-style initializer (the one that passes `continuation` to the closure). + typealias InstallUpdate = (progress: Double, status: String) + typealias StreamContinuation = AsyncThrowingStream.Continuation + + return AsyncThrowingStream { continuation in var cancellables = Set() var installTask: Task? - // Ensure cleanup when the stream ends for whatever reason - continuation.onTermination = { @Sendable _ in - print("Install stream terminated — cleaning up.") + // Explicitly annotate the termination parameter type so compiler is happy + continuation.onTermination = { @Sendable (reason: StreamContinuation.Termination) in + print("Install stream terminated: \(reason)") cancellables.removeAll() - // cancel the installation task if still running + // cancel running install task if still active installTask?.cancel() } - // Start the async work on a Task so we can await inside installTask = Task { // Start heartbeat to keep connection alive HeartbeatManager.shared.start() - // initialize view model the same way UI does (important) + // initialize view model same as UI code (important) let isIdevice = UserDefaults.standard.integer(forKey: "Feather.installationMethod") == 1 let viewModel = InstallerStatusViewModel(isIdevice: isIdevice) - // Log useful updates to console (debug) - viewModel.$status.sink { status in - print("[Installer] status ->", status) - }.store(in: &cancellables) + // Debug logging for status changes + viewModel.$status + .sink { status in + print("[Installer] status ->", status) + } + .store(in: &cancellables) + // Progress stream (combine upload & install progress) viewModel.$uploadProgress .combineLatest(viewModel.$installProgress) .sink { uploadProgress, installProgress in let overall = (uploadProgress + installProgress) / 2.0 - let status: String + let statusText: String if uploadProgress < 1.0 { - status = "📤 Uploading..." + statusText = "📤 Uploading..." } else if installProgress < 1.0 { - status = "📲 Installing..." + statusText = "📲 Installing..." } else { - status = "🏁 Finalizing..." + statusText = "🏁 Finalizing..." } - // debug print("[Installer] progress upload:\(uploadProgress) install:\(installProgress) overall:\(overall)") - continuation.yield((overall, status)) + continuation.yield((overall, statusText)) } .store(in: &cancellables) - // Watch for completion via published isCompleted (robust across enum shapes) + // Robust completion detection: watch isCompleted viewModel.$isCompleted .sink { completed in if completed { @@ -177,21 +182,20 @@ public func installApp(from ipaURL: URL) async throws .store(in: &cancellables) do { - // Create the installer tied to the view model let installer = await InstallationProxy(viewModel: viewModel) - // If you need same behaviour as UI, pass the suspend flag like the UI does: - // let suspend = (Bundle.main.bundleIdentifier == someIdentifier) // adapt as needed - // try await installer.install(at: ipaURL, suspend: suspend) - - // For now, call the simpler signature – change to include 'suspend:' if needed + // If your UI calls install(at: suspend:) when updating itself, + // replicate that logic here if you need that behaviour. try await installer.install(at: ipaURL) - // tiny pause so progress updates propagate + // small delay to let final progress propagate try await Task.sleep(nanoseconds: 500_000_000) - print("Installation call returned without throwing — waiting for viewModel to report completion.") - // Don't force finish here: wait for the published isCompleted to fire (above) + print("Installation call returned — waiting for viewModel to report completion.") + + // Note: we intentionally don't call continuation.finish() here - + // we rely on viewModel.$isCompleted to finish the stream so the + // installer has its normal lifecycle. } catch { print("[Installer] install threw error ->", error) @@ -201,4 +205,3 @@ public func installApp(from ipaURL: URL) async throws } } } - From 83bdc95ecbc6605b46d09e7d29abd0b8389b347a Mon Sep 17 00:00:00 2001 From: NovaDev404 <223823408+NovaDev404@users.noreply.github.com> Date: Sat, 20 Dec 2025 12:08:27 +1030 Subject: [PATCH 6/8] Fix bug --- Sources/prostore/install/installApp.swift | 30 +++++++++++++++++++---- Sources/prostore/views/AppsView.swift | 2 +- 2 files changed, 26 insertions(+), 6 deletions(-) diff --git a/Sources/prostore/install/installApp.swift b/Sources/prostore/install/installApp.swift index c28fbec..270912b 100644 --- a/Sources/prostore/install/installApp.swift +++ b/Sources/prostore/install/installApp.swift @@ -145,11 +145,31 @@ public func installApp(from ipaURL: URL) async throws let viewModel = InstallerStatusViewModel(isIdevice: isIdevice) // Debug logging for status changes - viewModel.$status - .sink { status in - print("[Installer] status ->", status) - } - .store(in: &cancellables) +// Watch status for completion / errors (replace the previous $isCompleted sink) +viewModel.$status + .sink { newStatus in + // If the view model exposes a computed Bool isCompleted, use it (safe regardless of enum shape) + if viewModel.isCompleted { + print("[Installer] detected completion via viewModel.isCompleted") + continuation.yield((1.0, "✅ Successfully installed app!")) + continuation.finish() + cancellables.removeAll() + return + } + + // If the status enum contains an error / broken case, finish with that error + // This covers the case where broken carries an associated Error + if case .broken(let error) = newStatus { + print("[Installer] detected broken status ->", error) + continuation.finish(throwing: transformInstallError(error)) + cancellables.removeAll() + return + } + + // Optional: handle a status that carries failure inside .completed (if that variant exists) + // e.g. if your enum had `.completed(.failure(let e))` then add handling here. + } + .store(in: &cancellables) // Progress stream (combine upload & install progress) viewModel.$uploadProgress diff --git a/Sources/prostore/views/AppsView.swift b/Sources/prostore/views/AppsView.swift index f3e2e8e..83bc9b8 100644 --- a/Sources/prostore/views/AppsView.swift +++ b/Sources/prostore/views/AppsView.swift @@ -86,7 +86,7 @@ public struct AppRaw: Decodable { } } } -public struct AppVersion: Decodable { +public struct AppVersion: Decodable, Sendable { let version: String? let date: String? let downloadURL: String? From 2f65385a4d6c86a5881368a16c2047f3812e0eb5 Mon Sep 17 00:00:00 2001 From: NovaDev404 <223823408+NovaDev404@users.noreply.github.com> Date: Sat, 20 Dec 2025 12:16:09 +1030 Subject: [PATCH 7/8] Fix another bug --- Sources/prostore/install/installApp.swift | 138 ++++++---------------- 1 file changed, 37 insertions(+), 101 deletions(-) diff --git a/Sources/prostore/install/installApp.swift b/Sources/prostore/install/installApp.swift index 270912b..5f0be67 100644 --- a/Sources/prostore/install/installApp.swift +++ b/Sources/prostore/install/installApp.swift @@ -2,14 +2,12 @@ import Foundation import IDeviceSwift import Combine -// MARK: - Error Transformer (existing helpers kept) +// MARK: - Error Transformer private func transformInstallError(_ error: Error) -> Error { let nsError = error as NSError let errorString = String(describing: error) - var userMessage = extractUserReadableErrorMessage(from: error) - - if let userMessage = userMessage, !userMessage.isEmpty { + if let userMessage = extractUserReadableErrorMessage(from: error), !userMessage.isEmpty { return NSError(domain: nsError.domain, code: nsError.code, userInfo: [NSLocalizedDescriptionKey: userMessage]) } @@ -17,21 +15,16 @@ private func transformInstallError(_ error: Error) -> Error { if errorString.contains("Missing Pairing") { return NSError(domain: nsError.domain, code: nsError.code, userInfo: [NSLocalizedDescriptionKey: "Missing pairing file. Please ensure pairing file exists in ProStore folder."]) } - if errorString.contains("Cannot connect to AFC") || errorString.contains("afc_client_connect") { return NSError(domain: nsError.domain, code: nsError.code, userInfo: [NSLocalizedDescriptionKey: "Cannot connect to AFC. Check USB connection, VPN, and accept trust dialog on device."]) } - if errorString.contains("installation_proxy") { return NSError(domain: nsError.domain, code: nsError.code, userInfo: [NSLocalizedDescriptionKey: "Installation service failed. The app may already be installed or device storage full."]) } - 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."]) } - let originalMessage = nsError.localizedDescription - let cleanedMessage = cleanGenericErrorMessage(originalMessage) - + let cleanedMessage = cleanGenericErrorMessage(nsError.localizedDescription) return NSError(domain: nsError.domain, code: nsError.code, userInfo: [NSLocalizedDescriptionKey: cleanedMessage]) } @@ -41,7 +34,6 @@ private func extractUserReadableErrorMessage(from error: Error) -> String? { } let errorString = String(describing: error) - let patterns = [ "Missing Pairing": "Missing pairing file. Please check ProStore folder.", "Cannot connect to AFC": "Cannot connect to device. Check USB and VPN.", @@ -51,10 +43,8 @@ private func extractUserReadableErrorMessage(from error: Error) -> String? { "Connection Failed:": "Connection to device failed." ] - for (pattern, message) in patterns { - if errorString.contains(pattern) { - return message - } + for (pattern, message) in patterns where errorString.contains(pattern) { + return message } let nsError = error as NSError @@ -69,44 +59,34 @@ private func extractUserReadableErrorMessage(from error: Error) -> String? { private func cleanGenericErrorMessage(_ message: String) -> String { var cleaned = message - let genericPrefixes = [ "The operation couldn't be completed. ", "The operation could not be completed. ", "IDeviceSwift.IDeviceSwiftError ", "IDeviceSwiftError " ] - - for prefix in genericPrefixes { - if cleaned.hasPrefix(prefix) { - cleaned = String(cleaned.dropFirst(prefix.count)) - break - } - } - - if cleaned.hasSuffix(".") { - cleaned = String(cleaned.dropLast()) + for prefix in genericPrefixes where cleaned.hasPrefix(prefix) { + cleaned = String(cleaned.dropFirst(prefix.count)) + break } - + if cleaned.hasSuffix(".") { cleaned = String(cleaned.dropLast()) } if cleaned == "error 1" || cleaned == "error 1." { return "Device installation failed. Please check: 1) VPN connection, 2) USB cable, 3) Trust dialog, 4) Pairing file." } - return cleaned.isEmpty ? "Unknown installation error" : cleaned } // MARK: - Install App /// Installs a signed IPA on the device using InstallationProxy -public func installApp(from ipaURL: URL) async throws --> AsyncThrowingStream<(progress: Double, status: String), Error> { +public func installApp(from ipaURL: URL) async throws -> AsyncThrowingStream<(progress: Double, status: String), Error> { - // Pre-flight check: verify IPA exists + // Pre-flight IPA check let fileManager = FileManager.default guard fileManager.fileExists(atPath: ipaURL.path) else { throw NSError(domain: "InstallApp", code: -1, userInfo: [NSLocalizedDescriptionKey: "IPA file not found: \(ipaURL.lastPathComponent)"]) } - // Check file size + // Validate file size do { let attributes = try fileManager.attributesOfItem(atPath: ipaURL.path) let fileSize = attributes[.size] as? Int64 ?? 0 @@ -119,8 +99,6 @@ public func installApp(from ipaURL: URL) async throws print("Installing app from: \(ipaURL.path)") - // === IMPORTANT: explicitly specify element and failure types so the compiler selects - // the continuation-style initializer (the one that passes `continuation` to the closure). typealias InstallUpdate = (progress: Double, status: String) typealias StreamContinuation = AsyncThrowingStream.Continuation @@ -128,95 +106,53 @@ public func installApp(from ipaURL: URL) async throws var cancellables = Set() var installTask: Task? - // Explicitly annotate the termination parameter type so compiler is happy - continuation.onTermination = { @Sendable (reason: StreamContinuation.Termination) in + continuation.onTermination = { @Sendable reason in print("Install stream terminated: \(reason)") cancellables.removeAll() - // cancel running install task if still active installTask?.cancel() } installTask = Task { - // Start heartbeat to keep connection alive HeartbeatManager.shared.start() - - // initialize view model same as UI code (important) let isIdevice = UserDefaults.standard.integer(forKey: "Feather.installationMethod") == 1 let viewModel = InstallerStatusViewModel(isIdevice: isIdevice) - // Debug logging for status changes -// Watch status for completion / errors (replace the previous $isCompleted sink) -viewModel.$status - .sink { newStatus in - // If the view model exposes a computed Bool isCompleted, use it (safe regardless of enum shape) - if viewModel.isCompleted { - print("[Installer] detected completion via viewModel.isCompleted") - continuation.yield((1.0, "✅ Successfully installed app!")) - continuation.finish() - cancellables.removeAll() - return - } - - // If the status enum contains an error / broken case, finish with that error - // This covers the case where broken carries an associated Error - if case .broken(let error) = newStatus { - print("[Installer] detected broken status ->", error) - continuation.finish(throwing: transformInstallError(error)) - cancellables.removeAll() - return - } - - // Optional: handle a status that carries failure inside .completed (if that variant exists) - // e.g. if your enum had `.completed(.failure(let e))` then add handling here. - } - .store(in: &cancellables) + // Status updates + viewModel.$status + .sink { newStatus in + if viewModel.isCompleted { + print("[Installer] detected completion via isCompleted") + continuation.yield((1.0, "✅ Successfully installed app!")) + continuation.finish() + cancellables.removeAll() + } + if case .broken(let error) = newStatus { + continuation.finish(throwing: transformInstallError(error)) + cancellables.removeAll() + } + } + .store(in: &cancellables) - // Progress stream (combine upload & install progress) + // Progress stream (upload + install) viewModel.$uploadProgress .combineLatest(viewModel.$installProgress) - .sink { uploadProgress, installProgress in - let overall = (uploadProgress + installProgress) / 2.0 + .sink { upload, install in + let overall = (upload + install) / 2 let statusText: String - if uploadProgress < 1.0 { - statusText = "📤 Uploading..." - } else if installProgress < 1.0 { - statusText = "📲 Installing..." - } else { - statusText = "🏁 Finalizing..." - } - print("[Installer] progress upload:\(uploadProgress) install:\(installProgress) overall:\(overall)") + if upload < 1.0 { statusText = "📤 Uploading..." } + else if install < 1.0 { statusText = "📲 Installing..." } + else { statusText = "🏁 Finalizing..." } + print("[Installer] progress upload:\(upload) install:\(install) overall:\(overall)") continuation.yield((overall, statusText)) } .store(in: &cancellables) - // Robust completion detection: watch isCompleted - viewModel.$isCompleted - .sink { completed in - if completed { - print("[Installer] detected completion via isCompleted=true") - continuation.yield((1.0, "✅ Successfully installed app!")) - continuation.finish() - cancellables.removeAll() - } - } - .store(in: &cancellables) - do { let installer = await InstallationProxy(viewModel: viewModel) - - // If your UI calls install(at: suspend:) when updating itself, - // replicate that logic here if you need that behaviour. try await installer.install(at: ipaURL) - - // small delay to let final progress propagate try await Task.sleep(nanoseconds: 500_000_000) - print("Installation call returned — waiting for viewModel to report completion.") - - // Note: we intentionally don't call continuation.finish() here - - // we rely on viewModel.$isCompleted to finish the stream so the - // installer has its normal lifecycle. - + // Stream finishes when viewModel.isCompleted becomes true or status reports broken/error } catch { print("[Installer] install threw error ->", error) continuation.finish(throwing: transformInstallError(error)) @@ -224,4 +160,4 @@ viewModel.$status } } } -} +} \ No newline at end of file From d45403ed209393a356ec3c9f5dc5bd02ce8c8202 Mon Sep 17 00:00:00 2001 From: NovaDev404 <223823408+NovaDev404@users.noreply.github.com> Date: Sat, 20 Dec 2025 12:17:30 +1030 Subject: [PATCH 8/8] Update Feather to ProStore --- Sources/prostore/install/installApp.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/prostore/install/installApp.swift b/Sources/prostore/install/installApp.swift index 5f0be67..ed4928f 100644 --- a/Sources/prostore/install/installApp.swift +++ b/Sources/prostore/install/installApp.swift @@ -114,7 +114,7 @@ public func installApp(from ipaURL: URL) async throws -> AsyncThrowingStream<(pr installTask = Task { HeartbeatManager.shared.start() - let isIdevice = UserDefaults.standard.integer(forKey: "Feather.installationMethod") == 1 + let isIdevice = UserDefaults.standard.integer(forKey: "ProStore.installationMethod") == 1 let viewModel = InstallerStatusViewModel(isIdevice: isIdevice) // Status updates