Files
openclaw/apps/ios/Sources/Onboarding/OnboardingWizardView.swift
Peter Steinberger ac89350327 feat(apps): review durable approvals on mobile (#104913)
* feat(apps): Android, iPhone, and Watch approval clients

Squash-rebased #103912 segment onto the deep-links tip on current main.
Native approval surfaces: iOS approval presentation with gateway-switch
lease preservation and resolution fencing, watchOS inbox + approval
actions with shipped-shape payload codec, Android approval notices with
publication-tokened dismissal. Native i18n inventory regenerated.

(cherry picked from commit 428a76670ffeede54248b7bd7aa4438e2589851b)
(cherry picked from commit 80225d5707c3645eeea5435f266131037b50ede6)
(cherry picked from commit 2a23b714dc30d773a294aa45adc617e46b80732e)
(cherry picked from commit 9ff1153827769e2cbab7c11c153f0f8634662c28)
(cherry picked from commit 5b25723525bd562e5f97478af492f7b46ad30fd1)
(cherry picked from commit 8c80e8467b5fe89aad0d4c74a0573f1600ed3af9)
(cherry picked from commit ad4037bc9846bf6f082b41c129ee68aa344576c3)
(cherry picked from commit fdf767dd662cff3c8a5a6d571f38f153410651ca)
(cherry picked from commit 00c120376ffd992ea68ce29254a9bc0a25ed1740)
(cherry picked from commit f36a95213e)
(cherry picked from commit e2c25cbe2baacab44d21871d8cb6734704f065ac)
(cherry picked from commit 7c4fda519080486d341a9f4df36d63f9e24b1235)
(cherry picked from commit 1b3d4eda3dc5988012124597f9454ae21fb187a1)
(cherry picked from commit 2a60619722)
(cherry picked from commit 6f0c386567)
(cherry picked from commit 784a5857b7)
(cherry picked from commit cbf294e026841c9bc2799da0fc7db666a69c52db)

* fix(apps): harden approval reconciliation and watch states
2026-07-11 19:59:07 -07:00

1545 lines
62 KiB
Swift

import Combine
import CoreImage
import OpenClawKit
import PhotosUI
import SwiftUI
import UIKit
private enum OnboardingFocusedField: Hashable {
case setupCode
case manualHost
case manualPort
case discoveryDomain
case gatewayToken
case gatewayPassword
}
struct OnboardingWizardView: View {
@Environment(NodeAppModel.self) private var appModel: NodeAppModel
@Environment(GatewayConnectionController.self) private var gatewayController: GatewayConnectionController
@Environment(\.scenePhase) private var scenePhase
@AppStorage("node.instanceId") private var instanceId: String = UUID().uuidString
@AppStorage("gateway.discovery.domain") private var discoveryDomain: String = ""
@AppStorage("onboarding.developerMode") private var developerModeEnabled: Bool = false
@State private var step: OnboardingStep
@State private var selectedMode: OnboardingConnectionMode?
@State private var manualHost: String = ""
@State private var manualPort: Int = 18789
@State private var manualPortText: String = "18789"
@State private var manualTLS: Bool = true
@State private var gatewayToken: String = ""
@State private var gatewayPassword: String = ""
@State private var gatewayCredentialFieldStableID: String?
@State private var connectMessage: String?
@State private var localConnectionFailure: String?
@State private var statusLine: String = ""
@State private var connectingGateway: OnboardingGatewayConnectionAttempt?
@State private var issue: GatewayConnectionIssue = .none
@State private var didMarkCompleted = false
@State private var pairingRequestId: String?
@State private var discoveryRestartTask: Task<Void, Never>?
@State private var showQRScanner: Bool = false
@State private var scannerError: String?
@State private var scannerResultHandoff = QRScannerResultHandoff()
@State private var scannerScanID: UInt64 = 0
@State private var pendingTargetSuppression = GatewayPendingTargetSuppression()
@State private var selectedPhoto: PhotosPickerItem?
@State private var showGatewayProblemDetails: Bool = false
@State private var lastPairingAutoResumeAttemptAt: Date?
@State private var pendingManualAuthOverride: GatewayConnectionController.ManualAuthOverride?
@State private var setupLinkStaging = GatewaySetupLinkStaging()
@State private var setupCode: String = ""
@State private var setupCodeStatus: String?
@State private var setupAttemptID: UUID?
@FocusState private var focusedField: OnboardingFocusedField?
private static let pairingAutoResumeTicker = Timer.publish(every: 2.0, on: .main, in: .common).autoconnect()
let allowSkip: Bool
let onRequestLocalNetworkAccess: (String) -> Void
let onClose: () -> Void
let onComplete: () -> Void
init(
allowSkip: Bool,
onRequestLocalNetworkAccess: @escaping (String) -> Void,
onClose: @escaping () -> Void,
onComplete: @escaping () -> Void)
{
self.allowSkip = allowSkip
self.onRequestLocalNetworkAccess = onRequestLocalNetworkAccess
self.onClose = onClose
self.onComplete = onComplete
_step = State(
initialValue: OnboardingStateStore.shouldPresentFirstRunIntro() ? .intro : .welcome)
}
private var isFullScreenStep: Bool {
self.step == .intro || self.step == .welcome || self.step == .success
}
private var currentProblem: GatewayConnectionProblem? {
self.appModel.lastGatewayProblem
}
private var connectPhase: OnboardingConnectPhase {
if self.connectingGateway != nil {
return .connecting(detail: self.statusLine.isEmpty ? "Connecting…" : self.statusLine)
}
if let message = self.localConnectionFailure {
return .failedStatus(message: message, allowsRetry: false)
}
if let problem = self.currentProblem {
return .failed(problem)
}
if self.issue != .none {
let message = self.connectMessage
?? (self.statusLine.isEmpty ? nil : self.statusLine)
?? self.issueFallbackMessage
return .failedStatus(message: message, allowsRetry: true)
}
return .ready
}
private var issueFallbackMessage: String {
switch self.issue {
case .none:
""
case .tokenMissing:
"Gateway auth token is missing."
case .unauthorized:
"Gateway rejected credentials."
case let .pairingRequired(requestId):
requestId.map { "Pairing required (request \($0))." } ?? "Pairing required."
case .network:
"Could not reach the gateway."
case let .unknown(message):
message
}
}
private func setConnectionFailure(_ message: String) {
self.localConnectionFailure = message
self.statusLine = message
}
var body: some View {
self.lifecycleContent
.onChange(of: self.scenePhase) { _, newValue in
guard newValue == ScenePhase.active else { return }
self.applyPendingGatewaySetupLinkIfNeeded()
self.attemptAutomaticPairingResumeIfNeeded()
}
.onReceive(Self.pairingAutoResumeTicker) { _ in
self.attemptAutomaticPairingResumeIfNeeded()
}
}
private var lifecycleContent: some View {
NavigationStack {
Group {
switch self.step {
case .intro:
self.introStep
case .welcome:
self.welcomeStep
case .success:
self.successStep
default:
Form {
switch self.step {
case .mode:
self.modeStep
case .connect:
self.connectStep
case .auth:
self.authStep
default:
EmptyView()
}
}
.formStyle(.grouped)
.scrollContentBackground(.hidden)
.background(Color(uiColor: .systemGroupedBackground))
.scrollDismissesKeyboard(.interactively)
}
}
.navigationTitle(self.isFullScreenStep ? "" : self.step.title)
.navigationBarTitleDisplayMode(.inline)
.tint(OpenClawBrand.activationPrimaryAction)
.toolbar {
if !self.isFullScreenStep {
ToolbarItem(placement: .principal) {
VStack(spacing: 2) {
Text(self.step.title)
.font(OpenClawType.headline)
Text(self.step.manualProgressTitle)
.font(OpenClawType.caption2)
.foregroundStyle(.secondary)
}
}
}
}
}
.safeAreaInset(edge: .bottom, alignment: .trailing, spacing: 0) {
self.keyboardDismissControl
}
.overlay(alignment: .topLeading) {
self.leadingChromeButton
.padding(.leading, 16)
.padding(.top, 10)
}
.gatewayTrustPromptAlert()
.alert("QR Scanner Unavailable", isPresented: Binding(
get: { self.scannerError != nil },
set: {
if !$0 { self.scannerError = nil }
})) {
Button(role: .cancel) {} label: {
Text("OK")
.font(OpenClawType.subheadSemiBold)
}
} message: {
Text(self.scannerError ?? "")
.font(OpenClawType.subhead)
}
.sheet(
isPresented: self.$showQRScanner,
onDismiss: {
self.processQueuedScannerResult()
},
content: {
self.qrScannerSheet
})
.sheet(isPresented: self.$showGatewayProblemDetails) {
if let currentProblem = self.currentProblem {
GatewayProblemDetailsSheet(
problem: currentProblem,
primaryActionTitle: self.gatewayProblemPrimaryActionTitle(currentProblem),
onPrimaryAction: {
Task { await self.handleGatewayProblemPrimaryAction(currentProblem) }
})
}
}
.onAppear {
self.initializeState()
self.applyPendingGatewaySetupLinkIfNeeded()
self.requestLocalNetworkAccessIfPastIntro(reason: "onboarding_appear")
}
.onDisappear {
self.invalidateSetupAttempt()
self.discoveryRestartTask?.cancel()
self.discoveryRestartTask = nil
self.scannerResultHandoff.cancel()
self.pendingTargetSuppression.resumeAutoConnect(controller: self.gatewayController)
}
.onChange(of: self.discoveryDomain) { _, _ in
self.scheduleDiscoveryRestart()
}
.onChange(of: self.manualPortText) { _, newValue in
let digits = newValue.filter(\.isNumber)
if digits != newValue {
self.manualPortText = digits
return
}
guard let parsed = Int(digits), parsed > 0 else {
self.manualPort = 0
return
}
self.manualPort = min(parsed, 65535)
}
.onChange(of: self.manualPort) { _, newValue in
let normalized = newValue > 0 ? String(newValue) : ""
if self.manualPortText != normalized {
self.manualPortText = normalized
}
}
.onChange(of: self.setupCode) { _, newValue in
guard !newValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return }
self.clearStagedGatewaySetupLink()
}
.onChange(of: self.appModel.lastGatewayProblem) { _, newValue in
self.updateConnectionIssue(problem: newValue, statusText: self.appModel.gatewayStatusText)
}
.onChange(of: self.appModel.gatewayStatusText) { _, newValue in
self.updateConnectionIssue(problem: self.appModel.lastGatewayProblem, statusText: newValue)
}
.onChange(of: self.appModel.gatewaySetupRequestID) { _, _ in
self.applyPendingGatewaySetupLinkIfNeeded()
}
.onChange(of: self.appModel.gatewayServerName) { _, newValue in
guard newValue != nil, self.setupLinkStaging.link == nil else { return }
self.showQRScanner = false
self.statusLine = "Connected."
if !self.didMarkCompleted, let selectedMode {
OnboardingStateStore.markCompleted(mode: selectedMode)
self.didMarkCompleted = true
}
self.navigate(to: .success)
}
}
private var qrScannerSheet: some View {
let scanID = self.scannerScanID
return NavigationStack {
QRScannerView(
onResult: { result in
self.queueScannedResult(result, scanID: scanID)
},
onError: { error in
guard self.scannerResultHandoff.isActive(scanID: scanID) else { return }
self.showQRScanner = false
self.statusLine = "Scanner error: \(error)"
self.scannerError = error
},
onDismiss: {
guard self.scannerResultHandoff.isActive(scanID: scanID) else { return }
self.showQRScanner = false
})
.ignoresSafeArea()
.navigationTitle("Scan Setup Code")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .principal) {
Text("Scan Setup Code")
.font(OpenClawType.headline)
}
ToolbarItem(placement: .topBarLeading) {
Button {
self.scannerResultHandoff.cancel()
self.showQRScanner = false
} label: {
Text("Cancel")
.font(OpenClawType.subheadSemiBold)
}
.font(OpenClawType.subheadSemiBold)
}
ToolbarItem(placement: .topBarTrailing) {
PhotosPicker(selection: self.$selectedPhoto, matching: .images) {
Label("Photos", systemImage: "photo")
.font(OpenClawType.subheadSemiBold)
}
}
}
}
.onChange(of: self.selectedPhoto) { _, newValue in
guard let item = newValue else { return }
self.selectedPhoto = nil
Task {
guard let data = try? await item.loadTransferable(type: Data.self) else {
guard self.scannerResultHandoff.isActive(scanID: scanID) else { return }
self.showQRScanner = false
self.scannerError = "Could not load the selected image."
return
}
guard self.scannerResultHandoff.isActive(scanID: scanID) else { return }
if let message = self.detectQRCode(from: data) {
if let link = GatewayConnectDeepLink.fromSetupInput(message) {
self.queueScannedResult(.gatewayLink(link), scanID: scanID)
return
}
if AppleReviewDemoMode.isSetupCode(message) {
self.queueScannedResult(.setupCode(message), scanID: scanID)
return
}
}
self.showQRScanner = false
self.scannerError = "No valid QR code found in the selected image."
}
}
}
@ViewBuilder
private var leadingChromeButton: some View {
if self.step.canGoBack {
Button {
self.navigateBack()
} label: {
Image(systemName: "chevron.left")
.font(OpenClawType.subheadSemiBold)
.accessibilityLabel("Back")
}
.buttonStyle(OpenClawCloseButtonStyle())
} else if self.allowSkip {
Button {
self.invalidateSetupAttempt()
self.onClose()
} label: {
Text("Close")
.font(OpenClawType.subheadSemiBold)
}
.buttonStyle(OpenClawCloseButtonStyle())
}
}
@ViewBuilder
private var keyboardDismissControl: some View {
if self.focusedField != nil {
Button {
self.dismissKeyboard()
} label: {
Image(systemName: "keyboard.chevron.compact.down")
.font(OpenClawType.headline)
.frame(width: 50, height: 44)
.contentShape(Capsule(style: .continuous))
}
.buttonStyle(.plain)
.foregroundStyle(OpenClawBrand.activationPrimaryAction)
.background(.ultraThinMaterial, in: Capsule(style: .continuous))
.overlay {
Capsule(style: .continuous)
.stroke(OpenClawBrand.activationNeutralStroke, lineWidth: 0.6)
}
.shadow(color: Color.black.opacity(0.08), radius: 14, x: 0, y: 4)
.accessibilityLabel("Dismiss Keyboard")
.padding(.trailing, 20)
.padding(.bottom, 10)
.transition(.opacity.combined(with: .scale(scale: 0.96)))
.animation(.smooth(duration: 0.16), value: self.focusedField)
}
}
private var introStep: some View {
OnboardingIntroStep(onContinue: self.advanceFromIntro)
}
private var welcomeStep: some View {
OnboardingWelcomeStep(
statusLine: self.statusLine,
isConnecting: self.connectingGateway != nil,
onScanQRCode: {
self.openQRScannerFromOnboarding()
},
onManualSetup: {
self.invalidateSetupAttempt()
self.statusLine = ""
self.navigate(to: .mode)
})
}
@ViewBuilder
private var modeStep: some View {
self.setupCodeSection
OnboardingModeSelectionSections(
selectedMode: self.selectedMode,
developerModeEnabled: Binding(
get: { self.developerModeEnabled },
set: { enabled in
self.developerModeEnabled = enabled
if !enabled, self.selectedMode == .developerLocal {
self.selectedMode = nil
}
}),
isConnecting: self.connectingGateway != nil,
onSelectMode: self.selectMode,
onContinue: {
self.navigate(to: .connect)
})
}
@ViewBuilder
private var connectStep: some View {
if let selectedMode {
Section {
self.connectPhaseView
self.onboardingLabeledContent("Mode", value: selectedMode.title)
self.onboardingLabeledContent("Discovery", value: self.gatewayController.discoveryStatusText)
} header: {
Text("Status")
.font(OpenClawType.footnoteSemiBold)
}
if let stagedLink = self.setupLinkStaging.link {
self.stagedGatewaySetupSection(stagedLink)
} else {
switch selectedMode {
case .homeNetwork:
self.homeNetworkConnectSection
case .remoteDomain:
self.remoteDomainConnectSection
case .developerLocal:
self.developerConnectSection
}
}
} else {
Section {
Text("Choose a mode first.")
.font(OpenClawType.body)
Button {
self.navigate(to: .mode)
} label: {
Text("Back to Mode Selection")
.font(OpenClawType.subheadSemiBold)
}
}
}
}
private var connectPhaseView: some View {
OnboardingConnectPhaseView(
phase: self.connectPhase,
primaryActionTitle: self.gatewayProblemPrimaryActionTitle,
onHandleProblem: { problem in
Task { await self.handleGatewayProblemPrimaryAction(problem) }
},
onRetry: {
Task { await self.retryLastAttempt() }
},
onShowDetails: {
self.showGatewayProblemDetails = true
})
}
private func stagedGatewaySetupSection(_ link: GatewayConnectDeepLink) -> some View {
OnboardingStagedGatewaySetupSection(
link: link,
isConnecting: self.connectingGateway == .manual,
isBusy: self.connectingGateway != nil,
onConnect: {
Task { await self.connectStagedGatewaySetupLink() }
},
onUseManualSetup: self.clearStagedGatewaySetupLink)
}
@ViewBuilder
private var homeNetworkConnectSection: some View {
OnboardingDiscoveredGatewaysSection(
gateways: self.gatewayController.gateways,
gatewayController: self.gatewayController,
connectingGateway: self.connectingGateway,
onConnect: { gateway in
Task { await self.connectDiscoveredGateway(gateway) }
},
onRestartDiscovery: self.gatewayController.restartDiscovery)
self.manualConnectionFieldsSection(title: "Manual Fallback")
}
private var remoteDomainConnectSection: some View {
self.manualConnectionFieldsSection(title: "Domain Settings")
}
private var developerConnectSection: some View {
Section {
self.onboardingTextField("Host", text: self.manualHostBinding, focusedField: .manualHost)
self.onboardingTextField("Port", text: self.manualPortTextBinding, focusedField: .manualPort)
.keyboardType(.numberPad)
self.manualConnectionSecurityRows
self.manualConnectButton
} header: {
Text("Developer Local")
.font(OpenClawType.footnoteSemiBold)
} footer: {
Text("Default host is localhost. Use your Mac LAN IP if simulator networking requires it.")
.font(OpenClawType.footnote)
}
}
@ViewBuilder
private var authStep: some View {
Section {
self.onboardingSecureField(
"Gateway Auth Token",
text: self.gatewayTokenBinding,
focusedField: .gatewayToken)
self.onboardingSecureField(
"Gateway Password",
text: self.gatewayPasswordBinding,
focusedField: .gatewayPassword)
if let problem = self.currentProblem {
GatewayProblemBanner(
problem: problem,
primaryActionTitle: self.gatewayProblemPrimaryActionTitle(problem),
onPrimaryAction: {
Task { await self.handleGatewayProblemPrimaryAction(problem) }
},
onShowDetails: {
self.showGatewayProblemDetails = true
})
} else if self.issue.needsAuthToken {
Text("Gateway rejected credentials. Scan a fresh setup code or update token/password.")
.font(OpenClawType.footnote)
.foregroundStyle(.secondary)
} else {
Text("OpenClaw is checking gateway and node access.")
.font(OpenClawType.footnote)
.foregroundStyle(.secondary)
}
} header: {
Text(self.gatewayStatusSectionTitle)
.font(OpenClawType.footnoteSemiBold)
}
if self.issue.needsPairing {
Section {
Button {
self.resumeAfterPairingApproval()
} label: {
Label("Resume After Approval", systemImage: "arrow.clockwise")
.font(OpenClawType.subheadSemiBold)
}
.font(OpenClawType.subheadSemiBold)
.disabled(self.connectingGateway != nil)
} header: {
Text("Pairing Approval")
.font(OpenClawType.footnoteSemiBold)
} footer: {
let requestLine: String = {
if let id = self.currentProblem?.requestId ?? self.issue.requestId, !id.isEmpty {
return "Request ID: \(id)"
}
return "Request ID: check `openclaw devices list`."
}()
let commandLine = self.currentProblem?.actionCommand ?? "openclaw devices approve <requestId>"
Text(
"Approve this device on the gateway.\n"
+ "1) `\(commandLine)`\n"
+ "2) `/pair approve` in your OpenClaw chat\n"
+ "\(requestLine)\n"
+ "OpenClaw will also retry automatically when you return to this app.")
.font(OpenClawType.caption)
}
}
Section {
Button {
self.openQRScannerFromOnboarding()
} label: {
Label("Scan Setup Code Again", systemImage: "qrcode.viewfinder")
.font(OpenClawType.subheadSemiBold)
}
.font(OpenClawType.subheadSemiBold)
.disabled(self.connectingGateway != nil)
Button {
Task { await self.retryLastAttempt() }
} label: {
if self.connectingGateway == .retry {
ProgressView()
.progressViewStyle(.circular)
} else {
Text("Retry Connection")
.font(OpenClawType.subheadSemiBold)
}
}
.font(OpenClawType.subheadSemiBold)
.disabled(self.connectingGateway != nil)
}
}
private var successStep: some View {
OnboardingSuccessStep(
gatewayName: self.appModel.gatewayServerName ?? "gateway",
gatewayAddress: self.appModel.gatewayRemoteAddress,
onGetStarted: self.onComplete)
}
}
extension OnboardingWizardView {
private var gatewayStatusSectionTitle: String {
if self.issue.needsPairing || self.currentProblem?.needsPairingApproval == true {
return "Gateway Approval"
}
if self.issue.needsAuthToken || self.currentProblem != nil {
return "Authentication"
}
return "Gateway Status"
}
private var setupCodeSection: some View {
Section {
HStack(spacing: 12) {
self.onboardingTextField("Enter setup code", text: self.$setupCode, focusedField: .setupCode)
.lineLimit(1)
.submitLabel(.go)
.onSubmit {
guard self.canApplySetupCode else { return }
Task { await self.applySetupCodeAndConnect() }
}
Button {
Task { await self.applySetupCodeAndConnect() }
} label: {
if self.connectingGateway == .setupCode {
ProgressView()
.progressViewStyle(.circular)
.controlSize(.small)
} else {
Text("Apply")
.font(OpenClawType.subheadSemiBold)
}
}
.buttonStyle(.bordered)
.buttonBorderShape(.capsule)
.controlSize(.regular)
.tint(OpenClawBrand.activationPrimaryAction)
.disabled(!self.canApplySetupCode)
}
.frame(minHeight: 50)
if let setupCodeStatus, !setupCodeStatus.isEmpty {
Text(setupCodeStatus)
.font(OpenClawType.footnote)
.foregroundStyle(.secondary)
}
} header: {
Text("Setup Code")
.font(OpenClawType.footnoteSemiBold)
} footer: {
Text("Use this if you have a setup code instead of scanning.")
.font(OpenClawType.footnote)
}
}
private var canApplySetupCode: Bool {
!self.setupCode.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
&& self.connectingGateway == nil
}
private func manualConnectionFieldsSection(title: LocalizedStringKey) -> some View {
Section {
self.onboardingTextField("Host", text: self.manualHostBinding, focusedField: .manualHost)
self.onboardingTextField("Port", text: self.manualPortTextBinding, focusedField: .manualPort)
.keyboardType(.numberPad)
self.manualConnectionSecurityRows
self.onboardingTextField(
"Discovery Domain (optional)",
text: self.$discoveryDomain,
focusedField: .discoveryDomain)
if self.selectedMode == .remoteDomain {
self.onboardingSecureField(
"Gateway Auth Token",
text: self.gatewayTokenBinding,
focusedField: .gatewayToken)
self.onboardingSecureField(
"Gateway Password",
text: self.gatewayPasswordBinding,
focusedField: .gatewayPassword)
}
self.manualConnectButton
} header: {
Text(title)
.font(OpenClawType.footnoteSemiBold)
}
}
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(selection: self.manualTLSBinding) {
Text("Unencrypted")
.font(OpenClawType.captionSemiBold)
.tag(false)
Text("Secure (TLS)")
.font(OpenClawType.captionSemiBold)
.tag(true)
} label: {
Text("Connection security")
.font(OpenClawType.captionSemiBold)
}
.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)
.font(OpenClawType.body)
} label: {
Text(title)
.font(OpenClawType.body)
}
}
private func onboardingTextField(
_ placeholder: LocalizedStringKey,
text: Binding<String>,
focusedField: OnboardingFocusedField) -> some View
{
TextField(
"",
text: text,
prompt: Text(placeholder)
.font(OpenClawType.subhead)
.foregroundStyle(.tertiary))
.font(OpenClawType.subhead)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.focused(self.$focusedField, equals: focusedField)
.accessibilityLabel(placeholder)
}
private func onboardingSecureField(
_ placeholder: LocalizedStringKey,
text: Binding<String>,
focusedField: OnboardingFocusedField) -> some View
{
ZStack(alignment: .leading) {
if text.wrappedValue.isEmpty {
Text(placeholder)
.font(OpenClawType.subhead)
.foregroundStyle(.tertiary)
.accessibilityHidden(true)
}
SecureField("", text: text)
.font(OpenClawType.subhead)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.focused(self.$focusedField, equals: focusedField)
}
.accessibilityLabel(placeholder)
}
private var manualConnectButton: some View {
Button {
Task { await self.connectManual() }
} label: {
if self.connectingGateway == .manual {
HStack(spacing: 8) {
ProgressView()
.progressViewStyle(.circular)
Text("Connecting…")
.font(OpenClawType.subheadSemiBold)
}
} else {
Text("Connect")
.font(OpenClawType.subheadSemiBold)
}
}
.font(OpenClawType.subheadSemiBold)
.disabled(!self.canConnectManual || self.connectingGateway != nil)
}
private func applySetupCodeAndConnect() async {
self.setupCodeStatus = nil
let raw = self.setupCode.trimmingCharacters(in: .whitespacesAndNewlines)
guard !raw.isEmpty else {
self.setupCodeStatus = "Paste a setup code to continue."
return
}
self.clearStagedGatewaySetupLink()
if AppleReviewDemoMode.isSetupCode(raw) {
self.setupCode = ""
self.setupCodeStatus = "Apple Review demo mode enabled."
self.handleScannedSetupCode(raw)
return
}
guard let parsedLink = GatewayConnectDeepLink.fromSetupInput(raw) else {
self.setupCodeStatus = "Setup code not recognized or uses an insecure ws:// gateway URL."
return
}
guard let attemptID = self.beginSetupAttempt() else { return }
defer { self.finishSetupAttempt(attemptID) }
let link = await self.gatewayController.selectReachableSetupLink(parsedLink)
guard self.setupAttemptID == attemptID else { return }
await self.applyGatewayLink(link)
self.setupCode = ""
self.setupCodeStatus = "Setup code applied. Connecting…"
self.connectMessage = "Connecting via setup code…"
self.statusLine = "Setup code loaded. Connecting to \(link.host):\(link.port)"
await self.prepareFocusedFieldForStepTransition()
self.navigate(to: .connect)
await self.connectManual(setupAttemptID: attemptID)
}
private func queueScannedResult(_ result: QRScannerResult, scanID: UInt64) {
guard self.scannerResultHandoff.queue(result, scanID: scanID) else { return }
self.statusLine = "QR loaded. Closing scanner..."
self.showQRScanner = false
}
private func processQueuedScannerResult() {
let delivery = self.scannerResultHandoff.processAfterDismissal { result in
switch result {
case let .gatewayLink(link):
self.handleScannedLink(link)
case let .setupCode(code):
self.handleScannedSetupCode(code)
}
}
if delivery == nil {
self.pendingTargetSuppression.resumeAutoConnect(.qrScanner, controller: self.gatewayController)
}
}
private func handleScannedLink(_ link: GatewayConnectDeepLink) {
self.showQRScanner = false
guard let attemptID = self.beginSetupAttempt() else { return }
self.setupCodeStatus = nil
Task { await self.connectScannedLink(link, attemptID: attemptID) }
}
private func connectScannedLink(_ parsedLink: GatewayConnectDeepLink, attemptID: UUID) async {
defer {
self.finishSetupAttempt(attemptID)
self.pendingTargetSuppression.resumeAutoConnect(.qrScanner, controller: self.gatewayController)
}
let link = await self.gatewayController.selectReachableSetupLink(parsedLink)
guard self.setupAttemptID == attemptID else { return }
await self.applyGatewayLink(link)
self.connectMessage = "Connecting via setup code…"
self.statusLine = "Setup code loaded. Connecting to \(link.host):\(link.port)"
self.navigate(to: .connect)
await self.connectManual(setupAttemptID: attemptID)
}
private func applyPendingGatewaySetupLinkIfNeeded() {
guard let link = self.appModel.consumePendingGatewaySetupLink() else { return }
self.showQRScanner = false
self.scannerResultHandoff.cancel()
self.showGatewayProblemDetails = false
let lease = self.gatewayController.cancelPendingConnectionAttempts()
self.pendingTargetSuppression.replace(owner: .setupLink, lease: lease)
if self.selectedMode == nil {
self.selectedMode = link.tls ? .remoteDomain : .homeNetwork
}
self.setupLinkStaging.stage(link)
self.localConnectionFailure = nil
self.setupCodeStatus = "Setup link loaded for \(link.host):\(link.port). Tap Connect to apply."
self.connectMessage = nil
self.statusLine = self.setupCodeStatus ?? ""
self.navigate(to: .connect)
}
private func connectStagedGatewaySetupLink() async {
guard self.connectingGateway == nil else { return }
guard let link = self.setupLinkStaging.link else { return }
guard link.isValidEndpoint else {
let message = "Setup link has an invalid gateway endpoint."
self.setupCodeStatus = message
self.setConnectionFailure(message)
return
}
self.connectingGateway = .manual
self.localConnectionFailure = nil
defer { self.connectingGateway = nil }
let lease = self.gatewayController.cancelPendingConnectionAttempts()
self.pendingTargetSuppression.replace(owner: .setupLink, lease: lease)
defer { self.pendingTargetSuppression.resumeAutoConnect(.setupLink, controller: self.gatewayController) }
await self.appModel.resetGatewaySessionsForTargetSwitch()
guard self.setupLinkStaging.link == link else { return }
_ = self.setupLinkStaging.take()
await self.applyGatewayLink(link, disconnectExistingGatewayForBootstrap: false)
self.setupCodeStatus = "Setup link applied. Connecting…"
self.issue = .none
self.connectMessage = "Connecting to \(link.host)"
self.statusLine = "Connecting to \(link.host):\(link.port)"
await self.connectCurrentManualGateway(host: link.host, port: link.port, forceReconnect: false)
}
private func clearStagedGatewaySetupLink() {
guard self.setupLinkStaging.cancel() else { return }
self.pendingTargetSuppression.resumeAutoConnect(.setupLink, controller: self.gatewayController)
let message = "Setup link cleared."
self.localConnectionFailure = nil
self.setupCodeStatus = message
self.statusLine = message
}
private func applyGatewayLink(
_ link: GatewayConnectDeepLink,
disconnectExistingGatewayForBootstrap: Bool = true) async
{
self.manualHost = link.host
self.manualPort = link.port
self.manualPortText = String(link.port)
self.manualTLS = link.tls
let setupAuth = GatewayConnectionController.ManualAuthOverride.setupAuth(from: link)
self.gatewayCredentialFieldStableID = setupAuth.targetStableID
if setupAuth.hasBootstrapToken {
await GatewayOnboardingReset.prepareForBootstrapPairing(
appModel: self.appModel,
instanceId: GatewaySettingsStore.currentInstanceID(),
gatewayStableID: setupAuth.targetStableID,
disconnectGateway: disconnectExistingGatewayForBootstrap)
}
self.gatewayToken = setupAuth.token
self.gatewayPassword = setupAuth.password
self.pendingManualAuthOverride = setupAuth.manualAuthOverride
let instanceId = GatewaySettingsStore.currentInstanceID()
if !instanceId.isEmpty {
GatewaySettingsStore.saveGatewayCredentials(
token: setupAuth.token,
bootstrapToken: setupAuth.bootstrapToken,
password: setupAuth.password,
gatewayStableID: setupAuth.targetStableID,
suppressStoredDeviceAuth: true,
instanceId: instanceId)
}
if self.selectedMode == nil {
self.selectedMode = link.tls ? .remoteDomain : .homeNetwork
}
}
private func handleScannedSetupCode(_ code: String) {
guard AppleReviewDemoMode.isSetupCode(code) else { return }
self.showQRScanner = false
self.invalidateSetupAttempt()
self.connectMessage = "Apple Review demo mode enabled."
self.statusLine = "Apple Review demo mode enabled."
self.selectedMode = .homeNetwork
self.appModel.enterAppleReviewDemoMode()
self.pendingTargetSuppression.releaseAutoConnect(.qrScanner, controller: self.gatewayController)
}
private func openQRScannerFromOnboarding(status: String = "Opening QR scanner…") {
// Stop active reconnect loops before scanning new credentials.
self.invalidateSetupAttempt()
let lease = self.gatewayController.cancelPendingConnectionAttempts(suspendCurrentGateway: true)
_ = self.setupLinkStaging.cancel()
self.pendingTargetSuppression.replace(owner: .qrScanner, lease: lease)
self.scannerScanID = self.scannerResultHandoff.beginScan()
self.connectingGateway = nil
self.localConnectionFailure = nil
self.connectMessage = nil
self.issue = .none
self.pairingRequestId = nil
self.statusLine = status
self.showQRScanner = true
}
private func resumeAfterPairingApproval() {
// We intentionally stop reconnect churn while unpaired to avoid generating multiple pending requests.
self.appModel.gatewayAutoReconnectEnabled = true
self.appModel.gatewayPairingPaused = false
self.appModel.gatewayPairingRequestId = nil
// Pairing state is sticky to prevent UI flip-flop during reconnect churn.
// Once the user explicitly resumes after approving, clear the sticky issue
// so new status/auth errors can surface instead of being masked as pairing.
self.issue = .none
self.connectMessage = "Retrying after approval…"
self.statusLine = "Retrying after approval…"
Task { await self.retryLastAttempt() }
}
private func resumeAfterPairingApprovalInBackground() {
// Keep the pairing issue sticky to avoid visual flicker while we probe for approval.
self.appModel.gatewayAutoReconnectEnabled = true
self.appModel.gatewayPairingPaused = false
self.appModel.gatewayPairingRequestId = nil
Task { await self.retryLastAttempt(silent: true) }
}
private func attemptAutomaticPairingResumeIfNeeded() {
guard self.scenePhase == .active else { return }
guard self.step == .auth else { return }
guard self.issue.needsPairing else { return }
guard self.connectingGateway == nil else { return }
let now = Date()
if let last = lastPairingAutoResumeAttemptAt, now.timeIntervalSince(last) < 6 {
return
}
self.lastPairingAutoResumeAttemptAt = now
self.resumeAfterPairingApprovalInBackground()
}
private func updateConnectionIssue(problem: GatewayConnectionProblem?, statusText: String) {
let next = GatewayConnectionIssue.detect(problem: problem)
let fallback = next == .none ? GatewayConnectionIssue.detect(from: statusText) : next
// Avoid "flip-flopping" the UI by clearing actionable issues when the underlying connection
// transitions through intermediate statuses (e.g. Offline/Connecting while reconnect churns).
if self.issue.needsPairing, fallback.needsPairing {
let mergedRequestId = fallback.requestId ?? self.issue.requestId ?? self.pairingRequestId
self.issue = .pairingRequired(requestId: mergedRequestId)
} else if self.issue.needsPairing, !fallback.needsPairing {
// Ignore non-pairing statuses until the user explicitly retries/scans again, or we connect.
} else if self.issue.needsAuthToken, !fallback.needsAuthToken, !fallback.needsPairing {
// Same idea for auth: once we learn credentials are missing/rejected, keep that sticky until
// the user retries/scans again or we successfully connect.
} else {
self.issue = fallback
}
if let requestId = problem?.requestId ?? fallback.requestId, !requestId.isEmpty {
self.pairingRequestId = requestId
}
if self.issue.needsAuthToken || self.issue.needsPairing || problem?.pauseReconnect == true {
self.step = .auth
}
if let problem {
self.connectMessage = problem.message
self.statusLine = problem.message
return
}
let trimmedStatus = statusText.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmedStatus.isEmpty {
self.connectMessage = trimmedStatus
self.statusLine = trimmedStatus
}
}
private func detectQRCode(from data: Data) -> String? {
guard let ciImage = CIImage(data: data) else { return nil }
let detector = CIDetector(
ofType: CIDetectorTypeQRCode,
context: nil,
options: [CIDetectorAccuracy: CIDetectorAccuracyHigh])
let features = detector?.features(in: ciImage) ?? []
for feature in features {
if let qr = feature as? CIQRCodeFeature, let message = qr.messageString {
return message
}
}
return nil
}
private func advanceFromIntro() {
OnboardingStateStore.markFirstRunIntroSeen()
self.requestLocalNetworkAccess(reason: "onboarding_continue")
self.statusLine = ""
self.navigate(to: .welcome)
}
private func requestLocalNetworkAccessIfPastIntro(reason: String) {
guard self.step != .intro else { return }
self.requestLocalNetworkAccess(reason: reason)
}
private func requestLocalNetworkAccess(reason: String) {
self.onRequestLocalNetworkAccess(reason)
}
private func navigateBack() {
guard let target = step.previous else { return }
self.invalidateSetupAttempt()
self.localConnectionFailure = nil
self.connectMessage = nil
self.navigate(to: target)
}
private func prepareFocusedFieldForStepTransition() async {
guard self.focusedField != nil else { return }
self.dismissKeyboard()
await Task.yield()
try? await Task.sleep(nanoseconds: 120_000_000)
}
private func dismissKeyboard() {
self.focusedField = nil
UIApplication.shared.sendAction(
#selector(UIResponder.resignFirstResponder),
to: nil,
from: nil,
for: nil)
}
private func navigate(to target: OnboardingStep) {
self.focusedField = nil
self.step = target
}
private func beginSetupAttempt() -> UUID? {
guard self.connectingGateway == nil else { return nil }
let attemptID = UUID()
self.setupAttemptID = attemptID
self.connectingGateway = .setupCode
return attemptID
}
private func finishSetupAttempt(_ attemptID: UUID) {
guard self.setupAttemptID == attemptID else { return }
self.invalidateSetupAttempt()
}
private func invalidateSetupAttempt() {
self.setupAttemptID = nil
self.connectingGateway = nil
}
private var canConnectManual: Bool {
let host = self.manualHost.trimmingCharacters(in: .whitespacesAndNewlines)
return !host.isEmpty && self.resolvedManualPort(host: host) != nil
}
private func initializeState() {
if self.manualHost.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
if let active = GatewaySettingsStore.activeGatewayEntry(),
active.kind == .manual,
let host = active.host,
let port = active.port
{
self.manualHost = host
self.manualPort = port
self.manualTLS = active.useTLS
} else {
self.manualHost = "openclaw.local"
self.manualPort = 18789
self.manualTLS = true
}
}
self.manualPortText = self.manualPort > 0 ? String(self.manualPort) : ""
if self.selectedMode == nil {
let lastMode = OnboardingStateStore.lastMode()
if lastMode == .developerLocal {
self.developerModeEnabled = true
}
if self.developerModeEnabled || lastMode != .developerLocal {
self.selectedMode = lastMode
}
}
if self.selectedMode == .developerLocal, self.manualHost == "openclaw.local" {
self.manualHost = "localhost"
self.manualTLS = false
}
let trimmedInstanceId = self.instanceId.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmedInstanceId.isEmpty,
let stableID = self.currentManualGatewayStableID
{
let credentials = GatewaySettingsStore.loadGatewayCredentials(
instanceId: trimmedInstanceId,
gatewayStableID: stableID)
let ownsFields = credentials.hasCredentials || credentials.suppressStoredDeviceAuth
self.gatewayCredentialFieldStableID = ownsFields ? stableID : nil
self.gatewayToken = credentials.token ?? ""
self.gatewayPassword = credentials.password ?? ""
self.pendingManualAuthOverride = GatewayConnectionController.ManualAuthOverride.persisted(
instanceId: trimmedInstanceId,
targetStableID: stableID)
}
let hasSavedGateway = GatewaySettingsStore.activeGatewayEntry() != nil
let hasToken = !self.gatewayToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
let hasPassword = !self.gatewayPassword.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
if !hasSavedGateway, !hasToken, !hasPassword {
self.statusLine = ""
}
}
private func scheduleDiscoveryRestart() {
self.discoveryRestartTask?.cancel()
self.discoveryRestartTask = Task { @MainActor in
try? await Task.sleep(nanoseconds: 350_000_000)
guard !Task.isCancelled else { return }
self.gatewayController.restartDiscovery()
}
}
private var currentManualGatewayStableID: String? {
let host = self.manualHost.trimmingCharacters(in: .whitespacesAndNewlines)
guard !host.isEmpty, let port = self.resolvedManualPort(host: host) else { return nil }
return GatewayConnectionController.ManualAuthOverride.manualStableID(
host: host,
port: port)
}
private var gatewayCredentialTargetStableID: String? {
// Auth fields follow the selected route. Otherwise a discovered-gateway retry can save
// credentials under the unrelated manual endpoint and immediately reload an empty bundle.
self.gatewayCredentialFieldStableID ?? self.currentManualGatewayStableID
}
private func resolvedManualPort(host: String) -> Int? {
guard self.manualPortText.isEmpty || self.manualPort > 0 else { return nil }
return GatewayConnectionController.resolvedManualPort(
host: host,
port: self.manualPort)
}
private var gatewayTokenBinding: Binding<String> {
Binding(
get: { self.gatewayToken },
set: { self.persistGatewayToken($0) })
}
private var gatewayPasswordBinding: Binding<String> {
Binding(
get: { self.gatewayPassword },
set: { self.persistGatewayPassword($0) })
}
private var manualHostBinding: Binding<String> {
Binding(
get: { self.manualHost },
set: { value in
let previousStableID = self.currentManualGatewayStableID
self.manualHost = value
if GatewayStableIdentifier.key(previousStableID) !=
GatewayStableIdentifier.key(self.currentManualGatewayStableID)
{
self.clearManualCredentialFields()
}
})
}
private var manualPortTextBinding: Binding<String> {
Binding(
get: { self.manualPortText },
set: { value in
let previousStableID = self.currentManualGatewayStableID
let digits = value.filter(\.isNumber)
self.manualPortText = digits
self.manualPort = min(Int(digits) ?? 0, 65535)
if GatewayStableIdentifier.key(previousStableID) !=
GatewayStableIdentifier.key(self.currentManualGatewayStableID)
{
self.clearManualCredentialFields()
}
})
}
private func persistGatewayToken(_ value: String) {
self.gatewayToken = value
let instanceId = GatewaySettingsStore.currentInstanceID()
guard !instanceId.isEmpty, let stableID = self.gatewayCredentialTargetStableID else { return }
self.gatewayCredentialFieldStableID = stableID
let saved = GatewaySettingsStore.updateGatewayCredentials(
token: value,
password: self.gatewayPassword,
gatewayStableID: stableID,
instanceId: instanceId)
self.pendingManualAuthOverride = saved
? GatewayConnectionController.ManualAuthOverride.persisted(
instanceId: instanceId,
targetStableID: stableID)
: nil
}
private func persistGatewayPassword(_ value: String) {
self.gatewayPassword = value
let instanceId = GatewaySettingsStore.currentInstanceID()
guard !instanceId.isEmpty, let stableID = self.gatewayCredentialTargetStableID else { return }
self.gatewayCredentialFieldStableID = stableID
let saved = GatewaySettingsStore.updateGatewayCredentials(
token: self.gatewayToken,
password: value,
gatewayStableID: stableID,
instanceId: instanceId)
self.pendingManualAuthOverride = saved
? GatewayConnectionController.ManualAuthOverride.persisted(
instanceId: instanceId,
targetStableID: stableID)
: nil
}
private func clearManualCredentialFields() {
self.gatewayToken = ""
self.gatewayPassword = ""
self.gatewayCredentialFieldStableID = nil
self.pendingManualAuthOverride = nil
}
private func selectGatewayCredentialTarget(_ stableID: String, allowManualOverride: Bool) {
let instanceId = GatewaySettingsStore.currentInstanceID()
if !GatewayStableIdentifier.matches(self.gatewayCredentialFieldStableID, stableID) {
let credentials = GatewaySettingsStore.loadGatewayCredentials(
instanceId: instanceId,
gatewayStableID: stableID)
self.gatewayCredentialFieldStableID = stableID
self.gatewayToken = credentials.token ?? ""
self.gatewayPassword = credentials.password ?? ""
}
guard allowManualOverride else {
self.pendingManualAuthOverride = nil
return
}
// Each attempt consumes the in-memory override. Reload durable bootstrap auth even
// when the endpoint fields did not change so retry never erases a one-time token.
self.pendingManualAuthOverride = GatewayConnectionController.ManualAuthOverride.persisted(
instanceId: instanceId,
targetStableID: stableID)
}
private func connectDiscoveredGateway(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) async {
self.selectGatewayCredentialTarget(gateway.stableID, allowManualOverride: false)
self.connectingGateway = .gateway(gateway.id)
self.localConnectionFailure = nil
self.issue = .none
self.connectMessage = "Connecting to \(gateway.name)"
self.statusLine = "Connecting to \(gateway.name)"
defer { self.connectingGateway = nil }
await self.gatewayController.connect(gateway)
}
private func selectMode(_ mode: OnboardingConnectionMode) {
self.selectedMode = mode
self.applyModeDefaults(mode)
}
private func applyModeDefaults(_ mode: OnboardingConnectionMode) {
let previousStableID = self.currentManualGatewayStableID
defer {
if GatewayStableIdentifier.key(previousStableID) !=
GatewayStableIdentifier.key(self.currentManualGatewayStableID)
{
self.clearManualCredentialFields()
}
}
let host = self.manualHost.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
let hostIsDefaultLike = host.isEmpty || host == "openclaw.local" || host == "localhost"
switch mode {
case .homeNetwork:
if hostIsDefaultLike { self.manualHost = "openclaw.local" }
self.manualTLS = true
if self.manualPort <= 0 || self.manualPort > 65535 { self.manualPort = 18789 }
case .remoteDomain:
if host == "openclaw.local" || host == "localhost" { self.manualHost = "" }
self.manualTLS = true
if self.manualPort <= 0 || self.manualPort > 65535 { self.manualPort = 18789 }
case .developerLocal:
if hostIsDefaultLike { self.manualHost = "localhost" }
self.manualTLS = false
if self.manualPort <= 0 || self.manualPort > 65535 { self.manualPort = 18789 }
}
}
private func connectManual(setupAttemptID: UUID? = nil) async {
if let setupAttemptID {
guard self.setupAttemptID == setupAttemptID else { return }
} else {
self.invalidateSetupAttempt()
}
let host = self.manualHost.trimmingCharacters(in: .whitespacesAndNewlines)
guard !host.isEmpty, let port = self.resolvedManualPort(host: host) else { return }
self.connectingGateway = .manual
self.localConnectionFailure = nil
self.issue = .none
self.connectMessage = "Connecting to \(host)"
self.statusLine = "Connecting to \(host):\(port)"
defer { self.connectingGateway = nil }
await self.connectCurrentManualGateway(host: host, port: port, forceReconnect: false)
}
private func connectCurrentManualGateway(host: String, port: Int, forceReconnect: Bool) async {
let stableID = GatewayConnectionController.ManualAuthOverride.manualStableID(
host: host,
port: port)
self.selectGatewayCredentialTarget(stableID, allowManualOverride: true)
if GatewayStableIdentifier.matches(
self.appModel.activeGatewayConnectConfig?.effectiveStableID,
stableID),
self.appModel.activeGatewayConnectConfig?.nodeOptions.allowStoredDeviceAuth == true
{
self.pendingManualAuthOverride = nil
}
let fieldsMatchTarget = GatewayStableIdentifier.matches(
self.gatewayCredentialFieldStableID,
stableID)
let pendingOverride = GatewayStableIdentifier.matches(
self.pendingManualAuthOverride?.targetStableID,
stableID)
? self.pendingManualAuthOverride
: nil
let authOverride = GatewayConnectionController.ManualAuthOverride.currentManualInput(
token: fieldsMatchTarget ? self.gatewayToken : nil,
pendingOverride: pendingOverride,
password: fieldsMatchTarget ? self.gatewayPassword : nil,
targetStableID: stableID)
let instanceId = GatewaySettingsStore.currentInstanceID()
if !instanceId.isEmpty, fieldsMatchTarget || pendingOverride != nil {
GatewaySettingsStore.saveGatewayCredentials(
token: authOverride?.token,
bootstrapToken: authOverride?.bootstrapToken,
password: authOverride?.password,
gatewayStableID: stableID,
suppressStoredDeviceAuth: authOverride?.suppressStoredDeviceAuth == true,
instanceId: instanceId)
}
await self.gatewayController.connectManual(
host: host,
port: port,
useTLS: self.manualTLS,
authOverride: authOverride,
forceReconnect: forceReconnect)
// The controller now owns this attempt's immutable override. A later retry must reload
// durable state so a spent bootstrap token cannot be resurrected from the live view.
self.pendingManualAuthOverride = nil
}
private func retryLastAttempt(silent: Bool = false) async {
self.connectingGateway = silent ? .retryAutomatically : .retry
self.localConnectionFailure = nil
// Keep current auth/pairing issue sticky while retrying to avoid Step 3 UI flip-flop.
if !silent {
self.connectMessage = "Retrying…"
self.statusLine = "Retrying last connection…"
}
defer { self.connectingGateway = nil }
switch GatewaySettingsStore.activeGatewayEntry()?.kind {
case .discovered:
await self.gatewayController.connectActiveGateway()
case .manual, .none:
// connectActiveGateway() replays the persisted endpoint and credentials,
// so token/host/port edits made on this screen would be ignored and
// a missing stored connection would silently do nothing. Manual
// retries must dial the current form input instead.
let host = self.manualHost.trimmingCharacters(in: .whitespacesAndNewlines)
if !host.isEmpty, let port = self.resolvedManualPort(host: host) {
await self.connectCurrentManualGateway(host: host, port: port, forceReconnect: true)
return
}
if !silent {
self.setConnectionFailure("No connection to retry. Check the gateway host and port.")
}
}
}
private func gatewayProblemPrimaryActionTitle(_ problem: GatewayConnectionProblem) -> String? {
GatewayProblemPrimaryAction.title(
for: problem,
retryTitle: "Retry connection",
resetTitle: "Scan QR again")
}
private func handleGatewayProblemPrimaryAction(_ problem: GatewayConnectionProblem) async {
if problem.suggestsOnboardingReset {
await GatewayOnboardingReset.reset(appModel: self.appModel, instanceId: self.instanceId)
self.gatewayToken = ""
self.gatewayPassword = ""
self.gatewayCredentialFieldStableID = nil
self.pendingManualAuthOverride = nil
self.connectingGateway = nil
self.connectMessage = nil
self.issue = .none
self.pairingRequestId = nil
self.navigate(to: .connect)
self.openQRScannerFromOnboarding(status: "Scan a fresh setup QR code from this gateway.")
return
}
if problem.canTrustRotatedCertificate {
self.connectingGateway = .trustCertificate
self.connectMessage = "Updating gateway certificate…"
self.statusLine = "Updating gateway certificate…"
defer { self.connectingGateway = nil }
_ = await self.gatewayController.trustRotatedGatewayCertificate(from: problem)
return
}
if GatewayProblemPrimaryAction.handleProtocolMismatchIfNeeded(problem) {
return
}
guard problem.retryable else { return }
await self.retryLastAttempt()
}
}