Skip to content

Commit

Permalink
Merge pull request #448 from XcodesOrg/matt/runtimeDownload
Browse files Browse the repository at this point in the history
Support Runtime/Platforms Downloading and Install 🚀
  • Loading branch information
MattKiazyk authored Dec 1, 2023
2 parents b650261 + c1836a7 commit c5ada02
Show file tree
Hide file tree
Showing 40 changed files with 1,187 additions and 85 deletions.
96 changes: 65 additions & 31 deletions Xcodes.xcodeproj/project.pbxproj

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -1,6 +1,15 @@
{
"object": {
"pins": [
{
"package": "AsyncNetworkService",
"repositoryURL": "https://github.com/RobotsAndPencils/AsyncHTTPNetworkService",
"state": {
"branch": "main",
"revision": "97770856c4e429f880d4b4dd68cfaf286dc00c30",
"version": null
}
},
{
"package": "CombineExpectations",
"repositoryURL": "https://github.com/groue/CombineExpectations",
Expand All @@ -14,7 +23,7 @@
"package": "XcodeReleases",
"repositoryURL": "https://github.com/xcodereleases/data",
"state": {
"branch": null,
"branch": "main",
"revision": "a43ad89e536d7a3da525fcc23fb182c37b756ecc",
"version": null
}
Expand Down Expand Up @@ -60,8 +69,8 @@
"repositoryURL": "https://github.com/mxcl/Path.swift",
"state": {
"branch": null,
"revision": "dac007e907a4f4c565cfdc55a9ce148a761a11d5",
"version": "0.16.3"
"revision": "8e355c28e9393c42e58b18c54cace2c42c98a616",
"version": "1.4.1"
}
},
{
Expand Down
12 changes: 11 additions & 1 deletion Xcodes/Backend/AppState+Install.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import Version
import LegibleError
import os.log
import DockProgress
import XcodesKit

/// Downloads and installs Xcodes
extension AppState {
Expand Down Expand Up @@ -489,7 +490,7 @@ extension AppState {

// MARK: -

func setInstallationStep(of version: Version, to step: InstallationStep) {
func setInstallationStep(of version: Version, to step: XcodeInstallationStep) {
DispatchQueue.main.async {
guard let index = self.allXcodes.firstIndex(where: { $0.version.isEquivalent(to: version) }) else { return }
self.allXcodes[index].installState = .installing(step)
Expand All @@ -498,6 +499,15 @@ extension AppState {
Current.notificationManager.scheduleNotification(title: xcode.id.appleDescription, body: step.description, category: .normal)
}
}

func setInstallationStep(of runtime: DownloadableRuntime, to step: RuntimeInstallationStep) {
DispatchQueue.main.async {
guard let index = self.downloadableRuntimes.firstIndex(where: { $0.identifier == runtime.identifier }) else { return }
self.downloadableRuntimes[index].installState = .installing(step)

Current.notificationManager.scheduleNotification(title: runtime.name, body: step.description, category: .normal)
}
}
}

extension AppState {
Expand Down
183 changes: 183 additions & 0 deletions Xcodes/Backend/AppState+Runtimes.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
import Foundation
import XcodesKit
import OSLog
import Combine
import Path
import AppleAPI

extension AppState {
func updateDownloadableRuntimes() {
Task {
do {

let downloadableRuntimes = try await self.runtimeService.downloadableRuntimes()
let runtimes = downloadableRuntimes.downloadables.map { runtime in
var updatedRuntime = runtime

// This loops through and matches up the simulatorVersion to the mappings
let simulatorBuildUpdate = downloadableRuntimes.sdkToSimulatorMappings.first { SDKToSimulatorMapping in
SDKToSimulatorMapping.simulatorBuildUpdate == runtime.simulatorVersion.buildUpdate
}
updatedRuntime.sdkBuildUpdate = simulatorBuildUpdate?.sdkBuildUpdate
return updatedRuntime
}

DispatchQueue.main.async {
self.downloadableRuntimes = runtimes
}
try? cacheDownloadableRuntimes(runtimes)
} catch {
Logger.appState.error("Error downloading runtimes: \(error.localizedDescription)")
}
}
}

func updateInstalledRuntimes() {
Task {
do {
let runtimes = try await self.runtimeService.localInstalledRuntimes()
DispatchQueue.main.async {
self.installedRuntimes = runtimes
}
} catch {
Logger.appState.error("Error loading installed runtimes: \(error.localizedDescription)")
}
}
}

func downloadRuntime(runtime: DownloadableRuntime) {
Task {
do {
try await downloadRunTimeFull(runtime: runtime)

DispatchQueue.main.async {
guard let index = self.downloadableRuntimes.firstIndex(where: { $0.identifier == runtime.identifier }) else { return }
self.downloadableRuntimes[index].installState = .installed
}

updateInstalledRuntimes()
}
catch {
Logger.appState.error("Error downloading runtime: \(error.localizedDescription)")
DispatchQueue.main.async {
self.error = error
self.presentedAlert = .generic(title: localizeString("Alert.Install.Error.Title"), message: error.legibleLocalizedDescription)
}
}
}
}

func downloadRunTimeFull(runtime: DownloadableRuntime) async throws {
// sets a proper cookie for runtimes
try await validateADCSession(path: runtime.downloadPath)

let downloader = Downloader(rawValue: UserDefaults.standard.string(forKey: "downloader") ?? "aria2") ?? .aria2
Logger.appState.info("Downloading \(runtime.visibleIdentifier) with \(downloader)")


let url = try await self.downloadRuntime(for: runtime, downloader: downloader, progressChanged: { [unowned self] progress in
DispatchQueue.main.async {
self.setInstallationStep(of: runtime, to: .downloading(progress: progress))
}
}).async()

Logger.appState.debug("Done downloading: \(url)")
DispatchQueue.main.async {
self.setInstallationStep(of: runtime, to: .installing)
}
switch runtime.contentType {
case .package:
// not supported yet (do we need to for old packages?)
throw "Installing via package not support - please install manually from \(url.description)"
case .diskImage:
try await self.installFromImage(dmgURL: url)
DispatchQueue.main.async {
self.setInstallationStep(of: runtime, to: .trashingArchive)
}
try Current.files.removeItem(at: url)
}
}

@MainActor
func downloadRuntime(for runtime: DownloadableRuntime, downloader: Downloader, progressChanged: @escaping (Progress) -> Void) -> AnyPublisher<URL, Error> {
// Check to see if the dmg is in the expected path in case it was downloaded but failed to install

// call https://developerservices2.apple.com/services/download?path=/Developer_Tools/watchOS_10_beta/watchOS_10_beta_Simulator_Runtime.dmg 1st to get cookie
// use runtime.url for final with cookies

// Check to see if the archive is in the expected path in case it was downloaded but failed to install
let url = URL(string: runtime.source)!
let expectedRuntimePath = Path.xcodesApplicationSupport/"\(url.lastPathComponent)"
// aria2 downloads directly to the destination (instead of into /tmp first) so we need to make sure that the download isn't incomplete
let aria2DownloadMetadataPath = expectedRuntimePath.parent/(expectedRuntimePath.basename() + ".aria2")
var aria2DownloadIsIncomplete = false
if case .aria2 = downloader, aria2DownloadMetadataPath.exists {
aria2DownloadIsIncomplete = true
}
if Current.files.fileExistsAtPath(expectedRuntimePath.string), aria2DownloadIsIncomplete == false {
Logger.appState.info("Found existing runtime that will be used for installation at \(expectedRuntimePath).")
return Just(expectedRuntimePath.url)
.setFailureType(to: Error.self)
.eraseToAnyPublisher()
}
else {

Logger.appState.info("Downloading runtime: \(url.lastPathComponent)")
switch downloader {
case .aria2:
let aria2Path = Path(url: Bundle.main.url(forAuxiliaryExecutable: "aria2c")!)!
return downloadRuntimeWithAria2(
runtime,
to: expectedRuntimePath,
aria2Path: aria2Path,
progressChanged: progressChanged)

case .urlSession:
// TODO: Support runtime download via URL Session
return Just(runtime.url)
.setFailureType(to: Error.self)
.eraseToAnyPublisher()
}
}
}

public func downloadRuntimeWithAria2(_ runtime: DownloadableRuntime, to destination: Path, aria2Path: Path, progressChanged: @escaping (Progress) -> Void) -> AnyPublisher<URL, Error> {
let cookies = AppleAPI.Current.network.session.configuration.httpCookieStorage?.cookies(for: runtime.url) ?? []

let (progress, publisher) = Current.shell.downloadWithAria2(
aria2Path,
runtime.url,
destination,
cookies
)
progressChanged(progress)
return publisher
.map { _ in destination.url }
.eraseToAnyPublisher()
}

public func installFromImage(dmgURL: URL) async throws {
try await self.runtimeService.installRuntimeImage(dmgURL: dmgURL)
}
}

extension AnyPublisher {
func async() async throws -> Output {
try await withCheckedThrowingContinuation { continuation in
var cancellable: AnyCancellable?

cancellable = first()
.sink { result in
switch result {
case .finished:
break
case let .failure(error):
continuation.resume(throwing: error)
}
cancellable?.cancel()
} receiveValue: { value in
continuation.resume(with: .success(value))
}
}
}
}
18 changes: 18 additions & 0 deletions Xcodes/Backend/AppState+Update.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import Version
import SwiftSoup
import struct XCModel.Xcode
import AppleAPI
import XcodesKit

extension AppState {

Expand Down Expand Up @@ -36,6 +37,8 @@ extension AppState {

func update() {
guard !isUpdating else { return }
updateDownloadableRuntimes()
updateInstalledRuntimes()
updatePublisher = updateSelectedXcodePath()
.flatMap { _ in
self.updateAvailableXcodes(from: self.dataSource)
Expand Down Expand Up @@ -125,6 +128,21 @@ extension AppState {
withIntermediateDirectories: true)
try data.write(to: Path.cacheFile.url)
}

// MARK: Runtime Cache

func loadCacheDownloadableRuntimes() throws {
guard let data = Current.files.contents(atPath: Path.runtimeCacheFile.string) else { return }
let runtimes = try JSONDecoder().decode([DownloadableRuntime].self, from: data)
self.downloadableRuntimes = runtimes
}

func cacheDownloadableRuntimes(_ runtimes: [DownloadableRuntime]) throws {
let data = try JSONEncoder().encode(runtimes)
try FileManager.default.createDirectory(at: Path.runtimeCacheFile.url.deletingLastPathComponent(),
withIntermediateDirectories: true)
try data.write(to: Path.runtimeCacheFile.url)
}
}

extension AppState {
Expand Down
38 changes: 37 additions & 1 deletion Xcodes/Backend/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,11 @@ import Path
import Version
import os.log
import DockProgress
import XcodesKit

class AppState: ObservableObject {
private let client = AppleAPI.Client()
internal let runtimeService = RuntimeService()

// MARK: - Published Properties

Expand Down Expand Up @@ -100,10 +102,17 @@ class AppState: ObservableObject {
Current.defaults.set(showOpenInRosettaOption, forKey: "showOpenInRosettaOption")
}
}

// MARK: - Runtimes

@Published var downloadableRuntimes: [DownloadableRuntime] = []
@Published var installedRuntimes: [CoreSimulatorImage] = []

// MARK: - Publisher Cancellables

var cancellables = Set<AnyCancellable>()
private var installationPublishers: [Version: AnyCancellable] = [:]
internal var runtimePublishers: [String: AnyCancellable] = [:]
private var selectPublisher: AnyCancellable?
private var uninstallPublisher: AnyCancellable?
private var autoInstallTimer: Timer?
Expand Down Expand Up @@ -150,9 +159,11 @@ class AppState: ObservableObject {
init() {
guard !isTesting else { return }
try? loadCachedAvailableXcodes()
try? loadCacheDownloadableRuntimes()
checkIfHelperIsInstalled()
setupAutoInstallTimer()
setupDefaults()
updateInstalledRuntimes()
}

func setupDefaults() {
Expand Down Expand Up @@ -180,11 +191,23 @@ class AppState: ObservableObject {
func validateADCSession(path: String) -> AnyPublisher<Void, Error> {
return Current.network.dataTask(with: URLRequest.downloadADCAuth(path: path))
.receive(on: DispatchQueue.main)
.tryMap { _ in
.tryMap { result -> Void in
let httpResponse = result.response as! HTTPURLResponse
if httpResponse.statusCode == 401 {
throw AuthenticationError.notAuthorized
}
}
.eraseToAnyPublisher()
}

func validateADCSession(path: String) async throws {
let result = try await Current.network.dataTaskAsync(with: URLRequest.downloadADCAuth(path: path))
let httpResponse = result.1 as! HTTPURLResponse
if httpResponse.statusCode == 401 {
throw AuthenticationError.notAuthorized
}
}

func validateSession() -> AnyPublisher<Void, Error> {

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

// MARK: Runtimes
func runtimeInstallPath(xcode: Xcode, runtime: DownloadableRuntime) -> Path? {
if let coreSimulatorInfo = installedRuntimes.filter({ $0.runtimeInfo.build == runtime.simulatorVersion.buildUpdate }).first {
let urlString = coreSimulatorInfo.path["relative"]!
// app was not allowed to open up file:// url's so remove
let fileRemovedString = urlString.replacingOccurrences(of: "file://", with: "")
let url = URL(fileURLWithPath: fileRemovedString)

return Path(url: url)!
}
return nil
}

// MARK: - Private

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

0 comments on commit c5ada02

Please sign in to comment.