From cf0f44823a1cf13b37aba5b611c1be9cd54a3f00 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 7 Dec 2025 01:53:33 +0100 Subject: [PATCH] VoiceWake: add SSH forward target --- apps/macos/Sources/Clawdis/AppState.swift | 44 +++++++ apps/macos/Sources/Clawdis/Constants.swift | 10 ++ .../Sources/Clawdis/SettingsRootView.swift | 69 +++++----- .../Sources/Clawdis/VoiceWakeForwarder.swift | 121 ++++++++++++++++++ .../Sources/Clawdis/VoiceWakeSettings.swift | 57 +++++++++ 5 files changed, 268 insertions(+), 33 deletions(-) create mode 100644 apps/macos/Sources/Clawdis/VoiceWakeForwarder.swift diff --git a/apps/macos/Sources/Clawdis/AppState.swift b/apps/macos/Sources/Clawdis/AppState.swift index c4f07a7e0..a49f61055 100644 --- a/apps/macos/Sources/Clawdis/AppState.swift +++ b/apps/macos/Sources/Clawdis/AppState.swift @@ -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) { diff --git a/apps/macos/Sources/Clawdis/Constants.swift b/apps/macos/Sources/Clawdis/Constants.swift index bc8e6328c..2eea34b27 100644 --- a/apps/macos/Sources/Clawdis/Constants.swift +++ b/apps/macos/Sources/Clawdis/Constants.swift @@ -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 diff --git a/apps/macos/Sources/Clawdis/SettingsRootView.swift b/apps/macos/Sources/Clawdis/SettingsRootView.swift index 86af1400c..a20de367c 100644 --- a/apps/macos/Sources/Clawdis/SettingsRootView.swift +++ b/apps/macos/Sources/Clawdis/SettingsRootView.swift @@ -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 { diff --git a/apps/macos/Sources/Clawdis/VoiceWakeForwarder.swift b/apps/macos/Sources/Clawdis/VoiceWakeForwarder.swift new file mode 100644 index 000000000..3826c6d1f --- /dev/null +++ b/apps/macos/Sources/Clawdis/VoiceWakeForwarder.swift @@ -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[..