mac: remove voice wake forward pref
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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)"))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user