A Foundation example for optional ipa-hub updates. Read the integration contract first and adapt the UI to your app.
Download the example
Download IpaHubUpdater.swift. Supports iOS/iPadOS/tvOS 15+, macOS 12+, watchOS 8+, and visionOS.
Implementation
swift
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<Result: Decodable>: 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<Result: Decodable>(
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<Result>.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<T>(_ 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).")
}
}
}
Before shipping
Test on your supported devices. Ask before installing, preserve user work, and treat a disconnected stream as unconfirmed. Do not automatically retry an installation.