Files

317 lines
15 KiB
Swift
Raw Permalink 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 Darwin
import Localization
public struct ToolRunResult: Sendable {
public let status: Int32
public let output: String
/// 真实 Homebrew 清理的完整输出统计,避免截断诊断文本后丢失汇总。
public let cleanupReport: CleanReport?
public init(status: Int32, output: String, cleanupReport: CleanReport? = nil) {
self.status = status
self.output = output
self.cleanupReport = cleanupReport
}
}
public enum ToolExecutionError: Error, LocalizedError, Sendable {
case unavailable(String)
case failed(tool: String, status: Int32, output: String)
case timedOut(String)
case outputTooLarge(String)
public var errorDescription: String? {
switch self {
case .unavailable(let tool):
tr("未找到 \(tool),无法安全清理")
case .timedOut(let tool): tr("\(tool) 执行超时,请稍后重试")
case .outputTooLarge(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,
previewTimeout: TimeInterval = 60, outputLimit: Int = 2_000_000) async throws -> ToolRunResult {
try Task.checkCancellation()
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"
if name == "brew" {
for key in ["HOMEBREW_NO_AUTO_UPDATE", "HOMEBREW_NO_AUTOREMOVE", "HOMEBREW_NO_ANALYTICS",
"HOMEBREW_NO_ENV_HINTS", "HOMEBREW_NO_COLOR", "NONINTERACTIVE"] {
environment[key] = "1"
}
environment["LC_ALL"] = "en_US.UTF-8"
}
process.environment = environment
if name == "brew" {
if arguments.first == "cleanup", !arguments.contains("--dry-run") {
// 真实清理可能已删除部分文件,不因超时或输出量中断;仍响应用户取消。
return try await runHomebrewCleanup(process, outputLimit: outputLimit)
}
return try await runHomebrewPreview(process, timeout: previewTimeout, outputLimit: outputLimit)
}
let pipe = Pipe()
process.standardOutput = pipe
process.standardError = pipe
process.standardInput = FileHandle.nullDevice
try Task.checkCancellation()
do {
try process.run()
} catch {
throw ToolExecutionError.failed(tool: name, status: -1, output: error.localizedDescription)
}
try? pipe.fileHandleForWriting.close()
let reader = pipe.fileHandleForReading
// 读取任务自己在读完后关闭句柄:取消时外面不能在它还在 read 时关掉同一个 fd,
// 否则读取会报错,或读到 fd 编号被系统复用后的其他文件。
let outputTask = Task.detached(priority: .utility) {
defer { try? reader.close() }
var tail = Data()
let limit = max(0, outputLimit)
while let chunk = try readChunk(reader) {
tail.append(chunk)
if tail.count > limit { tail.removeFirst(tail.count - limit) }
}
return String(decoding: tail, as: UTF8.self).trimmingCharacters(in: .whitespacesAndNewlines)
}
// 正常退出时读到 EOF;取消时通知读取任务自行退出并关闭句柄
defer {
outputTask.cancel()
stop(process)
}
while process.isRunning {
try Task.checkCancellation()
try await Task.sleep(for: .milliseconds(25))
}
try Task.checkCancellation()
let output = try await outputTask.value
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 runHomebrewPreview(_ process: Process, timeout: TimeInterval, outputLimit: Int) async throws -> ToolRunResult {
let outputURL = FileManager.default.temporaryDirectory.appendingPathComponent("xstats-brew-\(UUID())")
guard FileManager.default.createFile(atPath: outputURL.path, contents: nil) else {
throw CocoaError(.fileWriteUnknown)
}
defer { try? FileManager.default.removeItem(at: outputURL) }
let handle = try FileHandle(forWritingTo: outputURL)
defer {
stop(process)
try? handle.close()
}
process.standardOutput = handle
process.standardError = handle
process.standardInput = FileHandle.nullDevice
try Task.checkCancellation()
do { try process.run() } catch {
throw ToolExecutionError.failed(tool: "brew", status: -1, output: error.localizedDescription)
}
let deadline = ContinuousClock.now.advanced(by: .seconds(timeout))
while process.isRunning {
try Task.checkCancellation()
guard ContinuousClock.now < deadline else { throw ToolExecutionError.timedOut("brew") }
let size = (try FileManager.default.attributesOfItem(atPath: outputURL.path)[.size] as? NSNumber)?.intValue ?? 0
guard size <= outputLimit else { throw ToolExecutionError.outputTooLarge("brew") }
try await Task.sleep(for: .milliseconds(25))
}
try Task.checkCancellation()
let reader = try FileHandle(forReadingFrom: outputURL)
defer { try? reader.close() }
let data = try reader.read(upToCount: outputLimit + 1) ?? Data()
guard data.count <= outputLimit else { throw ToolExecutionError.outputTooLarge("brew") }
let output = String(decoding: data, as: UTF8.self).trimmingCharacters(in: .whitespacesAndNewlines)
guard process.terminationStatus == 0 else {
throw ToolExecutionError.failed(tool: "brew", status: process.terminationStatus,
output: String(output.prefix(2_000)))
}
return ToolRunResult(status: process.terminationStatus, output: output)
}
/// 持续排空管道,让清理执行到底;只保留有限的末尾诊断文本和逐行汇总。
private static func runHomebrewCleanup(_ process: Process, outputLimit: Int) async throws -> ToolRunResult {
let pipe = Pipe()
process.standardOutput = pipe
process.standardError = pipe
process.standardInput = FileHandle.nullDevice
try Task.checkCancellation()
do { try process.run() } catch {
throw ToolExecutionError.failed(tool: "brew", status: -1, output: error.localizedDescription)
}
try? pipe.fileHandleForWriting.close()
let reader = pipe.fileHandleForReading
// 与 run 相同:句柄只由读取任务在读完后关闭,取消时不与读取竞争
let outputTask = Task.detached(priority: .utility) {
defer { try? reader.close() }
return try readHomebrewCleanupOutput(reader, outputLimit: outputLimit)
}
defer {
outputTask.cancel()
stop(process)
}
while process.isRunning {
try Task.checkCancellation()
try await Task.sleep(for: .milliseconds(25))
}
try Task.checkCancellation()
let result = try await outputTask.value
guard process.terminationStatus == 0 else {
throw ToolExecutionError.failed(tool: "brew", status: process.terminationStatus,
output: String(result.output.suffix(2_000)))
}
guard !result.hasInvalidSummary else { throw HomebrewCleanup.PreviewError.unsupportedOutput }
return ToolRunResult(status: process.terminationStatus, output: result.output, cleanupReport: result.report)
}
private struct CleanupOutput: Sendable {
let output: String
let report: CleanReport
let hasInvalidSummary: Bool
}
private static func readHomebrewCleanupOutput(_ reader: FileHandle, outputLimit: Int) throws -> CleanupOutput {
var tail = Data()
var line = [UInt8]()
var lineTooLong = false
var report = CleanReport()
var hasInvalidSummary = false
while let chunk = try readChunk(reader) {
tail.append(chunk)
if tail.count > outputLimit { tail.removeFirst(tail.count - max(0, outputLimit)) }
for byte in chunk {
if byte == 0x0A {
if !lineTooLong { recordHomebrewLine(line, report: &report, hasInvalidSummary: &hasInvalidSummary) }
line.removeAll(keepingCapacity: true)
lineTooLong = false
} else if line.count < 64 * 1024 {
line.append(byte)
} else {
// 跳过异常长的单行诊断输出,继续读取后续的 Homebrew 汇总行。
lineTooLong = true
}
}
}
if !line.isEmpty, !lineTooLong {
recordHomebrewLine(line, report: &report, hasInvalidSummary: &hasInvalidSummary)
}
return CleanupOutput(output: String(decoding: tail, as: UTF8.self).trimmingCharacters(in: .whitespacesAndNewlines),
report: report, hasInvalidSummary: hasInvalidSummary)
}
/// 限时轮询让取消能中断读取;孙进程继承管道写端时,阻塞式 read 可能一直等不到 EOF。
private static func readChunk(_ reader: FileHandle) throws -> Data? {
var descriptor = pollfd(fd: reader.fileDescriptor, events: Int16(POLLIN | POLLHUP), revents: 0)
var bytes = [UInt8](repeating: 0, count: 64 * 1024)
while true {
try Task.checkCancellation()
let ready = Darwin.poll(&descriptor, 1, 100)
if ready == 0 || (ready < 0 && errno == EINTR) { continue }
guard ready > 0 else { throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO) }
let count = bytes.withUnsafeMutableBytes { Darwin.read(descriptor.fd, $0.baseAddress, $0.count) }
if count == 0 { return nil }
if count < 0 {
if errno == EINTR || errno == EAGAIN { continue }
throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO)
}
return Data(bytes.prefix(count))
}
}
private static func recordHomebrewLine(_ bytes: [UInt8], report: inout CleanReport, hasInvalidSummary: inout Bool) {
let line = String(decoding: bytes, as: UTF8.self).trimmingCharacters(in: .whitespacesAndNewlines)
do { try HomebrewCleanup.recordExecutionLine(line, report: &report) }
catch { hasInvalidSummary = true }
}
/// 只结束本次启动的进程;取消后仍给予短暂退出时间,防止清理子进程留在后台。
/// 不调用 `waitUntilExit()`:它靠当前线程的 RunLoop 接收退出通知,而这里跑在不驱动 RunLoop 的
/// 并发线程上,错过通知就会永久等待(停止清理卡在“正在停止”)。改为有上限的轮询。
private static func stop(_ process: Process) {
guard process.isRunning else { return }
process.terminate()
guard !waitForExit(process, seconds: 1) else { return }
_ = Darwin.kill(process.processIdentifier, SIGKILL)
// SIGKILL 无法被忽略,进程很快会被回收;仍设上限,不让调用方无限等待
_ = waitForExit(process, seconds: 2)
}
/// 进程已退出返回 true。`kill(pid, 0)` 报 ESRCH 说明已被回收,不必再等 Foundation 更新 `isRunning`。
private static func waitForExit(_ process: Process, seconds: Int) -> Bool {
let deadline = ContinuousClock.now.advanced(by: .seconds(seconds))
while ContinuousClock.now < deadline {
if !process.isRunning { return true }
if Darwin.kill(process.processIdentifier, 0) != 0 && errno == ESRCH { return true }
Thread.sleep(forTimeInterval: 0.01)
}
return !process.isRunning
}
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 }
}
}