Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
}
Expand Down
147 changes: 122 additions & 25 deletions samples/CameraAccess/CameraAccess/OpenClaw/OpenClawBridge.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,17 +7,27 @@ 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
private var sessionKey: String
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() {
Expand All @@ -32,56 +42,87 @@ 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,
toolName: String = "execute"
) 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))
}
Expand All @@ -92,14 +133,15 @@ 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",
"messages": conversationHistory,
"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)
Expand All @@ -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)")
}
Expand All @@ -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)
Expand All @@ -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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ final class SettingsManager {
case geminiAPIKey
case openClawHost
case openClawPort
case openClawRemoteURL
case openClawHookToken
case openClawGatewayToken
case geminiSystemPrompt
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
17 changes: 17 additions & 0 deletions samples/CameraAccess/CameraAccess/Settings/SettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 = ""
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
}
Expand Down
Loading