diff --git a/Xcodes/Backend/AppState+Runtimes.swift b/Xcodes/Backend/AppState+Runtimes.swift index 54be1114..ca8f5e46 100644 --- a/Xcodes/Backend/AppState+Runtimes.swift +++ b/Xcodes/Backend/AppState+Runtimes.swift @@ -4,6 +4,7 @@ import OSLog import Combine import Path import AppleAPI +import Version extension AppState { func updateDownloadableRuntimes() { @@ -48,6 +49,69 @@ extension AppState { } func downloadRuntime(runtime: DownloadableRuntime) { + guard let selectedXcode = self.allXcodes.first(where: { $0.selected }) else { + Logger.appState.error("No selected Xcode") + DispatchQueue.main.async { + self.presentedAlert = .generic(title: localizeString("Alert.Install.Error.Title"), message: "No selected Xcode. Please make an Xcode active") + } + return + } + // new runtimes + if runtime.contentType == .cryptexDiskImage { + // only selected xcodes > 16.1 beta 3 can download runtimes via a xcodebuild -downloadPlatform version + // only Runtimes coming from cryptexDiskImage can be downloaded via xcodebuild + if selectedXcode.version > Version(major: 16, minor: 0, patch: 0) { + downloadRuntimeViaXcodeBuild(runtime: runtime) + } else { + // not supported + Logger.appState.error("Trying to download a runtime we can't download") + DispatchQueue.main.async { + self.presentedAlert = .generic(title: localizeString("Alert.Install.Error.Title"), message: "Sorry. Apple only supports downloading runtimes iOS 18+, tvOS 18+, watchOS 11+, visionOS 2+ with Xcode 16.1+. Please download and make active.") + } + return + } + } else { + downloadRuntimeObseleteWay(runtime: runtime) + } + } + + func downloadRuntimeViaXcodeBuild(runtime: DownloadableRuntime) { + runtimePublishers[runtime.identifier] = Task { + do { + for try await progress in Current.shell.downloadRuntime(runtime.platform.shortName, runtime.simulatorVersion.buildUpdate) { + if progress.isIndeterminate { + DispatchQueue.main.async { + self.setInstallationStep(of: runtime, to: .installing, postNotification: false) + } + } else { + DispatchQueue.main.async { + self.setInstallationStep(of: runtime, to: .downloading(progress: progress), postNotification: false) + } + } + + } + Logger.appState.debug("Done downloading runtime - \(runtime.name)") + DispatchQueue.main.async { + guard let index = self.downloadableRuntimes.firstIndex(where: { $0.identifier == runtime.identifier }) else { return } + self.downloadableRuntimes[index].installState = .installed + self.update() + } + + } catch { + Logger.appState.error("Error downloading runtime: \(error.localizedDescription)") + DispatchQueue.main.async { + self.error = error + if let error = error as? String { + self.presentedAlert = .generic(title: localizeString("Alert.Install.Error.Title"), message: error) + } else { + self.presentedAlert = .generic(title: localizeString("Alert.Install.Error.Title"), message: error.legibleLocalizedDescription) + } + } + } + } + } + + func downloadRuntimeObseleteWay(runtime: DownloadableRuntime) { runtimePublishers[runtime.identifier] = Task { do { let downloadedURL = try await downloadRunTimeFull(runtime: runtime) diff --git a/Xcodes/Backend/Environment.swift b/Xcodes/Backend/Environment.swift index e75a8750..61574ce5 100644 --- a/Xcodes/Backend/Environment.swift +++ b/Xcodes/Backend/Environment.swift @@ -196,6 +196,77 @@ public struct Shell { return Process.run(unxipPath.url, workingDirectory: url.deletingLastPathComponent(), ["\(url.path)"]) } + public var 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. + nonisolated(unsafe) var 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) + + // TODO: fix warning. ObservingProgressView is currently tied to an updating progress + 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 { + continuation.finish(throwing: ProcessExecutionError(process: process, standardOutput: "", standardError: "")) + return + } + continuation.finish() + } + } + } } public struct Files { diff --git a/Xcodes/Backend/Progress+.swift b/Xcodes/Backend/Progress+.swift index 6e7688c9..ce2ff3be 100644 --- a/Xcodes/Backend/Progress+.swift +++ b/Xcodes/Backend/Progress+.swift @@ -70,5 +70,38 @@ 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 { + Logger.appState.error("Invalid regular expression") + } + + } } diff --git a/Xcodes/Frontend/Common/ObservingProgressIndicator.swift b/Xcodes/Frontend/Common/ObservingProgressIndicator.swift index a6774866..3302f0a3 100644 --- a/Xcodes/Frontend/Common/ObservingProgressIndicator.swift +++ b/Xcodes/Frontend/Common/ObservingProgressIndicator.swift @@ -31,6 +31,7 @@ public struct ObservingProgressIndicator: View { self.progress = progress cancellable = progress.publisher(for: \.fractionCompleted) .combineLatest(progress.publisher(for: \.localizedAdditionalDescription)) + .combineLatest(progress.publisher(for: \.isIndeterminate)) .throttle(for: 1.0, scheduler: DispatchQueue.main, latest: true) .sink { [weak self] _ in self?.objectWillChange.send() } } @@ -82,6 +83,18 @@ struct ObservingProgressBar_Previews: PreviewProvider { style: .bar, showsAdditionalDescription: true ) + + ObservingProgressIndicator( + configure(Progress()) { + $0.kind = .file + $0.fileOperationKind = .downloading + $0.totalUnitCount = 0 + $0.completedUnitCount = 0 + }, + controlSize: .regular, + style: .bar, + showsAdditionalDescription: true + ) } .previewLayout(.sizeThatFits) } diff --git a/Xcodes/Frontend/Common/ProgressIndicator.swift b/Xcodes/Frontend/Common/ProgressIndicator.swift index 4c11f0a6..bb801869 100644 --- a/Xcodes/Frontend/Common/ProgressIndicator.swift +++ b/Xcodes/Frontend/Common/ProgressIndicator.swift @@ -22,7 +22,10 @@ struct ProgressIndicator: NSViewRepresentable { nsView.doubleValue = doubleValue nsView.controlSize = controlSize nsView.isIndeterminate = isIndeterminate + nsView.usesThreadedAnimation = true + nsView.style = style + nsView.startAnimation(nil) } } diff --git a/Xcodes/Frontend/InfoPane/RuntimeInstallationStepDetailView.swift b/Xcodes/Frontend/InfoPane/RuntimeInstallationStepDetailView.swift index f59f0417..251c6bdb 100644 --- a/Xcodes/Frontend/InfoPane/RuntimeInstallationStepDetailView.swift +++ b/Xcodes/Frontend/InfoPane/RuntimeInstallationStepDetailView.swift @@ -26,8 +26,12 @@ struct RuntimeInstallationStepDetailView: View { ) case .installing, .trashingArchive: - ProgressView() - .scaleEffect(0.5) + ObservingProgressIndicator( + Progress(), + controlSize: .regular, + style: .bar, + showsAdditionalDescription: false + ) } } }