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 [] }