From 551c9637d8a3de6b2df760eb5fbd4392a51dd780 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 1 Jun 2026 03:46:28 +0100 Subject: [PATCH] fix(ios): polish iPad gateway setup --- apps/ios/Sources/Design/ChatProTab.swift | 8 ++++- apps/ios/Sources/Design/SettingsProTab.swift | 10 +++++++ .../Design/SettingsProTabActions.swift | 25 +++++++++++++--- .../Design/SettingsProTabSections.swift | 2 +- .../Sources/Gateway/GatewayProblemView.swift | 6 ++-- apps/ios/Sources/Model/NodeAppModel.swift | 16 +++++++++- .../Onboarding/OnboardingWizardSteps.swift | 6 ++-- apps/ios/Sources/OpenClawApp.swift | 4 +-- apps/ios/Sources/RootTabs.swift | 15 ++++++++++ .../Voice/TalkPermissionPromptView.swift | 4 +-- .../GatewayConnectionProblem.swift | 30 +++++++++---------- 11 files changed, 94 insertions(+), 32 deletions(-) diff --git a/apps/ios/Sources/Design/ChatProTab.swift b/apps/ios/Sources/Design/ChatProTab.swift index c39edd6b4dd..a8362a74e14 100644 --- a/apps/ios/Sources/Design/ChatProTab.swift +++ b/apps/ios/Sources/Design/ChatProTab.swift @@ -50,6 +50,11 @@ struct ChatProTab: View { .onChange(of: self.appModel.chatSessionKey) { _, _ in self.syncChatViewModel() } + .onChange(of: self.appModel.isOperatorGatewayConnected) { _, connected in + guard connected else { return } + self.syncChatViewModel() + self.viewModel?.refresh() + } } private var header: some View { @@ -151,7 +156,8 @@ struct ChatProTab: View { } private var gatewayConnected: Bool { - GatewayStatusBuilder.build(appModel: self.appModel) == .connected + GatewayStatusBuilder.build(appModel: self.appModel) == .connected && + self.appModel.isOperatorGatewayConnected } private var chatUserAccent: Color { diff --git a/apps/ios/Sources/Design/SettingsProTab.swift b/apps/ios/Sources/Design/SettingsProTab.swift index 8d5a22bfd5e..b4741a9f692 100644 --- a/apps/ios/Sources/Design/SettingsProTab.swift +++ b/apps/ios/Sources/Design/SettingsProTab.swift @@ -45,6 +45,7 @@ struct SettingsProTab: View { @State var gatewayPassword = "" @State var manualGatewayPortText = "" @State var setupStatusText: String? + @State var stagedGatewaySetupLink: GatewayConnectDeepLink? @State var pendingManualAuthOverride: GatewayConnectionController.ManualAuthOverride? @State var defaultShareInstruction = "" @State var showGatewayProblemDetails = false @@ -82,6 +83,7 @@ struct SettingsProTab: View { self.previousLocationModeRaw = self.locationModeRaw self.syncSettingsState() self.refreshNotificationSettings() + self.applyPendingGatewaySetupLinkIfNeeded() } .onChange(of: self.scenePhase) { _, phase in if phase == .active { @@ -107,9 +109,17 @@ struct SettingsProTab: View { .onChange(of: self.gatewayPassword) { _, newValue in self.persistGatewayPassword(newValue) } + .onChange(of: self.setupCode) { _, newValue in + if !newValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + self.stagedGatewaySetupLink = nil + } + } .onChange(of: self.defaultShareInstruction) { _, newValue in ShareToAgentSettings.saveDefaultInstruction(newValue) } + .onChange(of: self.appModel.gatewaySetupRequestID) { _, _ in + self.applyPendingGatewaySetupLinkIfNeeded() + } } .sheet(isPresented: self.$showGatewayProblemDetails) { if let gatewayProblem = self.appModel.lastGatewayProblem { diff --git a/apps/ios/Sources/Design/SettingsProTabActions.swift b/apps/ios/Sources/Design/SettingsProTabActions.swift index f262b1fa8ee..fa897fdca5a 100644 --- a/apps/ios/Sources/Design/SettingsProTabActions.swift +++ b/apps/ios/Sources/Design/SettingsProTabActions.swift @@ -202,17 +202,29 @@ extension SettingsProTab { await self.connectManual() } + func applyPendingGatewaySetupLinkIfNeeded() { + guard let link = self.appModel.consumePendingGatewaySetupLink() else { return } + self.setupCode = "" + self.setupStatusText = nil + self.stagedGatewaySetupLink = link + let security = link.tls ? "TLS" : "plain" + self.setupStatusText = "Setup link loaded for \(link.host):\(link.port) (\(security)). Tap Connect to apply." + } + @discardableResult func applySetupCode() -> Bool { let raw = self.setupCode.trimmingCharacters(in: .whitespacesAndNewlines) - guard !raw.isEmpty else { + let stagedLink = self.stagedGatewaySetupLink + guard !raw.isEmpty || stagedLink != nil else { self.setupStatusText = "Paste a setup code to continue." return false } - guard let link = GatewayConnectDeepLink.fromSetupInput(raw) else { + + guard let link = raw.isEmpty ? stagedLink : GatewayConnectDeepLink.fromSetupInput(raw) else { self.setupStatusText = "Setup code not recognized or uses an insecure ws:// gateway URL." return false } + self.stagedGatewaySetupLink = nil self.applyGatewayLink(link) return true } @@ -299,7 +311,7 @@ extension SettingsProTab { let trimmed = host.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return false } if Self.isTailnetHostOrIP(trimmed), !Self.hasTailnetIPv4() { - self.setupStatusText = "Tailscale is off on this iPhone. Turn it on, then try again." + self.setupStatusText = "Tailscale is off on this device. Turn it on, then try again." return false } self.setupStatusText = "Checking gateway reachability..." @@ -510,10 +522,15 @@ extension SettingsProTab { return gatewayStatus } + var canApplyGatewaySetup: Bool { + !self.setupCode.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + || self.stagedGatewaySetupLink != nil + } + var tailnetWarningText: String? { let host = self.manualGatewayHost.trimmingCharacters(in: .whitespacesAndNewlines) guard !host.isEmpty, Self.isTailnetHostOrIP(host), !Self.hasTailnetIPv4() else { return nil } - return "This gateway is on your tailnet. Turn on Tailscale on this iPhone, then tap Connect." + return "This gateway is on your tailnet. Turn on Tailscale on this device, then tap Connect." } func friendlyGatewayMessage(from raw: String) -> String? { diff --git a/apps/ios/Sources/Design/SettingsProTabSections.swift b/apps/ios/Sources/Design/SettingsProTabSections.swift index 1533bb6282d..74da4c92fe7 100644 --- a/apps/ios/Sources/Design/SettingsProTabSections.swift +++ b/apps/ios/Sources/Design/SettingsProTabSections.swift @@ -542,7 +542,7 @@ extension SettingsProTab { { Task { await self.applySetupCodeAndConnect() } } - .disabled(self.setupCode.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + .disabled(!self.canApplyGatewaySetup) } if let status = self.setupStatusLine { Text(status) diff --git a/apps/ios/Sources/Gateway/GatewayProblemView.swift b/apps/ios/Sources/Gateway/GatewayProblemView.swift index ca20fab31d1..e18c0e4673c 100644 --- a/apps/ios/Sources/Gateway/GatewayProblemView.swift +++ b/apps/ios/Sources/Gateway/GatewayProblemView.swift @@ -111,7 +111,7 @@ struct GatewayProblemBanner: View { case .gateway: "Fix on gateway" case .iphone: - "Fix on iPhone" + "Fix on this device" case .both: "Check both" case .network: @@ -227,9 +227,9 @@ struct GatewayProblemDetailsSheet: View { case .gateway: "Primary fix: gateway" case .iphone: - "Primary fix: this iPhone" + "Primary fix: this device" case .both: - "Primary fix: check both this iPhone and the gateway" + "Primary fix: check both this device and the gateway" case .network: "Primary fix: network or remote access" case .unknown: diff --git a/apps/ios/Sources/Model/NodeAppModel.swift b/apps/ios/Sources/Model/NodeAppModel.swift index 8d37ca83e0a..64c75e46224 100644 --- a/apps/ios/Sources/Model/NodeAppModel.swift +++ b/apps/ios/Sources/Model/NodeAppModel.swift @@ -138,7 +138,9 @@ final class NodeAppModel { var homeCanvasRevision: Int = 0 var lastShareEventText: String = "No share events yet." var openChatRequestID: Int = 0 + var gatewaySetupRequestID: Int = 0 private(set) var pendingAgentDeepLinkPrompt: AgentDeepLinkPrompt? + private var pendingGatewaySetupLink: GatewayConnectDeepLink? private(set) var pendingExecApprovalPrompt: ExecApprovalPrompt? private(set) var pendingExecApprovalPromptResolving: Bool = false private(set) var pendingExecApprovalPromptErrorText: String? @@ -4134,11 +4136,23 @@ extension NodeAppModel { switch route { case let .agent(link): await self.handleAgentDeepLink(link, originalURL: url) - case .gateway, .dashboard: + case let .gateway(link): + self.stageGatewaySetupLink(link) + case .dashboard: break } } + func stageGatewaySetupLink(_ link: GatewayConnectDeepLink) { + self.pendingGatewaySetupLink = link + self.gatewaySetupRequestID &+= 1 + } + + func consumePendingGatewaySetupLink() -> GatewayConnectDeepLink? { + defer { self.pendingGatewaySetupLink = nil } + return self.pendingGatewaySetupLink + } + private func handleAgentDeepLink(_ link: AgentDeepLink, originalURL: URL) async { let message = link.message.trimmingCharacters(in: .whitespacesAndNewlines) guard !message.isEmpty else { return } diff --git a/apps/ios/Sources/Onboarding/OnboardingWizardSteps.swift b/apps/ios/Sources/Onboarding/OnboardingWizardSteps.swift index c39e2355c1f..2803b44f573 100644 --- a/apps/ios/Sources/Onboarding/OnboardingWizardSteps.swift +++ b/apps/ios/Sources/Onboarding/OnboardingWizardSteps.swift @@ -7,7 +7,7 @@ struct OnboardingIntroStep: View { VStack(spacing: 0) { Spacer() - Image(systemName: "iphone.gen3") + Image(systemName: UIDevice.current.userInterfaceIdiom == .pad ? "ipad" : "iphone.gen3") .font(.system(size: 60, weight: .semibold)) .foregroundStyle(.tint) .padding(.bottom, 18) @@ -17,7 +17,7 @@ struct OnboardingIntroStep: View { .multilineTextAlignment(.center) .padding(.bottom, 10) - Text("Turn this iPhone into a secure OpenClaw node for chat, voice, camera, and device tools.") + Text("Turn this device into a secure OpenClaw node for chat, voice, camera, and device tools.") .font(.subheadline) .foregroundStyle(.secondary) .multilineTextAlignment(.center) @@ -114,7 +114,7 @@ struct OnboardingWelcomeStep: View { .foregroundStyle(.secondary) Text("/pair qr") .font(.system(.footnote, design: .monospaced).weight(.semibold)) - Text("Then scan the QR code here to connect this iPhone.") + Text("Then scan the QR code here to connect this device.") .font(.footnote) .foregroundStyle(.secondary) } diff --git a/apps/ios/Sources/OpenClawApp.swift b/apps/ios/Sources/OpenClawApp.swift index 37e424222d6..5b7d1207827 100644 --- a/apps/ios/Sources/OpenClawApp.swift +++ b/apps/ios/Sources/OpenClawApp.swift @@ -669,8 +669,8 @@ extension OpenClawApp { switch route { case .agent, .dashboard: await self.appModel.handleDeepLink(url: url) - case .gateway: - break + case let .gateway(link): + self.appModel.stageGatewaySetupLink(link) } } diff --git a/apps/ios/Sources/RootTabs.swift b/apps/ios/Sources/RootTabs.swift index 32ccea16473..7a8c9876782 100644 --- a/apps/ios/Sources/RootTabs.swift +++ b/apps/ios/Sources/RootTabs.swift @@ -32,6 +32,7 @@ struct RootTabs: View { @State private var didAutoOpenSettings: Bool = false @State private var didApplyInitialAppearance: Bool = false @State private var didApplyInitialChatSession: Bool = false + @State private var handledGatewaySetupRequestID: Int = 0 private enum AppTab: Hashable { case control @@ -237,6 +238,7 @@ struct RootTabs: View { .onAppear { self.updateCanvasState() } .onAppear { self.evaluateOnboardingPresentation(force: false) } .onAppear { self.maybeAutoOpenSettings() } + .onAppear { self.maybeOpenSettingsForGatewaySetup() } .onAppear { self.maybeShowQuickSetup() } .onAppear { self.applyInitialAppearanceIfNeeded() } .onAppear { self.applyInitialChatSessionIfNeeded() } @@ -296,6 +298,9 @@ struct RootTabs: View { .onChange(of: self.appModel.openChatRequestID) { _, _ in self.selectedTab = .chat } + .onChange(of: self.appModel.gatewaySetupRequestID) { _, _ in + self.maybeOpenSettingsForGatewaySetup() + } } private func rootPresentation(_ content: some View) -> some View { @@ -560,6 +565,16 @@ struct RootTabs: View { self.selectedTab = .settings } + private func maybeOpenSettingsForGatewaySetup() { + let requestID = self.appModel.gatewaySetupRequestID + guard requestID != 0, requestID != self.handledGatewaySetupRequestID else { return } + self.handledGatewaySetupRequestID = requestID + self.showOnboarding = false + self.presentedSheet = nil + self.didAutoOpenSettings = true + self.selectedTab = .settings + } + private func applyInitialChatSessionIfNeeded() { guard !self.didApplyInitialChatSession else { return } self.didApplyInitialChatSession = true diff --git a/apps/ios/Sources/Voice/TalkPermissionPromptView.swift b/apps/ios/Sources/Voice/TalkPermissionPromptView.swift index f1c04b600eb..5e5219a4a0e 100644 --- a/apps/ios/Sources/Voice/TalkPermissionPromptView.swift +++ b/apps/ios/Sources/Voice/TalkPermissionPromptView.swift @@ -147,8 +147,8 @@ struct TalkPermissionPromptView: View { case .upgradeRequested: "Approve this request on your gateway. Talk will start automatically when approval lands." default: - "This iPhone needs gateway approval before Talk can use realtime voice. Audio will go directly from " + - "this phone to the voice provider." + "This device needs gateway approval before Talk can use realtime voice. Audio will go directly from " + + "this device to the voice provider." } } diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayConnectionProblem.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayConnectionProblem.swift index 44ba145447c..9e7dd4a3b90 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayConnectionProblem.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayConnectionProblem.swift @@ -213,7 +213,7 @@ public enum GatewayConnectionProblemMapper { owner: .both, title: authError.titleOverride ?? "Gateway token required", message: authError.userMessageOverride - ?? "This gateway requires an auth token, but this iPhone did not send one.", + ?? "This gateway requires an auth token, but this device did not send one.", actionLabel: authError.actionLabel ?? "Open Settings", actionCommand: authError.actionCommand, docsURL: self.docsURL( @@ -229,7 +229,7 @@ public enum GatewayConnectionProblemMapper { owner: .both, title: authError.titleOverride ?? "Gateway token is out of date", message: authError.userMessageOverride - ?? "The token on this iPhone does not match the gateway token.", + ?? "The token on this device does not match the gateway token.", actionLabel: authError .actionLabel ?? (authError.canRetryWithDeviceToken ? "Retry once" : "Update gateway token"), actionCommand: authError.actionCommand, @@ -262,7 +262,7 @@ public enum GatewayConnectionProblemMapper { owner: .both, title: authError.titleOverride ?? "Gateway password required", message: authError.userMessageOverride - ?? "This gateway requires a password, but this iPhone did not send one.", + ?? "This gateway requires a password, but this device did not send one.", actionLabel: authError.actionLabel ?? "Open Settings", actionCommand: authError.actionCommand, docsURL: self.docsURL( @@ -278,7 +278,7 @@ public enum GatewayConnectionProblemMapper { owner: .both, title: authError.titleOverride ?? "Gateway password is out of date", message: authError.userMessageOverride - ?? "The saved password on this iPhone does not match the gateway password.", + ?? "The saved password on this device does not match the gateway password.", actionLabel: authError.actionLabel ?? "Update password", actionCommand: authError.actionCommand, docsURL: self.docsURL( @@ -322,7 +322,7 @@ public enum GatewayConnectionProblemMapper { return self.problem( kind: .deviceTokenMismatch, owner: .both, - title: authError.titleOverride ?? "This iPhone's saved device token is no longer valid", + title: authError.titleOverride ?? "This device's saved device token is no longer valid", message: authError.userMessageOverride ?? "The gateway rejected the stored device token for this role.", actionLabel: authError.actionLabel ?? "Repair pairing", @@ -355,7 +355,7 @@ public enum GatewayConnectionProblemMapper { title: authError.titleOverride ?? "Secure device identity is required", message: authError.userMessageOverride ?? - "This connection must include a signed device identity before the gateway can bind permissions to this iPhone.", + "This connection must include a signed device identity before the gateway can bind permissions to this device.", actionLabel: authError.actionLabel ?? "Retry from the app", actionCommand: authError.actionCommand, docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/platforms/ios"), @@ -369,7 +369,7 @@ public enum GatewayConnectionProblemMapper { owner: .iphone, title: authError.titleOverride ?? "Secure handshake expired", message: authError.userMessageOverride ?? "The device signature is too old to use.", - actionLabel: authError.actionLabel ?? "Check iPhone time", + actionLabel: authError.actionLabel ?? "Check device time", actionCommand: authError.actionCommand, docsURL: self.docsURL( authError.docsURLString, @@ -415,8 +415,8 @@ public enum GatewayConnectionProblemMapper { owner: .iphone, title: authError.titleOverride ?? "This device identity could not be verified", message: authError.userMessageOverride - ?? "The gateway could not verify the identity this iPhone presented.", - actionLabel: authError.actionLabel ?? "Re-pair this iPhone", + ?? "The gateway could not verify the identity this device presented.", + actionLabel: authError.actionLabel ?? "Re-pair this device", actionCommand: authError.actionCommand, docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/gateway/pairing"), requestId: authError.requestId, @@ -429,8 +429,8 @@ public enum GatewayConnectionProblemMapper { owner: .iphone, title: authError.titleOverride ?? "This device identity could not be verified", message: authError.userMessageOverride - ?? "The gateway could not verify the public key this iPhone presented.", - actionLabel: authError.actionLabel ?? "Re-pair this iPhone", + ?? "The gateway could not verify the public key this device presented.", + actionLabel: authError.actionLabel ?? "Re-pair this device", actionCommand: authError.actionCommand, docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/gateway/pairing"), requestId: authError.requestId, @@ -444,7 +444,7 @@ public enum GatewayConnectionProblemMapper { title: authError.titleOverride ?? "This device identity could not be verified", message: authError.userMessageOverride ?? "The gateway rejected the device identity because the device ID did not match.", - actionLabel: authError.actionLabel ?? "Re-pair this iPhone", + actionLabel: authError.actionLabel ?? "Re-pair this device", actionCommand: authError.actionCommand, docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/gateway/pairing"), requestId: authError.requestId, @@ -745,7 +745,7 @@ public enum GatewayConnectionProblemMapper { title: authError.titleOverride ?? "Additional approval required", message: authError.userMessageOverride ?? - "This iPhone is already paired, but it is requesting a new role that was not previously approved.", + "This device is already paired, but it is requesting a new role that was not previously approved.", actionLabel: authError.actionLabel ?? "Approve on gateway", actionCommand: authError.actionCommand ?? pairingCommand, docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/gateway/pairing"), @@ -759,7 +759,7 @@ public enum GatewayConnectionProblemMapper { owner: .gateway, title: authError.titleOverride ?? "Additional permissions required", message: authError.userMessageOverride - ?? "This iPhone is already paired, but it is requesting new permissions that require approval.", + ?? "This device is already paired, but it is requesting new permissions that require approval.", actionLabel: authError.actionLabel ?? "Approve on gateway", actionCommand: authError.actionCommand ?? pairingCommand, docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/gateway/pairing"), @@ -786,7 +786,7 @@ public enum GatewayConnectionProblemMapper { return self.problem( kind: .pairingRequired, owner: .gateway, - title: authError.titleOverride ?? "This iPhone is not approved yet", + title: authError.titleOverride ?? "This device is not approved yet", message: authError.userMessageOverride ?? "The gateway received the connection request, but this device must be approved first.", actionLabel: authError.actionLabel ?? "Approve on gateway",