Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions Xcodes/Backend/AppState+Runtimes.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import OSLog
import Combine
import Path
import AppleAPI
import Version

extension AppState {
func updateDownloadableRuntimes() {
Expand Down Expand Up @@ -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)
Expand Down
71 changes: 71 additions & 0 deletions Xcodes/Backend/Environment.swift
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,77 @@ public struct Shell {
return Process.run(unxipPath.url, workingDirectory: url.deletingLastPathComponent(), ["\(url.path)"])
}

public var downloadRuntime: (String, String) -> AsyncThrowingStream<Progress, Error> = { platform, version in
return AsyncThrowingStream<Progress, Error> { 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 {
Expand Down
33 changes: 33 additions & 0 deletions Xcodes/Backend/Progress+.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}

}
}

13 changes: 13 additions & 0 deletions Xcodes/Frontend/Common/ObservingProgressIndicator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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() }
}
Expand Down Expand Up @@ -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)
}
Expand Down
3 changes: 3 additions & 0 deletions Xcodes/Frontend/Common/ProgressIndicator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,12 @@ struct RuntimeInstallationStepDetailView: View {
)

case .installing, .trashingArchive:
ProgressView()
.scaleEffect(0.5)
ObservingProgressIndicator(
Progress(),
controlSize: .regular,
style: .bar,
showsAdditionalDescription: false
)
}
}
}
Expand Down