refactor(macos): inject main-actor services into node runtime

This commit is contained in:
Peter Steinberger
2026-01-07 20:20:52 +00:00
parent 5a09926126
commit eb5f0b73a9
5 changed files with 162 additions and 90 deletions

View File

@@ -1,45 +1,19 @@
import AppKit import AppKit
import ClawdbotIPC import ClawdbotIPC
import ClawdbotKit import ClawdbotKit
import CoreLocation
import Foundation import Foundation
actor MacNodeRuntime { actor MacNodeRuntime {
private let cameraCapture = CameraCaptureService() private let cameraCapture = CameraCaptureService()
private struct LocationPermissionRequired: Error {} private let makeMainActorServices: () async -> any MacNodeRuntimeMainActorServices
private var cachedMainActorServices: (any MacNodeRuntimeMainActorServices)?
@MainActor init(
private static func currentLocation( makeMainActorServices: @escaping () async -> any MacNodeRuntimeMainActorServices = {
desiredAccuracy: ClawdbotLocationAccuracy, await MainActor.run { LiveMacNodeRuntimeMainActorServices() }
maxAgeMs: Int?, })
timeoutMs: Int?) async throws -> (location: CLLocation, isPrecise: Bool)
{ {
let locationService = MacNodeLocationService() self.makeMainActorServices = makeMainActorServices
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)
} }
func handleInvoke(_ req: BridgeInvokeRequest) async -> BridgeInvokeResponse { func handleInvoke(_ req: BridgeInvokeRequest) async -> BridgeInvokeResponse {
@@ -246,11 +220,22 @@ actor MacNodeRuntime {
ClawdbotLocationGetParams() ClawdbotLocationGetParams()
let desired = params.desiredAccuracy ?? let desired = params.desiredAccuracy ??
(Self.locationPreciseEnabled() ? .precise : .balanced) (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 { do {
let (location, isPrecise) = try await Self.currentLocation( let location = try await services.currentLocation(
desiredAccuracy: desired, desiredAccuracy: desired,
maxAgeMs: params.maxAgeMs, maxAgeMs: params.maxAgeMs,
timeoutMs: params.timeoutMs) timeoutMs: params.timeoutMs)
let isPrecise = await services.locationAccuracyAuthorization() == .fullAccuracy
let payload = ClawdbotLocationPayload( let payload = ClawdbotLocationPayload(
lat: location.coordinate.latitude, lat: location.coordinate.latitude,
lon: location.coordinate.longitude, lon: location.coordinate.longitude,
@@ -263,13 +248,6 @@ actor MacNodeRuntime {
source: nil) source: nil)
let json = try Self.encodePayload(payload) let json = try Self.encodePayload(payload)
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json) 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 { } catch MacNodeLocationService.Error.timeout {
return BridgeInvokeResponse( return BridgeInvokeResponse(
id: req.id, id: req.id,
@@ -296,11 +274,13 @@ actor MacNodeRuntime {
code: .invalidRequest, code: .invalidRequest,
message: "INVALID_REQUEST: screen format must be mp4") 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, screenIndex: params.screenIndex,
durationMs: params.durationMs, durationMs: params.durationMs,
fps: params.fps, fps: params.fps,
includeAudio: params.includeAudio) includeAudio: params.includeAudio,
outPath: nil)
defer { try? FileManager.default.removeItem(atPath: res.path) } defer { try? FileManager.default.removeItem(atPath: res.path) }
let data = try Data(contentsOf: URL(fileURLWithPath: res.path)) let data = try Data(contentsOf: URL(fileURLWithPath: res.path))
struct ScreenPayload: Encodable { struct ScreenPayload: Encodable {
@@ -321,6 +301,13 @@ actor MacNodeRuntime {
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload) 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 { private func handleA2UIReset(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
try await self.ensureA2UIHost() try await self.ensureA2UIHost()

View File

@@ -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)
}
}

View File

@@ -93,34 +93,22 @@ struct LowCoverageHelperTests {
_ = PresenceReporter._testPrimaryIPv4Address() _ = PresenceReporter._testPrimaryIPv4Address()
} }
@Test func gatewayLaunchAgentHelpers() { @Test func gatewayLaunchAgentHelpers() async throws {
let keyBind = "CLAWDBOT_GATEWAY_BIND" try await TestIsolation.withEnvValues(
let keyToken = "CLAWDBOT_GATEWAY_TOKEN" [
let previousBind = ProcessInfo.processInfo.environment[keyBind] "CLAWDBOT_GATEWAY_BIND": "Lan",
let previousToken = ProcessInfo.processInfo.environment[keyToken] "CLAWDBOT_GATEWAY_TOKEN": " secret ",
defer { ])
if let previousBind { {
setenv(keyBind, previousBind, 1) #expect(GatewayLaunchAgentManager._testPreferredGatewayBind() == "lan")
} else { #expect(GatewayLaunchAgentManager._testPreferredGatewayToken() == "secret")
unsetenv(keyBind) #expect(
} GatewayLaunchAgentManager._testEscapePlistValue("a&b<c>\"'") ==
if let previousToken { "a&amp;b&lt;c&gt;&quot;&apos;")
setenv(keyToken, previousToken, 1)
} else { #expect(GatewayLaunchAgentManager._testGatewayExecutablePath(bundlePath: "/App") == "/App/Contents/Resources/Relay/clawdbot")
unsetenv(keyToken) #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<c>\"'") ==
"a&amp;b&lt;c&gt;&quot;&apos;")
#expect(GatewayLaunchAgentManager._testGatewayExecutablePath(bundlePath: "/App") == "/App/Contents/Resources/Relay/clawdbot")
#expect(GatewayLaunchAgentManager._testRelayDir(bundlePath: "/App") == "/App/Contents/Resources/Relay")
} }
@Test func portGuardianParsesListenersAndBuildsReports() { @Test func portGuardianParsesListenersAndBuildsReports() {

View File

@@ -1,9 +1,9 @@
import ClawdbotKit import ClawdbotKit
import CoreLocation
import Foundation import Foundation
import Testing import Testing
@testable import Clawdbot @testable import Clawdbot
@Suite(.serialized)
struct MacNodeRuntimeTests { struct MacNodeRuntimeTests {
@Test func handleInvokeRejectsUnknownCommand() async { @Test func handleInvokeRejectsUnknownCommand() async {
let runtime = MacNodeRuntime() let runtime = MacNodeRuntime()
@@ -31,21 +31,58 @@ struct MacNodeRuntimeTests {
} }
@Test func handleInvokeCameraListRequiresEnabledCamera() async { @Test func handleInvokeCameraListRequiresEnabledCamera() async {
let defaults = UserDefaults.standard await TestIsolation.withUserDefaultsValues([cameraEnabledKey: false]) {
let previous = defaults.object(forKey: cameraEnabledKey) let runtime = MacNodeRuntime()
defaults.set(false, forKey: cameraEnabledKey) let response = await runtime.handleInvoke(
defer { BridgeInvokeRequest(id: "req-4", command: ClawdbotCameraCommand.list.rawValue))
if let previous { #expect(response.ok == false)
defaults.set(previous, forKey: cameraEnabledKey) #expect(response.error?.message.contains("CAMERA_DISABLED") == true)
} else { }
defaults.removeObject(forKey: cameraEnabledKey) }
@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( let response = await runtime.handleInvoke(
BridgeInvokeRequest(id: "req-4", command: ClawdbotCameraCommand.list.rawValue)) BridgeInvokeRequest(id: "req-5", command: MacNodeScreenCommand.record.rawValue, paramsJSON: json))
#expect(response.ok == false) #expect(response.ok == true)
#expect(response.error?.message.contains("CAMERA_DISABLED") == 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)
} }
} }

View File

@@ -26,7 +26,7 @@ actor TestIsolationLock {
next.resume() next.resume()
} }
func withLock<T>(_ body: () async throws -> T) async rethrows -> T { func withLock<T: Sendable>(_ body: @Sendable () async throws -> T) async rethrows -> T {
await self.lock() await self.lock()
defer { self.unlock() } defer { self.unlock() }
return try await body() return try await body()
@@ -34,10 +34,10 @@ actor TestIsolationLock {
} }
enum TestIsolation { enum TestIsolation {
static func withIsolatedState<T>( static func withIsolatedState<T: Sendable>(
env: [String: String?] = [:], env: [String: String?] = [:],
defaults: [String: Any?] = [:], defaults: [String: Any?] = [:],
_ body: () async throws -> T) async rethrows -> T _ body: @Sendable () async throws -> T) async rethrows -> T
{ {
try await TestIsolationLock.shared.withLock { try await TestIsolationLock.shared.withLock {
var previousEnv: [String: String?] = [:] var previousEnv: [String: String?] = [:]
@@ -82,16 +82,16 @@ enum TestIsolation {
} }
} }
static func withEnvValues<T>( static func withEnvValues<T: Sendable>(
_ values: [String: String?], _ 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) try await Self.withIsolatedState(env: values, defaults: [:], body)
} }
static func withUserDefaultsValues<T>( static func withUserDefaultsValues<T: Sendable>(
_ values: [String: Any?], _ 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) try await Self.withIsolatedState(env: [:], defaults: values, body)
} }