242 lines
10 KiB
Swift
242 lines
10 KiB
Swift
import Foundation
|
|
import RunnerCore
|
|
import Virtualization
|
|
|
|
/// Locates, downloads, and opens macOS restore images (IPSWs).
|
|
///
|
|
/// Two distinct notions of "restore image" get conflated easily, so this type
|
|
/// keeps them apart:
|
|
///
|
|
/// * `VZMacOSRestoreImage.latestSupported` returns an image whose `url` is a
|
|
/// **network** URL on Apple's CDN. It cannot be handed to `VZMacOSInstaller`.
|
|
/// * `VZMacOSRestoreImage.image(from:)` (or `load(from:)`) opens a **local
|
|
/// file** URL. That is what the installer needs.
|
|
///
|
|
/// So the pipeline is always: discover → download → load.
|
|
public struct IPSWProvider: Sendable {
|
|
/// Where downloads are written, typically `<storeDir>/ipsw`.
|
|
public let downloadDirectory: URL
|
|
|
|
/// Creates a provider.
|
|
///
|
|
/// - Parameter downloadDirectory: Destination directory for downloads.
|
|
public init(downloadDirectory: URL) {
|
|
self.downloadDirectory = downloadDirectory
|
|
}
|
|
|
|
/// Asks Apple for the newest restore image this host can run.
|
|
///
|
|
/// - Returns: The CDN URL to download and the image's build version (e.g.
|
|
/// `25A354`), recorded into ``VMBundleConfig/macOSVersion``.
|
|
/// - Throws: ``CoreError/notFound(_:)`` when Apple reports no supported
|
|
/// image (which also happens with no network).
|
|
public func latestSupported() async throws -> (url: URL, buildVersion: String) {
|
|
let image: VZMacOSRestoreImage
|
|
do {
|
|
image = try await VZMacOSRestoreImage.latestSupported
|
|
} catch {
|
|
// The framework reports "no supported image" and "could not reach
|
|
// the CDN" identically, so the message has to cover both.
|
|
throw CoreError.notFound(
|
|
"no supported macOS restore image available: \(error.localizedDescription) "
|
|
+ "(check network connectivity, or pass --ipsw with a local file)"
|
|
)
|
|
}
|
|
return (image.url, image.buildVersion)
|
|
}
|
|
|
|
/// Downloads a restore image to ``downloadDirectory``.
|
|
///
|
|
/// IPSWs are ~15 GB, so this reports progress and resumes nothing — a failed
|
|
/// download is retried from scratch. The file is written to a `.partial`
|
|
/// name and renamed on completion so an interrupted run never leaves a
|
|
/// truncated file that looks valid.
|
|
///
|
|
/// - Parameters:
|
|
/// - remoteURL: The CDN URL from ``latestSupported()``.
|
|
/// - progress: Called with a fraction in `0...1`. May be called from an
|
|
/// arbitrary thread.
|
|
/// - Returns: The local file URL.
|
|
public func download(
|
|
from remoteURL: URL,
|
|
progress: (@Sendable (Double) -> Void)? = nil
|
|
) async throws -> URL {
|
|
// A file URL is already local; nothing to do.
|
|
if remoteURL.isFileURL {
|
|
progress?(1.0)
|
|
return remoteURL
|
|
}
|
|
|
|
let fileManager = FileManager.default
|
|
try fileManager.createDirectory(at: downloadDirectory, withIntermediateDirectories: true)
|
|
|
|
let fileName = IPSWProvider.localFileName(for: remoteURL)
|
|
let finalURL = downloadDirectory.appendingPathComponent(fileName)
|
|
|
|
// A previously completed download is reused: only fully-written files
|
|
// ever get the final name.
|
|
if fileManager.fileExists(atPath: finalURL.path) {
|
|
progress?(1.0)
|
|
return finalURL
|
|
}
|
|
|
|
let partialURL = downloadDirectory.appendingPathComponent(fileName + ".partial")
|
|
try? fileManager.removeItem(at: partialURL)
|
|
|
|
let configuration = URLSessionConfiguration.default
|
|
// The default 7-day resource timeout is useless as a failure signal and
|
|
// the default 60 s request timeout only bounds the *response start*.
|
|
// Six hours is generous for 15 GB on a slow link and still finite.
|
|
configuration.timeoutIntervalForRequest = 120
|
|
configuration.timeoutIntervalForResource = 6 * 60 * 60
|
|
configuration.waitsForConnectivity = true
|
|
|
|
let delegate = IPSWDownloadProgressDelegate(onProgress: progress)
|
|
let session = URLSession(configuration: configuration)
|
|
defer { session.finishTasksAndInvalidate() }
|
|
|
|
let temporaryURL: URL
|
|
let response: URLResponse
|
|
do {
|
|
(temporaryURL, response) = try await session.download(from: remoteURL, delegate: delegate)
|
|
} catch {
|
|
throw CoreError.notFound(
|
|
"restore image download failed for \(remoteURL.absoluteString): \(error.localizedDescription)"
|
|
)
|
|
}
|
|
|
|
if let http = response as? HTTPURLResponse, !(200..<300).contains(http.statusCode) {
|
|
try? fileManager.removeItem(at: temporaryURL)
|
|
throw CoreError.notFound(
|
|
"restore image download failed: HTTP \(http.statusCode) for \(remoteURL.absoluteString)"
|
|
)
|
|
}
|
|
|
|
// Move into `.partial` first, then rename: the final name is the
|
|
// "this file is complete" marker that the reuse check above trusts.
|
|
do {
|
|
try fileManager.moveItem(at: temporaryURL, to: partialURL)
|
|
try fileManager.moveItem(at: partialURL, to: finalURL)
|
|
} catch {
|
|
try? fileManager.removeItem(at: temporaryURL)
|
|
try? fileManager.removeItem(at: partialURL)
|
|
throw CoreError.provisioningFailed(
|
|
"could not store the downloaded restore image at \(finalURL.path): \(error.localizedDescription)"
|
|
)
|
|
}
|
|
|
|
progress?(1.0)
|
|
return finalURL
|
|
}
|
|
|
|
/// Opens a local IPSW.
|
|
///
|
|
/// Symlinks are resolved first: `VZMacOSRestoreImage` rejects a symlinked
|
|
/// path, and `~/Downloads` paths handed in by users are frequently symlinked
|
|
/// through `/Users` → `/System/Volumes/Data/Users`.
|
|
///
|
|
/// - Parameter localPath: Path to an `.ipsw` file.
|
|
/// - Returns: The loaded restore image.
|
|
/// - Throws: ``CoreError/notFound(_:)`` when the path does not exist, or
|
|
/// ``CoreError/configInvalid(_:)`` when it is not a local file URL.
|
|
public func load(localPath: String) async throws -> VZMacOSRestoreImage {
|
|
let expanded = (localPath as NSString).expandingTildeInPath
|
|
var url = URL(fileURLWithPath: expanded)
|
|
// Must happen before the framework ever sees the URL.
|
|
url.resolveSymlinksInPath()
|
|
|
|
guard url.isFileURL else {
|
|
throw CoreError.configInvalid(
|
|
"restore image path must be a local file, got \(url.absoluteString)"
|
|
)
|
|
}
|
|
|
|
// `VZMacOSRestoreImage.image(from:)` raises an Objective-C exception —
|
|
// not a Swift error — when handed a non-file or missing path, and an
|
|
// ObjC exception cannot be caught here. So the existence check is not
|
|
// politeness; it is the only thing standing between a typo and a crash.
|
|
var isDirectory: ObjCBool = false
|
|
guard FileManager.default.fileExists(atPath: url.path, isDirectory: &isDirectory),
|
|
!isDirectory.boolValue
|
|
else {
|
|
throw CoreError.notFound("restore image not found at \(url.path)")
|
|
}
|
|
|
|
do {
|
|
return try await VZMacOSRestoreImage.image(from: url)
|
|
} catch {
|
|
throw CoreError.provisioningFailed(
|
|
"could not read restore image at \(url.path): \(error.localizedDescription)"
|
|
)
|
|
}
|
|
}
|
|
|
|
/// Convenience: discover, download if not already present, and load.
|
|
///
|
|
/// - Parameter progress: Download progress callback.
|
|
/// - Returns: The loaded image and the local file it came from.
|
|
public func fetchLatest(
|
|
progress: (@Sendable (Double) -> Void)? = nil
|
|
) async throws -> (image: VZMacOSRestoreImage, localURL: URL) {
|
|
let (remoteURL, _) = try await latestSupported()
|
|
let localURL = try await download(from: remoteURL, progress: progress)
|
|
// Deliberately reloaded from the local file: the image returned by
|
|
// `latestSupported` carries a network URL, and the installer needs one
|
|
// whose `url` is on disk.
|
|
let image = try await load(localPath: localURL.path)
|
|
return (image, localURL)
|
|
}
|
|
|
|
// MARK: - Helpers
|
|
|
|
/// The on-disk name for a remote restore image.
|
|
///
|
|
/// Apple's CDN names are already unique (`UniversalMac_15.2_24C101_Restore.ipsw`);
|
|
/// anything else falls back to a name derived from the URL so two different
|
|
/// sources cannot collide.
|
|
static func localFileName(for remoteURL: URL) -> String {
|
|
let candidate = remoteURL.lastPathComponent
|
|
if candidate.lowercased().hasSuffix(".ipsw"), candidate.count > ".ipsw".count {
|
|
return candidate
|
|
}
|
|
let digest = abs(remoteURL.absoluteString.hashValue)
|
|
return "restore-\(String(digest, radix: 16)).ipsw"
|
|
}
|
|
}
|
|
|
|
/// Reports `URLSession` download progress as a fraction.
|
|
///
|
|
/// A task-scoped delegate is the only way to observe byte progress from the
|
|
/// `async` download API; the `didFinishDownloadingTo` callback is deliberately
|
|
/// *not* implemented, because the `async` variant owns the temporary file.
|
|
private final class IPSWDownloadProgressDelegate: NSObject, URLSessionDownloadDelegate, @unchecked Sendable {
|
|
private let onProgress: (@Sendable (Double) -> Void)?
|
|
|
|
init(onProgress: (@Sendable (Double) -> Void)?) {
|
|
self.onProgress = onProgress
|
|
}
|
|
|
|
func urlSession(
|
|
_ session: URLSession,
|
|
downloadTask: URLSessionDownloadTask,
|
|
didWriteData bytesWritten: Int64,
|
|
totalBytesWritten: Int64,
|
|
totalBytesExpectedToWrite: Int64
|
|
) {
|
|
// A chunked response reports -1 for the expected length; report nothing
|
|
// rather than a nonsense fraction.
|
|
guard totalBytesExpectedToWrite > 0 else { return }
|
|
let fraction = Double(totalBytesWritten) / Double(totalBytesExpectedToWrite)
|
|
onProgress?(min(max(fraction, 0), 1))
|
|
}
|
|
|
|
func urlSession(
|
|
_ session: URLSession,
|
|
downloadTask: URLSessionDownloadTask,
|
|
didFinishDownloadingTo location: URL
|
|
) {
|
|
// Intentionally empty. `URLSession.download(from:delegate:)` moves the
|
|
// file itself; doing anything here would race with it.
|
|
}
|
|
}
|