Skip to content

Commit c5ada02

Browse files
authored
Merge pull request #448 from XcodesOrg/matt/runtimeDownload
Support Runtime/Platforms Downloading and Install 🚀
2 parents b650261 + c1836a7 commit c5ada02

40 files changed

Lines changed: 1187 additions & 85 deletions

Xcodes.xcodeproj/project.pbxproj

Lines changed: 65 additions & 31 deletions
Large diffs are not rendered by default.

Xcodes.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved

Lines changed: 12 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Xcodes/Backend/AppState+Install.swift

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import Version
66
import LegibleError
77
import os.log
88
import DockProgress
9+
import XcodesKit
910

1011
/// Downloads and installs Xcodes
1112
extension AppState {
@@ -489,7 +490,7 @@ extension AppState {
489490

490491
// MARK: -
491492

492-
func setInstallationStep(of version: Version, to step: InstallationStep) {
493+
func setInstallationStep(of version: Version, to step: XcodeInstallationStep) {
493494
DispatchQueue.main.async {
494495
guard let index = self.allXcodes.firstIndex(where: { $0.version.isEquivalent(to: version) }) else { return }
495496
self.allXcodes[index].installState = .installing(step)
@@ -498,6 +499,15 @@ extension AppState {
498499
Current.notificationManager.scheduleNotification(title: xcode.id.appleDescription, body: step.description, category: .normal)
499500
}
500501
}
502+
503+
func setInstallationStep(of runtime: DownloadableRuntime, to step: RuntimeInstallationStep) {
504+
DispatchQueue.main.async {
505+
guard let index = self.downloadableRuntimes.firstIndex(where: { $0.identifier == runtime.identifier }) else { return }
506+
self.downloadableRuntimes[index].installState = .installing(step)
507+
508+
Current.notificationManager.scheduleNotification(title: runtime.name, body: step.description, category: .normal)
509+
}
510+
}
501511
}
502512

503513
extension AppState {
Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
import Foundation
2+
import XcodesKit
3+
import OSLog
4+
import Combine
5+
import Path
6+
import AppleAPI
7+
8+
extension AppState {
9+
func updateDownloadableRuntimes() {
10+
Task {
11+
do {
12+
13+
let downloadableRuntimes = try await self.runtimeService.downloadableRuntimes()
14+
let runtimes = downloadableRuntimes.downloadables.map { runtime in
15+
var updatedRuntime = runtime
16+
17+
// This loops through and matches up the simulatorVersion to the mappings
18+
let simulatorBuildUpdate = downloadableRuntimes.sdkToSimulatorMappings.first { SDKToSimulatorMapping in
19+
SDKToSimulatorMapping.simulatorBuildUpdate == runtime.simulatorVersion.buildUpdate
20+
}
21+
updatedRuntime.sdkBuildUpdate = simulatorBuildUpdate?.sdkBuildUpdate
22+
return updatedRuntime
23+
}
24+
25+
DispatchQueue.main.async {
26+
self.downloadableRuntimes = runtimes
27+
}
28+
try? cacheDownloadableRuntimes(runtimes)
29+
} catch {
30+
Logger.appState.error("Error downloading runtimes: \(error.localizedDescription)")
31+
}
32+
}
33+
}
34+
35+
func updateInstalledRuntimes() {
36+
Task {
37+
do {
38+
let runtimes = try await self.runtimeService.localInstalledRuntimes()
39+
DispatchQueue.main.async {
40+
self.installedRuntimes = runtimes
41+
}
42+
} catch {
43+
Logger.appState.error("Error loading installed runtimes: \(error.localizedDescription)")
44+
}
45+
}
46+
}
47+
48+
func downloadRuntime(runtime: DownloadableRuntime) {
49+
Task {
50+
do {
51+
try await downloadRunTimeFull(runtime: runtime)
52+
53+
DispatchQueue.main.async {
54+
guard let index = self.downloadableRuntimes.firstIndex(where: { $0.identifier == runtime.identifier }) else { return }
55+
self.downloadableRuntimes[index].installState = .installed
56+
}
57+
58+
updateInstalledRuntimes()
59+
}
60+
catch {
61+
Logger.appState.error("Error downloading runtime: \(error.localizedDescription)")
62+
DispatchQueue.main.async {
63+
self.error = error
64+
self.presentedAlert = .generic(title: localizeString("Alert.Install.Error.Title"), message: error.legibleLocalizedDescription)
65+
}
66+
}
67+
}
68+
}
69+
70+
func downloadRunTimeFull(runtime: DownloadableRuntime) async throws {
71+
// sets a proper cookie for runtimes
72+
try await validateADCSession(path: runtime.downloadPath)
73+
74+
let downloader = Downloader(rawValue: UserDefaults.standard.string(forKey: "downloader") ?? "aria2") ?? .aria2
75+
Logger.appState.info("Downloading \(runtime.visibleIdentifier) with \(downloader)")
76+
77+
78+
let url = try await self.downloadRuntime(for: runtime, downloader: downloader, progressChanged: { [unowned self] progress in
79+
DispatchQueue.main.async {
80+
self.setInstallationStep(of: runtime, to: .downloading(progress: progress))
81+
}
82+
}).async()
83+
84+
Logger.appState.debug("Done downloading: \(url)")
85+
DispatchQueue.main.async {
86+
self.setInstallationStep(of: runtime, to: .installing)
87+
}
88+
switch runtime.contentType {
89+
case .package:
90+
// not supported yet (do we need to for old packages?)
91+
throw "Installing via package not support - please install manually from \(url.description)"
92+
case .diskImage:
93+
try await self.installFromImage(dmgURL: url)
94+
DispatchQueue.main.async {
95+
self.setInstallationStep(of: runtime, to: .trashingArchive)
96+
}
97+
try Current.files.removeItem(at: url)
98+
}
99+
}
100+
101+
@MainActor
102+
func downloadRuntime(for runtime: DownloadableRuntime, downloader: Downloader, progressChanged: @escaping (Progress) -> Void) -> AnyPublisher<URL, Error> {
103+
// Check to see if the dmg is in the expected path in case it was downloaded but failed to install
104+
105+
// call https://developerservices2.apple.com/services/download?path=/Developer_Tools/watchOS_10_beta/watchOS_10_beta_Simulator_Runtime.dmg 1st to get cookie
106+
// use runtime.url for final with cookies
107+
108+
// Check to see if the archive is in the expected path in case it was downloaded but failed to install
109+
let url = URL(string: runtime.source)!
110+
let expectedRuntimePath = Path.xcodesApplicationSupport/"\(url.lastPathComponent)"
111+
// aria2 downloads directly to the destination (instead of into /tmp first) so we need to make sure that the download isn't incomplete
112+
let aria2DownloadMetadataPath = expectedRuntimePath.parent/(expectedRuntimePath.basename() + ".aria2")
113+
var aria2DownloadIsIncomplete = false
114+
if case .aria2 = downloader, aria2DownloadMetadataPath.exists {
115+
aria2DownloadIsIncomplete = true
116+
}
117+
if Current.files.fileExistsAtPath(expectedRuntimePath.string), aria2DownloadIsIncomplete == false {
118+
Logger.appState.info("Found existing runtime that will be used for installation at \(expectedRuntimePath).")
119+
return Just(expectedRuntimePath.url)
120+
.setFailureType(to: Error.self)
121+
.eraseToAnyPublisher()
122+
}
123+
else {
124+
125+
Logger.appState.info("Downloading runtime: \(url.lastPathComponent)")
126+
switch downloader {
127+
case .aria2:
128+
let aria2Path = Path(url: Bundle.main.url(forAuxiliaryExecutable: "aria2c")!)!
129+
return downloadRuntimeWithAria2(
130+
runtime,
131+
to: expectedRuntimePath,
132+
aria2Path: aria2Path,
133+
progressChanged: progressChanged)
134+
135+
case .urlSession:
136+
// TODO: Support runtime download via URL Session
137+
return Just(runtime.url)
138+
.setFailureType(to: Error.self)
139+
.eraseToAnyPublisher()
140+
}
141+
}
142+
}
143+
144+
public func downloadRuntimeWithAria2(_ runtime: DownloadableRuntime, to destination: Path, aria2Path: Path, progressChanged: @escaping (Progress) -> Void) -> AnyPublisher<URL, Error> {
145+
let cookies = AppleAPI.Current.network.session.configuration.httpCookieStorage?.cookies(for: runtime.url) ?? []
146+
147+
let (progress, publisher) = Current.shell.downloadWithAria2(
148+
aria2Path,
149+
runtime.url,
150+
destination,
151+
cookies
152+
)
153+
progressChanged(progress)
154+
return publisher
155+
.map { _ in destination.url }
156+
.eraseToAnyPublisher()
157+
}
158+
159+
public func installFromImage(dmgURL: URL) async throws {
160+
try await self.runtimeService.installRuntimeImage(dmgURL: dmgURL)
161+
}
162+
}
163+
164+
extension AnyPublisher {
165+
func async() async throws -> Output {
166+
try await withCheckedThrowingContinuation { continuation in
167+
var cancellable: AnyCancellable?
168+
169+
cancellable = first()
170+
.sink { result in
171+
switch result {
172+
case .finished:
173+
break
174+
case let .failure(error):
175+
continuation.resume(throwing: error)
176+
}
177+
cancellable?.cancel()
178+
} receiveValue: { value in
179+
continuation.resume(with: .success(value))
180+
}
181+
}
182+
}
183+
}

Xcodes/Backend/AppState+Update.swift

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import Version
55
import SwiftSoup
66
import struct XCModel.Xcode
77
import AppleAPI
8+
import XcodesKit
89

910
extension AppState {
1011

@@ -36,6 +37,8 @@ extension AppState {
3637

3738
func update() {
3839
guard !isUpdating else { return }
40+
updateDownloadableRuntimes()
41+
updateInstalledRuntimes()
3942
updatePublisher = updateSelectedXcodePath()
4043
.flatMap { _ in
4144
self.updateAvailableXcodes(from: self.dataSource)
@@ -125,6 +128,21 @@ extension AppState {
125128
withIntermediateDirectories: true)
126129
try data.write(to: Path.cacheFile.url)
127130
}
131+
132+
// MARK: Runtime Cache
133+
134+
func loadCacheDownloadableRuntimes() throws {
135+
guard let data = Current.files.contents(atPath: Path.runtimeCacheFile.string) else { return }
136+
let runtimes = try JSONDecoder().decode([DownloadableRuntime].self, from: data)
137+
self.downloadableRuntimes = runtimes
138+
}
139+
140+
func cacheDownloadableRuntimes(_ runtimes: [DownloadableRuntime]) throws {
141+
let data = try JSONEncoder().encode(runtimes)
142+
try FileManager.default.createDirectory(at: Path.runtimeCacheFile.url.deletingLastPathComponent(),
143+
withIntermediateDirectories: true)
144+
try data.write(to: Path.runtimeCacheFile.url)
145+
}
128146
}
129147

130148
extension AppState {

Xcodes/Backend/AppState.swift

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,11 @@ import Path
88
import Version
99
import os.log
1010
import DockProgress
11+
import XcodesKit
1112

1213
class AppState: ObservableObject {
1314
private let client = AppleAPI.Client()
15+
internal let runtimeService = RuntimeService()
1416

1517
// MARK: - Published Properties
1618

@@ -100,10 +102,17 @@ class AppState: ObservableObject {
100102
Current.defaults.set(showOpenInRosettaOption, forKey: "showOpenInRosettaOption")
101103
}
102104
}
105+
106+
// MARK: - Runtimes
107+
108+
@Published var downloadableRuntimes: [DownloadableRuntime] = []
109+
@Published var installedRuntimes: [CoreSimulatorImage] = []
110+
103111
// MARK: - Publisher Cancellables
104112

105113
var cancellables = Set<AnyCancellable>()
106114
private var installationPublishers: [Version: AnyCancellable] = [:]
115+
internal var runtimePublishers: [String: AnyCancellable] = [:]
107116
private var selectPublisher: AnyCancellable?
108117
private var uninstallPublisher: AnyCancellable?
109118
private var autoInstallTimer: Timer?
@@ -150,9 +159,11 @@ class AppState: ObservableObject {
150159
init() {
151160
guard !isTesting else { return }
152161
try? loadCachedAvailableXcodes()
162+
try? loadCacheDownloadableRuntimes()
153163
checkIfHelperIsInstalled()
154164
setupAutoInstallTimer()
155165
setupDefaults()
166+
updateInstalledRuntimes()
156167
}
157168

158169
func setupDefaults() {
@@ -180,11 +191,23 @@ class AppState: ObservableObject {
180191
func validateADCSession(path: String) -> AnyPublisher<Void, Error> {
181192
return Current.network.dataTask(with: URLRequest.downloadADCAuth(path: path))
182193
.receive(on: DispatchQueue.main)
183-
.tryMap { _ in
194+
.tryMap { result -> Void in
195+
let httpResponse = result.response as! HTTPURLResponse
196+
if httpResponse.statusCode == 401 {
197+
throw AuthenticationError.notAuthorized
198+
}
184199
}
185200
.eraseToAnyPublisher()
186201
}
187202

203+
func validateADCSession(path: String) async throws {
204+
let result = try await Current.network.dataTaskAsync(with: URLRequest.downloadADCAuth(path: path))
205+
let httpResponse = result.1 as! HTTPURLResponse
206+
if httpResponse.statusCode == 401 {
207+
throw AuthenticationError.notAuthorized
208+
}
209+
}
210+
188211
func validateSession() -> AnyPublisher<Void, Error> {
189212

190213
return Current.network.validateSession()
@@ -799,6 +822,19 @@ class AppState: ObservableObject {
799822
self.allXcodes = newAllXcodes.sorted { $0.version > $1.version }
800823
}
801824

825+
// MARK: Runtimes
826+
func runtimeInstallPath(xcode: Xcode, runtime: DownloadableRuntime) -> Path? {
827+
if let coreSimulatorInfo = installedRuntimes.filter({ $0.runtimeInfo.build == runtime.simulatorVersion.buildUpdate }).first {
828+
let urlString = coreSimulatorInfo.path["relative"]!
829+
// app was not allowed to open up file:// url's so remove
830+
let fileRemovedString = urlString.replacingOccurrences(of: "file://", with: "")
831+
let url = URL(fileURLWithPath: fileRemovedString)
832+
833+
return Path(url: url)!
834+
}
835+
return nil
836+
}
837+
802838
// MARK: - Private
803839

804840
private func uninstallXcode(path: Path) -> AnyPublisher<Void, Error> {

0 commit comments

Comments
 (0)