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) }
|
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 isWorking: Bool = false
|
||||||
@Published var earBoostActive: 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.voiceWakeLocaleID = UserDefaults.standard.string(forKey: voiceWakeLocaleKey) ?? Locale.current.identifier
|
||||||
self.voiceWakeAdditionalLocaleIDs = UserDefaults.standard
|
self.voiceWakeAdditionalLocaleIDs = UserDefaults.standard
|
||||||
.stringArray(forKey: voiceWakeAdditionalLocalesKey) ?? []
|
.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) {
|
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
|
@MainActor
|
||||||
enum AppActivationPolicy {
|
enum AppActivationPolicy {
|
||||||
static func apply(showDockIcon: Bool) {
|
static func apply(showDockIcon: Bool) {
|
||||||
|
|||||||
@@ -12,6 +12,16 @@ let defaultVoiceWakeTriggers = ["clawd", "claude"]
|
|||||||
let voiceWakeMicKey = "clawdis.voiceWakeMicID"
|
let voiceWakeMicKey = "clawdis.voiceWakeMicID"
|
||||||
let voiceWakeLocaleKey = "clawdis.voiceWakeLocaleID"
|
let voiceWakeLocaleKey = "clawdis.voiceWakeLocaleID"
|
||||||
let voiceWakeAdditionalLocalesKey = "clawdis.voiceWakeAdditionalLocaleIDs"
|
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 modelCatalogPathKey = "clawdis.modelCatalogPath"
|
||||||
let modelCatalogReloadKey = "clawdis.modelCatalogReload"
|
let modelCatalogReloadKey = "clawdis.modelCatalogReload"
|
||||||
let voiceWakeSupported: Bool = ProcessInfo.processInfo.operatingSystemVersion.majorVersion >= 26
|
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
|
@State private var selectedTab: SettingsTab = .general
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
TabView(selection: self.$selectedTab) {
|
ScrollView(.vertical) {
|
||||||
GeneralSettings(state: self.state)
|
TabView(selection: self.$selectedTab) {
|
||||||
.tabItem { Label("General", systemImage: "gearshape") }
|
GeneralSettings(state: self.state)
|
||||||
.tag(SettingsTab.general)
|
.tabItem { Label("General", systemImage: "gearshape") }
|
||||||
|
.tag(SettingsTab.general)
|
||||||
|
|
||||||
VoiceWakeSettings(state: self.state)
|
VoiceWakeSettings(state: self.state)
|
||||||
.tabItem { Label("Voice Wake", systemImage: "waveform.circle") }
|
.tabItem { Label("Voice Wake", systemImage: "waveform.circle") }
|
||||||
.tag(SettingsTab.voiceWake)
|
.tag(SettingsTab.voiceWake)
|
||||||
|
|
||||||
ConfigSettings()
|
ConfigSettings()
|
||||||
.tabItem { Label("Config", systemImage: "slider.horizontal.3") }
|
.tabItem { Label("Config", systemImage: "slider.horizontal.3") }
|
||||||
.tag(SettingsTab.config)
|
.tag(SettingsTab.config)
|
||||||
|
|
||||||
PermissionsSettings(
|
PermissionsSettings(
|
||||||
status: self.permissionMonitor.status,
|
status: self.permissionMonitor.status,
|
||||||
refresh: self.refreshPerms,
|
refresh: self.refreshPerms,
|
||||||
showOnboarding: { OnboardingController.shared.show() })
|
showOnboarding: { OnboardingController.shared.show() })
|
||||||
.tabItem { Label("Permissions", systemImage: "lock.shield") }
|
.tabItem { Label("Permissions", systemImage: "lock.shield") }
|
||||||
.tag(SettingsTab.permissions)
|
.tag(SettingsTab.permissions)
|
||||||
|
|
||||||
SessionsSettings()
|
SessionsSettings()
|
||||||
.tabItem { Label("Sessions", systemImage: "clock.arrow.circlepath") }
|
.tabItem { Label("Sessions", systemImage: "clock.arrow.circlepath") }
|
||||||
.tag(SettingsTab.sessions)
|
.tag(SettingsTab.sessions)
|
||||||
|
|
||||||
ToolsSettings()
|
ToolsSettings()
|
||||||
.tabItem { Label("Tools", systemImage: "wrench.and.screwdriver") }
|
.tabItem { Label("Tools", systemImage: "wrench.and.screwdriver") }
|
||||||
.tag(SettingsTab.tools)
|
.tag(SettingsTab.tools)
|
||||||
|
|
||||||
if self.state.debugPaneEnabled {
|
if self.state.debugPaneEnabled {
|
||||||
DebugSettings()
|
DebugSettings()
|
||||||
.tabItem { Label("Debug", systemImage: "ant") }
|
.tabItem { Label("Debug", systemImage: "ant") }
|
||||||
.tag(SettingsTab.debug)
|
.tag(SettingsTab.debug)
|
||||||
|
}
|
||||||
|
|
||||||
|
AboutSettings()
|
||||||
|
.tabItem { Label("About", systemImage: "info.circle") }
|
||||||
|
.tag(SettingsTab.about)
|
||||||
}
|
}
|
||||||
|
.padding(.horizontal, 28)
|
||||||
AboutSettings()
|
.padding(.vertical, 22)
|
||||||
.tabItem { Label("About", systemImage: "info.circle") }
|
.frame(maxWidth: .infinity, alignment: .topLeading)
|
||||||
.tag(SettingsTab.about)
|
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 28)
|
.frame(minWidth: SettingsTab.windowWidth, minHeight: SettingsTab.windowHeight)
|
||||||
.padding(.vertical, 22)
|
|
||||||
.frame(width: SettingsTab.windowWidth, height: SettingsTab.windowHeight, alignment: .topLeading)
|
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||||
.onReceive(NotificationCenter.default.publisher(for: .clawdisSelectSettingsTab)) { note in
|
.onReceive(NotificationCenter.default.publisher(for: .clawdisSelectSettingsTab)) { note in
|
||||||
if let tab = note.object as? SettingsTab {
|
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 {
|
if matched, !text.isEmpty {
|
||||||
self.stop()
|
self.stop()
|
||||||
AppStateStore.shared.triggerVoiceEars()
|
AppStateStore.shared.triggerVoiceEars()
|
||||||
|
let config = AppStateStore.shared.voiceWakeForwardConfig
|
||||||
|
Task.detached {
|
||||||
|
await VoiceWakeForwarder.forward(transcript: text, config: config)
|
||||||
|
}
|
||||||
onUpdate(.detected(text))
|
onUpdate(.detected(text))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -248,6 +252,7 @@ struct VoiceWakeSettings: View {
|
|||||||
@State private var meterError: String?
|
@State private var meterError: String?
|
||||||
private let meter = MicLevelMonitor()
|
private let meter = MicLevelMonitor()
|
||||||
@State private var availableLocales: [Locale] = []
|
@State private var availableLocales: [Locale] = []
|
||||||
|
@State private var showForwardAdvanced = false
|
||||||
|
|
||||||
private struct IndexedWord: Identifiable {
|
private struct IndexedWord: Identifiable {
|
||||||
let id: Int
|
let id: Int
|
||||||
@@ -276,6 +281,8 @@ struct VoiceWakeSettings: View {
|
|||||||
self.micPicker
|
self.micPicker
|
||||||
self.levelMeter
|
self.levelMeter
|
||||||
|
|
||||||
|
self.forwardSection
|
||||||
|
|
||||||
self.testCard
|
self.testCard
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
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 {
|
private var levelLabel: String {
|
||||||
let db = (meterLevel * 50) - 50
|
let db = (meterLevel * 50) - 50
|
||||||
return String(format: "%.0f dB", db)
|
return String(format: "%.0f dB", db)
|
||||||
|
|||||||
Reference in New Issue
Block a user