VoiceWake: add SSH forward target

This commit is contained in:
Peter Steinberger
2025-12-07 01:53:33 +01:00
parent 6355113af9
commit cf0f44823a
5 changed files with 268 additions and 33 deletions

View File

@@ -59,6 +59,22 @@ final class AppState: ObservableObject {
didSet { UserDefaults.standard.set(self.voiceWakeAdditionalLocaleIDs, forKey: voiceWakeAdditionalLocalesKey) }
}
@Published var voiceWakeForwardEnabled: Bool {
didSet { UserDefaults.standard.set(self.voiceWakeForwardEnabled, forKey: voiceWakeForwardEnabledKey) }
}
@Published var voiceWakeForwardTarget: String {
didSet { UserDefaults.standard.set(self.voiceWakeForwardTarget, forKey: voiceWakeForwardTargetKey) }
}
@Published var voiceWakeForwardIdentity: String {
didSet { UserDefaults.standard.set(self.voiceWakeForwardIdentity, forKey: voiceWakeForwardIdentityKey) }
}
@Published var voiceWakeForwardCommand: String {
didSet { UserDefaults.standard.set(self.voiceWakeForwardCommand, forKey: voiceWakeForwardCommandKey) }
}
@Published var isWorking: Bool = false
@Published var earBoostActive: Bool = false
@@ -79,6 +95,13 @@ final class AppState: ObservableObject {
self.voiceWakeLocaleID = UserDefaults.standard.string(forKey: voiceWakeLocaleKey) ?? Locale.current.identifier
self.voiceWakeAdditionalLocaleIDs = UserDefaults.standard
.stringArray(forKey: voiceWakeAdditionalLocalesKey) ?? []
self.voiceWakeForwardEnabled = UserDefaults.standard.bool(forKey: voiceWakeForwardEnabledKey)
let legacyTarget = Self.legacyTargetString()
self.voiceWakeForwardTarget = UserDefaults.standard
.string(forKey: voiceWakeForwardTargetKey) ?? legacyTarget
self.voiceWakeForwardIdentity = UserDefaults.standard.string(forKey: voiceWakeForwardIdentityKey) ?? ""
self.voiceWakeForwardCommand = UserDefaults.standard
.string(forKey: voiceWakeForwardCommandKey) ?? defaultVoiceWakeForwardCommand
}
func triggerVoiceEars(ttl: TimeInterval = 5) {
@@ -110,6 +133,27 @@ enum AppStateStore {
}
}
extension AppState {
var voiceWakeForwardConfig: VoiceWakeForwardConfig {
VoiceWakeForwardConfig(
enabled: self.voiceWakeForwardEnabled,
target: self.voiceWakeForwardTarget,
identityPath: self.voiceWakeForwardIdentity,
commandTemplate: self.voiceWakeForwardCommand,
timeout: defaultVoiceWakeForwardTimeout)
}
private static func legacyTargetString() -> String {
let host = UserDefaults.standard.string(forKey: voiceWakeForwardHostKey) ?? ""
let user = UserDefaults.standard.string(forKey: voiceWakeForwardUserKey) ?? ""
let savedPort = UserDefaults.standard.integer(forKey: voiceWakeForwardPortKey)
let port = savedPort == 0 ? defaultVoiceWakeForwardPort : savedPort
let userPrefix = user.isEmpty ? "" : "\(user)@"
let portSuffix = host.isEmpty ? "" : ":\(port)"
return "\(userPrefix)\(host)\(portSuffix)"
}
}
@MainActor
enum AppActivationPolicy {
static func apply(showDockIcon: Bool) {

View File

@@ -12,6 +12,16 @@ let defaultVoiceWakeTriggers = ["clawd", "claude"]
let voiceWakeMicKey = "clawdis.voiceWakeMicID"
let voiceWakeLocaleKey = "clawdis.voiceWakeLocaleID"
let voiceWakeAdditionalLocalesKey = "clawdis.voiceWakeAdditionalLocaleIDs"
let voiceWakeForwardEnabledKey = "clawdis.voiceWakeForwardEnabled"
let voiceWakeForwardTargetKey = "clawdis.voiceWakeForwardTarget"
let voiceWakeForwardHostKey = "clawdis.voiceWakeForwardHost"
let voiceWakeForwardUserKey = "clawdis.voiceWakeForwardUser"
let voiceWakeForwardPortKey = "clawdis.voiceWakeForwardPort"
let voiceWakeForwardIdentityKey = "clawdis.voiceWakeForwardIdentity"
let voiceWakeForwardCommandKey = "clawdis.voiceWakeForwardCommand"
let modelCatalogPathKey = "clawdis.modelCatalogPath"
let modelCatalogReloadKey = "clawdis.modelCatalogReload"
let voiceWakeSupported: Bool = ProcessInfo.processInfo.operatingSystemVersion.majorVersion >= 26
let defaultVoiceWakeForwardCommand = "clawdis-mac agent --message \"${text}\" --thinking low"
let defaultVoiceWakeForwardPort = 22
let defaultVoiceWakeForwardTimeout: TimeInterval = 6

View File

@@ -7,47 +7,50 @@ struct SettingsRootView: View {
@State private var selectedTab: SettingsTab = .general
var body: some View {
TabView(selection: self.$selectedTab) {
GeneralSettings(state: self.state)
.tabItem { Label("General", systemImage: "gearshape") }
.tag(SettingsTab.general)
ScrollView(.vertical) {
TabView(selection: self.$selectedTab) {
GeneralSettings(state: self.state)
.tabItem { Label("General", systemImage: "gearshape") }
.tag(SettingsTab.general)
VoiceWakeSettings(state: self.state)
.tabItem { Label("Voice Wake", systemImage: "waveform.circle") }
.tag(SettingsTab.voiceWake)
VoiceWakeSettings(state: self.state)
.tabItem { Label("Voice Wake", systemImage: "waveform.circle") }
.tag(SettingsTab.voiceWake)
ConfigSettings()
.tabItem { Label("Config", systemImage: "slider.horizontal.3") }
.tag(SettingsTab.config)
ConfigSettings()
.tabItem { Label("Config", systemImage: "slider.horizontal.3") }
.tag(SettingsTab.config)
PermissionsSettings(
status: self.permissionMonitor.status,
refresh: self.refreshPerms,
showOnboarding: { OnboardingController.shared.show() })
.tabItem { Label("Permissions", systemImage: "lock.shield") }
.tag(SettingsTab.permissions)
PermissionsSettings(
status: self.permissionMonitor.status,
refresh: self.refreshPerms,
showOnboarding: { OnboardingController.shared.show() })
.tabItem { Label("Permissions", systemImage: "lock.shield") }
.tag(SettingsTab.permissions)
SessionsSettings()
.tabItem { Label("Sessions", systemImage: "clock.arrow.circlepath") }
.tag(SettingsTab.sessions)
SessionsSettings()
.tabItem { Label("Sessions", systemImage: "clock.arrow.circlepath") }
.tag(SettingsTab.sessions)
ToolsSettings()
.tabItem { Label("Tools", systemImage: "wrench.and.screwdriver") }
.tag(SettingsTab.tools)
ToolsSettings()
.tabItem { Label("Tools", systemImage: "wrench.and.screwdriver") }
.tag(SettingsTab.tools)
if self.state.debugPaneEnabled {
DebugSettings()
.tabItem { Label("Debug", systemImage: "ant") }
.tag(SettingsTab.debug)
if self.state.debugPaneEnabled {
DebugSettings()
.tabItem { Label("Debug", systemImage: "ant") }
.tag(SettingsTab.debug)
}
AboutSettings()
.tabItem { Label("About", systemImage: "info.circle") }
.tag(SettingsTab.about)
}
AboutSettings()
.tabItem { Label("About", systemImage: "info.circle") }
.tag(SettingsTab.about)
.padding(.horizontal, 28)
.padding(.vertical, 22)
.frame(maxWidth: .infinity, alignment: .topLeading)
}
.padding(.horizontal, 28)
.padding(.vertical, 22)
.frame(width: SettingsTab.windowWidth, height: SettingsTab.windowHeight, alignment: .topLeading)
.frame(minWidth: SettingsTab.windowWidth, minHeight: SettingsTab.windowHeight)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.onReceive(NotificationCenter.default.publisher(for: .clawdisSelectSettingsTab)) { note in
if let tab = note.object as? SettingsTab {

View File

@@ -0,0 +1,121 @@
import Foundation
import OSLog
struct VoiceWakeForwardConfig: Sendable {
let enabled: Bool
let target: String
let identityPath: String
let commandTemplate: String
let timeout: TimeInterval
}
enum VoiceWakeForwarder {
private static let logger = Logger(subsystem: "com.steipete.clawdis", category: "voicewake.forward")
static func forward(transcript: String, config: VoiceWakeForwardConfig) async {
guard config.enabled else { return }
let destination = config.target.trimmingCharacters(in: .whitespacesAndNewlines)
guard let parsed = self.parse(target: destination) else {
self.logger.error("voice wake forward skipped: host missing")
return
}
let userHost = parsed.user.map { "\($0)@\(parsed.host)" } ?? parsed.host
var args: [String] = [
"-o", "BatchMode=yes",
"-o", "IdentitiesOnly=yes",
]
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(userHost)
let rendered = self.renderedCommand(template: config.commandTemplate, transcript: transcript)
args.append(contentsOf: ["sh", "-c", rendered])
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/ssh")
process.arguments = args
let input = Pipe()
process.standardInput = input
let output = Pipe()
process.standardOutput = output
process.standardError = output
do {
try process.run()
} catch {
self.logger.error("voice wake forward failed to start ssh: \(error.localizedDescription, privacy: .public)")
return
}
if let data = transcript.data(using: .utf8) {
input.fileHandleForWriting.write(data)
}
try? input.fileHandleForWriting.close()
await self.wait(process, timeout: config.timeout)
}
private static func renderedCommand(template: String, transcript: String) -> String {
let escaped = Self.shellEscape(transcript)
if template.contains("${text}") {
return template.replacingOccurrences(of: "${text}", with: escaped)
}
return template
}
private static func shellEscape(_ text: String) -> String {
// Single-quote based shell escaping.
let replaced = text.replacingOccurrences(of: "'", with: "'\\''")
return "'\(replaced)'"
}
private static func wait(_ process: Process, timeout: TimeInterval) async {
await withTaskGroup(of: Void.self) { group in
group.addTask {
process.waitUntilExit()
}
group.addTask {
let nanos = UInt64(max(timeout, 0.1) * 1_000_000_000)
try? await Task.sleep(nanoseconds: nanos)
if process.isRunning {
process.terminate()
}
}
_ = await group.next()
group.cancelAll()
}
if process.terminationStatus != 0 {
self.logger.debug("voice wake forward ssh exit=\(process.terminationStatus)")
}
}
private static func parse(target: String) -> (user: String?, host: String, port: Int)? {
guard !target.isEmpty else { return nil }
var remainder = target
var user: String?
if let at = remainder.firstIndex(of: "@") {
user = String(remainder[..<at])
remainder = String(remainder[remainder.index(after: at)...])
}
var host = remainder
var port = defaultVoiceWakeForwardPort
if let colon = remainder.lastIndex(of: ":"), colon != remainder.startIndex {
let p = String(remainder[remainder.index(after: colon)...])
if let parsedPort = Int(p) {
port = parsedPort
host = String(remainder[..<colon])
}
}
host = host.trimmingCharacters(in: .whitespacesAndNewlines)
guard !host.isEmpty else { return nil }
return (user: user?.trimmingCharacters(in: .whitespacesAndNewlines), host: host, port: port)
}
}

View File

@@ -176,6 +176,10 @@ final class VoiceWakeTester {
if matched, !text.isEmpty {
self.stop()
AppStateStore.shared.triggerVoiceEars()
let config = AppStateStore.shared.voiceWakeForwardConfig
Task.detached {
await VoiceWakeForwarder.forward(transcript: text, config: config)
}
onUpdate(.detected(text))
return
}
@@ -248,6 +252,7 @@ struct VoiceWakeSettings: View {
@State private var meterError: String?
private let meter = MicLevelMonitor()
@State private var availableLocales: [Locale] = []
@State private var showForwardAdvanced = false
private struct IndexedWord: Identifiable {
let id: Int
@@ -276,6 +281,8 @@ struct VoiceWakeSettings: View {
self.micPicker
self.levelMeter
self.forwardSection
self.testCard
VStack(alignment: .leading, spacing: 8) {
@@ -668,6 +675,56 @@ struct VoiceWakeSettings: View {
}
}
private var forwardSection: some View {
VStack(alignment: .leading, spacing: 8) {
Toggle(isOn: self.$state.voiceWakeForwardEnabled) {
Text("Forward wake to host (SSH)")
}
if self.state.voiceWakeForwardEnabled {
VStack(alignment: .leading, spacing: 8) {
LabeledContent("SSH target") {
TextField("steipete@peters-mac-studio-1", text: self.$state.voiceWakeForwardTarget)
.textFieldStyle(.roundedBorder)
.frame(width: 280)
}
DisclosureGroup(isExpanded: self.$showForwardAdvanced) {
VStack(alignment: .leading, spacing: 10) {
LabeledContent("Identity file") {
TextField(
"/Users/you/.ssh/voicewake_ed25519",
text: self.$state.voiceWakeForwardIdentity)
.textFieldStyle(.roundedBorder)
.frame(width: 320)
}
VStack(alignment: .leading, spacing: 4) {
Text("Remote command template")
.font(.callout.weight(.semibold))
TextField(
"clawdis-mac agent --message \"${text}\" --thinking low",
text: self.$state.voiceWakeForwardCommand,
axis: .vertical)
.textFieldStyle(.roundedBorder)
Text(
"${text} is replaced with the transcript."
+ "\nIt is also piped to stdin if you prefer $(cat).")
.font(.footnote)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
}
.padding(.top, 4)
} label: {
Text("Advanced")
.font(.callout.weight(.semibold))
}
}
.transition(.opacity.combined(with: .move(edge: .top)))
}
}
}
private var levelLabel: String {
let db = (meterLevel * 50) - 50
return String(format: "%.0f dB", db)