fix(ios): localize gateway problem presentation

This commit is contained in:
Vincent Koc
2026-07-12 13:34:42 +02:00
parent 3372812560
commit 4e689e33d5
8 changed files with 138 additions and 17 deletions
@@ -1007,7 +1007,7 @@ extension SettingsProTab {
var setupStatusLine: String? {
if let problem = self.appModel.lastGatewayProblem {
return problem.message
return problem.localizedMessage
}
let trimmedSetup = self.setupStatusText?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let gatewayStatus = self.appModel.gatewayStatusText.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -9,18 +9,19 @@ enum GatewayProblemPrimaryAction {
nonRetryableTitle: String? = nil) -> String?
{
if problem.suggestsOnboardingReset, let resetTitle {
return resetTitle
return String(localized: String.LocalizationValue(resetTitle))
}
if problem.canTrustRotatedCertificate {
return "Trust certificate"
return String(localized: "Trust certificate")
}
if problem.kind == .protocolMismatch {
return problem.actionLabel
return problem.localizedActionLabel
}
if problem.retryable {
return problem.actionLabel ?? retryTitle
return problem.localizedActionLabel
?? String(localized: String.LocalizationValue(retryTitle))
}
return nonRetryableTitle
return nonRetryableTitle.map { String(localized: String.LocalizationValue($0)) }
}
@MainActor
@@ -2,6 +2,50 @@ import OpenClawKit
import SwiftUI
import UIKit
extension GatewayConnectionProblem.PresentationText {
var localizedString: String {
switch self {
case let .localized(key):
String(localized: String.LocalizationValue(key))
case let .localizedFormat(format, arguments):
String(
format: String(localized: String.LocalizationValue(format)),
locale: .current,
arguments: arguments.map { $0 as CVarArg })
case let .verbatim(value):
value
}
}
}
extension GatewayConnectionProblem {
var localizedTitle: String {
self.titlePresentation.localizedString
}
var localizedMessage: String {
self.messagePresentation.localizedString
}
var localizedActionLabel: String? {
self.actionLabelPresentation?.localizedString
}
var localizedStatusText: String {
switch self.kind {
case .pairingRequired, .pairingRoleUpgradeRequired, .pairingScopeUpgradeRequired,
.pairingMetadataUpgradeRequired, .protocolMismatch:
guard let requestId else { return self.localizedTitle }
return String(
format: String(localized: "%@ (request ID: %@)"),
self.localizedTitle,
requestId)
default:
return self.localizedTitle
}
}
}
struct GatewayProblemBanner: View {
let problem: GatewayConnectionProblem
var primaryActionTitle: String?
@@ -11,12 +55,12 @@ struct GatewayProblemBanner: View {
var body: some View {
OpenClawNoticeBanner(
icon: self.iconName,
title: .localized(self.problem.title),
message: .localized(self.problem.message),
title: .verbatim(self.problem.localizedTitle),
message: .verbatim(self.problem.localizedMessage),
ownerLabel: .localized(self.ownerLabel),
tint: self.tint,
detail: self.problem.requestId.map(OpenClawNoticeDetail.requestID),
primaryActionTitle: self.primaryActionTitle.map(OpenClawTextValue.localized),
primaryActionTitle: self.primaryActionTitle.map(OpenClawTextValue.verbatim),
onPrimaryAction: self.onPrimaryAction,
secondaryActionTitle: "Details",
onSecondaryAction: self.onShowDetails)
@@ -88,9 +132,9 @@ struct GatewayProblemDetailsSheet: View {
List {
Section {
VStack(alignment: .leading, spacing: 10) {
Text(LocalizedStringKey(self.problem.title))
Text(verbatim: self.problem.localizedTitle)
.font(OpenClawType.title3)
Text(LocalizedStringKey(self.problem.message))
Text(verbatim: self.problem.localizedMessage)
.font(OpenClawType.body)
.foregroundStyle(.secondary)
Text(LocalizedStringKey(self.ownerSummary))
@@ -189,7 +233,7 @@ struct GatewayProblemDetailsSheet: View {
self.dismiss()
onPrimaryAction()
} label: {
Text(LocalizedStringKey(primaryActionTitle))
Text(verbatim: primaryActionTitle)
.font(OpenClawType.subheadSemiBold)
}
.font(OpenClawType.subheadSemiBold)
@@ -126,7 +126,7 @@ struct OnboardingConnectPhaseView: View {
let actionTitle = self.primaryActionTitle(problem)
GatewayProblemBanner(
problem: problem,
primaryActionTitle: actionTitle ?? (problem.retryable ? "Retry" : nil),
primaryActionTitle: actionTitle ?? (problem.retryable ? String(localized: "Retry") : nil),
onPrimaryAction: {
if actionTitle != nil {
self.onHandleProblem(problem)
@@ -142,7 +142,7 @@ struct OnboardingConnectPhaseView: View {
message: .verbatim(message),
ownerLabel: "Needs attention",
tint: OpenClawBrand.danger,
primaryActionTitle: allowsRetry ? "Retry" : nil,
primaryActionTitle: allowsRetry ? String(localized: "Retry") : nil,
onPrimaryAction: allowsRetry ? self.onRetry : nil)
case .ready:
OpenClawStatusBadge(label: "Ready to Connect", tone: .muted)
@@ -1091,8 +1091,8 @@ extension OnboardingWizardView {
}
if let problem {
self.connectMessage = problem.message
self.statusLine = problem.message
self.connectMessage = problem.localizedMessage
self.statusLine = problem.localizedMessage
return
}
@@ -221,7 +221,8 @@ extension RootTabsSourceGuardTests {
#expect(!connectionFailure.contains("self.connectMessage = message"))
#expect(connectionFailure.contains("self.statusLine = message"))
#expect(onboardingSource.contains(".failedStatus(message: message, allowsRetry: false)"))
#expect(onboardingSource.contains("primaryActionTitle: allowsRetry ? \"Retry\" : nil"))
#expect(onboardingSource.contains(
"primaryActionTitle: allowsRetry ? String(localized: \"Retry\") : nil"))
#expect(onboardingSource.contains("onPrimaryAction: allowsRetry ? self.onRetry : nil"))
#expect(stagedSetupClear.contains("self.localConnectionFailure = nil"))
#expect(onboardingRetry.contains("self.localConnectionFailure = nil"))
@@ -1,6 +1,12 @@
import Foundation
public struct GatewayConnectionProblem: Equatable, Sendable {
public enum PresentationText: Equatable, Sendable {
case localized(String)
case localizedFormat(String, [String])
case verbatim(String)
}
public enum Kind: String, Equatable, Sendable {
case gatewayAuthTokenMissing
case gatewayAuthTokenMismatch
@@ -51,6 +57,9 @@ public struct GatewayConnectionProblem: Equatable, Sendable {
public let title: String
public let message: String
public let actionLabel: String?
public let titlePresentation: PresentationText
public let messagePresentation: PresentationText
public let actionLabelPresentation: PresentationText?
public let actionCommand: String?
public let docsURL: URL?
public let requestId: String?
@@ -68,6 +77,9 @@ public struct GatewayConnectionProblem: Equatable, Sendable {
title: String,
message: String,
actionLabel: String? = nil,
titlePresentation: PresentationText? = nil,
messagePresentation: PresentationText? = nil,
actionLabelPresentation: PresentationText? = nil,
actionCommand: String? = nil,
docsURL: URL? = nil,
requestId: String? = nil,
@@ -84,6 +96,10 @@ public struct GatewayConnectionProblem: Equatable, Sendable {
self.title = title
self.message = message
self.actionLabel = Self.trimmedOrNil(actionLabel)
self.titlePresentation = titlePresentation ?? .localized(title)
self.messagePresentation = messagePresentation ?? .localized(message)
self.actionLabelPresentation = actionLabelPresentation
?? self.actionLabel.map(PresentationText.localized)
self.actionCommand = Self.trimmedOrNil(actionCommand)
self.docsURL = docsURL
self.requestId = Self.trimmedOrNil(requestId)
@@ -683,12 +699,20 @@ public enum GatewayConnectionProblemMapper {
: " This device could not verify the new certificate."
let message = "The saved TLS certificate pin for \(failure.host) "
+ "no longer matches the gateway certificate.\(trustedSuffix)"
let messagePresentation: GatewayConnectionProblem.PresentationText = failure.systemTrustOk
? .localizedFormat(
"The saved TLS certificate pin for %@ no longer matches the gateway certificate. The new certificate is trusted by this device; this is commonly caused by certificate rotation.",
[failure.host])
: .localizedFormat(
"The saved TLS certificate pin for %@ no longer matches the gateway certificate. This device could not verify the new certificate.",
[failure.host])
return GatewayConnectionProblem(
kind: .tlsPinMismatch,
owner: failure.systemTrustOk ? .network : .unknown,
title: "Gateway certificate changed",
message: message,
actionLabel: "Review certificate",
messagePresentation: messagePresentation,
actionCommand: nil,
docsURL: URL(string: "https://docs.openclaw.ai/gateway/troubleshooting"),
retryable: false,
@@ -705,6 +729,9 @@ public enum GatewayConnectionProblemMapper {
title: "Gateway certificate unavailable",
message: "OpenClaw could not read the gateway certificate for \(failure.host).",
actionLabel: "Retry",
messagePresentation: .localizedFormat(
"OpenClaw could not read the gateway certificate for %@.",
[failure.host]),
actionCommand: nil,
docsURL: URL(string: "https://docs.openclaw.ai/gateway/troubleshooting"),
retryable: true,
@@ -717,6 +744,9 @@ public enum GatewayConnectionProblemMapper {
title: "Gateway certificate is not trusted",
message: "This device does not trust the TLS certificate presented by \(failure.host).",
actionLabel: "Check certificate",
messagePresentation: .localizedFormat(
"This device does not trust the TLS certificate presented by %@.",
[failure.host]),
actionCommand: nil,
docsURL: URL(string: "https://docs.openclaw.ai/gateway/troubleshooting"),
retryable: false,
@@ -987,6 +1017,16 @@ public enum GatewayConnectionProblemMapper {
title: defaults.title,
message: defaults.message,
actionLabel: defaults.actionLabel,
titlePresentation: authError.titleOverride == nil
? .localized(defaults.title)
: .verbatim(defaults.title),
messagePresentation: authError.userMessageOverride == nil
&& defaults.message != authError.message
? .localized(defaults.message)
: .verbatim(defaults.message),
actionLabelPresentation: authError.actionLabel == nil
? defaults.actionLabel.map(GatewayConnectionProblem.PresentationText.localized)
: defaults.actionLabel.map(GatewayConnectionProblem.PresentationText.verbatim),
actionCommand: defaults.actionCommand,
docsURL: defaults.docsURL,
requestId: defaults.requestId,
@@ -47,6 +47,37 @@ struct GatewayErrorsTests {
#expect(error.minimumProbeProtocol == 4)
}
@Test func `app owned gateway copy remains localizable`() throws {
let error = GatewayConnectAuthError(
message: "pairing required",
detailCode: GatewayConnectAuthDetailCode.pairingRequired.rawValue,
canRetryWithDeviceToken: false,
requestId: "req-123")
let problem = try #require(GatewayConnectionProblemMapper.map(error: error))
#expect(problem.titlePresentation == .localized("Pairing approval required"))
#expect(problem.messagePresentation == .localized(
"Approve this device on the gateway, then reconnect."))
#expect(problem.actionLabelPresentation == .localized("Approve on gateway"))
}
@Test func `gateway supplied copy remains verbatim`() throws {
let error = GatewayConnectAuthError(
message: "pairing required",
detailCode: GatewayConnectAuthDetailCode.pairingRequired.rawValue,
canRetryWithDeviceToken: false,
titleOverride: "Custom gateway title",
userMessageOverride: "Custom gateway instructions",
actionLabel: "Custom gateway action")
let problem = try #require(GatewayConnectionProblemMapper.map(error: error))
#expect(problem.titlePresentation == .verbatim("Custom gateway title"))
#expect(problem.messagePresentation == .verbatim("Custom gateway instructions"))
#expect(problem.actionLabelPresentation == .verbatim("Custom gateway action"))
}
@Test func `protocol mismatch maps older app to update problem`() {
let error = GatewayConnectAuthError(
message: "protocol mismatch",
@@ -210,6 +241,10 @@ struct GatewayErrorsTests {
#expect(problem?.tlsStoreKey == "gateway.example.ts.net:443")
#expect(problem?.tlsExpectedFingerprint == "old")
#expect(problem?.tlsObservedFingerprint == "new")
#expect(problem?.messagePresentation == .localizedFormat(
"The saved TLS certificate pin for %@ no longer matches the gateway certificate. "
+ "The new certificate is trusted by this device; this is commonly caused by certificate rotation.",
["gateway.example.ts.net"]))
}
@Test func `untrusted TLS certificate pauses reconnect`() {