From eb5f0b73a90553ccb6b155699137bb425897cd4a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 7 Jan 2026 20:20:52 +0000 Subject: [PATCH] refactor(macos): inject main-actor services into node runtime --- .../Clawdbot/NodeMode/MacNodeRuntime.swift | 73 ++++++++----------- .../MacNodeRuntimeMainActorServices.swift | 60 +++++++++++++++ .../LowCoverageHelperTests.swift | 42 ++++------- .../MacNodeRuntimeTests.swift | 63 ++++++++++++---- .../ClawdbotIPCTests/TestIsolation.swift | 14 ++-- 5 files changed, 162 insertions(+), 90 deletions(-) create mode 100644 apps/macos/Sources/Clawdbot/NodeMode/MacNodeRuntimeMainActorServices.swift diff --git a/apps/macos/Sources/Clawdbot/NodeMode/MacNodeRuntime.swift b/apps/macos/Sources/Clawdbot/NodeMode/MacNodeRuntime.swift index dc4ae53e1..b439c66ea 100644 --- a/apps/macos/Sources/Clawdbot/NodeMode/MacNodeRuntime.swift +++ b/apps/macos/Sources/Clawdbot/NodeMode/MacNodeRuntime.swift @@ -1,45 +1,19 @@ import AppKit import ClawdbotIPC import ClawdbotKit -import CoreLocation import Foundation actor MacNodeRuntime { private let cameraCapture = CameraCaptureService() - private struct LocationPermissionRequired: Error {} + private let makeMainActorServices: () async -> any MacNodeRuntimeMainActorServices + private var cachedMainActorServices: (any MacNodeRuntimeMainActorServices)? - @MainActor - private static func currentLocation( - desiredAccuracy: ClawdbotLocationAccuracy, - maxAgeMs: Int?, - timeoutMs: Int?) async throws -> (location: CLLocation, isPrecise: Bool) + init( + makeMainActorServices: @escaping () async -> any MacNodeRuntimeMainActorServices = { + await MainActor.run { LiveMacNodeRuntimeMainActorServices() } + }) { - let locationService = MacNodeLocationService() - if locationService.authorizationStatus() != .authorizedAlways { - throw LocationPermissionRequired() - } - let location = try await locationService.currentLocation( - desiredAccuracy: desiredAccuracy, - maxAgeMs: maxAgeMs, - timeoutMs: timeoutMs) - let isPrecise = locationService.accuracyAuthorization() == .fullAccuracy - return (location: location, isPrecise: isPrecise) - } - - @MainActor - private static func recordScreen( - screenIndex: Int?, - durationMs: Int?, - fps: Double?, - includeAudio: Bool?) async throws -> (path: String, hasAudio: Bool) - { - let screenRecorder = ScreenRecordService() - return try await screenRecorder.record( - screenIndex: screenIndex, - durationMs: durationMs, - fps: fps, - includeAudio: includeAudio, - outPath: nil) + self.makeMainActorServices = makeMainActorServices } func handleInvoke(_ req: BridgeInvokeRequest) async -> BridgeInvokeResponse { @@ -246,11 +220,22 @@ actor MacNodeRuntime { ClawdbotLocationGetParams() let desired = params.desiredAccuracy ?? (Self.locationPreciseEnabled() ? .precise : .balanced) + let services = await self.mainActorServices() + let status = await services.locationAuthorizationStatus() + if status != .authorizedAlways { + return BridgeInvokeResponse( + id: req.id, + ok: false, + error: ClawdbotNodeError( + code: .unavailable, + message: "LOCATION_PERMISSION_REQUIRED: grant Location permission")) + } do { - let (location, isPrecise) = try await Self.currentLocation( + let location = try await services.currentLocation( desiredAccuracy: desired, maxAgeMs: params.maxAgeMs, timeoutMs: params.timeoutMs) + let isPrecise = await services.locationAccuracyAuthorization() == .fullAccuracy let payload = ClawdbotLocationPayload( lat: location.coordinate.latitude, lon: location.coordinate.longitude, @@ -263,13 +248,6 @@ actor MacNodeRuntime { source: nil) let json = try Self.encodePayload(payload) return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json) - } catch is LocationPermissionRequired { - return BridgeInvokeResponse( - id: req.id, - ok: false, - error: ClawdbotNodeError( - code: .unavailable, - message: "LOCATION_PERMISSION_REQUIRED: grant Location permission")) } catch MacNodeLocationService.Error.timeout { return BridgeInvokeResponse( id: req.id, @@ -296,11 +274,13 @@ actor MacNodeRuntime { code: .invalidRequest, message: "INVALID_REQUEST: screen format must be mp4") } - let res = try await Self.recordScreen( + let services = await self.mainActorServices() + let res = try await services.recordScreen( screenIndex: params.screenIndex, durationMs: params.durationMs, fps: params.fps, - includeAudio: params.includeAudio) + includeAudio: params.includeAudio, + outPath: nil) defer { try? FileManager.default.removeItem(atPath: res.path) } let data = try Data(contentsOf: URL(fileURLWithPath: res.path)) struct ScreenPayload: Encodable { @@ -321,6 +301,13 @@ actor MacNodeRuntime { return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload) } + private func mainActorServices() async -> any MacNodeRuntimeMainActorServices { + if let cachedMainActorServices { return cachedMainActorServices } + let services = await self.makeMainActorServices() + self.cachedMainActorServices = services + return services + } + private func handleA2UIReset(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse { try await self.ensureA2UIHost() diff --git a/apps/macos/Sources/Clawdbot/NodeMode/MacNodeRuntimeMainActorServices.swift b/apps/macos/Sources/Clawdbot/NodeMode/MacNodeRuntimeMainActorServices.swift new file mode 100644 index 000000000..a6e03e3e3 --- /dev/null +++ b/apps/macos/Sources/Clawdbot/NodeMode/MacNodeRuntimeMainActorServices.swift @@ -0,0 +1,60 @@ +import ClawdbotKit +import CoreLocation +import Foundation + +@MainActor +protocol MacNodeRuntimeMainActorServices: Sendable { + 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: ClawdbotLocationAccuracy, + maxAgeMs: Int?, + timeoutMs: Int?) async throws -> CLLocation +} + +@MainActor +final class LiveMacNodeRuntimeMainActorServices: MacNodeRuntimeMainActorServices, @unchecked Sendable { + private let screenRecorder = ScreenRecordService() + private let locationService = MacNodeLocationService() + + 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: ClawdbotLocationAccuracy, + maxAgeMs: Int?, + timeoutMs: Int?) async throws -> CLLocation + { + try await self.locationService.currentLocation( + desiredAccuracy: desiredAccuracy, + maxAgeMs: maxAgeMs, + timeoutMs: timeoutMs) + } +} diff --git a/apps/macos/Tests/ClawdbotIPCTests/LowCoverageHelperTests.swift b/apps/macos/Tests/ClawdbotIPCTests/LowCoverageHelperTests.swift index 080a29589..a22bdab44 100644 --- a/apps/macos/Tests/ClawdbotIPCTests/LowCoverageHelperTests.swift +++ b/apps/macos/Tests/ClawdbotIPCTests/LowCoverageHelperTests.swift @@ -93,34 +93,22 @@ struct LowCoverageHelperTests { _ = PresenceReporter._testPrimaryIPv4Address() } - @Test func gatewayLaunchAgentHelpers() { - let keyBind = "CLAWDBOT_GATEWAY_BIND" - let keyToken = "CLAWDBOT_GATEWAY_TOKEN" - let previousBind = ProcessInfo.processInfo.environment[keyBind] - let previousToken = ProcessInfo.processInfo.environment[keyToken] - defer { - if let previousBind { - setenv(keyBind, previousBind, 1) - } else { - unsetenv(keyBind) - } - if let previousToken { - setenv(keyToken, previousToken, 1) - } else { - unsetenv(keyToken) - } + @Test func gatewayLaunchAgentHelpers() async throws { + try await TestIsolation.withEnvValues( + [ + "CLAWDBOT_GATEWAY_BIND": "Lan", + "CLAWDBOT_GATEWAY_TOKEN": " secret ", + ]) + { + #expect(GatewayLaunchAgentManager._testPreferredGatewayBind() == "lan") + #expect(GatewayLaunchAgentManager._testPreferredGatewayToken() == "secret") + #expect( + GatewayLaunchAgentManager._testEscapePlistValue("a&b\"'") == + "a&b<c>"'") + + #expect(GatewayLaunchAgentManager._testGatewayExecutablePath(bundlePath: "/App") == "/App/Contents/Resources/Relay/clawdbot") + #expect(GatewayLaunchAgentManager._testRelayDir(bundlePath: "/App") == "/App/Contents/Resources/Relay") } - - setenv(keyBind, "Lan", 1) - setenv(keyToken, " secret ", 1) - #expect(GatewayLaunchAgentManager._testPreferredGatewayBind() == "lan") - #expect(GatewayLaunchAgentManager._testPreferredGatewayToken() == "secret") - #expect( - GatewayLaunchAgentManager._testEscapePlistValue("a&b\"'") == - "a&b<c>"'") - - #expect(GatewayLaunchAgentManager._testGatewayExecutablePath(bundlePath: "/App") == "/App/Contents/Resources/Relay/clawdbot") - #expect(GatewayLaunchAgentManager._testRelayDir(bundlePath: "/App") == "/App/Contents/Resources/Relay") } @Test func portGuardianParsesListenersAndBuildsReports() { diff --git a/apps/macos/Tests/ClawdbotIPCTests/MacNodeRuntimeTests.swift b/apps/macos/Tests/ClawdbotIPCTests/MacNodeRuntimeTests.swift index 7b64265f5..2dd408f1f 100644 --- a/apps/macos/Tests/ClawdbotIPCTests/MacNodeRuntimeTests.swift +++ b/apps/macos/Tests/ClawdbotIPCTests/MacNodeRuntimeTests.swift @@ -1,9 +1,9 @@ import ClawdbotKit +import CoreLocation import Foundation import Testing @testable import Clawdbot -@Suite(.serialized) struct MacNodeRuntimeTests { @Test func handleInvokeRejectsUnknownCommand() async { let runtime = MacNodeRuntime() @@ -31,21 +31,58 @@ struct MacNodeRuntimeTests { } @Test func handleInvokeCameraListRequiresEnabledCamera() async { - let defaults = UserDefaults.standard - let previous = defaults.object(forKey: cameraEnabledKey) - defaults.set(false, forKey: cameraEnabledKey) - defer { - if let previous { - defaults.set(previous, forKey: cameraEnabledKey) - } else { - defaults.removeObject(forKey: cameraEnabledKey) + await TestIsolation.withUserDefaultsValues([cameraEnabledKey: false]) { + let runtime = MacNodeRuntime() + let response = await runtime.handleInvoke( + BridgeInvokeRequest(id: "req-4", command: ClawdbotCameraCommand.list.rawValue)) + #expect(response.ok == false) + #expect(response.error?.message.contains("CAMERA_DISABLED") == true) + } + } + + @Test func handleInvokeScreenRecordUsesInjectedServices() async throws { + @MainActor + final class FakeMainActorServices: MacNodeRuntimeMainActorServices, @unchecked Sendable { + func recordScreen( + screenIndex: Int?, + durationMs: Int?, + fps: Double?, + includeAudio: Bool?, + outPath: String?) async throws -> (path: String, hasAudio: Bool) + { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("clawdbot-test-screen-record-\(UUID().uuidString).mp4") + try Data("ok".utf8).write(to: url) + return (path: url.path, hasAudio: false) + } + + func locationAuthorizationStatus() -> CLAuthorizationStatus { .authorizedAlways } + func locationAccuracyAuthorization() -> CLAccuracyAuthorization { .fullAccuracy } + func currentLocation( + desiredAccuracy: ClawdbotLocationAccuracy, + maxAgeMs: Int?, + timeoutMs: Int?) async throws -> CLLocation + { + CLLocation(latitude: 0, longitude: 0) } } - let runtime = MacNodeRuntime() + let services = await MainActor.run { FakeMainActorServices() } + let runtime = MacNodeRuntime(makeMainActorServices: { services }) + + let params = MacNodeScreenRecordParams(durationMs: 250) + let json = String(data: try JSONEncoder().encode(params), encoding: .utf8) let response = await runtime.handleInvoke( - BridgeInvokeRequest(id: "req-4", command: ClawdbotCameraCommand.list.rawValue)) - #expect(response.ok == false) - #expect(response.error?.message.contains("CAMERA_DISABLED") == true) + BridgeInvokeRequest(id: "req-5", command: MacNodeScreenCommand.record.rawValue, paramsJSON: json)) + #expect(response.ok == true) + let payloadJSON = try #require(response.payloadJSON) + + struct Payload: Decodable { + var format: String + var base64: String + } + let payload = try JSONDecoder().decode(Payload.self, from: Data(payloadJSON.utf8)) + #expect(payload.format == "mp4") + #expect(!payload.base64.isEmpty) } } diff --git a/apps/macos/Tests/ClawdbotIPCTests/TestIsolation.swift b/apps/macos/Tests/ClawdbotIPCTests/TestIsolation.swift index fa0180131..0613c830c 100644 --- a/apps/macos/Tests/ClawdbotIPCTests/TestIsolation.swift +++ b/apps/macos/Tests/ClawdbotIPCTests/TestIsolation.swift @@ -26,7 +26,7 @@ actor TestIsolationLock { next.resume() } - func withLock(_ body: () async throws -> T) async rethrows -> T { + func withLock(_ body: @Sendable () async throws -> T) async rethrows -> T { await self.lock() defer { self.unlock() } return try await body() @@ -34,10 +34,10 @@ actor TestIsolationLock { } enum TestIsolation { - static func withIsolatedState( + static func withIsolatedState( env: [String: String?] = [:], defaults: [String: Any?] = [:], - _ body: () async throws -> T) async rethrows -> T + _ body: @Sendable () async throws -> T) async rethrows -> T { try await TestIsolationLock.shared.withLock { var previousEnv: [String: String?] = [:] @@ -82,16 +82,16 @@ enum TestIsolation { } } - static func withEnvValues( + static func withEnvValues( _ values: [String: String?], - _ body: () async throws -> T) async rethrows -> T + _ body: @Sendable () async throws -> T) async rethrows -> T { try await Self.withIsolatedState(env: values, defaults: [:], body) } - static func withUserDefaultsValues( + static func withUserDefaultsValues( _ values: [String: Any?], - _ body: () async throws -> T) async rethrows -> T + _ body: @Sendable () async throws -> T) async rethrows -> T { try await Self.withIsolatedState(env: [:], defaults: values, body) }