diff --git a/Xcodes/Backend/AppState+Install.swift b/Xcodes/Backend/AppState+Install.swift index 70f82d0..41d71cc 100644 --- a/Xcodes/Backend/AppState+Install.swift +++ b/Xcodes/Backend/AppState+Install.swift @@ -466,7 +466,8 @@ extension AppState { let xcode = self.allXcodes[index] Current.notificationManager.scheduleNotification(title: xcode.id.appleDescription, body: step.description, category: .normal) } - }w func setInstallationStep(of runtime: DownloadableRuntime, to step: InstallationStep) { + } + func setInstallationStep(of runtime: DownloadableRuntime, to step: InstallationStep) { DispatchQueue.main.async { guard let index = self.downloadableRuntimes.firstIndex(where: { $0.identifier == runtime.identifier }) else { return } diff --git a/Xcodes/Backend/AppState+Runtimes.swift b/Xcodes/Backend/AppState+Runtimes.swift index 2f1c568..092accc 100644 --- a/Xcodes/Backend/AppState+Runtimes.swift +++ b/Xcodes/Backend/AppState+Runtimes.swift @@ -46,28 +46,38 @@ extension AppState { } func downloadRuntime(runtime: DownloadableRuntime) { - self.runtimePublishers[runtime.identifier] = downloadRunTimeFull(runtime: runtime) - .receive(on: DispatchQueue.main) - .sink( - receiveCompletion: { [unowned self] completion in - self.runtimePublishers[runtime.identifier] = nil - if case let .failure(error) = completion { -// // Prevent setting the app state error if it is an invalid session, we will present the sign in view instead -// if error as? AuthenticationError != .invalidSession { -// self.error = error -// self.presentedAlert = .generic(title: localizeString("Alert.Install.Error.Title"), message: error.legibleLocalizedDescription) -// } -// if let index = self.allXcodes.firstIndex(where: { $0.id == id }) { -// self.allXcodes[index].installState = .notInstalled -// } - } - }, - receiveValue: { _ in } - ) + Task { + try? await downloadRunTimeFull(runtime: runtime) + } + +// self.runtimePublishers[runtime.identifier] = downloadRunTimeFull(runtime: runtime) +// .receive(on: DispatchQueue.main) +// .sink( +// receiveCompletion: { [unowned self] completion in +// self.runtimePublishers[runtime.identifier] = nil +// if case let .failure(error) = completion { +// Logger.appState.error("Error downloading runtime: \(error.localizedDescription)") +//// // Prevent setting the app state error if it is an invalid session, we will present the sign in view instead +//// if error as? AuthenticationError != .invalidSession { +//// self.error = error +//// self.presentedAlert = .generic(title: localizeString("Alert.Install.Error.Title"), message: error.legibleLocalizedDescription) +//// } +//// if let index = self.allXcodes.firstIndex(where: { $0.id == id }) { +//// self.allXcodes[index].installState = .notInstalled +//// } +// } +// }, +// receiveValue: { _ in } +// ) } - func downloadRunTimeFull(runtime: DownloadableRuntime) -> AnyPublisher<(DownloadableRuntime, URL), Error> { - // gets a proper cookie for runtimes + 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)") + return validateADCSession(path: runtime.downloadPath) .flatMap { _ in @@ -82,6 +92,18 @@ extension AppState { }) .map { return (runtime, $0) } } + .flatMap { runtime, url -> AnyPublisher in + switch runtime.contentType { + case .package: + return self.installFromPackage(dmgURL: url, runtime: runtime) + case .diskImage: + return self.installFromImage(dmgURL: url) + } + } + .map { url in + // Done deleting + Logger.appState.debug("URL: \(url)") + } .eraseToAnyPublisher() } @@ -92,7 +114,9 @@ extension AppState { // 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 expectedRuntimePath = Path.xcodesApplicationSupport/"\(runtime.name).\(runtime.name.suffix(fromLast: "."))" +// let expectedRuntimePath = Path.xcodesApplicationSupport/"\(runtime.name).\(runtime.name.suffix(fromLast: "."))" + 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 @@ -106,7 +130,8 @@ extension AppState { .eraseToAnyPublisher() } else { -// let destination = Path.xcodesApplicationSupport/"Xcode-\(availableXcode.version).\(availableXcode.filename.suffix(fromLast: "."))" + + Logger.appState.info("Downloading runtime: \(url.lastPathComponent)") switch downloader { case .aria2: let aria2Path = Path(url: Bundle.main.url(forAuxiliaryExecutable: "aria2c")!)! @@ -115,12 +140,7 @@ extension AppState { to: expectedRuntimePath, aria2Path: aria2Path, progressChanged: progressChanged) -// return downloadXcodeWithAria2( -// availableXcode, -// to: destination, -// aria2Path: aria2Path, -// progressChanged: progressChanged -// ) + case .urlSession: return Just(runtime.url) @@ -134,7 +154,6 @@ extension AppState { // ) } } - } public func downloadRuntimeWithAria2(_ runtime: DownloadableRuntime, to destination: Path, aria2Path: Path, progressChanged: @escaping (Progress) -> Void) -> AnyPublisher { @@ -151,4 +170,54 @@ extension AppState { .map { _ in destination.url } .eraseToAnyPublisher() } + + public func downloadRuntimeWithAria2(_ runtime: DownloadableRuntime, to destination: Path, aria2Path: Path, progressChanged: @escaping (Progress) -> Void) async -> URL { + + } + + public func installFromImage(dmgURL: URL) -> AnyPublisher { + + + try? self.runtimeService.installRuntimeImage(dmgURL: dmgURL) + + + return Just(dmgURL) + .setFailureType(to: Error.self) + .eraseToAnyPublisher() + + } + + public func installFromPackage(dmgURL: URL, runtime: DownloadableRuntime) -> AnyPublisher { + Logger.appState.info("Mounting DMG") + Task { + do { + let mountedUrl = try await self.runtimeService.mountDMG(dmgUrl: dmgURL) + + // 2-Get the first path under the mounted path, should be a .pkg + let pkgPath = Path(url: mountedUrl)!.ls().first! + try Path.xcodesCaches.mkdir().setCurrentUserAsOwner() + + let expandedPkgPath = Path.xcodesCaches/runtime.identifier + //try expandedPkgPath.mkdir() + Logger.appState.info("PKG Path: \(pkgPath)") + Logger.appState.info("Expanded PKG Path: \(expandedPkgPath)") + //try? Current.files.removeItem(at: expandedPkgPath.url) + + // 5-Expand (not install) the pkg to temporary path + try await self.runtimeService.expand(pkgPath: pkgPath, expandedPkgPath: expandedPkgPath) + //try await self.runtimeService.unmountDMG(mountedURL: mountedUrl) + + } catch { + Logger.appState.error("Error installing runtime: \(error.localizedDescription)") + } + + } + + + + return Just(dmgURL) + .setFailureType(to: Error.self) + .eraseToAnyPublisher() + } + } diff --git a/Xcodes/Backend/AppState.swift b/Xcodes/Backend/AppState.swift index 55a0ce0..2fb00b8 100644 --- a/Xcodes/Backend/AppState.swift +++ b/Xcodes/Backend/AppState.swift @@ -186,6 +186,14 @@ class AppState: ObservableObject { .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 { return Current.network.validateSession() @@ -799,30 +807,17 @@ class AppState: ObservableObject { } // MARK: Runtimes - func getRunTimes(xcode: Xcode) -> [DownloadableRuntime] { - - let builds = xcode.sdks?.allBuilds() - - let runtimes: [DownloadableRuntime]? = builds?.flatMap { sdkBuild in - downloadableRuntimes.filter { - $0.sdkBuildUpdate == sdkBuild - } + func runtimeInstallPath(xcode: Xcode, runtime: DownloadableRuntime) -> Path? { + if let coreSimulatorInfo = installedRuntimes.filter({ $0.runtimeInfo.build == runtime.sdkBuildUpdate }).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)! } - - let updatedRuntimes = runtimes?.map { runtime in - var updatedRuntime = runtime - if let coreSimulatorInfo = installedRuntimes.filter({ $0.runtimeInfo.build == runtime.sdkBuildUpdate }).first { - let url = URL(fileURLWithPath: coreSimulatorInfo.path["relative"]!) - updatedRuntime.installState = .installed(Path(url: url)!) - } else { - updatedRuntime.installState = .notInstalled - } - return updatedRuntime - } - - return updatedRuntimes ?? [] + return nil } - // MARK: - Private diff --git a/Xcodes/Backend/Environment.swift b/Xcodes/Backend/Environment.swift index 2c93381..8b7da37 100644 --- a/Xcodes/Backend/Environment.swift +++ b/Xcodes/Backend/Environment.swift @@ -112,6 +112,101 @@ public struct Shell { return (progress, publisher) } + public var downloadWithAria2Async: (Path, URL, Path, [HTTPCookie]) async throws -> Progress = { aria2Path, url, destination, cookies in + let process = Process() + process.executableURL = aria2Path.url + process.arguments = [ + "--header=Cookie: \(cookies.map { "\($0.name)=\($0.value)" }.joined(separator: "; "))", + "--max-connection-per-server=16", + "--split=16", + "--summary-interval=1", + "--stop-with-process=\(ProcessInfo.processInfo.processIdentifier)", // if xcodes quits, stop aria2 process + "--dir=\(destination.parent.string)", + "--out=\(destination.basename())", + "--human-readable=false", // sets the output to use bytes instead of formatting + url.absoluteString, + ] + let stdOutPipe = Pipe() + process.standardOutput = stdOutPipe + let stdErrPipe = Pipe() + process.standardError = stdErrPipe + + var progress = Progress() + progress.kind = .file + progress.fileOperationKind = .downloading + + 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) + + progress.updateFromAria2(string: string) + } + + stdOutPipe.fileHandleForReading.waitForDataInBackgroundAndNotify() + stdErrPipe.fileHandleForReading.waitForDataInBackgroundAndNotify() + + do { + + defer { + //DispatchQueue.global(qos: .default).async { + process.waitUntilExit() + + NotificationCenter.default.removeObserver(observer, name: .NSFileHandleDataAvailable, object: nil) + + guard process.terminationReason == .exit, process.terminationStatus == 0 else { + if let aria2cError = Aria2CError(exitStatus: process.terminationStatus) { + throw aria2cError + } else { + throw ProcessExecutionError(process: process, standardOutput: "", standardError: "") + } + } + return +// } + } + try process.run() + } catch { + throw error + } + + +// let publisher = Deferred { +// Future { promise in +// DispatchQueue.global(qos: .default).async { +// process.waitUntilExit() +// +// NotificationCenter.default.removeObserver(observer, name: .NSFileHandleDataAvailable, object: nil) +// +// guard process.terminationReason == .exit, process.terminationStatus == 0 else { +// if let aria2cError = Aria2CError(exitStatus: process.terminationStatus) { +// return promise(.failure(aria2cError)) +// } else { +// return promise(.failure(ProcessExecutionError(process: process, standardOutput: "", standardError: ""))) +// } +// } +// promise(.success(())) +// } +// } +// } +// .handleEvents(receiveCancel: { +// process.terminate() +// NotificationCenter.default.removeObserver(observer, name: .NSFileHandleDataAvailable, object: nil) +// }) +// .eraseToAnyPublisher() +// +// return (progress, publisher) + } + public var unxipExperiment: (URL) -> AnyPublisher = { url in let unxipPath = Path(url: Bundle.main.url(forAuxiliaryExecutable: "unxip")!)! return Process.run(unxipPath.url, workingDirectory: url.deletingLastPathComponent(), ["\(url.path)"]) @@ -189,10 +284,15 @@ public struct Network { .mapError { $0 as Error } .eraseToAnyPublisher() } + public func dataTask(with request: URLRequest) -> AnyPublisher { dataTask(request) } - + + public func dataTaskAsync(with request: URLRequest) async throws -> (Data, URLResponse) { + return try await AppleAPI.Current.network.session.data(for: request) + } + public var downloadTask: (URL, URL, Data?) -> (Progress, AnyPublisher<(saveLocation: URL, response: URLResponse), Error>) = { AppleAPI.Current.network.session.downloadTask(with: $0, to: $1, resumingWith: $2) } public func downloadTask(with url: URL, to saveLocation: URL, resumingWith resumeData: Data?) -> (progress: Progress, publisher: AnyPublisher<(saveLocation: URL, response: URLResponse), Error>) { diff --git a/Xcodes/Backend/Path+.swift b/Xcodes/Backend/Path+.swift index 8bcc59c..06bbe63 100644 --- a/Xcodes/Backend/Path+.swift +++ b/Xcodes/Backend/Path+.swift @@ -32,4 +32,15 @@ extension Path { static var runtimeCacheFile: Path { return xcodesApplicationSupport/"downloadable-runtimes.json" } + + static var xcodesCaches: Path { + return caches/"com.xcodesorg.xcodesapp" + } + + @discardableResult + func setCurrentUserAsOwner() -> Path { + let user = ProcessInfo.processInfo.environment["SUDO_USER"] ?? NSUserName() + try? FileManager.default.setAttributes([.ownerAccountName: user], ofItemAtPath: string) + return self + } } diff --git a/Xcodes/Frontend/InfoPane/InfoPane.swift b/Xcodes/Frontend/InfoPane/InfoPane.swift index f18474b..7896426 100644 --- a/Xcodes/Frontend/InfoPane/InfoPane.swift +++ b/Xcodes/Frontend/InfoPane/InfoPane.swift @@ -249,14 +249,22 @@ struct InfoPane: View { @ViewBuilder private func runtimes(for xcode: Xcode) -> some View { - let runtimes = appState.getRunTimes(xcode: xcode) + VStack(alignment: .leading) { Text("Platforms") .font(.headline) .frame(maxWidth: .infinity, alignment: .leading) - ForEach(runtimes, id: \.simulatorVersion.buildUpdate) { runtime in + let builds = xcode.sdks?.allBuilds() + let runtimes = builds?.flatMap { sdkBuild in + appState.downloadableRuntimes.filter { + $0.sdkBuildUpdate == sdkBuild + } + } +// let runtimes = appState.getRunTimes(xcode: xcode) + + ForEach(runtimes ?? [], id: \.simulatorVersion.buildUpdate) { runtime in VStack { HStack { Text("\(runtime.visibleIdentifier)") @@ -264,19 +272,26 @@ struct InfoPane: View { Spacer() Text(runtime.downloadFileSizeString) .font(.subheadline) - DownloadRuntimeButton(runtime: runtime) + + // it's installed if we have a path + if let path = appState.runtimeInstallPath(xcode: xcode, runtime: runtime) { + Button(action: { appState.reveal(path: path.string) }) { + Image(systemName: "arrow.right.circle.fill") + } + .buttonStyle(PlainButtonStyle()) + .help("RevealInFinder") + } else { + DownloadRuntimeButton(runtime: runtime) + } } switch runtime.installState { - - case .notInstalled: - Text("NOT INSTALLED") case .installing(let installationStep): Text("INSTALLING") InstallationStepDetailView(installationStep: installationStep) .fixedSize(horizontal: false, vertical: true) - case .installed(let path): - Text(path.string) + default: + EmptyView() } } diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Services/RuntimeService.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Services/RuntimeService.swift index e269375..c767207 100644 --- a/Xcodes/XcodesKit/Sources/XcodesKit/Services/RuntimeService.swift +++ b/Xcodes/XcodesKit/Sources/XcodesKit/Services/RuntimeService.swift @@ -8,6 +8,10 @@ extension URL { public struct RuntimeService { var networkService: AsyncHTTPNetworkService + public enum Error: LocalizedError, Equatable { + case unavailableRuntime(String) + case failedMountingDMG + } public init() { networkService = AsyncHTTPNetworkService() @@ -50,7 +54,30 @@ public struct RuntimeService { } } + public func installRuntimeImage(dmgURL: URL) throws { + Task { + _ = try await Current.shell.installRuntimeImage(dmgURL) + } + } + public func mountDMG(dmgUrl: URL) async throws -> URL { + let resultPlist = try await Current.shell.mountDmg(dmgUrl) + + let dict = try? (PropertyListSerialization.propertyList(from: resultPlist.out.data(using: .utf8)!, format: nil) as? NSDictionary) + let systemEntities = dict?["system-entities"] as? NSArray + guard let path = systemEntities?.compactMap ({ ($0 as? NSDictionary)?["mount-point"] as? String }).first else { + throw Error.failedMountingDMG + } + return URL(fileURLWithPath: path) + } + + public func unmountDMG(mountedURL: URL) async throws { + let url = try await Current.shell.unmountDmg(mountedURL) + } + + public func expand(pkgPath: Path, expandedPkgPath: Path) async throws { + _ = try await Current.shell.expandPkg(pkgPath.url, expandedPkgPath.url) + } } diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Shell/Shell.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Shell/Shell.swift index 520ad0a..81157d7 100644 --- a/Xcodes/XcodesKit/Sources/XcodesKit/Shell/Shell.swift +++ b/Xcodes/XcodesKit/Sources/XcodesKit/Shell/Shell.swift @@ -5,4 +5,22 @@ public struct Shell { public var installedRuntimes: () async throws -> ProcessOutput = { try await Process.run(Path.root.usr.bin.join("xcrun"), "simctl", "runtime", "list", "-j") } + public var mountDmg: (URL) async throws -> ProcessOutput = { + try await Process.run(Path.root.usr.bin.join("hdiutil"), "attach", "-nobrowse", "-plist", $0.path) + } + public var unmountDmg: (URL) async throws -> ProcessOutput = { + try await Process.run(Path.root.usr.bin.join("hdiutil"), "detach", $0.path) + } + public var expandPkg: (URL, URL) async throws -> ProcessOutput = { + try await Process.run(Path.root.usr.sbin.join("pkgutil"), "--verbose", "--expand", $0.path, $1.path) + } + public var createPkg: (URL, URL) async throws -> ProcessOutput = { + try await Process.run(Path.root.usr.sbin.join("pkgutil"), "--flatten", $0.path, $1.path) + } + public var installPkg: (URL, String) async throws -> ProcessOutput = { + try await Process.run(Path.root.usr.sbin.join("installer"), "-pkg", $0.path, "-target", $1) + } + public var installRuntimeImage: (URL) async throws -> ProcessOutput = { + try await Process.run(Path.root.usr.bin.join("xcrun"), "simctl", "runtime", "add", $0.path) + } }