iOS: port gateway connect/discovery stability + onboarding reset (#18164)

Merged via /review-pr -> /prepare-pr -> /merge-pr.

Prepared head SHA: 8165ec5bae
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Reviewed-by: @mbelinky
This commit is contained in:
Mariano
2026-02-16 16:07:22 +00:00
committed by GitHub
parent def3a3ced1
commit 9a1e168685
11 changed files with 509 additions and 82 deletions

View File

@@ -25,6 +25,7 @@ Docs: https://docs.openclaw.ai
- CLI/QR: restore fail-fast validation for `openclaw qr --remote` when neither `gateway.remote.url` nor tailscale `serve`/`funnel` is configured, preventing unusable remote pairing QR flows. (#18166) Thanks @mbelinky.
- OpenClawKit/iOS ChatUI: accept canonical session-key completion events for local pending runs and preserve message IDs across history refreshes, preventing stuck "thinking" state and message flicker after gateway replies. (#18165) Thanks @mbelinky.
- iOS/Talk: harden mobile talk config handling by ignoring redacted/env-placeholder API keys, support secure local keychain override, improve accessibility motion/contrast behavior in status UI, and tighten ATS to local-network allowance. (#18163) Thanks @mbelinky.
- iOS/Gateway: stabilize connect/discovery state handling, add onboarding reset recovery in Settings, and fix iOS gateway-controller coverage for command-surface and last-connection persistence behavior. (#18164) Thanks @mbelinky.
## 2026.2.15

View File

@@ -2,8 +2,10 @@ import OpenClawChatUI
import OpenClawKit
import OpenClawProtocol
import Foundation
import OSLog
struct IOSGatewayChatTransport: OpenClawChatTransport, Sendable {
private static let logger = Logger(subsystem: "ai.openclaw", category: "ios.chat.transport")
private let gateway: GatewayNodeSession
init(gateway: GatewayNodeSession) {
@@ -33,10 +35,8 @@ struct IOSGatewayChatTransport: OpenClawChatTransport, Sendable {
}
func setActiveSessionKey(_ sessionKey: String) async throws {
struct Subscribe: Codable { var sessionKey: String }
let data = try JSONEncoder().encode(Subscribe(sessionKey: sessionKey))
let json = String(data: data, encoding: .utf8)
await self.gateway.sendEvent(event: "chat.subscribe", payloadJSON: json)
// Operator clients receive chat events without node-style subscriptions.
// (chat.subscribe is a node event, not an operator RPC method.)
}
func requestHistory(sessionKey: String) async throws -> OpenClawChatHistoryPayload {
@@ -54,6 +54,7 @@ struct IOSGatewayChatTransport: OpenClawChatTransport, Sendable {
idempotencyKey: String,
attachments: [OpenClawChatAttachmentPayload]) async throws -> OpenClawChatSendResponse
{
Self.logger.info("chat.send start sessionKey=\(sessionKey, privacy: .public) len=\(message.count, privacy: .public) attachments=\(attachments.count, privacy: .public)")
struct Params: Codable {
var sessionKey: String
var message: String
@@ -72,8 +73,15 @@ struct IOSGatewayChatTransport: OpenClawChatTransport, Sendable {
idempotencyKey: idempotencyKey)
let data = try JSONEncoder().encode(params)
let json = String(data: data, encoding: .utf8)
let res = try await self.gateway.request(method: "chat.send", paramsJSON: json, timeoutSeconds: 35)
return try JSONDecoder().decode(OpenClawChatSendResponse.self, from: res)
do {
let res = try await self.gateway.request(method: "chat.send", paramsJSON: json, timeoutSeconds: 35)
let decoded = try JSONDecoder().decode(OpenClawChatSendResponse.self, from: res)
Self.logger.info("chat.send ok runId=\(decoded.runId, privacy: .public)")
return decoded
} catch {
Self.logger.error("chat.send failed \(error.localizedDescription, privacy: .public)")
throw error
}
}
func requestHealth(timeoutMs: Int) async throws -> Bool {

View File

@@ -72,32 +72,55 @@ final class GatewayConnectionController {
}
}
func connect(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) async {
func allowAutoConnectAgain() {
self.didAutoConnect = false
self.maybeAutoConnect()
}
func restartDiscovery() {
self.discovery.stop()
self.didAutoConnect = false
self.discovery.start()
self.updateFromDiscovery()
}
/// Returns `nil` when a connect attempt was started, otherwise returns a user-facing error.
func connectWithDiagnostics(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) async -> String? {
await self.connectDiscoveredGateway(gateway)
}
private func connectDiscoveredGateway(
_ gateway: GatewayDiscoveryModel.DiscoveredGateway) async
_ gateway: GatewayDiscoveryModel.DiscoveredGateway) async -> String?
{
let instanceId = UserDefaults.standard.string(forKey: "node.instanceId")?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if instanceId.isEmpty {
return "Missing instanceId (node.instanceId). Try restarting the app."
}
let token = GatewaySettingsStore.loadGatewayToken(instanceId: instanceId)
let password = GatewaySettingsStore.loadGatewayPassword(instanceId: instanceId)
// Resolve the service endpoint (SRV/A/AAAA). TXT is unauthenticated; do not route via TXT.
guard let target = await self.resolveServiceEndpoint(gateway.endpoint) else { return }
guard let target = await self.resolveServiceEndpoint(gateway.endpoint) else {
return "Failed to resolve the discovered gateway endpoint."
}
let stableID = gateway.stableID
// Discovery is a LAN operation; refuse unauthenticated plaintext connects.
let tlsRequired = true
let stored = GatewayTLSStore.loadFingerprint(stableID: stableID)
guard gateway.tlsEnabled || stored != nil else { return }
guard gateway.tlsEnabled || stored != nil else {
return "Discovered gateway is missing TLS and no trusted fingerprint is stored."
}
if tlsRequired, stored == nil {
guard let url = self.buildGatewayURL(host: target.host, port: target.port, useTLS: true)
else { return }
guard let fp = await self.probeTLSFingerprint(url: url) else { return }
else { return "Failed to build TLS URL for trust verification." }
guard let fp = await self.probeTLSFingerprint(url: url) else {
return "Failed to read TLS fingerprint from discovered gateway."
}
self.pendingTrustConnect = (url: url, stableID: stableID, isManual: false)
self.pendingTrustPrompt = TrustPrompt(
stableID: stableID,
@@ -107,7 +130,7 @@ final class GatewayConnectionController {
fingerprintSha256: fp,
isManual: false)
self.appModel?.gatewayStatusText = "Verify gateway TLS fingerprint"
return
return nil
}
let tlsParams = stored.map { fp in
@@ -118,7 +141,7 @@ final class GatewayConnectionController {
host: target.host,
port: target.port,
useTLS: tlsParams?.required == true)
else { return }
else { return "Failed to build discovered gateway URL." }
GatewaySettingsStore.saveLastGatewayConnectionDiscovered(stableID: stableID, useTLS: true)
self.didAutoConnect = true
self.startAutoConnect(
@@ -127,6 +150,11 @@ final class GatewayConnectionController {
tls: tlsParams,
token: token,
password: password)
return nil
}
func connect(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) async {
_ = await self.connectWithDiagnostics(gateway)
}
func connectManual(host: String, port: Int, useTLS: Bool) async {
@@ -490,6 +518,125 @@ final class GatewayConnectionController {
}
}
private func resolveHostPortFromBonjourEndpoint(_ endpoint: NWEndpoint) async -> (host: String, port: Int)? {
switch endpoint {
case let .hostPort(host, port):
return (host: host.debugDescription, port: Int(port.rawValue))
case let .service(name, type, domain, _):
return await Self.resolveBonjourServiceToHostPort(name: name, type: type, domain: domain)
default:
return nil
}
}
private static func resolveBonjourServiceToHostPort(
name: String,
type: String,
domain: String,
timeoutSeconds: TimeInterval = 3.0
) async -> (host: String, port: Int)? {
// NetService callbacks are delivered via a run loop. If we resolve from a thread without one,
// we can end up never receiving callbacks, which in turn leaks the continuation and leaves
// the UI stuck "connecting". Keep the whole lifecycle on the main run loop and always
// resume the continuation exactly once (timeout/cancel safe).
@MainActor
final class Resolver: NSObject, @preconcurrency NetServiceDelegate {
private var cont: CheckedContinuation<(host: String, port: Int)?, Never>?
private let service: NetService
private var timeoutTask: Task<Void, Never>?
private var finished = false
init(cont: CheckedContinuation<(host: String, port: Int)?, Never>, service: NetService) {
self.cont = cont
self.service = service
super.init()
}
func start(timeoutSeconds: TimeInterval) {
self.service.delegate = self
self.service.schedule(in: .main, forMode: .default)
// NetService has its own timeout, but we keep a manual one as a backstop in case
// callbacks never arrive (e.g. local network permission issues).
self.timeoutTask = Task { @MainActor [weak self] in
guard let self else { return }
let ns = UInt64(max(0.1, timeoutSeconds) * 1_000_000_000)
try? await Task.sleep(nanoseconds: ns)
self.finish(nil)
}
self.service.resolve(withTimeout: timeoutSeconds)
}
func netServiceDidResolveAddress(_ sender: NetService) {
self.finish(Self.extractHostPort(sender))
}
func netService(_ sender: NetService, didNotResolve errorDict: [String: NSNumber]) {
_ = errorDict // currently best-effort; callers surface a generic failure
self.finish(nil)
}
private func finish(_ result: (host: String, port: Int)?) {
guard !self.finished else { return }
self.finished = true
self.timeoutTask?.cancel()
self.timeoutTask = nil
self.service.stop()
self.service.remove(from: .main, forMode: .default)
let c = self.cont
self.cont = nil
c?.resume(returning: result)
}
private static func extractHostPort(_ svc: NetService) -> (host: String, port: Int)? {
let port = svc.port
if let host = svc.hostName?.trimmingCharacters(in: .whitespacesAndNewlines), !host.isEmpty {
return (host: host, port: port)
}
guard let addrs = svc.addresses else { return nil }
for addrData in addrs {
let host = addrData.withUnsafeBytes { ptr -> String? in
guard let base = ptr.baseAddress, !ptr.isEmpty else { return nil }
var buffer = [CChar](repeating: 0, count: Int(NI_MAXHOST))
let rc = getnameinfo(
base.assumingMemoryBound(to: sockaddr.self),
socklen_t(ptr.count),
&buffer,
socklen_t(buffer.count),
nil,
0,
NI_NUMERICHOST)
guard rc == 0 else { return nil }
return String(cString: buffer)
}
if let host, !host.isEmpty {
return (host: host, port: port)
}
}
return nil
}
}
return await withCheckedContinuation { cont in
Task { @MainActor in
let service = NetService(domain: domain, type: type, name: name)
let resolver = Resolver(cont: cont, service: service)
// Keep the resolver alive for the lifetime of the NetService resolve.
objc_setAssociatedObject(service, "resolver", resolver, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
resolver.start(timeoutSeconds: timeoutSeconds)
}
}
}
private func buildGatewayURL(host: String, port: Int, useTLS: Bool) -> URL? {
let scheme = useTLS ? "wss" : "ws"
var components = URLComponents()

View File

@@ -0,0 +1,113 @@
import SwiftUI
struct GatewayQuickSetupSheet: View {
@Environment(NodeAppModel.self) private var appModel
@Environment(GatewayConnectionController.self) private var gatewayController
@Environment(\.dismiss) private var dismiss
@AppStorage("onboarding.quickSetupDismissed") private var quickSetupDismissed: Bool = false
@State private var connecting: Bool = false
@State private var connectError: String?
var body: some View {
NavigationStack {
VStack(alignment: .leading, spacing: 16) {
Text("Connect to a Gateway?")
.font(.title2.bold())
if let candidate = self.bestCandidate {
VStack(alignment: .leading, spacing: 6) {
Text(verbatim: candidate.name)
.font(.headline)
Text(verbatim: candidate.debugID)
.font(.footnote)
.foregroundStyle(.secondary)
VStack(alignment: .leading, spacing: 2) {
// Use verbatim strings so Bonjour-provided values can't be interpreted as
// localized format strings (which can crash with Objective-C exceptions).
Text(verbatim: "Discovery: \(self.gatewayController.discoveryStatusText)")
Text(verbatim: "Status: \(self.appModel.gatewayStatusText)")
Text(verbatim: "Node: \(self.appModel.nodeStatusText)")
Text(verbatim: "Operator: \(self.appModel.operatorStatusText)")
}
.font(.footnote)
.foregroundStyle(.secondary)
}
.padding(12)
.background(.thinMaterial)
.clipShape(RoundedRectangle(cornerRadius: 14))
Button {
self.connectError = nil
self.connecting = true
Task {
let err = await self.gatewayController.connectWithDiagnostics(candidate)
await MainActor.run {
self.connecting = false
self.connectError = err
// If we kicked off a connect, leave the sheet up so the user can see status evolve.
}
}
} label: {
Group {
if self.connecting {
HStack(spacing: 8) {
ProgressView().progressViewStyle(.circular)
Text("Connecting…")
}
} else {
Text("Connect")
}
}
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.disabled(self.connecting)
if let connectError {
Text(connectError)
.font(.footnote)
.foregroundStyle(.secondary)
.textSelection(.enabled)
}
Button {
self.dismiss()
} label: {
Text("Not now")
.frame(maxWidth: .infinity)
}
.buttonStyle(.bordered)
.disabled(self.connecting)
Toggle("Dont show this again", isOn: self.$quickSetupDismissed)
.padding(.top, 4)
} else {
Text("No gateways found yet. Make sure your gateway is running and Bonjour discovery is enabled.")
.foregroundStyle(.secondary)
}
Spacer()
}
.padding()
.navigationTitle("Quick Setup")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button {
self.quickSetupDismissed = true
self.dismiss()
} label: {
Text("Close")
}
}
}
}
}
private var bestCandidate: GatewayDiscoveryModel.DiscoveredGateway? {
// Prefer whatever discovery says is first; the list is already name-sorted.
self.gatewayController.gateways.first
}
}

View File

@@ -207,6 +207,25 @@ enum GatewaySettingsStore {
return .manual(host: host, port: port, useTLS: useTLS, stableID: stableID)
}
static func clearLastGatewayConnection(defaults: UserDefaults = .standard) {
defaults.removeObject(forKey: self.lastGatewayKindDefaultsKey)
defaults.removeObject(forKey: self.lastGatewayHostDefaultsKey)
defaults.removeObject(forKey: self.lastGatewayPortDefaultsKey)
defaults.removeObject(forKey: self.lastGatewayTlsDefaultsKey)
defaults.removeObject(forKey: self.lastGatewayStableIDDefaultsKey)
}
static func deleteGatewayCredentials(instanceId: String) {
let trimmed = instanceId.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
_ = KeychainStore.delete(
service: self.gatewayService,
account: self.gatewayTokenAccount(instanceId: trimmed))
_ = KeychainStore.delete(
service: self.gatewayService,
account: self.gatewayPasswordAccount(instanceId: trimmed))
}
static func loadGatewayClientIdOverride(stableID: String) -> String? {
let trimmedID = stableID.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedID.isEmpty else { return nil }

View File

@@ -10,7 +10,6 @@ import UserNotifications
private struct NotificationCallError: Error, Sendable {
let message: String
}
// Ensures notification requests return promptly even if the system prompt blocks.
private final class NotificationInvokeLatch<T: Sendable>: @unchecked Sendable {
private let lock = NSLock()
@@ -37,7 +36,6 @@ private final class NotificationInvokeLatch<T: Sendable>: @unchecked Sendable {
cont?.resume(returning: response)
}
}
@MainActor
@Observable
final class NodeAppModel {
@@ -53,6 +51,8 @@ final class NodeAppModel {
private let camera: any CameraServicing
private let screenRecorder: any ScreenRecordingServicing
var gatewayStatusText: String = "Offline"
var nodeStatusText: String = "Offline"
var operatorStatusText: String = "Offline"
var gatewayServerName: String?
var gatewayRemoteAddress: String?
var connectedGatewayID: String?

View File

@@ -1,4 +1,5 @@
import SwiftUI
import Foundation
@main
struct OpenClawApp: App {
@@ -7,6 +8,7 @@ struct OpenClawApp: App {
@Environment(\.scenePhase) private var scenePhase
init() {
Self.installUncaughtExceptionLogger()
GatewaySettingsStore.bootstrapPersistence()
let appModel = NodeAppModel()
_appModel = State(initialValue: appModel)
@@ -29,3 +31,18 @@ struct OpenClawApp: App {
}
}
}
extension OpenClawApp {
private static func installUncaughtExceptionLogger() {
NSLog("OpenClaw: installing uncaught exception handler")
NSSetUncaughtExceptionHandler { exception in
// Useful when the app hits NSExceptions from SwiftUI/WebKit internals; these do not
// produce a normal Swift error backtrace.
let reason = exception.reason ?? "(no reason)"
NSLog("UNCAUGHT EXCEPTION: %@ %@", exception.name.rawValue, reason)
for line in exception.callStackSymbols {
NSLog(" %@", line)
}
}
}
}

View File

@@ -3,6 +3,7 @@ import UIKit
struct RootCanvas: View {
@Environment(NodeAppModel.self) private var appModel
@Environment(GatewayConnectionController.self) private var gatewayController
@Environment(VoiceWakeManager.self) private var voiceWake
@Environment(\.colorScheme) private var systemColorScheme
@Environment(\.scenePhase) private var scenePhase
@@ -14,6 +15,7 @@ struct RootCanvas: View {
@AppStorage("gateway.preferredStableID") private var preferredGatewayStableID: String = ""
@AppStorage("gateway.manual.enabled") private var manualGatewayEnabled: Bool = false
@AppStorage("gateway.manual.host") private var manualGatewayHost: String = ""
@AppStorage("onboarding.quickSetupDismissed") private var quickSetupDismissed: Bool = false
@State private var presentedSheet: PresentedSheet?
@State private var voiceWakeToastText: String?
@State private var toastDismissTask: Task<Void, Never>?
@@ -22,11 +24,13 @@ struct RootCanvas: View {
private enum PresentedSheet: Identifiable {
case settings
case chat
case quickSetup
var id: Int {
switch self {
case .settings: 0
case .chat: 1
case .quickSetup: 2
}
}
}
@@ -63,12 +67,16 @@ struct RootCanvas: View {
sessionKey: self.appModel.mainSessionKey,
agentName: self.appModel.activeAgentName,
userAccent: self.appModel.seamColor)
case .quickSetup:
GatewayQuickSetupSheet()
}
}
.onAppear { self.updateIdleTimer() }
.onAppear { self.maybeAutoOpenSettings() }
.onChange(of: self.preventSleep) { _, _ in self.updateIdleTimer() }
.onChange(of: self.scenePhase) { _, _ in self.updateIdleTimer() }
.onAppear { self.maybeShowQuickSetup() }
.onChange(of: self.gatewayController.gateways.count) { _, _ in self.maybeShowQuickSetup() }
.onAppear { self.updateCanvasDebugStatus() }
.onChange(of: self.canvasDebugStatusEnabled) { _, _ in self.updateCanvasDebugStatus() }
.onChange(of: self.appModel.gatewayStatusText) { _, _ in self.updateCanvasDebugStatus() }
@@ -155,6 +163,14 @@ struct RootCanvas: View {
self.didAutoOpenSettings = true
self.presentedSheet = .settings
}
private func maybeShowQuickSetup() {
guard !self.quickSetupDismissed else { return }
guard self.presentedSheet == nil else { return }
guard self.appModel.gatewayServerName == nil else { return }
guard !self.gatewayController.gateways.isEmpty else { return }
self.presentedSheet = .quickSetup
}
}
private struct CanvasContent: View {

View File

@@ -28,6 +28,12 @@ struct SettingsTab: View {
@AppStorage("gateway.manual.tls") private var manualGatewayTLS: Bool = true
@AppStorage("gateway.discovery.debugLogs") private var discoveryDebugLogsEnabled: Bool = false
@AppStorage("canvas.debugStatusEnabled") private var canvasDebugStatusEnabled: Bool = false
// Onboarding control (RootCanvas listens to onboarding.requestID and force-opens the wizard).
@AppStorage("onboarding.requestID") private var onboardingRequestID: Int = 0
@AppStorage("gateway.onboardingComplete") private var onboardingComplete: Bool = false
@AppStorage("gateway.hasConnectedOnce") private var hasConnectedOnce: Bool = false
@State private var connectingGatewayID: String?
@State private var localIPAddress: String?
@State private var lastLocationModeRaw: String = OpenClawLocationMode.off.rawValue
@@ -40,6 +46,9 @@ struct SettingsTab: View {
@State private var gatewayExpanded: Bool = true
@State private var selectedAgentPickerId: String = ""
@State private var showResetOnboardingAlert: Bool = false
@State private var suppressCredentialPersist: Bool = false
private let gatewayLogger = Logger(subsystem: "ai.openclaw.ios", category: "GatewaySettings")
var body: some View {
@@ -104,7 +113,6 @@ struct SettingsTab: View {
.foregroundStyle(.secondary)
}
DisclosureGroup("Advanced") {
if self.appModel.gatewayServerName == nil {
LabeledContent("Discovery", value: self.gatewayController.discoveryStatusText)
}
@@ -149,69 +157,74 @@ struct SettingsTab: View {
self.gatewayList(showing: .all)
}
Toggle("Use Manual Gateway", isOn: self.$manualGatewayEnabled)
DisclosureGroup("Advanced") {
Toggle("Use Manual Gateway", isOn: self.$manualGatewayEnabled)
TextField("Host", text: self.$manualGatewayHost)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
TextField("Host", text: self.$manualGatewayHost)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
TextField("Port (optional)", text: self.manualPortBinding)
.keyboardType(.numberPad)
TextField("Port (optional)", text: self.manualPortBinding)
.keyboardType(.numberPad)
Toggle("Use TLS", isOn: self.$manualGatewayTLS)
Toggle("Use TLS", isOn: self.$manualGatewayTLS)
Button {
Task { await self.connectManual() }
} label: {
if self.connectingGatewayID == "manual" {
HStack(spacing: 8) {
ProgressView()
.progressViewStyle(.circular)
Text("Connecting…")
Button {
Task { await self.connectManual() }
} label: {
if self.connectingGatewayID == "manual" {
HStack(spacing: 8) {
ProgressView()
.progressViewStyle(.circular)
Text("Connecting…")
}
} else {
Text("Connect (Manual)")
}
} else {
Text("Connect (Manual)")
}
}
.disabled(self.connectingGatewayID != nil || self.manualGatewayHost
.trimmingCharacters(in: .whitespacesAndNewlines)
.isEmpty || !self.manualPortIsValid)
.disabled(self.connectingGatewayID != nil || self.manualGatewayHost
.trimmingCharacters(in: .whitespacesAndNewlines)
.isEmpty || !self.manualPortIsValid)
Text(
"Use this when mDNS/Bonjour discovery is blocked. "
+ "Leave port empty for 443 on tailnet DNS (TLS) or 18789 otherwise.")
.font(.footnote)
.foregroundStyle(.secondary)
Text(
"Use this when mDNS/Bonjour discovery is blocked. "
+ "Leave port empty for 443 on tailnet DNS (TLS) or 18789 otherwise.")
.font(.footnote)
.foregroundStyle(.secondary)
Toggle("Discovery Debug Logs", isOn: self.$discoveryDebugLogsEnabled)
.onChange(of: self.discoveryDebugLogsEnabled) { _, newValue in
self.gatewayController.setDiscoveryDebugLoggingEnabled(newValue)
Toggle("Discovery Debug Logs", isOn: self.$discoveryDebugLogsEnabled)
.onChange(of: self.discoveryDebugLogsEnabled) { _, newValue in
self.gatewayController.setDiscoveryDebugLoggingEnabled(newValue)
}
NavigationLink("Discovery Logs") {
GatewayDiscoveryDebugLogView()
}
NavigationLink("Discovery Logs") {
GatewayDiscoveryDebugLogView()
Toggle("Debug Canvas Status", isOn: self.$canvasDebugStatusEnabled)
TextField("Gateway Auth Token", text: self.$gatewayToken)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
SecureField("Gateway Password", text: self.$gatewayPassword)
Button("Reset Onboarding", role: .destructive) {
self.showResetOnboardingAlert = true
}
VStack(alignment: .leading, spacing: 6) {
Text("Debug")
.font(.footnote.weight(.semibold))
.foregroundStyle(.secondary)
Text(self.gatewayDebugText())
.font(.system(size: 12, weight: .regular, design: .monospaced))
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(10)
.background(.thinMaterial, in: RoundedRectangle(cornerRadius: 10, style: .continuous))
}
}
Toggle("Debug Canvas Status", isOn: self.$canvasDebugStatusEnabled)
TextField("Gateway Token", text: self.$gatewayToken)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
SecureField("Gateway Password", text: self.$gatewayPassword)
VStack(alignment: .leading, spacing: 6) {
Text("Debug")
.font(.footnote.weight(.semibold))
.foregroundStyle(.secondary)
Text(self.gatewayDebugText())
.font(.system(size: 12, weight: .regular, design: .monospaced))
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(10)
.background(.thinMaterial, in: RoundedRectangle(cornerRadius: 10, style: .continuous))
}
}
} label: {
HStack(spacing: 10) {
Circle()
@@ -310,6 +323,15 @@ struct SettingsTab: View {
.accessibilityLabel("Close")
}
}
.alert("Reset Onboarding?", isPresented: self.$showResetOnboardingAlert) {
Button("Reset", role: .destructive) {
self.resetOnboarding()
}
Button("Cancel", role: .cancel) {}
} message: {
Text(
"This will disconnect, clear saved gateway connection + credentials, and reopen the onboarding wizard.")
}
.onAppear {
self.localIPAddress = NetworkInterfaces.primaryIPv4Address()
self.lastLocationModeRaw = self.locationEnabledModeRaw
@@ -339,12 +361,14 @@ struct SettingsTab: View {
GatewaySettingsStore.savePreferredGatewayStableID(trimmed)
}
.onChange(of: self.gatewayToken) { _, newValue in
guard !self.suppressCredentialPersist else { return }
let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
let instanceId = self.instanceId.trimmingCharacters(in: .whitespacesAndNewlines)
guard !instanceId.isEmpty else { return }
GatewaySettingsStore.saveGatewayToken(trimmed, instanceId: instanceId)
}
.onChange(of: self.gatewayPassword) { _, newValue in
guard !self.suppressCredentialPersist else { return }
let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
let instanceId = self.instanceId.trimmingCharacters(in: .whitespacesAndNewlines)
guard !instanceId.isEmpty else { return }
@@ -432,10 +456,11 @@ struct SettingsTab: View {
ForEach(rows) { gateway in
HStack {
VStack(alignment: .leading, spacing: 2) {
Text(gateway.name)
// Avoid localized-string formatting edge cases from Bonjour-advertised names.
Text(verbatim: gateway.name)
let detailLines = self.gatewayDetailLines(gateway)
ForEach(detailLines, id: \.self) { line in
Text(line)
Text(verbatim: line)
.font(.footnote)
.foregroundStyle(.secondary)
}
@@ -521,7 +546,10 @@ struct SettingsTab: View {
GatewaySettingsStore.saveLastDiscoveredGatewayStableID(gateway.stableID)
defer { self.connectingGatewayID = nil }
await self.gatewayController.connect(gateway)
let err = await self.gatewayController.connectWithDiagnostics(gateway)
if let err {
self.setupStatusText = err
}
}
private func connectLastKnown() async {
@@ -860,6 +888,43 @@ struct SettingsTab: View {
SettingsNetworkingHelpers.httpURLString(host: host, port: port, fallback: fallback)
}
private func resetOnboarding() {
// Disconnect first so RootCanvas doesn't instantly mark onboarding complete again.
self.appModel.disconnectGateway()
self.connectingGatewayID = nil
self.setupStatusText = nil
self.setupCode = ""
self.gatewayAutoConnect = false
self.suppressCredentialPersist = true
defer { self.suppressCredentialPersist = false }
self.gatewayToken = ""
self.gatewayPassword = ""
let trimmedInstanceId = self.instanceId.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmedInstanceId.isEmpty {
GatewaySettingsStore.deleteGatewayCredentials(instanceId: trimmedInstanceId)
}
// Reset onboarding state + clear saved gateway connection (the two things RootCanvas checks).
GatewaySettingsStore.clearLastGatewayConnection()
// RootCanvas also short-circuits onboarding when these are true.
self.onboardingComplete = false
self.hasConnectedOnce = false
// Clear manual override so it doesn't count as an existing gateway config.
self.manualGatewayEnabled = false
self.manualGatewayHost = ""
// Force re-present even without app restart.
self.onboardingRequestID += 1
// The onboarding wizard is presented from RootCanvas; dismiss Settings so it can show.
self.dismiss()
}
private func gatewayDetailLines(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) -> [String] {
var lines: [String] = []
if let lanHost = gateway.lanHost { lines.append("LAN: \(lanHost)") }

View File

@@ -815,23 +815,14 @@ final class TalkModeManager: NSObject {
private func subscribeChatIfNeeded(sessionKey: String) async {
let key = sessionKey.trimmingCharacters(in: .whitespacesAndNewlines)
guard !key.isEmpty else { return }
guard let gateway else { return }
guard !self.chatSubscribedSessionKeys.contains(key) else { return }
let payload = "{\"sessionKey\":\"\(key)\"}"
await gateway.sendEvent(event: "chat.subscribe", payloadJSON: payload)
// Operator clients receive chat events without node-style subscriptions.
self.chatSubscribedSessionKeys.insert(key)
self.logger.info("chat.subscribe ok sessionKey=\(key, privacy: .public)")
}
private func unsubscribeAllChats() async {
guard let gateway else { return }
let keys = self.chatSubscribedSessionKeys
self.chatSubscribedSessionKeys.removeAll()
for key in keys {
let payload = "{\"sessionKey\":\"\(key)\"}"
await gateway.sendEvent(event: "chat.unsubscribe", payloadJSON: payload)
}
}
private func buildPrompt(transcript: String) -> String {

View File

@@ -76,4 +76,54 @@ private func withUserDefaults<T>(_ updates: [String: Any?], _ body: () throws ->
#expect(commands.contains(OpenClawLocationCommand.get.rawValue))
}
}
@Test @MainActor func currentCommandsExcludeDangerousSystemExecCommands() {
withUserDefaults([
"node.instanceId": "ios-test",
"camera.enabled": true,
"location.enabledMode": OpenClawLocationMode.whileUsing.rawValue,
]) {
let appModel = NodeAppModel()
let controller = GatewayConnectionController(appModel: appModel, startDiscovery: false)
let commands = Set(controller._test_currentCommands())
// iOS should expose notify, but not host shell/exec-approval commands.
#expect(commands.contains(OpenClawSystemCommand.notify.rawValue))
#expect(!commands.contains(OpenClawSystemCommand.run.rawValue))
#expect(!commands.contains(OpenClawSystemCommand.which.rawValue))
#expect(!commands.contains(OpenClawSystemCommand.execApprovalsGet.rawValue))
#expect(!commands.contains(OpenClawSystemCommand.execApprovalsSet.rawValue))
}
}
@Test @MainActor func loadLastConnectionReadsSavedValues() {
withUserDefaults([:]) {
GatewaySettingsStore.saveLastGatewayConnectionManual(
host: "gateway.example.com",
port: 443,
useTLS: true,
stableID: "manual:gateway.example.com:443")
let loaded = GatewaySettingsStore.loadLastGatewayConnection()
guard case let .manual(host, port, useTLS, stableID) = loaded else {
Issue.record("Expected manual last-gateway connection")
return
}
#expect(host == "gateway.example.com")
#expect(port == 443)
#expect(useTLS == true)
#expect(stableID == "manual:gateway.example.com:443")
}
}
@Test @MainActor func loadLastConnectionReturnsNilForInvalidData() {
withUserDefaults([
"gateway.last.kind": "manual",
"gateway.last.host": "",
"gateway.last.port": 0,
"gateway.last.tls": false,
"gateway.last.stableID": "manual:bad:0",
]) {
let loaded = GatewaySettingsStore.loadLastGatewayConnection()
#expect(loaded == nil)
}
}
}