diff --git a/apps/ios/Sources/Gateway/GatewayConnectionController.swift b/apps/ios/Sources/Gateway/GatewayConnectionController.swift
index 1fc38c197c..eb4541158d 100644
--- a/apps/ios/Sources/Gateway/GatewayConnectionController.swift
+++ b/apps/ios/Sources/Gateway/GatewayConnectionController.swift
@@ -67,6 +67,11 @@ final class GatewayConnectionController {
port: port,
useTLS: tlsParams?.required == true)
else { return }
+ GatewaySettingsStore.saveLastGatewayConnection(
+ host: host,
+ port: port,
+ useTLS: tlsParams?.required == true,
+ stableID: gateway.stableID)
self.didAutoConnect = true
self.startAutoConnect(
url: url,
@@ -81,13 +86,24 @@ final class GatewayConnectionController {
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let token = GatewaySettingsStore.loadGatewayToken(instanceId: instanceId)
let password = GatewaySettingsStore.loadGatewayPassword(instanceId: instanceId)
- let stableID = self.manualStableID(host: host, port: port)
- let tlsParams = self.resolveManualTLSParams(stableID: stableID, tlsEnabled: useTLS)
+ let resolvedUseTLS = useTLS || self.shouldForceTLS(host: host)
+ guard let resolvedPort = self.resolveManualPort(host: host, port: port, useTLS: resolvedUseTLS)
+ else { return }
+ let stableID = self.manualStableID(host: host, port: resolvedPort)
+ let tlsParams = self.resolveManualTLSParams(
+ stableID: stableID,
+ tlsEnabled: resolvedUseTLS,
+ allowTOFUReset: self.shouldForceTLS(host: host))
guard let url = self.buildGatewayURL(
host: host,
- port: port,
+ port: resolvedPort,
useTLS: tlsParams?.required == true)
else { return }
+ GatewaySettingsStore.saveLastGatewayConnection(
+ host: host,
+ port: resolvedPort,
+ useTLS: tlsParams?.required == true,
+ stableID: stableID)
self.didAutoConnect = true
self.startAutoConnect(
url: url,
@@ -97,6 +113,38 @@ final class GatewayConnectionController {
password: password)
}
+ func connectLastKnown() async {
+ guard let last = GatewaySettingsStore.loadLastGatewayConnection() else { return }
+ let instanceId = UserDefaults.standard.string(forKey: "node.instanceId")?
+ .trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
+ let token = GatewaySettingsStore.loadGatewayToken(instanceId: instanceId)
+ let password = GatewaySettingsStore.loadGatewayPassword(instanceId: instanceId)
+ let resolvedUseTLS = last.useTLS || self.shouldForceTLS(host: last.host)
+ let tlsParams = self.resolveManualTLSParams(
+ stableID: last.stableID,
+ tlsEnabled: resolvedUseTLS,
+ allowTOFUReset: self.shouldForceTLS(host: last.host))
+ guard let url = self.buildGatewayURL(
+ host: last.host,
+ port: last.port,
+ useTLS: tlsParams?.required == true)
+ else { return }
+ if resolvedUseTLS != last.useTLS {
+ GatewaySettingsStore.saveLastGatewayConnection(
+ host: last.host,
+ port: last.port,
+ useTLS: resolvedUseTLS,
+ stableID: last.stableID)
+ }
+ self.didAutoConnect = true
+ self.startAutoConnect(
+ url: url,
+ gatewayStableID: last.stableID,
+ tls: tlsParams,
+ token: token,
+ password: password)
+ }
+
private func updateFromDiscovery() {
let newGateways = self.discovery.gateways
self.gateways = newGateways
@@ -143,9 +191,13 @@ final class GatewayConnectionController {
let manualPort = defaults.integer(forKey: "gateway.manual.port")
let resolvedPort = manualPort > 0 ? manualPort : 18789
let manualTLS = defaults.bool(forKey: "gateway.manual.tls")
+ let resolvedUseTLS = manualTLS || self.shouldForceTLS(host: manualHost)
let stableID = self.manualStableID(host: manualHost, port: resolvedPort)
- let tlsParams = self.resolveManualTLSParams(stableID: stableID, tlsEnabled: manualTLS)
+ let tlsParams = self.resolveManualTLSParams(
+ stableID: stableID,
+ tlsEnabled: resolvedUseTLS,
+ allowTOFUReset: self.shouldForceTLS(host: manualHost))
guard let url = self.buildGatewayURL(
host: manualHost,
@@ -169,21 +221,60 @@ final class GatewayConnectionController {
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let candidates = [preferredStableID, lastDiscoveredStableID].filter { !$0.isEmpty }
- guard let targetStableID = candidates.first(where: { id in
+ if let targetStableID = candidates.first(where: { id in
self.gateways.contains(where: { $0.stableID == id })
- }) else { return }
+ }) {
+ guard let target = self.gateways.first(where: { $0.stableID == targetStableID }) else { return }
+ guard let host = self.resolveGatewayHost(target) else { return }
+ let port = target.gatewayPort ?? 18789
+ let tlsParams = self.resolveDiscoveredTLSParams(gateway: target)
+ guard let url = self.buildGatewayURL(host: host, port: port, useTLS: tlsParams?.required == true)
+ else { return }
- guard let target = self.gateways.first(where: { $0.stableID == targetStableID }) else { return }
- guard let host = self.resolveGatewayHost(target) else { return }
- let port = target.gatewayPort ?? 18789
- let tlsParams = self.resolveDiscoveredTLSParams(gateway: target)
- guard let url = self.buildGatewayURL(host: host, port: port, useTLS: tlsParams?.required == true)
+ self.didAutoConnect = true
+ self.startAutoConnect(
+ url: url,
+ gatewayStableID: target.stableID,
+ tls: tlsParams,
+ token: token,
+ password: password)
+ return
+ }
+
+ let lastKnown = GatewaySettingsStore.loadLastGatewayConnection()
+ if self.gateways.count == 1, lastKnown == nil, let gateway = self.gateways.first {
+ guard let host = self.resolveGatewayHost(gateway) else { return }
+ let port = gateway.gatewayPort ?? 18789
+ let tlsParams = self.resolveDiscoveredTLSParams(gateway: gateway)
+ guard let url = self.buildGatewayURL(host: host, port: port, useTLS: tlsParams?.required == true)
+ else { return }
+
+ self.didAutoConnect = true
+ self.startAutoConnect(
+ url: url,
+ gatewayStableID: gateway.stableID,
+ tls: tlsParams,
+ token: token,
+ password: password)
+ return
+ }
+
+ guard let lastKnown else { return }
+ let resolvedUseTLS = lastKnown.useTLS || self.shouldForceTLS(host: lastKnown.host)
+ let tlsParams = self.resolveManualTLSParams(
+ stableID: lastKnown.stableID,
+ tlsEnabled: resolvedUseTLS,
+ allowTOFUReset: self.shouldForceTLS(host: lastKnown.host))
+ guard let url = self.buildGatewayURL(
+ host: lastKnown.host,
+ port: lastKnown.port,
+ useTLS: tlsParams?.required == true)
else { return }
self.didAutoConnect = true
self.startAutoConnect(
url: url,
- gatewayStableID: target.stableID,
+ gatewayStableID: lastKnown.stableID,
tls: tlsParams,
token: token,
password: password)
@@ -212,7 +303,7 @@ final class GatewayConnectionController {
password: String?)
{
guard let appModel else { return }
- let connectOptions = self.makeConnectOptions()
+ let connectOptions = self.makeConnectOptions(stableID: gatewayStableID)
Task { [weak appModel] in
guard let appModel else { return }
@@ -244,13 +335,17 @@ final class GatewayConnectionController {
return nil
}
- private func resolveManualTLSParams(stableID: String, tlsEnabled: Bool) -> GatewayTLSParams? {
+ private func resolveManualTLSParams(
+ stableID: String,
+ tlsEnabled: Bool,
+ allowTOFUReset: Bool = false) -> GatewayTLSParams?
+ {
let stored = GatewayTLSStore.loadFingerprint(stableID: stableID)
if tlsEnabled || stored != nil {
return GatewayTLSParams(
required: true,
expectedFingerprint: stored,
- allowTOFU: stored == nil,
+ allowTOFU: stored == nil || allowTOFUReset,
storeKey: stableID)
}
@@ -258,12 +353,12 @@ final class GatewayConnectionController {
}
private func resolveGatewayHost(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) -> String? {
- if let lanHost = gateway.lanHost?.trimmingCharacters(in: .whitespacesAndNewlines), !lanHost.isEmpty {
- return lanHost
- }
if let tailnet = gateway.tailnetDns?.trimmingCharacters(in: .whitespacesAndNewlines), !tailnet.isEmpty {
return tailnet
}
+ if let lanHost = gateway.lanHost?.trimmingCharacters(in: .whitespacesAndNewlines), !lanHost.isEmpty {
+ return lanHost
+ }
return nil
}
@@ -276,16 +371,20 @@ final class GatewayConnectionController {
return components.url
}
+ private func shouldForceTLS(host: String) -> Bool {
+ let trimmed = host.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
+ if trimmed.isEmpty { return false }
+ return trimmed.hasSuffix(".ts.net") || trimmed.hasSuffix(".ts.net.")
+ }
+
private func manualStableID(host: String, port: Int) -> String {
"manual|\(host.lowercased())|\(port)"
}
- private func makeConnectOptions() -> GatewayConnectOptions {
+ private func makeConnectOptions(stableID: String?) -> GatewayConnectOptions {
let defaults = UserDefaults.standard
let displayName = self.resolvedDisplayName(defaults: defaults)
- let manualClientId = defaults.string(forKey: "gateway.manual.clientId")?
- .trimmingCharacters(in: .whitespacesAndNewlines)
- let resolvedClientId = manualClientId?.isEmpty == false ? manualClientId! : "openclaw-ios"
+ let resolvedClientId = self.resolvedClientId(defaults: defaults, stableID: stableID)
return GatewayConnectOptions(
role: "node",
@@ -298,6 +397,31 @@ final class GatewayConnectionController {
clientDisplayName: displayName)
}
+ private func resolvedClientId(defaults: UserDefaults, stableID: String?) -> String {
+ if let stableID,
+ let override = GatewaySettingsStore.loadGatewayClientIdOverride(stableID: stableID) {
+ return override
+ }
+ let manualClientId = defaults.string(forKey: "gateway.manual.clientId")?
+ .trimmingCharacters(in: .whitespacesAndNewlines)
+ if manualClientId?.isEmpty == false {
+ return manualClientId!
+ }
+ return "openclaw-ios"
+ }
+
+ private func resolveManualPort(host: String, port: Int, useTLS: Bool) -> Int? {
+ if port > 0 {
+ return port <= 65535 ? port : nil
+ }
+ let trimmedHost = host.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !trimmedHost.isEmpty else { return nil }
+ if useTLS && self.shouldForceTLS(host: trimmedHost) {
+ return 443
+ }
+ return 18789
+ }
+
private func resolvedDisplayName(defaults: UserDefaults) -> String {
let key = "node.displayName"
let existingRaw = defaults.string(forKey: key)
diff --git a/apps/ios/Sources/Gateway/GatewaySettingsStore.swift b/apps/ios/Sources/Gateway/GatewaySettingsStore.swift
index c48cf2af42..68a3eb0c40 100644
--- a/apps/ios/Sources/Gateway/GatewaySettingsStore.swift
+++ b/apps/ios/Sources/Gateway/GatewaySettingsStore.swift
@@ -13,6 +13,11 @@ enum GatewaySettingsStore {
private static let manualTlsDefaultsKey = "gateway.manual.tls"
private static let manualPasswordDefaultsKey = "gateway.manual.password"
private static let discoveryDebugLogsDefaultsKey = "gateway.discovery.debugLogs"
+ private static let lastGatewayHostDefaultsKey = "gateway.last.host"
+ private static let lastGatewayPortDefaultsKey = "gateway.last.port"
+ private static let lastGatewayTlsDefaultsKey = "gateway.last.tls"
+ private static let lastGatewayStableIDDefaultsKey = "gateway.last.stableID"
+ private static let clientIdOverrideDefaultsPrefix = "gateway.clientIdOverride."
private static let instanceIdAccount = "instanceId"
private static let preferredGatewayStableIDAccount = "preferredStableID"
@@ -109,6 +114,49 @@ enum GatewaySettingsStore {
account: self.gatewayPasswordAccount(instanceId: instanceId))
}
+ static func saveLastGatewayConnection(host: String, port: Int, useTLS: Bool, stableID: String) {
+ let defaults = UserDefaults.standard
+ defaults.set(host, forKey: self.lastGatewayHostDefaultsKey)
+ defaults.set(port, forKey: self.lastGatewayPortDefaultsKey)
+ defaults.set(useTLS, forKey: self.lastGatewayTlsDefaultsKey)
+ defaults.set(stableID, forKey: self.lastGatewayStableIDDefaultsKey)
+ }
+
+ static func loadLastGatewayConnection() -> (host: String, port: Int, useTLS: Bool, stableID: String)? {
+ let defaults = UserDefaults.standard
+ let host = defaults.string(forKey: self.lastGatewayHostDefaultsKey)?
+ .trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
+ let port = defaults.integer(forKey: self.lastGatewayPortDefaultsKey)
+ let useTLS = defaults.bool(forKey: self.lastGatewayTlsDefaultsKey)
+ let stableID = defaults.string(forKey: self.lastGatewayStableIDDefaultsKey)?
+ .trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
+
+ guard !host.isEmpty, port > 0, port <= 65535, !stableID.isEmpty else { return nil }
+ return (host: host, port: port, useTLS: useTLS, stableID: stableID)
+ }
+
+ static func loadGatewayClientIdOverride(stableID: String) -> String? {
+ let trimmedID = stableID.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !trimmedID.isEmpty else { return nil }
+ let key = self.clientIdOverrideDefaultsPrefix + trimmedID
+ let value = UserDefaults.standard.string(forKey: key)?
+ .trimmingCharacters(in: .whitespacesAndNewlines)
+ if value?.isEmpty == false { return value }
+ return nil
+ }
+
+ static func saveGatewayClientIdOverride(stableID: String, clientId: String?) {
+ let trimmedID = stableID.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !trimmedID.isEmpty else { return }
+ let key = self.clientIdOverrideDefaultsPrefix + trimmedID
+ let trimmedClientId = clientId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
+ if trimmedClientId.isEmpty {
+ UserDefaults.standard.removeObject(forKey: key)
+ } else {
+ UserDefaults.standard.set(trimmedClientId, forKey: key)
+ }
+ }
+
private static func gatewayTokenAccount(instanceId: String) -> String {
"gateway-token.\(instanceId)"
}
diff --git a/apps/ios/Sources/Info.plist b/apps/ios/Sources/Info.plist
index f70fcbd0df..00c26b9e9a 100644
--- a/apps/ios/Sources/Info.plist
+++ b/apps/ios/Sources/Info.plist
@@ -41,16 +41,6 @@
OpenClaw uses your location when you allow location sharing.
NSMicrophoneUsageDescription
OpenClaw needs microphone access for voice wake.
- NSPhotoLibraryUsageDescription
- OpenClaw can read recent photos when requested via the gateway.
- NSContactsUsageDescription
- OpenClaw can access your contacts when requested via the gateway.
- NSCalendarsUsageDescription
- OpenClaw can read and add calendar events when requested via the gateway.
- NSRemindersUsageDescription
- OpenClaw can read and add reminders when requested via the gateway.
- NSMotionUsageDescription
- OpenClaw can read motion activity and pedometer data when requested via the gateway.
NSSpeechRecognitionUsageDescription
OpenClaw uses on-device speech recognition for voice wake.
UIApplicationSceneManifest
diff --git a/apps/ios/Sources/Model/NodeAppModel.swift b/apps/ios/Sources/Model/NodeAppModel.swift
index 625c8e7104..9db963a846 100644
--- a/apps/ios/Sources/Model/NodeAppModel.swift
+++ b/apps/ios/Sources/Model/NodeAppModel.swift
@@ -62,7 +62,7 @@ final class NodeAppModel {
private var gatewayTask: Task?
private var voiceWakeSyncTask: Task?
@ObservationIgnored private var cameraHUDDismissTask: Task?
- @ObservationIgnored private var capabilityRouter: NodeCapabilityRouter
+ @ObservationIgnored private lazy var capabilityRouter: NodeCapabilityRouter = self.buildCapabilityRouter()
private let gatewayHealthMonitor = GatewayHealthMonitor()
private let notificationCenter: NotificationCentering
let voiceWake = VoiceWakeManager()
@@ -111,8 +111,6 @@ final class NodeAppModel {
self.remindersService = remindersService
self.motionService = motionService
self.talkMode = talkMode
- self.capabilityRouter = NodeCapabilityRouter(handlers: [:])
- self.capabilityRouter = self.buildCapabilityRouter()
self.voiceWake.configure { [weak self] cmd in
guard let self else { return }
@@ -298,6 +296,9 @@ final class NodeAppModel {
self.gatewayTask = Task {
var attempt = 0
+ var currentOptions = connectOptions
+ var didFallbackClientId = false
+ let trimmedStableID = gatewayStableID.trimmingCharacters(in: .whitespacesAndNewlines)
while !Task.isCancelled {
await MainActor.run {
if attempt == 0 {
@@ -314,7 +315,7 @@ final class NodeAppModel {
url: url,
token: token,
password: password,
- connectOptions: connectOptions,
+ connectOptions: currentOptions,
sessionBox: sessionBox,
onConnected: { [weak self] in
guard let self else { return }
@@ -363,6 +364,23 @@ final class NodeAppModel {
try? await Task.sleep(nanoseconds: 1_000_000_000)
} catch {
if Task.isCancelled { break }
+ if !didFallbackClientId,
+ let fallbackClientId = self.legacyClientIdFallback(
+ currentClientId: currentOptions.clientId,
+ error: error)
+ {
+ didFallbackClientId = true
+ currentOptions.clientId = fallbackClientId
+ if !trimmedStableID.isEmpty {
+ GatewaySettingsStore.saveGatewayClientIdOverride(
+ stableID: trimmedStableID,
+ clientId: fallbackClientId)
+ }
+ await MainActor.run {
+ self.gatewayStatusText = "Gateway rejected client id. Retrying…"
+ }
+ continue
+ }
attempt += 1
await MainActor.run {
self.gatewayStatusText = "Gateway error: \(error.localizedDescription)"
@@ -394,6 +412,16 @@ final class NodeAppModel {
}
}
+ private func legacyClientIdFallback(currentClientId: String, error: Error) -> String? {
+ let normalizedClientId = currentClientId.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
+ guard normalizedClientId == "openclaw-ios" else { return nil }
+ let message = error.localizedDescription.lowercased()
+ guard message.contains("invalid connect params"), message.contains("/client/id") else {
+ return nil
+ }
+ return "moltbot-ios"
+ }
+
func disconnectGateway() {
self.gatewayTask?.cancel()
self.gatewayTask = nil
@@ -507,7 +535,10 @@ final class NodeAppModel {
guard let self else { return false }
do {
let data = try await self.gateway.request(method: "health", paramsJSON: nil, timeoutSeconds: 6)
- return (try? JSONDecoder().decode(OpenClawGatewayHealthOK.self, from: data))?.ok ?? true
+ guard let decoded = try? JSONDecoder().decode(OpenClawGatewayHealthOK.self, from: data) else {
+ return false
+ }
+ return decoded.ok ?? false
} catch {
return false
}
diff --git a/apps/ios/Sources/Onboarding/GatewayOnboardingView.swift b/apps/ios/Sources/Onboarding/GatewayOnboardingView.swift
new file mode 100644
index 0000000000..df1372c740
--- /dev/null
+++ b/apps/ios/Sources/Onboarding/GatewayOnboardingView.swift
@@ -0,0 +1,311 @@
+import SwiftUI
+import UIKit
+
+struct GatewayOnboardingView: View {
+ @Environment(NodeAppModel.self) private var appModel: NodeAppModel
+ @Environment(GatewayConnectionController.self) private var gatewayController: GatewayConnectionController
+ @AppStorage("gateway.preferredStableID") private var preferredGatewayStableID: String = ""
+ @AppStorage("gateway.lastDiscoveredStableID") private var lastDiscoveredGatewayStableID: String = ""
+ @AppStorage("gateway.manual.enabled") private var manualGatewayEnabled: Bool = false
+ @AppStorage("gateway.manual.host") private var manualGatewayHost: String = ""
+ @AppStorage("gateway.manual.port") private var manualGatewayPort: Int = 18789
+ @AppStorage("gateway.manual.tls") private var manualGatewayTLS: Bool = true
+ @State private var connectStatusText: String?
+ @State private var connectingGatewayID: String?
+ @State private var showManualEntry: Bool = false
+ @State private var manualGatewayPortText: String = ""
+
+ var body: some View {
+ NavigationStack {
+ Form {
+ Section {
+ Text("Connect to your gateway to get started.")
+ LabeledContent("Discovery", value: self.gatewayController.discoveryStatusText)
+ LabeledContent("Status", value: self.appModel.gatewayStatusText)
+ }
+
+ Section("Gateways") {
+ self.gatewayList()
+ }
+
+ Section {
+ DisclosureGroup(isExpanded: self.$showManualEntry) {
+ TextField("Host", text: self.$manualGatewayHost)
+ .textInputAutocapitalization(.never)
+ .autocorrectionDisabled()
+
+ TextField("Port (optional)", text: self.manualPortBinding)
+ .keyboardType(.numberPad)
+
+ Toggle("Use TLS", isOn: self.$manualGatewayTLS)
+
+ Button {
+ Task { await self.connectManual() }
+ } label: {
+ if self.connectingGatewayID == "manual" {
+ HStack(spacing: 8) {
+ ProgressView()
+ .progressViewStyle(.circular)
+ Text("Connecting...")
+ }
+ } else {
+ Text("Connect manual gateway")
+ }
+ }
+ .disabled(self.connectingGatewayID != nil || self.manualGatewayHost
+ .trimmingCharacters(in: .whitespacesAndNewlines)
+ .isEmpty || !self.manualPortIsValid)
+
+ Button("Paste gateway URL") {
+ self.pasteGatewayURL()
+ }
+
+ Text(
+ "Use this when discovery is blocked. "
+ + "Leave port empty for 443 on tailnet DNS (TLS) or 18789 otherwise.")
+ .font(.footnote)
+ .foregroundStyle(.secondary)
+ } label: {
+ Text("Manual gateway")
+ }
+ }
+
+ if let text = self.connectStatusText {
+ Section {
+ Text(text)
+ .font(.footnote)
+ .foregroundStyle(.secondary)
+ }
+ }
+ }
+ .navigationTitle("Connect Gateway")
+ .onAppear {
+ self.syncManualPortText()
+ }
+ .onChange(of: self.manualGatewayPort) { _, _ in
+ self.syncManualPortText()
+ }
+ .onChange(of: self.appModel.gatewayServerName) { _, _ in
+ self.connectStatusText = nil
+ }
+ }
+ }
+
+ @ViewBuilder
+ private func gatewayList() -> some View {
+ if self.gatewayController.gateways.isEmpty {
+ VStack(alignment: .leading, spacing: 10) {
+ Text("No gateways found yet.")
+ .foregroundStyle(.secondary)
+ Text("Make sure you are on the same Wi-Fi as your gateway, or your tailnet DNS is set.")
+ .font(.footnote)
+ .foregroundStyle(.secondary)
+
+ if let lastKnown = GatewaySettingsStore.loadLastGatewayConnection() {
+ Button {
+ Task { await self.connectLastKnown() }
+ } label: {
+ self.lastKnownButtonLabel(host: lastKnown.host, port: lastKnown.port)
+ }
+ .disabled(self.connectingGatewayID != nil)
+ .buttonStyle(.borderedProminent)
+ .tint(self.appModel.seamColor)
+ }
+ }
+ } else {
+ ForEach(self.gatewayController.gateways) { gateway in
+ HStack {
+ VStack(alignment: .leading, spacing: 2) {
+ Text(gateway.name)
+ let detailLines = self.gatewayDetailLines(gateway)
+ ForEach(detailLines, id: \.self) { line in
+ Text(line)
+ .font(.footnote)
+ .foregroundStyle(.secondary)
+ }
+ }
+ Spacer()
+
+ Button {
+ Task { await self.connect(gateway) }
+ } label: {
+ if self.connectingGatewayID == gateway.id {
+ ProgressView()
+ .progressViewStyle(.circular)
+ } else {
+ Text("Connect")
+ }
+ }
+ .disabled(self.connectingGatewayID != nil)
+ }
+ }
+ }
+ }
+
+ private func connect(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) async {
+ self.connectingGatewayID = gateway.id
+ self.manualGatewayEnabled = false
+ self.preferredGatewayStableID = gateway.stableID
+ GatewaySettingsStore.savePreferredGatewayStableID(gateway.stableID)
+ self.lastDiscoveredGatewayStableID = gateway.stableID
+ GatewaySettingsStore.saveLastDiscoveredGatewayStableID(gateway.stableID)
+ defer { self.connectingGatewayID = nil }
+ await self.gatewayController.connect(gateway)
+ }
+
+ private func connectLastKnown() async {
+ self.connectingGatewayID = "last-known"
+ defer { self.connectingGatewayID = nil }
+ await self.gatewayController.connectLastKnown()
+ }
+
+ private var manualPortBinding: Binding {
+ Binding(
+ get: { self.manualGatewayPortText },
+ set: { newValue in
+ let filtered = newValue.filter(\.isNumber)
+ if self.manualGatewayPortText != filtered {
+ self.manualGatewayPortText = filtered
+ }
+ if filtered.isEmpty {
+ if self.manualGatewayPort != 0 {
+ self.manualGatewayPort = 0
+ }
+ } else if let port = Int(filtered), self.manualGatewayPort != port {
+ self.manualGatewayPort = port
+ }
+ })
+ }
+
+ private var manualPortIsValid: Bool {
+ if self.manualGatewayPortText.isEmpty { return true }
+ return self.manualGatewayPort >= 1 && self.manualGatewayPort <= 65535
+ }
+
+ private func syncManualPortText() {
+ if self.manualGatewayPort > 0 {
+ let next = String(self.manualGatewayPort)
+ if self.manualGatewayPortText != next {
+ self.manualGatewayPortText = next
+ }
+ } else if !self.manualGatewayPortText.isEmpty {
+ self.manualGatewayPortText = ""
+ }
+ }
+
+ @ViewBuilder
+ private func lastKnownButtonLabel(host: String, port: Int) -> some View {
+ if self.connectingGatewayID == "last-known" {
+ HStack(spacing: 8) {
+ ProgressView()
+ .progressViewStyle(.circular)
+ Text("Connecting...")
+ }
+ .frame(maxWidth: .infinity)
+ } else {
+ HStack(spacing: 8) {
+ Image(systemName: "bolt.horizontal.circle.fill")
+ VStack(alignment: .leading, spacing: 2) {
+ Text("Connect last known")
+ Text("\(host):\(port)")
+ .font(.footnote)
+ .foregroundStyle(.secondary)
+ }
+ Spacer()
+ }
+ .frame(maxWidth: .infinity)
+ }
+ }
+
+ private func connectManual() async {
+ let host = self.manualGatewayHost.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !host.isEmpty else {
+ self.connectStatusText = "Failed: host required"
+ return
+ }
+ guard self.manualPortIsValid else {
+ self.connectStatusText = "Failed: invalid port"
+ return
+ }
+
+ self.connectingGatewayID = "manual"
+ self.manualGatewayEnabled = true
+ defer { self.connectingGatewayID = nil }
+
+ await self.gatewayController.connectManual(
+ host: host,
+ port: self.manualGatewayPort,
+ useTLS: self.manualGatewayTLS)
+ }
+
+ private func pasteGatewayURL() {
+ guard let text = UIPasteboard.general.string else {
+ self.connectStatusText = "Clipboard is empty."
+ return
+ }
+ if self.applyGatewayInput(text) {
+ self.connectStatusText = nil
+ self.showManualEntry = true
+ } else {
+ self.connectStatusText = "Could not parse gateway URL."
+ }
+ }
+
+ private func applyGatewayInput(_ text: String) -> Bool {
+ let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !trimmed.isEmpty else { return false }
+
+ if let components = URLComponents(string: trimmed),
+ let host = components.host?.trimmingCharacters(in: .whitespacesAndNewlines),
+ !host.isEmpty
+ {
+ let scheme = components.scheme?.lowercased()
+ let defaultPort: Int = {
+ let hostLower = host.lowercased()
+ if (scheme == "wss" || scheme == "https"), hostLower.hasSuffix(".ts.net") {
+ return 443
+ }
+ return 18789
+ }()
+ let port = components.port ?? defaultPort
+ if scheme == "wss" || scheme == "https" {
+ self.manualGatewayTLS = true
+ } else if scheme == "ws" || scheme == "http" {
+ self.manualGatewayTLS = false
+ }
+ self.manualGatewayHost = host
+ self.manualGatewayPort = port
+ self.manualGatewayPortText = String(port)
+ return true
+ }
+
+ if let hostPort = SettingsNetworkingHelpers.parseHostPort(from: trimmed) {
+ self.manualGatewayHost = hostPort.host
+ self.manualGatewayPort = hostPort.port
+ self.manualGatewayPortText = String(hostPort.port)
+ return true
+ }
+
+ return false
+ }
+
+ private func gatewayDetailLines(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) -> [String] {
+ var lines: [String] = []
+ if let lanHost = gateway.lanHost { lines.append("LAN: \(lanHost)") }
+ if let tailnet = gateway.tailnetDns { lines.append("Tailnet: \(tailnet)") }
+
+ let gatewayPort = gateway.gatewayPort
+ let canvasPort = gateway.canvasPort
+ if gatewayPort != nil || canvasPort != nil {
+ let gw = gatewayPort.map(String.init) ?? "-"
+ let canvas = canvasPort.map(String.init) ?? "-"
+ lines.append("Ports: gateway \(gw) / canvas \(canvas)")
+ }
+
+ if lines.isEmpty {
+ lines.append(gateway.debugID)
+ }
+
+ return lines
+ }
+}
diff --git a/apps/ios/Sources/OpenClawApp.swift b/apps/ios/Sources/OpenClawApp.swift
index 8ad23ae20a..489c1ce78a 100644
--- a/apps/ios/Sources/OpenClawApp.swift
+++ b/apps/ios/Sources/OpenClawApp.swift
@@ -15,7 +15,7 @@ struct OpenClawApp: App {
var body: some Scene {
WindowGroup {
- RootCanvas()
+ RootView()
.environment(self.appModel)
.environment(self.appModel.voiceWake)
.environment(self.gatewayController)
diff --git a/apps/ios/Sources/RootView.swift b/apps/ios/Sources/RootView.swift
new file mode 100644
index 0000000000..5938e7f227
--- /dev/null
+++ b/apps/ios/Sources/RootView.swift
@@ -0,0 +1,46 @@
+import SwiftUI
+
+struct RootView: View {
+ @Environment(NodeAppModel.self) private var appModel: NodeAppModel
+ @AppStorage("gateway.onboardingComplete") private var onboardingComplete: Bool = false
+ @AppStorage("gateway.preferredStableID") private var preferredGatewayStableID: String = ""
+ @AppStorage("gateway.manual.enabled") private var manualGatewayEnabled: Bool = false
+ @AppStorage("gateway.manual.host") private var manualGatewayHost: String = ""
+
+ var body: some View {
+ Group {
+ if self.shouldShowOnboarding {
+ GatewayOnboardingView()
+ } else {
+ RootCanvas()
+ }
+ }
+ .onAppear { self.bootstrapOnboardingIfNeeded() }
+ .onChange(of: self.appModel.gatewayServerName) { _, newValue in
+ if newValue != nil {
+ self.onboardingComplete = true
+ }
+ }
+ }
+
+ private var shouldShowOnboarding: Bool {
+ if self.appModel.gatewayServerName != nil { return false }
+ if self.onboardingComplete { return false }
+ if self.hasExistingGatewayConfig { return false }
+ return true
+ }
+
+ private var hasExistingGatewayConfig: Bool {
+ if GatewaySettingsStore.loadLastGatewayConnection() != nil { return true }
+ let preferred = self.preferredGatewayStableID.trimmingCharacters(in: .whitespacesAndNewlines)
+ if !preferred.isEmpty { return true }
+ let manualHost = self.manualGatewayHost.trimmingCharacters(in: .whitespacesAndNewlines)
+ return self.manualGatewayEnabled && !manualHost.isEmpty
+ }
+
+ private func bootstrapOnboardingIfNeeded() {
+ if !self.onboardingComplete, self.hasExistingGatewayConfig {
+ self.onboardingComplete = true
+ }
+ }
+}
diff --git a/apps/ios/Sources/Settings/SettingsTab.swift b/apps/ios/Sources/Settings/SettingsTab.swift
index f5c808e275..d136725bcf 100644
--- a/apps/ios/Sources/Settings/SettingsTab.swift
+++ b/apps/ios/Sources/Settings/SettingsTab.swift
@@ -41,6 +41,7 @@ struct SettingsTab: View {
@State private var lastLocationModeRaw: String = OpenClawLocationMode.off.rawValue
@State private var gatewayToken: String = ""
@State private var gatewayPassword: String = ""
+ @State private var manualGatewayPortText: String = ""
var body: some View {
NavigationStack {
@@ -121,7 +122,7 @@ struct SettingsTab: View {
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
- TextField("Port", value: self.$manualGatewayPort, format: .number)
+ TextField("Port (optional)", text: self.manualPortBinding)
.keyboardType(.numberPad)
Toggle("Use TLS", isOn: self.$manualGatewayTLS)
@@ -141,11 +142,11 @@ struct SettingsTab: View {
}
.disabled(self.connectingGatewayID != nil || self.manualGatewayHost
.trimmingCharacters(in: .whitespacesAndNewlines)
- .isEmpty || self.manualGatewayPort <= 0 || self.manualGatewayPort > 65535)
+ .isEmpty || !self.manualPortIsValid)
Text(
"Use this when mDNS/Bonjour discovery is blocked. "
- + "The gateway WebSocket listens on port 18789 by default.")
+ + "Leave port empty for 443 on tailnet DNS (TLS) or 18789 otherwise.")
.font(.footnote)
.foregroundStyle(.secondary)
@@ -233,6 +234,7 @@ struct SettingsTab: View {
.onAppear {
self.localIPAddress = Self.primaryIPv4Address()
self.lastLocationModeRaw = self.locationEnabledModeRaw
+ self.syncManualPortText()
let trimmedInstanceId = self.instanceId.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmedInstanceId.isEmpty {
self.gatewayToken = GatewaySettingsStore.loadGatewayToken(instanceId: trimmedInstanceId) ?? ""
@@ -256,6 +258,9 @@ struct SettingsTab: View {
guard !instanceId.isEmpty else { return }
GatewaySettingsStore.saveGatewayPassword(trimmed, instanceId: instanceId)
}
+ .onChange(of: self.manualGatewayPort) { _, _ in
+ self.syncManualPortText()
+ }
.onChange(of: self.appModel.gatewayServerName) { _, _ in
self.connectStatus.text = nil
}
@@ -279,8 +284,24 @@ struct SettingsTab: View {
@ViewBuilder
private func gatewayList(showing: GatewayListMode) -> some View {
if self.gatewayController.gateways.isEmpty {
- Text("No gateways found yet.")
- .foregroundStyle(.secondary)
+ VStack(alignment: .leading, spacing: 12) {
+ Text("No gateways found yet.")
+ .foregroundStyle(.secondary)
+ Text("If your gateway is on another network, connect it and ensure DNS is working.")
+ .font(.footnote)
+ .foregroundStyle(.secondary)
+
+ if let lastKnown = GatewaySettingsStore.loadLastGatewayConnection() {
+ Button {
+ Task { await self.connectLastKnown() }
+ } label: {
+ self.lastKnownButtonLabel(host: lastKnown.host, port: lastKnown.port)
+ }
+ .disabled(self.connectingGatewayID != nil)
+ .buttonStyle(.borderedProminent)
+ .tint(self.appModel.seamColor)
+ }
+ }
} else {
let connectedID = self.appModel.connectedGatewayID
let rows = self.gatewayController.gateways.filter { gateway in
@@ -378,13 +399,77 @@ struct SettingsTab: View {
await self.gatewayController.connect(gateway)
}
+ private func connectLastKnown() async {
+ self.connectingGatewayID = "last-known"
+ defer { self.connectingGatewayID = nil }
+ await self.gatewayController.connectLastKnown()
+ }
+
+ @ViewBuilder
+ private func lastKnownButtonLabel(host: String, port: Int) -> some View {
+ if self.connectingGatewayID == "last-known" {
+ HStack(spacing: 8) {
+ ProgressView()
+ .progressViewStyle(.circular)
+ Text("Connecting…")
+ }
+ .frame(maxWidth: .infinity)
+ } else {
+ HStack(spacing: 8) {
+ Image(systemName: "bolt.horizontal.circle.fill")
+ VStack(alignment: .leading, spacing: 2) {
+ Text("Connect last known")
+ Text("\(host):\(port)")
+ .font(.footnote)
+ .foregroundStyle(.secondary)
+ }
+ Spacer()
+ }
+ .frame(maxWidth: .infinity)
+ }
+ }
+
+ private var manualPortBinding: Binding {
+ Binding(
+ get: { self.manualGatewayPortText },
+ set: { newValue in
+ let filtered = newValue.filter(\.isNumber)
+ if self.manualGatewayPortText != filtered {
+ self.manualGatewayPortText = filtered
+ }
+ if filtered.isEmpty {
+ if self.manualGatewayPort != 0 {
+ self.manualGatewayPort = 0
+ }
+ } else if let port = Int(filtered), self.manualGatewayPort != port {
+ self.manualGatewayPort = port
+ }
+ })
+ }
+
+ private var manualPortIsValid: Bool {
+ if self.manualGatewayPortText.isEmpty { return true }
+ return self.manualGatewayPort >= 1 && self.manualGatewayPort <= 65535
+ }
+
+ private func syncManualPortText() {
+ if self.manualGatewayPort > 0 {
+ let next = String(self.manualGatewayPort)
+ if self.manualGatewayPortText != next {
+ self.manualGatewayPortText = next
+ }
+ } else if !self.manualGatewayPortText.isEmpty {
+ self.manualGatewayPortText = ""
+ }
+ }
+
private func connectManual() async {
let host = self.manualGatewayHost.trimmingCharacters(in: .whitespacesAndNewlines)
guard !host.isEmpty else {
self.connectStatus.text = "Failed: host required"
return
}
- guard self.manualGatewayPort > 0, self.manualGatewayPort <= 65535 else {
+ guard self.manualPortIsValid else {
self.connectStatus.text = "Failed: invalid port"
return
}
diff --git a/apps/ios/Sources/Voice/TalkModeManager.swift b/apps/ios/Sources/Voice/TalkModeManager.swift
index d7038f67b6..06b6c4085c 100644
--- a/apps/ios/Sources/Voice/TalkModeManager.swift
+++ b/apps/ios/Sources/Voice/TalkModeManager.swift
@@ -451,7 +451,8 @@ final class TalkModeManager: NSObject {
private func handleTranscript(transcript: String, isFinal: Bool) async {
let trimmed = transcript.trimmingCharacters(in: .whitespacesAndNewlines)
- if self.isSpeaking, self.interruptOnSpeech {
+ let ttsActive = self.isSpeechOutputActive
+ if ttsActive, self.interruptOnSpeech {
if self.shouldInterrupt(with: trimmed) {
self.stopSpeaking()
}
@@ -470,7 +471,7 @@ final class TalkModeManager: NSObject {
_ = await self.endPushToTalk()
return
}
- if self.captureMode == .continuous, !self.isSpeaking {
+ if self.captureMode == .continuous, !self.isSpeechOutputActive {
await self.processTranscript(trimmed, restartAfter: true)
}
}
@@ -489,7 +490,7 @@ final class TalkModeManager: NSObject {
private func checkSilence() async {
if self.captureMode == .continuous {
- guard self.isListening, !self.isSpeaking else { return }
+ guard self.isListening, !self.isSpeechOutputActive else { return }
let transcript = self.lastTranscript.trimmingCharacters(in: .whitespacesAndNewlines)
guard !transcript.isEmpty else { return }
guard let lastHeard else { return }
@@ -895,16 +896,22 @@ final class TalkModeManager: NSObject {
}
private func stopSpeaking(storeInterruption: Bool = true) {
- guard self.isSpeaking else { return }
- let interruptedAt = self.lastPlaybackWasPCM
- ? self.pcmPlayer.stop()
- : self.mp3Player.stop()
- if storeInterruption {
- self.lastInterruptedAtSeconds = interruptedAt
+ let hasIncremental = self.incrementalSpeechActive ||
+ self.incrementalSpeechTask != nil ||
+ !self.incrementalSpeechQueue.isEmpty
+ if self.isSpeaking {
+ let interruptedAt = self.lastPlaybackWasPCM
+ ? self.pcmPlayer.stop()
+ : self.mp3Player.stop()
+ if storeInterruption {
+ self.lastInterruptedAtSeconds = interruptedAt
+ }
+ _ = self.lastPlaybackWasPCM
+ ? self.mp3Player.stop()
+ : self.pcmPlayer.stop()
+ } else if !hasIncremental {
+ return
}
- _ = self.lastPlaybackWasPCM
- ? self.mp3Player.stop()
- : self.pcmPlayer.stop()
TalkSystemSpeechSynthesizer.shared.stop()
self.cancelIncrementalSpeech()
self.isSpeaking = false
@@ -923,6 +930,13 @@ final class TalkModeManager: NSObject {
true
}
+ private var isSpeechOutputActive: Bool {
+ self.isSpeaking ||
+ self.incrementalSpeechActive ||
+ self.incrementalSpeechTask != nil ||
+ !self.incrementalSpeechQueue.isEmpty
+ }
+
private func applyDirective(_ directive: TalkDirective?) {
let requestedVoice = directive?.voiceId?.trimmingCharacters(in: .whitespacesAndNewlines)
let resolvedVoice = self.resolveVoiceAlias(requestedVoice)
@@ -1348,15 +1362,33 @@ private struct IncrementalSpeechBuffer {
if newText.hasPrefix(self.latestText) {
self.latestText = newText
} else if self.latestText.hasPrefix(newText) {
- // Keep the longer cached text.
+ // Stream reset or correction; prefer the newer prefix.
+ self.latestText = newText
+ self.spokenOffset = min(self.spokenOffset, newText.count)
} else {
- self.latestText += newText
+ // Diverged text means chunks arrived out of order or stream restarted.
+ let commonPrefix = Self.commonPrefixCount(self.latestText, newText)
+ self.latestText = newText
+ if self.spokenOffset > commonPrefix {
+ self.spokenOffset = commonPrefix
+ }
}
if self.spokenOffset > self.latestText.count {
self.spokenOffset = self.latestText.count
}
}
+ private static func commonPrefixCount(_ lhs: String, _ rhs: String) -> Int {
+ let left = Array(lhs)
+ let right = Array(rhs)
+ let limit = min(left.count, right.count)
+ var idx = 0
+ while idx < limit, left[idx] == right[idx] {
+ idx += 1
+ }
+ return idx
+ }
+
private mutating func extractSegments(isFinal: Bool) -> [String] {
let chars = Array(self.latestText)
guard self.spokenOffset < chars.count else { return [] }