File size: 10,027 Bytes
fc93158
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
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 {
            request.httpBody = try JSONSerialization.data(withJSONObject: body.foundationValue, 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<String>()
        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)
    }
}