import Foundation // iOS/iPadOS/tvOS 15+, macOS 12+, watchOS 8+, visionOS 1+. // ipa-hub adds exact HTTP-host ATS exceptions and missing Local Network prompt text before signing. // No NSAllowsArbitraryLoads is needed. Local Network permission still requires user approval. @available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) @MainActor final class IpaHubUpdater { struct Check: Decodable { let available: Bool let current: String? let latest: String? let reason: String? let prefetch: String? } struct Progress: Decodable { let step: String let message: String let completed: Double? let total: Double? var fraction: Double? { guard let completed, let total, total > 0 else { return nil } return min(1, max(0, completed / total)) } } enum Prefetch: String { case background = "true", disabled = "false", foreground = "foreground" } private struct Event: Decodable { let type: String let progress: Progress? let message: String? let result: Result? } private struct InstallationResult: Decodable {} private struct Failure: Decodable { let error: String } struct UpdateError: LocalizedError { let message: String var errorDescription: String? { message } } // A redirect could disclose the installation token to another host. private final class NoRedirects: NSObject, URLSessionTaskDelegate, @unchecked Sendable { func urlSession( _ session: URLSession, task: URLSessionTask, willPerformHTTPRedirection response: HTTPURLResponse, newRequest request: URLRequest, completionHandler: @escaping @Sendable (URLRequest?) -> Void ) { completionHandler(nil) } } private let endpoints: [URL] private var preferredEndpoint: URL? private let session: URLSession /// Nil for normal App Store/development installations without ipa-hub metadata. init?(bundle: Bundle = .main) { let metadata = bundle.object(forInfoDictionaryKey: "IPAHub") as? [String: Any] guard let destinations = metadata?["updateURL"] as? [String] else { return nil } var urls: [URL] = [] for destination in destinations { guard let url = URL(string: destination.trimmingCharacters(in: .whitespacesAndNewlines)), ["http", "https"].contains(url.scheme?.lowercased() ?? ""), let host = url.host, !host.isEmpty, url.user == nil, url.password == nil, url.fragment == nil, url.path.range(of: #"^(/api/app/[a-f0-9]{48}/update|/update/[a-f0-9]{48})$"#, options: .regularExpression) != nil else { return nil } if !urls.contains(url) { urls.append(url) } } guard !urls.isEmpty else { return nil } endpoints = urls let configuration = URLSessionConfiguration.ephemeral configuration.requestCachePolicy = .reloadIgnoringLocalCacheData configuration.timeoutIntervalForRequest = 30 configuration.timeoutIntervalForResource = 30 * 60 session = URLSession(configuration: configuration, delegate: NoRedirects(), delegateQueue: nil) } deinit { session.invalidateAndCancel() } /// Default prefetch starts in the background; foreground prefetch streams progress until the check result arrives. func check(prefetch: Prefetch = .background, onProgress: (Progress) -> Void = { _ in }) async throws -> Check { if prefetch == .foreground { return try await stream(check: true, values: ["prefetch": prefetch.rawValue], onProgress: onProgress) } let (data, response) = try await withDestination { endpoint in let url = try route(endpoint, check: true, values: ["prefetch": prefetch.rawValue]) var request = URLRequest(url: url) request.setValue("application/json", forHTTPHeaderField: "Accept") request.timeoutInterval = 30 return try await session.data(for: request) } try validate(response, errorData: data) return try JSONDecoder().decode(Check.self, from: data) } /// Call only after explicit user confirmation, with the app's work saved. /// openAfterInstall permits reopening only if the Mac found this app running immediately before installation. func install(openAfterInstall: Bool = true, onProgress: (Progress) -> Void) async throws { let _: InstallationResult = try await stream( check: false, values: ["open": openAfterInstall ? "true" : "false"], onProgress: onProgress ) } private func stream( check: Bool, values: [String: String], onProgress: (Progress) -> Void ) async throws -> Result { let (bytes, response) = try await withDestination { endpoint in let url = try route(endpoint, check: check, values: values) var request = URLRequest(url: url) request.httpMethod = check ? "GET" : "POST" request.setValue("application/x-ndjson", forHTTPHeaderField: "Accept") request.timeoutInterval = 30 * 60 return try await session.bytes(for: request) } guard let http = response as? HTTPURLResponse else { throw UpdateError(message: "ipa-hub returned an invalid response.") } if !(200..<300).contains(http.statusCode) { var data = Data() for try await byte in bytes { guard data.count < 32_768 else { break } data.append(byte) } try validate(response, errorData: data) } guard http.value(forHTTPHeaderField: "Content-Type")?.hasPrefix("application/x-ndjson") == true else { throw UpdateError(message: "ipa-hub did not return a progress stream.") } for try await line in bytes.lines { try Task.checkCancellation() guard !line.isEmpty else { continue } guard line.utf8.count <= 1_048_576 else { throw UpdateError(message: "ipa-hub returned an oversized progress event.") } let event = try JSONDecoder().decode(Event.self, from: Data(line.utf8)) switch event.type { case "progress": if let progress = event.progress { onProgress(progress) } case "result": guard let result = event.result else { throw UpdateError(message: "ipa-hub returned an incomplete result.") } return result case "error": throw UpdateError(message: event.message ?? "ipa-hub could not install the update.") case "selection": throw UpdateError(message: "Choose the required asset or signing profile in ipa-hub on your Mac, then retry.") default: continue // Ignore future event types. } } throw UpdateError(message: check ? "The connection ended before prefetch completed. Check again before installing." : "The connection ended before confirmation. Installation may still be running; check ipa-hub before retrying.") } // Retry only errors that mean the request never reached a server. Never replay a lost install stream. private func withDestination(_ operation: @MainActor (URL) async throws -> T) async throws -> T { let ordered = preferredEndpoint.map { preferred in [preferred] + endpoints.filter { $0 != preferred } } ?? endpoints for (index, endpoint) in ordered.enumerated() { try Task.checkCancellation() do { let result = try await operation(endpoint) preferredEndpoint = endpoint return result } catch let error as URLError where index + 1 < ordered.count && [ URLError.Code.cannotFindHost, .dnsLookupFailed, .cannotConnectToHost, .appTransportSecurityRequiresSecureConnection ].contains(error.code) { continue } } throw UpdateError(message: "No ipa-hub destination is available.") } private func route(_ endpoint: URL, check: Bool, values: [String: String]) throws -> URL { guard var parts = URLComponents(url: endpoint, resolvingAgainstBaseURL: false) else { throw UpdateError(message: "Invalid ipa-hub update URL.") } if check { parts.path += "/check" } var items = (parts.queryItems ?? []).filter { values[$0.name] == nil } items += values.map { URLQueryItem(name: $0.key, value: $0.value) } parts.queryItems = items guard let url = parts.url else { throw UpdateError(message: "Invalid ipa-hub update URL.") } return url } private func validate(_ response: URLResponse, errorData: Data) throws { guard let http = response as? HTTPURLResponse else { throw UpdateError(message: "ipa-hub returned an invalid response.") } guard (200..<300).contains(http.statusCode) else { let message = (try? JSONDecoder().decode(Failure.self, from: errorData))?.error throw UpdateError(message: message ?? "ipa-hub returned HTTP \(http.statusCode).") } } }