fix(ios): localize setup and status surfaces

This commit is contained in:
Vincent Koc
2026-07-12 07:17:03 +02:00
committed by Vincent Koc
parent f038f509d9
commit 6d4afffec3
23 changed files with 662 additions and 398 deletions
@@ -38,7 +38,7 @@ struct SettingsChannelsDestination: View {
VStack(alignment: .leading, spacing: 3) {
Text("Channels / Integrations")
.font(OpenClawType.headline)
Text(self.summaryDetail)
Text(verbatim: self.summaryDetail)
.font(OpenClawType.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
@@ -67,7 +67,7 @@ struct SettingsChannelsDestination: View {
ProStatusRow(
icon: "exclamationmark.triangle",
title: "Channel status unavailable",
detail: errorText,
detail: .verbatim(errorText),
value: "error",
color: OpenClawBrand.warn)
} else if !self.canRead {
@@ -136,25 +136,26 @@ struct SettingsChannelsDestination: View {
}
private var headerValue: String? {
if self.isLoading { return "Loading" }
guard self.canRead else { return "Offline" }
return "\(self.channelEntries.count)"
if self.isLoading { return String(localized: "Loading") }
guard self.canRead else { return String(localized: "Offline") }
return self.channelEntries.count.formatted()
}
private var summaryDetail: String {
guard self.canRead else {
return "Connect to load channel integrations."
return String(localized: "Connect to load channel integrations.")
}
if let errorText {
return errorText
}
return "Installed channel clients, account state, and message-routing readiness."
return String(
localized: "Installed channel clients, account state, and message-routing readiness.")
}
private var summaryValue: String {
guard self.canRead else { return "offline" }
if self.isLoading { return "loading" }
if self.errorText != nil { return "error" }
guard self.canRead else { return String(localized: "offline") }
if self.isLoading { return String(localized: "loading") }
if self.errorText != nil { return String(localized: "error") }
let configured = self.channelEntries.count(where: { $0.configured })
return "\(configured)/\(self.channelEntries.count)"
}
@@ -301,7 +302,10 @@ struct SettingsChannelsDestination: View {
}
static func fallbackDetail(_ id: String) -> String {
self.fallbackMetadata[id.lowercased()]?.detail ?? "Channel integration"
if id.lowercased() == "clickclack" {
return String(localized: "Self-hosted chat bot routing.")
}
return String(localized: "Channel integration")
}
static func fallbackSystemImage(_ id: String) -> String {
@@ -311,18 +315,21 @@ struct SettingsChannelsDestination: View {
private static let fallbackMetadata: [String: SettingsChannelFallbackMetadata] = [
"clickclack": SettingsChannelFallbackMetadata(
label: "ClickClack",
detail: "Self-hosted chat bot routing.",
systemImage: "bubble.left.and.bubble.right"),
]
private static func relativeTime(_ milliseconds: Int) -> String {
let age = max(0, Int(Date().timeIntervalSince1970 * 1000) - milliseconds)
let minutes = age / 60000
if minutes < 1 { return "now" }
if minutes < 60 { return "\(minutes)m ago" }
if minutes < 1 { return String(localized: "now") }
if minutes < 60 {
return String(format: String(localized: "%@m ago"), minutes.formatted())
}
let hours = minutes / 60
if hours < 24 { return "\(hours)h ago" }
return "\(hours / 24)d ago"
if hours < 24 {
return String(format: String(localized: "%@h ago"), hours.formatted())
}
return String(format: String(localized: "%@d ago"), (hours / 24).formatted())
}
private static func message(for error: Error) -> String {
@@ -346,14 +353,14 @@ private struct SettingsChannelRow: View {
HStack(alignment: .top, spacing: 12) {
ProIconBadge(systemName: self.entry.systemImage, color: self.entry.color)
VStack(alignment: .leading, spacing: 4) {
Text(self.entry.label)
Text(verbatim: self.entry.label)
.font(OpenClawType.subheadSemiBold)
Text(self.entry.detailText)
Text(verbatim: self.entry.detailText)
.font(OpenClawType.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
if let lastError = self.entry.lastError {
Text(lastError)
Text(verbatim: lastError)
.font(OpenClawType.caption2Medium)
.foregroundStyle(OpenClawBrand.warn)
.lineLimit(2)
@@ -385,9 +392,9 @@ private struct SettingsChannelRow: View {
.foregroundStyle(account.color)
.frame(width: 28, height: 28)
VStack(alignment: .leading, spacing: 2) {
Text(account.displayName)
Text(verbatim: account.displayName)
.font(OpenClawType.captionSemiBold)
Text(account.detailText)
Text(verbatim: account.detailText)
.font(OpenClawType.caption2)
.foregroundStyle(.secondary)
.lineLimit(1)
@@ -465,16 +472,19 @@ private struct SettingsChannelEntry: Identifiable {
}
var statusValue: String {
if self.connected { return "connected" }
if self.running { return "running" }
if self.linked { return "linked" }
if self.configured { return "configured" }
return "not set"
if self.connected { return String(localized: "connected") }
if self.running { return String(localized: "running") }
if self.linked { return String(localized: "linked") }
if self.configured { return String(localized: "configured") }
return String(localized: "not set")
}
var detailText: String {
if let lastActivityText {
return "\(self.detail) • active \(lastActivityText)"
return String(
format: String(localized: "%@ • active %@"),
self.detail,
lastActivityText)
}
if let unavailableReason {
return unavailableReason
@@ -485,7 +495,6 @@ private struct SettingsChannelEntry: Identifiable {
private struct SettingsChannelFallbackMetadata {
let label: String
let detail: String
let systemImage: String
}
@@ -508,24 +517,33 @@ private struct SettingsChannelAccount: Identifiable {
var detailText: String {
let state = if self.connected {
"connected"
String(localized: "connected")
} else if self.running {
"running"
String(localized: "running")
} else if self.linked {
"linked"
String(localized: "linked")
} else if self.configured {
"configured"
String(localized: "configured")
} else {
"not configured"
String(localized: "not configured")
}
let enabledText = self.enabled ? "enabled" : "disabled"
let enabledText = self.enabled
? String(localized: "enabled")
: String(localized: "disabled")
if let healthState, !healthState.isEmpty {
return "\(state), \(enabledText), \(healthState)"
return String(
format: String(localized: "%@, %@, %@"),
state,
enabledText,
healthState)
}
if let lastError, !lastError.isEmpty {
return "\(state), \(enabledText), error"
return String(
format: String(localized: "%@, %@, error"),
state,
enabledText)
}
return "\(state), \(enabledText)"
return String(format: String(localized: "%@, %@"), state, enabledText)
}
var color: Color {
@@ -553,7 +571,7 @@ private enum SettingsChannelError: Error {
var message: String {
switch self {
case .invalidPayload:
"Could not encode channel request."
String(localized: "Could not encode channel request.")
}
}
}
@@ -7,24 +7,24 @@ import UserNotifications
extension SettingsProTab {
func detailStatusCard(
icon: String,
title: String,
detail: String,
value: String,
title: OpenClawTextValue,
detail: OpenClawTextValue,
value: OpenClawTextValue,
color: Color) -> some View
{
Section {
HStack(spacing: 12) {
SettingsIcon(systemName: icon, color: color)
VStack(alignment: .leading, spacing: 2) {
Text(title)
title.text
.font(OpenClawType.headline)
Text(detail)
detail.text
.font(OpenClawType.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
Spacer(minLength: 8)
Text(value)
value.text
.font(OpenClawType.subheadMedium)
.foregroundStyle(color)
}
@@ -36,67 +36,71 @@ extension SettingsProTab {
self.diagnosticCheckRow(
icon: "stethoscope",
title: "Last Run",
detail: self.diagnosticsLastRunText,
value: self.diagnosticsRunValue,
detail: .verbatim(self.diagnosticsLastRunText),
value: .verbatim(self.diagnosticsRunValue),
color: self.diagnosticsRunColor)
self.diagnosticCheckRow(
icon: "antenna.radiowaves.left.and.right",
title: "Gateway Link",
detail: self.gatewayStatusDetail,
value: self.gatewayStatusValue,
detail: .verbatim(self.gatewayStatusDetail),
value: .verbatim(self.gatewayStatusValue),
color: self.gatewayStatusColor)
self.diagnosticCheckRow(
icon: "dot.radiowaves.left.and.right",
title: "Discovery",
detail: self.gatewayController.discoveryStatusText,
value: "\(self.gatewayController.gateways.count)",
detail: .verbatim(self.gatewayController.discoveryStatusText),
value: .verbatim(self.gatewayController.gateways.count.formatted()),
color: self.gatewayController.gateways.isEmpty ? .secondary : OpenClawBrand.accent)
self.diagnosticCheckRow(
icon: "waveform",
title: "Talk Config",
detail: self.gatewayTalkConfigDetail,
value: self.gatewayTalkConfigValue,
detail: .verbatim(self.gatewayTalkConfigDetail),
value: .verbatim(self.gatewayTalkConfigValue),
color: self.gatewayTalkConfigColor)
self.diagnosticCheckRow(
icon: "bell",
title: "Notifications",
detail: "Approval and event alert channel",
value: self.notificationStatusText,
value: .verbatim(self.notificationStatusText),
color: self.notificationStatusColor)
self.diagnosticCheckRow(
icon: "rectangle.on.rectangle",
title: "Screen Capture",
detail: "Live foreground capture state",
value: self.appModel.screenRecordActive ? "live" : "idle",
value: .verbatim(self.appModel.screenRecordActive
? String(localized: "live")
: String(localized: "idle")),
color: self.appModel.screenRecordActive ? OpenClawBrand.ok : .secondary)
self.diagnosticCheckRow(
icon: "mic",
title: "Voice Wake",
detail: self.appModel.voiceWake.statusText,
value: self.voiceWakeEnabled ? "on" : "off",
detail: .verbatim(self.appModel.voiceWake.statusText),
value: .verbatim(self.voiceWakeEnabled
? String(localized: "on")
: String(localized: "off")),
color: self.voiceWakeEnabled ? OpenClawBrand.ok : .secondary)
}
}
func diagnosticCheckRow(
icon: String,
title: String,
detail: String,
value: String,
title: OpenClawTextValue,
detail: OpenClawTextValue,
value: OpenClawTextValue,
color: Color) -> some View
{
HStack(spacing: 12) {
SettingsIcon(systemName: icon, color: color)
VStack(alignment: .leading, spacing: 2) {
Text(title)
title.text
.font(OpenClawType.subheadSemiBold)
Text(detail)
detail.text
.font(OpenClawType.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
}
Spacer(minLength: 8)
Text(value)
value.text
.font(OpenClawType.subhead)
.foregroundStyle(.secondary)
}
@@ -119,7 +123,9 @@ extension SettingsProTab {
func switchGateway(to entry: GatewaySettingsStore.GatewayRegistryEntry) async {
guard self.connectingGateway == nil else { return }
self.connectingGateway = .gateway(entry.id)
self.setupStatusText = "Switching to \(entry.name)"
self.setupStatusText = String(
format: String(localized: "Switching to %@…"),
entry.name)
defer {
self.connectingGateway = nil
self.refreshGatewayRegistry()
@@ -135,14 +141,18 @@ extension SettingsProTab {
guard let entry = self.pendingForgetGateway else { return }
self.pendingForgetGateway = nil
guard self.gatewayController.forgetGateway(stableID: entry.stableID) else {
self.setupStatusText = "Could not forget \(entry.name)."
self.setupStatusText = String(
format: String(localized: "Could not forget %@."),
entry.name)
self.refreshGatewayRegistry()
return
}
if GatewayStableIdentifier.matches(self.gatewayCredentialFieldStableID, entry.stableID) {
self.clearManualCredentialFields()
}
self.setupStatusText = "Forgot \(entry.name)."
self.setupStatusText = String(
format: String(localized: "Forgot %@."),
entry.name)
self.refreshGatewayRegistry()
}
@@ -156,11 +166,13 @@ extension SettingsProTab {
let endpoint = if let host = entry.host, let port = entry.port {
"\(host):\(port)"
} else {
"Saved endpoint unavailable"
String(localized: "Saved endpoint unavailable")
}
return entry.useTLS ? "\(endpoint) • TLS" : endpoint
case .discovered:
return entry.useTLS ? "Discovered • TLS" : "Discovered"
return entry.useTLS
? String(localized: "Discovered • TLS")
: String(localized: "Discovered")
}
}
@@ -285,11 +297,11 @@ extension SettingsProTab {
guard await self.applySetupCode(attemptID: attemptID) else { return }
let host = self.manualGatewayHost.trimmingCharacters(in: .whitespacesAndNewlines)
guard self.resolvedManualPort(host: host) != nil else {
self.setupStatusText = "Failed: invalid port"
self.setupStatusText = String(localized: "Failed: invalid port")
return
}
guard await self.preflightGateway(host: host) else { return }
self.setupStatusText = "Setup code applied. Connecting..."
self.setupStatusText = String(localized: "Setup code applied. Connecting...")
await self.connectManual(setupAttemptID: attemptID)
}
@@ -303,8 +315,13 @@ extension SettingsProTab {
self.setupCode = ""
self.setupStatusText = nil
self.stagedGatewaySetupLink = link
let security = link.tls ? "TLS" : "plain"
self.setupStatusText = "Setup link loaded for \(link.host):\(link.port) (\(security)). Tap Connect to apply."
let security = link.tls ? String(localized: "TLS") : String(localized: "plain")
self.setupStatusText = String(
format: String(
localized: "Setup link loaded for %@:%@ (%@). Tap Connect to apply."),
link.host,
link.port.formatted(),
security)
}
@discardableResult
@@ -312,21 +329,22 @@ extension SettingsProTab {
let raw = self.setupCode.trimmingCharacters(in: .whitespacesAndNewlines)
let stagedLink = self.stagedGatewaySetupLink
guard !raw.isEmpty || stagedLink != nil else {
self.setupStatusText = "Paste a setup code to continue."
self.setupStatusText = String(localized: "Paste a setup code to continue.")
return false
}
if AppleReviewDemoMode.isSetupCode(raw) {
self.stagedGatewaySetupLink = nil
self.setupCode = ""
self.setupStatusText = "Apple Review demo mode enabled."
self.setupStatusText = String(localized: "Apple Review demo mode enabled.")
self.appModel.enterAppleReviewDemoMode()
self.pendingTargetSuppression.releaseAutoConnect(.setupLink, controller: self.gatewayController)
return false
}
guard let parsedLink = raw.isEmpty ? stagedLink : GatewayConnectDeepLink.fromSetupInput(raw) else {
self.setupStatusText = "Setup code not recognized or uses an insecure ws:// gateway URL."
self.setupStatusText = String(
localized: "Setup code not recognized or uses an insecure ws:// gateway URL.")
return false
}
let link = await self.gatewayController.selectReachableSetupLink(parsedLink)
@@ -371,13 +389,13 @@ extension SettingsProTab {
self.pendingTargetSuppression.replace(owner: .qrScanner, lease: lease)
self.scannerScanID = self.scannerResultHandoff.beginScan()
self.connectingGateway = nil
self.setupStatusText = "Opening QR scanner..."
self.setupStatusText = String(localized: "Opening QR scanner...")
self.showQRScanner = true
}
func queueScannedResult(_ result: QRScannerResult, scanID: UInt64) {
guard self.scannerResultHandoff.queue(result, scanID: scanID) else { return }
self.setupStatusText = "QR loaded. Closing scanner..."
self.setupStatusText = String(localized: "QR loaded. Closing scanner...")
self.showQRScanner = false
}
@@ -407,7 +425,7 @@ extension SettingsProTab {
self.showQRScanner = false
self.setupCode = ""
self.stagedGatewaySetupLink = nil
self.setupStatusText = "Apple Review demo mode enabled."
self.setupStatusText = String(localized: "Apple Review demo mode enabled.")
self.appModel.enterAppleReviewDemoMode()
self.pendingTargetSuppression.releaseAutoConnect(.qrScanner, controller: self.gatewayController)
}
@@ -431,10 +449,13 @@ extension SettingsProTab {
let link = await self.gatewayController.selectReachableSetupLink(parsedLink)
guard self.setupAttemptID == attemptID else { return }
await self.applyGatewayLink(link)
self.setupStatusText = "QR loaded. Connecting to \(link.host):\(link.port)..."
self.setupStatusText = String(
format: String(localized: "QR loaded. Connecting to %@:%@..."),
link.host,
link.port.formatted())
let host = self.manualGatewayHost.trimmingCharacters(in: .whitespacesAndNewlines)
guard self.resolvedManualPort(host: host) != nil else {
self.setupStatusText = "Failed: invalid port"
self.setupStatusText = String(localized: "Failed: invalid port")
return
}
guard await self.preflightGateway(host: host) else { return }
@@ -455,15 +476,15 @@ extension SettingsProTab {
}
let host = self.manualGatewayHost.trimmingCharacters(in: .whitespacesAndNewlines)
guard !host.isEmpty else {
self.setupStatusText = "Failed: host required"
self.setupStatusText = String(localized: "Failed: host required")
return
}
guard self.manualPortIsValid else {
self.setupStatusText = "Failed: invalid port"
self.setupStatusText = String(localized: "Failed: invalid port")
return
}
guard let port = self.resolvedManualPort(host: host) else {
self.setupStatusText = "Failed: invalid port"
self.setupStatusText = String(localized: "Failed: invalid port")
return
}
self.connectingGateway = .manual
@@ -520,7 +541,8 @@ extension SettingsProTab {
let trimmed = host.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return false }
if Self.isTailnetHostOrIP(trimmed), !Self.hasTailnetIPv4() {
self.setupStatusText = "Tailscale is off on this device. Turn it on, then try again."
self.setupStatusText = String(
localized: "Tailscale is off on this device. Turn it on, then try again.")
return false
}
self.gatewayController.requestLocalNetworkAccess(reason: "settings_preflight")
@@ -897,30 +919,31 @@ extension SettingsProTab {
func title(for route: SettingsRoute) -> String {
switch route {
case .gateway: "Gateway"
case .appleWatch: "Apple Watch"
case .approvals: "Approvals"
case .permissions: "Permissions"
case .channels: "Channels"
case .voice: "Voice & Talk"
case .diagnostics: "Diagnostics"
case .privacy: "Privacy"
case .notifications: "Notifications"
case .licenses: "Licenses"
case .about: "About"
case .gateway: String(localized: "Gateway")
case .appleWatch: String(localized: "Apple Watch")
case .approvals: String(localized: "Approvals")
case .permissions: String(localized: "Permissions")
case .channels: String(localized: "Channels")
case .voice: String(localized: "Voice & Talk")
case .diagnostics: String(localized: "Diagnostics")
case .privacy: String(localized: "Privacy")
case .notifications: String(localized: "Notifications")
case .licenses: String(localized: "Licenses")
case .about: String(localized: "About")
}
}
func sendDirectWatchSetup() async {
guard !self.isSendingWatchDirectSetup else { return }
self.isSendingWatchDirectSetup = true
self.watchDirectSetupStatusText = "Preparing one-time setup…"
self.watchDirectSetupStatusText = String(localized: "Preparing one-time setup…")
defer { self.isSendingWatchDirectSetup = false }
do {
let result = try await self.appModel.sendDirectWatchSetup()
self.watchDirectSetupStatusText = result.deliveredImmediately
? "Setup sent. Open OpenClaw on the watch to connect."
: "Setup queued for the watch. Open OpenClaw before the code expires."
? String(localized: "Setup sent. Open OpenClaw on the watch to connect.")
: String(
localized: "Setup queued for the watch. Open OpenClaw before the code expires.")
} catch {
self.watchDirectSetupStatusText = error.localizedDescription
}
@@ -1009,16 +1032,18 @@ extension SettingsProTab {
var tailnetWarningText: String? {
let host = self.manualGatewayHost.trimmingCharacters(in: .whitespacesAndNewlines)
guard !host.isEmpty, Self.isTailnetHostOrIP(host), !Self.hasTailnetIPv4() else { return nil }
return "This gateway is on your tailnet. Turn on Tailscale on this device, then tap Connect."
return String(
localized: "This gateway is on your tailnet. Turn on Tailscale on this device, then tap Connect.")
}
func friendlyGatewayMessage(from raw: String) -> String? {
let lower = raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
if lower.contains("pairing required") {
return "Pairing required. Run /pair approve in your OpenClaw chat, then connect again."
return String(
localized: "Pairing required. Run /pair approve in your OpenClaw chat, then connect again.")
}
if lower.contains("device nonce required") || lower.contains("device nonce mismatch") {
return "Secure handshake failed. Check Tailscale, then connect again."
return String(localized: "Secure handshake failed. Check Tailscale, then connect again.")
}
if lower.contains("tls fingerprint verification timed out")
|| lower.contains("no tls endpoint detected")
@@ -1026,19 +1051,25 @@ extension SettingsProTab {
return raw.trimmingCharacters(in: .whitespacesAndNewlines)
}
if lower.contains("timed out") {
return "Connection timed out. Make sure Tailscale is connected, then try again."
return String(
localized: "Connection timed out. Make sure Tailscale is connected, then try again.")
}
if lower.contains("unauthorized role") {
return "Connected, but some controls are restricted for nodes. This is expected."
return String(
localized: "Connected, but some controls are restricted for nodes. This is expected.")
}
return nil
}
func isTransientSetupStatus(_ raw: String) -> Bool {
let lower = raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
return lower == "setup code applied. connecting..."
|| lower.hasPrefix("qr loaded. connecting to ")
|| lower == "checking gateway reachability..."
let setupApplied = String(localized: "Setup code applied. Connecting...").lowercased()
let checkingReachability = String(localized: "Checking gateway reachability...").lowercased()
let qrFormat = String(localized: "QR loaded. Connecting to %@:%@...").lowercased()
let qrPrefix = qrFormat.components(separatedBy: "%@").first ?? qrFormat
return lower == setupApplied
|| (!qrPrefix.isEmpty && lower.hasPrefix(qrPrefix))
|| lower == checkingReachability
}
var shouldShowRealtimeVoicePicker: Bool {
@@ -1076,15 +1107,19 @@ extension SettingsProTab {
}
var talkApiKeyStatus: String {
guard self.appModel.talkMode.gatewayTalkConfigLoaded else { return "Not loaded" }
return self.appModel.talkMode.gatewayTalkApiKeyConfigured ? "Configured" : "Not configured"
guard self.appModel.talkMode.gatewayTalkConfigLoaded else {
return String(localized: "Not loaded")
}
return self.appModel.talkMode.gatewayTalkApiKeyConfigured
? String(localized: "Configured")
: String(localized: "Not configured")
}
var gatewayTalkActiveVoiceDetail: String {
let title = self.appModel.talkMode.gatewayTalkActiveModeTitle.trimmingCharacters(in: .whitespacesAndNewlines)
let subtitle = (self.appModel.talkMode.gatewayTalkActiveModeSubtitle ?? "")
.trimmingCharacters(in: .whitespacesAndNewlines)
if title.isEmpty { return "Not active" }
if title.isEmpty { return String(localized: "Not active") }
if subtitle.isEmpty { return title }
return "\(title)\(subtitle)"
}
@@ -1113,13 +1148,17 @@ extension SettingsProTab {
}
var gatewayStatusDetail: String {
if self.appModel.isAppleReviewDemoModeEnabled { return "Apple Review demo mode" }
return self.gatewayConnected ? "Connected" : self.appModel.gatewayDisplayStatusText
if self.appModel.isAppleReviewDemoModeEnabled {
return String(localized: "Apple Review demo mode")
}
return self.gatewayConnected
? String(localized: "Connected")
: self.appModel.gatewayDisplayStatusText
}
var gatewayStatusValue: String {
if self.appModel.isAppleReviewDemoModeEnabled { return "demo" }
return self.gatewayConnected ? "online" : "offline"
if self.appModel.isAppleReviewDemoModeEnabled { return String(localized: "demo") }
return self.gatewayConnected ? String(localized: "online") : String(localized: "offline")
}
var gatewayStatusColor: Color {
@@ -1137,22 +1176,27 @@ extension SettingsProTab {
var approvalEmptyDetail: String {
if self.appModel.isAppleReviewDemoModeEnabled {
return "Live gateway requests are disabled in demo mode."
return String(localized: "Live gateway requests are disabled in demo mode.")
}
if self.notificationsNeedAttention {
return "Foreground approvals still appear while OpenClaw is connected."
return String(
localized: "Foreground approvals still appear while OpenClaw is connected.")
}
return self.gatewayConnected ? "Gateway requests will appear here." : "Connect to the gateway."
return self.gatewayConnected
? String(localized: "Gateway requests will appear here.")
: String(localized: "Connect to the gateway.")
}
var gatewayTalkConfigDetail: String {
if self.appModel.isAppleReviewDemoModeEnabled { return "Demo mode only" }
if self.appModel.isAppleReviewDemoModeEnabled { return String(localized: "Demo mode only") }
return self.appModel.talkMode.gatewayTalkTransportLabel
}
var gatewayTalkConfigValue: String {
if self.appModel.isAppleReviewDemoModeEnabled { return "demo" }
return self.appModel.talkMode.gatewayTalkConfigLoaded ? "loaded" : "missing"
if self.appModel.isAppleReviewDemoModeEnabled { return String(localized: "demo") }
return self.appModel.talkMode.gatewayTalkConfigLoaded
? String(localized: "loaded")
: String(localized: "missing")
}
var gatewayTalkConfigColor: Color {
@@ -1161,7 +1205,7 @@ extension SettingsProTab {
}
var gatewayAddress: String {
self.appModel.gatewayRemoteAddress ?? "Waiting for gateway"
self.appModel.gatewayRemoteAddress ?? String(localized: "Waiting for gateway")
}
var gatewayServer: String {
@@ -1177,7 +1221,12 @@ extension SettingsProTab {
}
var approvalWaitingText: String {
self.pendingApprovalCount == 1 ? "1 waiting" : "\(self.pendingApprovalCount) waiting"
if self.pendingApprovalCount == 1 {
return String(localized: "1 waiting")
}
return String(
format: String(localized: "%@ waiting"),
self.pendingApprovalCount.formatted())
}
var notificationsNeedAttention: Bool {
@@ -1186,41 +1235,54 @@ extension SettingsProTab {
var approvalItems: [SettingsApprovalItem] {
guard let pendingApproval else { return [] }
let pendingTitle = pendingApproval.commandPreview.map(OpenClawTextValue.verbatim)
?? OpenClawTextValue.localized("Review gateway action")
let agentDetail = String(
format: String(localized: "Agent: %@"),
self.appModel.activeAgentName)
return [
SettingsApprovalItem(
id: "pending-real",
icon: "terminal.fill",
title: pendingApproval.commandPreview ?? "Review gateway action",
detail: "Agent: \(self.appModel.activeAgentName)",
priority: self.appModel.pendingExecApprovalPromptResolving ? "Resolving" : "High",
title: pendingTitle,
detail: .verbatim(agentDetail),
priority: self.appModel.pendingExecApprovalPromptResolving
? .localized("Resolving")
: .localized("High"),
color: OpenClawBrand.danger),
SettingsApprovalItem(
id: "pending-context",
icon: "doc.text.fill",
title: pendingApproval.allowsAllowAlways ? "Permission can be saved" : "One-time approval",
title: pendingApproval.allowsAllowAlways
? .localized("Permission can be saved")
: .localized("One-time approval"),
detail: "Gateway request",
priority: pendingApproval.allowsAllowAlways ? "Medium" : "Review",
priority: pendingApproval.allowsAllowAlways
? .localized("Medium")
: .localized("Review"),
color: OpenClawBrand.warn),
]
}
var voiceDetail: String {
if self.talkEnabled, self.voiceWakeEnabled { return "Talk + Wake" }
if self.talkEnabled { return "Talk on" }
if self.voiceWakeEnabled { return "Wake on" }
return "Off"
if self.talkEnabled, self.voiceWakeEnabled { return String(localized: "Talk + Wake") }
if self.talkEnabled { return String(localized: "Talk on") }
if self.voiceWakeEnabled { return String(localized: "Wake on") }
return String(localized: "Off")
}
var diagnosticsHealthValue: String {
if self.appModel.isAppleReviewDemoModeEnabled { return "demo" }
if self.gatewayConnected { return "ready" }
if self.gatewayController.gateways.isEmpty { return "check" }
return "partial"
if self.appModel.isAppleReviewDemoModeEnabled { return String(localized: "demo") }
if self.gatewayConnected { return String(localized: "ready") }
if self.gatewayController.gateways.isEmpty { return String(localized: "check") }
return String(localized: "partial")
}
var diagnosticsRunValue: String {
guard let diagnosticsIssueCount else { return "pending" }
return diagnosticsIssueCount == 0 ? "pass" : "\(diagnosticsIssueCount)"
guard let diagnosticsIssueCount else { return String(localized: "pending") }
return diagnosticsIssueCount == 0
? String(localized: "pass")
: diagnosticsIssueCount.formatted()
}
var diagnosticsRunColor: Color {
@@ -1230,7 +1292,7 @@ extension SettingsProTab {
var locationPermissionDetailText: String? {
if self.isChangingLocationMode {
return "Requesting iOS location permission…"
return String(localized: "Requesting iOS location permission…")
}
return self.locationSettingsPresentation.statusText
}
@@ -1294,15 +1356,17 @@ extension SettingsProTab {
let host = PushBuildConfig.current.relayBaseURL.flatMap {
URLComponents(url: $0, resolvingAgainstBaseURL: false)?.host
} ?? "ios-push-relay.openclaw.ai"
return """
This build uses OpenClaw's hosted push relay at \(host) for notification \
delivery data.
"""
return String(
format: String(
localized: "This build uses OpenClaw's hosted push relay at %@ for notification delivery data."),
host)
}
return "This build is not configured to use OpenClaw's hosted push relay."
return String(
localized: "This build is not configured to use OpenClaw's hosted push relay.")
}
var notificationRelayDisclosureMessage: String {
"Enabling this sends delivery data through OpenClaw's hosted push relay."
String(
localized: "Enabling this sends delivery data through OpenClaw's hosted push relay.")
}
}
@@ -118,9 +118,11 @@ extension SettingsProTab {
self.gatewayQuickSwitchMenu
}
}
SettingsDetailRow("Address", value: self.gatewayAddress)
SettingsDetailRow("Server", value: self.gatewayServer)
SettingsDetailRow("Agents", value: "\(self.appModel.gatewayAgents.count)")
SettingsDetailRow("Address", value: .verbatim(self.gatewayAddress))
SettingsDetailRow("Server", value: .verbatim(self.gatewayServer))
SettingsDetailRow(
"Agents",
value: .verbatim(self.appModel.gatewayAgents.count.formatted()))
self.gatewayActions
}
}
@@ -202,7 +204,7 @@ extension SettingsProTab {
func settingsListRow(
icon: String,
iconColor: Color,
title: String,
title: LocalizedStringKey,
route: SettingsRoute,
badgeValue: String? = nil) -> some View
{
@@ -278,16 +280,22 @@ extension SettingsProTab {
self.detailStatusCard(
icon: "antenna.radiowaves.left.and.right",
title: "Gateway",
detail: self.gatewayStatusDetail,
value: self.gatewayStatusValue,
detail: .verbatim(self.gatewayStatusDetail),
value: .verbatim(self.gatewayStatusValue),
color: self.gatewayStatusColor)
self.detailListCard {
SettingsDetailRow("Address", value: self.gatewayAddress)
SettingsDetailRow("Server", value: self.gatewayServer)
SettingsDetailRow("Discovered", value: "\(self.gatewayController.gateways.count)")
SettingsDetailRow("Default Agent", value: self.appModel.activeAgentName)
SettingsDetailRow("Agents", value: "\(self.appModel.gatewayAgents.count)")
SettingsDetailRow("Address", value: .verbatim(self.gatewayAddress))
SettingsDetailRow("Server", value: .verbatim(self.gatewayServer))
SettingsDetailRow(
"Discovered",
value: .verbatim(self.gatewayController.gateways.count.formatted()))
SettingsDetailRow(
"Default Agent",
value: .verbatim(self.appModel.activeAgentName))
SettingsDetailRow(
"Agents",
value: .verbatim(self.appModel.gatewayAgents.count.formatted()))
}
Section {
@@ -352,13 +360,16 @@ extension SettingsProTab {
self.detailStatusCard(
icon: "checkmark.shield.fill",
title: "Approvals",
detail: self.notificationsNeedAttention
? "Out-of-app approval alerts need notification permission."
: (self.pendingApprovalCount == 0 ? "No gateway actions are waiting for review." :
"Review pending gateway actions."),
detail: .verbatim(self.notificationsNeedAttention
? String(localized: "Out-of-app approval alerts need notification permission.")
: (self.pendingApprovalCount == 0
? String(localized: "No gateway actions are waiting for review.")
: String(localized: "Review pending gateway actions."))),
value: self.notificationsNeedAttention
? "Alerts Off"
: (self.pendingApprovalCount == 0 ? "clear" : self.approvalWaitingText),
? .verbatim(String(localized: "Alerts Off"))
: (self.pendingApprovalCount == 0
? .verbatim(String(localized: "clear"))
: .verbatim(self.approvalWaitingText)),
color: self.notificationsNeedAttention ? OpenClawBrand.warn :
(self.pendingApprovalCount == 0 ? OpenClawBrand.ok : OpenClawBrand.warn))
@@ -376,10 +387,17 @@ extension SettingsProTab {
self.detailStatusCard(
icon: "applewatch",
title: "Apple Watch",
detail: watchStatus.appInstalled
? "Relay remains available; direct mode adds an independent Gateway node."
: "Install the OpenClaw watch app before enabling direct mode.",
value: watchStatus.reachable ? "Reachable" : (watchStatus.appInstalled ? "Installed" : "Unavailable"),
detail: .verbatim(watchStatus.appInstalled
? String(
localized: "Relay remains available; direct mode adds an independent Gateway node.")
: String(
localized: "Install the OpenClaw watch app before enabling direct mode.")),
value: .verbatim(
watchStatus.reachable
? String(localized: "Reachable")
: (watchStatus.appInstalled
? String(localized: "Installed")
: String(localized: "Unavailable"))),
color: watchStatus.appInstalled ? OpenClawBrand.ok : OpenClawBrand.warn)
Section {
@@ -586,8 +604,8 @@ extension SettingsProTab {
self.detailStatusCard(
icon: "waveform",
title: "Voice & Talk",
detail: self.appModel.talkMode.gatewayTalkVoiceModeTitle,
value: self.voiceDetail,
detail: .verbatim(self.appModel.talkMode.gatewayTalkVoiceModeTitle),
value: .verbatim(self.voiceDetail),
color: self.talkEnabled || self.voiceWakeEnabled ? OpenClawBrand.accent : .secondary)
self.voiceFeatureCard
@@ -602,7 +620,7 @@ extension SettingsProTab {
icon: "checklist.checked",
title: "Health Check",
detail: "Run app, permission, and gateway-adjacent checks without editing setup.",
value: self.diagnosticsHealthValue,
value: .verbatim(self.diagnosticsHealthValue),
color: self.gatewayDiagnosticConnected ? OpenClawBrand.ok : OpenClawBrand.warn)
Section {
@@ -618,10 +636,14 @@ extension SettingsProTab {
self.diagnosticChecksCard
self.detailListCard {
SettingsDetailRow("Device", value: DeviceInfoHelper.deviceFamily())
SettingsDetailRow("Platform", value: DeviceInfoHelper.platformStringForDisplay())
SettingsDetailRow("App", value: DeviceInfoHelper.openClawVersionString())
SettingsDetailRow("Model", value: DeviceInfoHelper.modelIdentifier())
SettingsDetailRow("Device", value: .verbatim(DeviceInfoHelper.deviceFamily()))
SettingsDetailRow(
"Platform",
value: .verbatim(DeviceInfoHelper.platformStringForDisplay()))
SettingsDetailRow(
"App",
value: .verbatim(DeviceInfoHelper.openClawVersionString()))
SettingsDetailRow("Model", value: .verbatim(DeviceInfoHelper.modelIdentifier()))
}
self.diagnosticsAdvancedCard
@@ -670,7 +692,9 @@ extension SettingsProTab {
.labelsHidden()
.disabled(self.notificationStatus == .checking || self.isRequestingNotificationAuthorization)
.accessibilityIdentifier("settings-notifications-toggle")
.accessibilityValue(self.notificationServingActive ? "On" : "Off")
.accessibilityValue(self.notificationServingActive
? String(localized: "On")
: String(localized: "Off"))
.accessibilityHint("Turns OpenClaw notification delivery on or off")
}
@@ -792,8 +816,10 @@ extension SettingsProTab {
// Concise public details only; deep hardware identifiers live in Diagnostics.
detailListCard {
SettingsDetailRow("Device", value: DeviceInfoHelper.deviceFamily())
SettingsDetailRow("iOS", value: DeviceInfoHelper.iOSVersionStringForDisplay())
SettingsDetailRow("Device", value: .verbatim(DeviceInfoHelper.deviceFamily()))
SettingsDetailRow(
"iOS",
value: .verbatim(DeviceInfoHelper.iOSVersionStringForDisplay()))
}
Section {
@@ -826,7 +852,12 @@ extension SettingsProTab {
/// About link row with explicit branded label; shorthand `Link("Title", ...)`
/// would bypass the typography audit and OpenClawType styling.
func aboutLinkRow(title: String, icon: String, color: Color, url: URL) -> some View {
func aboutLinkRow(
title: LocalizedStringKey,
icon: String,
color: Color,
url: URL) -> some View
{
Link(destination: url) {
HStack {
Label {
@@ -843,10 +874,10 @@ extension SettingsProTab {
}
.contentShape(Rectangle())
}
.accessibilityLabel(title)
.accessibilityLabel(Text(title))
}
func toggleCard(title: String, isOn: Binding<Bool>) -> some View {
func toggleCard(title: LocalizedStringKey, isOn: Binding<Bool>) -> some View {
Section {
self.settingsToggle(title, isOn: isOn)
}
@@ -878,7 +909,9 @@ extension SettingsProTab {
.disabled(self.isChangingLocationMode)
.accessibilityIdentifier("settings-location-sharing-toggle")
.accessibilityLabel("Location Sharing")
.accessibilityValue(self.locationSettingsPresentation.sharingControlIsOn ? "On" : "Off")
.accessibilityValue(self.locationSettingsPresentation.sharingControlIsOn
? String(localized: "On")
: String(localized: "Off"))
if self.locationSettingsPresentation.showsAccessLevel,
let accessLevelText = self.locationSettingsPresentation.accessLevelText
@@ -1199,13 +1232,16 @@ extension SettingsProTab {
}
}
func gatewaySecureField(_ placeholder: String, text: Binding<String>) -> some View {
func gatewaySecureField(
_ placeholder: LocalizedStringKey,
text: Binding<String>) -> some View
{
ZStack(alignment: .leading) {
SecureField("", text: text)
.font(OpenClawType.subhead)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.accessibilityLabel(placeholder)
.accessibilityLabel(Text(placeholder))
if text.wrappedValue.isEmpty {
Text(placeholder)
.font(OpenClawType.subheadSemiBold)
@@ -1244,7 +1280,8 @@ extension SettingsProTab {
} label: {
SettingsDetailRow(
"Wake Words",
value: VoiceWakePreferences.displayString(for: self.voiceWake.triggerWords))
value: .verbatim(
VoiceWakePreferences.displayString(for: self.voiceWake.triggerWords)))
}
}
}
@@ -1279,13 +1316,19 @@ extension SettingsProTab {
}
.font(OpenClawType.body)
}
SettingsDetailRow("Voice Mode", value: self.appModel.talkMode.gatewayTalkVoiceModeTitle)
SettingsDetailRow("Active Voice", value: self.gatewayTalkActiveVoiceDetail)
SettingsDetailRow(
"Voice Mode",
value: .localized(self.appModel.talkMode.gatewayTalkVoiceModeTitle))
SettingsDetailRow(
"Active Voice",
value: .verbatim(self.gatewayTalkActiveVoiceDetail))
if let issue = self.gatewayTalkLastIssueDetail {
SettingsDetailRow("Last Voice Issue", value: issue)
SettingsDetailRow("Last Voice Issue", value: .verbatim(issue))
}
SettingsDetailRow("Transport", value: self.appModel.talkMode.gatewayTalkTransportLabel)
SettingsDetailRow("API Key", value: self.talkApiKeyStatus)
SettingsDetailRow(
"Transport",
value: .localized(self.appModel.talkMode.gatewayTalkTransportLabel))
SettingsDetailRow("API Key", value: .verbatim(self.talkApiKeyStatus))
}
}
}
@@ -1324,7 +1367,9 @@ extension SettingsProTab {
NavigationLink {
GatewayDiscoveryDebugLogView()
} label: {
SettingsDetailRow("Discovery Logs", value: self.gatewayController.discoveryStatusText)
SettingsDetailRow(
"Discovery Logs",
value: .verbatim(self.gatewayController.discoveryStatusText))
}
}
}
@@ -1333,12 +1378,12 @@ extension SettingsProTab {
Section("Device") {
TextField("Device Name", text: self.$displayName)
.font(OpenClawType.body)
SettingsDetailRow("Instance ID", value: self.instanceId)
SettingsDetailRow("Instance ID", value: .verbatim(self.instanceId))
}
}
func settingsToggle(
_ title: String,
_ title: LocalizedStringKey,
isOn: Binding<Bool>,
onChange: ((Bool) -> Void)? = nil) -> some View
{
@@ -1355,8 +1400,10 @@ extension SettingsProTab {
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.accessibilityLabel(title)
.accessibilityValue(isOn.wrappedValue ? "On" : "Off")
.accessibilityLabel(Text(title))
.accessibilityValue(isOn.wrappedValue
? String(localized: "On")
: String(localized: "Off"))
.onChange(of: isOn.wrappedValue) { _, enabled in
onChange?(enabled)
}
@@ -27,17 +27,17 @@ enum SettingsLayout {
/// detail row on this view so row typography cannot drift between sections;
/// plain `LabeledContent(String, value:)` renders unbranded system fonts.
struct SettingsDetailRow: View {
let label: String
let value: String
let label: LocalizedStringKey
let value: OpenClawTextValue
init(_ label: String, value: String) {
init(_ label: LocalizedStringKey, value: OpenClawTextValue) {
self.label = label
self.value = value
}
var body: some View {
LabeledContent {
Text(self.value)
self.value.text
.font(OpenClawType.subhead)
.lineLimit(1)
.truncationMode(.middle)
@@ -176,15 +176,33 @@ struct SettingsBuildMetadataStrip: View {
let timestamp = self.metadata.buildTimestamp
let built = self.metadata.localizedBuildDate() ?? timestamp
if let commit, let timestamp, let built {
return Text("Version \(version), commit \(commit), built \(built), timestamp \(timestamp)")
return Text(verbatim: String(
format: String(
localized: "Version %1$@, commit %2$@, built %3$@, timestamp %4$@"),
version,
commit,
built,
timestamp))
}
if let commit {
return Text("Version \(version), commit \(commit), build date unavailable")
return Text(verbatim: String(
format: String(
localized: "Version %1$@, commit %2$@, build date unavailable"),
version,
commit))
}
if let timestamp, let built {
return Text("Version \(version), commit unavailable, built \(built), timestamp \(timestamp)")
return Text(verbatim: String(
format: String(
localized: "Version %1$@, commit unavailable, built %2$@, timestamp %3$@"),
version,
built,
timestamp))
}
return Text("Version \(version), commit unavailable, build date unavailable")
return Text(verbatim: String(
format: String(
localized: "Version %@, commit unavailable, build date unavailable"),
version))
}
private func copyCommit() {
@@ -200,9 +218,9 @@ struct SettingsBuildMetadataStrip: View {
struct SettingsApprovalItem: Identifiable {
let id: String
let icon: String
let title: String
let detail: String
let priority: String
let title: OpenClawTextValue
let detail: OpenClawTextValue
let priority: OpenClawTextValue
let color: Color
}
@@ -220,16 +238,16 @@ struct SettingsApprovalRow: View {
.fill(self.item.color)
}
VStack(alignment: .leading, spacing: 2) {
Text(self.item.title)
self.item.title.text
.font(OpenClawType.subheadSemiBold)
.lineLimit(1)
Text(self.item.detail)
self.item.detail.text
.font(OpenClawType.caption2Medium)
.foregroundStyle(.secondary)
.lineLimit(1)
}
Spacer(minLength: 8)
Text(self.item.priority)
self.item.priority.text
.font(OpenClawType.captionBold)
.foregroundStyle(self.item.color)
.padding(.horizontal, 9)
@@ -279,32 +297,35 @@ enum SettingsNotificationPresentation: Equatable {
var text: String {
switch self {
case .checking: "Checking"
case .enabled: "Enabled"
case .off: "Off"
case .setup: "Setup"
case .denied: "Denied"
case .notSet: "Not Enabled"
case .unknown: "Unknown"
case .checking: String(localized: "Checking")
case .enabled: String(localized: "Enabled")
case .off: String(localized: "Off")
case .setup: String(localized: "Setup")
case .denied: String(localized: "Denied")
case .notSet: String(localized: "Not Enabled")
case .unknown: String(localized: "Unknown")
}
}
var detail: String {
switch self {
case .checking:
"Checking iOS notification permission."
String(localized: "Checking iOS notification permission.")
case .enabled:
"OpenClaw can show approval prompts and event alerts when the app is not active."
String(
localized: "OpenClaw can show approval prompts and event alerts when the app is not active.")
case .off:
"OpenClaw notifications are off."
String(localized: "OpenClaw notifications are off.")
case .setup:
"Finish notification setup to receive alerts when the app is not active."
String(
localized: "Finish notification setup to receive alerts when the app is not active.")
case .denied:
"Notifications have been denied. Enable them in iOS Settings."
String(localized: "Notifications have been denied. Enable them in iOS Settings.")
case .notSet:
"Enable notifications to receive approval prompts and event alerts outside the app."
String(
localized: "Enable notifications to receive approval prompts and event alerts outside the app.")
case .unknown:
"OpenClaw cannot determine the current notification permission state."
String(localized: "OpenClaw cannot determine the current notification permission state.")
}
}
@@ -493,8 +514,8 @@ private struct SettingsGatewayStatesPreview: View {
ProCard(padding: 0, radius: SettingsLayout.cardRadius) {
ProStatusRow(
icon: value == "online" ? "antenna.radiowaves.left.and.right" : "wifi.slash",
title: title,
detail: detail,
title: .localized(title),
detail: .localized(detail),
value: value,
color: color,
actionTitle: value == "setup" ? "Scan QR" : nil,
+11 -9
View File
@@ -155,22 +155,24 @@ struct TalkProTab: View {
private var conversationSection: some View {
Section("Conversation") {
SettingsDetailRow("Agent", value: self.appModel.chatAgentName)
SettingsDetailRow("Session", value: self.appModel.chatSessionKey)
SettingsDetailRow("Runtime", value: self.appModel.talkMode.statusText)
SettingsDetailRow("Agent", value: .verbatim(self.appModel.chatAgentName))
SettingsDetailRow("Session", value: .verbatim(self.appModel.chatSessionKey))
SettingsDetailRow("Runtime", value: .localized(self.appModel.talkMode.statusText))
}
}
private var voiceModeSection: some View {
Section("Voice Mode") {
SettingsDetailRow("Configured", value: self.appModel.talkMode.gatewayTalkVoiceModeTitle)
SettingsDetailRow("Active", value: self.activeModeText)
SettingsDetailRow("Transport", value: self.transportText)
SettingsDetailRow(
"Configured",
value: .localized(self.appModel.talkMode.gatewayTalkVoiceModeTitle))
SettingsDetailRow("Active", value: .verbatim(self.activeModeText))
SettingsDetailRow("Transport", value: .localized(self.transportText))
if let issueText = self.talkIssueText {
SettingsDetailRow("Last issue", value: issueText)
SettingsDetailRow("Last issue", value: .verbatim(issueText))
}
SettingsDetailRow("Permission", value: self.permissionText)
SettingsDetailRow("Speech language", value: self.speechLocaleText)
SettingsDetailRow("Permission", value: .localized(self.permissionText))
SettingsDetailRow("Speech language", value: .verbatim(self.speechLocaleText))
}
}
@@ -9,9 +9,9 @@ struct TalkRuntimeIssueBanner: View {
var body: some View {
OpenClawNoticeBanner(
icon: self.iconName,
title: self.issue.fallbackBannerTitle,
message: self.issue.fallbackBannerMessage,
ownerLabel: self.issue.fallbackBannerOwnerLabel,
title: .verbatim(self.issue.fallbackBannerTitle),
message: .verbatim(self.issue.fallbackBannerMessage),
ownerLabel: .verbatim(self.issue.fallbackBannerOwnerLabel),
tint: self.tint,
detail: .accent(self.issue.displayMessage),
primaryActionTitle: "Open Settings",
@@ -16,14 +16,16 @@ struct DeepLinkAgentPromptAlert: ViewModifier {
Alert(
title: Text("Run OpenClaw agent?")
.font(OpenClawType.headline),
message: Text(
"""
message: Text(verbatim: String(
format: String(localized: """
Message:
\(prompt.messagePreview)
%1$@
URL:
\(prompt.urlPreview)
""")
%2$@
"""),
prompt.messagePreview,
prompt.urlPreview))
.font(OpenClawType.subhead),
primaryButton: .cancel(
Text("Cancel")
@@ -275,22 +275,30 @@ private struct ExecApprovalPromptCard: View {
guard let expiresAtMs else { return nil }
let remainingSeconds = Int((Double(expiresAtMs) / 1000.0) - Date().timeIntervalSince1970)
if remainingSeconds <= 0 {
return "expired"
return String(localized: "expired")
}
if remainingSeconds < 60 {
return "under a minute"
return String(localized: "under a minute")
}
if remainingSeconds < 3600 {
let minutes = Int(ceil(Double(remainingSeconds) / 60.0))
return minutes == 1 ? "about 1 minute" : "about \(minutes) minutes"
return minutes == 1
? String(localized: "about 1 minute")
: String(
format: String(localized: "about %@ minutes"),
minutes.formatted())
}
let hours = Int(ceil(Double(remainingSeconds) / 3600.0))
return hours == 1 ? "about 1 hour" : "about \(hours) hours"
return hours == 1
? String(localized: "about 1 hour")
: String(
format: String(localized: "about %@ hours"),
hours.formatted())
}
}
private struct ExecApprovalPromptMetadataRow: View {
let label: String
let label: LocalizedStringKey
let value: String
var body: some View {
@@ -298,7 +306,7 @@ private struct ExecApprovalPromptMetadataRow: View {
Text(self.label)
.font(OpenClawType.caption)
.foregroundStyle(.secondary)
Text(self.value)
Text(verbatim: self.value)
.font(OpenClawType.footnote)
.textSelection(.enabled)
}
@@ -39,11 +39,11 @@ extension GatewayConnectionController {
let requiresTLS = !trimmedHost.isEmpty && !LoopbackHost.isLocalNetworkHost(trimmedHost)
let effectiveTLS = requestedTLS || requiresTLS
let helperText: String? = if requiresTLS {
"Secure connection is required for this host."
String(localized: "Secure connection is required for this host.")
} else if effectiveTLS {
nil
} else {
"Use only on a trusted private network."
String(localized: "Use only on a trusted private network.")
}
return GatewayManualTransportPresentation(
requiresTLS: requiresTLS,
@@ -1281,22 +1281,43 @@ extension GatewayConnectionController {
switch failure {
case .endpointUnreachable:
if host.lowercased().trimmingCharacters(in: CharacterSet(charactersIn: ".")).hasSuffix(".ts.net") {
String(localized: """
Can't reach gateway at \(host):\(port). \
Verify Tailscale Serve is enabled and publishes this Gateway.
""")
String(
format: String(localized: """
Can't reach gateway at %1$@:%2$@. \
Verify Tailscale Serve is enabled and publishes this Gateway.
"""),
host,
port.formatted())
} else {
String(localized: "Can't reach gateway at \(host):\(port). Check Tailscale or LAN.")
String(
format: String(
localized: "Can't reach gateway at %1$@:%2$@. Check Tailscale or LAN."),
host,
port.formatted())
}
case .tlsHandshakeTimeout:
"TLS fingerprint verification timed out for \(host):\(port). "
+ "Secure endpoint was reached, but TLS did not finish in time."
String(
format: String(localized: """
TLS fingerprint verification timed out for %1$@:%2$@. \
Secure endpoint was reached, but TLS did not finish in time.
"""),
host,
port.formatted())
case .tlsUnavailable:
"No secure gateway endpoint was detected at \(host):\(port). "
+ "Enable gateway TLS or Tailscale Serve, or use a trusted private LAN address "
+ "with Unencrypted selected."
String(
format: String(localized: """
No secure gateway endpoint was detected at %1$@:%2$@. \
Enable gateway TLS or Tailscale Serve, or use a trusted private LAN address \
with Unencrypted selected.
"""),
host,
port.formatted())
case .certificateUnavailable:
"Could not read the TLS certificate from \(host):\(port)."
String(
format: String(
localized: "Could not read the TLS certificate from %1$@:%2$@."),
host,
port.formatted())
}
}
@@ -11,12 +11,12 @@ struct GatewayProblemBanner: View {
var body: some View {
OpenClawNoticeBanner(
icon: self.iconName,
title: self.problem.title,
message: self.problem.message,
ownerLabel: self.ownerLabel,
title: .localized(self.problem.title),
message: .localized(self.problem.message),
ownerLabel: .localized(self.ownerLabel),
tint: self.tint,
detail: self.problem.requestId.map(OpenClawNoticeDetail.requestID),
primaryActionTitle: self.primaryActionTitle,
primaryActionTitle: self.primaryActionTitle.map(OpenClawTextValue.localized),
onPrimaryAction: self.onPrimaryAction,
secondaryActionTitle: "Details",
onSecondaryAction: self.onShowDetails)
@@ -88,12 +88,12 @@ struct GatewayProblemDetailsSheet: View {
List {
Section {
VStack(alignment: .leading, spacing: 10) {
Text(self.problem.title)
Text(LocalizedStringKey(self.problem.title))
.font(OpenClawType.title3)
Text(self.problem.message)
Text(LocalizedStringKey(self.problem.message))
.font(OpenClawType.body)
.foregroundStyle(.secondary)
Text(self.ownerSummary)
Text(LocalizedStringKey(self.ownerSummary))
.font(OpenClawType.footnoteSemiBold)
.foregroundStyle(.secondary)
}
@@ -170,7 +170,7 @@ struct GatewayProblemDetailsSheet: View {
if let copyFeedback {
Section {
Text(copyFeedback)
Text(verbatim: copyFeedback)
.font(OpenClawType.footnote)
.foregroundStyle(.secondary)
}
@@ -189,7 +189,7 @@ struct GatewayProblemDetailsSheet: View {
self.dismiss()
onPrimaryAction()
} label: {
Text(primaryActionTitle)
Text(LocalizedStringKey(primaryActionTitle))
.font(OpenClawType.subheadSemiBold)
}
.font(OpenClawType.subheadSemiBold)
@@ -84,7 +84,7 @@ struct GatewayQuickSetupSheet: View {
Image(systemName: "lock.shield.fill")
.foregroundStyle(OpenClawBrand.warn)
VStack(alignment: .leading, spacing: 3) {
Text(availability.actionTitle)
Text(LocalizedStringKey(availability.actionTitle))
.font(OpenClawType.subheadSemiBold)
Text(guidanceText)
.font(OpenClawType.caption)
@@ -369,7 +369,7 @@ private struct GatewayQuickSetupErrorView: View {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(OpenClawBrand.warn)
.padding(.top, 1)
Text(self.message)
Text(verbatim: self.message)
.font(OpenClawType.footnote)
.foregroundStyle(.secondary)
.textSelection(.enabled)
@@ -17,7 +17,11 @@ final class LiveActivityManager {
self.hydrateCurrentAndPruneDuplicates()
}
func showConnecting(statusText: String = "Connecting...", agentName: String, sessionKey: String) {
func showConnecting(
statusText: String = String(localized: "Connecting..."),
agentName: String,
sessionKey: String)
{
self.hydrateCurrentAndPruneDuplicates()
if self.currentActivity != nil {
@@ -77,7 +81,7 @@ final class LiveActivityManager {
self.updateCurrent(state: self.attentionState(statusText: statusText), staleDate: nil)
}
func handleConnecting(statusText: String = "Connecting...") {
func handleConnecting(statusText: String = String(localized: "Connecting...")) {
self.updateCurrent(
state: self.connectingState(statusText: statusText),
staleDate: Date().addingTimeInterval(self.connectingStaleSeconds))
@@ -172,7 +176,7 @@ final class LiveActivityManager {
private func disconnectedState() -> OpenClawActivityAttributes.ContentState {
OpenClawActivityAttributes.ContentState(
statusText: "Disconnected",
statusText: String(localized: "Disconnected"),
isIdle: false,
isDisconnected: true,
isConnecting: false,
+13 -5
View File
@@ -3735,7 +3735,9 @@ extension NodeAppModel {
}
if problem.needsPairingApproval || problem.pauseReconnect {
LiveActivityManager.shared.showAttention(
statusText: problem.needsPairingApproval ? "Approval needed" : "Action required",
statusText: problem.needsPairingApproval
? String(localized: "Approval needed")
: String(localized: "Action required"),
agentName: self.activeAgentName,
sessionKey: self.mainSessionKey)
}
@@ -3752,7 +3754,9 @@ extension NodeAppModel {
}
if problem.needsPairingApproval || problem.pauseReconnect {
LiveActivityManager.shared.showAttention(
statusText: problem.needsPairingApproval ? "Approval needed" : "Action required",
statusText: problem.needsPairingApproval
? String(localized: "Approval needed")
: String(localized: "Action required"),
agentName: self.activeAgentName,
sessionKey: self.mainSessionKey)
}
@@ -4417,7 +4421,9 @@ extension NodeAppModel {
self.gatewayServerName = nil
self.gatewayRemoteAddress = nil
LiveActivityManager.shared.showConnecting(
statusText: (attempt == 0) ? "Connecting..." : "Reconnecting...",
statusText: (attempt == 0)
? String(localized: "Connecting...")
: String(localized: "Reconnecting..."),
agentName: self.activeAgentName,
sessionKey: self.mainSessionKey)
}
@@ -5847,7 +5853,7 @@ extension NodeAppModel {
guard self.isOperatorGatewayConnected else {
return WatchChatPreview(
items: [],
statusText: "Connect iPhone chat to read messages")
statusText: String(localized: "Connect iPhone chat to read messages"))
}
payload = try await IOSGatewayChatTransport(gateway: self.operatorSession)
.requestHistory(sessionKey: self.chatSessionKey)
@@ -5859,7 +5865,9 @@ extension NodeAppModel {
statusText: items.isEmpty ? "No chat messages yet" : nil)
} catch {
GatewayDiagnostics.log("watch app snapshot: chat preview failed error=\(error.localizedDescription)")
return WatchChatPreview(items: [], statusText: "Chat unavailable")
return WatchChatPreview(
items: [],
statusText: String(localized: "Chat unavailable"))
}
}
@@ -139,7 +139,7 @@ struct OnboardingConnectPhaseView: View {
OpenClawNoticeBanner(
icon: "exclamationmark.triangle.fill",
title: "Connection Failed",
message: message,
message: .verbatim(message),
ownerLabel: "Needs attention",
tint: OpenClawBrand.danger,
primaryActionTitle: allowsRetry ? "Retry" : nil,
@@ -586,17 +586,23 @@ struct OnboardingWizardView: View {
} footer: {
let requestLine: String = {
if let id = self.currentProblem?.requestId ?? self.issue.requestId, !id.isEmpty {
return "Request ID: \(id)"
return String(
format: String(localized: "Request ID: %@"),
id)
}
return "Request ID: check `openclaw devices list`."
return String(localized: "Request ID: check `openclaw devices list`.")
}()
let commandLine = self.currentProblem?.actionCommand ?? "openclaw devices approve <requestId>"
Text(
"Approve this device on the gateway.\n"
+ "1) `\(commandLine)`\n"
+ "2) `/pair approve` in your OpenClaw chat\n"
+ "\(requestLine)\n"
+ "OpenClaw will also retry automatically when you return to this app.")
Text(verbatim: String(
format: String(localized: """
Approve this device on the gateway.
1) `%1$@`
2) `/pair approve` in your OpenClaw chat
%2$@
OpenClaw will also retry automatically when you return to this app.
"""),
commandLine,
requestLine))
.font(OpenClawType.caption)
}
}
+4 -1
View File
@@ -340,7 +340,10 @@ struct RootTabs: View {
self.sidebarHorizontalSeparator
}
.accessibilityElement(children: .combine)
.accessibilityLabel("OpenClaw \(self.sidebarGatewayStatusTitle)")
.accessibilityLabel(
String(
format: String(localized: "OpenClaw %@"),
self.sidebarGatewayStatusTitle))
}
private var sidebarGatewayStatusTitle: String {
@@ -77,18 +77,18 @@ struct PrivacyAccessSectionView: View {
{
VStack(alignment: .leading, spacing: 6) {
HStack {
Label(title, systemImage: icon)
Label(LocalizedStringKey(title), systemImage: icon)
.font(OpenClawType.subheadSemiBold)
Spacer()
OpenClawStatusBadge(label: status, tone: self.statusTone(for: status))
OpenClawStatusBadge(label: .localized(status), tone: self.statusTone(for: status))
.accessibilityIdentifier("privacy-access-\(title)-status")
}
Text(detail)
Text(LocalizedStringKey(detail))
.font(OpenClawType.footnote)
.foregroundStyle(.secondary)
if let actionTitle, let action {
Button(action: action) {
Text(actionTitle)
Text(LocalizedStringKey(actionTitle))
.font(OpenClawType.footnoteSemiBold)
}
.buttonStyle(.bordered)
@@ -1,3 +1,5 @@
import Foundation
enum TalkGatewayPermissionState: Equatable {
case unknown
case ready
@@ -11,21 +13,21 @@ enum TalkGatewayPermissionState: Equatable {
var statusLabel: String {
switch self {
case .unknown:
"Not checked"
String(localized: "Not checked")
case .ready:
"Ready"
String(localized: "Ready")
case let .missingScope(scope):
"Missing \(scope)"
String(format: String(localized: "Missing %@"), scope)
case .requestingUpgrade:
"Requesting approval"
String(localized: "Requesting approval")
case .upgradeRequested:
"Approval requested"
String(localized: "Approval requested")
case .requestFailed:
"Request failed"
String(localized: "Request failed")
case .apiKeyMissing:
"API key missing"
String(localized: "API key missing")
case .loadFailed:
"Load failed"
String(localized: "Load failed")
}
}
@@ -37,23 +37,23 @@ struct TalkRuntimeIssue: Equatable {
var displayMessage: String {
if !self.message.isEmpty { return self.message }
return "Realtime voice did not start."
return String(localized: "Realtime voice did not start.")
}
var fallbackStatusText: String {
"Listening (iOS Speech fallback)"
String(localized: "Listening (iOS Speech fallback)")
}
var fallbackBannerTitle: String {
"Using iOS Speech fallback"
String(localized: "Using iOS Speech fallback")
}
var fallbackBannerOwnerLabel: String {
"Fallback active"
String(localized: "Fallback active")
}
var fallbackBannerMessage: String {
"Realtime voice did not start. Talk is running with iOS speech recognition and TTS."
String(localized: "Realtime voice did not start. Talk is running with iOS speech recognition and TTS.")
}
var technicalDetails: String {
@@ -62,15 +62,25 @@ extension TalkModeManager {
{
switch status {
case .denied:
return "\(kind) permission denied"
return String(
format: String(localized: "%@ permission denied"),
kind)
case .restricted:
return "\(kind) permission restricted"
return String(
format: String(localized: "%@ permission restricted"),
kind)
case .notDetermined:
return "\(kind) permission not granted"
return String(
format: String(localized: "%@ permission not granted"),
kind)
case .authorized:
return "\(kind) permission denied"
return String(
format: String(localized: "%@ permission denied"),
kind)
@unknown default:
return "\(kind) permission denied"
return String(
format: String(localized: "%@ permission denied"),
kind)
}
}
}
+97 -70
View File
@@ -238,7 +238,7 @@ final class TalkModeManager: NSObject {
private var realtimeModelId: String?
private var realtimeVoiceId: String?
private var configuredVoiceModeDescriptor = TalkVoiceModeDescriptor(
title: "Not loaded",
title: String(localized: "Not loaded"),
subtitle: nil,
providerId: nil,
modelId: nil,
@@ -395,7 +395,9 @@ final class TalkModeManager: NSObject {
self.gatewayTalkActiveModeSubtitle = nil
guard shouldRestart else {
if self.isEnabled {
self.statusText = self.gatewayConnected ? "Ready" : "Offline"
self.statusText = self.gatewayConnected
? String(localized: "Ready")
: String(localized: "Offline")
}
return
}
@@ -416,7 +418,7 @@ final class TalkModeManager: NSObject {
self.scheduleRealtimeRestart(after: nil, generation: restartGeneration)
return
}
self.statusText = "Reconnecting"
self.statusText = String(localized: "Reconnecting")
self.scheduleRealtimeRestart(after: delay, generation: restartGeneration)
}
@@ -463,7 +465,7 @@ final class TalkModeManager: NSObject {
self.gatewayTalkActiveModeTitle = "Not active"
self.gatewayTalkActiveModeSubtitle = nil
if self.isEnabled, !self.isSpeaking {
self.statusText = "Offline"
self.statusText = String(localized: "Offline")
}
self.realtimePrefetchGeneration &+= 1
self.realtimePrefetchTask?.cancel()
@@ -512,23 +514,24 @@ final class TalkModeManager: NSObject {
self.isListening = false
self.isSpeaking = false
self.isUserSpeechDetected = false
self.statusText = "Ready"
self.statusText = String(localized: "Ready")
self.gatewayTalkConfigLoaded = true
self.gatewayTalkApiKeyConfigured = true
self.gatewayTalkDefaultModelId = "gpt-realtime-2"
self.gatewayTalkDefaultVoiceId = "marin"
self.gatewayTalkProviderLabel = "OpenAI"
self.gatewayTalkTransportLabel = "Gateway Relay"
self.gatewayTalkTransportLabel = String(localized: "Gateway Relay")
self.gatewayTalkUsesRealtime = true
self.gatewayTalkUsesRealtimeRelay = true
self.gatewayTalkRealtimeProviderLabel = "OpenAI"
self.gatewayTalkRealtimeModelId = "gpt-realtime-2"
self.gatewayTalkRealtimeVoiceId = "marin"
self.gatewayTalkVoiceModeTitle = "Realtime Voice"
self.gatewayTalkVoiceModeSubtitle = "Gateway relay ready"
self.gatewayTalkVoiceModeAccessibilityValue = "Realtime Voice, Gateway relay ready"
self.gatewayTalkActiveModeTitle = "Ready"
self.gatewayTalkActiveModeSubtitle = "Listening starts from this phone"
self.gatewayTalkVoiceModeTitle = String(localized: "Realtime Voice")
self.gatewayTalkVoiceModeSubtitle = String(localized: "Gateway relay ready")
self.gatewayTalkVoiceModeAccessibilityValue = String(
localized: "Realtime Voice, Gateway relay ready")
self.gatewayTalkActiveModeTitle = String(localized: "Ready")
self.gatewayTalkActiveModeSubtitle = String(localized: "Listening starts from this phone")
self.gatewayTalkLastIssueText = nil
self.gatewayTalkCurrentFallbackIssue = nil
self.gatewayTalkPermissionState = .ready
@@ -594,7 +597,7 @@ final class TalkModeManager: NSObject {
}
#endif
self.logger.info("start")
self.statusText = "Requesting permissions…"
self.statusText = String(localized: "Requesting permissions…")
let permissionStartedAt = Self.nowSeconds()
let micOk = if self.allowSimulatorCapture {
true
@@ -606,14 +609,14 @@ final class TalkModeManager: NSObject {
+ "elapsedMs=\(Self.elapsedMs(since: permissionStartedAt))")
guard micOk else {
self.logger.warning("start blocked: microphone permission denied")
self.statusText = "Microphone permission denied"
self.statusText = String(localized: "Microphone permission denied")
return
}
guard self.isCurrentStartAttempt(attemptID) else { return }
await self.ensureTalkConfigLoadedForStart()
guard self.isCurrentStartAttempt(attemptID) else { return }
if self.gatewayTalkPermissionState.requiresTalkPermissionAction {
self.statusText = "Gateway permission required"
self.statusText = String(localized: "Gateway permission required")
GatewayDiagnostics.log("talk.timeline manager start blocked gateway permission")
return
}
@@ -642,7 +645,7 @@ final class TalkModeManager: NSObject {
self.stopNativeCaptureAndDiscardTranscript()
self.deactivateAudioSession()
self.statusText = Self.permissionMessage(
kind: "Speech recognition",
kind: String(localized: "Speech recognition"),
status: SFSpeechRecognizer.authorizationStatus())
return
}
@@ -665,7 +668,9 @@ final class TalkModeManager: NSObject {
} catch {
self.stopNativeCaptureAndDiscardTranscript()
self.deactivateAudioSession()
self.statusText = "Start failed: \(error.localizedDescription)"
self.statusText = String(
format: String(localized: "Start failed: %@"),
error.localizedDescription)
self.logger.error("start failed: \(error.localizedDescription, privacy: .public)")
}
}
@@ -675,7 +680,7 @@ final class TalkModeManager: NSObject {
guard self.captureMode != .pushToTalk else { return false }
guard self.finishingPushToTalk == nil else { return false }
guard self.foregroundAudioCaptureAllowed else {
self.statusText = "Paused"
self.statusText = String(localized: "Paused")
GatewayDiagnostics.log("talk start ignored: app backgrounded")
return false
}
@@ -688,7 +693,7 @@ final class TalkModeManager: NSObject {
return false
}
guard self.gatewayConnected else {
self.statusText = "Offline"
self.statusText = String(localized: "Offline")
GatewayDiagnostics.log("talk.timeline manager start blocked gateway offline")
return false
}
@@ -736,7 +741,7 @@ final class TalkModeManager: NSObject {
self.gatewayTalkProviderLabel = TalkModeProviderSelection.openAIRealtime.label
self.gatewayTalkUsesRealtime = true
self.gatewayTalkUsesRealtimeRelay = false
self.gatewayTalkTransportLabel = "Native WebRTC"
self.gatewayTalkTransportLabel = String(localized: "Native WebRTC")
self.gatewayTalkRealtimeProviderLabel = Self.displayName(forProvider: self.realtimeProvider ?? "openai")
self.gatewayTalkRealtimeModelId = self.realtimeModelId
self.gatewayTalkRealtimeVoiceId = self.realtimeVoiceId
@@ -753,7 +758,7 @@ final class TalkModeManager: NSObject {
self.isUserSpeechDetected = false
self.isPushToTalkActive = false
self.captureMode = .idle
self.statusText = "Off"
self.statusText = String(localized: "Off")
self.pendingRealtimeIssue = nil
self.gatewayTalkCurrentFallbackIssue = nil
self.gatewayTalkActiveModeTitle = "Not active"
@@ -794,7 +799,7 @@ final class TalkModeManager: NSObject {
self.foregroundPushToTalkAllowed = false
guard self.isEnabled || self.activePTTCaptureId != nil || self.finishingPushToTalk != nil else { return }
if keepContinuousActive {
self.statusText = self.isListening ? "Listening" : self.statusText
self.statusText = self.isListening ? String(localized: "Listening") : self.statusText
return
}
self.cancelFinishingPushToTalk()
@@ -803,7 +808,7 @@ final class TalkModeManager: NSObject {
self.isListening = false
self.isPushToTalkActive = false
self.captureMode = .idle
self.statusText = "Paused"
self.statusText = String(localized: "Paused")
self.gatewayTalkActiveModeTitle = "Paused"
self.gatewayTalkActiveModeSubtitle = nil
self.lastTranscript = ""
@@ -928,12 +933,12 @@ final class TalkModeManager: NSObject {
try self.ensurePushToTalkStartCurrent(captureId: captureId, canStartCapture: canStartCapture)
}
#endif
self.statusText = "Requesting permissions…"
self.statusText = String(localized: "Requesting permissions…")
if !self.allowSimulatorCapture {
let micOk = await Self.requestMicrophonePermission()
try self.ensurePushToTalkStartCurrent(captureId: captureId, canStartCapture: canStartCapture)
guard micOk else {
self.statusText = "Microphone permission denied"
self.statusText = String(localized: "Microphone permission denied")
throw NSError(domain: "TalkMode", code: 4, userInfo: [
NSLocalizedDescriptionKey: "Microphone permission denied",
])
@@ -942,7 +947,7 @@ final class TalkModeManager: NSObject {
try self.ensurePushToTalkStartCurrent(captureId: captureId, canStartCapture: canStartCapture)
guard speechOk else {
self.statusText = Self.permissionMessage(
kind: "Speech recognition",
kind: String(localized: "Speech recognition"),
status: SFSpeechRecognizer.authorizationStatus())
throw NSError(domain: "TalkMode", code: 5, userInfo: [
NSLocalizedDescriptionKey: "Speech recognition permission denied",
@@ -958,7 +963,7 @@ final class TalkModeManager: NSObject {
try self.ensurePushToTalkStartCurrent(captureId: captureId, canStartCapture: canStartCapture)
self.isListening = true
self.isPushToTalkActive = true
self.statusText = "Listening (PTT)"
self.statusText = String(localized: "Listening (PTT)")
} catch {
if self.activePTTCaptureId == captureId {
self.stopRecognition()
@@ -971,9 +976,11 @@ final class TalkModeManager: NSObject {
let isPermissionError = nsError.domain == "TalkMode" && (nsError.code == 4 || nsError.code == 5)
let isCancelled = error is CancellationError || (nsError.domain == "TalkMode" && nsError.code == 9)
if isCancelled {
self.statusText = "Ready"
self.statusText = String(localized: "Ready")
} else if !isPermissionError {
self.statusText = "Start failed: \(error.localizedDescription)"
self.statusText = String(
format: String(localized: "Start failed: %@"),
error.localizedDescription)
}
}
let shouldResume = self.isEnabled
@@ -1004,7 +1011,7 @@ final class TalkModeManager: NSObject {
self.pttTimeoutTask?.cancel()
self.pttTimeoutTask = nil
self.pttAutoStopEnabled = false
self.statusText = "Ready"
self.statusText = String(localized: "Ready")
self.finishActivePushToTalk(captureId)
let payload = OpenClawTalkPTTStopPayload(
captureId: captureId,
@@ -1029,7 +1036,7 @@ final class TalkModeManager: NSObject {
self.lastHeard = nil
guard !transcript.isEmpty else {
self.statusText = "Ready"
self.statusText = String(localized: "Ready")
let shouldResume = self.isEnabled
self.finishActivePushToTalk(captureId)
let payload = OpenClawTalkPTTStopPayload(
@@ -1042,7 +1049,7 @@ final class TalkModeManager: NSObject {
}
guard self.gatewayConnected else {
self.statusText = "Gateway not connected"
self.statusText = String(localized: "Gateway not connected")
let shouldResume = self.isEnabled
self.finishActivePushToTalk(captureId)
let payload = OpenClawTalkPTTStopPayload(
@@ -1148,7 +1155,7 @@ final class TalkModeManager: NSObject {
self.pttTimeoutTask?.cancel()
self.pttTimeoutTask = nil
self.finishActivePushToTalk(captureId)
self.statusText = "Ready"
self.statusText = String(localized: "Ready")
let payload = OpenClawTalkPTTStopPayload(
captureId: captureId,
@@ -1183,7 +1190,7 @@ final class TalkModeManager: NSObject {
}
private func pushToTalkOfflineError() -> NSError {
self.statusText = "Offline"
self.statusText = String(localized: "Offline")
return NSError(domain: "TalkMode", code: 7, userInfo: [
NSLocalizedDescriptionKey: "Gateway not connected",
])
@@ -1216,7 +1223,7 @@ final class TalkModeManager: NSObject {
{
precondition(self.finishingPushToTalk == nil)
let generation = self.beginTranscriptProcessing()
self.statusText = "Thinking…"
self.statusText = String(localized: "Thinking…")
let task = Task { @MainActor [weak self] in
guard let self else { return }
@@ -1256,7 +1263,9 @@ final class TalkModeManager: NSObject {
self.invalidateTranscriptProcessing()
self.stopSpeaking(storeInterruption: false)
if hadFinishingPushToTalk {
self.statusText = self.gatewayConnected ? "Ready" : "Offline"
self.statusText = self.gatewayConnected
? String(localized: "Ready")
: String(localized: "Offline")
}
}
@@ -1277,7 +1286,9 @@ final class TalkModeManager: NSObject {
self.pttAudioOwnershipEndHandler?(captureId)
let transientStatuses = ["Thinking…", "Generating voice…", "Speaking…", "Speaking (System)…"]
if transientStatuses.contains(self.statusText) {
self.statusText = self.gatewayConnected ? "Ready" : "Offline"
self.statusText = self.gatewayConnected
? String(localized: "Ready")
: String(localized: "Offline")
}
self.scheduleContinuousResume(self.isEnabled)
}
@@ -1451,7 +1462,7 @@ final class TalkModeManager: NSObject {
if isCancellation {
GatewayDiagnostics.log("talk speech: cancelled")
if self.captureMode == .continuous, self.isEnabled, !self.isSpeaking {
self.statusText = "Listening"
self.statusText = String(localized: "Listening")
}
self.logger.debug("speech recognition cancelled")
return false
@@ -1461,9 +1472,13 @@ final class TalkModeManager: NSObject {
if !self.isSpeaking {
if msg.localizedCaseInsensitiveContains("no speech detected") {
// Treat as transient silence. Don't scare users with an error banner.
self.statusText = self.isEnabled ? "Listening" : "Speech error: \(msg)"
self.statusText = self.isEnabled
? String(localized: "Listening")
: String(format: String(localized: "Speech error: %@"), msg)
} else {
self.statusText = "Speech error: \(msg)"
self.statusText = String(
format: String(localized: "Speech error: %@"),
msg)
}
}
self.logger.debug("speech recognition error: \(msg, privacy: .public)")
@@ -1490,7 +1505,7 @@ final class TalkModeManager: NSObject {
try self.startRecognition()
self.isListening = true
if self.statusText.localizedCaseInsensitiveContains("speech error") {
self.statusText = "Listening"
self.statusText = String(localized: "Listening")
}
GatewayDiagnostics.log("talk speech: recognition restarted")
} catch {
@@ -1679,7 +1694,7 @@ final class TalkModeManager: NSObject {
}
}
guard let gateway = self.gateway else {
self.statusText = "Gateway not connected"
self.statusText = String(localized: "Gateway not connected")
self.scheduleContinuousResume(restartAfter)
return
}
@@ -1747,7 +1762,7 @@ final class TalkModeManager: NSObject {
self.isListening = false
self.isUserSpeechDetected = false
self.captureMode = .idle
self.statusText = "Thinking…"
self.statusText = String(localized: "Thinking…")
self.lastTranscript = ""
self.lastHeard = nil
self.stopRecognition()
@@ -1759,7 +1774,7 @@ final class TalkModeManager: NSObject {
shouldApply: { self.isCurrentTranscriptProcessing(generation) })
guard self.isCurrentTranscriptProcessing(generation) else { return }
guard await gateway.currentRoute() == gatewayRoute else {
self.statusText = "Gateway not connected"
self.statusText = String(localized: "Gateway not connected")
return
}
guard self.isCurrentTranscriptProcessing(generation) else { return }
@@ -1798,7 +1813,9 @@ final class TalkModeManager: NSObject {
"chat.send ok runId=\(runId, privacy: .public) status=\(normalizedStatus, privacy: .public)")
GatewayDiagnostics.log("talk: chat.send ok runId=\(runId) status=\(normalizedStatus)")
if Self.isTerminalChatSendFailure(acknowledgement.status) {
streamingOwner.terminalStatus = normalizedStatus == "error" ? "Chat error" : "Aborted"
streamingOwner.terminalStatus = normalizedStatus == "error"
? String(localized: "Chat error")
: String(localized: "Aborted")
self.logger.warning(
"""
chat.send terminal ack runId=\(runId, privacy: .public) \
@@ -1819,13 +1836,15 @@ final class TalkModeManager: NSObject {
else { return }
guard self.isCurrentTranscriptProcessing(generation) else { return }
if completedSuccessfully, !self.isEnabled {
streamingOwner.terminalStatus = "Ready"
streamingOwner.terminalStatus = String(localized: "Ready")
}
} catch is CancellationError {
return
} catch {
guard self.isCurrentTranscriptProcessing(generation) else { return }
streamingOwner.terminalStatus = "Talk failed: \(error.localizedDescription)"
streamingOwner.terminalStatus = String(
format: String(localized: "Talk failed: %@"),
error.localizedDescription)
self.logger.error("finalize failed: \(error.localizedDescription, privacy: .public)")
GatewayDiagnostics.log("talk: failed error=\(error.localizedDescription)")
}
@@ -1882,7 +1901,7 @@ final class TalkModeManager: NSObject {
streamingOwner.task?.cancel()
await self.finishIncrementalSpeech()
guard self.isCurrentTranscriptProcessing(generation) else { return nil }
streamingOwner.terminalStatus = "Aborted"
streamingOwner.terminalStatus = String(localized: "Aborted")
return nil
} else if completion.state == .error {
self.logger.warning("chat completion error runId=\(runId, privacy: .public)")
@@ -1890,7 +1909,7 @@ final class TalkModeManager: NSObject {
streamingOwner.task?.cancel()
await self.finishIncrementalSpeech()
guard self.isCurrentTranscriptProcessing(generation) else { return nil }
streamingOwner.terminalStatus = "Chat error"
streamingOwner.terminalStatus = String(localized: "Chat error")
return nil
}
}
@@ -1918,7 +1937,7 @@ final class TalkModeManager: NSObject {
streamingOwner.task?.cancel()
await self.finishIncrementalSpeech()
guard self.isCurrentTranscriptProcessing(generation) else { return nil }
streamingOwner.terminalStatus = "No reply"
streamingOwner.terminalStatus = String(localized: "No reply")
return nil
}
self.logger.info("assistant text ok chars=\(assistantText.count, privacy: .public)")
@@ -2007,7 +2026,7 @@ final class TalkModeManager: NSObject {
return .unavailable(realtimeIssue(message: "Gateway not connected", phase: "start"))
}
guard self.foregroundAudioCaptureAllowed else {
self.statusText = "Paused"
self.statusText = String(localized: "Paused")
GatewayDiagnostics.log("talk realtime ignored: app backgrounded")
return .ignored
}
@@ -2424,7 +2443,7 @@ final class TalkModeManager: NSObject {
self.speechGeneration += 1
let speechGeneration = self.speechGeneration
self.statusText = "Generating voice…"
self.statusText = String(localized: "Generating voice…")
self.isSpeaking = true
self.lastSpokenText = cleaned
defer {
@@ -2456,7 +2475,9 @@ final class TalkModeManager: NSObject {
try await self.playSystemVoice(text: cleaned, language: language)
} catch {
guard !Task.isCancelled, self.speechGeneration == speechGeneration else { return }
self.statusText = "Speak failed: \(error.localizedDescription)"
self.statusText = String(
format: String(localized: "Speak failed: %@"),
error.localizedDescription)
self.logger.error("system voice failed: \(error.localizedDescription, privacy: .public)")
}
}
@@ -2519,7 +2540,7 @@ final class TalkModeManager: NSObject {
self.startSpeechInterruptionRecognitionIfNeeded()
self.statusText = "Speaking…"
self.statusText = String(localized: "Speaking…")
let result = await self.playElevenLabsStream(
rawStream,
sampleRate: TalkTTSValidation.pcmSampleRate(from: outputFormat))
@@ -2555,7 +2576,9 @@ final class TalkModeManager: NSObject {
try await self.playSystemVoice(text: cleaned, language: language)
} catch {
guard !Task.isCancelled, self.speechGeneration == speechGeneration else { return }
self.statusText = "Speak failed: \(error.localizedDescription)"
self.statusText = String(
format: String(localized: "Speak failed: %@"),
error.localizedDescription)
self.logger.error("system voice failed: \(error.localizedDescription, privacy: .public)")
}
}
@@ -2606,7 +2629,7 @@ final class TalkModeManager: NSObject {
transport: "native",
isRealtime: false))
self.startSpeechInterruptionRecognitionIfNeeded()
self.statusText = "Speaking…"
self.statusText = String(localized: "Speaking…")
let result: StreamingPlaybackResult
switch audio.playbackMode {
case let .pcm(sampleRate):
@@ -2649,7 +2672,7 @@ final class TalkModeManager: NSObject {
transport: "native",
isRealtime: false))
self.startSpeechInterruptionRecognitionIfNeeded()
self.statusText = "Speaking (System)…"
self.statusText = String(localized: "Speaking (System)…")
try await TalkSystemSpeechSynthesizer.shared.speak(text: text, language: language)
}
@@ -2844,7 +2867,7 @@ final class TalkModeManager: NSObject {
while !Task.isCancelled {
guard !self.incrementalSpeechQueue.isEmpty else { break }
let segment = self.incrementalSpeechQueue.removeFirst()
self.statusText = "Speaking…"
self.statusText = String(localized: "Speaking…")
self.isSpeaking = true
self.lastSpokenText = segment
guard await self.updateIncrementalContextIfNeeded(speechGeneration: speechGeneration) else { return }
@@ -3522,13 +3545,13 @@ extension TalkModeManager {
case "google":
"Google"
case "system":
"iOS System Voice"
String(localized: "iOS System Voice")
case "realtime":
"Realtime Voice"
String(localized: "Realtime Voice")
case let provider where !provider.isEmpty:
provider
default:
"Gateway Default"
String(localized: "Gateway Default")
}
}
@@ -3547,7 +3570,7 @@ extension TalkModeManager {
self.gatewayTalkLastIssueText = nil
self.gatewayTalkActiveModeTitle = self.configuredVoiceModeDescriptor.title
self.gatewayTalkActiveModeSubtitle = self.configuredVoiceModeDescriptor.subtitle
self.statusText = "Listening (Realtime)"
self.statusText = String(localized: "Listening (Realtime)")
}
private func handleRealtimeRelayStatus(_ status: String) {
@@ -3582,7 +3605,7 @@ extension TalkModeManager {
self.gatewayTalkCurrentFallbackIssue = nil
self.gatewayTalkActiveModeTitle = "iOS Speech + TTS"
self.gatewayTalkActiveModeSubtitle = nil
self.statusText = "Listening"
self.statusText = String(localized: "Listening")
}
private func markNativeFallbackActive(after issue: TalkRuntimeIssue) {
@@ -3847,7 +3870,11 @@ extension TalkModeManager {
? Self.displayName(forProvider: routing.activeProvider)
: providerSelection.label
let transport = usesRealtimeConfig ? (usesRealtimeRelay ? "gateway-relay" : "webrtc") : "native"
let transportLabel = usesRealtimeRelay ? "Gateway Relay" : (usesRealtimeConfig ? "Native WebRTC" : "Native")
let transportLabel = usesRealtimeRelay
? String(localized: "Gateway Relay")
: (usesRealtimeConfig
? String(localized: "Native WebRTC")
: String(localized: "Native"))
self.gatewayTalkProviderLabel = providerLabel
self.gatewayTalkUsesRealtime = usesRealtimeConfig
self.gatewayTalkUsesRealtimeRelay = usesRealtimeRelay
@@ -3908,7 +3935,7 @@ extension TalkModeManager {
self.silenceWindow = TimeInterval(Self.defaultSilenceTimeoutMs) / 1000
if let missingScope = Self.missingTalkScope(from: error) {
self.gatewayTalkPermissionState = .missingScope(missingScope)
self.statusText = "Gateway permission required"
self.statusText = String(localized: "Gateway permission required")
GatewayDiagnostics.log("talk config missing gateway scope=\(missingScope)")
} else {
self.gatewayTalkPermissionState = .loadFailed(error.localizedDescription)
@@ -3923,15 +3950,15 @@ extension TalkModeManager {
self.realtimeModelId = nil
self.realtimeVoiceId = nil
self.configuredProviderModelId = nil
self.gatewayTalkProviderLabel = "Not loaded"
self.gatewayTalkTransportLabel = "Not loaded"
self.gatewayTalkProviderLabel = String(localized: "Not loaded")
self.gatewayTalkTransportLabel = String(localized: "Not loaded")
self.gatewayTalkUsesRealtime = false
self.gatewayTalkUsesRealtimeRelay = false
self.gatewayTalkRealtimeProviderLabel = nil
self.gatewayTalkRealtimeModelId = nil
self.gatewayTalkRealtimeVoiceId = nil
self.applyVoiceModeDescriptor(TalkVoiceModeDescriptor(
title: "Not loaded",
title: String(localized: "Not loaded"),
subtitle: nil,
providerId: nil,
modelId: nil,
@@ -3949,7 +3976,7 @@ extension TalkModeManager {
func markTalkPermissionUpgradeRequested(requestId: String?) {
self.gatewayTalkPermissionState = .upgradeRequested(requestId: requestId)
self.statusText = "Approval requested"
self.statusText = String(localized: "Approval requested")
}
private static func missingTalkScope(from error: Error) -> String? {
@@ -4393,11 +4420,11 @@ extension TalkModeManager {
func _test_realtimeStatusPreservesPushToTalkCapture() -> Bool {
self.captureMode = .pushToTalk
self.isListening = false
self.statusText = "Listening (PTT)"
self.statusText = String(localized: "Listening (PTT)")
self.handleRealtimeRelayStatus("Listening (Realtime)")
return self.captureMode == .pushToTalk &&
!self.isListening &&
self.statusText == "Listening (PTT)"
self.statusText == String(localized: "Listening (PTT)")
}
func _test_prepareRealtimeRelayStart() {
+42 -21
View File
@@ -44,7 +44,7 @@ private enum VoiceWakeAudioError: LocalizedError {
var errorDescription: String? {
switch self {
case .invalidInputFormat:
"Microphone input format unavailable"
String(localized: "Microphone input format unavailable")
}
}
}
@@ -227,7 +227,7 @@ final class VoiceWakeManager: NSObject {
self.tearDownRecognitionPipeline()
}
if self.isEnabled {
self.statusText = "Paused"
self.statusText = String(localized: "Paused")
}
} else if self.isEnabled {
self.scheduleStart()
@@ -263,7 +263,7 @@ final class VoiceWakeManager: NSObject {
guard self.suppressionReasons.isEmpty else {
self.isListening = false
self.statusText = "Paused"
self.statusText = String(localized: "Paused")
return
}
@@ -273,15 +273,16 @@ final class VoiceWakeManager: NSObject {
// The iOS Simulators audio stack is unreliable for long-running microphone capture.
// (Weve observed CoreAudio deadlocks after TCC permission prompts.)
self.isListening = false
self.statusText = "Voice Wake isnt supported on Simulator"
self.statusText = String(localized: "Voice Wake isnt supported on Simulator")
return
}
self.statusText = "Requesting permissions…"
self.statusText = String(localized: "Requesting permissions…")
let micOk = await Self.requestMicrophonePermission()
guard micOk else {
self.statusText = Self.microphonePermissionMessage(kind: "Microphone")
self.statusText = Self.microphonePermissionMessage(
kind: String(localized: "Microphone"))
self.isListening = false
return
}
@@ -289,7 +290,7 @@ final class VoiceWakeManager: NSObject {
let speechOk = await Self.requestSpeechPermission()
guard speechOk else {
self.statusText = Self.permissionMessage(
kind: "Speech recognition",
kind: String(localized: "Speech recognition"),
status: SFSpeechRecognizer.authorizationStatus())
self.isListening = false
return
@@ -297,14 +298,16 @@ final class VoiceWakeManager: NSObject {
self.speechRecognizer = SFSpeechRecognizer()
guard self.speechRecognizer != nil else {
self.statusText = "Speech recognizer unavailable"
self.statusText = String(localized: "Speech recognizer unavailable")
self.isListening = false
return
}
guard self.isEnabled, self.suppressionReasons.isEmpty else {
self.isListening = false
self.statusText = self.isEnabled ? "Paused" : "Off"
self.statusText = self.isEnabled
? String(localized: "Paused")
: String(localized: "Off")
return
}
@@ -312,18 +315,20 @@ final class VoiceWakeManager: NSObject {
try self.configureOwnedAudioSession()
try self.startRecognition()
self.isListening = true
self.statusText = "Listening"
self.statusText = String(localized: "Listening")
} catch {
self.isListening = false
self.tearDownRecognitionPipeline()
self.statusText = "Start failed: \(error.localizedDescription)"
self.statusText = String(
format: String(localized: "Start failed: %@"),
error.localizedDescription)
}
}
func stop() {
self.isEnabled = false
self.isListening = false
self.statusText = "Off"
self.statusText = String(localized: "Off")
self.cancelScheduledStart()
self.tearDownRecognitionPipeline()
}
@@ -437,7 +442,9 @@ final class VoiceWakeManager: NSObject {
{
guard self.recognitionGeneration == recognitionGeneration else { return }
if let errorText {
self.statusText = "Recognizer error: \(errorText)"
self.statusText = String(
format: String(localized: "Recognizer error: %@"),
errorText)
self.isListening = false
self.tearDownRecognitionPipeline()
self.scheduleStart(after: self.recognitionErrorRestartDelayNs)
@@ -450,7 +457,7 @@ final class VoiceWakeManager: NSObject {
if cmd == self.lastDispatched { return }
self.lastDispatched = cmd
self.lastTriggeredCommand = cmd
self.statusText = "Triggered"
self.statusText = String(localized: "Triggered")
self.commandGeneration &+= 1
let commandGeneration = self.commandGeneration
@@ -606,23 +613,37 @@ final class VoiceWakeManager: NSObject {
{
switch status {
case .denied:
return "\(kind) permission denied"
return String(
format: String(localized: "%@ permission denied"),
kind)
case .restricted:
return "\(kind) permission restricted"
return String(
format: String(localized: "%@ permission restricted"),
kind)
case .notDetermined:
return "\(kind) permission not granted"
return String(
format: String(localized: "%@ permission not granted"),
kind)
case .authorized:
return "\(kind) permission denied"
return String(
format: String(localized: "%@ permission denied"),
kind)
@unknown default:
return "\(kind) permission denied"
return String(
format: String(localized: "%@ permission denied"),
kind)
}
}
private nonisolated static func deniedByDefaultPermissionMessage(kind: String, isUndetermined: Bool) -> String {
if isUndetermined {
return "\(kind) permission not granted"
return String(
format: String(localized: "%@ permission not granted"),
kind)
}
return "\(kind) permission denied"
return String(
format: String(localized: "%@ permission denied"),
kind)
}
}