diff --git a/samples/CameraAccess/CameraAccess/OpenClaw/OpenClawBridge.swift b/samples/CameraAccess/CameraAccess/OpenClaw/OpenClawBridge.swift index 1f48ac6f..6102c8d3 100644 --- a/samples/CameraAccess/CameraAccess/OpenClaw/OpenClawBridge.swift +++ b/samples/CameraAccess/CameraAccess/OpenClaw/OpenClawBridge.swift @@ -32,31 +32,48 @@ class OpenClawBridge: ObservableObject { self.sessionKey = OpenClawBridge.stableSessionKey } + // MARK: - Endpoint resolution + + /// Returns the first reachable gateway base URL, trying remote (Tailscale) before local. + /// - Returns: A reachable base URL string, or nil if all endpoints are unreachable. + private func resolveGatewayBaseURL() async -> String? { + var candidates: [String] = [] + + // 1. Remote URL (Tailscale / public) if configured + let remote = SettingsManager.shared.openClawRemoteURL + if !remote.isEmpty { candidates.append(remote) } + + // 2. Local network URL + let local = "\(GeminiConfig.openClawHost):\(GeminiConfig.openClawPort)" + candidates.append(local) + + for baseURL in candidates { + guard let url = URL(string: "\(baseURL)/health") else { continue } + var req = URLRequest(url: url) + req.httpMethod = "GET" + req.setValue("Bearer \(GeminiConfig.openClawGatewayToken)", forHTTPHeaderField: "Authorization") + if let (_, response) = try? await pingSession.data(for: req), + let http = response as? HTTPURLResponse, + (200...499).contains(http.statusCode) { + NSLog("[OpenClaw] Resolved gateway: %@", baseURL) + return baseURL + } + } + return nil + } + func checkConnection() async { guard GeminiConfig.isOpenClawConfigured else { connectionState = .notConfigured return } connectionState = .checking - guard let url = URL(string: "\(GeminiConfig.openClawHost):\(GeminiConfig.openClawPort)/v1/chat/completions") else { - connectionState = .unreachable("Invalid URL") - return - } - var request = URLRequest(url: url) - request.httpMethod = "GET" - request.setValue("Bearer \(GeminiConfig.openClawGatewayToken)", forHTTPHeaderField: "Authorization") - request.setValue("glass", forHTTPHeaderField: "x-openclaw-message-channel") - do { - let (_, response) = try await pingSession.data(for: request) - if let http = response as? HTTPURLResponse, (200...499).contains(http.statusCode) { - connectionState = .connected - NSLog("[OpenClaw] Gateway reachable (HTTP %d)", http.statusCode) - } else { - connectionState = .unreachable("Unexpected response") - } - } catch { - connectionState = .unreachable(error.localizedDescription) - NSLog("[OpenClaw] Gateway unreachable: %@", error.localizedDescription) + if let base = await resolveGatewayBaseURL() { + connectionState = .connected + NSLog("[OpenClaw] Gateway reachable at %@", base) + } else { + connectionState = .unreachable("No reachable gateway (tried local and remote)") + NSLog("[OpenClaw] All gateway endpoints unreachable") } } @@ -73,9 +90,10 @@ class OpenClawBridge: ObservableObject { ) async -> ToolResult { lastToolCallStatus = .executing(toolName) - guard let url = URL(string: "\(GeminiConfig.openClawHost):\(GeminiConfig.openClawPort)/v1/chat/completions") else { - lastToolCallStatus = .failed(toolName, "Invalid URL") - return .failure("Invalid gateway URL") + guard let baseURL = await resolveGatewayBaseURL(), + let url = URL(string: "\(baseURL)/v1/chat/completions") else { + lastToolCallStatus = .failed(toolName, "No reachable gateway") + return .failure("No reachable gateway (local and remote both unavailable)") } // Append the new user message to conversation history diff --git a/samples/CameraAccess/CameraAccess/Settings/SettingsManager.swift b/samples/CameraAccess/CameraAccess/Settings/SettingsManager.swift index 8d63a557..5bf23b3d 100644 --- a/samples/CameraAccess/CameraAccess/Settings/SettingsManager.swift +++ b/samples/CameraAccess/CameraAccess/Settings/SettingsManager.swift @@ -9,6 +9,7 @@ final class SettingsManager { case geminiAPIKey case openClawHost case openClawPort + case openClawRemoteURL // e.g. Tailscale URL, used when off local network case openClawHookToken case openClawGatewayToken case geminiSystemPrompt @@ -39,6 +40,13 @@ final class SettingsManager { set { defaults.set(newValue, forKey: Key.openClawHost.rawValue) } } + /// Optional remote URL (e.g. Tailscale) used when the local gateway is unreachable. + /// If set, OpenClawBridge will try this before falling back to openClawHost. + var openClawRemoteURL: String { + get { defaults.string(forKey: Key.openClawRemoteURL.rawValue) ?? "" } + set { defaults.set(newValue, forKey: Key.openClawRemoteURL.rawValue) } + } + var openClawPort: Int { get { let stored = defaults.integer(forKey: Key.openClawPort.rawValue) diff --git a/samples/CameraAccess/CameraAccess/Settings/SettingsView.swift b/samples/CameraAccess/CameraAccess/Settings/SettingsView.swift index 8e22fe33..41e3bb82 100644 --- a/samples/CameraAccess/CameraAccess/Settings/SettingsView.swift +++ b/samples/CameraAccess/CameraAccess/Settings/SettingsView.swift @@ -7,6 +7,7 @@ struct SettingsView: View { @State private var geminiAPIKey: String = "" @State private var openClawHost: String = "" @State private var openClawPort: String = "" + @State private var openClawRemoteURL: String = "" @State private var openClawHookToken: String = "" @State private var openClawGatewayToken: String = "" @State private var geminiSystemPrompt: String = "" @@ -49,6 +50,20 @@ struct SettingsView: View { .font(.system(.body, design: .monospaced)) } + VStack(alignment: .leading, spacing: 4) { + Text("Remote URL (Tailscale / Public)") + .font(.caption) + .foregroundColor(.secondary) + Text("Used when off local network. Leave empty to use local only.") + .font(.caption2) + .foregroundColor(.secondary) + TextField("http://100.x.x.x:18789", text: $openClawRemoteURL) + .autocapitalization(.none) + .disableAutocorrection(true) + .keyboardType(.URL) + .font(.system(.body, design: .monospaced)) + } + VStack(alignment: .leading, spacing: 4) { Text("Port") .font(.caption) @@ -147,6 +162,7 @@ struct SettingsView: View { geminiSystemPrompt = settings.geminiSystemPrompt openClawHost = settings.openClawHost openClawPort = String(settings.openClawPort) + openClawRemoteURL = settings.openClawRemoteURL openClawHookToken = settings.openClawHookToken openClawGatewayToken = settings.openClawGatewayToken webrtcSignalingURL = settings.webrtcSignalingURL @@ -162,6 +178,7 @@ struct SettingsView: View { if let port = Int(openClawPort.trimmingCharacters(in: .whitespacesAndNewlines)) { settings.openClawPort = port } + settings.openClawRemoteURL = openClawRemoteURL.trimmingCharacters(in: .whitespacesAndNewlines) settings.openClawHookToken = openClawHookToken.trimmingCharacters(in: .whitespacesAndNewlines) settings.openClawGatewayToken = openClawGatewayToken.trimmingCharacters(in: .whitespacesAndNewlines) settings.webrtcSignalingURL = webrtcSignalingURL.trimmingCharacters(in: .whitespacesAndNewlines)