Files
XStats/Packages/XStatsKit/Sources/Cleaner/ToolExecution.swift
T

106 lines
4.3 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Copyright (C) 2026 ysicing
// SPDX-License-Identifier: AGPL-3.0-or-later
import Foundation
import Localization
public struct ToolRunResult: Sendable {
public let status: Int32
public let output: String
}
public enum ToolExecutionError: Error, LocalizedError, Sendable {
case unavailable(String)
case failed(tool: String, status: Int32, output: String)
public var errorDescription: String? {
switch self {
case .unavailable(let tool):
tr("未找到 \(tool),无法安全清理")
case .failed(let tool, let status, let output):
output.isEmpty
? tr("\(tool) 清理失败(退出码 \(status))")
: tr("\(tool) 清理失败(退出码 \(status)):\(output)")
}
}
}
enum DeveloperToolRunner {
/// 只从常见的用户工具目录和系统工具目录解析,避免依赖 GUI 进程通常不完整的 PATH。
static func executable(named name: String, home: String) -> URL? {
guard !name.isEmpty, !name.contains("/") else { return nil }
return searchDirectories(home: home)
.map { $0.appendingPathComponent(name) }
.first { FileManager.default.isExecutableFile(atPath: $0.path) }
}
/// 外部工具可能执行较久;放到并发执行器,避免阻塞调用它的主 Actor。
@concurrent
static func run(_ name: String, arguments: [String], home: String) async throws -> ToolRunResult {
guard let executable = executable(named: name, home: home) else {
throw ToolExecutionError.unavailable(name)
}
let process = Process()
process.executableURL = executable
process.arguments = arguments
process.currentDirectoryURL = FileManager.default.temporaryDirectory
var environment = ProcessInfo.processInfo.environment
environment["HOME"] = home
environment["PATH"] = ([executable.deletingLastPathComponent()] + searchDirectories(home: home))
.map(\.path).uniqued().joined(separator: ":")
environment["NO_COLOR"] = "1"
environment["TERM"] = "dumb"
process.environment = environment
let pipe = Pipe()
process.standardOutput = pipe
process.standardError = pipe
do {
try process.run()
} catch {
throw ToolExecutionError.failed(tool: name, status: -1, output: error.localizedDescription)
}
let data = pipe.fileHandleForReading.readDataToEndOfFile()
process.waitUntilExit()
let output = String(decoding: data, as: UTF8.self)
.trimmingCharacters(in: .whitespacesAndNewlines)
guard process.terminationStatus == 0 else {
throw ToolExecutionError.failed(tool: name, status: process.terminationStatus,
output: String(output.prefix(2_000)))
}
return ToolRunResult(status: process.terminationStatus, output: output)
}
private static func searchDirectories(home: String) -> [URL] {
let homeURL = URL(fileURLWithPath: home, isDirectory: true)
var directories = [
homeURL.appendingPathComponent(".local/bin"),
homeURL.appendingPathComponent(".cargo/bin"),
homeURL.appendingPathComponent(".bun/bin"),
homeURL.appendingPathComponent(".volta/bin"),
homeURL.appendingPathComponent(".asdf/shims"),
homeURL.appendingPathComponent(".local/share/mise/shims"),
]
let nvmRoot = homeURL.appendingPathComponent(".nvm/versions/node")
let nvmVersions = ((try? FileManager.default.contentsOfDirectory(
at: nvmRoot, includingPropertiesForKeys: nil, options: [.skipsHiddenFiles]
)) ?? []).sorted {
$0.lastPathComponent.compare($1.lastPathComponent, options: .numeric) == .orderedDescending
}
directories += nvmVersions.map { $0.appendingPathComponent("bin") }
directories += ["/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/bin"]
.map { URL(fileURLWithPath: $0, isDirectory: true) }
return directories.uniqued()
}
}
private extension Sequence where Element: Hashable {
func uniqued() -> [Element] {
var seen = Set<Element>()
return filter { seen.insert($0).inserted }
}
}