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 },