refactor(macos): inject main-actor services into node runtime
This commit is contained in:
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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&b<c>"'")
|
||||||
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&b<c>"'")
|
|
||||||
|
|
||||||
#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() {
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user