mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-10 00:11:19 +00:00
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:
parent
e242c61400
commit
6438c89f05
22 changed files with 703 additions and 290 deletions
File diff suppressed because it is too large
Load diff
|
|
@ -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 ->
|
||||
|
|
|
|||
|
|
@ -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? {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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() }
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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\")"))
|
||||
|
|
|
|||
|
|
@ -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("))
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue