diff --git a/Sources/XcodesKit/RuntimeInstaller.swift b/Sources/XcodesKit/RuntimeInstaller.swift index d9f04ef..9eed4ff 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 @@ -102,22 +99,27 @@ 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 deleteIfNeeded: (URL) -> Void = { dmgUrl in + if shouldDelete { + Current.logging.log("Deleting Archive") + try? Current.files.removeItem(at: dmgUrl) + } } - 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) + 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) + 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) } } @@ -186,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 @@ -224,6 +226,124 @@ public class RuntimeInstaller { destination.setCurrentUserAsOwner() 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)!)") + } + } + + /// 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() + 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 + } + + // 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) + } + + // 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 + + 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 { @@ -232,7 +352,8 @@ extension RuntimeInstaller { case failedMountingDMG case rootNeeded case missingRuntimeSource(String) - case unsupportedCryptexDiskImage + case xcode16_1OrGreaterRequired(Version) + case noXcodeSelectedFound public var errorDescription: String? { switch self { @@ -243,9 +364,11 @@ 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 "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: + return "No Xcode is currently selected, please make sure that you have one selected and installed before trying to install this runtime" } } } @@ -294,3 +417,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") + } + + } +}