From 679ced7840680dad86d3d875d85c92a4da7386cc Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 12 Dec 2025 16:09:31 +0000 Subject: [PATCH] mac: remove voice wake forward pref --- apps/macos/Sources/Clawdis/AgentRPC.swift | 20 +-- apps/macos/Sources/Clawdis/AppState.swift | 33 ----- apps/macos/Sources/Clawdis/Constants.swift | 5 - .../Sources/Clawdis/ControlSocketServer.swift | 6 + apps/macos/Sources/Clawdis/DebugActions.swift | 44 +------ .../Sources/Clawdis/VoicePushToTalk.swift | 15 +-- .../Clawdis/VoiceSessionCoordinator.swift | 15 +-- .../Clawdis/VoiceWakeForwardSection.swift | 119 ----------------- .../Sources/Clawdis/VoiceWakeForwarder.swift | 122 ++---------------- .../Sources/Clawdis/VoiceWakeOverlay.swift | 10 +- .../Sources/Clawdis/VoiceWakeRuntime.swift | 8 +- .../Sources/Clawdis/VoiceWakeTester.swift | 4 +- .../CommandResolverTests.swift | 55 ++++++++ .../VoiceWakeForwarderTests.swift | 32 +---- docs/mac/voicewake.md | 3 + 15 files changed, 101 insertions(+), 390 deletions(-) delete mode 100644 apps/macos/Sources/Clawdis/VoiceWakeForwardSection.swift diff --git a/apps/macos/Sources/Clawdis/AgentRPC.swift b/apps/macos/Sources/Clawdis/AgentRPC.swift index 01362dc3d..87d0540c6 100644 --- a/apps/macos/Sources/Clawdis/AgentRPC.swift +++ b/apps/macos/Sources/Clawdis/AgentRPC.swift @@ -2,7 +2,9 @@ import Foundation import OSLog struct ControlRequestParams: @unchecked Sendable { - let raw: [String: AnyHashable] + /// Heterogeneous JSON-ish params (Bool/String/Int/Double/[...]/[String:...]). + /// `@unchecked Sendable` is intentional: values are treated as immutable payloads. + let raw: [String: Any] } actor AgentRPC { @@ -35,7 +37,7 @@ actor AgentRPC { do { _ = try await self.controlRequest( method: "set-heartbeats", - params: ControlRequestParams(raw: ["enabled": AnyHashable(enabled)])) + params: ControlRequestParams(raw: ["enabled": enabled])) return true } catch { self.logger.error("setHeartbeatsEnabled failed \(error.localizedDescription, privacy: .public)") @@ -65,13 +67,13 @@ actor AgentRPC { to: String?) async -> (ok: Bool, text: String?, error: String?) { do { - let params: [String: AnyHashable] = [ - "message": AnyHashable(text), - "sessionId": AnyHashable(session), - "thinking": AnyHashable(thinking ?? "default"), - "deliver": AnyHashable(deliver), - "to": AnyHashable(to ?? ""), - "idempotencyKey": AnyHashable(UUID().uuidString), + let params: [String: Any] = [ + "message": text, + "sessionId": session, + "thinking": thinking ?? "default", + "deliver": deliver, + "to": to ?? "", + "idempotencyKey": UUID().uuidString, ] _ = try await self.controlRequest(method: "agent", params: ControlRequestParams(raw: params)) return (true, nil, nil) diff --git a/apps/macos/Sources/Clawdis/AppState.swift b/apps/macos/Sources/Clawdis/AppState.swift index 2f4eb007d..d363b07fe 100644 --- a/apps/macos/Sources/Clawdis/AppState.swift +++ b/apps/macos/Sources/Clawdis/AppState.swift @@ -108,18 +108,6 @@ final class AppState: ObservableObject { forKey: voiceWakeAdditionalLocalesKey) } } } - @Published var voiceWakeForwardEnabled: Bool { - didSet { self.ifNotPreview { UserDefaults.standard.set( - self.voiceWakeForwardEnabled, - forKey: voiceWakeForwardEnabledKey) } } - } - - @Published var voiceWakeForwardCommand: String { - didSet { self.ifNotPreview { UserDefaults.standard.set( - self.voiceWakeForwardCommand, - forKey: voiceWakeForwardCommandKey) } } - } - @Published var voicePushToTalkEnabled: Bool { didSet { self.ifNotPreview { UserDefaults.standard.set( self.voicePushToTalkEnabled, @@ -210,18 +198,8 @@ 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) self.voicePushToTalkEnabled = UserDefaults.standard .object(forKey: voicePushToTalkEnabledKey) as? Bool ?? false - - var storedForwardCommand = UserDefaults.standard - .string(forKey: voiceWakeForwardCommandKey) ?? defaultVoiceWakeForwardCommand - // Guard against older prefs missing flags; the forwarder depends on these for replies. - if !storedForwardCommand.contains("--deliver") || !storedForwardCommand.contains("--session") { - storedForwardCommand = defaultVoiceWakeForwardCommand - UserDefaults.standard.set(storedForwardCommand, forKey: voiceWakeForwardCommandKey) - } - self.voiceWakeForwardCommand = storedForwardCommand if let storedHeartbeats = UserDefaults.standard.object(forKey: heartbeatsEnabledKey) as? Bool { self.heartbeatsEnabled = storedHeartbeats } else { @@ -350,8 +328,6 @@ extension AppState { state.voiceWakeMicID = "BuiltInMic" state.voiceWakeLocaleID = Locale.current.identifier state.voiceWakeAdditionalLocaleIDs = ["en-US", "de-DE"] - state.voiceWakeForwardEnabled = true - state.voiceWakeForwardCommand = defaultVoiceWakeForwardCommand state.voicePushToTalkEnabled = false state.iconOverride = .system state.heartbeatsEnabled = true @@ -396,15 +372,6 @@ enum AppStateStore { } } -extension AppState { - var voiceWakeForwardConfig: VoiceWakeForwardConfig { - VoiceWakeForwardConfig( - enabled: self.voiceWakeForwardEnabled, - commandTemplate: self.voiceWakeForwardCommand, - timeout: defaultVoiceWakeForwardTimeout) - } -} - @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 c94dc6136..ccb270189 100644 --- a/apps/macos/Sources/Clawdis/Constants.swift +++ b/apps/macos/Sources/Clawdis/Constants.swift @@ -14,8 +14,6 @@ let defaultVoiceWakeTriggers = ["clawd", "claude"] let voiceWakeMicKey = "clawdis.voiceWakeMicID" let voiceWakeLocaleKey = "clawdis.voiceWakeLocaleID" let voiceWakeAdditionalLocalesKey = "clawdis.voiceWakeAdditionalLocaleIDs" -let voiceWakeForwardEnabledKey = "clawdis.voiceWakeForwardEnabled" -let voiceWakeForwardCommandKey = "clawdis.voiceWakeForwardCommand" let voicePushToTalkEnabledKey = "clawdis.voicePushToTalkEnabled" let iconOverrideKey = "clawdis.iconOverride" let connectionModeKey = "clawdis.connectionMode" @@ -31,6 +29,3 @@ let attachExistingGatewayOnlyKey = "clawdis.gateway.attachExistingOnly" 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" -// Allow enough time for remote agent responses (LLM replies often take >10s). -let defaultVoiceWakeForwardTimeout: TimeInterval = 30 diff --git a/apps/macos/Sources/Clawdis/ControlSocketServer.swift b/apps/macos/Sources/Clawdis/ControlSocketServer.swift index 686fc0f30..a4e01eb79 100644 --- a/apps/macos/Sources/Clawdis/ControlSocketServer.swift +++ b/apps/macos/Sources/Clawdis/ControlSocketServer.swift @@ -10,6 +10,11 @@ final actor ControlSocketServer { private let maxRequestBytes = 512 * 1024 private let allowedTeamIDs: Set = ["Y5PE65HELJ"] + private func disableSigPipe(fd: Int32) { + var one: Int32 = 1 + _ = setsockopt(fd, SOL_SOCKET, SO_NOSIGPIPE, &one, socklen_t(MemoryLayout.size(ofValue: one))) + } + func start() { // Already running guard self.listenFD == -1 else { return } @@ -75,6 +80,7 @@ final actor ControlSocketServer { var len: socklen_t = socklen_t(MemoryLayout.size) let client = accept(listenFD, &addr, &len) guard client >= 0 else { return } + self.disableSigPipe(fd: client) Task.detached { [weak self] in defer { close(client) } guard let self else { return } diff --git a/apps/macos/Sources/Clawdis/DebugActions.swift b/apps/macos/Sources/Clawdis/DebugActions.swift index 0b7a98bd8..3efa40449 100644 --- a/apps/macos/Sources/Clawdis/DebugActions.swift +++ b/apps/macos/Sources/Clawdis/DebugActions.swift @@ -63,45 +63,13 @@ enum DebugActions { This is a debug test from the Mac app. Reply with "Debug test works (and a funny pun)" \ if you received that. """ - let config = await MainActor.run { AppStateStore.shared.voiceWakeForwardConfig } - let shouldForward = config.enabled - - if shouldForward { - let result = await VoiceWakeForwarder.forward(transcript: message, config: config) - switch result { - case .success: - return .success("Forwarded. Await reply.") - case let .failure(error): - let detail = error.localizedDescription.trimmingCharacters(in: .whitespacesAndNewlines) - return .failure(.message("Forward failed: \(detail)")) - } - } - - do { - let status = await AgentRPC.shared.status() - if !status.ok { - try await AgentRPC.shared.start() - } - - let rpcResult = await AgentRPC.shared.send( - text: message, - thinking: "low", - session: "main", - deliver: true, - to: nil) - - if rpcResult.ok { - return .success("Sent locally via voice wake path.") - } else { - let reason = rpcResult.error?.trimmingCharacters(in: .whitespacesAndNewlines) - let detail = (reason?.isEmpty == false) - ? reason! - : "No error returned. Check logs or rpc output." - return .failure(.message("Local send failed: \(detail)")) - } - } catch { + let result = await VoiceWakeForwarder.forward(transcript: message) + switch result { + case .success: + return .success("Sent. Await reply.") + case let .failure(error): let detail = error.localizedDescription.trimmingCharacters(in: .whitespacesAndNewlines) - return .failure(.message("Local send failed: \(detail)")) + return .failure(.message("Send failed: \(detail)")) } } diff --git a/apps/macos/Sources/Clawdis/VoicePushToTalk.swift b/apps/macos/Sources/Clawdis/VoicePushToTalk.swift index e33eefe34..4988a64b8 100644 --- a/apps/macos/Sources/Clawdis/VoicePushToTalk.swift +++ b/apps/macos/Sources/Clawdis/VoicePushToTalk.swift @@ -100,7 +100,6 @@ actor VoicePushToTalk { private struct Config { let micID: String? let localeID: String? - let forwardConfig: VoiceWakeForwardConfig let triggerChime: VoiceWakeChime let sendChime: VoiceWakeChime } @@ -263,12 +262,6 @@ actor VoicePushToTalk { return (self.committed + self.volatile).trimmingCharacters(in: .whitespacesAndNewlines) }() let finalText = Self.join(self.adoptedPrefix, finalRecognized) - - let forward: VoiceWakeForwardConfig = if let cached = self.activeConfig?.forwardConfig { - cached - } else { - await MainActor.run { AppStateStore.shared.voiceWakeForwardConfig } - } let chime = finalText.isEmpty ? .none : (self.activeConfig?.sendChime ?? .none) let token = self.overlayToken @@ -279,18 +272,15 @@ actor VoicePushToTalk { VoiceSessionCoordinator.shared.finalize( token: token, text: finalText, - forwardConfig: forward, sendChime: chime, autoSendAfter: nil) VoiceSessionCoordinator.shared.sendNow(token: token, reason: reason) - } else if !finalText.isEmpty, forward.enabled { + } else if !finalText.isEmpty { if chime != .none { VoiceWakeChimePlayer.play(chime, reason: "ptt.fallback_send") } Task.detached { - await VoiceWakeForwarder.forward( - transcript: VoiceWakeForwarder.prefixedTranscript(finalText), - config: forward) + await VoiceWakeForwarder.forward(transcript: finalText) } } } @@ -319,7 +309,6 @@ actor VoicePushToTalk { return Config( micID: state.voiceWakeMicID.isEmpty ? nil : state.voiceWakeMicID, localeID: state.voiceWakeLocaleID, - forwardConfig: state.voiceWakeForwardConfig, triggerChime: state.voiceWakeTriggerChime, sendChime: state.voiceWakeSendChime) } diff --git a/apps/macos/Sources/Clawdis/VoiceSessionCoordinator.swift b/apps/macos/Sources/Clawdis/VoiceSessionCoordinator.swift index ff74cf630..f0c2dc8d1 100644 --- a/apps/macos/Sources/Clawdis/VoiceSessionCoordinator.swift +++ b/apps/macos/Sources/Clawdis/VoiceSessionCoordinator.swift @@ -14,7 +14,6 @@ final class VoiceSessionCoordinator: ObservableObject { var text: String var attributed: NSAttributedString? var isFinal: Bool - var forwardConfig: VoiceWakeForwardConfig? var sendChime: VoiceWakeChime var autoSendDelay: TimeInterval? } @@ -45,7 +44,6 @@ final class VoiceSessionCoordinator: ObservableObject { text: text, attributed: attributedText, isFinal: false, - forwardConfig: forwardEnabled ? AppStateStore.shared.voiceWakeForwardConfig : nil, sendChime: .none, autoSendDelay: nil) self.session = session @@ -69,7 +67,6 @@ final class VoiceSessionCoordinator: ObservableObject { func finalize( token: UUID, text: String, - forwardConfig: VoiceWakeForwardConfig, sendChime: VoiceWakeChime, autoSendAfter: TimeInterval?) { @@ -79,7 +76,6 @@ final class VoiceSessionCoordinator: ObservableObject { "coordinator finalize token=\(token.uuidString) len=\(text.count) autoSendAfter=\(autoSendAfter ?? -1)") self.session?.text = text self.session?.isFinal = true - self.session?.forwardConfig = forwardConfig self.session?.sendChime = sendChime self.session?.autoSendDelay = autoSendAfter @@ -87,7 +83,6 @@ final class VoiceSessionCoordinator: ObservableObject { VoiceWakeOverlayController.shared.presentFinal( token: token, transcript: text, - forwardConfig: forwardConfig, autoSendAfter: autoSendAfter, sendChime: sendChime, attributed: attributed) @@ -96,12 +91,6 @@ final class VoiceSessionCoordinator: ObservableObject { func sendNow(token: UUID, reason: String = "explicit") { guard let session, session.token == token else { return } let text = session.text.trimmingCharacters(in: .whitespacesAndNewlines) - guard let forward = session.forwardConfig, forward.enabled else { - self.logger.info("coordinator sendNow \(reason) no forward config -> dismiss") - VoiceWakeOverlayController.shared.dismiss(token: token, reason: .explicit, outcome: .empty) - self.clearSession() - return - } guard !text.isEmpty else { self.logger.info("coordinator sendNow \(reason) empty -> dismiss") VoiceWakeOverlayController.shared.dismiss(token: token, reason: .empty, outcome: .empty) @@ -110,9 +99,7 @@ final class VoiceSessionCoordinator: ObservableObject { } VoiceWakeOverlayController.shared.beginSendUI(token: token, sendChime: session.sendChime) Task.detached { - _ = await VoiceWakeForwarder.forward( - transcript: VoiceWakeForwarder.prefixedTranscript(text), - config: forward) + _ = await VoiceWakeForwarder.forward(transcript: text) } } diff --git a/apps/macos/Sources/Clawdis/VoiceWakeForwardSection.swift b/apps/macos/Sources/Clawdis/VoiceWakeForwardSection.swift deleted file mode 100644 index 2c808fcbc..000000000 --- a/apps/macos/Sources/Clawdis/VoiceWakeForwardSection.swift +++ /dev/null @@ -1,119 +0,0 @@ -import SwiftUI - -enum VoiceWakeForwardStatus: Equatable { - case idle - case checking - case ok - case failed(String) -} - -struct VoiceWakeForwardSection: View { - @Binding var enabled: Bool - @Binding var target: String - @Binding var identity: String - @Binding var command: String - @Binding var showAdvanced: Bool - @Binding var status: VoiceWakeForwardStatus - let onTest: () -> Void - let onChange: () -> Void - var showToggle: Bool = true - var title: String = "Forward wake to host (SSH)" - var subtitle: String = "Send wake transcripts to a remote Clawdis host." - - var body: some View { - VStack(alignment: .leading, spacing: 8) { - if self.showToggle { - Toggle(isOn: self.$enabled) { - Text(self.title) - } - } else { - Text(self.title) - .font(.callout.weight(.semibold)) - Text(self.subtitle) - .font(.footnote) - .foregroundStyle(.secondary) - } - - if self.enabled { - VStack(alignment: .leading, spacing: 8) { - HStack(spacing: 10) { - Text("SSH") - .font(.callout.weight(.semibold)) - .frame(width: 40, alignment: .leading) - TextField("steipete@peters-mac-studio-1", text: self.$target) - .textFieldStyle(.roundedBorder) - .frame(maxWidth: .infinity) - .onChange(of: self.target) { _, _ in - self.onChange() - } - self.statusIcon - .frame(width: 16, height: 16, alignment: .center) - Button("Test") { self.onTest() } - .disabled(self.target.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) - } - - if case let .failed(message) = self.status { - Text(message) - .font(.caption) - .foregroundStyle(.secondary) - .lineLimit(5) - } - - DisclosureGroup(isExpanded: self.$showAdvanced) { - VStack(alignment: .leading, spacing: 10) { - LabeledContent("Identity file") { - TextField( - "/Users/you/.ssh/voicewake_ed25519", - text: self.$identity) - .textFieldStyle(.roundedBorder) - .frame(width: 320) - .onChange(of: self.identity) { _, _ in - self.onChange() - } - } - - VStack(alignment: .leading, spacing: 4) { - Text("Remote command template") - .font(.callout.weight(.semibold)) - TextField( - "clawdis-mac agent --message \"${text}\" --thinking low", - text: self.$command, - axis: .vertical) - .textFieldStyle(.roundedBorder) - .onChange(of: self.command) { _, _ in - self.onChange() - } - 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 statusIcon: some View { - Group { - switch self.status { - case .idle: - Image(systemName: "circle.dashed").foregroundStyle(.secondary) - case .checking: - ProgressView().controlSize(.mini) - case .ok: - Image(systemName: "checkmark.circle.fill").foregroundStyle(.green) - case .failed: - Image(systemName: "exclamationmark.triangle.fill").foregroundStyle(.yellow) - } - } - } -} diff --git a/apps/macos/Sources/Clawdis/VoiceWakeForwarder.swift b/apps/macos/Sources/Clawdis/VoiceWakeForwarder.swift index 744a0c2f4..91e2e2576 100644 --- a/apps/macos/Sources/Clawdis/VoiceWakeForwarder.swift +++ b/apps/macos/Sources/Clawdis/VoiceWakeForwarder.swift @@ -1,12 +1,6 @@ import Foundation import OSLog -struct VoiceWakeForwardConfig: Sendable { - let enabled: Bool - let commandTemplate: String - let timeout: TimeInterval -} - enum VoiceWakeForwarder { private static let logger = Logger(subsystem: "com.steipete.clawdis", category: "voicewake.forward") @@ -28,35 +22,32 @@ enum VoiceWakeForwarder { """ } - static func clearCliCache() { - // Legacy no-op; CLI caching removed now that we rely on AgentRPC. - } - enum VoiceWakeForwardError: LocalizedError, Equatable { case rpcFailed(String) - case disabled var errorDescription: String? { switch self { case let .rpcFailed(message): message - case .disabled: "Voice wake forwarding disabled" } } } + struct ForwardOptions: Sendable { + var session: String = "main" + var thinking: String = "low" + var deliver: Bool = true + var to: String? + } + @discardableResult static func forward( transcript: String, - config: VoiceWakeForwardConfig) async -> Result + options: ForwardOptions = ForwardOptions()) async -> Result { - guard config.enabled else { return .failure(.disabled) } let payload = Self.prefixedTranscript(transcript) - let options = self.parseCommandTemplate(config.commandTemplate) - let thinking = options.thinking ?? "default" - let result = await AgentRPC.shared.send( text: payload, - thinking: thinking, + thinking: options.thinking, session: options.session, deliver: options.deliver, to: options.to) @@ -71,102 +62,9 @@ enum VoiceWakeForwarder { return .failure(.rpcFailed(message)) } - static func checkConnection(config: VoiceWakeForwardConfig) async -> Result { - guard config.enabled else { return .failure(.disabled) } + static func checkConnection() async -> Result { let status = await AgentRPC.shared.status() if status.ok { return .success(()) } return .failure(.rpcFailed(status.error ?? "agent rpc unreachable")) } - - static func shellEscape(_ text: String) -> String { - // Single-quote based shell escaping. - let replaced = text.replacingOccurrences(of: "'", with: "'\\''") - return "'\(replaced)'" - } - - // MARK: - Template parsing - - struct ForwardOptions { - var session: String = "main" - var thinking: String? = "low" - var deliver: Bool = true - var to: String? - } - - private static func parseCommandTemplate(_ template: String) -> ForwardOptions { - var options = ForwardOptions() - let parts = self.tokenize(template) - var idx = 0 - while idx < parts.count { - let part = parts[idx] - switch part { - case "--session", "--session-id": - if idx + 1 < parts.count { options.session = parts[idx + 1] } - idx += 1 - case "--thinking": - if idx + 1 < parts.count { options.thinking = parts[idx + 1] } - idx += 1 - case "--deliver": - options.deliver = true - case "--no-deliver": - options.deliver = false - case "--to": - if idx + 1 < parts.count { options.to = parts[idx + 1] } - idx += 1 - default: - break - } - idx += 1 - } - 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) - } - #endif } diff --git a/apps/macos/Sources/Clawdis/VoiceWakeOverlay.swift b/apps/macos/Sources/Clawdis/VoiceWakeOverlay.swift index 2b8abb2e8..a3be02971 100644 --- a/apps/macos/Sources/Clawdis/VoiceWakeOverlay.swift +++ b/apps/macos/Sources/Clawdis/VoiceWakeOverlay.swift @@ -31,7 +31,6 @@ final class VoiceWakeOverlayController: ObservableObject { private var hostingView: NSHostingView? private var autoSendTask: Task? private var autoSendToken: UUID? - private var forwardConfig: VoiceWakeForwardConfig? private var activeToken: UUID? private var activeSource: Source? @@ -64,7 +63,6 @@ final class VoiceWakeOverlayController: ObservableObject { self.logger.log(level: .info, "\(message)") self.activeToken = token self.activeSource = source - self.forwardConfig = nil self.autoSendTask?.cancel(); self.autoSendTask = nil; self.autoSendToken = nil self.model.text = transcript self.model.isFinal = isFinal @@ -91,7 +89,6 @@ final class VoiceWakeOverlayController: ObservableObject { """ self.logger.log(level: .info, "\(message)") self.autoSendTask?.cancel(); self.autoSendTask = nil; self.autoSendToken = nil - self.forwardConfig = nil self.model.text = transcript self.model.isFinal = false self.model.forwardEnabled = false @@ -106,7 +103,6 @@ final class VoiceWakeOverlayController: ObservableObject { func presentFinal( token: UUID, transcript: String, - forwardConfig: VoiceWakeForwardConfig, autoSendAfter delay: TimeInterval?, sendChime: VoiceWakeChime = .none, attributed: NSAttributedString? = nil) @@ -116,15 +112,14 @@ final class VoiceWakeOverlayController: ObservableObject { overlay presentFinal token=\(token.uuidString) \ len=\(transcript.count) \ autoSendAfter=\(delay ?? -1) \ - forwardEnabled=\(forwardConfig.enabled) + forwardEnabled=\(!transcript.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) """ self.logger.log(level: .info, "\(message)") self.autoSendTask?.cancel() self.autoSendToken = token - self.forwardConfig = forwardConfig self.model.text = transcript self.model.isFinal = true - self.model.forwardEnabled = forwardConfig.enabled + self.model.forwardEnabled = !transcript.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty self.model.isSending = false self.model.isEditing = false self.model.attributed = attributed ?? self.makeAttributed(from: transcript) @@ -228,7 +223,6 @@ final class VoiceWakeOverlayController: ObservableObject { self.model.level = 0 self.activeToken = nil self.activeSource = nil - self.forwardConfig = nil if outcome == .empty { AppStateStore.shared.blinkOnce() } else if outcome == .sent { diff --git a/apps/macos/Sources/Clawdis/VoiceWakeRuntime.swift b/apps/macos/Sources/Clawdis/VoiceWakeRuntime.swift index 14405458d..21f8d616b 100644 --- a/apps/macos/Sources/Clawdis/VoiceWakeRuntime.swift +++ b/apps/macos/Sources/Clawdis/VoiceWakeRuntime.swift @@ -334,7 +334,6 @@ actor VoiceWakeRuntime { await MainActor.run { VoiceSessionCoordinator.shared.updateLevel(token: token, 0) } } - let forwardConfig = await MainActor.run { AppStateStore.shared.voiceWakeForwardConfig } let delay: TimeInterval = 0.0 let sendChime = finalTranscript.isEmpty ? .none : config.sendChime if let token = self.overlayToken { @@ -342,18 +341,15 @@ actor VoiceWakeRuntime { VoiceSessionCoordinator.shared.finalize( token: token, text: finalTranscript, - forwardConfig: forwardConfig, sendChime: sendChime, autoSendAfter: delay) } - } else if forwardConfig.enabled, !finalTranscript.isEmpty { + } else if !finalTranscript.isEmpty { if sendChime != .none { await MainActor.run { VoiceWakeChimePlayer.play(sendChime, reason: "voicewake.send") } } Task.detached { - await VoiceWakeForwarder.forward( - transcript: VoiceWakeForwarder.prefixedTranscript(finalTranscript), - config: forwardConfig) + await VoiceWakeForwarder.forward(transcript: finalTranscript) } } self.overlayToken = nil diff --git a/apps/macos/Sources/Clawdis/VoiceWakeTester.swift b/apps/macos/Sources/Clawdis/VoiceWakeTester.swift index c1d521d26..de1c80cf4 100644 --- a/apps/macos/Sources/Clawdis/VoiceWakeTester.swift +++ b/apps/macos/Sources/Clawdis/VoiceWakeTester.swift @@ -134,10 +134,8 @@ final class VoiceWakeTester { self.detectedText = text self.logger.info("voice wake detected; forwarding (len=\(text.count))") await MainActor.run { AppStateStore.shared.triggerVoiceEars(ttl: nil) } - let config = await MainActor.run { AppStateStore.shared.voiceWakeForwardConfig } Task.detached { - let payload = VoiceWakeForwarder.prefixedTranscript(text) - await VoiceWakeForwarder.forward(transcript: payload, config: config) + await VoiceWakeForwarder.forward(transcript: text) } Task { @MainActor in onUpdate(.detected(text)) } self.holdUntilSilence(onUpdate: onUpdate) diff --git a/apps/macos/Tests/ClawdisIPCTests/CommandResolverTests.swift b/apps/macos/Tests/ClawdisIPCTests/CommandResolverTests.swift index a2a302a92..79974a719 100644 --- a/apps/macos/Tests/ClawdisIPCTests/CommandResolverTests.swift +++ b/apps/macos/Tests/ClawdisIPCTests/CommandResolverTests.swift @@ -20,6 +20,17 @@ import Testing } @Test func prefersClawdisBinary() async throws { + UserDefaults.standard.set(AppState.ConnectionMode.local.rawValue, forKey: connectionModeKey) + UserDefaults.standard.removeObject(forKey: remoteTargetKey) + UserDefaults.standard.removeObject(forKey: remoteIdentityKey) + UserDefaults.standard.removeObject(forKey: remoteProjectRootKey) + defer { + UserDefaults.standard.removeObject(forKey: connectionModeKey) + UserDefaults.standard.removeObject(forKey: remoteTargetKey) + UserDefaults.standard.removeObject(forKey: remoteIdentityKey) + UserDefaults.standard.removeObject(forKey: remoteProjectRootKey) + } + let tmp = try makeTempDir() CommandResolver.setProjectRoot(tmp.path) @@ -31,6 +42,17 @@ import Testing } @Test func fallsBackToNodeAndScript() async throws { + UserDefaults.standard.set(AppState.ConnectionMode.local.rawValue, forKey: connectionModeKey) + UserDefaults.standard.removeObject(forKey: remoteTargetKey) + UserDefaults.standard.removeObject(forKey: remoteIdentityKey) + UserDefaults.standard.removeObject(forKey: remoteProjectRootKey) + defer { + UserDefaults.standard.removeObject(forKey: connectionModeKey) + UserDefaults.standard.removeObject(forKey: remoteTargetKey) + UserDefaults.standard.removeObject(forKey: remoteIdentityKey) + UserDefaults.standard.removeObject(forKey: remoteProjectRootKey) + } + let tmp = try makeTempDir() CommandResolver.setProjectRoot(tmp.path) @@ -50,6 +72,17 @@ import Testing } @Test func fallsBackToPnpm() async throws { + UserDefaults.standard.set(AppState.ConnectionMode.local.rawValue, forKey: connectionModeKey) + UserDefaults.standard.removeObject(forKey: remoteTargetKey) + UserDefaults.standard.removeObject(forKey: remoteIdentityKey) + UserDefaults.standard.removeObject(forKey: remoteProjectRootKey) + defer { + UserDefaults.standard.removeObject(forKey: connectionModeKey) + UserDefaults.standard.removeObject(forKey: remoteTargetKey) + UserDefaults.standard.removeObject(forKey: remoteIdentityKey) + UserDefaults.standard.removeObject(forKey: remoteProjectRootKey) + } + let tmp = try makeTempDir() CommandResolver.setProjectRoot(tmp.path) @@ -62,6 +95,17 @@ import Testing } @Test func pnpmKeepsExtraArgsAfterSubcommand() async throws { + UserDefaults.standard.set(AppState.ConnectionMode.local.rawValue, forKey: connectionModeKey) + UserDefaults.standard.removeObject(forKey: remoteTargetKey) + UserDefaults.standard.removeObject(forKey: remoteIdentityKey) + UserDefaults.standard.removeObject(forKey: remoteProjectRootKey) + defer { + UserDefaults.standard.removeObject(forKey: connectionModeKey) + UserDefaults.standard.removeObject(forKey: remoteTargetKey) + UserDefaults.standard.removeObject(forKey: remoteIdentityKey) + UserDefaults.standard.removeObject(forKey: remoteProjectRootKey) + } + let tmp = try makeTempDir() CommandResolver.setProjectRoot(tmp.path) @@ -75,6 +119,17 @@ import Testing } @Test func preferredPathsStartWithProjectNodeBins() async throws { + UserDefaults.standard.set(AppState.ConnectionMode.local.rawValue, forKey: connectionModeKey) + UserDefaults.standard.removeObject(forKey: remoteTargetKey) + UserDefaults.standard.removeObject(forKey: remoteIdentityKey) + UserDefaults.standard.removeObject(forKey: remoteProjectRootKey) + defer { + UserDefaults.standard.removeObject(forKey: connectionModeKey) + UserDefaults.standard.removeObject(forKey: remoteTargetKey) + UserDefaults.standard.removeObject(forKey: remoteIdentityKey) + UserDefaults.standard.removeObject(forKey: remoteProjectRootKey) + } + let tmp = try makeTempDir() CommandResolver.setProjectRoot(tmp.path) diff --git a/apps/macos/Tests/ClawdisIPCTests/VoiceWakeForwarderTests.swift b/apps/macos/Tests/ClawdisIPCTests/VoiceWakeForwarderTests.swift index f46db3d36..13ef28f9e 100644 --- a/apps/macos/Tests/ClawdisIPCTests/VoiceWakeForwarderTests.swift +++ b/apps/macos/Tests/ClawdisIPCTests/VoiceWakeForwarderTests.swift @@ -2,16 +2,6 @@ import Testing @testable import Clawdis @Suite(.serialized) struct VoiceWakeForwarderTests { - @Test func shellEscapeHandlesQuotesAndParens() { - let text = "Debug test works (and a funny pun)" - let escaped = VoiceWakeForwarder.shellEscape(text) - #expect(escaped == "'Debug test works (and a funny pun)'") - - let textWithQuote = "Debug test works (and a funny pun)'" - let escapedQuote = VoiceWakeForwarder.shellEscape(textWithQuote) - #expect(escapedQuote == "'Debug test works (and a funny pun)'\\'''") - } - @Test func prefixedTranscriptUsesMachineName() { let transcript = "hello world" let prefixed = VoiceWakeForwarder.prefixedTranscript(transcript, machineName: "My-Mac") @@ -21,29 +11,11 @@ import Testing #expect(prefixed.hasSuffix("\n\nhello world")) } - @Test func parsesCommandTemplateOverrides() { - let opts = VoiceWakeForwarder._testParseCommandTemplate( - "clawdis-mac agent --session alt --thinking high --no-deliver --to +123 --message \"${text}\"") - #expect(opts.session == "alt") - #expect(opts.thinking == "high") - #expect(opts.deliver == false) - #expect(opts.to == "+123") - } - - @Test func parsesCommandTemplateDefaults() { - let opts = VoiceWakeForwarder._testParseCommandTemplate("clawdis-mac agent --message \"${text}\"") + @Test func forwardOptionsDefaults() { + let opts = VoiceWakeForwarder.ForwardOptions() #expect(opts.session == "main") #expect(opts.thinking == "low") #expect(opts.deliver == true) #expect(opts.to == nil) } - - @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") - } } diff --git a/docs/mac/voicewake.md b/docs/mac/voicewake.md index 156cf685f..02a752cbc 100644 --- a/docs/mac/voicewake.md +++ b/docs/mac/voicewake.md @@ -32,6 +32,9 @@ Updated: 2025-12-08 · Owners: mac app - Language & mic pickers, live level meter, trigger-word table, tester, forward target/command all remain unchanged. - **Sounds**: chimes on trigger detect and on send; defaults to the macOS “Glass” system sound. You can pick any `NSSound`-loadable file (e.g. MP3/WAV/AIFF) for each event or choose **No Sound**. +## Forwarding behavior +- When Voice Wake is enabled, transcripts are forwarded to the active gateway/agent (the same local vs remote mode used by the rest of the mac app). + ## Forwarding payload - `VoiceWakeForwarder.prefixedTranscript(_:)` prepends the machine hint before sending. Shared between wake-word and push-to-talk paths.