fix(macos): require local realtime relay opt-in

Co-authored-by: Zhilong Zheng <zhengzhilong1115@gmail.com>
Punchcard-Session: golden-meadow-cedar-dv
This commit is contained in:
Vincent Koc
2026-08-11 18:00:00 +08:00
parent 862e21212e
commit 0c79ca4da3
10 changed files with 158 additions and 54 deletions
+59 -43
View File
@@ -30059,7 +30059,7 @@
},
{
"kind": "ui-localized-call-multiline",
"line": 399,
"line": 403,
"path": "apps/macos/Sources/OpenClaw/AppState.swift",
"source": "These settings changed outside the app while you were editing: \\(fieldList). Choose which version to keep.",
"surface": "apple",
@@ -30067,7 +30067,7 @@
},
{
"kind": "conditional-branch",
"line": 1017,
"line": 1022,
"path": "apps/macos/Sources/OpenClaw/AppState.swift",
"source": "\\(user)@\\(host)",
"surface": "apple",
@@ -30075,7 +30075,7 @@
},
{
"kind": "conditional-branch",
"line": 1017,
"line": 1022,
"path": "apps/macos/Sources/OpenClaw/AppState.swift",
"source": "\\(user)@\\(host):\\(port)",
"surface": "apple",
@@ -39139,7 +39139,7 @@
},
{
"kind": "ui-named-argument",
"line": 187,
"line": 189,
"path": "apps/macos/Sources/OpenClaw/VoiceWakeSettings.swift",
"source": "Push-to-talk paused",
"surface": "apple",
@@ -39147,7 +39147,7 @@
},
{
"kind": "ui-named-argument",
"line": 188,
"line": 190,
"path": "apps/macos/Sources/OpenClaw/VoiceWakeSettings.swift",
"source": "Push-to-Talk resumes when Talk Mode is turned off.",
"surface": "apple",
@@ -39155,7 +39155,7 @@
},
{
"kind": "ui-named-argument",
"line": 196,
"line": 198,
"path": "apps/macos/Sources/OpenClaw/VoiceWakeSettings.swift",
"source": "Play phase-transition sounds",
"surface": "apple",
@@ -39163,7 +39163,7 @@
},
{
"kind": "ui-named-argument-multiline",
"line": 197,
"line": 199,
"path": "apps/macos/Sources/OpenClaw/VoiceWakeSettings.swift",
"source": "Play short sounds when Talk Mode switches between listening, thinking, and speaking.",
"surface": "apple",
@@ -39171,7 +39171,7 @@
},
{
"kind": "ui-named-argument",
"line": 203,
"line": 205,
"path": "apps/macos/Sources/OpenClaw/VoiceWakeSettings.swift",
"source": "Right Option stops speech",
"surface": "apple",
@@ -39179,7 +39179,7 @@
},
{
"kind": "ui-named-argument",
"line": 204,
"line": 206,
"path": "apps/macos/Sources/OpenClaw/VoiceWakeSettings.swift",
"source": "Tap Right Option to interrupt speech and return to listening.",
"surface": "apple",
@@ -39187,7 +39187,7 @@
},
{
"kind": "ui-call",
"line": 209,
"line": 211,
"path": "apps/macos/Sources/OpenClaw/VoiceWakeSettings.swift",
"source": "Recognition",
"surface": "apple",
@@ -39195,7 +39195,7 @@
},
{
"kind": "ui-call",
"line": 215,
"line": 217,
"path": "apps/macos/Sources/OpenClaw/VoiceWakeSettings.swift",
"source": "Test",
"surface": "apple",
@@ -39203,7 +39203,7 @@
},
{
"kind": "ui-call",
"line": 315,
"line": 317,
"path": "apps/macos/Sources/OpenClaw/VoiceWakeSettings.swift",
"source": "Trigger Words",
"surface": "apple",
@@ -39211,7 +39211,7 @@
},
{
"kind": "ui-named-argument",
"line": 317,
"line": 319,
"path": "apps/macos/Sources/OpenClaw/VoiceWakeSettings.swift",
"source": "Wake phrases",
"surface": "apple",
@@ -39219,7 +39219,7 @@
},
{
"kind": "ui-named-argument",
"line": 318,
"line": 320,
"path": "apps/macos/Sources/OpenClaw/VoiceWakeSettings.swift",
"source": "Short phrases that start voice wake detection.",
"surface": "apple",
@@ -39227,7 +39227,7 @@
},
{
"kind": "ui-call",
"line": 324,
"line": 326,
"path": "apps/macos/Sources/OpenClaw/VoiceWakeSettings.swift",
"source": "Add word",
"surface": "apple",
@@ -39235,7 +39235,7 @@
},
{
"kind": "ui-call",
"line": 329,
"line": 331,
"path": "apps/macos/Sources/OpenClaw/VoiceWakeSettings.swift",
"source": "Reset",
"surface": "apple",
@@ -39243,7 +39243,7 @@
},
{
"kind": "ui-call",
"line": 351,
"line": 353,
"path": "apps/macos/Sources/OpenClaw/VoiceWakeSettings.swift",
"source": "No wake phrases configured",
"surface": "apple",
@@ -39251,7 +39251,7 @@
},
{
"kind": "ui-call",
"line": 381,
"line": 383,
"path": "apps/macos/Sources/OpenClaw/VoiceWakeSettings.swift",
"source": "Sounds",
"surface": "apple",
@@ -39259,7 +39259,7 @@
},
{
"kind": "ui-named-argument",
"line": 383,
"line": 385,
"path": "apps/macos/Sources/OpenClaw/VoiceWakeSettings.swift",
"source": "Trigger sound",
"surface": "apple",
@@ -39267,7 +39267,7 @@
},
{
"kind": "ui-named-argument",
"line": 387,
"line": 389,
"path": "apps/macos/Sources/OpenClaw/VoiceWakeSettings.swift",
"source": "Send sound",
"surface": "apple",
@@ -39275,7 +39275,7 @@
},
{
"kind": "ui-call",
"line": 474,
"line": 476,
"path": "apps/macos/Sources/OpenClaw/VoiceWakeSettings.swift",
"source": "No Sound",
"surface": "apple",
@@ -39283,7 +39283,7 @@
},
{
"kind": "ui-call",
"line": 482,
"line": 484,
"path": "apps/macos/Sources/OpenClaw/VoiceWakeSettings.swift",
"source": "Choose file…",
"surface": "apple",
@@ -39291,7 +39291,7 @@
},
{
"kind": "ui-call",
"line": 502,
"line": 504,
"path": "apps/macos/Sources/OpenClaw/VoiceWakeSettings.swift",
"source": "Play",
"surface": "apple",
@@ -39299,7 +39299,7 @@
},
{
"kind": "ui-named-argument",
"line": 550,
"line": 552,
"path": "apps/macos/Sources/OpenClaw/VoiceWakeSettings.swift",
"source": "Microphone",
"surface": "apple",
@@ -39307,7 +39307,7 @@
},
{
"kind": "ui-call",
"line": 552,
"line": 554,
"path": "apps/macos/Sources/OpenClaw/VoiceWakeSettings.swift",
"source": "System default",
"surface": "apple",
@@ -39315,7 +39315,7 @@
},
{
"kind": "conditional-branch",
"line": 554,
"line": 556,
"path": "apps/macos/Sources/OpenClaw/VoiceWakeSettings.swift",
"source": "Unavailable",
"surface": "apple",
@@ -39323,7 +39323,7 @@
},
{
"kind": "ui-call",
"line": 565,
"line": 567,
"path": "apps/macos/Sources/OpenClaw/VoiceWakeSettings.swift",
"source": "Disconnected (using System default)",
"surface": "apple",
@@ -39331,7 +39331,7 @@
},
{
"kind": "ui-named-argument",
"line": 584,
"line": 586,
"path": "apps/macos/Sources/OpenClaw/VoiceWakeSettings.swift",
"source": "Recognition language",
"surface": "apple",
@@ -39339,7 +39339,7 @@
},
{
"kind": "ui-named-argument",
"line": 585,
"line": 587,
"path": "apps/macos/Sources/OpenClaw/VoiceWakeSettings.swift",
"source": "Languages are tried in order. Models may need a first-use download on macOS 26.",
"surface": "apple",
@@ -39347,7 +39347,7 @@
},
{
"kind": "ui-call",
"line": 587,
"line": 589,
"path": "apps/macos/Sources/OpenClaw/VoiceWakeSettings.swift",
"source": "Language",
"surface": "apple",
@@ -39355,7 +39355,7 @@
},
{
"kind": "ui-call",
"line": 589,
"line": 591,
"path": "apps/macos/Sources/OpenClaw/VoiceWakeSettings.swift",
"source": "\\(self.friendlyName(for: current)) (System)",
"surface": "apple",
@@ -39363,7 +39363,7 @@
},
{
"kind": "ui-call",
"line": 601,
"line": 603,
"path": "apps/macos/Sources/OpenClaw/VoiceWakeSettings.swift",
"source": "Voice Wake needs on-device recognition for its selected language. Push-to-talk and Talk Mode remain available.",
"surface": "apple",
@@ -39371,7 +39371,7 @@
},
{
"kind": "ui-named-argument",
"line": 613,
"line": 615,
"path": "apps/macos/Sources/OpenClaw/VoiceWakeSettings.swift",
"source": "Additional languages",
"surface": "apple",
@@ -39379,7 +39379,7 @@
},
{
"kind": "ui-named-argument",
"line": 656,
"line": 658,
"path": "apps/macos/Sources/OpenClaw/VoiceWakeSettings.swift",
"source": "Add another language",
"surface": "apple",
@@ -39387,7 +39387,7 @@
},
{
"kind": "ui-call",
"line": 660,
"line": 662,
"path": "apps/macos/Sources/OpenClaw/VoiceWakeSettings.swift",
"source": "Add",
"surface": "apple",
@@ -39395,7 +39395,7 @@
},
{
"kind": "ui-named-argument",
"line": 766,
"line": 768,
"path": "apps/macos/Sources/OpenClaw/VoiceWakeSettings.swift",
"source": "Live level",
"surface": "apple",
@@ -39403,7 +39403,7 @@
},
{
"kind": "ui-call",
"line": 840,
"line": 842,
"path": "apps/macos/Sources/OpenClaw/VoiceWakeSettings.swift",
"source": "Wake phrase",
"surface": "apple",
@@ -39411,7 +39411,7 @@
},
{
"kind": "ui-modifier",
"line": 857,
"line": 859,
"path": "apps/macos/Sources/OpenClaw/VoiceWakeSettings.swift",
"source": "Remove trigger word",
"surface": "apple",
@@ -39419,7 +39419,7 @@
},
{
"kind": "ui-localized-call",
"line": 881,
"line": 883,
"path": "apps/macos/Sources/OpenClaw/VoiceWakeSettings.swift",
"source": "Language %lld",
"surface": "apple",
@@ -39427,7 +39427,7 @@
},
{
"kind": "ui-named-argument",
"line": 883,
"line": 885,
"path": "apps/macos/Sources/OpenClaw/VoiceWakeSettings.swift",
"source": "Fallback recognition language.",
"surface": "apple",
@@ -39435,7 +39435,7 @@
},
{
"kind": "ui-call",
"line": 887,
"line": 889,
"path": "apps/macos/Sources/OpenClaw/VoiceWakeSettings.swift",
"source": "Language \\(self.index + 2)",
"surface": "apple",
@@ -39443,7 +39443,7 @@
},
{
"kind": "ui-modifier",
"line": 904,
"line": 906,
"path": "apps/macos/Sources/OpenClaw/VoiceWakeSettings.swift",
"source": "Remove language",
"surface": "apple",
@@ -39451,12 +39451,28 @@
},
{
"kind": "ui-call-concatenated",
"line": 919,
"line": 921,
"path": "apps/macos/Sources/OpenClaw/VoiceWakeSettings.swift",
"source": "OpenClaw reacts when any trigger appears in a transcription. Keep phrases short to avoid false positives.",
"surface": "apple",
"id": "native.apple.237e9371a693b0ff"
},
{
"kind": "ui-named-argument",
"line": 938,
"path": "apps/macos/Sources/OpenClaw/VoiceWakeSettings.swift",
"source": "Use realtime Gateway relay",
"surface": "apple",
"id": "native.apple.31708bc96d7b1f5f"
},
{
"kind": "ui-named-argument-multiline",
"line": 939,
"path": "apps/macos/Sources/OpenClaw/VoiceWakeSettings.swift",
"source": "Use the Gateway's configured realtime voice session on this Mac. Requires realtime, gateway-relay, and agent-consult in Talk settings.",
"surface": "apple",
"id": "native.apple.9ef48cad7ccbf2c2"
},
{
"kind": "ui-call",
"line": 11,
@@ -0,0 +1,10 @@
import Foundation
extension AppState {
func persistTalkRealtimeRelayPreference(previousValue: Bool) {
guard !self.isPreview else { return }
AppDefaults.standard.set(self.talkRealtimeRelayEnabled, forKey: talkRealtimeRelayEnabledKey)
guard self.talkEnabled, self.talkRealtimeRelayEnabled != previousValue else { return }
Task { await TalkModeRuntime.shared.realtimeRelayPreferenceDidChange() }
}
}
+7 -1
View File
@@ -81,7 +81,7 @@ final class AppState {
private static let logger = Logger(subsystem: "ai.openclaw", category: "app-state")
private static let execApprovalsReadRetryAttempts = 5
private let isPreview: Bool
let isPreview: Bool
@ObservationIgnored private let execApprovalsDefaultsAsyncResolver:
@MainActor () async -> Result<ExecApprovalsResolvedDefaults, ExecApprovalsReadError>
@ObservationIgnored private let execApprovalsReadRetryDelay: Duration
@@ -258,6 +258,10 @@ final class AppState {
}
}
var talkRealtimeRelayEnabled: Bool {
didSet { self.persistTalkRealtimeRelayPreference(previousValue: oldValue) }
}
var talkPhaseSoundsEnabled: Bool {
didSet {
self.ifNotPreview {
@@ -485,6 +489,7 @@ final class AppState {
self.voiceWakeTriggersTalkMode = AppDefaults.standard
.object(forKey: voiceWakeTriggersTalkModeKey) as? Bool ?? false
self.talkEnabled = AppDefaults.standard.bool(forKey: talkEnabledKey)
self.talkRealtimeRelayEnabled = isTalkRealtimeRelayEnabled()
if let storedPhaseSounds = AppDefaults.standard.object(forKey: talkPhaseSoundsEnabledKey) as? Bool {
self.talkPhaseSoundsEnabled = storedPhaseSounds
} else {
@@ -1540,6 +1545,7 @@ extension AppState {
state.voiceWakeAdditionalLocaleIDs = ["en-US", "de-DE"]
state.voicePushToTalkEnabled = false
state.talkEnabled = false
state.talkRealtimeRelayEnabled = false
state.talkPhaseSoundsEnabled = true
state.talkShiftToStopEnabled = true
state.iconOverride = .system
@@ -31,6 +31,7 @@ let voiceWakeAdditionalLocalesKey = "openclaw.voiceWakeAdditionalLocaleIDs"
let voicePushToTalkEnabledKey = "openclaw.voicePushToTalkEnabled"
let voiceWakeTriggersTalkModeKey = "openclaw.voiceWakeTriggersTalkMode"
let talkEnabledKey = "openclaw.talkEnabled"
let talkRealtimeRelayEnabledKey = "openclaw.talkRealtimeRelayEnabled"
let talkPhaseSoundsEnabledKey = "openclaw.talkPhaseSoundsEnabled"
let talkShiftToStopEnabledKey = "openclaw.talkShiftToStopEnabled"
let iconOverrideKey = "openclaw.iconOverride"
@@ -44,6 +45,10 @@ let quickChatEnabledKey = "openclaw.quickChatEnabled"
let cameraEnabledKey = "openclaw.cameraEnabled"
let computerControlEnabledKey = "openclaw.computerControlEnabled"
func isTalkRealtimeRelayEnabled(defaults: UserDefaults = AppDefaults.standard) -> Bool {
defaults.object(forKey: talkRealtimeRelayEnabledKey) as? Bool ?? false
}
func isComputerControlEnabled(defaults: UserDefaults = AppDefaults.standard) -> Bool {
// object(forKey:) preserves an explicit false; bool(forKey:) would conflate it with an unset default.
defaults.object(forKey: computerControlEnabledKey) as? Bool ?? true
@@ -23,7 +23,7 @@ struct TalkModeGatewayConfigState {
let realtimeTransport: String?
let realtimeBrain: String?
var enablesMacOSRealtimeRelay: Bool {
var hasGatewayRealtimeRelayTuple: Bool {
self.realtimeMode == "realtime" &&
self.realtimeTransport == "gateway-relay" &&
self.realtimeBrain == "agent-consult"
@@ -85,7 +85,8 @@ actor TalkModeRuntime {
private var realtimeMode: String?
private var realtimeTransport: String?
private var realtimeBrain: String?
private var realtimeRelayEnabled = false
private var hasGatewayRealtimeRelayTuple = false
private var macOSRealtimeRelayOptIn = false
var realtimeSession: RealtimeTalkRelaySession?
var realtimeSessionReadyAt: Date?
var rapidRealtimeRestartCount = 0
@@ -182,6 +183,13 @@ actor TalkModeRuntime {
}
}
func realtimeRelayPreferenceDidChange() async {
guard self.isEnabled else { return }
self.lifecycleGeneration &+= 1
await self.stop()
await self.start()
}
func isCurrent(_ generation: Int) -> Bool {
generation == self.lifecycleGeneration && self.isEnabled
}
@@ -314,15 +322,28 @@ actor TalkModeRuntime {
}
private func shouldAttemptRealtimeRelay() -> Bool {
guard self.realtimeMode == "realtime" else { return false }
guard self.realtimeRelayEnabled else {
guard self.macOSRealtimeRelayOptIn else {
self.logger.debug("talk macOS realtime relay disabled locally; using native speech")
return false
}
guard self.hasGatewayRealtimeRelayTuple else {
self.logger.warning(
"talk macOS realtime relay not explicitly enabled: " +
"talk macOS realtime relay opted in but Gateway tuple is incompatible: " +
"mode=\(self.realtimeMode ?? "missing", privacy: .public) " +
"transport=\(self.realtimeTransport ?? "missing", privacy: .public) " +
"brain=\(self.realtimeBrain ?? "missing", privacy: .public); using native fallback")
return false
}
return true
return Self.shouldUseRealtimeRelay(
localOptIn: self.macOSRealtimeRelayOptIn,
hasGatewayRealtimeRelayTuple: self.hasGatewayRealtimeRelayTuple)
}
static func shouldUseRealtimeRelay(
localOptIn: Bool,
hasGatewayRealtimeRelayTuple: Bool) -> Bool
{
localOptIn && hasGatewayRealtimeRelayTuple
}
// MARK: - Speech recognition
@@ -1452,7 +1473,10 @@ extension TalkModeRuntime {
self.realtimeMode = cfg.realtimeMode
self.realtimeTransport = cfg.realtimeTransport
self.realtimeBrain = cfg.realtimeBrain
self.realtimeRelayEnabled = cfg.enablesMacOSRealtimeRelay
self.hasGatewayRealtimeRelayTuple = cfg.hasGatewayRealtimeRelayTuple
self.macOSRealtimeRelayOptIn = await MainActor.run {
AppStateStore.shared.talkRealtimeRelayEnabled
}
let configuredSilenceMs = cfg.silenceTimeoutMs
let locale = await MainActor.run { AppStateStore.shared.voiceWakeLocaleID }
let isCJKLocale = locale.hasPrefix("ko") || locale.hasPrefix("ja") || locale.hasPrefix("zh")
@@ -1483,7 +1507,8 @@ extension TalkModeRuntime {
"speechLocale=\(cfg.speechLocaleID ?? "device", privacy: .public) " +
"realtimeMode=\(cfg.realtimeMode ?? "off", privacy: .public) " +
"realtimeTransport=\(cfg.realtimeTransport ?? "default", privacy: .public) " +
"realtimeBrain=\(cfg.realtimeBrain ?? "default", privacy: .public)")
"realtimeBrain=\(cfg.realtimeBrain ?? "default", privacy: .public) " +
"macOSRealtimeOptIn=\(self.macOSRealtimeRelayOptIn, privacy: .public)")
}
static func selectTalkProviderConfig(
@@ -182,6 +182,8 @@ struct VoiceWakeSettings: View {
subtitle: "Start listening while you hold the key and show the preview overlay.",
binding: self.$state.voicePushToTalkEnabled)
self.realtimeRelayToggle
if self.state.voicePushToTalkEnabled, self.state.talkEnabled {
SettingsCardRow(
title: "Push-to-talk paused",
@@ -930,6 +932,18 @@ private struct TriggerPhraseHelpRow: View {
}
}
extension VoiceWakeSettings {
private var realtimeRelayToggle: some View {
SettingsCardToggleRow(
title: "Use realtime Gateway relay",
subtitle: """
Use the Gateway's configured realtime voice session on this Mac. \
Requires realtime, gateway-relay, and agent-consult in Talk settings.
""",
binding: self.$state.talkRealtimeRelayEnabled)
}
}
#if DEBUG
struct VoiceWakeSettings_Previews: PreviewProvider {
static var previews: some View {
@@ -80,7 +80,7 @@ struct TalkModeGatewayConfigTests {
#expect(parsed.realtimeMode == "realtime")
#expect(parsed.realtimeTransport == "gateway-relay")
#expect(parsed.realtimeBrain == "agent-consult")
#expect(parsed.enablesMacOSRealtimeRelay)
#expect(parsed.hasGatewayRealtimeRelayTuple)
}
@Test func `realtime config infers its sole provider and reads provider defaults`() {
@@ -104,7 +104,7 @@ struct TalkModeGatewayConfigTests {
#expect(parsed.realtimeMode == "realtime")
#expect(parsed.realtimeTransport == nil)
#expect(parsed.realtimeBrain == nil)
#expect(!parsed.enablesMacOSRealtimeRelay)
#expect(!parsed.hasGatewayRealtimeRelayTuple)
}
@Test func `realtime provider config lookup is case insensitive`() {
@@ -88,6 +88,30 @@ private final class RuntimeTestPCMPlayer: PCMStreamingAudioPlaying {
}
struct TalkModeRuntimeSpeechTests {
@Test func `macOS realtime relay requires local opt in and exact Gateway tuple`() {
#expect(!TalkModeRuntime.shouldUseRealtimeRelay(
localOptIn: false,
hasGatewayRealtimeRelayTuple: false))
#expect(!TalkModeRuntime.shouldUseRealtimeRelay(
localOptIn: false,
hasGatewayRealtimeRelayTuple: true))
#expect(!TalkModeRuntime.shouldUseRealtimeRelay(
localOptIn: true,
hasGatewayRealtimeRelayTuple: false))
#expect(TalkModeRuntime.shouldUseRealtimeRelay(
localOptIn: true,
hasGatewayRealtimeRelayTuple: true))
}
@Test @MainActor func `macOS realtime relay preference defaults off and reads explicit opt in`() async {
await TestIsolation.withUserDefaultsValues([talkRealtimeRelayEnabledKey: nil]) {
#expect(!AppState(preview: true).talkRealtimeRelayEnabled)
}
await TestIsolation.withUserDefaultsValues([talkRealtimeRelayEnabledKey: true]) {
#expect(AppState(preview: true).talkRealtimeRelayEnabled)
}
}
@Test func `speech request uses dictation defaults`() {
let request = SFSpeechAudioBufferRecognitionRequest()
+4
View File
@@ -72,6 +72,10 @@ of these together:
Any other combination — including a partially set one — keeps the native path.
The Mac must also opt in locally with **Settings > Voice Wake > Use realtime Gateway relay**.
This preference defaults off and stays on that Mac; Gateway config alone never activates the
streamed path.
The Gateway must also advertise `gateway-relay` and `agent-consult` for the selected provider in
`talk.catalog`. Realtime requires macOS 26 or newer, matching Voice Wake; on older versions the
Talk runtime returns before startup and the native path is the only one available.