VoiceWake: add SSH connectivity check UI
This commit is contained in:
@@ -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}") {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user