import Foundation import OpenClawProtocol import UniformTypeIdentifiers actor MacNodeBrowserProxy { static let shared = MacNodeBrowserProxy() struct Endpoint { let baseURL: URL let token: String? let password: String? } private struct RequestParams: Decodable { let method: String? let path: String? let query: [String: OpenClawProtocol.AnyCodable]? let body: OpenClawProtocol.AnyCodable? let timeoutMs: Int? let profile: String? } private struct ProxyFilePayload { let path: String let base64: String let mimeType: String? func asJSON() -> [String: Any] { var json: [String: Any] = [ "path": self.path, "base64": self.base64, ] if let mimeType = self.mimeType { json["mimeType"] = mimeType } return json } } private static let maxProxyFileBytes = 10 * 1024 * 1024 private let endpointProvider: @Sendable () -> Endpoint private let performRequest: @Sendable (URLRequest) async throws -> (Data, URLResponse) init( session: URLSession = .shared, endpointProvider: (@Sendable () -> Endpoint)? = nil, performRequest: (@Sendable (URLRequest) async throws -> (Data, URLResponse))? = nil) { self.endpointProvider = endpointProvider ?? MacNodeBrowserProxy.defaultEndpoint self.performRequest = performRequest ?? { request in try await session.data(for: request) } } func request(paramsJSON: String?) async throws -> String { let params = try Self.decodeRequestParams(from: paramsJSON) let request = try Self.makeRequest(params: params, endpoint: self.endpointProvider()) let (data, response) = try await self.performRequest(request) let http = try Self.requireHTTPResponse(response) guard (200..<300).contains(http.statusCode) else { throw NSError(domain: "MacNodeBrowserProxy", code: http.statusCode, userInfo: [ NSLocalizedDescriptionKey: Self.httpErrorMessage(statusCode: http.statusCode, data: data), ]) } let result = try JSONSerialization.jsonObject(with: data, options: [.fragmentsAllowed]) let files = try Self.loadProxyFiles(from: result) var payload: [String: Any] = ["result": result] if !files.isEmpty { payload["files"] = files.map { $0.asJSON() } } let payloadData = try JSONSerialization.data(withJSONObject: payload) guard let payloadJSON = String(data: payloadData, encoding: .utf8) else { throw NSError(domain: "MacNodeBrowserProxy", code: 2, userInfo: [ NSLocalizedDescriptionKey: "browser proxy returned invalid UTF-8", ]) } return payloadJSON } private static func defaultEndpoint() -> Endpoint { let config = GatewayEndpointStore.localConfig() let controlPort = GatewayEnvironment.gatewayPort() + 2 let baseURL = URL(string: "http://127.0.0.1:\(controlPort)")! return Endpoint(baseURL: baseURL, token: config.token, password: config.password) } private static func decodeRequestParams(from raw: String?) throws -> RequestParams { guard let raw else { throw NSError(domain: "MacNodeBrowserProxy", code: 3, userInfo: [ NSLocalizedDescriptionKey: "INVALID_REQUEST: paramsJSON required", ]) } return try JSONDecoder().decode(RequestParams.self, from: Data(raw.utf8)) } private static func makeRequest(params: RequestParams, endpoint: Endpoint) throws -> URLRequest { let method = (params.method ?? "GET").trimmingCharacters(in: .whitespacesAndNewlines).uppercased() let path = (params.path ?? "").trimmingCharacters(in: .whitespacesAndNewlines) guard !path.isEmpty else { throw NSError(domain: "MacNodeBrowserProxy", code: 1, userInfo: [ NSLocalizedDescriptionKey: "INVALID_REQUEST: path required", ]) } let normalizedPath = path.hasPrefix("/") ? path : "/\(path)" guard var components = URLComponents( url: endpoint.baseURL.appendingPathComponent(String(normalizedPath.dropFirst())), resolvingAgainstBaseURL: false) else { throw NSError(domain: "MacNodeBrowserProxy", code: 4, userInfo: [ NSLocalizedDescriptionKey: "INVALID_REQUEST: invalid browser proxy URL", ]) } var queryItems: [URLQueryItem] = [] if let query = params.query { for key in query.keys.sorted() { let value = query[key]?.value guard value != nil, !(value is NSNull) else { continue } queryItems.append(URLQueryItem(name: key, value: Self.stringValue(for: value))) } } let profile = params.profile?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" if !profile.isEmpty, !queryItems.contains(where: { $0.name == "profile" }) { queryItems.append(URLQueryItem(name: "profile", value: profile)) } if !queryItems.isEmpty { components.queryItems = queryItems } guard let url = components.url else { throw NSError(domain: "MacNodeBrowserProxy", code: 5, userInfo: [ NSLocalizedDescriptionKey: "INVALID_REQUEST: invalid browser proxy URL", ]) } var request = URLRequest(url: url) request.httpMethod = method request.timeoutInterval = params.timeoutMs.map { TimeInterval(max($0, 1)) / 1000 } ?? 5 request.setValue("application/json", forHTTPHeaderField: "Accept") if let token = endpoint.token?.trimmingCharacters(in: .whitespacesAndNewlines), !token.isEmpty { request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") } else if let password = endpoint.password?.trimmingCharacters(in: .whitespacesAndNewlines), !password.isEmpty { request.setValue(password, forHTTPHeaderField: "x-openclaw-password") } if method != "GET", let body = params.body?.value { request.httpBody = try JSONSerialization.data(withJSONObject: body, options: [.fragmentsAllowed]) request.setValue("application/json", forHTTPHeaderField: "Content-Type") } return request } private static func requireHTTPResponse(_ response: URLResponse) throws -> HTTPURLResponse { guard let http = response as? HTTPURLResponse else { throw NSError(domain: "MacNodeBrowserProxy", code: 6, userInfo: [ NSLocalizedDescriptionKey: "browser proxy returned a non-HTTP response", ]) } return http } private static func httpErrorMessage(statusCode: Int, data: Data) -> String { if let object = try? JSONSerialization.jsonObject(with: data, options: [.fragmentsAllowed]) as? [String: Any], let error = object["error"] as? String, !error.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { return error } if let text = String(data: data, encoding: .utf8)? .trimmingCharacters(in: .whitespacesAndNewlines), !text.isEmpty { return text } return "HTTP \(statusCode)" } private static func stringValue(for value: Any?) -> String? { guard let value else { return nil } if let string = value as? String { return string } if let bool = value as? Bool { return bool ? "true" : "false" } if let number = value as? NSNumber { return number.stringValue } return String(describing: value) } private static func loadProxyFiles(from result: Any) throws -> [ProxyFilePayload] { let paths = self.collectProxyPaths(from: result) return try paths.map(self.loadProxyFile) } private static func collectProxyPaths(from payload: Any) -> [String] { guard let object = payload as? [String: Any] else { return [] } var paths = Set() if let path = object["path"] as? String, !path.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { paths.insert(path.trimmingCharacters(in: .whitespacesAndNewlines)) } if let imagePath = object["imagePath"] as? String, !imagePath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { paths.insert(imagePath.trimmingCharacters(in: .whitespacesAndNewlines)) } if let download = object["download"] as? [String: Any], let path = download["path"] as? String, !path.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { paths.insert(path.trimmingCharacters(in: .whitespacesAndNewlines)) } return paths.sorted() } private static func loadProxyFile(path: String) throws -> ProxyFilePayload { let url = URL(fileURLWithPath: path) let values = try url.resourceValues(forKeys: [.isRegularFileKey, .fileSizeKey]) guard values.isRegularFile == true else { throw NSError(domain: "MacNodeBrowserProxy", code: 7, userInfo: [ NSLocalizedDescriptionKey: "browser proxy file not found: \(path)", ]) } if let fileSize = values.fileSize, fileSize > Self.maxProxyFileBytes { throw NSError(domain: "MacNodeBrowserProxy", code: 8, userInfo: [ NSLocalizedDescriptionKey: "browser proxy file exceeds 10MB: \(path)", ]) } let data = try Data(contentsOf: url) let mimeType = UTType(filenameExtension: url.pathExtension)?.preferredMIMEType return ProxyFilePayload(path: path, base64: data.base64EncodedString(), mimeType: mimeType) } }