diff --git a/Sources/ProcessBarMonitor/SystemMetricsProvider.swift b/Sources/ProcessBarMonitor/SystemMetricsProvider.swift index 9723cbc..68f96b7 100644 --- a/Sources/ProcessBarMonitor/SystemMetricsProvider.swift +++ b/Sources/ProcessBarMonitor/SystemMetricsProvider.swift @@ -7,6 +7,24 @@ actor SystemMetricsProvider { private var lastTemperatureRefresh = Date.distantPast private var lastTemperatureMode: TemperatureMode = .hottestCPU private let appleSiliconProvider = AppleSiliconTemperatureProvider() + /// Caches the confirmed temperature tool state to avoid repeated shell detection. + private var temperatureToolState: TemperatureToolState = .unchecked + + /// 3-state cache for temperature command resolution. + /// - unchecked: initial state, needs detection + /// - unavailable: both tools confirmed missing, skip detection + /// - resolved: tool found, store its kind and the resolved executable path + private enum TemperatureToolState { + case unchecked + case unavailable + case resolved(kind: TemperatureToolKind, path: String) + } + + /// Supported temperature measurement tools. + private enum TemperatureToolKind { + case istats + case osxCpuTemp + } func snapshot(temperatureMode: TemperatureMode) -> SystemSummary { let temperature = bestEffortCPUTemperature(mode: temperatureMode) @@ -84,17 +102,66 @@ actor SystemMetricsProvider { return appleTemp } - cachedTemperature = commandTemperature( - "/bin/zsh", - ["-lc", "if command -v istats >/dev/null 2>&1; then istats cpu temp --value-only; elif command -v osx-cpu-temp >/dev/null 2>&1; then osx-cpu-temp; else exit 1; fi"] - ) + // Skip detection entirely if both tools were previously confirmed unavailable + if case .unchecked = temperatureToolState { + temperatureToolState = resolveTemperatureTool() + } + + switch temperatureToolState { + case .unavailable: + cachedTemperature = nil + case .resolved(let kind, let path): + cachedTemperature = runTemperatureCommand(kind: kind, path: path) + case .unchecked: + cachedTemperature = nil + } return cachedTemperature } - private func commandTemperature(_ launchPath: String, _ arguments: [String]) -> Double? { + /// Detects which temperature tool is available and caches the resolved executable path. + /// Called at most once per app run when cache is cold. + private func resolveTemperatureTool() -> TemperatureToolState { let process = Process() - process.executableURL = URL(fileURLWithPath: launchPath) - process.arguments = arguments + process.executableURL = URL(fileURLWithPath: "/bin/zsh") + process.arguments = ["-lc", """ + if command -v istats >/dev/null 2>&1; then + echo "istats:$(command -v istats)" + elif command -v osx-cpu-temp >/dev/null 2>&1; then + echo "osx-cpu-temp:$(command -v osx-cpu-temp)" + else + echo "none" + fi + """] + let output = Pipe() + process.standardOutput = output + process.standardError = Pipe() + + do { try process.run() } catch { return .unavailable } + process.waitUntilExit() + + guard process.terminationStatus == 0 else { return .unavailable } + let data = output.fileHandleForReading.readDataToEndOfFile() + guard let raw = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) else { return .unavailable } + + if raw.hasPrefix("istats:") { + let path = String(raw.dropFirst(7)) + return .resolved(kind: .istats, path: path) + } else if raw.hasPrefix("osx-cpu-temp:") { + let path = String(raw.dropFirst(13)) + return .resolved(kind: .osxCpuTemp, path: path) + } + return .unavailable + } + + private func runTemperatureCommand(kind: TemperatureToolKind, path: String) -> Double? { + let process = Process() + process.executableURL = URL(fileURLWithPath: path) + switch kind { + case .istats: + process.arguments = ["cpu", "temp", "--value-only"] + case .osxCpuTemp: + process.arguments = [] + } let output = Pipe() process.standardOutput = output process.standardError = Pipe()