diff --git a/apps/macos/Sources/Clawdis/VoiceWakeForwarder.swift b/apps/macos/Sources/Clawdis/VoiceWakeForwarder.swift index 1e9119c62..26988cbe4 100644 --- a/apps/macos/Sources/Clawdis/VoiceWakeForwarder.swift +++ b/apps/macos/Sources/Clawdis/VoiceWakeForwarder.swift @@ -12,6 +12,20 @@ struct VoiceWakeForwardConfig: Sendable { enum VoiceWakeForwarder { private static let logger = Logger(subsystem: "com.steipete.clawdis", category: "voicewake.forward") + enum VoiceWakeForwardError: LocalizedError, Equatable { + case invalidTarget + case launchFailed(String) + case nonZeroExit(Int32) + + var errorDescription: String? { + switch self { + case .invalidTarget: "Missing or invalid SSH target" + case let .launchFailed(message): "ssh failed to start: \(message)" + case let .nonZeroExit(code): "ssh exited with code \(code)" + } + } + } + static func forward(transcript: String, config: VoiceWakeForwardConfig) async { guard config.enabled else { return } let destination = config.target.trimmingCharacters(in: .whitespacesAndNewlines) @@ -60,6 +74,42 @@ enum VoiceWakeForwarder { await self.wait(process, timeout: config.timeout) } + static func checkConnection(config: VoiceWakeForwardConfig) async -> Result { + let destination = config.target.trimmingCharacters(in: .whitespacesAndNewlines) + guard let parsed = self.parse(target: destination) else { + return .failure(.invalidTarget) + } + + let userHost = parsed.user.map { "\($0)@\(parsed.host)" } ?? parsed.host + + var args: [String] = [ + "-o", "BatchMode=yes", + "-o", "IdentitiesOnly=yes", + "-o", "ConnectTimeout=4", + ] + if parsed.port > 0 { args.append(contentsOf: ["-p", String(parsed.port)]) } + if !config.identityPath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + args.append(contentsOf: ["-i", config.identityPath]) + } + args.append(contentsOf: [userHost, "true"]) + + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/ssh") + process.arguments = args + + do { + try process.run() + } catch { + return .failure(.launchFailed(error.localizedDescription)) + } + + await self.wait(process, timeout: 6) + if process.terminationStatus == 0 { + return .success(()) + } + return .failure(.nonZeroExit(process.terminationStatus)) + } + static func renderedCommand(template: String, transcript: String) -> String { let escaped = Self.shellEscape(transcript) if template.contains("${text}") { diff --git a/apps/macos/Sources/Clawdis/VoiceWakeSettings.swift b/apps/macos/Sources/Clawdis/VoiceWakeSettings.swift index 202bf73ce..2f71128b3 100644 --- a/apps/macos/Sources/Clawdis/VoiceWakeSettings.swift +++ b/apps/macos/Sources/Clawdis/VoiceWakeSettings.swift @@ -11,6 +11,13 @@ enum VoiceWakeTestState: Equatable { case failed(String) } +private enum ForwardStatus: Equatable { + case idle + case checking + case ok + case failed(String) +} + private struct AudioInputDevice: Identifiable, Equatable { let uid: String let name: String @@ -253,6 +260,7 @@ struct VoiceWakeSettings: View { private let meter = MicLevelMonitor() @State private var availableLocales: [Locale] = [] @State private var showForwardAdvanced = false + @State private var forwardStatus: ForwardStatus = .idle private struct IndexedWord: Identifiable { let id: Int @@ -686,8 +694,11 @@ struct VoiceWakeSettings: View { TextField("steipete@peters-mac-studio-1", text: self.$state.voiceWakeForwardTarget) .textFieldStyle(.roundedBorder) .frame(width: 280) + .onChange(of: self.state.voiceWakeForwardTarget) { _, _ in self.forwardStatus = .idle } } + self.forwardStatusRow + DisclosureGroup(isExpanded: self.$showForwardAdvanced) { VStack(alignment: .leading, spacing: 10) { LabeledContent("Identity file") { @@ -696,6 +707,9 @@ struct VoiceWakeSettings: View { text: self.$state.voiceWakeForwardIdentity) .textFieldStyle(.roundedBorder) .frame(width: 320) + .onChange(of: self.state.voiceWakeForwardIdentity) { _, _ in + self.forwardStatus = .idle + } } VStack(alignment: .leading, spacing: 4) { @@ -706,6 +720,9 @@ struct VoiceWakeSettings: View { text: self.$state.voiceWakeForwardCommand, axis: .vertical) .textFieldStyle(.roundedBorder) + .onChange(of: self.state.voiceWakeForwardCommand) { _, _ in + self.forwardStatus = .idle + } Text( "${text} is replaced with the transcript." + "\nIt is also piped to stdin if you prefer $(cat).") @@ -725,11 +742,54 @@ struct VoiceWakeSettings: View { } } + private var forwardStatusRow: some View { + HStack(spacing: 10) { + switch self.forwardStatus { + case .idle: + Image(systemName: "circle.dashed") + .foregroundStyle(.secondary) + case .checking: + ProgressView().controlSize(.small) + case .ok: + Image(systemName: "checkmark.circle.fill").foregroundStyle(.green) + case let .failed(message): + Image(systemName: "exclamationmark.triangle.fill").foregroundStyle(.yellow) + .help(message) + } + + Button("Check connection") { + Task { await self.checkForwardConnection() } + } + .disabled(self.state.voiceWakeForwardTarget.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + + if case let .failed(message) = self.forwardStatus { + Text(message) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(5) + } + } + } + private var levelLabel: String { let db = (meterLevel * 50) - 50 return String(format: "%.0f dB", db) } + private func checkForwardConnection() async { + self.forwardStatus = .checking + let config = AppStateStore.shared.voiceWakeForwardConfig + let result = await VoiceWakeForwarder.checkConnection(config: config) + await MainActor.run { + switch result { + case .success: + self.forwardStatus = .ok + case let .failure(error): + self.forwardStatus = .failed(error.localizedDescription) + } + } + } + @MainActor private func restartMeter() async { self.meterError = nil