fix(ios): polish iPad gateway setup

This commit is contained in:
Peter Steinberger
2026-06-01 03:46:28 +01:00
parent c5eddadd9d
commit 551c9637d8
11 changed files with 94 additions and 32 deletions

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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? {

View File

@@ -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)

View File

@@ -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:

View File

@@ -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 }

View File

@@ -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)
}

View File

@@ -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)
}
}

View File

@@ -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

View File

@@ -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."
}
}

View File

@@ -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",