From c1d58ae357bf2514f1b3dc9a4192cafc5d64a246 Mon Sep 17 00:00:00 2001 From: juliosuas Date: Mon, 6 Apr 2026 05:23:15 -0600 Subject: [PATCH] feat: complete remote gateway support for iOS + Android (fixes #35) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Enables VisionClaw to work outside the local network — critical for Talent Land demos and any use case where phone is on mobile data. ## How it works 1. New "Remote URL" field in Settings (e.g. Tailscale IP or public URL) 2. On session start, checkConnection() tries Remote URL first, then falls back to local Host — first reachable wins 3. Resolved URL is cached for the session (no re-probing per tool call) 4. Both OpenClawBridge AND OpenClawEventClient use the resolved URL 5. If the remote fails mid-session, cache is invalidated and next call re-resolves ## Changes (both platforms) ### OpenClawBridge - New resolveGatewayBaseURL with proper 2-step health check (/health + /v1/chat/completions) and specific error messages - Cached resolved URL — no latency penalty per tool call - Auto-invalidation on network errors (triggers re-resolve) - x-openclaw-scopes: operator.write header added ### OpenClawEventClient - New overrideBaseURL property — uses resolved gateway URL - Proper ws:// → wss:// upgrade when host uses https:// ### SettingsManager + SettingsView/SettingsScreen - New openClawRemoteURL setting with UI field on both platforms - Clear caption: "For use outside your home Wi-Fi" ### GeminiSessionViewModel - Passes resolved gateway URL to EventClient on connect ## Demo setup (Talent Land) 1. Install Tailscale on Mac + phone 2. In Settings → Remote URL: http://100.x.x.x:18789 3. Works on any network — mobile data, venue Wi-Fi, anything Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Gemini/GeminiSessionViewModel.swift | 2 + .../OpenClaw/OpenClawBridge.swift | 147 +++++++++++++++--- .../OpenClaw/OpenClawEventClient.swift | 25 ++- .../Settings/SettingsManager.swift | 9 +- .../CameraAccess/Settings/SettingsView.swift | 17 ++ .../gemini/GeminiSessionViewModel.kt | 2 + .../cameraaccess/openclaw/OpenClawBridge.kt | 131 +++++++++++++--- .../openclaw/OpenClawEventClient.kt | 18 ++- .../cameraaccess/settings/SettingsManager.kt | 5 + .../cameraaccess/ui/SettingsScreen.kt | 15 ++ 10 files changed, 310 insertions(+), 61 deletions(-) diff --git a/samples/CameraAccess/CameraAccess/Gemini/GeminiSessionViewModel.swift b/samples/CameraAccess/CameraAccess/Gemini/GeminiSessionViewModel.swift index e7d9d902..83e41631 100644 --- a/samples/CameraAccess/CameraAccess/Gemini/GeminiSessionViewModel.swift +++ b/samples/CameraAccess/CameraAccess/Gemini/GeminiSessionViewModel.swift @@ -172,6 +172,8 @@ class GeminiSessionViewModel: ObservableObject { self.geminiService.sendTextMessage(text) } } + // Pass the resolved gateway URL so EventClient works on remote networks too + eventClient.overrideBaseURL = openClawBridge.resolvedGatewayBaseURL eventClient.connect() } } diff --git a/samples/CameraAccess/CameraAccess/OpenClaw/OpenClawBridge.swift b/samples/CameraAccess/CameraAccess/OpenClaw/OpenClawBridge.swift index 1f48ac6f..ed914a79 100644 --- a/samples/CameraAccess/CameraAccess/OpenClaw/OpenClawBridge.swift +++ b/samples/CameraAccess/CameraAccess/OpenClaw/OpenClawBridge.swift @@ -7,10 +7,17 @@ enum OpenClawConnectionState: Equatable { case unreachable(String) } +enum GatewayMode: Equatable { + case local + case remote + case none +} + @MainActor class OpenClawBridge: ObservableObject { @Published var lastToolCallStatus: ToolCallStatus = .idle @Published var connectionState: OpenClawConnectionState = .notConfigured + @Published var gatewayMode: GatewayMode = .none private let session: URLSession private let pingSession: URLSession @@ -18,6 +25,9 @@ class OpenClawBridge: ObservableObject { private var conversationHistory: [[String: String]] = [] private let maxHistoryTurns = 10 + /// Cached resolved base URL — set once during checkConnection(), reused by delegateTask() + private var resolvedBaseURL: String? + private static let stableSessionKey = "agent:main:glass" init() { @@ -32,40 +42,71 @@ class OpenClawBridge: ObservableObject { self.sessionKey = OpenClawBridge.stableSessionKey } + // MARK: - Connection check with remote fallback + func checkConnection() async { guard GeminiConfig.isOpenClawConfigured else { connectionState = .notConfigured + gatewayMode = .none + resolvedBaseURL = nil return } connectionState = .checking - guard let url = URL(string: "\(GeminiConfig.openClawHost):\(GeminiConfig.openClawPort)/v1/chat/completions") else { - connectionState = .unreachable("Invalid URL") - return + + // Build candidate URLs: remote first (Tailscale/public), then local + var candidates: [(String, GatewayMode)] = [] + + let remote = SettingsManager.shared.openClawRemoteURL + if !remote.isEmpty { + candidates.append((remote, .remote)) } - 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) { + + let local = "\(GeminiConfig.openClawHost):\(GeminiConfig.openClawPort)" + candidates.append((local, .local)) + + for (baseURL, mode) in candidates { + let result = await probeGateway(baseURL) + switch result { + case .reachable: + resolvedBaseURL = baseURL + gatewayMode = mode connectionState = .connected - NSLog("[OpenClaw] Gateway reachable (HTTP %d)", http.statusCode) - } else { - connectionState = .unreachable("Unexpected response") + NSLog("[OpenClaw] Connected via %@ → %@", mode == .remote ? "REMOTE" : "LOCAL", baseURL) + return + case .authFailed(let msg): + // Auth issues apply to all candidates — stop trying + resolvedBaseURL = nil + gatewayMode = .none + connectionState = .unreachable(msg) + return + case .endpointDisabled: + resolvedBaseURL = nil + gatewayMode = .none + connectionState = .unreachable("chatCompletions endpoint disabled — enable it in openclaw.json") + return + case .unreachable: + // Try next candidate + continue } - } catch { - connectionState = .unreachable(error.localizedDescription) - NSLog("[OpenClaw] Gateway unreachable: %@", error.localizedDescription) } + + // All candidates failed + resolvedBaseURL = nil + gatewayMode = .none + let tried = candidates.map { $0.0 }.joined(separator: ", ") + connectionState = .unreachable("No reachable gateway (tried: \(tried))") + NSLog("[OpenClaw] All gateway endpoints unreachable") } + /// The resolved gateway base URL for use by EventClient and other components. + var resolvedGatewayBaseURL: String? { resolvedBaseURL } + func resetSession() { conversationHistory = [] NSLog("[OpenClaw] Session reset (key retained: %@)", sessionKey) } - // MARK: - Agent Chat (session continuity via x-openclaw-session-key header) + // MARK: - Agent Chat func delegateTask( task: String, @@ -73,15 +114,15 @@ 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") + // Use cached URL — no re-resolution per call (fast for demo) + guard let baseURL = resolvedBaseURL, + let url = URL(string: "\(baseURL)/v1/chat/completions") else { + lastToolCallStatus = .failed(toolName, "No reachable gateway") + return .failure("Gateway not connected. Check Settings → OpenClaw.") } - // Append the new user message to conversation history conversationHistory.append(["role": "user", "content": task]) - // Trim history to keep only the most recent turns (user+assistant pairs) if conversationHistory.count > maxHistoryTurns * 2 { conversationHistory = Array(conversationHistory.suffix(maxHistoryTurns * 2)) } @@ -92,6 +133,7 @@ class OpenClawBridge: ObservableObject { request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.setValue(sessionKey, forHTTPHeaderField: "x-openclaw-session-key") request.setValue("glass", forHTTPHeaderField: "x-openclaw-message-channel") + request.setValue("operator.write", forHTTPHeaderField: "x-openclaw-scopes") let body: [String: Any] = [ "model": "openclaw", @@ -99,7 +141,7 @@ class OpenClawBridge: ObservableObject { "stream": false ] - NSLog("[OpenClaw] Sending %d messages in conversation", conversationHistory.count) + NSLog("[OpenClaw] Sending %d messages via %@", conversationHistory.count, baseURL) do { request.httpBody = try JSONSerialization.data(withJSONObject: body) @@ -110,6 +152,12 @@ class OpenClawBridge: ObservableObject { let code = httpResponse?.statusCode ?? 0 let bodyStr = String(data: data, encoding: .utf8) ?? "no body" NSLog("[OpenClaw] Chat failed: HTTP %d - %@", code, String(bodyStr.prefix(200))) + + // If remote fails, try re-resolving on next call + if code == 0 || code >= 500 { + resolvedBaseURL = nil + } + lastToolCallStatus = .failed(toolName, "HTTP \(code)") return .failure("Agent returned HTTP \(code)") } @@ -119,7 +167,6 @@ class OpenClawBridge: ObservableObject { let first = choices.first, let message = first["message"] as? [String: Any], let content = message["content"] as? String { - // Append assistant response to history for continuity conversationHistory.append(["role": "assistant", "content": content]) NSLog("[OpenClaw] Agent result: %@", String(content.prefix(200))) lastToolCallStatus = .completed(toolName) @@ -128,13 +175,63 @@ class OpenClawBridge: ObservableObject { let raw = String(data: data, encoding: .utf8) ?? "OK" conversationHistory.append(["role": "assistant", "content": raw]) - NSLog("[OpenClaw] Agent raw: %@", String(raw.prefix(200))) lastToolCallStatus = .completed(toolName) return .success(raw) } catch { NSLog("[OpenClaw] Agent error: %@", error.localizedDescription) + // Network error — invalidate cache so next call re-resolves + resolvedBaseURL = nil lastToolCallStatus = .failed(toolName, error.localizedDescription) return .failure("Agent error: \(error.localizedDescription)") } } + + // MARK: - Private + + private enum ProbeResult { + case reachable + case authFailed(String) + case endpointDisabled + case unreachable + } + + private func probeGateway(_ baseURL: String) async -> ProbeResult { + // Step 1: Health check + guard let healthURL = URL(string: "\(baseURL)/health") else { return .unreachable } + var healthReq = URLRequest(url: healthURL) + healthReq.httpMethod = "GET" + do { + let (_, resp) = try await pingSession.data(for: healthReq) + if let http = resp as? HTTPURLResponse, !(200...299).contains(http.statusCode) { + return .unreachable + } + } catch { + return .unreachable + } + + // Step 2: Verify chat completions endpoint + guard let chatURL = URL(string: "\(baseURL)/v1/chat/completions") else { return .unreachable } + var chatReq = URLRequest(url: chatURL) + chatReq.httpMethod = "GET" + chatReq.setValue("Bearer \(GeminiConfig.openClawGatewayToken)", forHTTPHeaderField: "Authorization") + chatReq.setValue("glass", forHTTPHeaderField: "x-openclaw-message-channel") + do { + let (_, resp) = try await pingSession.data(for: chatReq) + if let http = resp as? HTTPURLResponse { + switch http.statusCode { + case 200...299, 405: + return .reachable + case 401, 403: + return .authFailed("Authentication failed (HTTP \(http.statusCode)) — check your gateway token") + case 404: + return .endpointDisabled + default: + return .unreachable + } + } + } catch { + return .unreachable + } + return .unreachable + } } diff --git a/samples/CameraAccess/CameraAccess/OpenClaw/OpenClawEventClient.swift b/samples/CameraAccess/CameraAccess/OpenClaw/OpenClawEventClient.swift index 8ceeef59..50b4efb9 100644 --- a/samples/CameraAccess/CameraAccess/OpenClaw/OpenClawEventClient.swift +++ b/samples/CameraAccess/CameraAccess/OpenClaw/OpenClawEventClient.swift @@ -10,6 +10,9 @@ class OpenClawEventClient { private var reconnectDelay: TimeInterval = 2 private let maxReconnectDelay: TimeInterval = 30 + /// Optional override base URL — set by GeminiSessionViewModel from the resolved gateway. + var overrideBaseURL: String? + func connect() { guard GeminiConfig.isOpenClawConfigured else { NSLog("[OpenClawWS] Not configured, skipping") @@ -34,12 +37,22 @@ class OpenClawEventClient { // MARK: - Private private func establishConnection() { - let host = GeminiConfig.openClawHost - .replacingOccurrences(of: "http://", with: "") - .replacingOccurrences(of: "https://", with: "") - let port = GeminiConfig.openClawPort - guard let url = URL(string: "ws://\(host):\(port)") else { - NSLog("[OpenClawWS] Invalid URL") + // Use resolved gateway URL if available (supports remote/Tailscale), else fall back to local config + let baseURL: String + if let override = overrideBaseURL, !override.isEmpty { + baseURL = override + } else { + baseURL = "\(GeminiConfig.openClawHost):\(GeminiConfig.openClawPort)" + } + + let wsURL = baseURL + .replacingOccurrences(of: "https://", with: "wss://") + .replacingOccurrences(of: "http://", with: "ws://") + // If no scheme, prepend ws:// + let finalURL = wsURL.hasPrefix("ws://") || wsURL.hasPrefix("wss://") ? wsURL : "ws://\(wsURL)" + + guard let url = URL(string: finalURL) else { + NSLog("[OpenClawWS] Invalid URL: %@", finalURL) return } diff --git a/samples/CameraAccess/CameraAccess/Settings/SettingsManager.swift b/samples/CameraAccess/CameraAccess/Settings/SettingsManager.swift index 8d63a557..a2f7d5f5 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 case openClawHookToken case openClawGatewayToken case geminiSystemPrompt @@ -39,6 +40,12 @@ final class SettingsManager { set { defaults.set(newValue, forKey: Key.openClawHost.rawValue) } } + /// Remote gateway URL (Tailscale IP or public URL). When set, tried before local. + 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) @@ -89,7 +96,7 @@ final class SettingsManager { func resetAll() { for key in [Key.geminiAPIKey, .geminiSystemPrompt, .openClawHost, .openClawPort, - .openClawHookToken, .openClawGatewayToken, .webrtcSignalingURL, + .openClawRemoteURL, .openClawHookToken, .openClawGatewayToken, .webrtcSignalingURL, .speakerOutputEnabled, .videoStreamingEnabled, .proactiveNotificationsEnabled] { defaults.removeObject(forKey: key.rawValue) diff --git a/samples/CameraAccess/CameraAccess/Settings/SettingsView.swift b/samples/CameraAccess/CameraAccess/Settings/SettingsView.swift index 8e22fe33..7e181330 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 = "" @@ -58,6 +59,20 @@ struct SettingsView: View { .font(.system(.body, design: .monospaced)) } + VStack(alignment: .leading, spacing: 4) { + Text("Remote URL (Tailscale / Public)") + .font(.caption) + .foregroundColor(.secondary) + Text("For use outside your home Wi-Fi. Tried first; falls back to local Host above.") + .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("Hook Token") .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) diff --git a/samples/CameraAccessAndroid/app/src/main/java/com/meta/wearable/dat/externalsampleapps/cameraaccess/gemini/GeminiSessionViewModel.kt b/samples/CameraAccessAndroid/app/src/main/java/com/meta/wearable/dat/externalsampleapps/cameraaccess/gemini/GeminiSessionViewModel.kt index 31567442..8e01479a 100644 --- a/samples/CameraAccessAndroid/app/src/main/java/com/meta/wearable/dat/externalsampleapps/cameraaccess/gemini/GeminiSessionViewModel.kt +++ b/samples/CameraAccessAndroid/app/src/main/java/com/meta/wearable/dat/externalsampleapps/cameraaccess/gemini/GeminiSessionViewModel.kt @@ -174,6 +174,8 @@ class GeminiSessionViewModel : ViewModel() { geminiService.sendTextMessage(text) } } + // Pass resolved gateway URL so EventClient works on remote networks too + eventClient.overrideBaseURL = openClawBridge.resolvedBaseURL eventClient.connect() } } diff --git a/samples/CameraAccessAndroid/app/src/main/java/com/meta/wearable/dat/externalsampleapps/cameraaccess/openclaw/OpenClawBridge.kt b/samples/CameraAccessAndroid/app/src/main/java/com/meta/wearable/dat/externalsampleapps/cameraaccess/openclaw/OpenClawBridge.kt index 4310ca8c..54d34387 100644 --- a/samples/CameraAccessAndroid/app/src/main/java/com/meta/wearable/dat/externalsampleapps/cameraaccess/openclaw/OpenClawBridge.kt +++ b/samples/CameraAccessAndroid/app/src/main/java/com/meta/wearable/dat/externalsampleapps/cameraaccess/openclaw/OpenClawBridge.kt @@ -2,6 +2,7 @@ package com.meta.wearable.dat.externalsampleapps.cameraaccess.openclaw import android.util.Log import com.meta.wearable.dat.externalsampleapps.cameraaccess.gemini.GeminiConfig +import com.meta.wearable.dat.externalsampleapps.cameraaccess.settings.SettingsManager import java.util.concurrent.TimeUnit import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow @@ -15,6 +16,8 @@ import okhttp3.RequestBody.Companion.toRequestBody import org.json.JSONArray import org.json.JSONObject +enum class GatewayMode { LOCAL, REMOTE, NONE } + class OpenClawBridge { companion object { private const val TAG = "OpenClawBridge" @@ -27,6 +30,9 @@ class OpenClawBridge { private val _connectionState = MutableStateFlow(OpenClawConnectionState.NotConfigured) val connectionState: StateFlow = _connectionState.asStateFlow() + private val _gatewayMode = MutableStateFlow(GatewayMode.NONE) + val gatewayMode: StateFlow = _gatewayMode.asStateFlow() + fun setToolCallStatus(status: ToolCallStatus) { _lastToolCallStatus.value = status } @@ -44,36 +50,65 @@ class OpenClawBridge { private var sessionKey: String = "agent:main:glass" private val conversationHistory = mutableListOf() + /** Cached resolved base URL — set once during checkConnection(), reused by delegateTask() */ + var resolvedBaseURL: String? = null + private set + suspend fun checkConnection() = withContext(Dispatchers.IO) { if (!GeminiConfig.isOpenClawConfigured) { _connectionState.value = OpenClawConnectionState.NotConfigured + _gatewayMode.value = GatewayMode.NONE + resolvedBaseURL = null return@withContext } _connectionState.value = OpenClawConnectionState.Checking - val url = "${GeminiConfig.openClawHost}:${GeminiConfig.openClawPort}/v1/chat/completions" - try { - val request = Request.Builder() - .url(url) - .get() - .addHeader("Authorization", "Bearer ${GeminiConfig.openClawGatewayToken}") - .addHeader("x-openclaw-message-channel", "glass") - .build() + // Build candidates: remote first, then local + data class Candidate(val url: String, val mode: GatewayMode) + val candidates = mutableListOf() - val response = pingClient.newCall(request).execute() - val code = response.code - response.close() + val remote = SettingsManager.openClawRemoteURL + if (remote.isNotEmpty()) { + candidates.add(Candidate(remote, GatewayMode.REMOTE)) + } - if (code in 200..499) { - _connectionState.value = OpenClawConnectionState.Connected - Log.d(TAG, "Gateway reachable (HTTP $code)") - } else { - _connectionState.value = OpenClawConnectionState.Unreachable("Unexpected response") + val local = "${GeminiConfig.openClawHost}:${GeminiConfig.openClawPort}" + candidates.add(Candidate(local, GatewayMode.LOCAL)) + + for (candidate in candidates) { + val result = probeGateway(candidate.url) + when (result) { + ProbeResult.REACHABLE -> { + resolvedBaseURL = candidate.url + _gatewayMode.value = candidate.mode + _connectionState.value = OpenClawConnectionState.Connected + Log.d(TAG, "Connected via ${candidate.mode} → ${candidate.url}") + return@withContext + } + is ProbeResult.AUTH_FAILED -> { + resolvedBaseURL = null + _gatewayMode.value = GatewayMode.NONE + _connectionState.value = OpenClawConnectionState.Unreachable(result.message) + return@withContext + } + ProbeResult.ENDPOINT_DISABLED -> { + resolvedBaseURL = null + _gatewayMode.value = GatewayMode.NONE + _connectionState.value = OpenClawConnectionState.Unreachable( + "chatCompletions endpoint disabled — enable it in openclaw.json" + ) + return@withContext + } + ProbeResult.UNREACHABLE -> continue } - } catch (e: Exception) { - _connectionState.value = OpenClawConnectionState.Unreachable(e.message ?: "Unknown error") - Log.d(TAG, "Gateway unreachable: ${e.message}") } + + // All failed + resolvedBaseURL = null + _gatewayMode.value = GatewayMode.NONE + val tried = candidates.joinToString { it.url } + _connectionState.value = OpenClawConnectionState.Unreachable("No reachable gateway (tried: $tried)") + Log.d(TAG, "All gateway endpoints unreachable") } fun resetSession() { @@ -87,22 +122,26 @@ class OpenClawBridge { ): ToolResult = withContext(Dispatchers.IO) { _lastToolCallStatus.value = ToolCallStatus.Executing(toolName) - val url = "${GeminiConfig.openClawHost}:${GeminiConfig.openClawPort}/v1/chat/completions" + val baseURL = resolvedBaseURL + if (baseURL == null) { + _lastToolCallStatus.value = ToolCallStatus.Failed(toolName, "No reachable gateway") + return@withContext ToolResult.Failure("Gateway not connected. Check Settings → OpenClaw.") + } + + val url = "$baseURL/v1/chat/completions" - // Append user message conversationHistory.add(JSONObject().apply { put("role", "user") put("content", task) }) - // Trim history if (conversationHistory.size > MAX_HISTORY_TURNS * 2) { val trimmed = conversationHistory.takeLast(MAX_HISTORY_TURNS * 2) conversationHistory.clear() conversationHistory.addAll(trimmed) } - Log.d(TAG, "Sending ${conversationHistory.size} messages in conversation") + Log.d(TAG, "Sending ${conversationHistory.size} messages via $baseURL") try { val messagesArray = JSONArray() @@ -123,6 +162,7 @@ class OpenClawBridge { .addHeader("Content-Type", "application/json") .addHeader("x-openclaw-session-key", sessionKey) .addHeader("x-openclaw-message-channel", "glass") + .addHeader("x-openclaw-scopes", "operator.write") .build() val response = client.newCall(request).execute() @@ -132,6 +172,7 @@ class OpenClawBridge { if (statusCode !in 200..299) { Log.d(TAG, "Chat failed: HTTP $statusCode - ${responseBody.take(200)}") + if (statusCode == 0 || statusCode >= 500) resolvedBaseURL = null _lastToolCallStatus.value = ToolCallStatus.Failed(toolName, "HTTP $statusCode") return@withContext ToolResult.Failure("Agent returned HTTP $statusCode") } @@ -156,14 +197,56 @@ class OpenClawBridge { put("role", "assistant") put("content", responseBody) }) - Log.d(TAG, "Agent raw: ${responseBody.take(200)}") _lastToolCallStatus.value = ToolCallStatus.Completed(toolName) return@withContext ToolResult.Success(responseBody) } catch (e: Exception) { Log.e(TAG, "Agent error: ${e.message}") + resolvedBaseURL = null _lastToolCallStatus.value = ToolCallStatus.Failed(toolName, e.message ?: "Unknown") return@withContext ToolResult.Failure("Agent error: ${e.message}") } } + // Private + + private sealed class ProbeResult { + data object REACHABLE : ProbeResult() + data class AUTH_FAILED(val message: String) : ProbeResult() + data object ENDPOINT_DISABLED : ProbeResult() + data object UNREACHABLE : ProbeResult() + } + + private fun probeGateway(baseURL: String): ProbeResult { + // Step 1: Health check + try { + val healthReq = Request.Builder().url("$baseURL/health").get().build() + val healthResp = pingClient.newCall(healthReq).execute() + val healthCode = healthResp.code + healthResp.close() + if (healthCode !in 200..299) return ProbeResult.UNREACHABLE + } catch (e: Exception) { + return ProbeResult.UNREACHABLE + } + + // Step 2: Verify chat completions endpoint + try { + val chatReq = Request.Builder() + .url("$baseURL/v1/chat/completions") + .get() + .addHeader("Authorization", "Bearer ${GeminiConfig.openClawGatewayToken}") + .addHeader("x-openclaw-message-channel", "glass") + .build() + val chatResp = pingClient.newCall(chatReq).execute() + val code = chatResp.code + chatResp.close() + return when (code) { + in 200..299, 405 -> ProbeResult.REACHABLE + 401, 403 -> ProbeResult.AUTH_FAILED("Authentication failed (HTTP $code) — check your gateway token") + 404 -> ProbeResult.ENDPOINT_DISABLED + else -> ProbeResult.UNREACHABLE + } + } catch (e: Exception) { + return ProbeResult.UNREACHABLE + } + } } diff --git a/samples/CameraAccessAndroid/app/src/main/java/com/meta/wearable/dat/externalsampleapps/cameraaccess/openclaw/OpenClawEventClient.kt b/samples/CameraAccessAndroid/app/src/main/java/com/meta/wearable/dat/externalsampleapps/cameraaccess/openclaw/OpenClawEventClient.kt index 0ff8981e..2eb3ecc9 100644 --- a/samples/CameraAccessAndroid/app/src/main/java/com/meta/wearable/dat/externalsampleapps/cameraaccess/openclaw/OpenClawEventClient.kt +++ b/samples/CameraAccessAndroid/app/src/main/java/com/meta/wearable/dat/externalsampleapps/cameraaccess/openclaw/OpenClawEventClient.kt @@ -22,6 +22,9 @@ class OpenClawEventClient { var onNotification: ((String) -> Unit)? = null + /** Set from GeminiSessionViewModel with the resolved gateway URL for remote support */ + var overrideBaseURL: String? = null + private var webSocket: WebSocket? = null private var isConnected = false private var shouldReconnect = false @@ -53,11 +56,16 @@ class OpenClawEventClient { } private fun establishConnection() { - val host = GeminiConfig.openClawHost - .replace("http://", "") - .replace("https://", "") - val port = GeminiConfig.openClawPort - val url = "ws://$host:$port" + // Use resolved gateway URL if available (supports remote/Tailscale), else fall back to local + val baseURL = if (!overrideBaseURL.isNullOrEmpty()) { + overrideBaseURL!! + } else { + "${GeminiConfig.openClawHost}:${GeminiConfig.openClawPort}" + } + val url = baseURL + .replace("https://", "wss://") + .replace("http://", "ws://") + .let { if (it.startsWith("ws://") || it.startsWith("wss://")) it else "ws://$it" } Log.d(TAG, "Connecting to $url") diff --git a/samples/CameraAccessAndroid/app/src/main/java/com/meta/wearable/dat/externalsampleapps/cameraaccess/settings/SettingsManager.kt b/samples/CameraAccessAndroid/app/src/main/java/com/meta/wearable/dat/externalsampleapps/cameraaccess/settings/SettingsManager.kt index dd8d2d26..666f45e8 100644 --- a/samples/CameraAccessAndroid/app/src/main/java/com/meta/wearable/dat/externalsampleapps/cameraaccess/settings/SettingsManager.kt +++ b/samples/CameraAccessAndroid/app/src/main/java/com/meta/wearable/dat/externalsampleapps/cameraaccess/settings/SettingsManager.kt @@ -25,6 +25,11 @@ object SettingsManager { get() = prefs.getString("openClawHost", null) ?: Secrets.openClawHost set(value) = prefs.edit().putString("openClawHost", value).apply() + /** Remote gateway URL (Tailscale IP or public URL). When set, tried before local. */ + var openClawRemoteURL: String + get() = prefs.getString("openClawRemoteURL", null) ?: "" + set(value) = prefs.edit().putString("openClawRemoteURL", value).apply() + var openClawPort: Int get() { val stored = prefs.getInt("openClawPort", 0) diff --git a/samples/CameraAccessAndroid/app/src/main/java/com/meta/wearable/dat/externalsampleapps/cameraaccess/ui/SettingsScreen.kt b/samples/CameraAccessAndroid/app/src/main/java/com/meta/wearable/dat/externalsampleapps/cameraaccess/ui/SettingsScreen.kt index dd913363..fdde2e20 100644 --- a/samples/CameraAccessAndroid/app/src/main/java/com/meta/wearable/dat/externalsampleapps/cameraaccess/ui/SettingsScreen.kt +++ b/samples/CameraAccessAndroid/app/src/main/java/com/meta/wearable/dat/externalsampleapps/cameraaccess/ui/SettingsScreen.kt @@ -46,6 +46,7 @@ fun SettingsScreen( var systemPrompt by remember { mutableStateOf(SettingsManager.geminiSystemPrompt) } var openClawHost by remember { mutableStateOf(SettingsManager.openClawHost) } var openClawPort by remember { mutableStateOf(SettingsManager.openClawPort.toString()) } + var openClawRemoteURL by remember { mutableStateOf(SettingsManager.openClawRemoteURL) } var openClawHookToken by remember { mutableStateOf(SettingsManager.openClawHookToken) } var openClawGatewayToken by remember { mutableStateOf(SettingsManager.openClawGatewayToken) } var webrtcSignalingURL by remember { mutableStateOf(SettingsManager.webrtcSignalingURL) } @@ -58,6 +59,7 @@ fun SettingsScreen( SettingsManager.geminiSystemPrompt = systemPrompt.trim() SettingsManager.openClawHost = openClawHost.trim() openClawPort.trim().toIntOrNull()?.let { SettingsManager.openClawPort = it } + SettingsManager.openClawRemoteURL = openClawRemoteURL.trim() SettingsManager.openClawHookToken = openClawHookToken.trim() SettingsManager.openClawGatewayToken = openClawGatewayToken.trim() SettingsManager.webrtcSignalingURL = webrtcSignalingURL.trim() @@ -70,6 +72,7 @@ fun SettingsScreen( systemPrompt = SettingsManager.geminiSystemPrompt openClawHost = SettingsManager.openClawHost openClawPort = SettingsManager.openClawPort.toString() + openClawRemoteURL = SettingsManager.openClawRemoteURL openClawHookToken = SettingsManager.openClawHookToken openClawGatewayToken = SettingsManager.openClawGatewayToken webrtcSignalingURL = SettingsManager.webrtcSignalingURL @@ -132,6 +135,18 @@ fun SettingsScreen( placeholder = "18789", keyboardType = KeyboardType.Number, ) + MonoTextField( + value = openClawRemoteURL, + onValueChange = { openClawRemoteURL = it }, + label = "Remote URL (Tailscale / Public)", + placeholder = "http://100.x.x.x:18789", + keyboardType = KeyboardType.Uri, + ) + Text( + "For use outside your home Wi-Fi. Tried first; falls back to local Host above.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) MonoTextField( value = openClawHookToken, onValueChange = { openClawHookToken = it },