fix(mobile): clarify gateway connection security setup (#101325)

* fix(mobile): clarify gateway connection setup

* chore(i18n): sync native mobile strings

* fix(mobile): align manual gateway previews

* chore(i18n): refresh native source inventory

* chore(android): remove unused connect tab strings

* test(i18n): drop removed connect tab sentinel

* fix(ios): brand connection security picker labels

* fix(mobile): hide gateway endpoint previews

* chore(i18n): sync native source inventory

* fix(mobile): remove redundant TLS helper copy
This commit is contained in:
Josh Avant 2026-07-07 14:43:11 -05:00 committed by GitHub
parent e242c61400
commit 6438c89f05
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 703 additions and 290 deletions

File diff suppressed because it is too large Load diff

View file

@ -2712,7 +2712,7 @@ class NodeRuntime private constructor(
private fun gatewayTlsProbeFailureMessage(failure: GatewayTlsProbeFailure?): String =
when (failure) {
GatewayTlsProbeFailure.TLS_UNAVAILABLE ->
"Failed: this host requires wss:// or Tailscale Serve. No TLS endpoint detected."
"Failed: no secure gateway endpoint was detected. Enable gateway TLS or Tailscale Serve, or use a trusted private LAN address with Unencrypted selected."
GatewayTlsProbeFailure.TLS_HANDSHAKE_TIMEOUT ->
"Failed: secure endpoint reached, but TLS fingerprint verification timed out. Check Tailscale Serve or gateway TLS and retry."
GatewayTlsProbeFailure.ENDPOINT_UNREACHABLE, null ->

View file

@ -18,6 +18,13 @@ internal data class GatewayEndpointConfig(
val displayUrl: String,
)
/** Effective transport shown by manual gateway forms before they connect. */
internal data class GatewayManualTransportPresentation(
val requiresTls: Boolean,
val effectiveTls: Boolean,
val helperText: String?,
)
/** Decoded setup-code payload; only one credential family is expected to be populated. */
internal data class GatewaySetupCode(
val url: String,
@ -375,6 +382,53 @@ internal fun composeGatewayManualUrl(
return "$scheme://${ai.openclaw.app.gateway.formatGatewayAuthority(bareHost, port)}"
}
/** Keeps manual transport controls aligned with the runtime's remote-host TLS policy. */
internal fun gatewayManualTransportPresentation(
hostInput: String,
requestedTls: Boolean,
): GatewayManualTransportPresentation {
val host = hostInput.trim()
if (host.isEmpty()) {
return gatewayManualTransportPresentation(
requiresTls = false,
effectiveTls = requestedTls,
)
}
if (host.contains("://")) {
val config = parseGatewayEndpointResult(host).config
if (config != null) {
return gatewayManualTransportPresentation(
requiresTls = !isLocalCleartextGatewayHost(config.host),
effectiveTls = config.tls,
)
}
}
val normalizedHost = host.trimEnd('/')
val requiresTls = !isLocalCleartextGatewayHost(normalizedHost)
val effectiveTls = requestedTls || requiresTls
return gatewayManualTransportPresentation(
requiresTls = requiresTls,
effectiveTls = effectiveTls,
)
}
private fun gatewayManualTransportPresentation(
requiresTls: Boolean,
effectiveTls: Boolean,
): GatewayManualTransportPresentation =
GatewayManualTransportPresentation(
requiresTls = requiresTls,
effectiveTls = effectiveTls,
helperText =
when {
requiresTls -> "Secure connection is required for this host."
effectiveTls -> null
else -> "Use only on a trusted private network."
},
)
private fun parseJsonObject(input: String): JsonObject? = runCatching { gatewaySetupJson.parseToJsonElement(input).jsonObject }.getOrNull()
private fun resolveSetupCodeCandidate(rawInput: String): String? {

View file

@ -588,6 +588,11 @@ fun OnboardingFlow(
setupError = "That looks like a setup code. Go back and choose Setup Gateway, then Use setup code."
return
}
val transport =
gatewayManualTransportPresentation(
hostInput = manualHost,
requestedTls = manualTls,
)
val plan =
resolveGatewayConnectPlan(
useSetupCode = false,
@ -597,14 +602,14 @@ fun OnboardingFlow(
savedManualTls = savedManualTls,
manualHostInput = manualHost,
manualPortInput = manualPort,
manualTlsInput = manualTls,
manualTlsInput = transport.effectiveTls,
bootstrapTokenInput = "",
tokenInput = token,
passwordInput = password,
)
if (plan == null) {
val endpointError =
composeGatewayManualUrl(manualHost, manualPort, manualTls)
composeGatewayManualUrl(manualHost, manualPort, transport.effectiveTls)
?.let(::parseGatewayEndpointResult)
?.error
?: GatewayEndpointValidationError.INVALID_URL
@ -1574,6 +1579,13 @@ private fun ManualGatewaySetupScreen(
onPair: () -> Unit,
modifier: Modifier = Modifier,
) {
val transport =
remember(manualHost, manualTls) {
gatewayManualTransportPresentation(
hostInput = manualHost,
requestedTls = manualTls,
)
}
ClawScaffold(modifier = modifier, contentPadding = onboardingContentPadding()) {
Column(modifier = Modifier.fillMaxSize().imePadding(), verticalArrangement = Arrangement.SpaceBetween) {
LazyColumn(
@ -1613,16 +1625,27 @@ private fun ManualGatewaySetupScreen(
}
}
item {
LabeledField(label = "Connection type") {
LabeledField(label = "Connection security") {
Row(horizontalArrangement = Arrangement.spacedBy(9.dp)) {
TogglePill(text = "Local network", selected = !manualTls, onClick = { onManualTlsChange(false) })
TogglePill(text = "Secure remote", selected = manualTls, onClick = { onManualTlsChange(true) })
TogglePill(
text = "Unencrypted",
selected = !transport.effectiveTls,
enabled = !transport.requiresTls,
onClick = { onManualTlsChange(false) },
)
TogglePill(
text = "Secure (TLS)",
selected = transport.effectiveTls,
onClick = { onManualTlsChange(true) },
)
}
transport.helperText?.let { helperText ->
Text(
text = helperText,
style = ClawTheme.type.caption,
color = ClawTheme.colors.textMuted,
)
}
Text(
text = "Local works for LAN or emulator hosts. Secure remote is for wss:// or Tailscale Serve/Funnel.",
style = ClawTheme.type.caption,
color = ClawTheme.colors.textMuted,
)
}
}
error?.let { message ->
@ -1714,6 +1737,7 @@ private fun GatewayRecoveryScreen(
)
val context = LocalContext.current
val approvalCommand = recoveryGatewayApprovalCommand(gatewayConnectionProblem)
val protocolUpdateCommand = recoveryGatewayProtocolMismatchCommand(gatewayConnectionProblem)
val recoveryTitle =
when {
recoveryState == GatewayRecoveryUiState.Connected -> "Gateway paired"
@ -1803,6 +1827,16 @@ private fun GatewayRecoveryScreen(
Spacer(modifier = Modifier.height(18.dp))
ApprovalCommandBlock(command = command, onCopy = { copyApprovalCommand(context, command) })
}
protocolUpdateCommand?.let { command ->
Spacer(modifier = Modifier.height(18.dp))
Text(
text = "On the Gateway computer, run:",
style = ClawTheme.type.caption,
color = ClawTheme.colors.textMuted,
)
Spacer(modifier = Modifier.height(8.dp))
ApprovalCommandBlock(command = command, onCopy = { copyGatewayCommand(context, command) })
}
if (recoveryProgressItems.isNotEmpty()) {
Spacer(modifier = Modifier.height(20.dp))
GatewayRecoveryProgress(items = recoveryProgressItems)
@ -2259,10 +2293,12 @@ private fun OnboardingHeader(
private fun TogglePill(
text: String,
selected: Boolean,
enabled: Boolean = true,
onClick: () -> Unit,
) {
Surface(
onClick = onClick,
enabled = enabled,
modifier = Modifier.height(34.dp),
shape = RoundedCornerShape(ClawTheme.radii.pill),
color = if (selected) ClawTheme.colors.primary else ClawTheme.colors.surfaceRaised,
@ -2636,6 +2672,15 @@ private fun recoveryGatewayProtocolMismatchDetail(gatewayConnectionProblem: Gate
return protocolMismatchVersions(clientMin, clientMax, expected)?.let { "$summary $it" } ?: summary
}
internal fun recoveryGatewayProtocolMismatchCommand(
gatewayConnectionProblem: GatewayConnectionProblem?,
): String? {
if (gatewayConnectionProblem?.code != "PROTOCOL_MISMATCH") return null
val clientMin = gatewayConnectionProblem.clientMinProtocol ?: return null
val expected = gatewayConnectionProblem.expectedProtocol ?: return null
return "openclaw update".takeIf { clientMin > expected }
}
private fun protocolMismatchVersions(
clientMin: Int?,
clientMax: Int?,
@ -2758,6 +2803,15 @@ private fun copyApprovalCommand(
Toast.makeText(context, "Approval command copied", Toast.LENGTH_SHORT).show()
}
private fun copyGatewayCommand(
context: Context,
command: String,
) {
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
clipboard.setPrimaryClip(ClipData.newPlainText("OpenClaw gateway command", command))
Toast.makeText(context, "Command copied", Toast.LENGTH_SHORT).show()
}
/** One permission row plus launcher callback for onboarding's final setup step. */
private data class PermissionRowModel(
val title: String,

View file

@ -1156,6 +1156,13 @@ private fun GatewaySettingsScreen(
var showSetupCodeHelp by remember { mutableStateOf(false) }
var pendingSetupResetPlan by remember { mutableStateOf<GatewayConnectPlan?>(null) }
var pendingForgetStableId by remember { mutableStateOf<String?>(null) }
val transport =
remember(hostInput, tlsInput) {
gatewayManualTransportPresentation(
hostInput = hostInput,
requestedTls = tlsInput,
)
}
fun saveAndConnect(plan: GatewayConnectPlan) {
validationText = null
@ -1308,11 +1315,26 @@ private fun GatewaySettingsScreen(
ClawTextField(value = hostInput, onValueChange = { hostInput = it }, placeholder = "Host", modifier = Modifier.weight(1f))
ClawTextField(value = portInput, onValueChange = { portInput = it }, placeholder = "Port", modifier = Modifier.weight(0.62f))
}
Text(text = "Connection security", style = ClawTheme.type.caption, color = ClawTheme.colors.textMuted)
val securityOptions = listOf("Unencrypted", "Secure (TLS)")
ClawSegmentedControl(
options = listOf("Local", "TLS"),
selected = if (tlsInput) "TLS" else "Local",
onSelect = { selected -> tlsInput = selected == "TLS" },
options = securityOptions,
selected = if (transport.effectiveTls) "Secure (TLS)" else "Unencrypted",
onSelect = { selected -> tlsInput = selected == "Secure (TLS)" },
enabledOptions =
if (transport.requiresTls) {
setOf("Secure (TLS)")
} else {
securityOptions.toSet()
},
)
transport.helperText?.let { helperText ->
Text(
text = helperText,
style = ClawTheme.type.caption,
color = ClawTheme.colors.textMuted,
)
}
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
ClawTextField(value = tokenInput, onValueChange = { tokenInput = it }, placeholder = "Token", modifier = Modifier.weight(1f))
ClawTextField(value = bootstrapTokenInput, onValueChange = { bootstrapTokenInput = it }, placeholder = "Bootstrap", modifier = Modifier.weight(1.05f))
@ -1333,7 +1355,7 @@ private fun GatewaySettingsScreen(
savedManualTls = manualTls,
manualHostInput = hostInput,
manualPortInput = portInput,
manualTlsInput = tlsInput,
manualTlsInput = transport.effectiveTls,
tokenInput = tokenInput,
bootstrapTokenInput = bootstrapTokenInput,
passwordInput = passwordInput,

View file

@ -458,6 +458,7 @@ internal fun ClawSegmentedControl(
selected: String,
onSelect: (String) -> Unit,
modifier: Modifier = Modifier,
enabledOptions: Set<String> = options.toSet(),
) {
Row(
modifier =
@ -469,20 +470,26 @@ internal fun ClawSegmentedControl(
) {
options.forEach { option ->
val active = option == selected
val enabled = option in enabledOptions
Box(
modifier =
Modifier
.weight(1f)
.clip(RoundedCornerShape(ClawTheme.radii.control))
.background(if (active) ClawTheme.colors.primary else Color.Transparent)
.clickable { onSelect(option) }
.clickable(enabled = enabled) { onSelect(option) }
.padding(horizontal = 9.dp, vertical = 7.dp),
contentAlignment = Alignment.Center,
) {
Text(
text = option,
style = ClawTheme.type.caption,
color = if (active) ClawTheme.colors.primaryText else ClawTheme.colors.textMuted,
color =
when {
active -> ClawTheme.colors.primaryText
enabled -> ClawTheme.colors.textMuted
else -> ClawTheme.colors.textSubtle
},
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)

View file

@ -630,7 +630,7 @@ class GatewayBootstrapAuthTest {
)
assertEquals(
"Failed: this host requires wss:// or Tailscale Serve. No TLS endpoint detected.",
"Failed: no secure gateway endpoint was detected. Enable gateway TLS or Tailscale Serve, or use a trusted private LAN address with Unencrypted selected.",
waitForStatusText(runtime),
)
assertNull(runtime.pendingGatewayTrust.value)

View file

@ -9,6 +9,45 @@ import java.util.Base64
@RunWith(RobolectricTestRunner::class)
class GatewayConfigResolverTest {
@Test
fun manualTransportForcesSecureConnectionForRemoteHosts() {
val presentation =
gatewayManualTransportPresentation(
hostInput = "gateway.example.com",
requestedTls = false,
)
assertEquals(true, presentation.requiresTls)
assertEquals(true, presentation.effectiveTls)
assertEquals("Secure connection is required for this host.", presentation.helperText)
}
@Test
fun manualTransportAllowsUnencryptedPrivateLanConnections() {
val presentation =
gatewayManualTransportPresentation(
hostInput = "192.168.1.20",
requestedTls = false,
)
assertEquals(false, presentation.requiresTls)
assertEquals(false, presentation.effectiveTls)
assertEquals("Use only on a trusted private network.", presentation.helperText)
}
@Test
fun manualTransportDoesNotRepeatSelectedPrivateLanTlsState() {
val presentation =
gatewayManualTransportPresentation(
hostInput = "192.168.1.20",
requestedTls = true,
)
assertEquals(false, presentation.requiresTls)
assertEquals(true, presentation.effectiveTls)
assertNull(presentation.helperText)
}
@Test
fun parseGatewayEndpointUsesDefaultTlsPortForBareWssUrls() {
val parsed = parseGatewayEndpoint("wss://gateway.example")

View file

@ -945,6 +945,23 @@ class OnboardingFlowLogicTest {
),
),
)
assertEquals(
"openclaw update",
recoveryGatewayProtocolMismatchCommand(
GatewayConnectionProblem(
code = "PROTOCOL_MISMATCH",
message = "protocol mismatch",
reason = null,
requestId = null,
recommendedNextStep = null,
pauseReconnect = true,
retryable = false,
clientMinProtocol = 6,
clientMaxProtocol = 6,
expectedProtocol = 5,
),
),
)
}
@Test

View file

@ -965,7 +965,21 @@ extension SettingsProTab {
TextField("Port", text: self.manualPortBinding)
.font(OpenClawType.body)
.keyboardType(.numberPad)
self.settingsToggle("Use TLS", isOn: self.$manualGatewayTLS)
Picker("Connection security", selection: self.manualGatewayTLSBinding) {
Text("Unencrypted")
.font(OpenClawType.captionSemiBold)
.tag(false)
Text("Secure (TLS)")
.font(OpenClawType.captionSemiBold)
.tag(true)
}
.pickerStyle(.segmented)
.disabled(self.manualGatewayTransport.requiresTLS)
if let helperText = self.manualGatewayTransport.helperText {
Text(helperText)
.font(OpenClawType.footnote)
.foregroundStyle(.secondary)
}
self.gatewayActionButton(
title: "Connect Manual",
icon: "network",
@ -980,6 +994,21 @@ extension SettingsProTab {
.disabled(self.setupAttemptID != nil)
}
private var manualGatewayTransport: GatewayManualTransportPresentation {
GatewayConnectionController.manualTransportPresentation(
host: self.manualGatewayHost,
requestedTLS: self.manualGatewayTLS)
}
private var manualGatewayTLSBinding: Binding<Bool> {
Binding(
get: { self.manualGatewayTransport.effectiveTLS },
set: { enabled in
guard !self.manualGatewayTransport.requiresTLS else { return }
self.manualGatewayTLS = enabled
})
}
var gatewayAdvancedCard: some View {
Section {
self.settingsToggle("Auto-connect on launch", isOn: self.$gatewayAutoConnect)

View file

@ -1261,7 +1261,8 @@ final class GatewayConnectionController {
"TLS fingerprint verification timed out for \(host):\(port). "
+ "Secure endpoint was reached, but TLS did not finish in time."
case .tlsUnavailable:
"No TLS endpoint detected at \(host):\(port). Remote gateways must use HTTPS/WSS."
"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."
case .certificateUnavailable:
"Could not read the TLS certificate from \(host):\(port)."
}
@ -1494,6 +1495,12 @@ private struct GatewayPendingTrustConnect {
let gatewayGeneration: UInt64?
}
struct GatewayManualTransportPresentation: Equatable {
let requiresTLS: Bool
let effectiveTLS: Bool
let helperText: String?
}
extension GatewayConnectionController {
private func buildGatewayURL(host: String, port: Int, useTLS: Bool) -> URL? {
let scheme = useTLS ? "wss" : "ws"
@ -1505,11 +1512,29 @@ extension GatewayConnectionController {
}
private func resolveManualUseTLS(host: String, useTLS: Bool) -> Bool {
useTLS || self.shouldRequireTLS(host: host)
Self.manualTransportPresentation(
host: host,
requestedTLS: useTLS).effectiveTLS
}
private func shouldRequireTLS(host: String) -> Bool {
!LoopbackHost.isLocalNetworkHost(host)
static func manualTransportPresentation(
host: String,
requestedTLS: Bool) -> GatewayManualTransportPresentation
{
let trimmedHost = host.trimmingCharacters(in: .whitespacesAndNewlines)
let requiresTLS = !trimmedHost.isEmpty && !LoopbackHost.isLocalNetworkHost(trimmedHost)
let effectiveTLS = requestedTLS || requiresTLS
let helperText: String? = if requiresTLS {
"Secure connection is required for this host."
} else if effectiveTLS {
nil
} else {
"Use only on a trusted private network."
}
return GatewayManualTransportPresentation(
requiresTLS: requiresTLS,
effectiveTLS: effectiveTLS,
helperText: helperText)
}
private func manualStableID(host: String, port: Int) -> String {

View file

@ -24,8 +24,12 @@ enum GatewayProblemPrimaryAction {
}
@MainActor
static func openProtocolMismatchHelpIfNeeded(_ problem: GatewayConnectionProblem) -> Bool {
static func handleProtocolMismatchIfNeeded(_ problem: GatewayConnectionProblem) -> Bool {
guard problem.kind == .protocolMismatch else { return false }
if let command = problem.actionCommand {
UIPasteboard.general.string = command
return true
}
if let url = problem.docsURL {
UIApplication.shared.open(url)
}

View file

@ -155,7 +155,7 @@ struct GatewayQuickSetupSheet: View {
_ = await self.gatewayController.trustRotatedGatewayCertificate(from: problem)
return
}
if GatewayProblemPrimaryAction.openProtocolMismatchHelpIfNeeded(problem) {
if GatewayProblemPrimaryAction.handleProtocolMismatchIfNeeded(problem) {
return
}
guard problem.retryable else { return }

View file

@ -687,7 +687,7 @@ struct OnboardingWizardView: View {
self.onboardingTextField("Host", text: self.manualHostBinding, focusedField: .manualHost)
self.onboardingTextField("Port", text: self.manualPortTextBinding, focusedField: .manualPort)
.keyboardType(.numberPad)
self.onboardingButtonToggle("Use TLS", isOn: self.$manualTLS)
self.manualConnectionSecurityRows
self.manualConnectButton
} header: {
Text("Developer Local")
@ -866,7 +866,7 @@ extension OnboardingWizardView {
self.onboardingTextField("Host", text: self.manualHostBinding, focusedField: .manualHost)
self.onboardingTextField("Port", text: self.manualPortTextBinding, focusedField: .manualPort)
.keyboardType(.numberPad)
self.onboardingButtonToggle("Use TLS", isOn: self.$manualTLS)
self.manualConnectionSecurityRows
self.onboardingTextField(
"Discovery Domain (optional)",
text: self.$discoveryDomain,
@ -888,6 +888,41 @@ extension OnboardingWizardView {
}
}
private var manualTransport: GatewayManualTransportPresentation {
GatewayConnectionController.manualTransportPresentation(
host: self.manualHost,
requestedTLS: self.manualTLS)
}
private var manualTLSBinding: Binding<Bool> {
Binding(
get: { self.manualTransport.effectiveTLS },
set: { enabled in
guard !self.manualTransport.requiresTLS else { return }
self.manualTLS = enabled
})
}
@ViewBuilder
private var manualConnectionSecurityRows: some View {
Picker("Connection security", selection: self.manualTLSBinding) {
Text("Unencrypted")
.font(OpenClawType.captionSemiBold)
.tag(false)
Text("Secure (TLS)")
.font(OpenClawType.captionSemiBold)
.tag(true)
}
.pickerStyle(.segmented)
.disabled(self.manualTransport.requiresTLS)
if let helperText = self.manualTransport.helperText {
Text(helperText)
.font(OpenClawType.footnote)
.foregroundStyle(.secondary)
}
}
private func onboardingLabeledContent(_ title: LocalizedStringKey, value: String) -> some View {
LabeledContent {
Text(verbatim: value)
@ -1650,7 +1685,7 @@ extension OnboardingWizardView {
_ = await self.gatewayController.trustRotatedGatewayCertificate(from: problem)
return
}
if GatewayProblemPrimaryAction.openProtocolMismatchHelpIfNeeded(problem) {
if GatewayProblemPrimaryAction.handleProtocolMismatchIfNeeded(problem) {
return
}
guard problem.retryable else { return }

View file

@ -1247,7 +1247,7 @@ extension RootTabs {
}
} else if problem.canTrustRotatedCertificate {
Task { await self.gatewayController.trustRotatedGatewayCertificate(from: problem) }
} else if GatewayProblemPrimaryAction.openProtocolMismatchHelpIfNeeded(problem) {
} else if GatewayProblemPrimaryAction.handleProtocolMismatchIfNeeded(problem) {
return
} else if problem.retryable {
Task { await self.gatewayController.connectActiveGateway() }

View file

@ -120,6 +120,36 @@ import Testing
#expect(controller._test_resolveManualUseTLS(host: "0.0.0.0", useTLS: false) == false)
}
@Test @MainActor func `manual transport presentation shows effective remote security`() {
let presentation = GatewayConnectionController.manualTransportPresentation(
host: "gateway.example.com",
requestedTLS: false)
#expect(presentation.requiresTLS)
#expect(presentation.effectiveTLS)
#expect(presentation.helperText == "Secure connection is required for this host.")
}
@Test @MainActor func `manual transport presentation allows unencrypted private LAN`() {
let presentation = GatewayConnectionController.manualTransportPresentation(
host: "192.168.1.20",
requestedTLS: false)
#expect(!presentation.requiresTLS)
#expect(!presentation.effectiveTLS)
#expect(presentation.helperText == "Use only on a trusted private network.")
}
@Test @MainActor func `manual transport presentation does not repeat selected private LAN TLS state`() {
let presentation = GatewayConnectionController.manualTransportPresentation(
host: "192.168.1.20",
requestedTLS: true)
#expect(!presentation.requiresTLS)
#expect(presentation.effectiveTLS)
#expect(presentation.helperText == nil)
}
@Test @MainActor func `manual connections allow private lan plaintext`() {
let controller = self.makeController()
@ -210,7 +240,7 @@ import Testing
#expect(appModel.gatewayStatusText == "Verify gateway TLS fingerprint")
}
@Test @MainActor func staleTrustAcceptanceReleasesAutoConnectSuppression() async {
@Test @MainActor func `stale trust acceptance releases auto connect suppression`() async {
let host = "gateway-\(UUID().uuidString).example.com"
let port = 18789
let stableID = "manual|\(host.lowercased())|\(port)"
@ -346,7 +376,7 @@ import Testing
#expect(appModel.gatewayStatusText == message)
}
@Test @MainActor func targetSwitchCancelsSuspendedDiscoveryResolution() async {
@Test @MainActor func `target switch cancels suspended discovery resolution`() async {
let defaults = UserDefaults.standard
let previousInstanceID = defaults.string(forKey: "node.instanceId")
defer {

View file

@ -19,6 +19,23 @@ struct GatewayProblemPrimaryActionTests {
#expect(title == "Update app")
}
@Test func `older gateway exposes its copyable update command`() {
let problem = GatewayConnectionProblem(
kind: .protocolMismatch,
owner: .gateway,
title: "Gateway update required",
message: "The gateway is older than this app.",
actionLabel: "Copy update command",
actionCommand: "openclaw update",
retryable: false,
pauseReconnect: true)
let title = GatewayProblemPrimaryAction.title(for: problem, retryTitle: "Retry connection")
#expect(title == "Copy update command")
#expect(problem.actionCommand == "openclaw update")
}
@Test func `reset-suggesting problem uses reset title when provided`() {
let problem = GatewayConnectionProblem(
kind: .gatewayAuthTokenMismatch,

View file

@ -226,6 +226,20 @@ struct OpenClawTypographyTests {
#expect(onboardingWizard.contains("title: \"Same Machine (Dev)\""))
#expect(onboardingWizard.contains("if lastMode == .developerLocal"))
#expect(onboardingWizard.contains("self.developerModeEnabled = true"))
let onboardingSecurityPicker = try Self.extract(
onboardingWizard,
from: "private var manualConnectionSecurityRows",
to: " private func onboardingLabeledContent")
let onboardingUnencryptedOption = try Self.extract(
onboardingSecurityPicker,
from: "Text(\"Unencrypted\")",
to: ".tag(false)")
let onboardingSecureOption = try Self.extract(
onboardingSecurityPicker,
from: "Text(\"Secure (TLS)\")",
to: ".tag(true)")
#expect(onboardingUnencryptedOption.contains(".font(OpenClawType.captionSemiBold)"))
#expect(onboardingSecureOption.contains(".font(OpenClawType.captionSemiBold)"))
#expect(settingsSections.contains(".font(OpenClawType.body)"))
#expect(settingsSections.contains("self.settingsToggle(\"Show Talk Control\", isOn: self.$talkButtonEnabled)"))
@ -253,6 +267,20 @@ struct OpenClawTypographyTests {
#expect(gatewaySecureField.contains(".autocorrectionDisabled()"))
#expect(settingsSections.contains("Picker(\"Default Agent\", selection: self.$selectedAgentPickerId)"))
#expect(settingsSections.contains("Text(\"Default\")"))
let settingsSecurityPicker = try Self.extract(
settingsSections,
from: "Picker(\"Connection security\", selection: self.manualGatewayTLSBinding)",
to: " .pickerStyle(.segmented)")
let settingsUnencryptedOption = try Self.extract(
settingsSecurityPicker,
from: "Text(\"Unencrypted\")",
to: ".tag(false)")
let settingsSecureOption = try Self.extract(
settingsSecurityPicker,
from: "Text(\"Secure (TLS)\")",
to: ".tag(true)")
#expect(settingsUnencryptedOption.contains(".font(OpenClawType.captionSemiBold)"))
#expect(settingsSecureOption.contains(".font(OpenClawType.captionSemiBold)"))
#expect(!privacyAccess.contains("DisclosureGroup(\"Privacy & Access\")"))
#expect(privacyAccess.contains("Text(\"Privacy & Access\")"))

View file

@ -930,7 +930,7 @@ struct RootTabsSourceGuardTests {
#expect(rootSource.contains("resetTitle: \"Reset onboarding\""))
#expect(rootSource.contains("GatewayOnboardingReset.reset(appModel: self.appModel, instanceId: instanceId)"))
#expect(rootSource.contains("self.gatewayController.trustRotatedGatewayCertificate(from: problem)"))
#expect(rootSource.contains("GatewayProblemPrimaryAction.openProtocolMismatchHelpIfNeeded(problem)"))
#expect(rootSource.contains("GatewayProblemPrimaryAction.handleProtocolMismatchIfNeeded(problem)"))
#expect(rootSource.contains("await self.gatewayController.connectActiveGateway()"))
#expect(rootSource.contains("GatewayProblemDetailsSheet("))

View file

@ -689,11 +689,9 @@ final class OpenClawSnapshotUITests: XCTestCase {
XCTAssertTrue(port.waitForExistence(timeout: 5))
port.tap()
port.typeText(String(repeating: XCUIKeyboardKey.delete.rawValue, count: 5) + "18920")
let useTLS = app.switches["Use TLS"]
XCTAssertTrue(useTLS.waitForExistence(timeout: 5))
if useTLS.value as? String != "0" {
useTLS.tap()
}
let unencrypted = app.buttons["Unencrypted"]
XCTAssertTrue(unencrypted.waitForExistence(timeout: 5))
unencrypted.tap()
app.buttons["Connect"].tap()
let tokenField = app.secureTextFields["Gateway Auth Token"]

View file

@ -807,6 +807,7 @@ public enum GatewayConnectionProblemMapper {
let message: String
let owner: GatewayConnectionProblem.Owner
let actionLabel: String
let actionCommand: String?
if let clientMax = authError.clientMaxProtocol,
let expected = authError.expectedProtocol,
clientMax < expected
@ -816,6 +817,7 @@ public enum GatewayConnectionProblemMapper {
?? "This app is older than the gateway. Update OpenClaw on this device, then retry."
owner = .iphone
actionLabel = authError.actionLabel ?? "Update app"
actionCommand = authError.actionCommand
} else if let clientMin = authError.clientMinProtocol,
let expected = authError.expectedProtocol,
clientMin > expected
@ -824,13 +826,15 @@ public enum GatewayConnectionProblemMapper {
message = authError.userMessageOverride
?? "The gateway is older than this app. Update OpenClaw on the gateway host, then retry."
owner = .gateway
actionLabel = authError.actionLabel ?? "Update gateway"
actionLabel = authError.actionLabel ?? "Copy update command"
actionCommand = authError.actionCommand ?? "openclaw update"
} else {
title = authError.titleOverride ?? "OpenClaw update required"
message = authError.userMessageOverride
?? "The app and gateway use incompatible protocol versions. Update OpenClaw on both, then retry."
owner = .both
actionLabel = authError.actionLabel ?? "Update OpenClaw"
actionCommand = authError.actionCommand
}
return self.problem(
kind: .protocolMismatch,
@ -838,7 +842,7 @@ public enum GatewayConnectionProblemMapper {
title: title,
message: message,
actionLabel: actionLabel,
actionCommand: authError.actionCommand,
actionCommand: actionCommand,
docsURL: self.docsURL(
authError.docsURLString,
fallback: "https://docs.openclaw.ai/gateway/troubleshooting"),

View file

@ -88,6 +88,8 @@ struct GatewayErrorsTests {
#expect(problem?.title == "Gateway update required")
#expect(problem?
.message == "The gateway is older than this app. Update OpenClaw on the gateway host, then retry.")
#expect(problem?.actionLabel == "Copy update command")
#expect(problem?.actionCommand == "openclaw update")
#expect(problem?.retryable == false)
#expect(problem?.pauseReconnect == true)
}