Swift client

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.