diff --git a/CHANGELOG.md b/CHANGELOG.md
index 13896afe2c2..e8cb8a430d8 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -24,6 +24,7 @@ Docs: https://docs.openclaw.ai
- CLI/Pairing: make `openclaw qr --remote` prefer `gateway.remote.url` over tailscale/public URL resolution and register the `openclaw clawbot qr` legacy alias path. (#18091)
- CLI/QR: restore fail-fast validation for `openclaw qr --remote` when neither `gateway.remote.url` nor tailscale `serve`/`funnel` is configured, preventing unusable remote pairing QR flows. (#18166) Thanks @mbelinky.
- OpenClawKit/iOS ChatUI: accept canonical session-key completion events for local pending runs and preserve message IDs across history refreshes, preventing stuck "thinking" state and message flicker after gateway replies. (#18165) Thanks @mbelinky.
+- iOS/Talk: harden mobile talk config handling by ignoring redacted/env-placeholder API keys, support secure local keychain override, improve accessibility motion/contrast behavior in status UI, and tighten ATS to local-network allowance. (#18163) Thanks @mbelinky.
## 2026.2.15
diff --git a/apps/ios/Sources/Gateway/GatewaySettingsStore.swift b/apps/ios/Sources/Gateway/GatewaySettingsStore.swift
index 11fbbc5f0ca..fa4d2953d68 100644
--- a/apps/ios/Sources/Gateway/GatewaySettingsStore.swift
+++ b/apps/ios/Sources/Gateway/GatewaySettingsStore.swift
@@ -4,6 +4,7 @@ import os
enum GatewaySettingsStore {
private static let gatewayService = "ai.openclaw.gateway"
private static let nodeService = "ai.openclaw.node"
+ private static let talkService = "ai.openclaw.talk"
private static let instanceIdDefaultsKey = "node.instanceId"
private static let preferredGatewayStableIDDefaultsKey = "gateway.preferredStableID"
@@ -24,6 +25,7 @@ enum GatewaySettingsStore {
private static let instanceIdAccount = "instanceId"
private static let preferredGatewayStableIDAccount = "preferredStableID"
private static let lastDiscoveredGatewayStableIDAccount = "lastDiscoveredStableID"
+ private static let talkElevenLabsApiKeyAccount = "elevenlabs.apiKey"
static func bootstrapPersistence() {
self.ensureStableInstanceID()
@@ -143,6 +145,27 @@ enum GatewaySettingsStore {
case discovered
}
+ static func loadTalkElevenLabsApiKey() -> String? {
+ let value = KeychainStore.loadString(
+ service: self.talkService,
+ account: self.talkElevenLabsApiKeyAccount)?
+ .trimmingCharacters(in: .whitespacesAndNewlines)
+ if value?.isEmpty == false { return value }
+ return nil
+ }
+
+ static func saveTalkElevenLabsApiKey(_ apiKey: String?) {
+ let trimmed = apiKey?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
+ if trimmed.isEmpty {
+ _ = KeychainStore.delete(service: self.talkService, account: self.talkElevenLabsApiKeyAccount)
+ return
+ }
+ _ = KeychainStore.saveString(
+ trimmed,
+ service: self.talkService,
+ account: self.talkElevenLabsApiKeyAccount)
+ }
+
static func saveLastGatewayConnectionManual(host: String, port: Int, useTLS: Bool, stableID: String) {
let defaults = UserDefaults.standard
defaults.set(LastGatewayKind.manual.rawValue, forKey: self.lastGatewayKindDefaultsKey)
diff --git a/apps/ios/Sources/Info.plist b/apps/ios/Sources/Info.plist
index 6fe56394eed..562692bdb11 100644
--- a/apps/ios/Sources/Info.plist
+++ b/apps/ios/Sources/Info.plist
@@ -17,15 +17,15 @@
CFBundleName
$(PRODUCT_NAME)
CFBundlePackageType
- APPL
- CFBundleShortVersionString
- 2026.2.16
- CFBundleVersion
- 20260216
- NSAppTransportSecurity
-
- NSAllowsArbitraryLoadsInWebContent
-
+ APPL
+ CFBundleShortVersionString
+ 2026.2.16
+ CFBundleVersion
+ 20260216
+ NSAppTransportSecurity
+
+ NSAllowsLocalNetworking
+
NSBonjourServices
diff --git a/apps/ios/Sources/RootTabs.swift b/apps/ios/Sources/RootTabs.swift
index 35786fa89a6..4733a4a30fc 100644
--- a/apps/ios/Sources/RootTabs.swift
+++ b/apps/ios/Sources/RootTabs.swift
@@ -3,6 +3,7 @@ import SwiftUI
struct RootTabs: View {
@Environment(NodeAppModel.self) private var appModel
@Environment(VoiceWakeManager.self) private var voiceWake
+ @Environment(\.accessibilityReduceMotion) private var reduceMotion
@AppStorage(VoiceWakePreferences.enabledKey) private var voiceWakeEnabled: Bool = false
@State private var selectedTab: Int = 0
@State private var voiceWakeToastText: String?
@@ -52,14 +53,14 @@ struct RootTabs: View {
guard !trimmed.isEmpty else { return }
self.toastDismissTask?.cancel()
- withAnimation(.spring(response: 0.25, dampingFraction: 0.85)) {
+ withAnimation(self.reduceMotion ? .none : .spring(response: 0.25, dampingFraction: 0.85)) {
self.voiceWakeToastText = trimmed
}
self.toastDismissTask = Task {
try? await Task.sleep(nanoseconds: 2_300_000_000)
await MainActor.run {
- withAnimation(.easeOut(duration: 0.25)) {
+ withAnimation(self.reduceMotion ? .none : .easeOut(duration: 0.25)) {
self.voiceWakeToastText = nil
}
}
diff --git a/apps/ios/Sources/Settings/SettingsTab.swift b/apps/ios/Sources/Settings/SettingsTab.swift
index 8eb725df4a1..14e01fa0692 100644
--- a/apps/ios/Sources/Settings/SettingsTab.swift
+++ b/apps/ios/Sources/Settings/SettingsTab.swift
@@ -33,6 +33,7 @@ struct SettingsTab: View {
@State private var lastLocationModeRaw: String = OpenClawLocationMode.off.rawValue
@State private var gatewayToken: String = ""
@State private var gatewayPassword: String = ""
+ @State private var talkElevenLabsApiKey: String = ""
@AppStorage("gateway.setupCode") private var setupCode: String = ""
@State private var setupStatusText: String?
@State private var manualGatewayPortText: String = ""
@@ -235,6 +236,12 @@ struct SettingsTab: View {
.onChange(of: self.talkEnabled) { _, newValue in
self.appModel.setTalkEnabled(newValue)
}
+ SecureField("Talk ElevenLabs API Key (optional)", text: self.$talkElevenLabsApiKey)
+ .textInputAutocapitalization(.never)
+ .autocorrectionDisabled()
+ Text("Use this local override when gateway config redacts talk.apiKey for mobile clients.")
+ .font(.footnote)
+ .foregroundStyle(.secondary)
// Keep this separate so users can hide the side bubble without disabling Talk Mode.
Toggle("Show Talk Button", isOn: self.$talkButtonEnabled)
@@ -312,6 +319,7 @@ struct SettingsTab: View {
self.gatewayToken = GatewaySettingsStore.loadGatewayToken(instanceId: trimmedInstanceId) ?? ""
self.gatewayPassword = GatewaySettingsStore.loadGatewayPassword(instanceId: trimmedInstanceId) ?? ""
}
+ self.talkElevenLabsApiKey = GatewaySettingsStore.loadTalkElevenLabsApiKey() ?? ""
// Keep setup front-and-center when disconnected; keep things compact once connected.
self.gatewayExpanded = !self.isGatewayConnected
self.selectedAgentPickerId = self.appModel.selectedAgentId ?? ""
@@ -342,6 +350,9 @@ struct SettingsTab: View {
guard !instanceId.isEmpty else { return }
GatewaySettingsStore.saveGatewayPassword(trimmed, instanceId: instanceId)
}
+ .onChange(of: self.talkElevenLabsApiKey) { _, newValue in
+ GatewaySettingsStore.saveTalkElevenLabsApiKey(newValue)
+ }
.onChange(of: self.manualGatewayPort) { _, _ in
self.syncManualPortText()
}
diff --git a/apps/ios/Sources/Status/StatusPill.swift b/apps/ios/Sources/Status/StatusPill.swift
index cd81c011bb1..ea5e425c49d 100644
--- a/apps/ios/Sources/Status/StatusPill.swift
+++ b/apps/ios/Sources/Status/StatusPill.swift
@@ -2,6 +2,8 @@ import SwiftUI
struct StatusPill: View {
@Environment(\.scenePhase) private var scenePhase
+ @Environment(\.accessibilityReduceMotion) private var reduceMotion
+ @Environment(\.colorSchemeContrast) private var contrast
enum GatewayState: Equatable {
case connected
@@ -49,11 +51,11 @@ struct StatusPill: View {
Circle()
.fill(self.gateway.color)
.frame(width: 9, height: 9)
- .scaleEffect(self.gateway == .connecting ? (self.pulse ? 1.15 : 0.85) : 1.0)
- .opacity(self.gateway == .connecting ? (self.pulse ? 1.0 : 0.6) : 1.0)
+ .scaleEffect(self.gateway == .connecting && !self.reduceMotion ? (self.pulse ? 1.15 : 0.85) : 1.0)
+ .opacity(self.gateway == .connecting && !self.reduceMotion ? (self.pulse ? 1.0 : 0.6) : 1.0)
Text(self.gateway.title)
- .font(.system(size: 13, weight: .semibold))
+ .font(.subheadline.weight(.semibold))
.foregroundStyle(.primary)
}
@@ -64,17 +66,17 @@ struct StatusPill: View {
if let activity {
HStack(spacing: 6) {
Image(systemName: activity.systemImage)
- .font(.system(size: 13, weight: .semibold))
+ .font(.subheadline.weight(.semibold))
.foregroundStyle(activity.tint ?? .primary)
Text(activity.title)
- .font(.system(size: 13, weight: .semibold))
+ .font(.subheadline.weight(.semibold))
.foregroundStyle(.primary)
.lineLimit(1)
}
.transition(.opacity.combined(with: .move(edge: .top)))
} else {
Image(systemName: self.voiceWakeEnabled ? "mic.fill" : "mic.slash")
- .font(.system(size: 13, weight: .semibold))
+ .font(.subheadline.weight(.semibold))
.foregroundStyle(self.voiceWakeEnabled ? .primary : .secondary)
.accessibilityLabel(self.voiceWakeEnabled ? "Voice Wake enabled" : "Voice Wake disabled")
.transition(.opacity.combined(with: .move(edge: .top)))
@@ -87,21 +89,28 @@ struct StatusPill: View {
.fill(.ultraThinMaterial)
.overlay {
RoundedRectangle(cornerRadius: 14, style: .continuous)
- .strokeBorder(.white.opacity(self.brighten ? 0.24 : 0.18), lineWidth: 0.5)
+ .strokeBorder(
+ .white.opacity(self.contrast == .increased ? 0.5 : (self.brighten ? 0.24 : 0.18)),
+ lineWidth: self.contrast == .increased ? 1.0 : 0.5
+ )
}
.shadow(color: .black.opacity(0.25), radius: 12, y: 6)
}
}
.buttonStyle(.plain)
- .accessibilityLabel("Status")
+ .accessibilityLabel("Connection Status")
.accessibilityValue(self.accessibilityValue)
- .onAppear { self.updatePulse(for: self.gateway, scenePhase: self.scenePhase) }
+ .accessibilityHint("Double tap to open settings")
+ .onAppear { self.updatePulse(for: self.gateway, scenePhase: self.scenePhase, reduceMotion: self.reduceMotion) }
.onDisappear { self.pulse = false }
.onChange(of: self.gateway) { _, newValue in
- self.updatePulse(for: newValue, scenePhase: self.scenePhase)
+ self.updatePulse(for: newValue, scenePhase: self.scenePhase, reduceMotion: self.reduceMotion)
}
.onChange(of: self.scenePhase) { _, newValue in
- self.updatePulse(for: self.gateway, scenePhase: newValue)
+ self.updatePulse(for: self.gateway, scenePhase: newValue, reduceMotion: self.reduceMotion)
+ }
+ .onChange(of: self.reduceMotion) { _, newValue in
+ self.updatePulse(for: self.gateway, scenePhase: self.scenePhase, reduceMotion: newValue)
}
.animation(.easeInOut(duration: 0.18), value: self.activity?.title)
}
@@ -113,9 +122,9 @@ struct StatusPill: View {
return "\(self.gateway.title), Voice Wake \(self.voiceWakeEnabled ? "enabled" : "disabled")"
}
- private func updatePulse(for gateway: GatewayState, scenePhase: ScenePhase) {
- guard gateway == .connecting, scenePhase == .active else {
- withAnimation(.easeOut(duration: 0.2)) { self.pulse = false }
+ private func updatePulse(for gateway: GatewayState, scenePhase: ScenePhase, reduceMotion: Bool) {
+ guard gateway == .connecting, scenePhase == .active, !reduceMotion else {
+ withAnimation(reduceMotion ? .none : .easeOut(duration: 0.2)) { self.pulse = false }
return
}
diff --git a/apps/ios/Sources/Status/VoiceWakeToast.swift b/apps/ios/Sources/Status/VoiceWakeToast.swift
index b7942f2036f..ef6fc1295a7 100644
--- a/apps/ios/Sources/Status/VoiceWakeToast.swift
+++ b/apps/ios/Sources/Status/VoiceWakeToast.swift
@@ -1,17 +1,19 @@
import SwiftUI
struct VoiceWakeToast: View {
+ @Environment(\.colorSchemeContrast) private var contrast
+
var command: String
var brighten: Bool = false
var body: some View {
HStack(spacing: 10) {
Image(systemName: "mic.fill")
- .font(.system(size: 14, weight: .semibold))
+ .font(.subheadline.weight(.semibold))
.foregroundStyle(.primary)
Text(self.command)
- .font(.system(size: 14, weight: .semibold))
+ .font(.subheadline.weight(.semibold))
.foregroundStyle(.primary)
.lineLimit(1)
.truncationMode(.tail)
@@ -23,11 +25,14 @@ struct VoiceWakeToast: View {
.fill(.ultraThinMaterial)
.overlay {
RoundedRectangle(cornerRadius: 14, style: .continuous)
- .strokeBorder(.white.opacity(self.brighten ? 0.24 : 0.18), lineWidth: 0.5)
+ .strokeBorder(
+ .white.opacity(self.contrast == .increased ? 0.5 : (self.brighten ? 0.24 : 0.18)),
+ lineWidth: self.contrast == .increased ? 1.0 : 0.5
+ )
}
.shadow(color: .black.opacity(0.25), radius: 12, y: 6)
}
- .accessibilityLabel("Voice Wake")
- .accessibilityValue(self.command)
+ .accessibilityLabel("Voice Wake triggered")
+ .accessibilityValue("Command: \(self.command)")
}
}
diff --git a/apps/ios/Sources/Voice/TalkModeManager.swift b/apps/ios/Sources/Voice/TalkModeManager.swift
index 8351a6d5f9a..86aacd2bb4f 100644
--- a/apps/ios/Sources/Voice/TalkModeManager.swift
+++ b/apps/ios/Sources/Voice/TalkModeManager.swift
@@ -16,6 +16,7 @@ import Speech
final class TalkModeManager: NSObject {
private typealias SpeechRequest = SFSpeechAudioBufferRecognitionRequest
private static let defaultModelIdFallback = "eleven_v3"
+ private static let redactedConfigSentinel = "__OPENCLAW_REDACTED__"
var isEnabled: Bool = false
var isListening: Bool = false
var isSpeaking: Bool = false
@@ -1668,6 +1669,15 @@ extension TalkModeManager {
return value.allSatisfy { $0.isLetter || $0.isNumber || $0 == "-" || $0 == "_" }
}
+ private static func normalizedTalkApiKey(_ raw: String?) -> String? {
+ let trimmed = (raw ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !trimmed.isEmpty else { return nil }
+ guard trimmed != Self.redactedConfigSentinel else { return nil }
+ // Config values may be env placeholders (for example `${ELEVENLABS_API_KEY}`).
+ if trimmed.hasPrefix("${"), trimmed.hasSuffix("}") { return nil }
+ return trimmed
+ }
+
func reloadConfig() async {
guard let gateway else { return }
do {
@@ -1699,7 +1709,15 @@ extension TalkModeManager {
}
self.defaultOutputFormat = (talk?["outputFormat"] as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines)
- self.apiKey = (talk?["apiKey"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
+ let rawConfigApiKey = (talk?["apiKey"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
+ let configApiKey = Self.normalizedTalkApiKey(rawConfigApiKey)
+ let localApiKey = Self.normalizedTalkApiKey(GatewaySettingsStore.loadTalkElevenLabsApiKey())
+ if rawConfigApiKey == Self.redactedConfigSentinel {
+ self.apiKey = (localApiKey?.isEmpty == false) ? localApiKey : nil
+ GatewayDiagnostics.log("talk config apiKey redacted; using local override if present")
+ } else {
+ self.apiKey = (localApiKey?.isEmpty == false) ? localApiKey : configApiKey
+ }
if let interrupt = talk?["interruptOnSpeech"] as? Bool {
self.interruptOnSpeech = interrupt
}