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 }