diff --git a/apps/macos/Sources/Clawdis/AppState.swift b/apps/macos/Sources/Clawdis/AppState.swift index 30c3885cf..fa00a67a9 100644 --- a/apps/macos/Sources/Clawdis/AppState.swift +++ b/apps/macos/Sources/Clawdis/AppState.swift @@ -88,14 +88,6 @@ final class AppState: ObservableObject { 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) } } @@ -172,11 +164,6 @@ final class AppState: ObservableObject { 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.voicePushToTalkEnabled = UserDefaults.standard .object(forKey: voicePushToTalkEnabledKey) as? Bool ?? false @@ -317,21 +304,9 @@ 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 diff --git a/apps/macos/Sources/Clawdis/Constants.swift b/apps/macos/Sources/Clawdis/Constants.swift index 9b115f511..8de1bdabf 100644 --- a/apps/macos/Sources/Clawdis/Constants.swift +++ b/apps/macos/Sources/Clawdis/Constants.swift @@ -16,11 +16,6 @@ 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 voicePushToTalkEnabledKey = "clawdis.voicePushToTalkEnabled" let iconOverrideKey = "clawdis.iconOverride" @@ -36,6 +31,5 @@ let heartbeatsEnabledKey = "clawdis.heartbeatsEnabled" let voiceWakeSupported: Bool = ProcessInfo.processInfo.operatingSystemVersion.majorVersion >= 26 let cliHelperSearchPaths = ["/usr/local/bin", "/opt/homebrew/bin"] let defaultVoiceWakeForwardCommand = "clawdis-mac agent --message \"${text}\" --thinking low --session main --deliver" -let defaultVoiceWakeForwardPort = 22 // Allow enough time for remote agent responses (LLM replies often take >10s). let defaultVoiceWakeForwardTimeout: TimeInterval = 30 diff --git a/apps/macos/Sources/Clawdis/Utilities.swift b/apps/macos/Sources/Clawdis/Utilities.swift index 5c018c000..ccf0baff1 100644 --- a/apps/macos/Sources/Clawdis/Utilities.swift +++ b/apps/macos/Sources/Clawdis/Utilities.swift @@ -501,7 +501,11 @@ enum CommandResolver { } private static func sanitizedTarget(_ raw: String) -> String { - VoiceWakeForwarder.sanitizedTarget(raw) + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.hasPrefix("ssh ") { + return trimmed.replacingOccurrences(of: "ssh ", with: "").trimmingCharacters(in: .whitespacesAndNewlines) + } + return trimmed } private static func shellQuote(_ text: String) -> String { diff --git a/apps/macos/Sources/Clawdis/VoiceWakeForwarder.swift b/apps/macos/Sources/Clawdis/VoiceWakeForwarder.swift index 1f918776b..be8980d04 100644 --- a/apps/macos/Sources/Clawdis/VoiceWakeForwarder.swift +++ b/apps/macos/Sources/Clawdis/VoiceWakeForwarder.swift @@ -3,8 +3,6 @@ import OSLog struct VoiceWakeForwardConfig: Sendable { let enabled: Bool - let target: String - let identityPath: String let commandTemplate: String let timeout: TimeInterval } @@ -30,13 +28,11 @@ enum VoiceWakeForwarder { } enum VoiceWakeForwardError: LocalizedError, Equatable { - case invalidTarget case rpcFailed(String) case disabled var errorDescription: String? { switch self { - case .invalidTarget: return "Missing or invalid target" case let .rpcFailed(message): return message case .disabled: return "Voice wake forwarding disabled" } @@ -72,7 +68,6 @@ enum VoiceWakeForwarder { static func checkConnection(config: VoiceWakeForwardConfig) async -> Result { guard config.enabled else { return .failure(.disabled) } - guard !self.sanitizedTarget(config.target).isEmpty else { return .failure(.invalidTarget) } let status = await AgentRPC.shared.status() if status.ok { return .success(()) } return .failure(.rpcFailed(status.error ?? "agent rpc unreachable")) @@ -84,42 +79,6 @@ enum VoiceWakeForwarder { return "'\(replaced)'" } - static func parse(target: String) -> (user: String?, host: String, port: Int)? { - guard !target.isEmpty else { return nil } - var remainder = target - if remainder.hasPrefix("ssh ") { - remainder = remainder.replacingOccurrences(of: "ssh ", with: "") - } - remainder = remainder.trimmingCharacters(in: .whitespacesAndNewlines) - var user: String? - if let at = remainder.firstIndex(of: "@") { - user = String(remainder[.. String { - let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) - if trimmed.hasPrefix("ssh ") { - return trimmed.replacingOccurrences(of: "ssh ", with: "").trimmingCharacters(in: .whitespacesAndNewlines) - } - return trimmed - } - // MARK: - Template parsing struct ForwardOptions { @@ -131,7 +90,7 @@ enum VoiceWakeForwarder { private static func parseCommandTemplate(_ template: String) -> ForwardOptions { var options = ForwardOptions() - let parts = template.split(whereSeparator: { $0.isWhitespace }).map(String.init) + let parts = self.tokenize(template) var idx = 0 while idx < parts.count { let part = parts[idx] @@ -157,6 +116,49 @@ enum VoiceWakeForwarder { return options } + private static func tokenize(_ text: String) -> [String] { + var tokens: [String] = [] + var current = "" + var quote: Character? + var escapeNext = false + + func flush() { + if !current.isEmpty { + tokens.append(current) + current = "" + } + } + + for ch in text { + if escapeNext { + current.append(ch) + escapeNext = false + continue + } + if ch == "\\" && quote == "\"" { + escapeNext = true + continue + } + if ch == "\"" || ch == "'" { + if quote == ch { + quote = nil + } else if quote == nil { + quote = ch + } else { + current.append(ch) + } + continue + } + if ch.isWhitespace && quote == nil { + flush() + continue + } + current.append(ch) + } + flush() + return tokens + } + #if DEBUG static func _testParseCommandTemplate(_ template: String) -> ForwardOptions { self.parseCommandTemplate(template) diff --git a/apps/macos/Sources/Clawdis/VoiceWakeOverlay.swift b/apps/macos/Sources/Clawdis/VoiceWakeOverlay.swift index 1baf4a26e..78cbbfa80 100644 --- a/apps/macos/Sources/Clawdis/VoiceWakeOverlay.swift +++ b/apps/macos/Sources/Clawdis/VoiceWakeOverlay.swift @@ -125,7 +125,7 @@ final class VoiceWakeOverlayController: ObservableObject { self.model.isSending = true let payload = VoiceWakeForwarder.prefixedTranscript(text) - self.logger.log(level: .info, "overlay sendNow forwarding len=\(payload.count, privacy: .public) target=\(forwardConfig.target, privacy: .public)") + self.logger.log(level: .info, "overlay sendNow forwarding len=\(payload.count, privacy: .public)") Task.detached { await VoiceWakeForwarder.forward(transcript: payload, config: forwardConfig) } diff --git a/apps/macos/Tests/ClawdisIPCTests/VoiceWakeForwarderTests.swift b/apps/macos/Tests/ClawdisIPCTests/VoiceWakeForwarderTests.swift index 9b7d91290..f46db3d36 100644 --- a/apps/macos/Tests/ClawdisIPCTests/VoiceWakeForwarderTests.swift +++ b/apps/macos/Tests/ClawdisIPCTests/VoiceWakeForwarderTests.swift @@ -2,20 +2,6 @@ import Testing @testable import Clawdis @Suite(.serialized) struct VoiceWakeForwarderTests { - @Test func parsesUserHostPort() { - let parsed = VoiceWakeForwarder.parse(target: "user@example.local:2222") - #expect(parsed?.user == "user") - #expect(parsed?.host == "example.local") - #expect(parsed?.port == 2222) - } - - @Test func parsesHostOnlyDefaultsPort() { - let parsed = VoiceWakeForwarder.parse(target: "primary.local") - #expect(parsed?.user == nil) - #expect(parsed?.host == "primary.local") - #expect(parsed?.port == defaultVoiceWakeForwardPort) - } - @Test func shellEscapeHandlesQuotesAndParens() { let text = "Debug test works (and a funny pun)" let escaped = VoiceWakeForwarder.shellEscape(text) @@ -52,8 +38,12 @@ import Testing #expect(opts.to == nil) } - @Test func sanitizedTargetStripsSshPrefix() { - let trimmed = VoiceWakeForwarder.sanitizedTarget("ssh user@box:22 ") - #expect(trimmed == "user@box:22") + @Test func parsesCommandTemplateWithQuotedValues() { + let opts = VoiceWakeForwarder._testParseCommandTemplate( + "clawdis-mac agent --session \"team chat\" --thinking \"deep focus\" --to \"+1 555 1212\" --message \"${text}\"") + #expect(opts.session == "team chat") + #expect(opts.thinking == "deep focus") + #expect(opts.deliver == true) + #expect(opts.to == "+1 555 1212") } }