287 lines · 9.5 KB · Swift
RawDownload
#if canImport(Darwin)import Darwin#elseif canImport(Glibc)import Glibc#elseif canImport(Musl)import Musl#endifimport Foundation public enum ProviderVersionDetector {    private struct ClaudeExecutableFingerprint: Equatable, Hashable {        let realPath: String        let modificationDate: Date        let fileSize: UInt64        let inode: UInt64    }     private struct ClaudeVersionCacheEntry {        let fingerprint: ClaudeExecutableFingerprint        let version: String        let cachedAt: Date    }     private final class PendingDetection {        let group = DispatchGroup()        var result: String?    }     static let claudeVersionCacheTTL: TimeInterval = 30 * 60    private static let lock = NSLock()    private nonisolated(unsafe) static var claudeVersionCache: ClaudeVersionCacheEntry?    private nonisolated(unsafe) static var claudePendingDetections: [ClaudeExecutableFingerprint: PendingDetection] =        [:]     #if DEBUG    public nonisolated(unsafe) static var whichHook: ((String) -> String?)?    public nonisolated(unsafe) static var attributesHook: ((String) -> [FileAttributeKey: Any]?)?    public nonisolated(unsafe) static var runClaudeVersionHook: ((String) throws -> TTYCommandRunner.Result?)?    public nonisolated(unsafe) static var nowHook: (() -> Date)?     public static func resetHooksAndCache() {        self.lock.lock()        self.claudeVersionCache = nil        self.claudePendingDetections.removeAll()        self.whichHook = nil        self.attributesHook = nil        self.runClaudeVersionHook = nil        self.nowHook = nil        self.lock.unlock()    }    #endif     private static func currentDate() -> Date {        #if DEBUG        return self.nowHook?() ?? Date()        #else        return Date()        #endif    }     private static func resolveRealPath(_ path: String) -> String {        var buffer = [CChar](repeating: 0, count: Int(PATH_MAX))        if realpath(path, &buffer) != nil {            return buffer.withUnsafeBufferPointer { rawBuffer in                guard let baseAddress = rawBuffer.baseAddress else { return path }                return String(cString: baseAddress)            }        }        return URL(fileURLWithPath: path).resolvingSymlinksInPath().path    }     private static func getClaudeFingerprint(forPath path: String) -> ClaudeExecutableFingerprint? {        let resolvedPath = self.resolveRealPath(path)        #if DEBUG        let attributesOpt = self.attributesHook != nil ? self.attributesHook?(resolvedPath) : try? FileManager.default            .attributesOfItem(atPath: resolvedPath)        #else        let attributesOpt = try? FileManager.default.attributesOfItem(atPath: resolvedPath)        #endif        guard let attributes = attributesOpt else {            return nil        }        guard let modificationDate = attributes[.modificationDate] as? Date,              let fileSize = (attributes[.size] as? NSNumber)?.uint64Value,              let inode = (attributes[.systemFileNumber] as? NSNumber)?.uint64Value        else {            return nil        }        return ClaudeExecutableFingerprint(            realPath: resolvedPath,            modificationDate: modificationDate,            fileSize: fileSize,            inode: inode)    }     private static func runClaudeVersionCommand(path: String) -> String? {        let commandResult: TTYCommandRunner.Result?        #if DEBUG        if let hook = runClaudeVersionHook {            do {                commandResult = try hook(path)            } catch {                commandResult = nil            }        } else {            do {                commandResult = try TTYCommandRunner().run(                    binary: path,                    send: "",                    options: TTYCommandRunner.Options(                        timeout: 5.0,                        extraArgs: ["--version"],                        initialDelay: 0.0,                        useClaudeProbeWorkingDirectory: true))            } catch {                commandResult = nil            }        }        #else        do {            commandResult = try TTYCommandRunner().run(                binary: path,                send: "",                options: TTYCommandRunner.Options(                    timeout: 5.0,                    extraArgs: ["--version"],                    initialDelay: 0.0,                    useClaudeProbeWorkingDirectory: true))        } catch {            commandResult = nil        }        #endif         guard let commandResult,              commandResult.completion == .processExited(status: 0)        else { return nil }        let trimmed = TextParsing.stripANSICodes(commandResult.text)            .trimmingCharacters(in: .whitespacesAndNewlines)        return trimmed.isEmpty ? nil : trimmed    }     public static func claudeVersion() -> String? {        #if DEBUG        let pathOpt = self.whichHook != nil ? self.whichHook!("claude") : TTYCommandRunner.which("claude")        #else        let pathOpt = TTYCommandRunner.which("claude")        #endif        guard let path = pathOpt else { return nil }         guard let fingerprint = getClaudeFingerprint(forPath: path) else {            return self.runClaudeVersionCommand(path: path)        }        self.lock.lock()        let now = self.currentDate()        let cacheAge = self.claudeVersionCache.map { now.timeIntervalSince($0.cachedAt) }        if let cached = claudeVersionCache,           cached.fingerprint == fingerprint,           let cacheAge,           cacheAge >= 0,           cacheAge < self.claudeVersionCacheTTL        {            self.lock.unlock()            return cached.version        }        self.claudeVersionCache = nil         if let pending = claudePendingDetections[fingerprint] {            self.lock.unlock()            pending.group.wait()            self.lock.lock()            let result = pending.result            self.lock.unlock()            return result        }         let pending = PendingDetection()        pending.group.enter()        self.claudePendingDetections[fingerprint] = pending        self.lock.unlock()         let result = self.runClaudeVersionCommand(path: path)        let completedAt = self.currentDate()         self.lock.lock()        pending.result = result        if let version = result {            self.claudeVersionCache = ClaudeVersionCacheEntry(                fingerprint: fingerprint,                version: version,                cachedAt: completedAt)        }        self.claudePendingDetections.removeValue(forKey: fingerprint)        pending.group.leave()        self.lock.unlock()         return result    }     public static func codexVersion() -> String? {        guard let path = TTYCommandRunner.which("codex") else { return nil }        let candidates = [            ["--version"],            ["version"],            ["-v"],        ]        for args in candidates {            if let version = Self.run(path: path, args: args) {                return version            }        }        return nil    }     public static func geminiVersion() -> String? {        let env = ProcessInfo.processInfo.environment        guard let path = BinaryLocator.resolveGeminiBinary(env: env, loginPATH: nil)            ?? TTYCommandRunner.which("gemini") else { return nil }        let candidates = [            ["--version"],            ["-v"],        ]        for args in candidates {            if let version = Self.run(path: path, args: args) {                return version            }        }        return nil    }     static func run(        path: String,        args: [String],        timeout: TimeInterval = 2.0,        environment: [String: String]? = nil,        mergeStandardError: Bool = false) -> String?    {        let proc = Process()        proc.executableURL = URL(fileURLWithPath: path)        proc.arguments = args        proc.environment = environment        let out = Pipe()        proc.standardOutput = out        proc.standardError = mergeStandardError ? out : FileHandle.nullDevice        proc.standardInput = nil        let outputCapture = ProcessPipeCapture(pipe: out)        outputCapture.start()         let exitSemaphore = DispatchSemaphore(value: 0)        proc.terminationHandler = { _ in            exitSemaphore.signal()        }         do {            try proc.run()        } catch {            outputCapture.stop()            return nil        }         let didExit = exitSemaphore.wait(timeout: .now() + timeout) == .success        if !didExit, !Self.forceExit(proc, exitSemaphore: exitSemaphore) {            outputCapture.stop()            return nil        }         let data = outputCapture.finishSynchronously(timeout: 0.25)        guard proc.terminationStatus == 0,              let text = ProcessPipeCapture.decodeUTF8(data)                  .split(whereSeparator: \.isNewline).first        else { return nil }        let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)        return trimmed.isEmpty ? nil : trimmed    }     private static func forceExit(_ proc: Process, exitSemaphore: DispatchSemaphore) -> Bool {        guard proc.isRunning else { return true }         proc.terminate()        if exitSemaphore.wait(timeout: .now() + 0.5) == .success {            return true        }         guard proc.isRunning else { return true }        kill(proc.processIdentifier, SIGKILL)        return exitSemaphore.wait(timeout: .now() + 1.0) == .success    }}