mac: remove voice wake forward pref

This commit is contained in:
Peter Steinberger
2025-12-12 16:09:31 +00:00
parent 6b64039fcb
commit 679ced7840
15 changed files with 101 additions and 390 deletions

View File

@@ -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)

View File

@@ -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) {

View File

@@ -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

View File

@@ -10,6 +10,11 @@ final actor ControlSocketServer {
private let maxRequestBytes = 512 * 1024
private let allowedTeamIDs: Set<String> = ["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<sockaddr>.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 }

View File

@@ -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)"))
}
}

View File

@@ -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)
}

View File

@@ -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)
}
}

View File

@@ -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)
}
}
}
}

View File

@@ -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<Void, VoiceWakeForwardError>
options: ForwardOptions = ForwardOptions()) async -> Result<Void, VoiceWakeForwardError>
{
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<Void, VoiceWakeForwardError> {
guard config.enabled else { return .failure(.disabled) }
static func checkConnection() async -> Result<Void, VoiceWakeForwardError> {
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
}

View File

@@ -31,7 +31,6 @@ final class VoiceWakeOverlayController: ObservableObject {
private var hostingView: NSHostingView<VoiceWakeOverlayView>?
private var autoSendTask: Task<Void, Never>?
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 {

View File

@@ -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

View File

@@ -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)

View File

@@ -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)

View File

@@ -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")
}
}

View File

@@ -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.