Files
wehub-resource-sync 1d1286fadb
Build / Build and test (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:18:38 +08:00

215 lines
7.4 KiB
Swift

import Foundation
public enum HomebrewExecutableValidator {
public static let standardPaths = [
"/opt/homebrew/bin/brew",
"/usr/local/bin/brew",
"/opt/workbrew/bin/brew"
]
public static func validatedPath(
for rawPath: String,
fileManager: FileManager = .default
) -> String? {
let trimmed = rawPath.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
var candidate = (trimmed as NSString).expandingTildeInPath
if (candidate as NSString).lastPathComponent != "brew" {
candidate = (candidate as NSString).appendingPathComponent("brew")
}
let standardized = URL(fileURLWithPath: candidate).standardizedFileURL
guard isValidBrewExecutable(at: standardized.path, fileManager: fileManager) else {
return nil
}
return standardized.path
}
public static func isValidBrewExecutable(
at path: String,
fileManager: FileManager = .default
) -> Bool {
let url = URL(fileURLWithPath: path).standardizedFileURL
guard url.lastPathComponent == "brew",
url.deletingLastPathComponent().lastPathComponent == "bin" else {
return false
}
var isDirectory: ObjCBool = false
guard fileManager.fileExists(atPath: url.path, isDirectory: &isDirectory),
!isDirectory.boolValue,
fileManager.isExecutableFile(atPath: url.path) else {
return false
}
let resolvedURL = url.resolvingSymlinksInPath().standardizedFileURL
var resolvedIsDirectory: ObjCBool = false
guard fileManager.fileExists(atPath: resolvedURL.path, isDirectory: &resolvedIsDirectory),
!resolvedIsDirectory.boolValue,
fileManager.isExecutableFile(atPath: resolvedURL.path) else {
return false
}
return looksLikeHomebrewExecutable(at: resolvedURL)
|| looksLikeHomebrewExecutable(at: url)
}
private static func looksLikeHomebrewExecutable(at url: URL) -> Bool {
guard let fileHandle = try? FileHandle(forReadingFrom: url) else {
return false
}
defer { try? fileHandle.close() }
guard let data = try? fileHandle.read(upToCount: 16_384),
let sample = String(data: data, encoding: .utf8) else {
return false
}
return sample.contains("HOMEBREW")
|| sample.contains("Homebrew")
|| sample.contains("brew.rb")
}
}
public protocol HomebrewCommandRunning: Sendable {
func run(
executable: String,
arguments: [String],
onOutput: @escaping @MainActor (String) -> Void,
onError: @escaping @MainActor (String) -> Void
) async throws -> Int32
func cancel() async
}
public actor HomebrewCommandRunner: HomebrewCommandRunning {
private var activeProcess: Process?
public init() {}
/// Runs a command asynchronously, streaming output and error chunks to the provided handlers.
/// Returns the termination status (exit code).
public func run(
executable: String,
arguments: [String],
onOutput: @escaping @MainActor (String) -> Void,
onError: @escaping @MainActor (String) -> Void
) async throws -> Int32 {
if activeProcess != nil {
throw NSError(
domain: "HomebrewCommandRunner",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "A process is already running."]
)
}
let process = Process()
guard let validatedExecutable = HomebrewExecutableValidator.validatedPath(for: executable) else {
throw NSError(
domain: "HomebrewCommandRunner",
code: 2,
userInfo: [NSLocalizedDescriptionKey: "The configured Homebrew executable is invalid."]
)
}
process.executableURL = URL(fileURLWithPath: validatedExecutable)
// Setup environment path, ensuring homebrew binaries are visible
var env = ProcessInfo.processInfo.environment
let path = env["PATH"] ?? ""
let brewPaths = ["/opt/homebrew/bin", "/usr/local/bin"]
var pathComponents = path.components(separatedBy: ":")
for p in brewPaths {
if !pathComponents.contains(p) {
pathComponents.insert(p, at: 0)
}
}
env["PATH"] = pathComponents.joined(separator: ":")
env["HOMEBREW_NO_EMOJI"] = "1"
env["HOMEBREW_NO_AUTO_UPDATE"] = "1"
process.environment = env
process.arguments = arguments
let outputPipe = Pipe()
let errorPipe = Pipe()
process.standardOutput = outputPipe
process.standardError = errorPipe
self.activeProcess = process
let group = DispatchGroup()
// Attach readability handlers to stream output in real-time
outputPipe.fileHandleForReading.readabilityHandler = { handle in
let data = handle.availableData
guard !data.isEmpty else { return }
if let string = String(data: data, encoding: .utf8) {
group.enter()
Task { @MainActor in
onOutput(string)
group.leave()
}
}
}
errorPipe.fileHandleForReading.readabilityHandler = { handle in
let data = handle.availableData
guard !data.isEmpty else { return }
if let string = String(data: data, encoding: .utf8) {
group.enter()
Task { @MainActor in
onError(string)
group.leave()
}
}
}
return try await withCheckedThrowingContinuation { continuation in
process.terminationHandler = { [weak self] proc in
// Clear readability handlers to prevent resource leaks
outputPipe.fileHandleForReading.readabilityHandler = nil
errorPipe.fileHandleForReading.readabilityHandler = nil
Task { [weak self] in
guard let self = self else { return }
// Wait for all pending UI output tasks to complete
await withCheckedContinuation { (waitContinuation: CheckedContinuation<Void, Never>) in
group.notify(queue: .global()) {
waitContinuation.resume()
}
}
await self.clearProcess(for: proc)
continuation.resume(returning: proc.terminationStatus)
}
}
do {
try process.run()
} catch {
outputPipe.fileHandleForReading.readabilityHandler = nil
errorPipe.fileHandleForReading.readabilityHandler = nil
Task { [weak self] in
guard let self = self else { return }
await self.clearProcess(for: process)
}
continuation.resume(throwing: error)
}
}
}
public func cancel() {
if let process = activeProcess, process.isRunning {
process.terminate()
}
}
private func clearProcess(for process: Process) {
if self.activeProcess === process {
self.activeProcess = nil
}
}
}