mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-28 05:16:23 -06:00
fix(ios): localize gateway problem presentation
This commit is contained in:
@@ -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`() {
|
||||
|
||||
Reference in New Issue
Block a user