diff --git a/apps/macos/Sources/Clawdis/VoiceWakeForwarder.swift b/apps/macos/Sources/Clawdis/VoiceWakeForwarder.swift index ae8b8d589..a5873600e 100644 --- a/apps/macos/Sources/Clawdis/VoiceWakeForwarder.swift +++ b/apps/macos/Sources/Clawdis/VoiceWakeForwarder.swift @@ -10,11 +10,64 @@ struct VoiceWakeForwardConfig: Sendable { } enum VoiceWakeForwarder { - private static let logger = Logger(subsystem: "com.steipete.clawdis", category: "voicewake.forward") - private static let cliPathPrefix = "PATH=\(cliHelperSearchPaths.joined(separator: ":")):$PATH" + private final class CLICache: @unchecked Sendable { + private var value: (target: String, path: String)? + private let lock = NSLock() - static func commandWithCliPath(_ command: String) -> String { - "\(self.cliPathPrefix); \(command)" + func get() -> (target: String, path: String)? { + self.lock.lock(); defer { self.lock.unlock() } + return self.value + } + + func set(_ newValue: (target: String, path: String)?) { + self.lock.lock(); self.value = newValue; self.lock.unlock() + } + } + + private static let logger = Logger(subsystem: "com.steipete.clawdis", category: "voicewake.forward") + private static let cliSearchCandidates = ["clawdis-mac"] + cliHelperSearchPaths.map { "\($0)/clawdis-mac" } + private static let cliCache = CLICache() + + static func clearCliCache() { + self.cliCache.set(nil) + } + + private static func cliLookupPrefix(target: String, echoPath: Bool) -> String { + let normalizedTarget = target.trimmingCharacters(in: .whitespacesAndNewlines) + let pathPrefix = "PATH=\(cliHelperSearchPaths.joined(separator: ":")):$PATH" + let searchList = self.cliSearchCandidates.joined(separator: " ") + + var steps: [String] = [pathPrefix] + + let cached = self.cliCache.get() + + if let cached, cached.target == normalizedTarget { + steps.append("CLI=\"\(cached.path)\"") + steps.append("if [ ! -x \"$CLI\" ]; then CLI=\"\"; fi") + } else { + steps.append("CLI=\"\"") + } + + steps.append("if [ -z \"$CLI\" ]; then CLI=$(command -v clawdis-mac 2>/dev/null || true); fi") + steps.append("if [ -z \"$CLI\" ]; then for c in \(searchList); do [ -x \"$c\" ] && CLI=\"$c\" && break; done; fi") + steps.append("if [ -z \"$CLI\" ]; then echo 'clawdis-mac missing'; exit 127; fi") + + if echoPath { + steps.append("echo __CLI:$CLI") + } + + return steps.joined(separator: "; ") + } + + static func commandWithCliPath(_ command: String, target: String, echoCliPath: Bool = false) -> String { + let rewritten: String + if command.contains("clawdis-mac") { + rewritten = command.replacingOccurrences(of: "clawdis-mac", with: "\"$CLI\"") + } else { + rewritten = "\"$CLI\" \(command)" + } + + return "\(self.cliLookupPrefix(target: target, echoPath: echoCliPath)); \(rewritten)" } enum VoiceWakeForwardError: LocalizedError, Equatable { @@ -62,7 +115,7 @@ enum VoiceWakeForwarder { args.append(userHost) let rendered = self.renderedCommand(template: config.commandTemplate, transcript: transcript) - args.append(contentsOf: ["sh", "-c", self.commandWithCliPath(rendered)]) + args.append(contentsOf: ["sh", "-c", self.commandWithCliPath(rendered, target: destination)]) self.logger.info("voice wake forward starting host=\(userHost, privacy: .public)") @@ -139,7 +192,7 @@ enum VoiceWakeForwarder { // Stage 2: ensure remote clawdis-mac is present and responsive. var checkArgs = baseArgs - let statusCommand = self.commandWithCliPath("clawdis-mac status") + let statusCommand = self.commandWithCliPath("clawdis-mac status", target: destination, echoCliPath: true) checkArgs.append(contentsOf: [userHost, "sh", "-c", statusCommand]) let checkProc = Process() checkProc.executableURL = URL(fileURLWithPath: "/usr/bin/ssh") @@ -154,6 +207,14 @@ enum VoiceWakeForwarder { } let statusOut = await self.wait(checkProc, timeout: 6, capturing: checkPipe) if checkProc.terminationStatus == 0 { + if let cliLine = statusOut + .split(separator: "\n") + .last(where: { $0.hasPrefix("__CLI:") }) { + let path = String(cliLine.dropFirst("__CLI:".count)) + if !path.isEmpty { + self.cliCache.set((target: destination, path: path)) + } + } return .success(()) } return .failure(.cliMissingOrFailed(checkProc.terminationStatus, statusOut)) diff --git a/apps/macos/Sources/Clawdis/VoiceWakeSettings.swift b/apps/macos/Sources/Clawdis/VoiceWakeSettings.swift index f27950123..470aaa42f 100644 --- a/apps/macos/Sources/Clawdis/VoiceWakeSettings.swift +++ b/apps/macos/Sources/Clawdis/VoiceWakeSettings.swift @@ -755,7 +755,10 @@ struct VoiceWakeSettings: View { TextField("steipete@peters-mac-studio-1", text: self.$state.voiceWakeForwardTarget) .textFieldStyle(.roundedBorder) .frame(maxWidth: .infinity) - .onChange(of: self.state.voiceWakeForwardTarget) { _, _ in self.forwardStatus = .idle } + .onChange(of: self.state.voiceWakeForwardTarget) { _, _ in + self.forwardStatus = .idle + VoiceWakeForwarder.clearCliCache() + } self.forwardStatusIcon .frame(width: 16, height: 16, alignment: .center) Button("Test") { @@ -837,6 +840,7 @@ struct VoiceWakeSettings: View { } private func checkForwardConnection() async { + VoiceWakeForwarder.clearCliCache() self.forwardStatus = .checking let config = AppStateStore.shared.voiceWakeForwardConfig let result = await VoiceWakeForwarder.checkConnection(config: config) diff --git a/apps/macos/Tests/ClawdisIPCTests/VoiceWakeForwarderTests.swift b/apps/macos/Tests/ClawdisIPCTests/VoiceWakeForwarderTests.swift index e3626bffe..b3d0197a1 100644 --- a/apps/macos/Tests/ClawdisIPCTests/VoiceWakeForwarderTests.swift +++ b/apps/macos/Tests/ClawdisIPCTests/VoiceWakeForwarderTests.swift @@ -31,9 +31,9 @@ import Testing } @Test func commandPrefersCliInstallPaths() { - let command = VoiceWakeForwarder.commandWithCliPath("clawdis-mac status") - let prefix = "PATH=\(cliHelperSearchPaths.joined(separator: ":")):$PATH; " - #expect(command.hasPrefix(prefix)) - #expect(command.contains("clawdis-mac status")) + let command = VoiceWakeForwarder.commandWithCliPath("clawdis-mac status", target: "user@host") + #expect(command.contains("PATH=\(cliHelperSearchPaths.joined(separator: ":")):$PATH")) + #expect(command.contains("for c in clawdis-mac /usr/local/bin/clawdis-mac /opt/homebrew/bin/clawdis-mac")) + #expect(command.contains("\"$CLI\" status")) } }