Vendor OpenClaw source as Adolf fork baseline
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Adolf is a fork/vendored clone of github.com/openclaw/openclaw (v2026.6.11), free to diverge. Tree copied sans upstream .git; upstream remote added for future syncs. Node pinned to 24 (.nvmrc); engines already require >=22.19. Preserves docs/ARCHITECTURE.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
This commit is contained in:
256
apps/macos/Sources/OpenClaw/NodeMode/MacNodeBrowserProxy.swift
Normal file
256
apps/macos/Sources/OpenClaw/NodeMode/MacNodeBrowserProxy.swift
Normal file
@@ -0,0 +1,256 @@
|
||||
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 endpoint = self.endpointProvider()
|
||||
let request = try Self.makeRequest(params: params, endpoint: endpoint)
|
||||
let data: Data
|
||||
let response: URLResponse
|
||||
do {
|
||||
(data, response) = try await self.performRequest(request)
|
||||
} catch {
|
||||
throw Self.unavailableError(endpoint: endpoint, cause: error)
|
||||
}
|
||||
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 unavailableError(endpoint: Endpoint, cause: Error) -> NSError {
|
||||
let url = endpoint.baseURL.absoluteString
|
||||
let message = """
|
||||
UNAVAILABLE: macOS app node could not reach the local browser control service at \(url). \
|
||||
In remote mode, browser control is owned by the CLI node-host; start `openclaw node start` \
|
||||
on this Mac and target that browser node. Underlying error: \(cause.localizedDescription)
|
||||
"""
|
||||
return NSError(domain: "MacNodeBrowserProxy", code: 9, userInfo: [
|
||||
NSLocalizedDescriptionKey: message,
|
||||
NSUnderlyingErrorKey: cause,
|
||||
])
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import CoreLocation
|
||||
import Foundation
|
||||
import OpenClawKit
|
||||
|
||||
@MainActor
|
||||
final class MacNodeLocationService: NSObject, CLLocationManagerDelegate, LocationServiceCommon {
|
||||
enum Error: Swift.Error {
|
||||
case timeout
|
||||
case unavailable
|
||||
}
|
||||
|
||||
private let manager = CLLocationManager()
|
||||
private var locationContinuation: CheckedContinuation<CLLocation, Swift.Error>?
|
||||
|
||||
var locationManager: CLLocationManager {
|
||||
self.manager
|
||||
}
|
||||
|
||||
var locationRequestContinuation: CheckedContinuation<CLLocation, Swift.Error>? {
|
||||
get { self.locationContinuation }
|
||||
set { self.locationContinuation = newValue }
|
||||
}
|
||||
|
||||
override init() {
|
||||
super.init()
|
||||
self.configureLocationManager()
|
||||
}
|
||||
|
||||
func currentLocation(
|
||||
desiredAccuracy: OpenClawLocationAccuracy,
|
||||
maxAgeMs: Int?,
|
||||
timeoutMs: Int?) async throws -> CLLocation
|
||||
{
|
||||
guard CLLocationManager.locationServicesEnabled() else {
|
||||
throw Error.unavailable
|
||||
}
|
||||
return try await LocationCurrentRequest.resolve(
|
||||
manager: self.manager,
|
||||
desiredAccuracy: desiredAccuracy,
|
||||
maxAgeMs: maxAgeMs,
|
||||
timeoutMs: timeoutMs,
|
||||
request: { try await self.requestLocationOnce() },
|
||||
withTimeout: { timeoutMs, operation in
|
||||
try await self.withTimeout(timeoutMs: timeoutMs) {
|
||||
try await operation()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private func withTimeout<T: Sendable>(
|
||||
timeoutMs: Int,
|
||||
operation: @escaping () async throws -> T) async throws -> T
|
||||
{
|
||||
if timeoutMs == 0 {
|
||||
return try await operation()
|
||||
}
|
||||
|
||||
return try await withCheckedThrowingContinuation { continuation in
|
||||
var didFinish = false
|
||||
|
||||
func finish(returning value: T) {
|
||||
guard !didFinish else { return }
|
||||
didFinish = true
|
||||
continuation.resume(returning: value)
|
||||
}
|
||||
|
||||
func finish(throwing error: Swift.Error) {
|
||||
guard !didFinish else { return }
|
||||
didFinish = true
|
||||
continuation.resume(throwing: error)
|
||||
}
|
||||
|
||||
let timeoutItem = DispatchWorkItem {
|
||||
finish(throwing: Error.timeout)
|
||||
}
|
||||
DispatchQueue.main.asyncAfter(
|
||||
deadline: .now() + .milliseconds(timeoutMs),
|
||||
execute: timeoutItem)
|
||||
|
||||
Task { @MainActor in
|
||||
do {
|
||||
let value = try await operation()
|
||||
timeoutItem.cancel()
|
||||
finish(returning: value)
|
||||
} catch {
|
||||
timeoutItem.cancel()
|
||||
finish(throwing: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - CLLocationManagerDelegate (nonisolated for Swift 6 compatibility)
|
||||
|
||||
nonisolated func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
|
||||
Task { @MainActor in
|
||||
guard let cont = self.locationContinuation else { return }
|
||||
self.locationContinuation = nil
|
||||
if let latest = locations.last {
|
||||
cont.resume(returning: latest)
|
||||
} else {
|
||||
cont.resume(throwing: Error.unavailable)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated func locationManager(_ manager: CLLocationManager, didFailWithError error: Swift.Error) {
|
||||
let errorCopy = error // Capture error for Sendable compliance
|
||||
Task { @MainActor in
|
||||
guard let cont = self.locationContinuation else { return }
|
||||
self.locationContinuation = nil
|
||||
cont.resume(throwing: errorCopy)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
import Foundation
|
||||
import OpenClawKit
|
||||
import OSLog
|
||||
|
||||
struct MacNodeGatewayTLSSessionCache {
|
||||
private struct Key: Equatable {
|
||||
let url: URL
|
||||
let required: Bool
|
||||
let expectedFingerprint: String?
|
||||
let allowTOFU: Bool
|
||||
let storeKey: String?
|
||||
|
||||
init(url: URL, params: GatewayTLSParams) {
|
||||
self.url = url
|
||||
self.required = params.required
|
||||
self.expectedFingerprint = params.expectedFingerprint
|
||||
self.allowTOFU = params.allowTOFU
|
||||
self.storeKey = params.storeKey
|
||||
}
|
||||
}
|
||||
|
||||
private var cachedKey: Key?
|
||||
private var cachedBox: WebSocketSessionBox?
|
||||
|
||||
mutating func sessionBox(url: URL, params: GatewayTLSParams) -> WebSocketSessionBox {
|
||||
let key = Key(url: url, params: params)
|
||||
if let cachedKey = self.cachedKey, cachedKey == key, let cachedBox = self.cachedBox {
|
||||
return cachedBox
|
||||
}
|
||||
let box = WebSocketSessionBox(session: GatewayTLSPinningSession(params: params))
|
||||
self.cachedKey = key
|
||||
self.cachedBox = box
|
||||
return box
|
||||
}
|
||||
|
||||
mutating func invalidate() {
|
||||
self.cachedKey = nil
|
||||
self.cachedBox = nil
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class MacNodeModeCoordinator {
|
||||
static let shared = MacNodeModeCoordinator()
|
||||
static var nodeIdentityProfile: GatewayDeviceIdentityProfile {
|
||||
self.resolveNodeIdentityProfile(
|
||||
defaults: .standard,
|
||||
isExistingInstallation: AppStateStore.shared.onboardingSeen)
|
||||
}
|
||||
|
||||
static func prepareNodeIdentityProfile(isExistingInstallation: Bool) {
|
||||
_ = self.resolveNodeIdentityProfile(
|
||||
defaults: .standard,
|
||||
isExistingInstallation: isExistingInstallation)
|
||||
}
|
||||
|
||||
static func resolveNodeIdentityProfile(
|
||||
defaults: UserDefaults,
|
||||
isExistingInstallation: Bool) -> GatewayDeviceIdentityProfile
|
||||
{
|
||||
if let rawValue = defaults.string(forKey: macNodeIdentityProfileKey),
|
||||
let stored = GatewayDeviceIdentityProfile(rawValue: rawValue),
|
||||
stored == .primary || stored == .node
|
||||
{
|
||||
return stored
|
||||
}
|
||||
// Released builds used the primary identity for the Mac node. Persist the
|
||||
// install-era choice before onboarding can change connection state.
|
||||
let selected: GatewayDeviceIdentityProfile = isExistingInstallation ? .primary : .node
|
||||
defaults.set(selected.rawValue, forKey: macNodeIdentityProfileKey)
|
||||
return selected
|
||||
}
|
||||
|
||||
private let logger = Logger(subsystem: "ai.openclaw", category: "mac-node")
|
||||
private var task: Task<Void, Never>?
|
||||
private let runtime: MacNodeRuntime
|
||||
private let session: GatewayNodeSession
|
||||
private var autoRepairedTLSFingerprintsByStoreKey: [String: String] = [:]
|
||||
private var tlsSessionCache = MacNodeGatewayTLSSessionCache()
|
||||
|
||||
private init() {
|
||||
let session = GatewayNodeSession()
|
||||
self.session = session
|
||||
self.runtime = MacNodeRuntime(
|
||||
canvasSurfaceUrl: { await session.currentCanvasHostUrl() },
|
||||
refreshCanvasSurfaceUrl: { await session.refreshCanvasHostUrl() })
|
||||
}
|
||||
|
||||
func start() {
|
||||
guard self.task == nil else { return }
|
||||
self.task = Task { [weak self] in
|
||||
await self?.run()
|
||||
}
|
||||
}
|
||||
|
||||
func stop() {
|
||||
self.task?.cancel()
|
||||
self.task = nil
|
||||
Task { await self.session.disconnect() }
|
||||
}
|
||||
|
||||
func setPreferredGatewayStableID(_ stableID: String?) {
|
||||
GatewayDiscoveryPreferences.setPreferredStableID(stableID)
|
||||
Task { await self.session.disconnect() }
|
||||
}
|
||||
|
||||
private func run() async {
|
||||
var retryDelay: UInt64 = 1_000_000_000
|
||||
var lastCameraEnabled: Bool?
|
||||
var lastBrowserControlEnabled: Bool?
|
||||
let defaults = UserDefaults.standard
|
||||
|
||||
while !Task.isCancelled {
|
||||
if await MainActor.run(body: { AppStateStore.shared.isPaused }) {
|
||||
try? await Task.sleep(nanoseconds: 1_000_000_000)
|
||||
continue
|
||||
}
|
||||
|
||||
let cameraEnabled = defaults.object(forKey: cameraEnabledKey) as? Bool ?? false
|
||||
if lastCameraEnabled == nil {
|
||||
lastCameraEnabled = cameraEnabled
|
||||
} else if lastCameraEnabled != cameraEnabled {
|
||||
lastCameraEnabled = cameraEnabled
|
||||
await self.session.disconnect()
|
||||
try? await Task.sleep(nanoseconds: 200_000_000)
|
||||
}
|
||||
let browserControlEnabled = OpenClawConfigFile.browserControlEnabled()
|
||||
if lastBrowserControlEnabled == nil {
|
||||
lastBrowserControlEnabled = browserControlEnabled
|
||||
} else if lastBrowserControlEnabled != browserControlEnabled {
|
||||
lastBrowserControlEnabled = browserControlEnabled
|
||||
await self.session.disconnect()
|
||||
try? await Task.sleep(nanoseconds: 200_000_000)
|
||||
}
|
||||
|
||||
var attemptedURL: URL?
|
||||
do {
|
||||
let config = try await GatewayEndpointStore.shared.requireConfig()
|
||||
attemptedURL = config.url
|
||||
let caps = self.currentCaps()
|
||||
let commands = self.currentCommands(caps: caps)
|
||||
let permissions = await self.currentPermissions()
|
||||
let connectOptions = GatewayConnectOptions(
|
||||
role: "node",
|
||||
scopes: [],
|
||||
caps: caps,
|
||||
commands: commands,
|
||||
permissions: permissions,
|
||||
clientId: "openclaw-macos",
|
||||
clientMode: "node",
|
||||
clientDisplayName: InstanceIdentity.displayName,
|
||||
deviceIdentityProfile: Self.nodeIdentityProfile)
|
||||
let sessionBox = self.buildSessionBox(
|
||||
url: config.url,
|
||||
connectionMode: AppStateStore.shared.connectionMode)
|
||||
|
||||
try await self.session.connect(
|
||||
url: config.url,
|
||||
token: config.token,
|
||||
bootstrapToken: nil,
|
||||
password: config.password,
|
||||
connectOptions: connectOptions,
|
||||
sessionBox: sessionBox,
|
||||
onConnected: { [weak self] in
|
||||
guard let self else { return }
|
||||
self.logger.info("mac node connected to gateway")
|
||||
let mainSessionKey = await GatewayConnection.shared.mainSessionKey()
|
||||
await self.runtime.updateMainSessionKey(mainSessionKey)
|
||||
await self.runtime.setEventSender { [weak self] event, payload in
|
||||
guard let self else { return }
|
||||
await self.session.sendEvent(event: event, payloadJSON: payload)
|
||||
}
|
||||
},
|
||||
onDisconnected: { [weak self] reason in
|
||||
guard let self else { return }
|
||||
await self.runtime.setEventSender(nil)
|
||||
self.logger.error("mac node disconnected: \(reason, privacy: .public)")
|
||||
},
|
||||
onInvoke: { [weak self] req in
|
||||
guard let self else {
|
||||
return BridgeInvokeResponse(
|
||||
id: req.id,
|
||||
ok: false,
|
||||
error: OpenClawNodeError(code: .unavailable, message: "UNAVAILABLE: node not ready"))
|
||||
}
|
||||
return await self.runtime.handleInvoke(req)
|
||||
})
|
||||
|
||||
retryDelay = 1_000_000_000
|
||||
try? await Task.sleep(nanoseconds: 1_000_000_000)
|
||||
} catch {
|
||||
if await self.autoRepairStaleTLSPinIfNeeded(error: error, url: attemptedURL) {
|
||||
retryDelay = 1_000_000_000
|
||||
continue
|
||||
}
|
||||
self.logger.error("mac node gateway connect failed: \(error.localizedDescription, privacy: .public)")
|
||||
try? await Task.sleep(nanoseconds: min(retryDelay, 10_000_000_000))
|
||||
retryDelay = min(retryDelay * 2, 10_000_000_000)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated static func resolvedCaps(
|
||||
browserControlEnabled: Bool,
|
||||
cameraEnabled: Bool,
|
||||
locationMode: OpenClawLocationMode,
|
||||
connectionMode: AppState.ConnectionMode) -> [String]
|
||||
{
|
||||
var caps: [String] = [
|
||||
OpenClawCapability.canvas.rawValue,
|
||||
OpenClawCapability.screen.rawValue,
|
||||
]
|
||||
if browserControlEnabled, connectionMode == .local {
|
||||
caps.append(OpenClawCapability.browser.rawValue)
|
||||
}
|
||||
if cameraEnabled {
|
||||
caps.append(OpenClawCapability.camera.rawValue)
|
||||
}
|
||||
if locationMode != .off {
|
||||
caps.append(OpenClawCapability.location.rawValue)
|
||||
}
|
||||
return caps
|
||||
}
|
||||
|
||||
private func currentCaps() -> [String] {
|
||||
let rawLocationMode = UserDefaults.standard.string(forKey: locationModeKey) ?? "off"
|
||||
return Self.resolvedCaps(
|
||||
browserControlEnabled: OpenClawConfigFile.browserControlEnabled(),
|
||||
cameraEnabled: UserDefaults.standard.object(forKey: cameraEnabledKey) as? Bool ?? false,
|
||||
locationMode: OpenClawLocationMode(rawValue: rawLocationMode) ?? .off,
|
||||
connectionMode: AppStateStore.shared.connectionMode)
|
||||
}
|
||||
|
||||
private func currentPermissions() async -> [String: Bool] {
|
||||
let statuses = await PermissionManager.status()
|
||||
return Dictionary(uniqueKeysWithValues: statuses.map { ($0.key.rawValue, $0.value) })
|
||||
}
|
||||
|
||||
nonisolated static func resolvedCommands(caps: [String]) -> [String] {
|
||||
var commands: [String] = [
|
||||
OpenClawCanvasCommand.present.rawValue,
|
||||
OpenClawCanvasCommand.hide.rawValue,
|
||||
OpenClawCanvasCommand.navigate.rawValue,
|
||||
OpenClawCanvasCommand.evalJS.rawValue,
|
||||
OpenClawCanvasCommand.snapshot.rawValue,
|
||||
OpenClawCanvasA2UICommand.push.rawValue,
|
||||
OpenClawCanvasA2UICommand.pushJSONL.rawValue,
|
||||
OpenClawCanvasA2UICommand.reset.rawValue,
|
||||
MacNodeScreenCommand.snapshot.rawValue,
|
||||
MacNodeScreenCommand.record.rawValue,
|
||||
OpenClawSystemCommand.notify.rawValue,
|
||||
OpenClawSystemCommand.which.rawValue,
|
||||
OpenClawSystemCommand.run.rawValue,
|
||||
OpenClawSystemCommand.execApprovalsGet.rawValue,
|
||||
OpenClawSystemCommand.execApprovalsSet.rawValue,
|
||||
]
|
||||
|
||||
let capsSet = Set(caps)
|
||||
if capsSet.contains(OpenClawCapability.browser.rawValue) {
|
||||
commands.append(OpenClawBrowserCommand.proxy.rawValue)
|
||||
}
|
||||
if capsSet.contains(OpenClawCapability.camera.rawValue) {
|
||||
commands.append(OpenClawCameraCommand.list.rawValue)
|
||||
commands.append(OpenClawCameraCommand.snap.rawValue)
|
||||
commands.append(OpenClawCameraCommand.clip.rawValue)
|
||||
}
|
||||
if capsSet.contains(OpenClawCapability.location.rawValue) {
|
||||
commands.append(OpenClawLocationCommand.get.rawValue)
|
||||
}
|
||||
|
||||
return commands
|
||||
}
|
||||
|
||||
private func currentCommands(caps: [String]) -> [String] {
|
||||
Self.resolvedCommands(caps: caps)
|
||||
}
|
||||
|
||||
nonisolated static func tlsPinStoreKey(for url: URL) -> String {
|
||||
let host = url.host?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty ?? "gateway"
|
||||
let port = url.port ?? 443
|
||||
return "\(host):\(port)"
|
||||
}
|
||||
|
||||
nonisolated static func shouldAutoRepairStaleTLSPin(url: URL, failure: GatewayTLSValidationFailure) -> Bool {
|
||||
guard failure.kind == .pinMismatch else { return false }
|
||||
guard url.scheme?.lowercased() == "wss" else { return false }
|
||||
guard failure.storeKey == nil || failure.storeKey == self.tlsPinStoreKey(for: url) else { return false }
|
||||
guard let host = url.host?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased(), !host.isEmpty
|
||||
else { return false }
|
||||
|
||||
if LoopbackHost.isLoopback(host) {
|
||||
return failure.systemTrustOk
|
||||
}
|
||||
|
||||
// Tailscale Serve uses publicly trusted, rotating certificates for *.ts.net names.
|
||||
// A stale legacy leaf pin should not leave the companion app half-connected forever.
|
||||
if host == "ts.net" || host.hasSuffix(".ts.net") {
|
||||
return failure.systemTrustOk
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
private func autoRepairStaleTLSPinIfNeeded(error: Error, url: URL?) async -> Bool {
|
||||
guard let tlsError = error as? GatewayTLSValidationError, let url else { return false }
|
||||
guard Self.shouldAutoRepairStaleTLSPin(url: url, failure: tlsError.failure) else { return false }
|
||||
let storeKey = tlsError.failure.storeKey ?? Self.tlsPinStoreKey(for: url)
|
||||
guard let observedFingerprint = tlsError.failure.observedFingerprint else { return false }
|
||||
guard self.autoRepairedTLSFingerprintsByStoreKey[storeKey] != observedFingerprint else { return false }
|
||||
|
||||
guard GatewayTLSStore.replaceFingerprint(observedFingerprint, stableID: storeKey) else { return false }
|
||||
self.autoRepairedTLSFingerprintsByStoreKey[storeKey] = observedFingerprint
|
||||
self.logger.info("replaced stale gateway TLS pin storeKey=\(storeKey, privacy: .public)")
|
||||
await self.session.disconnect()
|
||||
return true
|
||||
}
|
||||
|
||||
nonisolated static func tlsParams(
|
||||
for url: URL,
|
||||
connectionMode: AppState.ConnectionMode,
|
||||
root: [String: Any],
|
||||
storedFingerprint: String?) -> GatewayTLSParams?
|
||||
{
|
||||
guard url.scheme?.lowercased() == "wss" else { return nil }
|
||||
let stableID = Self.tlsPinStoreKey(for: url)
|
||||
let configuredFingerprint = connectionMode == .remote
|
||||
? GatewayRemoteConfig.resolveTLSFingerprint(root: root)
|
||||
: nil
|
||||
let expectedFingerprint = configuredFingerprint ?? storedFingerprint
|
||||
return GatewayTLSParams(
|
||||
required: true,
|
||||
expectedFingerprint: expectedFingerprint,
|
||||
allowTOFU: expectedFingerprint == nil,
|
||||
storeKey: stableID)
|
||||
}
|
||||
|
||||
private func buildSessionBox(url: URL, connectionMode: AppState.ConnectionMode) -> WebSocketSessionBox? {
|
||||
guard url.scheme?.lowercased() == "wss" else {
|
||||
self.tlsSessionCache.invalidate()
|
||||
return nil
|
||||
}
|
||||
let stableID = Self.tlsPinStoreKey(for: url)
|
||||
let stored = GatewayTLSStore.loadFingerprint(stableID: stableID)
|
||||
guard let params = Self.tlsParams(
|
||||
for: url,
|
||||
connectionMode: connectionMode,
|
||||
root: OpenClawConfigFile.loadDict(),
|
||||
storedFingerprint: stored)
|
||||
else {
|
||||
self.tlsSessionCache.invalidate()
|
||||
return nil
|
||||
}
|
||||
return self.tlsSessionCache.sessionBox(url: url, params: params)
|
||||
}
|
||||
}
|
||||
1192
apps/macos/Sources/OpenClaw/NodeMode/MacNodeRuntime.swift
Normal file
1192
apps/macos/Sources/OpenClaw/NodeMode/MacNodeRuntime.swift
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,82 @@
|
||||
import CoreLocation
|
||||
import Foundation
|
||||
import OpenClawKit
|
||||
|
||||
@MainActor
|
||||
protocol MacNodeRuntimeMainActorServices: Sendable {
|
||||
func snapshotScreen(
|
||||
screenIndex: Int?,
|
||||
maxWidth: Int?,
|
||||
quality: Double?,
|
||||
format: OpenClawScreenSnapshotFormat?) async throws
|
||||
-> (data: Data, format: OpenClawScreenSnapshotFormat, width: Int, height: Int)
|
||||
|
||||
func recordScreen(
|
||||
screenIndex: Int?,
|
||||
durationMs: Int?,
|
||||
fps: Double?,
|
||||
includeAudio: Bool?,
|
||||
outPath: String?) async throws -> (path: String, hasAudio: Bool)
|
||||
|
||||
func locationAuthorizationStatus() -> CLAuthorizationStatus
|
||||
func locationAccuracyAuthorization() -> CLAccuracyAuthorization
|
||||
func currentLocation(
|
||||
desiredAccuracy: OpenClawLocationAccuracy,
|
||||
maxAgeMs: Int?,
|
||||
timeoutMs: Int?) async throws -> CLLocation
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class LiveMacNodeRuntimeMainActorServices: MacNodeRuntimeMainActorServices, @unchecked Sendable {
|
||||
private let screenSnapshotter = ScreenSnapshotService()
|
||||
private let screenRecorder = ScreenRecordService()
|
||||
private let locationService = MacNodeLocationService()
|
||||
|
||||
func snapshotScreen(
|
||||
screenIndex: Int?,
|
||||
maxWidth: Int?,
|
||||
quality: Double?,
|
||||
format: OpenClawScreenSnapshotFormat?) async throws
|
||||
-> (data: Data, format: OpenClawScreenSnapshotFormat, width: Int, height: Int)
|
||||
{
|
||||
try await self.screenSnapshotter.snapshot(
|
||||
screenIndex: screenIndex,
|
||||
maxWidth: maxWidth,
|
||||
quality: quality,
|
||||
format: format)
|
||||
}
|
||||
|
||||
func recordScreen(
|
||||
screenIndex: Int?,
|
||||
durationMs: Int?,
|
||||
fps: Double?,
|
||||
includeAudio: Bool?,
|
||||
outPath: String?) async throws -> (path: String, hasAudio: Bool)
|
||||
{
|
||||
try await self.screenRecorder.record(
|
||||
screenIndex: screenIndex,
|
||||
durationMs: durationMs,
|
||||
fps: fps,
|
||||
includeAudio: includeAudio,
|
||||
outPath: outPath)
|
||||
}
|
||||
|
||||
func locationAuthorizationStatus() -> CLAuthorizationStatus {
|
||||
self.locationService.authorizationStatus()
|
||||
}
|
||||
|
||||
func locationAccuracyAuthorization() -> CLAccuracyAuthorization {
|
||||
self.locationService.accuracyAuthorization()
|
||||
}
|
||||
|
||||
func currentLocation(
|
||||
desiredAccuracy: OpenClawLocationAccuracy,
|
||||
maxAgeMs: Int?,
|
||||
timeoutMs: Int?) async throws -> CLLocation
|
||||
{
|
||||
try await self.locationService.currentLocation(
|
||||
desiredAccuracy: desiredAccuracy,
|
||||
maxAgeMs: maxAgeMs,
|
||||
timeoutMs: timeoutMs)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import Foundation
|
||||
import OpenClawKit
|
||||
|
||||
enum MacNodeScreenCommand: String, Codable {
|
||||
case snapshot = "screen.snapshot"
|
||||
case record = "screen.record"
|
||||
}
|
||||
|
||||
struct MacNodeScreenSnapshotParams: Codable, Equatable {
|
||||
var screenIndex: Int?
|
||||
var maxWidth: Int?
|
||||
var quality: Double?
|
||||
var format: OpenClawScreenSnapshotFormat?
|
||||
}
|
||||
|
||||
struct MacNodeScreenRecordParams: Codable, Equatable {
|
||||
var screenIndex: Int?
|
||||
var durationMs: Int?
|
||||
var fps: Double?
|
||||
var format: String?
|
||||
var includeAudio: Bool?
|
||||
}
|
||||
Reference in New Issue
Block a user