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
96 changes: 65 additions & 31 deletions Xcodes.xcodeproj/project.pbxproj

Large diffs are not rendered by default.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

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