mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-15 18:26:05 +00:00
* 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 commitf36a95213e) (cherry picked from commit e2c25cbe2baacab44d21871d8cb6734704f065ac) (cherry picked from commit 7c4fda519080486d341a9f4df36d63f9e24b1235) (cherry picked from commit 1b3d4eda3dc5988012124597f9454ae21fb187a1) (cherry picked from commit2a60619722) (cherry picked from commit6f0c386567) (cherry picked from commit784a5857b7) (cherry picked from commit cbf294e026841c9bc2799da0fc7db666a69c52db) * fix(apps): harden approval reconciliation and watch states
1545 lines
62 KiB
Swift
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()
|
|
}
|
|
}
|