mirror of
https://github.com/XcodesOrg/XcodesApp.git
synced 2026-03-25 08:55:46 +00:00
WIP download runtime, refactor
This commit is contained in:
parent
7325502853
commit
dc5a8b03b6
8 changed files with 297 additions and 61 deletions
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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<URL, Error> 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<URL, Error> {
|
||||
|
|
@ -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<URL, Error> {
|
||||
|
||||
|
||||
try? self.runtimeService.installRuntimeImage(dmgURL: dmgURL)
|
||||
|
||||
|
||||
return Just(dmgURL)
|
||||
.setFailureType(to: Error.self)
|
||||
.eraseToAnyPublisher()
|
||||
|
||||
}
|
||||
|
||||
public func installFromPackage(dmgURL: URL, runtime: DownloadableRuntime) -> AnyPublisher<URL, Error> {
|
||||
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()
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Void, Error> {
|
||||
|
||||
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
|
||||
|
||||
|
|
|
|||
|
|
@ -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<Void, Error> { 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<ProcessOutput, Error> = { 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<URLSession.DataTaskPublisher.Output, Error> {
|
||||
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>) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue