Skip to content

Commit d161a5e

Browse files
committed
Fixes up error messages and checks for correct version
1 parent c763a32 commit d161a5e

1 file changed

Lines changed: 111 additions & 72 deletions

File tree

Sources/XcodesKit/RuntimeInstaller.swift

Lines changed: 111 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -113,8 +113,7 @@ public class RuntimeInstaller {
113113
let dmgUrl = try await downloadOrUseExistingArchive(runtime: matchedRuntime, to: destinationDirectory, downloader: downloader)
114114
try await installFromImage(dmgUrl: dmgUrl)
115115
case .cryptexDiskImage:
116-
//
117-
try await downloadAndInstallUsingXcodeBuild(runtime: matchedRuntime, to: destinationDirectory)
116+
try await downloadAndInstallUsingXcodeBuild(runtime: matchedRuntime)
118117
}
119118
}
120119

@@ -222,85 +221,119 @@ public class RuntimeInstaller {
222221
return result
223222
}
224223

225-
private func downloadAndInstallUsingXcodeBuild(runtime: DownloadableRuntime, to destinationDirectory: Path) async throws {
226-
227-
let downloadRuntime: (String, String) -> AsyncThrowingStream<Progress, Swift.Error> = { platform, version in
228-
return AsyncThrowingStream<Progress, Swift.Error> { continuation in
229-
Task {
230-
// Assume progress will not have data races, so we manually opt-out isolation checks.
231-
let progress = Progress()
232-
progress.kind = .file
233-
progress.fileOperationKind = .downloading
234-
235-
let process = Process()
236-
let xcodeBuildPath = Path.root.usr.bin.join("xcodebuild").url
237-
238-
process.executableURL = xcodeBuildPath
239-
process.arguments = [
240-
"-downloadPlatform",
241-
"\(platform)",
242-
"-buildVersion",
243-
"\(version)"
244-
]
245-
246-
let stdOutPipe = Pipe()
247-
process.standardOutput = stdOutPipe
248-
let stdErrPipe = Pipe()
249-
process.standardError = stdErrPipe
250-
251-
let observer = NotificationCenter.default.addObserver(
252-
forName: .NSFileHandleDataAvailable,
253-
object: nil,
254-
queue: OperationQueue.main
255-
) { note in
256-
guard
257-
// This should always be the case for Notification.Name.NSFileHandleDataAvailable
258-
let handle = note.object as? FileHandle,
259-
handle === stdOutPipe.fileHandleForReading || handle === stdErrPipe.fileHandleForReading
260-
else { return }
261-
262-
defer { handle.waitForDataInBackgroundAndNotify() }
263-
264-
let string = String(decoding: handle.availableData, as: UTF8.self)
265-
progress.updateFromXcodebuild(text: string)
266-
continuation.yield(progress)
267-
}
268-
269-
stdOutPipe.fileHandleForReading.waitForDataInBackgroundAndNotify()
270-
stdErrPipe.fileHandleForReading.waitForDataInBackgroundAndNotify()
271-
272-
continuation.onTermination = { @Sendable _ in
273-
process.terminate()
274-
NotificationCenter.default.removeObserver(observer, name: .NSFileHandleDataAvailable, object: nil)
275-
}
276-
277-
do {
278-
try process.run()
279-
} catch {
280-
continuation.finish(throwing: error)
281-
}
282-
283-
process.waitUntilExit()
224+
// MARK: Xcode 16.1 Runtime installation helpers
225+
/// Downloads and installs the runtime using xcodebuild, requires Xcode 16.1+ to download a runtime using a given directory
226+
/// - Parameters:
227+
/// - runtime: The runtime to download and install to identify the platform and version numbers
228+
private func downloadAndInstallUsingXcodeBuild(runtime: DownloadableRuntime) async throws {
284229

285-
NotificationCenter.default.removeObserver(observer, name: .NSFileHandleDataAvailable, object: nil)
230+
// Make sure that we are using a version of xcode that supports this
231+
try await ensureSelectedXcodeVersionForDownload()
286232

287-
guard process.terminationReason == .exit, process.terminationStatus == 0 else {
288-
struct ProcessExecutionError: Swift.Error {}
289-
continuation.finish(throwing: ProcessExecutionError())
290-
return
291-
}
292-
continuation.finish()
293-
}
294-
}
295-
}
233+
// Kick off the download/install process and get an async stream of the progress
234+
let downloadStream = createXcodebuildDownloadStream(runtime: runtime)
296235

297-
for try await progress in downloadRuntime(runtime.platform.shortName, runtime.simulatorVersion.buildUpdate) {
236+
// Observe the progress and update the console from it
237+
for try await progress in downloadStream {
298238
let formatter = NumberFormatter(numberStyle: .percent)
299239
guard Current.shell.isatty() else { return }
300240
// These escape codes move up a line and then clear to the end
301241
Current.logging.log("\u{1B}[1A\u{1B}[KDownloading Runtime \(runtime.visibleIdentifier): \(formatter.string(from: progress.fractionCompleted)!)")
302242
}
303243
}
244+
245+
private func ensureSelectedXcodeVersionForDownload() async throws {
246+
let xcodeBuildPath = Path.root.usr.bin.join("xcodebuild")
247+
let versionString = try await Process.run(xcodeBuildPath, "-version").async()
248+
let versionPattern = #"Xcode (\d+\.\d+)"#
249+
let versionRegex = try NSRegularExpression(pattern: versionPattern)
250+
251+
// parse out the version string (e.g. 16.1) from the xcodebuild version command and convert it to a `Version`
252+
guard let match = versionRegex.firstMatch(in: versionString.out, range: NSRange(versionString.out.startIndex..., in: versionString.out)),
253+
let versionRange = Range(match.range(at: 1), in: versionString.out),
254+
let version = Version(tolerant: String(versionString.out[versionRange])) else {
255+
throw Error.noXcodeSelectedFound
256+
}
257+
258+
guard version >= Version(16, 1, 0) else {
259+
throw Error.xcode16_1OrGreaterRequired(version)
260+
}
261+
262+
// If we made it here, we're gucci and 16.1 or greater is selected
263+
}
264+
265+
private func createXcodebuildDownloadStream(runtime: DownloadableRuntime) -> AsyncThrowingStream<Progress, Swift.Error> {
266+
let platform = runtime.platform.shortName
267+
let version = runtime.simulatorVersion.buildUpdate
268+
269+
return AsyncThrowingStream<Progress, Swift.Error> { continuation in
270+
Task {
271+
// Assume progress will not have data races, so we manually opt-out isolation checks.
272+
let progress = Progress()
273+
progress.kind = .file
274+
progress.fileOperationKind = .downloading
275+
276+
let process = Process()
277+
let xcodeBuildPath = Path.root.usr.bin.join("xcodebuild").url
278+
279+
process.executableURL = xcodeBuildPath
280+
process.arguments = [
281+
"-downloadPlatform",
282+
"\(platform)",
283+
"-buildVersion",
284+
"\(version)"
285+
]
286+
287+
let stdOutPipe = Pipe()
288+
process.standardOutput = stdOutPipe
289+
let stdErrPipe = Pipe()
290+
process.standardError = stdErrPipe
291+
292+
let observer = NotificationCenter.default.addObserver(
293+
forName: .NSFileHandleDataAvailable,
294+
object: nil,
295+
queue: OperationQueue.main
296+
) { note in
297+
guard
298+
// This should always be the case for Notification.Name.NSFileHandleDataAvailable
299+
let handle = note.object as? FileHandle,
300+
handle === stdOutPipe.fileHandleForReading || handle === stdErrPipe.fileHandleForReading
301+
else { return }
302+
303+
defer { handle.waitForDataInBackgroundAndNotify() }
304+
305+
let string = String(decoding: handle.availableData, as: UTF8.self)
306+
progress.updateFromXcodebuild(text: string)
307+
continuation.yield(progress)
308+
}
309+
310+
stdOutPipe.fileHandleForReading.waitForDataInBackgroundAndNotify()
311+
stdErrPipe.fileHandleForReading.waitForDataInBackgroundAndNotify()
312+
313+
continuation.onTermination = { @Sendable _ in
314+
process.terminate()
315+
NotificationCenter.default.removeObserver(observer, name: .NSFileHandleDataAvailable, object: nil)
316+
}
317+
318+
do {
319+
try process.run()
320+
} catch {
321+
continuation.finish(throwing: error)
322+
}
323+
324+
process.waitUntilExit()
325+
326+
NotificationCenter.default.removeObserver(observer, name: .NSFileHandleDataAvailable, object: nil)
327+
328+
guard process.terminationReason == .exit, process.terminationStatus == 0 else {
329+
struct ProcessExecutionError: Swift.Error {}
330+
continuation.finish(throwing: ProcessExecutionError())
331+
return
332+
}
333+
continuation.finish()
334+
}
335+
}
336+
}
304337
}
305338

306339
extension RuntimeInstaller {
@@ -309,6 +342,8 @@ extension RuntimeInstaller {
309342
case failedMountingDMG
310343
case rootNeeded
311344
case missingRuntimeSource(String)
345+
case xcode16_1OrGreaterRequired(Version)
346+
case noXcodeSelectedFound
312347

313348
public var errorDescription: String? {
314349
switch self {
@@ -320,6 +355,10 @@ extension RuntimeInstaller {
320355
return "Must be run as root to install the specified runtime"
321356
case let .missingRuntimeSource(identifier):
322357
return "Runtime \(identifier) is missing source url. Downloading of iOS 18 runtimes are only supported using Xcode 16.1+ and can only be installed, not just downloaded."
358+
case let .xcode16_1OrGreaterRequired(version):
359+
return "Installing this runtime requires Xcode 16.1 or greater to be selected, but is currently \(version.description)"
360+
case .noXcodeSelectedFound:
361+
return "No Xcode is currently selected, please make sure that you have one selected and installed before trying to install this runtime"
323362
}
324363
}
325364
}

0 commit comments

Comments
 (0)