From 8bddafba658c8bfcdfe2246a93bce9dcf44a0f55 Mon Sep 17 00:00:00 2001 From: joshavant <830519+joshavant@users.noreply.github.com> Date: Tue, 23 Jun 2026 14:40:00 -0500 Subject: [PATCH] fix(ios): defer local network discovery until onboarding --- .../Design/SettingsProTabActions.swift | 1 + .../Gateway/GatewayConnectionController.swift | 36 +++++++++++++++++-- .../Onboarding/OnboardingWizardView.swift | 19 +++++++++- apps/ios/Sources/OpenClawApp.swift | 3 +- apps/ios/Sources/RootTabs.swift | 25 ++++++++++++- 5 files changed, 79 insertions(+), 5 deletions(-) diff --git a/apps/ios/Sources/Design/SettingsProTabActions.swift b/apps/ios/Sources/Design/SettingsProTabActions.swift index bcef133a774..48e48ea9dbb 100644 --- a/apps/ios/Sources/Design/SettingsProTabActions.swift +++ b/apps/ios/Sources/Design/SettingsProTabActions.swift @@ -326,6 +326,7 @@ extension SettingsProTab { self.setupStatusText = "Tailscale is off on this device. Turn it on, then try again." return false } + self.gatewayController.requestLocalNetworkAccess(reason: "settings_preflight") self.setupStatusText = "Checking gateway reachability..." let ok = await TCPProbe.probe(host: trimmed, port: port, timeoutSeconds: 3, queueLabel: "gateway.preflight") if !ok { diff --git a/apps/ios/Sources/Gateway/GatewayConnectionController.swift b/apps/ios/Sources/Gateway/GatewayConnectionController.swift index 3391091012b..952b9e33dcd 100644 --- a/apps/ios/Sources/Gateway/GatewayConnectionController.swift +++ b/apps/ios/Sources/Gateway/GatewayConnectionController.swift @@ -127,6 +127,8 @@ final class GatewayConnectionController { private let discovery = GatewayDiscoveryModel() private let discoveryEnabled: Bool private weak var appModel: NodeAppModel? + private var localNetworkAccessRequested: Bool + private var currentScenePhase: ScenePhase = .inactive private var didAutoConnect = false private var pendingServiceResolvers: [String: GatewayServiceResolver] = [:] private var pendingTrustConnect: PendingTrustConnect? @@ -137,9 +139,14 @@ final class GatewayConnectionController { let useTLS: Bool } - init(appModel: NodeAppModel, startDiscovery: Bool = true) { + init( + appModel: NodeAppModel, + startDiscovery: Bool = true, + deferDiscoveryUntilLocalNetworkRequest: Bool = false) + { self.discoveryEnabled = startDiscovery self.appModel = appModel + self.localNetworkAccessRequested = !deferDiscoveryUntilLocalNetworkRequest GatewaySettingsStore.bootstrapPersistence() let defaults = UserDefaults.standard @@ -148,7 +155,7 @@ final class GatewayConnectionController { self.updateFromDiscovery() self.observeDiscovery() - if self.discoveryEnabled { + if self.discoveryEnabled, self.localNetworkAccessRequested { self.discovery.start() } } @@ -157,11 +164,29 @@ final class GatewayConnectionController { self.discovery.setDebugLoggingEnabled(enabled) } + func requestLocalNetworkAccess(reason: String) { + guard self.discoveryEnabled else { + self.discovery.stop() + self.updateFromDiscovery() + return + } + + self.localNetworkAccessRequested = true + GatewayDiagnostics.log("local network access requested reason=\(reason)") + + guard self.currentScenePhase != .background else { return } + self.discovery.start() + self.updateFromDiscovery() + self.attemptAutoReconnectIfNeeded() + } + func setScenePhase(_ phase: ScenePhase) { + self.currentScenePhase = phase guard self.discoveryEnabled else { self.discovery.stop() return } + guard self.localNetworkAccessRequested else { return } switch phase { case .background: @@ -181,6 +206,10 @@ final class GatewayConnectionController { self.updateFromDiscovery() return } + guard self.localNetworkAccessRequested else { + self.requestLocalNetworkAccess(reason: "restart_discovery") + return + } self.discovery.stop() self.didAutoConnect = false @@ -197,6 +226,7 @@ final class GatewayConnectionController { _ gateway: GatewayDiscoveryModel.DiscoveredGateway, forceReconnect: Bool = false) async -> String? { + self.requestLocalNetworkAccess(reason: "connect_discovered_gateway") let instanceId = UserDefaults.standard.string(forKey: "node.instanceId")? .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" if instanceId.isEmpty { @@ -275,6 +305,7 @@ final class GatewayConnectionController { authOverride: ManualAuthOverride? = nil, forceReconnect: Bool = false) async { + self.requestLocalNetworkAccess(reason: "connect_manual") let instanceId = GatewaySettingsStore.currentInstanceID() let token = authOverride.map(\.token) ?? GatewaySettingsStore.loadGatewayToken(instanceId: instanceId) @@ -340,6 +371,7 @@ final class GatewayConnectionController { } func connectLastKnown() async { + self.requestLocalNetworkAccess(reason: "connect_last_known") guard let last = GatewaySettingsStore.loadLastGatewayConnection() else { return } switch last { case let .manual(host, port, useTLS, _): diff --git a/apps/ios/Sources/Onboarding/OnboardingWizardView.swift b/apps/ios/Sources/Onboarding/OnboardingWizardView.swift index 061c67ed11c..6fc7e8ac947 100644 --- a/apps/ios/Sources/Onboarding/OnboardingWizardView.swift +++ b/apps/ios/Sources/Onboarding/OnboardingWizardView.swift @@ -73,10 +73,16 @@ struct OnboardingWizardView: View { private static let pairingAutoResumeTicker = Timer.publish(every: 2.0, on: .main, in: .common).autoconnect() let allowSkip: Bool + let onRequestLocalNetworkAccess: (String) -> Void let onClose: () -> Void - init(allowSkip: Bool, onClose: @escaping () -> Void) { + init( + allowSkip: Bool, + onRequestLocalNetworkAccess: @escaping (String) -> Void, + onClose: @escaping () -> Void) + { self.allowSkip = allowSkip + self.onRequestLocalNetworkAccess = onRequestLocalNetworkAccess self.onClose = onClose _step = State( initialValue: OnboardingStateStore.shouldPresentFirstRunIntro() ? .intro : .welcome) @@ -231,6 +237,7 @@ struct OnboardingWizardView: View { } .onAppear { self.initializeState() + self.requestLocalNetworkAccessIfPastIntro(reason: "onboarding_appear") } .onDisappear { self.discoveryRestartTask?.cancel() @@ -864,10 +871,20 @@ extension OnboardingWizardView { private func advanceFromIntro() { OnboardingStateStore.markFirstRunIntroSeen() + self.requestLocalNetworkAccess(reason: "onboarding_continue") self.statusLine = "In your OpenClaw chat, run /pair qr, then scan the code here." self.step = .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 = self.step.previous else { return } self.connectingGatewayID = nil diff --git a/apps/ios/Sources/OpenClawApp.swift b/apps/ios/Sources/OpenClawApp.swift index 2dd4e36e626..b5ef28fec44 100644 --- a/apps/ios/Sources/OpenClawApp.swift +++ b/apps/ios/Sources/OpenClawApp.swift @@ -646,7 +646,8 @@ struct OpenClawApp: App { _gatewayController = State( initialValue: GatewayConnectionController( appModel: appModel, - startDiscovery: !Self.screenshotModeEnabled)) + startDiscovery: !Self.screenshotModeEnabled, + deferDiscoveryUntilLocalNetworkRequest: true)) } var body: some Scene { diff --git a/apps/ios/Sources/RootTabs.swift b/apps/ios/Sources/RootTabs.swift index acdce1dec97..e8577659761 100644 --- a/apps/ios/Sources/RootTabs.swift +++ b/apps/ios/Sources/RootTabs.swift @@ -683,6 +683,7 @@ struct RootTabs: View { self.updateIdleTimer() self.updateHomeCanvasState() guard newValue == .active else { return } + self.maybeRequestLocalNetworkAccess(reason: "scene_active") Task { await self.appModel.refreshGatewayOverviewIfConnected() await MainActor.run { @@ -729,6 +730,10 @@ struct RootTabs: View { .onChange(of: self.onboardingRequestID) { _, _ in self.evaluateOnboardingPresentation(force: true) } + .onChange(of: self.showOnboarding) { _, newValue in + guard !newValue else { return } + self.maybeRequestLocalNetworkAccess(reason: "onboarding_dismissed") + } .onChange(of: self.appModel.openChatRequestID) { _, _ in self.selectSidebarDestination(.chat) } @@ -767,6 +772,9 @@ struct RootTabs: View { .fullScreenCover(isPresented: self.$showOnboarding) { OnboardingWizardView( allowSkip: self.onboardingAllowSkip, + onRequestLocalNetworkAccess: { reason in + self.requestLocalNetworkAccess(reason: reason) + }, onClose: { self.showOnboarding = false }) @@ -1045,13 +1053,14 @@ extension RootTabs { shouldPresentOnLaunch: OnboardingStateStore.shouldPresentOnLaunch(appModel: self.appModel)) switch route { case .none: - break + self.maybeRequestLocalNetworkAccess(reason: "root_appear") case .onboarding: self.onboardingAllowSkip = true self.showOnboarding = true case .settings: self.didAutoOpenSettings = true self.selectSidebarDestination(.gateway) + self.maybeRequestLocalNetworkAccess(reason: "root_appear") } } @@ -1078,6 +1087,7 @@ extension RootTabs { guard route == .settings else { return } self.didAutoOpenSettings = true self.selectSidebarDestination(.gateway) + self.maybeRequestLocalNetworkAccess(reason: "auto_open_settings") } private func maybeOpenSettingsForGatewaySetup() { @@ -1088,6 +1098,19 @@ extension RootTabs { self.presentedSheet = nil self.didAutoOpenSettings = true self.selectSidebarDestination(.gateway) + self.requestLocalNetworkAccess(reason: "gateway_setup_deeplink") + } + + private func maybeRequestLocalNetworkAccess(reason: String) { + guard self.didEvaluateOnboarding else { return } + guard self.scenePhase == .active else { return } + guard !self.showOnboarding else { return } + self.requestLocalNetworkAccess(reason: reason) + } + + private func requestLocalNetworkAccess(reason: String) { + guard !self.appModel.isAppleReviewDemoModeEnabled else { return } + self.gatewayController.requestLocalNetworkAccess(reason: reason) } private func applyInitialChatSessionIfNeeded() {