Files
openclaw/apps/ios/Tests/RootTabsSourceGuardTests+GatewaySupport.swift
Cihat Gündüz c3fba2bb20 fix(ios): forgetting a paired gateway does nothing (#114083)
* fix(ios): forgetting a paired gateway does nothing

Confirming "Forget Gateway" in Settings never removed the gateway. The
confirmation dialog derived its isPresented binding from the payload it
later needed and cleared that payload on dismissal, so by the time the
destructive button's action ran, forgetPendingGateway() found nil at its
opening guard and returned without calling forgetGateway(stableID:).
Nothing was logged either, because the failure branch was never reached.

Hand the entry to the action through the dialog's presenting: parameter
instead of reading it back from view state, and take it as a parameter
rather than looking it up. Everything downstream is unchanged; it simply
runs now.

* Shorten the dialog comment to one line

The three-line version repeated in prose what the next line already shows.
What is worth keeping is the trap: someone simplifying this back to reading
the entry from view state reintroduces the bug.

* fix(ios): harden forget gateway payload handoff

Co-authored-by: Cihat Gündüz <anthropic@fline.dev>

* test(ios): run gateway settings source guards

* test(ios): allow forget dialog state cleanup

* test(ci): provide rg in PR review fixture

---------

Co-authored-by: Cihat Gündüz <anthropic@fline.dev>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-07-28 09:59:23 -04:00

346 lines
23 KiB
Swift

import Foundation
import Testing
extension RootTabsSourceGuardTests {
static func assertGatewaySettingsSurfaceGuards() throws {
let settingsSource = try String(contentsOf: self.settingsProTabSourceURL(), encoding: .utf8)
let sectionsSource = try String(contentsOf: self.settingsProTabSectionsSourceURL(), encoding: .utf8)
let actionsSource = try String(contentsOf: self.settingsProTabActionsSourceURL(), encoding: .utf8)
let trustSource = try String(contentsOf: self.gatewayTrustPromptAlertSourceURL(), encoding: .utf8)
let controllerSource = try self.gatewayConnectionControllerSource()
let rootSource = try String(contentsOf: self.rootTabsSourceURL(), encoding: .utf8)
let scannerSource = try String(contentsOf: self.qrScannerSourceURL(), encoding: .utf8)
let settingsScannerSheet = try self.extract(
settingsSource,
from: "isPresented: self.$showQRScanner,",
to: ".sheet(isPresented: self.$showNotificationRelayDisclosure)")
let settingsOnDismiss = try #require(settingsScannerSheet.range(of: "onDismiss: {"))
let settingsProcessing = try #require(settingsScannerSheet.range(of: "self.processQueuedScannerResult()"))
let settingsContent = try #require(settingsScannerSheet.range(of: "content: {"))
let forgetGatewayDialog = try self.extract(
settingsSource,
from: ".confirmationDialog(\n String(\n format: String(localized: \"Forget %@?\")",
to: "private func applyGatewaySetupRequestIfNeeded()")
let forgetGatewayAction = try self.extract(
actionsSource,
from: "func forgetGateway(_ entry: GatewaySettingsStore.GatewayRegistryEntry) async",
to: "func refreshGatewayRegistry()")
let settingsPendingSetupHandler = try self.extract(
actionsSource,
from: "func applyGatewaySetupLink(_ link: GatewayConnectDeepLink)",
to: "@discardableResult\n func applySetupCode(attemptID: UUID)")
let settingsScannerCancel = try #require(
settingsPendingSetupHandler.range(of: "self.scannerResultHandoff.cancel()"))
let settingsSetupStaging = try #require(
settingsPendingSetupHandler.range(of: "self.stagedGatewaySetupLink = link"))
let scannerMake = try self.extract(
scannerSource,
from: "func makeUIViewController",
to: "func updateUIViewController")
let scannerLifecycle = try self.extract(
scannerSource,
from: "final class QRScannerContainerViewController",
to: "final class Coordinator")
let scannerDelivery = try self.extract(
scannerSource,
from: "private func deliver(_ result: QRScannerResult",
to: "func dataScanner(_: DataScannerViewController, didRemove")
let stopScanning = try #require(scannerDelivery.range(of: "scanner.stopScanning()"))
let deliverResult = try #require(scannerDelivery.range(of: "self.parent.onResult(result)"))
let activeProblemToast = try self.extract(
rootSource,
from: "private var activeGatewayProblemToast: GatewayConnectionProblem?",
to: "private var gatewayToastAnimation: Animation?")
let gatewaySetupSource = try self.extract(
rootSource,
from: "private func maybeOpenSettingsForGatewaySetup()",
to: "private func maybeRequestLocalNetworkAccess")
let consumedGatewaySetup = try #require(
gatewaySetupSource.range(of: "appModel.consumePendingGatewaySetupLink()"))
let onboardingSetupOwnerGuard = try #require(
gatewaySetupSource.range(of: "guard !self.showOnboarding else { return }"))
let deliveredGatewaySetup = try #require(
gatewaySetupSource.range(of: "self.gatewaySetupRequest = GatewaySetupRequest"))
#expect(scannerSource.contains("static let defaultSettlingNanoseconds: UInt64 = 1_200_000_000"))
#expect(scannerSource.contains("QRScannerContainerViewController(coordinator: context.coordinator)"))
#expect(!scannerMake.contains("startScanning()"))
#expect(scannerLifecycle.contains("override func viewDidAppear"))
#expect(scannerLifecycle.contains("try self.scanner.startScanning()"))
#expect(scannerLifecycle.contains("override func viewWillDisappear"))
#expect(scannerLifecycle.contains("self.stopScannerCapture()"))
// The dialog dismisses after its button action returns. Carry the selected
// entry into the async task instead of rereading dismissal-cleared view state.
#expect(forgetGatewayDialog.contains("presenting: self.pendingForgetGateway"))
#expect(forgetGatewayDialog.contains("{ entry in"))
#expect(forgetGatewayDialog.contains("Task { await self.forgetGateway(entry) }"))
#expect(forgetGatewayAction.contains("self.pendingForgetGateway = nil"))
#expect(forgetGatewayAction.components(separatedBy: "pendingForgetGateway").count == 2)
#expect(sectionsSource.contains("var gatewayDestination: some View"))
#expect(sectionsSource.contains("This phone has limited Gateway access."))
#expect(sectionsSource.contains("Use a secure wss:// or Tailscale Serve Gateway"))
#expect(sectionsSource.contains("Label(\"Scan Full-Access Code\""))
#expect(sectionsSource.contains("self.gatewayActions"))
#expect(sectionsSource.contains("self.manualGatewayCard"))
#expect(sectionsSource.contains("self.gatewaySetupCard"))
#expect(sectionsSource.contains("self.gatewayAdvancedCard"))
// Pairing stays reachable without scrolling: nav-bar scanner button on the
// gateway route plus a status-card hero while nothing is paired. The hero
// honors the same connect lock as the other scanner entry points.
#expect(sectionsSource.contains("if route == .gateway {"))
#expect(sectionsSource.contains(
"let showScanHero = self.gatewayNeedsPairing && self.connectingGateway == nil"))
#expect(sectionsSource.contains("actionTitle: showScanHero ? \"Scan QR to Pair\" : nil"))
#expect(sectionsSource.contains("title: \"Reconnect\""))
#expect(sectionsSource.contains("Task { await self.reconnectGateway() }"))
#expect(sectionsSource.contains("title: \"Diagnose\""))
#expect(sectionsSource.contains("Task { await self.runDiagnostics() }"))
#expect(sectionsSource.contains("title: \"Scan QR\""))
#expect(sectionsSource.contains("self.openGatewayQRScanner()"))
#expect(sectionsSource.contains("title: \"Connect\""))
#expect(sectionsSource.contains("Task { await self.applySetupCodeAndConnect() }"))
#expect(sectionsSource.contains("Task { await self.connect(gateway) }"))
#expect(sectionsSource.contains("tailnetWarningText"))
// Gateway problems surface once, as the root toast; the settings page must not
// embed a second copy of the banner.
#expect(!sectionsSource.contains("GatewayProblemBanner("))
// Sections compare gateway owners byte-exact, not with raw string equality.
#expect(!sectionsSource.contains("entry.stableID == self.gatewayRegistry.activeStableID"))
#expect(sectionsSource.components(separatedBy: "GatewayStableIdentifier.matches(").count >= 4)
#expect(rootSource.contains("GatewayProblemBanner("))
#expect(rootSource.contains(".gesture(self.gatewayToastSwipeGesture)"))
// Operator auth/pairing problems can coexist with a connected node, so the
// root's only remediation surface must not depend on aggregate status.
#expect(activeProblemToast.contains("appModel.lastGatewayProblem"))
#expect(!activeProblemToast.contains("gatewayStatus"))
// Every problem report re-surfaces a swiped-away toast. Visible problem
// banners stay stationary when reconnects re-report the same failure.
#expect(rootSource.contains("self.appModel.gatewayProblemReportCount"))
#expect(rootSource.contains("guard self.isGatewayToastSwipeDismissed else { return }"))
#expect(!rootSource.contains("GatewayToastShakeEffect"))
#expect(actionsSource.contains("await self.gatewayController.connectActiveGateway()"))
#expect(actionsSource.contains("self.gatewayController.refreshActiveGatewayRegistrationFromSettings()"))
#expect(actionsSource.contains("self.gatewayController.restartDiscovery()"))
#expect(actionsSource.contains("await self.appModel.refreshGatewayOverviewIfConnected()"))
#expect(actionsSource
.contains("self.gatewayController.requestLocalNetworkAccess(reason: \"settings_preflight\")"))
#expect(controllerSource.contains("await self.tcpReachabilityProbe("))
#expect(controllerSource.contains("Check Tailscale or LAN."))
#expect(actionsSource.contains("Tailscale is off on this device. Turn it on, then try again."))
#expect(actionsSource.contains("Run /pair approve in your OpenClaw chat"))
#expect(settingsSource.contains("self.resetOnboarding()"))
#expect(settingsSource.contains(".onChange(of: self.onboardingRequestID)"))
#expect(settingsSource.contains("self.syncAfterOnboardingReset()"))
#expect(settingsSource.contains("let acceptsGatewaySetupRequests: Bool"))
#expect(settingsSource.contains("guard self.acceptsGatewaySetupRequests else { return }"))
#expect(settingsSource.contains(".onChange(of: self.acceptsGatewaySetupRequests)"))
#expect(rootSource.matches(of: /acceptsGatewaySetupRequests: !self\.showOnboarding/).count == 1)
#expect(actionsSource.contains("func syncAfterOnboardingReset()"))
#expect(actionsSource.contains("self.pendingManualAuthOverride = nil"))
// The root toast is the only gateway problem surface outside covers, so it
// must keep the reset-onboarding primary action the settings banner had.
#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.handleProtocolMismatchIfNeeded(problem)"))
#expect(rootSource.contains("await self.gatewayController.connectActiveGateway()"))
#expect(rootSource.contains("GatewayProblemDetailsSheet("))
#expect(onboardingSetupOwnerGuard.lowerBound < consumedGatewaySetup.lowerBound)
#expect(consumedGatewaySetup.lowerBound < deliveredGatewaySetup.lowerBound)
#expect(settingsSource.contains("QRScannerView("))
#expect(settingsOnDismiss.lowerBound < settingsProcessing.lowerBound)
#expect(settingsProcessing.lowerBound < settingsContent.lowerBound)
#expect(settingsPendingSetupHandler.contains("self.showQRScanner = false"))
#expect(settingsScannerCancel.lowerBound < settingsSetupStaging.lowerBound)
#expect(settingsPendingSetupHandler.contains(
"self.gatewayController.cancelPendingConnectionAttempts()"))
#expect(!settingsSource.contains(".onChange(of: self.showQRScanner)"))
#expect(actionsSource.contains("case let .gatewayLink(link):"))
#expect(actionsSource.contains("case let .setupCode(code):"))
#expect(actionsSource.contains(
"self.stagedGatewaySetupLink = nil\n self.setupCode = \"\"\n await self.applyGatewayLink(link)"))
#expect(stopScanning.lowerBound < deliverResult.lowerBound)
#expect(trustSource.contains("Trust this gateway?"))
#expect(trustSource.contains("Trust and connect"))
#expect(trustSource.contains("let isEnabled: Bool"))
#expect(rootSource.contains(".gatewayTrustPromptAlert(isEnabled: !self.showOnboarding)"))
}
static func assertGatewayOnboardingFlowGuards() throws {
let onboardingSource = try self.onboardingWizardSource()
let actionsSource = try String(contentsOf: self.settingsProTabActionsSourceURL(), encoding: .utf8)
let controllerSource = try self.gatewayConnectionControllerSource()
let pendingSetupHandler = try self.extract(
onboardingSource,
from: "private func applyPendingGatewaySetupLinkIfNeeded()",
to: "private func connectStagedGatewaySetupLink()")
let stagedSetupConnect = try self.extract(
onboardingSource,
from: "private func connectStagedGatewaySetupLink()",
to: "private func clearStagedGatewaySetupLink()")
let stagedSetupClear = try self.extract(
onboardingSource,
from: "private func clearStagedGatewaySetupLink()",
to: "private func applyGatewayLink(")
let connectionFailure = try self.extract(
onboardingSource,
from: "private func setConnectionFailure(_ message: String)",
to: "var body: some View")
let stagedValidation = try #require(stagedSetupConnect.range(of: "guard link.isValidEndpoint"))
let stagedConsumption = try #require(stagedSetupConnect.range(of: "self.setupLinkStaging.take()"))
let stagedReset = try #require(
stagedSetupConnect.range(of: "await self.appModel.resetGatewaySessionsForTargetSwitch()"))
let onboardingGatewayLink = try self.extract(
onboardingSource,
from: "private func applyGatewayLink(",
to: "private func handleScannedSetupCode(")
let settingsGatewayLink = try self.extract(
actionsSource,
from: "func applyGatewayLink(",
to: "func openGatewayQRScanner()")
let onboardingManualConnect = try self.extract(
onboardingSource,
from: "private func connectCurrentManualGateway(",
to: "private func retryLastAttempt(")
let onboardingRetry = try self.extract(
onboardingSource,
from: "private func retryLastAttempt(",
to: "private func gatewayProblemPrimaryActionTitle(")
let settingsManualConnect = try self.extract(
actionsSource,
from: "func connectManual(setupAttemptID: UUID? = nil) async",
to: "func preflightGateway(host: String)")
#expect(onboardingSource.contains(".gatewayTrustPromptAlert()"))
#expect(onboardingSource.contains("self.applyPendingGatewaySetupLinkIfNeeded()"))
#expect(onboardingSource.contains(".onChange(of: self.appModel.gatewaySetupRequestID)"))
#expect(onboardingSource.contains("self.appModel.consumePendingGatewaySetupLink()"))
#expect(onboardingSource.contains("self.scannerResultHandoff.cancel()"))
#expect(!onboardingSource.contains("pendingScannerResult"))
#expect(onboardingSource.contains("self.setupLinkStaging.stage(link)"))
#expect(pendingSetupHandler.contains("self.gatewayController.cancelPendingConnectionAttempts()"))
#expect(pendingSetupHandler.contains("if self.selectedMode == nil"))
#expect(onboardingSource.contains("Tap Connect to apply."))
#expect(onboardingSource.contains("self.connectStagedGatewaySetupLink()"))
#expect(onboardingSource.contains("Credentials are applied only after you tap Connect."))
#expect(onboardingSource.contains("Plaintext (local network)"))
#expect(onboardingSource.contains("self.statusLine = message"))
#expect(!pendingSetupHandler.contains("self.manualHost ="))
#expect(!pendingSetupHandler.contains("self.manualPort ="))
#expect(!pendingSetupHandler.contains("self.manualTLS ="))
#expect(!pendingSetupHandler.contains("self.applyGatewayLink(link)"))
#expect(!pendingSetupHandler.contains("self.handleScannedLink(link)"))
#expect(!pendingSetupHandler.contains("self.connectManual()"))
#expect(stagedValidation.lowerBound < stagedConsumption.lowerBound)
#expect(stagedReset.lowerBound < stagedConsumption.lowerBound)
#expect(!stagedSetupConnect.contains("self.appModel.disconnectGateway()"))
#expect(stagedSetupConnect.contains(
"self.applyGatewayLink(link, disconnectExistingGatewayForBootstrap: false)"))
#expect(stagedSetupConnect.contains("guard self.connectingGateway == nil else { return }"))
#expect(onboardingSource.contains("case gateway(GatewayStableIdentifier.Key)"))
#expect(onboardingSource.contains("self.connectingGateway = .gateway(gateway.id)"))
#expect(!onboardingSource.contains("connectingGatewayID"))
#expect(stagedSetupConnect.contains("self.setConnectionFailure(message)"))
#expect(connectionFailure.contains("self.localConnectionFailure = message"))
#expect(!connectionFailure.contains("self.connectMessage = message"))
#expect(connectionFailure.contains("self.statusLine = message"))
#expect(onboardingSource.contains(".failedStatus(message: localFailure, allowsRetry: false)"))
#expect(onboardingSource.contains(
"primaryActionTitle: allowsRetry ? OpenClawTextValue.localized(\"Retry\") : nil"))
#expect(onboardingSource.contains("onPrimaryAction: allowsRetry ? self.onRetry : nil"))
#expect(stagedSetupClear.contains("self.localConnectionFailure = nil"))
#expect(onboardingRetry.contains("self.localConnectionFailure = nil"))
#expect(onboardingRetry.contains(
"self.setConnectionFailure(\"No connection to retry. Check the gateway host and port.\")"))
#expect(onboardingSource.contains("self.setupLinkStaging.link == nil else { return }"))
#expect(onboardingGatewayLink.contains("self.gatewayToken = setupAuth.token"))
#expect(onboardingGatewayLink.contains("self.gatewayPassword = setupAuth.password"))
#expect(settingsGatewayLink.contains("self.gatewayToken = setupAuth.token"))
#expect(settingsGatewayLink.contains("self.gatewayPassword = setupAuth.password"))
#expect(onboardingManualConnect.contains("nodeOptions.allowStoredDeviceAuth == true"))
#expect(onboardingManualConnect.contains("self.pendingManualAuthOverride = nil"))
#expect(onboardingManualConnect.contains("targetStableID: stableID"))
#expect(settingsManualConnect.contains("nodeOptions.allowStoredDeviceAuth == true"))
#expect(settingsManualConnect.contains("self.pendingManualAuthOverride = nil"))
#expect(settingsManualConnect.contains("targetStableID: stableID"))
#expect(!controllerSource.contains("shouldApplyTokenField"))
#expect(!controllerSource.contains("shouldApplyPasswordField"))
#expect(controllerSource.contains("allowStoredDeviceAuth: !suppressStoredDeviceAuth"))
#expect(controllerSource.contains(
"deviceAuthGatewayID: GatewaySettingsStore.authenticationOwnerID("))
#expect(controllerSource.contains("DeviceAuthStore.migrateUnscopedToken("))
#expect(controllerSource.contains("DeviceAuthStore.discardUnscopedTokens("))
#expect(onboardingSource.contains(
"self.selectGatewayCredentialTarget(gateway.stableID, allowManualOverride: false)"))
#expect(actionsSource.contains(
"self.selectGatewayCredentialTarget(gateway.stableID, allowManualOverride: false)"))
#expect(onboardingSource.contains(
"self.gatewayCredentialFieldStableID ?? self.currentManualGatewayStableID"))
#expect(actionsSource.contains(
"self.gatewayCredentialFieldStableID ?? self.currentManualGatewayStableID"))
// Gateway stable IDs compare byte-exact via GatewayStableIdentifier, never
// via trimmed/string equality; a regressed comparison silently reuses
// credentials across distinct gateway owners.
#expect(onboardingSource.contains(
"if !GatewayStableIdentifier.matches(self.gatewayCredentialFieldStableID, stableID)"))
#expect(actionsSource.contains(
"if !GatewayStableIdentifier.matches(self.gatewayCredentialFieldStableID, stableID)"))
#expect(!onboardingSource.contains("gatewayCredentialFieldStableID == stableID"))
#expect(!actionsSource.contains("gatewayCredentialFieldStableID == stableID"))
#expect(onboardingSource.contains("GatewayStableIdentifier.key(previousStableID) !="))
#expect(actionsSource.contains("GatewayStableIdentifier.key(previousStableID) !="))
}
static func assertGatewayReconnectGuards() throws {
let modelSource = try String(contentsOf: self.nodeAppModelSourceURL(), encoding: .utf8)
let controllerSource = try self.gatewayConnectionControllerSource()
let backgroundReconnect = try self.extract(
modelSource,
from: "private func performBackgroundAliveBeaconIfNeeded(",
to: "private func publishBackgroundAliveBeacon(")
let disconnectGateway = try self.extract(
modelSource,
from: "func disconnectGateway()",
to: "private func disableGatewayAutoReconnect()")
let operatorGatewayLoop = try self.extract(
modelSource,
from: "private func startOperatorGatewayLoop(",
to: "private func startNodeGatewayLoop(")
let nodeGatewayLoop = try self.extract(
modelSource,
from: "private func startNodeGatewayLoop(",
to: "private func makeOperatorConnectOptions(")
let wakeWordRefresh = try self.extract(
modelSource,
from: "private func refreshWakeWordsFromGateway(",
to: "private func isGatewayHealthMonitorDisabled()")
#expect(disconnectGateway.contains("self.beginGatewaySessionReset(chainingAfterExisting: true)"))
#expect(!disconnectGateway.contains("Task {"))
#expect(modelSource.contains(
"private func isCurrentGatewayRoute(generation: UInt64, stableID: String) -> Bool"))
#expect(modelSource.matches(
of: /self\.isCurrentGatewayRoute\(generation: routeGeneration, stableID: stableID\)/).count >= 2)
#expect(operatorGatewayLoop.contains("gatewayReconnectLoopDelay(source: \"operator_loop\")"))
#expect(nodeGatewayLoop.contains("gatewayReconnectLoopDelay(source: \"node_loop\")"))
#expect(modelSource.contains("refreshWakeWordsFromGateway(shouldApply: shouldContinue)"))
#expect(wakeWordRefresh.matches(of: /guard shouldApply\(\) else \{ return \}/).count >= 2)
#expect(modelSource.contains("if !self.gatewayAutoReconnectEnabled || self.gatewayPairingPaused"))
#expect(controllerSource.contains("acceptPendingTrustPrompt()"))
#expect(controllerSource.contains("trustRotatedGatewayCertificate(from problem: GatewayConnectionProblem)"))
#expect(controllerSource.contains("allowAutoReconnect: false"))
#expect(controllerSource.contains("guard allowAutoReconnect else { return }"))
#expect(controllerSource.contains("guard self.autoConnectSuppressionGeneration == nil else { return }"))
#expect(backgroundReconnect.contains("let generation = self.gatewayConnectGeneration"))
#expect(backgroundReconnect.contains("await self.resetGatewaySessionsForForcedReconnect()"))
#expect(backgroundReconnect.contains("expectedGeneration: generation"))
#expect(modelSource.contains("expectedGeneration: UInt64)"))
#expect(!modelSource.contains("expectedGeneration: UInt64?"))
#expect(modelSource.contains("GatewayStableIdentifier.exact(self.connectedGatewayID)"))
}
}