VoiceWake: add SSH forward target
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
121
apps/macos/Sources/Clawdis/VoiceWakeForwarder.swift
Normal file
121
apps/macos/Sources/Clawdis/VoiceWakeForwarder.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user