Skip to content

Commit c763a32

Browse files
committed
Gets downloads working a bit
1 parent 74516ad commit c763a32

1 file changed

Lines changed: 131 additions & 21 deletions

File tree

Sources/XcodesKit/RuntimeInstaller.swift

Lines changed: 131 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -102,23 +102,20 @@ public class RuntimeInstaller {
102102
public func downloadAndInstallRuntime(identifier: String, to destinationDirectory: Path, with downloader: Downloader, shouldDelete: Bool) async throws {
103103
let matchedRuntime = try await getMatchingRuntime(identifier: identifier)
104104

105-
if matchedRuntime.contentType == .package && !Current.shell.isRoot() {
106-
throw Error.rootNeeded
107-
}
108-
109-
let dmgUrl = try await downloadOrUseExistingArchive(runtime: matchedRuntime, to: destinationDirectory, downloader: downloader)
110-
switch matchedRuntime.contentType {
111-
case .package:
112-
try await installFromPackage(dmgUrl: dmgUrl, runtime: matchedRuntime)
113-
case .diskImage:
114-
try await installFromImage(dmgUrl: dmgUrl)
115-
case .cryptexDiskImage:
116-
throw Error.unsupportedCryptexDiskImage
117-
}
118-
if shouldDelete {
119-
Current.logging.log("Deleting Archive")
120-
try? Current.files.removeItem(at: dmgUrl)
121-
}
105+
switch matchedRuntime.contentType {
106+
case .package:
107+
guard Current.shell.isRoot() else {
108+
throw Error.rootNeeded
109+
}
110+
let dmgUrl = try await downloadOrUseExistingArchive(runtime: matchedRuntime, to: destinationDirectory, downloader: downloader)
111+
try await installFromPackage(dmgUrl: dmgUrl, runtime: matchedRuntime)
112+
case .diskImage:
113+
let dmgUrl = try await downloadOrUseExistingArchive(runtime: matchedRuntime, to: destinationDirectory, downloader: downloader)
114+
try await installFromImage(dmgUrl: dmgUrl)
115+
case .cryptexDiskImage:
116+
//
117+
try await downloadAndInstallUsingXcodeBuild(runtime: matchedRuntime, to: destinationDirectory)
118+
}
122119
}
123120

124121
private func getMatchingRuntime(identifier: String) async throws -> DownloadableRuntime {
@@ -224,6 +221,86 @@ public class RuntimeInstaller {
224221
destination.setCurrentUserAsOwner()
225222
return result
226223
}
224+
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()
284+
285+
NotificationCenter.default.removeObserver(observer, name: .NSFileHandleDataAvailable, object: nil)
286+
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+
}
296+
297+
for try await progress in downloadRuntime(runtime.platform.shortName, runtime.simulatorVersion.buildUpdate) {
298+
let formatter = NumberFormatter(numberStyle: .percent)
299+
guard Current.shell.isatty() else { return }
300+
// These escape codes move up a line and then clear to the end
301+
Current.logging.log("\u{1B}[1A\u{1B}[KDownloading Runtime \(runtime.visibleIdentifier): \(formatter.string(from: progress.fractionCompleted)!)")
302+
}
303+
}
227304
}
228305

229306
extension RuntimeInstaller {
@@ -232,7 +309,6 @@ extension RuntimeInstaller {
232309
case failedMountingDMG
233310
case rootNeeded
234311
case missingRuntimeSource(String)
235-
case unsupportedCryptexDiskImage
236312

237313
public var errorDescription: String? {
238314
switch self {
@@ -243,9 +319,7 @@ extension RuntimeInstaller {
243319
case .rootNeeded:
244320
return "Must be run as root to install the specified runtime"
245321
case let .missingRuntimeSource(identifier):
246-
return "Runtime \(identifier) is missing source url. Downloading of iOS 18 runtimes are not supported. Please install manually see https://developer.apple.com/documentation/xcode/installing-additional-simulator-runtimes"
247-
case .unsupportedCryptexDiskImage:
248-
return "Cryptex Disk Image is not yet supported."
322+
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."
249323
}
250324
}
251325
}
@@ -294,3 +368,39 @@ extension Array {
294368
return result
295369
}
296370
}
371+
372+
373+
private extension Progress {
374+
func updateFromXcodebuild(text: String) {
375+
self.totalUnitCount = 100
376+
self.completedUnitCount = 0
377+
self.localizedAdditionalDescription = "" // to not show the addtional
378+
379+
do {
380+
381+
let downloadPattern = #"(\d+\.\d+)% \(([\d.]+ (?:MB|GB)) of ([\d.]+ GB)\)"#
382+
let downloadRegex = try NSRegularExpression(pattern: downloadPattern)
383+
384+
// Search for matches in the text
385+
if let match = downloadRegex.firstMatch(in: text, range: NSRange(text.startIndex..., in: text)) {
386+
// Extract the percentage - simpler then trying to extract size MB/GB and convert to bytes.
387+
if let percentRange = Range(match.range(at: 1), in: text), let percentDouble = Double(text[percentRange]) {
388+
let percent = Int64(percentDouble.rounded())
389+
self.completedUnitCount = percent
390+
}
391+
}
392+
393+
// "Downloading tvOS 18.1 Simulator (22J5567a): Installing..." or
394+
// "Downloading tvOS 18.1 Simulator (22J5567a): Installing (registering download)..."
395+
if text.range(of: "Installing") != nil {
396+
// sets the progress to indeterminite to show animating progress
397+
self.totalUnitCount = 0
398+
self.completedUnitCount = 0
399+
}
400+
401+
} catch {
402+
print("Invalid regular expression")
403+
}
404+
405+
}
406+
}

0 commit comments

Comments
 (0)