Skip to content

Commit b97570e

Browse files
committed
macOS: improve chat, browser, cron, and permissions flows
1 parent 7d2b146 commit b97570e

20 files changed

+939
-64
lines changed

apps/macos/Sources/OpenClaw/GatewayConnection.swift

Lines changed: 62 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,44 @@ actor GatewayConnection {
110110
private var subscribers: [UUID: AsyncStream<GatewayPush>.Continuation] = [:]
111111
private var lastSnapshot: HelloOk?
112112

113+
private struct LossyDecodable<Value: Decodable>: Decodable {
114+
let value: Value?
115+
116+
init(from decoder: Decoder) throws {
117+
do {
118+
self.value = try Value(from: decoder)
119+
} catch {
120+
self.value = nil
121+
}
122+
}
123+
}
124+
125+
private struct LossyCronListResponse: Decodable {
126+
let jobs: [LossyDecodable<CronJob>]
127+
128+
enum CodingKeys: String, CodingKey {
129+
case jobs
130+
}
131+
132+
init(from decoder: Decoder) throws {
133+
let container = try decoder.container(keyedBy: CodingKeys.self)
134+
self.jobs = try container.decodeIfPresent([LossyDecodable<CronJob>].self, forKey: .jobs) ?? []
135+
}
136+
}
137+
138+
private struct LossyCronRunsResponse: Decodable {
139+
let entries: [LossyDecodable<CronRunLogEntry>]
140+
141+
enum CodingKeys: String, CodingKey {
142+
case entries
143+
}
144+
145+
init(from decoder: Decoder) throws {
146+
let container = try decoder.container(keyedBy: CodingKeys.self)
147+
self.entries = try container.decodeIfPresent([LossyDecodable<CronRunLogEntry>].self, forKey: .entries) ?? []
148+
}
149+
}
150+
113151
init(
114152
configProvider: @escaping @Sendable () async throws -> Config = GatewayConnection.defaultConfigProvider,
115153
sessionBox: WebSocketSessionBox? = nil)
@@ -703,17 +741,17 @@ extension GatewayConnection {
703741
}
704742

705743
func cronList(includeDisabled: Bool = true) async throws -> [CronJob] {
706-
let res: CronListResponse = try await self.requestDecoded(
744+
let data = try await self.requestRaw(
707745
method: .cronList,
708746
params: ["includeDisabled": AnyCodable(includeDisabled)])
709-
return res.jobs
747+
return try Self.decodeCronListResponse(data)
710748
}
711749

712750
func cronRuns(jobId: String, limit: Int = 200) async throws -> [CronRunLogEntry] {
713-
let res: CronRunsResponse = try await self.requestDecoded(
751+
let data = try await self.requestRaw(
714752
method: .cronRuns,
715753
params: ["id": AnyCodable(jobId), "limit": AnyCodable(limit)])
716-
return res.entries
754+
return try Self.decodeCronRunsResponse(data)
717755
}
718756

719757
func cronRun(jobId: String, force: Bool = true) async throws {
@@ -739,4 +777,24 @@ extension GatewayConnection {
739777
func cronAdd(payload: [String: AnyCodable]) async throws {
740778
try await self.requestVoid(method: .cronAdd, params: payload)
741779
}
780+
781+
nonisolated static func decodeCronListResponse(_ data: Data) throws -> [CronJob] {
782+
let decoded = try JSONDecoder().decode(LossyCronListResponse.self, from: data)
783+
let jobs = decoded.jobs.compactMap(\.value)
784+
let skipped = decoded.jobs.count - jobs.count
785+
if skipped > 0 {
786+
gatewayConnectionLogger.warning("cron.list skipped \(skipped, privacy: .public) malformed jobs")
787+
}
788+
return jobs
789+
}
790+
791+
nonisolated static func decodeCronRunsResponse(_ data: Data) throws -> [CronRunLogEntry] {
792+
let decoded = try JSONDecoder().decode(LossyCronRunsResponse.self, from: data)
793+
let entries = decoded.entries.compactMap(\.value)
794+
let skipped = decoded.entries.count - entries.count
795+
if skipped > 0 {
796+
gatewayConnectionLogger.warning("cron.runs skipped \(skipped, privacy: .public) malformed entries")
797+
}
798+
return entries
799+
}
742800
}

apps/macos/Sources/OpenClaw/GatewayEndpointStore.swift

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -614,6 +614,44 @@ actor GatewayEndpointStore {
614614
}
615615

616616
extension GatewayEndpointStore {
617+
static func localConfig() -> GatewayConnection.Config {
618+
self.localConfig(
619+
root: OpenClawConfigFile.loadDict(),
620+
env: ProcessInfo.processInfo.environment,
621+
launchdSnapshot: GatewayLaunchAgentManager.launchdConfigSnapshot(),
622+
tailscaleIP: TailscaleService.fallbackTailnetIPv4())
623+
}
624+
625+
static func localConfig(
626+
root: [String: Any],
627+
env: [String: String],
628+
launchdSnapshot: LaunchAgentPlistSnapshot?,
629+
tailscaleIP: String?) -> GatewayConnection.Config
630+
{
631+
let port = GatewayEnvironment.gatewayPort()
632+
let bind = self.resolveGatewayBindMode(root: root, env: env)
633+
let customBindHost = self.resolveGatewayCustomBindHost(root: root)
634+
let scheme = self.resolveGatewayScheme(root: root, env: env)
635+
let host = self.resolveLocalGatewayHost(
636+
bindMode: bind,
637+
customBindHost: customBindHost,
638+
tailscaleIP: tailscaleIP)
639+
let token = self.resolveGatewayToken(
640+
isRemote: false,
641+
root: root,
642+
env: env,
643+
launchdSnapshot: launchdSnapshot)
644+
let password = self.resolveGatewayPassword(
645+
isRemote: false,
646+
root: root,
647+
env: env,
648+
launchdSnapshot: launchdSnapshot)
649+
return (
650+
url: URL(string: "\(scheme)://\(host):\(port)")!,
651+
token: token,
652+
password: password)
653+
}
654+
617655
private static func normalizeDashboardPath(_ rawPath: String?) -> String {
618656
let trimmed = (rawPath ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
619657
guard !trimmed.isEmpty else { return "/" }
@@ -721,5 +759,18 @@ extension GatewayEndpointStore {
721759
customBindHost: customBindHost,
722760
tailscaleIP: tailscaleIP)
723761
}
762+
763+
static func _testLocalConfig(
764+
root: [String: Any],
765+
env: [String: String],
766+
launchdSnapshot: LaunchAgentPlistSnapshot? = nil,
767+
tailscaleIP: String? = nil) -> GatewayConnection.Config
768+
{
769+
self.localConfig(
770+
root: root,
771+
env: env,
772+
launchdSnapshot: launchdSnapshot,
773+
tailscaleIP: tailscaleIP)
774+
}
724775
}
725776
#endif
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import Foundation
2+
import OpenClawKit
3+
import OpenClawProtocol
4+
5+
actor MacNodeBrowserProxy {
6+
static let shared = MacNodeBrowserProxy()
7+
8+
private struct RequestParams: Decodable {
9+
let method: String?
10+
let path: String?
11+
let query: [String: OpenClawProtocol.AnyCodable]?
12+
let body: OpenClawProtocol.AnyCodable?
13+
let timeoutMs: Int?
14+
let profile: String?
15+
}
16+
17+
private let connection: GatewayConnection
18+
19+
init(
20+
connection: GatewayConnection = GatewayConnection(configProvider: {
21+
GatewayEndpointStore.localConfig()
22+
}))
23+
{
24+
self.connection = connection
25+
}
26+
27+
func request(paramsJSON: String?) async throws -> String {
28+
let params = try Self.decodeRequestParams(from: paramsJSON)
29+
let method = (params.method ?? "GET").trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
30+
let path = (params.path ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
31+
guard !path.isEmpty else {
32+
throw NSError(domain: "MacNodeBrowserProxy", code: 1, userInfo: [
33+
NSLocalizedDescriptionKey: "INVALID_REQUEST: path required",
34+
])
35+
}
36+
37+
var query = params.query?.mapValues { OpenClawKit.AnyCodable($0.value) } ?? [:]
38+
let profile = params.profile?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
39+
if !profile.isEmpty, query["profile"] == nil {
40+
query["profile"] = OpenClawKit.AnyCodable(profile)
41+
}
42+
43+
var gatewayParams: [String: OpenClawKit.AnyCodable] = [
44+
"method": OpenClawKit.AnyCodable(method),
45+
"path": OpenClawKit.AnyCodable(path),
46+
]
47+
if !query.isEmpty {
48+
gatewayParams["query"] = OpenClawKit.AnyCodable(query)
49+
}
50+
if let body = params.body {
51+
gatewayParams["body"] = OpenClawKit.AnyCodable(body.value)
52+
}
53+
if let timeoutMs = params.timeoutMs, timeoutMs > 0 {
54+
gatewayParams["timeoutMs"] = OpenClawKit.AnyCodable(timeoutMs)
55+
}
56+
57+
let data = try await self.connection.requestRaw(
58+
method: "browser.request",
59+
params: gatewayParams,
60+
timeoutMs: params.timeoutMs.map(Double.init))
61+
guard let payloadJSON = String(data: data, encoding: .utf8) else {
62+
throw NSError(domain: "MacNodeBrowserProxy", code: 2, userInfo: [
63+
NSLocalizedDescriptionKey: "browser request returned invalid UTF-8",
64+
])
65+
}
66+
return payloadJSON
67+
}
68+
69+
private static func decodeRequestParams(from raw: String?) throws -> RequestParams {
70+
guard let raw else {
71+
throw NSError(domain: "MacNodeBrowserProxy", code: 3, userInfo: [
72+
NSLocalizedDescriptionKey: "INVALID_REQUEST: paramsJSON required",
73+
])
74+
}
75+
return try JSONDecoder().decode(RequestParams.self, from: Data(raw.utf8))
76+
}
77+
}

apps/macos/Sources/OpenClaw/NodeMode/MacNodeModeCoordinator.swift

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ final class MacNodeModeCoordinator {
3232
private func run() async {
3333
var retryDelay: UInt64 = 1_000_000_000
3434
var lastCameraEnabled: Bool?
35+
var lastBrowserControlEnabled: Bool?
3536
let defaults = UserDefaults.standard
3637

3738
while !Task.isCancelled {
@@ -48,6 +49,14 @@ final class MacNodeModeCoordinator {
4849
await self.session.disconnect()
4950
try? await Task.sleep(nanoseconds: 200_000_000)
5051
}
52+
let browserControlEnabled = OpenClawConfigFile.browserControlEnabled()
53+
if lastBrowserControlEnabled == nil {
54+
lastBrowserControlEnabled = browserControlEnabled
55+
} else if lastBrowserControlEnabled != browserControlEnabled {
56+
lastBrowserControlEnabled = browserControlEnabled
57+
await self.session.disconnect()
58+
try? await Task.sleep(nanoseconds: 200_000_000)
59+
}
5160

5261
do {
5362
let config = try await GatewayEndpointStore.shared.requireConfig()
@@ -108,6 +117,9 @@ final class MacNodeModeCoordinator {
108117

109118
private func currentCaps() -> [String] {
110119
var caps: [String] = [OpenClawCapability.canvas.rawValue, OpenClawCapability.screen.rawValue]
120+
if OpenClawConfigFile.browserControlEnabled() {
121+
caps.append(OpenClawCapability.browser.rawValue)
122+
}
111123
if UserDefaults.standard.object(forKey: cameraEnabledKey) as? Bool ?? false {
112124
caps.append(OpenClawCapability.camera.rawValue)
113125
}
@@ -142,6 +154,9 @@ final class MacNodeModeCoordinator {
142154
]
143155

144156
let capsSet = Set(caps)
157+
if capsSet.contains(OpenClawCapability.browser.rawValue) {
158+
commands.append(OpenClawBrowserCommand.proxy.rawValue)
159+
}
145160
if capsSet.contains(OpenClawCapability.camera.rawValue) {
146161
commands.append(OpenClawCameraCommand.list.rawValue)
147162
commands.append(OpenClawCameraCommand.snap.rawValue)

apps/macos/Sources/OpenClaw/NodeMode/MacNodeRuntime.swift

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,16 +6,21 @@ import OpenClawKit
66
actor MacNodeRuntime {
77
private let cameraCapture = CameraCaptureService()
88
private let makeMainActorServices: () async -> any MacNodeRuntimeMainActorServices
9+
private let browserProxyRequest: @Sendable (String?) async throws -> String
910
private var cachedMainActorServices: (any MacNodeRuntimeMainActorServices)?
1011
private var mainSessionKey: String = "main"
1112
private var eventSender: (@Sendable (String, String?) async -> Void)?
1213

1314
init(
1415
makeMainActorServices: @escaping () async -> any MacNodeRuntimeMainActorServices = {
1516
await MainActor.run { LiveMacNodeRuntimeMainActorServices() }
17+
},
18+
browserProxyRequest: @escaping @Sendable (String?) async throws -> String = { paramsJSON in
19+
try await MacNodeBrowserProxy.shared.request(paramsJSON: paramsJSON)
1620
})
1721
{
1822
self.makeMainActorServices = makeMainActorServices
23+
self.browserProxyRequest = browserProxyRequest
1924
}
2025

2126
func updateMainSessionKey(_ sessionKey: String) {
@@ -50,6 +55,8 @@ actor MacNodeRuntime {
5055
OpenClawCanvasA2UICommand.push.rawValue,
5156
OpenClawCanvasA2UICommand.pushJSONL.rawValue:
5257
return try await self.handleA2UIInvoke(req)
58+
case OpenClawBrowserCommand.proxy.rawValue:
59+
return try await self.handleBrowserProxyInvoke(req)
5360
case OpenClawCameraCommand.snap.rawValue,
5461
OpenClawCameraCommand.clip.rawValue,
5562
OpenClawCameraCommand.list.rawValue:
@@ -165,6 +172,11 @@ actor MacNodeRuntime {
165172
}
166173
}
167174

175+
private func handleBrowserProxyInvoke(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
176+
let payloadJSON = try await self.browserProxyRequest(req.paramsJSON)
177+
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payloadJSON)
178+
}
179+
168180
private func handleCameraInvoke(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
169181
guard Self.cameraEnabled() else {
170182
return BridgeInvokeResponse(

0 commit comments

Comments
 (0)