Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
216 changes: 68 additions & 148 deletions Sources/prostore/install/installApp.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
// installApp.swift
import Foundation
import IDeviceSwift
import Combine
Expand All @@ -7,75 +6,34 @@ import Combine
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]
)

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
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 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."]
)
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 service failed. The app may already be installed or device storage 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]
)

let cleanedMessage = cleanGenericErrorMessage(nsError.localizedDescription)
return NSError(domain: nsError.domain, code: nsError.code, userInfo: [NSLocalizedDescriptionKey: cleanedMessage])
}

// 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
}

let errorString = String(describing: error)

// Look for specific error patterns in the string representation
let patterns = [
"Missing Pairing": "Missing pairing file. Please check ProStore folder.",
"Cannot connect to AFC": "Cannot connect to device. Check USB and VPN.",
Expand All @@ -84,158 +42,120 @@ private func extractUserReadableErrorMessage(from error: Error) -> String? {
"File Error:": "File operation failed.",
"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
}

// 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())
for prefix in genericPrefixes where cleaned.hasPrefix(prefix) {
cleaned = String(cleaned.dropFirst(prefix.count))
break
}

// If it's just "error 1.", provide more helpful message
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)"]
)
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
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
HeartbeatManager.shared.start()
typealias InstallUpdate = (progress: Double, status: String)
typealias StreamContinuation = AsyncThrowingStream<InstallUpdate, Error>.Continuation

let viewModel = InstallerStatusViewModel()
var cancellables = Set<AnyCancellable>()
return AsyncThrowingStream<InstallUpdate, Error> { continuation in
var cancellables = Set<AnyCancellable>()
var installTask: Task<Void, Never>?

// Progress stream
viewModel.$uploadProgress
.combineLatest(viewModel.$installProgress)
.sink { uploadProgress, installProgress in
let overallProgress = (uploadProgress + installProgress) / 2.0
let status: String

if uploadProgress < 1.0 {
status = "📤 Uploading..."
} else if installProgress < 1.0 {
status = "📲 Installing..."
} else {
status = "🏁 Finalizing..."
}
continuation.onTermination = { @Sendable reason in
print("Install stream terminated: \(reason)")
cancellables.removeAll()
installTask?.cancel()
}

continuation.yield((overallProgress, status))
}
.store(in: &cancellables)
installTask = Task {
HeartbeatManager.shared.start()
let isIdevice = UserDefaults.standard.integer(forKey: "ProStore.installationMethod") == 1
let viewModel = InstallerStatusViewModel(isIdevice: isIdevice)

// Completion handling
// Status updates
viewModel.$status
.sink { installerStatus in
switch installerStatus {

case .completed(.success):
.sink { newStatus in
if viewModel.isCompleted {
print("[Installer] detected completion via isCompleted")
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)
)
}
if case .broken(let error) = newStatus {
continuation.finish(throwing: transformInstallError(error))
cancellables.removeAll()

default:
break
}
}
.store(in: &cancellables)

// Progress stream (upload + install)
viewModel.$uploadProgress
.combineLatest(viewModel.$installProgress)
.sink { upload, install in
let overall = (upload + install) / 2
let statusText: String
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)

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!")

try await Task.sleep(nanoseconds: 500_000_000)
print("Installation call returned — waiting for viewModel to report completion.")
// Stream finishes when viewModel.isCompleted becomes true or status reports broken/error
} catch {
continuation.finish(
throwing: transformInstallError(error)
)
print("[Installer] install threw error ->", error)
continuation.finish(throwing: transformInstallError(error))
cancellables.removeAll()
}
}
Expand Down
2 changes: 1 addition & 1 deletion Sources/prostore/views/AppsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down
5 changes: 3 additions & 2 deletions project.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -59,3 +59,4 @@ targets:
- package: IDeviceKit

product: IDeviceSwift

Loading