From c763a321bde19f047c6d4fb990015bcdbc600050 Mon Sep 17 00:00:00 2001 From: Joe Spadafora Date: Wed, 23 Oct 2024 14:48:20 -0400 Subject: [PATCH 1/6] Gets downloads working a bit --- Sources/XcodesKit/RuntimeInstaller.swift | 152 +++++++++++++++++++---- 1 file changed, 131 insertions(+), 21 deletions(-) diff --git a/Sources/XcodesKit/RuntimeInstaller.swift b/Sources/XcodesKit/RuntimeInstaller.swift index d9f04ef..7a97f58 100644 --- a/Sources/XcodesKit/RuntimeInstaller.swift +++ b/Sources/XcodesKit/RuntimeInstaller.swift @@ -102,23 +102,20 @@ public class RuntimeInstaller { public func downloadAndInstallRuntime(identifier: String, to destinationDirectory: Path, with downloader: Downloader, shouldDelete: Bool) async throws { let matchedRuntime = try await getMatchingRuntime(identifier: identifier) - if matchedRuntime.contentType == .package && !Current.shell.isRoot() { - throw Error.rootNeeded - } - - let dmgUrl = try await downloadOrUseExistingArchive(runtime: matchedRuntime, to: destinationDirectory, downloader: downloader) - switch matchedRuntime.contentType { - case .package: - try await installFromPackage(dmgUrl: dmgUrl, runtime: matchedRuntime) - case .diskImage: - try await installFromImage(dmgUrl: dmgUrl) - case .cryptexDiskImage: - throw Error.unsupportedCryptexDiskImage - } - if shouldDelete { - Current.logging.log("Deleting Archive") - try? Current.files.removeItem(at: dmgUrl) - } + switch matchedRuntime.contentType { + case .package: + guard Current.shell.isRoot() else { + throw Error.rootNeeded + } + let dmgUrl = try await downloadOrUseExistingArchive(runtime: matchedRuntime, to: destinationDirectory, downloader: downloader) + try await installFromPackage(dmgUrl: dmgUrl, runtime: matchedRuntime) + case .diskImage: + let dmgUrl = try await downloadOrUseExistingArchive(runtime: matchedRuntime, to: destinationDirectory, downloader: downloader) + try await installFromImage(dmgUrl: dmgUrl) + case .cryptexDiskImage: + // + try await downloadAndInstallUsingXcodeBuild(runtime: matchedRuntime, to: destinationDirectory) + } } private func getMatchingRuntime(identifier: String) async throws -> DownloadableRuntime { @@ -224,6 +221,86 @@ public class RuntimeInstaller { destination.setCurrentUserAsOwner() return result } + + private func downloadAndInstallUsingXcodeBuild(runtime: DownloadableRuntime, to destinationDirectory: Path) async throws { + + let downloadRuntime: (String, String) -> AsyncThrowingStream = { platform, version in + return AsyncThrowingStream { continuation in + Task { + // Assume progress will not have data races, so we manually opt-out isolation checks. + let progress = Progress() + progress.kind = .file + progress.fileOperationKind = .downloading + + let process = Process() + let xcodeBuildPath = Path.root.usr.bin.join("xcodebuild").url + + process.executableURL = xcodeBuildPath + process.arguments = [ + "-downloadPlatform", + "\(platform)", + "-buildVersion", + "\(version)" + ] + + let stdOutPipe = Pipe() + process.standardOutput = stdOutPipe + let stdErrPipe = Pipe() + process.standardError = stdErrPipe + + let observer = NotificationCenter.default.addObserver( + forName: .NSFileHandleDataAvailable, + object: nil, + queue: OperationQueue.main + ) { note in + guard + // This should always be the case for Notification.Name.NSFileHandleDataAvailable + let handle = note.object as? FileHandle, + handle === stdOutPipe.fileHandleForReading || handle === stdErrPipe.fileHandleForReading + else { return } + + defer { handle.waitForDataInBackgroundAndNotify() } + + let string = String(decoding: handle.availableData, as: UTF8.self) + progress.updateFromXcodebuild(text: string) + continuation.yield(progress) + } + + stdOutPipe.fileHandleForReading.waitForDataInBackgroundAndNotify() + stdErrPipe.fileHandleForReading.waitForDataInBackgroundAndNotify() + + continuation.onTermination = { @Sendable _ in + process.terminate() + NotificationCenter.default.removeObserver(observer, name: .NSFileHandleDataAvailable, object: nil) + } + + do { + try process.run() + } catch { + continuation.finish(throwing: error) + } + + process.waitUntilExit() + + NotificationCenter.default.removeObserver(observer, name: .NSFileHandleDataAvailable, object: nil) + + guard process.terminationReason == .exit, process.terminationStatus == 0 else { + struct ProcessExecutionError: Swift.Error {} + continuation.finish(throwing: ProcessExecutionError()) + return + } + continuation.finish() + } + } + } + + for try await progress in downloadRuntime(runtime.platform.shortName, runtime.simulatorVersion.buildUpdate) { + let formatter = NumberFormatter(numberStyle: .percent) + guard Current.shell.isatty() else { return } + // These escape codes move up a line and then clear to the end + Current.logging.log("\u{1B}[1A\u{1B}[KDownloading Runtime \(runtime.visibleIdentifier): \(formatter.string(from: progress.fractionCompleted)!)") + } + } } extension RuntimeInstaller { @@ -232,7 +309,6 @@ extension RuntimeInstaller { case failedMountingDMG case rootNeeded case missingRuntimeSource(String) - case unsupportedCryptexDiskImage public var errorDescription: String? { switch self { @@ -243,9 +319,7 @@ extension RuntimeInstaller { case .rootNeeded: return "Must be run as root to install the specified runtime" case let .missingRuntimeSource(identifier): - 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" - case .unsupportedCryptexDiskImage: - return "Cryptex Disk Image is not yet supported." + 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." } } } @@ -294,3 +368,39 @@ extension Array { return result } } + + +private extension Progress { + func updateFromXcodebuild(text: String) { + self.totalUnitCount = 100 + self.completedUnitCount = 0 + self.localizedAdditionalDescription = "" // to not show the addtional + + do { + + let downloadPattern = #"(\d+\.\d+)% \(([\d.]+ (?:MB|GB)) of ([\d.]+ GB)\)"# + let downloadRegex = try NSRegularExpression(pattern: downloadPattern) + + // Search for matches in the text + if let match = downloadRegex.firstMatch(in: text, range: NSRange(text.startIndex..., in: text)) { + // Extract the percentage - simpler then trying to extract size MB/GB and convert to bytes. + if let percentRange = Range(match.range(at: 1), in: text), let percentDouble = Double(text[percentRange]) { + let percent = Int64(percentDouble.rounded()) + self.completedUnitCount = percent + } + } + + // "Downloading tvOS 18.1 Simulator (22J5567a): Installing..." or + // "Downloading tvOS 18.1 Simulator (22J5567a): Installing (registering download)..." + if text.range(of: "Installing") != nil { + // sets the progress to indeterminite to show animating progress + self.totalUnitCount = 0 + self.completedUnitCount = 0 + } + + } catch { + print("Invalid regular expression") + } + + } +} From d161a5e980def58d71ea009281cc52ebecc0142d Mon Sep 17 00:00:00 2001 From: Joe Spadafora Date: Thu, 24 Oct 2024 13:26:42 -0400 Subject: [PATCH 2/6] Fixes up error messages and checks for correct version --- Sources/XcodesKit/RuntimeInstaller.swift | 183 ++++++++++++++--------- 1 file changed, 111 insertions(+), 72 deletions(-) diff --git a/Sources/XcodesKit/RuntimeInstaller.swift b/Sources/XcodesKit/RuntimeInstaller.swift index 7a97f58..3a318f2 100644 --- a/Sources/XcodesKit/RuntimeInstaller.swift +++ b/Sources/XcodesKit/RuntimeInstaller.swift @@ -113,8 +113,7 @@ public class RuntimeInstaller { let dmgUrl = try await downloadOrUseExistingArchive(runtime: matchedRuntime, to: destinationDirectory, downloader: downloader) try await installFromImage(dmgUrl: dmgUrl) case .cryptexDiskImage: - // - try await downloadAndInstallUsingXcodeBuild(runtime: matchedRuntime, to: destinationDirectory) + try await downloadAndInstallUsingXcodeBuild(runtime: matchedRuntime) } } @@ -222,85 +221,119 @@ public class RuntimeInstaller { return result } - private func downloadAndInstallUsingXcodeBuild(runtime: DownloadableRuntime, to destinationDirectory: Path) async throws { - - let downloadRuntime: (String, String) -> AsyncThrowingStream = { platform, version in - return AsyncThrowingStream { continuation in - Task { - // Assume progress will not have data races, so we manually opt-out isolation checks. - let progress = Progress() - progress.kind = .file - progress.fileOperationKind = .downloading - - let process = Process() - let xcodeBuildPath = Path.root.usr.bin.join("xcodebuild").url - - process.executableURL = xcodeBuildPath - process.arguments = [ - "-downloadPlatform", - "\(platform)", - "-buildVersion", - "\(version)" - ] - - let stdOutPipe = Pipe() - process.standardOutput = stdOutPipe - let stdErrPipe = Pipe() - process.standardError = stdErrPipe - - let observer = NotificationCenter.default.addObserver( - forName: .NSFileHandleDataAvailable, - object: nil, - queue: OperationQueue.main - ) { note in - guard - // This should always be the case for Notification.Name.NSFileHandleDataAvailable - let handle = note.object as? FileHandle, - handle === stdOutPipe.fileHandleForReading || handle === stdErrPipe.fileHandleForReading - else { return } - - defer { handle.waitForDataInBackgroundAndNotify() } - - let string = String(decoding: handle.availableData, as: UTF8.self) - progress.updateFromXcodebuild(text: string) - continuation.yield(progress) - } - - stdOutPipe.fileHandleForReading.waitForDataInBackgroundAndNotify() - stdErrPipe.fileHandleForReading.waitForDataInBackgroundAndNotify() - - continuation.onTermination = { @Sendable _ in - process.terminate() - NotificationCenter.default.removeObserver(observer, name: .NSFileHandleDataAvailable, object: nil) - } - - do { - try process.run() - } catch { - continuation.finish(throwing: error) - } - - process.waitUntilExit() + // MARK: Xcode 16.1 Runtime installation helpers + /// Downloads and installs the runtime using xcodebuild, requires Xcode 16.1+ to download a runtime using a given directory + /// - Parameters: + /// - runtime: The runtime to download and install to identify the platform and version numbers + private func downloadAndInstallUsingXcodeBuild(runtime: DownloadableRuntime) async throws { - NotificationCenter.default.removeObserver(observer, name: .NSFileHandleDataAvailable, object: nil) + // Make sure that we are using a version of xcode that supports this + try await ensureSelectedXcodeVersionForDownload() - guard process.terminationReason == .exit, process.terminationStatus == 0 else { - struct ProcessExecutionError: Swift.Error {} - continuation.finish(throwing: ProcessExecutionError()) - return - } - continuation.finish() - } - } - } + // Kick off the download/install process and get an async stream of the progress + let downloadStream = createXcodebuildDownloadStream(runtime: runtime) - for try await progress in downloadRuntime(runtime.platform.shortName, runtime.simulatorVersion.buildUpdate) { + // Observe the progress and update the console from it + for try await progress in downloadStream { let formatter = NumberFormatter(numberStyle: .percent) guard Current.shell.isatty() else { return } // These escape codes move up a line and then clear to the end Current.logging.log("\u{1B}[1A\u{1B}[KDownloading Runtime \(runtime.visibleIdentifier): \(formatter.string(from: progress.fractionCompleted)!)") } } + + private func ensureSelectedXcodeVersionForDownload() async throws { + let xcodeBuildPath = Path.root.usr.bin.join("xcodebuild") + let versionString = try await Process.run(xcodeBuildPath, "-version").async() + let versionPattern = #"Xcode (\d+\.\d+)"# + let versionRegex = try NSRegularExpression(pattern: versionPattern) + + // parse out the version string (e.g. 16.1) from the xcodebuild version command and convert it to a `Version` + guard let match = versionRegex.firstMatch(in: versionString.out, range: NSRange(versionString.out.startIndex..., in: versionString.out)), + let versionRange = Range(match.range(at: 1), in: versionString.out), + let version = Version(tolerant: String(versionString.out[versionRange])) else { + throw Error.noXcodeSelectedFound + } + + guard version >= Version(16, 1, 0) else { + throw Error.xcode16_1OrGreaterRequired(version) + } + + // If we made it here, we're gucci and 16.1 or greater is selected + } + + private func createXcodebuildDownloadStream(runtime: DownloadableRuntime) -> AsyncThrowingStream { + let platform = runtime.platform.shortName + let version = runtime.simulatorVersion.buildUpdate + + return AsyncThrowingStream { continuation in + Task { + // Assume progress will not have data races, so we manually opt-out isolation checks. + let progress = Progress() + progress.kind = .file + progress.fileOperationKind = .downloading + + let process = Process() + let xcodeBuildPath = Path.root.usr.bin.join("xcodebuild").url + + process.executableURL = xcodeBuildPath + process.arguments = [ + "-downloadPlatform", + "\(platform)", + "-buildVersion", + "\(version)" + ] + + let stdOutPipe = Pipe() + process.standardOutput = stdOutPipe + let stdErrPipe = Pipe() + process.standardError = stdErrPipe + + let observer = NotificationCenter.default.addObserver( + forName: .NSFileHandleDataAvailable, + object: nil, + queue: OperationQueue.main + ) { note in + guard + // This should always be the case for Notification.Name.NSFileHandleDataAvailable + let handle = note.object as? FileHandle, + handle === stdOutPipe.fileHandleForReading || handle === stdErrPipe.fileHandleForReading + else { return } + + defer { handle.waitForDataInBackgroundAndNotify() } + + let string = String(decoding: handle.availableData, as: UTF8.self) + progress.updateFromXcodebuild(text: string) + continuation.yield(progress) + } + + stdOutPipe.fileHandleForReading.waitForDataInBackgroundAndNotify() + stdErrPipe.fileHandleForReading.waitForDataInBackgroundAndNotify() + + continuation.onTermination = { @Sendable _ in + process.terminate() + NotificationCenter.default.removeObserver(observer, name: .NSFileHandleDataAvailable, object: nil) + } + + do { + try process.run() + } catch { + continuation.finish(throwing: error) + } + + process.waitUntilExit() + + NotificationCenter.default.removeObserver(observer, name: .NSFileHandleDataAvailable, object: nil) + + guard process.terminationReason == .exit, process.terminationStatus == 0 else { + struct ProcessExecutionError: Swift.Error {} + continuation.finish(throwing: ProcessExecutionError()) + return + } + continuation.finish() + } + } + } } extension RuntimeInstaller { @@ -309,6 +342,8 @@ extension RuntimeInstaller { case failedMountingDMG case rootNeeded case missingRuntimeSource(String) + case xcode16_1OrGreaterRequired(Version) + case noXcodeSelectedFound public var errorDescription: String? { switch self { @@ -320,6 +355,10 @@ extension RuntimeInstaller { return "Must be run as root to install the specified runtime" case let .missingRuntimeSource(identifier): 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." + case let .xcode16_1OrGreaterRequired(version): + return "Installing this runtime requires Xcode 16.1 or greater to be selected, but is currently \(version.description)" + case .noXcodeSelectedFound: + return "No Xcode is currently selected, please make sure that you have one selected and installed before trying to install this runtime" } } } From 66c012b8e360dcd231576d0eff029045dfadffa3 Mon Sep 17 00:00:00 2001 From: Joe Spadafora Date: Thu, 24 Oct 2024 13:30:08 -0400 Subject: [PATCH 3/6] Convert tabs to spaces to match project --- Sources/XcodesKit/RuntimeInstaller.swift | 320 +++++++++++------------ 1 file changed, 160 insertions(+), 160 deletions(-) diff --git a/Sources/XcodesKit/RuntimeInstaller.swift b/Sources/XcodesKit/RuntimeInstaller.swift index 3a318f2..fb808b6 100644 --- a/Sources/XcodesKit/RuntimeInstaller.swift +++ b/Sources/XcodesKit/RuntimeInstaller.swift @@ -102,19 +102,19 @@ public class RuntimeInstaller { public func downloadAndInstallRuntime(identifier: String, to destinationDirectory: Path, with downloader: Downloader, shouldDelete: Bool) async throws { let matchedRuntime = try await getMatchingRuntime(identifier: identifier) - switch matchedRuntime.contentType { - case .package: - guard Current.shell.isRoot() else { - throw Error.rootNeeded - } - let dmgUrl = try await downloadOrUseExistingArchive(runtime: matchedRuntime, to: destinationDirectory, downloader: downloader) - try await installFromPackage(dmgUrl: dmgUrl, runtime: matchedRuntime) - case .diskImage: - let dmgUrl = try await downloadOrUseExistingArchive(runtime: matchedRuntime, to: destinationDirectory, downloader: downloader) - try await installFromImage(dmgUrl: dmgUrl) - case .cryptexDiskImage: - try await downloadAndInstallUsingXcodeBuild(runtime: matchedRuntime) - } + switch matchedRuntime.contentType { + case .package: + guard Current.shell.isRoot() else { + throw Error.rootNeeded + } + let dmgUrl = try await downloadOrUseExistingArchive(runtime: matchedRuntime, to: destinationDirectory, downloader: downloader) + try await installFromPackage(dmgUrl: dmgUrl, runtime: matchedRuntime) + case .diskImage: + let dmgUrl = try await downloadOrUseExistingArchive(runtime: matchedRuntime, to: destinationDirectory, downloader: downloader) + try await installFromImage(dmgUrl: dmgUrl) + case .cryptexDiskImage: + try await downloadAndInstallUsingXcodeBuild(runtime: matchedRuntime) + } } private func getMatchingRuntime(identifier: String) async throws -> DownloadableRuntime { @@ -221,119 +221,119 @@ public class RuntimeInstaller { return result } - // MARK: Xcode 16.1 Runtime installation helpers - /// Downloads and installs the runtime using xcodebuild, requires Xcode 16.1+ to download a runtime using a given directory - /// - Parameters: - /// - runtime: The runtime to download and install to identify the platform and version numbers - private func downloadAndInstallUsingXcodeBuild(runtime: DownloadableRuntime) async throws { - - // Make sure that we are using a version of xcode that supports this - try await ensureSelectedXcodeVersionForDownload() - - // Kick off the download/install process and get an async stream of the progress - let downloadStream = createXcodebuildDownloadStream(runtime: runtime) - - // Observe the progress and update the console from it - for try await progress in downloadStream { - let formatter = NumberFormatter(numberStyle: .percent) - guard Current.shell.isatty() else { return } - // These escape codes move up a line and then clear to the end - Current.logging.log("\u{1B}[1A\u{1B}[KDownloading Runtime \(runtime.visibleIdentifier): \(formatter.string(from: progress.fractionCompleted)!)") - } - } - - private func ensureSelectedXcodeVersionForDownload() async throws { - let xcodeBuildPath = Path.root.usr.bin.join("xcodebuild") - let versionString = try await Process.run(xcodeBuildPath, "-version").async() - let versionPattern = #"Xcode (\d+\.\d+)"# - let versionRegex = try NSRegularExpression(pattern: versionPattern) - - // parse out the version string (e.g. 16.1) from the xcodebuild version command and convert it to a `Version` - guard let match = versionRegex.firstMatch(in: versionString.out, range: NSRange(versionString.out.startIndex..., in: versionString.out)), - let versionRange = Range(match.range(at: 1), in: versionString.out), - let version = Version(tolerant: String(versionString.out[versionRange])) else { - throw Error.noXcodeSelectedFound - } - - guard version >= Version(16, 1, 0) else { - throw Error.xcode16_1OrGreaterRequired(version) - } - - // If we made it here, we're gucci and 16.1 or greater is selected - } - - private func createXcodebuildDownloadStream(runtime: DownloadableRuntime) -> AsyncThrowingStream { - let platform = runtime.platform.shortName - let version = runtime.simulatorVersion.buildUpdate - - return AsyncThrowingStream { continuation in - Task { - // Assume progress will not have data races, so we manually opt-out isolation checks. - let progress = Progress() - progress.kind = .file - progress.fileOperationKind = .downloading - - let process = Process() - let xcodeBuildPath = Path.root.usr.bin.join("xcodebuild").url - - process.executableURL = xcodeBuildPath - process.arguments = [ - "-downloadPlatform", - "\(platform)", - "-buildVersion", - "\(version)" - ] - - let stdOutPipe = Pipe() - process.standardOutput = stdOutPipe - let stdErrPipe = Pipe() - process.standardError = stdErrPipe - - let observer = NotificationCenter.default.addObserver( - forName: .NSFileHandleDataAvailable, - object: nil, - queue: OperationQueue.main - ) { note in - guard - // This should always be the case for Notification.Name.NSFileHandleDataAvailable - let handle = note.object as? FileHandle, - handle === stdOutPipe.fileHandleForReading || handle === stdErrPipe.fileHandleForReading - else { return } - - defer { handle.waitForDataInBackgroundAndNotify() } - - let string = String(decoding: handle.availableData, as: UTF8.self) - progress.updateFromXcodebuild(text: string) - continuation.yield(progress) - } - - stdOutPipe.fileHandleForReading.waitForDataInBackgroundAndNotify() - stdErrPipe.fileHandleForReading.waitForDataInBackgroundAndNotify() - - continuation.onTermination = { @Sendable _ in - process.terminate() - NotificationCenter.default.removeObserver(observer, name: .NSFileHandleDataAvailable, object: nil) - } - - do { - try process.run() - } catch { - continuation.finish(throwing: error) - } - - process.waitUntilExit() - - NotificationCenter.default.removeObserver(observer, name: .NSFileHandleDataAvailable, object: nil) - - guard process.terminationReason == .exit, process.terminationStatus == 0 else { - struct ProcessExecutionError: Swift.Error {} - continuation.finish(throwing: ProcessExecutionError()) - return - } - continuation.finish() - } - } - } + // MARK: Xcode 16.1 Runtime installation helpers + /// Downloads and installs the runtime using xcodebuild, requires Xcode 16.1+ to download a runtime using a given directory + /// - Parameters: + /// - runtime: The runtime to download and install to identify the platform and version numbers + private func downloadAndInstallUsingXcodeBuild(runtime: DownloadableRuntime) async throws { + + // Make sure that we are using a version of xcode that supports this + try await ensureSelectedXcodeVersionForDownload() + + // Kick off the download/install process and get an async stream of the progress + let downloadStream = createXcodebuildDownloadStream(runtime: runtime) + + // Observe the progress and update the console from it + for try await progress in downloadStream { + let formatter = NumberFormatter(numberStyle: .percent) + guard Current.shell.isatty() else { return } + // These escape codes move up a line and then clear to the end + Current.logging.log("\u{1B}[1A\u{1B}[KDownloading Runtime \(runtime.visibleIdentifier): \(formatter.string(from: progress.fractionCompleted)!)") + } + } + + private func ensureSelectedXcodeVersionForDownload() async throws { + let xcodeBuildPath = Path.root.usr.bin.join("xcodebuild") + let versionString = try await Process.run(xcodeBuildPath, "-version").async() + let versionPattern = #"Xcode (\d+\.\d+)"# + let versionRegex = try NSRegularExpression(pattern: versionPattern) + + // parse out the version string (e.g. 16.1) from the xcodebuild version command and convert it to a `Version` + guard let match = versionRegex.firstMatch(in: versionString.out, range: NSRange(versionString.out.startIndex..., in: versionString.out)), + let versionRange = Range(match.range(at: 1), in: versionString.out), + let version = Version(tolerant: String(versionString.out[versionRange])) else { + throw Error.noXcodeSelectedFound + } + + guard version >= Version(16, 1, 0) else { + throw Error.xcode16_1OrGreaterRequired(version) + } + + // If we made it here, we're gucci and 16.1 or greater is selected + } + + private func createXcodebuildDownloadStream(runtime: DownloadableRuntime) -> AsyncThrowingStream { + let platform = runtime.platform.shortName + let version = runtime.simulatorVersion.buildUpdate + + return AsyncThrowingStream { continuation in + Task { + // Assume progress will not have data races, so we manually opt-out isolation checks. + let progress = Progress() + progress.kind = .file + progress.fileOperationKind = .downloading + + let process = Process() + let xcodeBuildPath = Path.root.usr.bin.join("xcodebuild").url + + process.executableURL = xcodeBuildPath + process.arguments = [ + "-downloadPlatform", + "\(platform)", + "-buildVersion", + "\(version)" + ] + + let stdOutPipe = Pipe() + process.standardOutput = stdOutPipe + let stdErrPipe = Pipe() + process.standardError = stdErrPipe + + let observer = NotificationCenter.default.addObserver( + forName: .NSFileHandleDataAvailable, + object: nil, + queue: OperationQueue.main + ) { note in + guard + // This should always be the case for Notification.Name.NSFileHandleDataAvailable + let handle = note.object as? FileHandle, + handle === stdOutPipe.fileHandleForReading || handle === stdErrPipe.fileHandleForReading + else { return } + + defer { handle.waitForDataInBackgroundAndNotify() } + + let string = String(decoding: handle.availableData, as: UTF8.self) + progress.updateFromXcodebuild(text: string) + continuation.yield(progress) + } + + stdOutPipe.fileHandleForReading.waitForDataInBackgroundAndNotify() + stdErrPipe.fileHandleForReading.waitForDataInBackgroundAndNotify() + + continuation.onTermination = { @Sendable _ in + process.terminate() + NotificationCenter.default.removeObserver(observer, name: .NSFileHandleDataAvailable, object: nil) + } + + do { + try process.run() + } catch { + continuation.finish(throwing: error) + } + + process.waitUntilExit() + + NotificationCenter.default.removeObserver(observer, name: .NSFileHandleDataAvailable, object: nil) + + guard process.terminationReason == .exit, process.terminationStatus == 0 else { + struct ProcessExecutionError: Swift.Error {} + continuation.finish(throwing: ProcessExecutionError()) + return + } + continuation.finish() + } + } + } } extension RuntimeInstaller { @@ -342,8 +342,8 @@ extension RuntimeInstaller { case failedMountingDMG case rootNeeded case missingRuntimeSource(String) - case xcode16_1OrGreaterRequired(Version) - case noXcodeSelectedFound + case xcode16_1OrGreaterRequired(Version) + case noXcodeSelectedFound public var errorDescription: String? { switch self { @@ -410,36 +410,36 @@ extension Array { private extension Progress { - func updateFromXcodebuild(text: String) { - self.totalUnitCount = 100 - self.completedUnitCount = 0 - self.localizedAdditionalDescription = "" // to not show the addtional - - do { - - let downloadPattern = #"(\d+\.\d+)% \(([\d.]+ (?:MB|GB)) of ([\d.]+ GB)\)"# - let downloadRegex = try NSRegularExpression(pattern: downloadPattern) - - // Search for matches in the text - if let match = downloadRegex.firstMatch(in: text, range: NSRange(text.startIndex..., in: text)) { - // Extract the percentage - simpler then trying to extract size MB/GB and convert to bytes. - if let percentRange = Range(match.range(at: 1), in: text), let percentDouble = Double(text[percentRange]) { - let percent = Int64(percentDouble.rounded()) - self.completedUnitCount = percent - } - } - - // "Downloading tvOS 18.1 Simulator (22J5567a): Installing..." or - // "Downloading tvOS 18.1 Simulator (22J5567a): Installing (registering download)..." - if text.range(of: "Installing") != nil { - // sets the progress to indeterminite to show animating progress - self.totalUnitCount = 0 - self.completedUnitCount = 0 - } - - } catch { - print("Invalid regular expression") - } - - } + func updateFromXcodebuild(text: String) { + self.totalUnitCount = 100 + self.completedUnitCount = 0 + self.localizedAdditionalDescription = "" // to not show the addtional + + do { + + let downloadPattern = #"(\d+\.\d+)% \(([\d.]+ (?:MB|GB)) of ([\d.]+ GB)\)"# + let downloadRegex = try NSRegularExpression(pattern: downloadPattern) + + // Search for matches in the text + if let match = downloadRegex.firstMatch(in: text, range: NSRange(text.startIndex..., in: text)) { + // Extract the percentage - simpler then trying to extract size MB/GB and convert to bytes. + if let percentRange = Range(match.range(at: 1), in: text), let percentDouble = Double(text[percentRange]) { + let percent = Int64(percentDouble.rounded()) + self.completedUnitCount = percent + } + } + + // "Downloading tvOS 18.1 Simulator (22J5567a): Installing..." or + // "Downloading tvOS 18.1 Simulator (22J5567a): Installing (registering download)..." + if text.range(of: "Installing") != nil { + // sets the progress to indeterminite to show animating progress + self.totalUnitCount = 0 + self.completedUnitCount = 0 + } + + } catch { + print("Invalid regular expression") + } + + } } From c5127eeee9c237144b2b7dbdce2588380da381fe Mon Sep 17 00:00:00 2001 From: Joe Spadafora Date: Thu, 24 Oct 2024 13:33:05 -0400 Subject: [PATCH 4/6] Adds small documentation to new functions added --- Sources/XcodesKit/RuntimeInstaller.swift | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/Sources/XcodesKit/RuntimeInstaller.swift b/Sources/XcodesKit/RuntimeInstaller.swift index fb808b6..3358b93 100644 --- a/Sources/XcodesKit/RuntimeInstaller.swift +++ b/Sources/XcodesKit/RuntimeInstaller.swift @@ -38,9 +38,6 @@ public class RuntimeInstaller { } } - - - installed.forEach { runtime in let resolvedBetaNumber = downloadablesResponse.sdkToSeedMappings.first { $0.buildUpdate == runtime.build @@ -242,6 +239,8 @@ public class RuntimeInstaller { } } + /// Checks the existing `xcodebuild -version` to ensure that the version is appropriate to use for downloading the cryptex style 16.1+ downloads + /// otherwise will throw an error private func ensureSelectedXcodeVersionForDownload() async throws { let xcodeBuildPath = Path.root.usr.bin.join("xcodebuild") let versionString = try await Process.run(xcodeBuildPath, "-version").async() @@ -255,6 +254,7 @@ public class RuntimeInstaller { throw Error.noXcodeSelectedFound } + // actually compare the version against version 16.1 to ensure it's equal or greater guard version >= Version(16, 1, 0) else { throw Error.xcode16_1OrGreaterRequired(version) } @@ -262,6 +262,7 @@ public class RuntimeInstaller { // If we made it here, we're gucci and 16.1 or greater is selected } + // Creates and invokes the xcodebuild install command and converts it to a stream of Progress private func createXcodebuildDownloadStream(runtime: DownloadableRuntime) -> AsyncThrowingStream { let platform = runtime.platform.shortName let version = runtime.simulatorVersion.buildUpdate From ec44af81c18eb740296c03ae0291278f2a62cf45 Mon Sep 17 00:00:00 2001 From: Joe Spadafora Date: Thu, 24 Oct 2024 14:28:59 -0400 Subject: [PATCH 5/6] Restore delete behavior to fix tests --- Sources/XcodesKit/RuntimeInstaller.swift | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/Sources/XcodesKit/RuntimeInstaller.swift b/Sources/XcodesKit/RuntimeInstaller.swift index 3358b93..1fad323 100644 --- a/Sources/XcodesKit/RuntimeInstaller.swift +++ b/Sources/XcodesKit/RuntimeInstaller.swift @@ -99,6 +99,13 @@ public class RuntimeInstaller { public func downloadAndInstallRuntime(identifier: String, to destinationDirectory: Path, with downloader: Downloader, shouldDelete: Bool) async throws { let matchedRuntime = try await getMatchingRuntime(identifier: identifier) + let deleteIfNeeded: (URL) -> Void = { dmgUrl in + if shouldDelete { + Current.logging.log("Deleting Archive") + try? Current.files.removeItem(at: dmgUrl) + } + } + switch matchedRuntime.contentType { case .package: guard Current.shell.isRoot() else { @@ -106,9 +113,11 @@ public class RuntimeInstaller { } let dmgUrl = try await downloadOrUseExistingArchive(runtime: matchedRuntime, to: destinationDirectory, downloader: downloader) try await installFromPackage(dmgUrl: dmgUrl, runtime: matchedRuntime) + deleteIfNeeded(dmgUrl) case .diskImage: let dmgUrl = try await downloadOrUseExistingArchive(runtime: matchedRuntime, to: destinationDirectory, downloader: downloader) try await installFromImage(dmgUrl: dmgUrl) + deleteIfNeeded(dmgUrl) case .cryptexDiskImage: try await downloadAndInstallUsingXcodeBuild(runtime: matchedRuntime) } From d0b01bc907c206505c4d2898a052b749f564b250 Mon Sep 17 00:00:00 2001 From: Matt Kiazyk Date: Tue, 29 Oct 2024 21:40:34 -0500 Subject: [PATCH 6/6] Updated some error descriptions --- Sources/XcodesKit/RuntimeInstaller.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/XcodesKit/RuntimeInstaller.swift b/Sources/XcodesKit/RuntimeInstaller.swift index 1fad323..9eed4ff 100644 --- a/Sources/XcodesKit/RuntimeInstaller.swift +++ b/Sources/XcodesKit/RuntimeInstaller.swift @@ -188,7 +188,7 @@ public class RuntimeInstaller { @MainActor public func downloadOrUseExistingArchive(runtime: DownloadableRuntime, to destinationDirectory: Path, downloader: Downloader) async throws -> URL { guard let source = runtime.source else { - throw Error.missingRuntimeSource(runtime.identifier) + throw Error.missingRuntimeSource(runtime.visibleIdentifier) } let url = URL(string: source)! let destination = destinationDirectory/url.lastPathComponent @@ -364,7 +364,7 @@ extension RuntimeInstaller { case .rootNeeded: return "Must be run as root to install the specified runtime" case let .missingRuntimeSource(identifier): - 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." + return "Downloading runtime \(identifier) is not supported at this time. Please use `xcodes runtimes install \"\(identifier)\"` instead." case let .xcode16_1OrGreaterRequired(version): return "Installing this runtime requires Xcode 16.1 or greater to be selected, but is currently \(version.description)" case .noXcodeSelectedFound: