Skip to content

Commit 8e78c1c

Browse files
authored
Merge pull request #622 from XcodesOrg/matt/cryptexRuntimeDownloads
feat: support downloading of cryptex (ex iOS 18+) runtimes
2 parents c245a1e + c31a1ef commit 8e78c1c

6 files changed

Lines changed: 190 additions & 2 deletions

File tree

Xcodes/Backend/AppState+Runtimes.swift

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import OSLog
44
import Combine
55
import Path
66
import AppleAPI
7+
import Version
78

89
extension AppState {
910
func updateDownloadableRuntimes() {
@@ -48,6 +49,69 @@ extension AppState {
4849
}
4950

5051
func downloadRuntime(runtime: DownloadableRuntime) {
52+
guard let selectedXcode = self.allXcodes.first(where: { $0.selected }) else {
53+
Logger.appState.error("No selected Xcode")
54+
DispatchQueue.main.async {
55+
self.presentedAlert = .generic(title: localizeString("Alert.Install.Error.Title"), message: "No selected Xcode. Please make an Xcode active")
56+
}
57+
return
58+
}
59+
// new runtimes
60+
if runtime.contentType == .cryptexDiskImage {
61+
// only selected xcodes > 16.1 beta 3 can download runtimes via a xcodebuild -downloadPlatform version
62+
// only Runtimes coming from cryptexDiskImage can be downloaded via xcodebuild
63+
if selectedXcode.version > Version(major: 16, minor: 0, patch: 0) {
64+
downloadRuntimeViaXcodeBuild(runtime: runtime)
65+
} else {
66+
// not supported
67+
Logger.appState.error("Trying to download a runtime we can't download")
68+
DispatchQueue.main.async {
69+
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.")
70+
}
71+
return
72+
}
73+
} else {
74+
downloadRuntimeObseleteWay(runtime: runtime)
75+
}
76+
}
77+
78+
func downloadRuntimeViaXcodeBuild(runtime: DownloadableRuntime) {
79+
runtimePublishers[runtime.identifier] = Task {
80+
do {
81+
for try await progress in Current.shell.downloadRuntime(runtime.platform.shortName, runtime.simulatorVersion.buildUpdate) {
82+
if progress.isIndeterminate {
83+
DispatchQueue.main.async {
84+
self.setInstallationStep(of: runtime, to: .installing, postNotification: false)
85+
}
86+
} else {
87+
DispatchQueue.main.async {
88+
self.setInstallationStep(of: runtime, to: .downloading(progress: progress), postNotification: false)
89+
}
90+
}
91+
92+
}
93+
Logger.appState.debug("Done downloading runtime - \(runtime.name)")
94+
DispatchQueue.main.async {
95+
guard let index = self.downloadableRuntimes.firstIndex(where: { $0.identifier == runtime.identifier }) else { return }
96+
self.downloadableRuntimes[index].installState = .installed
97+
self.update()
98+
}
99+
100+
} catch {
101+
Logger.appState.error("Error downloading runtime: \(error.localizedDescription)")
102+
DispatchQueue.main.async {
103+
self.error = error
104+
if let error = error as? String {
105+
self.presentedAlert = .generic(title: localizeString("Alert.Install.Error.Title"), message: error)
106+
} else {
107+
self.presentedAlert = .generic(title: localizeString("Alert.Install.Error.Title"), message: error.legibleLocalizedDescription)
108+
}
109+
}
110+
}
111+
}
112+
}
113+
114+
func downloadRuntimeObseleteWay(runtime: DownloadableRuntime) {
51115
runtimePublishers[runtime.identifier] = Task {
52116
do {
53117
let downloadedURL = try await downloadRunTimeFull(runtime: runtime)

Xcodes/Backend/Environment.swift

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,77 @@ public struct Shell {
196196
return Process.run(unxipPath.url, workingDirectory: url.deletingLastPathComponent(), ["\(url.path)"])
197197
}
198198

199+
public var downloadRuntime: (String, String) -> AsyncThrowingStream<Progress, Error> = { platform, version in
200+
return AsyncThrowingStream<Progress, Error> { continuation in
201+
Task {
202+
// Assume progress will not have data races, so we manually opt-out isolation checks.
203+
nonisolated(unsafe) var progress = Progress()
204+
progress.kind = .file
205+
progress.fileOperationKind = .downloading
206+
207+
let process = Process()
208+
let xcodeBuildPath = Path.root.usr.bin.join("xcodebuild").url
209+
210+
process.executableURL = xcodeBuildPath
211+
process.arguments = [
212+
"-downloadPlatform",
213+
"\(platform)",
214+
"-buildVersion",
215+
"\(version)"
216+
]
217+
218+
let stdOutPipe = Pipe()
219+
process.standardOutput = stdOutPipe
220+
let stdErrPipe = Pipe()
221+
process.standardError = stdErrPipe
222+
223+
let observer = NotificationCenter.default.addObserver(
224+
forName: .NSFileHandleDataAvailable,
225+
object: nil,
226+
queue: OperationQueue.main
227+
) { note in
228+
guard
229+
// This should always be the case for Notification.Name.NSFileHandleDataAvailable
230+
let handle = note.object as? FileHandle,
231+
handle === stdOutPipe.fileHandleForReading || handle === stdErrPipe.fileHandleForReading
232+
else { return }
233+
234+
defer { handle.waitForDataInBackgroundAndNotify() }
235+
236+
let string = String(decoding: handle.availableData, as: UTF8.self)
237+
238+
// TODO: fix warning. ObservingProgressView is currently tied to an updating progress
239+
progress.updateFromXcodebuild(text: string)
240+
241+
continuation.yield(progress)
242+
}
243+
244+
stdOutPipe.fileHandleForReading.waitForDataInBackgroundAndNotify()
245+
stdErrPipe.fileHandleForReading.waitForDataInBackgroundAndNotify()
246+
247+
continuation.onTermination = { @Sendable _ in
248+
process.terminate()
249+
NotificationCenter.default.removeObserver(observer, name: .NSFileHandleDataAvailable, object: nil)
250+
}
251+
252+
do {
253+
try process.run()
254+
} catch {
255+
continuation.finish(throwing: error)
256+
}
257+
258+
process.waitUntilExit()
259+
260+
NotificationCenter.default.removeObserver(observer, name: .NSFileHandleDataAvailable, object: nil)
261+
262+
guard process.terminationReason == .exit, process.terminationStatus == 0 else {
263+
continuation.finish(throwing: ProcessExecutionError(process: process, standardOutput: "", standardError: ""))
264+
return
265+
}
266+
continuation.finish()
267+
}
268+
}
269+
}
199270
}
200271

201272
public struct Files {

Xcodes/Backend/Progress+.swift

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,5 +70,38 @@ extension Progress {
7070
}
7171

7272
}
73+
74+
func updateFromXcodebuild(text: String) {
75+
self.totalUnitCount = 100
76+
self.completedUnitCount = 0
77+
self.localizedAdditionalDescription = "" // to not show the addtional
78+
79+
do {
80+
81+
let downloadPattern = #"(\d+\.\d+)% \(([\d.]+ (?:MB|GB)) of ([\d.]+ GB)\)"#
82+
let downloadRegex = try NSRegularExpression(pattern: downloadPattern)
83+
84+
// Search for matches in the text
85+
if let match = downloadRegex.firstMatch(in: text, range: NSRange(text.startIndex..., in: text)) {
86+
// Extract the percentage - simpler then trying to extract size MB/GB and convert to bytes.
87+
if let percentRange = Range(match.range(at: 1), in: text), let percentDouble = Double(text[percentRange]) {
88+
let percent = Int64(percentDouble.rounded())
89+
self.completedUnitCount = percent
90+
}
91+
}
92+
93+
// "Downloading tvOS 18.1 Simulator (22J5567a): Installing..." or
94+
// "Downloading tvOS 18.1 Simulator (22J5567a): Installing (registering download)..."
95+
if text.range(of: "Installing") != nil {
96+
// sets the progress to indeterminite to show animating progress
97+
self.totalUnitCount = 0
98+
self.completedUnitCount = 0
99+
}
100+
101+
} catch {
102+
Logger.appState.error("Invalid regular expression")
103+
}
104+
105+
}
73106
}
74107

Xcodes/Frontend/Common/ObservingProgressIndicator.swift

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ public struct ObservingProgressIndicator: View {
3131
self.progress = progress
3232
cancellable = progress.publisher(for: \.fractionCompleted)
3333
.combineLatest(progress.publisher(for: \.localizedAdditionalDescription))
34+
.combineLatest(progress.publisher(for: \.isIndeterminate))
3435
.throttle(for: 1.0, scheduler: DispatchQueue.main, latest: true)
3536
.sink { [weak self] _ in self?.objectWillChange.send() }
3637
}
@@ -82,6 +83,18 @@ struct ObservingProgressBar_Previews: PreviewProvider {
8283
style: .bar,
8384
showsAdditionalDescription: true
8485
)
86+
87+
ObservingProgressIndicator(
88+
configure(Progress()) {
89+
$0.kind = .file
90+
$0.fileOperationKind = .downloading
91+
$0.totalUnitCount = 0
92+
$0.completedUnitCount = 0
93+
},
94+
controlSize: .regular,
95+
style: .bar,
96+
showsAdditionalDescription: true
97+
)
8598
}
8699
.previewLayout(.sizeThatFits)
87100
}

Xcodes/Frontend/Common/ProgressIndicator.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,10 @@ struct ProgressIndicator: NSViewRepresentable {
2222
nsView.doubleValue = doubleValue
2323
nsView.controlSize = controlSize
2424
nsView.isIndeterminate = isIndeterminate
25+
nsView.usesThreadedAnimation = true
26+
2527
nsView.style = style
28+
nsView.startAnimation(nil)
2629
}
2730
}
2831

Xcodes/Frontend/InfoPane/RuntimeInstallationStepDetailView.swift

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,12 @@ struct RuntimeInstallationStepDetailView: View {
2626
)
2727

2828
case .installing, .trashingArchive:
29-
ProgressView()
30-
.scaleEffect(0.5)
29+
ObservingProgressIndicator(
30+
Progress(),
31+
controlSize: .regular,
32+
style: .bar,
33+
showsAdditionalDescription: false
34+
)
3135
}
3236
}
3337
}

0 commit comments

Comments
 (0)