VoiceWake: add SSH connectivity check UI

This commit is contained in:
Peter Steinberger
2025-12-07 02:03:25 +01:00
parent b27f0dd490
commit 374472deda
2 changed files with 110 additions and 0 deletions

View File

@@ -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<Void, VoiceWakeForwardError> {
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}") {

View File

@@ -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