From 4e689e33d5def728e2b4ae6f03f27ef2b94519e8 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 12 Jul 2026 13:34:42 +0200 Subject: [PATCH] fix(ios): localize gateway problem presentation --- .../Design/SettingsProTabActions.swift | 2 +- .../Gateway/GatewayProblemPrimaryAction.swift | 11 ++-- .../Sources/Gateway/GatewayProblemView.swift | 56 +++++++++++++++++-- .../OnboardingWizardConnectionSections.swift | 4 +- .../Onboarding/OnboardingWizardView.swift | 4 +- ...tTabsSourceGuardTests+GatewaySupport.swift | 3 +- .../GatewayConnectionProblem.swift | 40 +++++++++++++ .../OpenClawKitTests/GatewayErrorsTests.swift | 35 ++++++++++++ 8 files changed, 138 insertions(+), 17 deletions(-) diff --git a/apps/ios/Sources/Design/SettingsProTabActions.swift b/apps/ios/Sources/Design/SettingsProTabActions.swift index e98fa59d042d..66d9c936df6d 100644 --- a/apps/ios/Sources/Design/SettingsProTabActions.swift +++ b/apps/ios/Sources/Design/SettingsProTabActions.swift @@ -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) diff --git a/apps/ios/Sources/Gateway/GatewayProblemPrimaryAction.swift b/apps/ios/Sources/Gateway/GatewayProblemPrimaryAction.swift index 40e79d6726ed..106a1c517f7d 100644 --- a/apps/ios/Sources/Gateway/GatewayProblemPrimaryAction.swift +++ b/apps/ios/Sources/Gateway/GatewayProblemPrimaryAction.swift @@ -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 diff --git a/apps/ios/Sources/Gateway/GatewayProblemView.swift b/apps/ios/Sources/Gateway/GatewayProblemView.swift index 3c445f5bbd2f..6bfda32f3ea5 100644 --- a/apps/ios/Sources/Gateway/GatewayProblemView.swift +++ b/apps/ios/Sources/Gateway/GatewayProblemView.swift @@ -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) diff --git a/apps/ios/Sources/Onboarding/OnboardingWizardConnectionSections.swift b/apps/ios/Sources/Onboarding/OnboardingWizardConnectionSections.swift index 2069314e96dd..5069907031eb 100644 --- a/apps/ios/Sources/Onboarding/OnboardingWizardConnectionSections.swift +++ b/apps/ios/Sources/Onboarding/OnboardingWizardConnectionSections.swift @@ -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) diff --git a/apps/ios/Sources/Onboarding/OnboardingWizardView.swift b/apps/ios/Sources/Onboarding/OnboardingWizardView.swift index 01d54716462c..3d0596b7134d 100644 --- a/apps/ios/Sources/Onboarding/OnboardingWizardView.swift +++ b/apps/ios/Sources/Onboarding/OnboardingWizardView.swift @@ -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 } diff --git a/apps/ios/Tests/RootTabsSourceGuardTests+GatewaySupport.swift b/apps/ios/Tests/RootTabsSourceGuardTests+GatewaySupport.swift index 6f26fa3f3a3b..21e961338f08 100644 --- a/apps/ios/Tests/RootTabsSourceGuardTests+GatewaySupport.swift +++ b/apps/ios/Tests/RootTabsSourceGuardTests+GatewaySupport.swift @@ -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")) diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayConnectionProblem.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayConnectionProblem.swift index b2db4231de13..9198b10ae1f6 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayConnectionProblem.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayConnectionProblem.swift @@ -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, diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayErrorsTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayErrorsTests.swift index 3988fa13226b..2a65dbf647b9 100644 --- a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayErrorsTests.swift +++ b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayErrorsTests.swift @@ -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`() {