fix(macos): stabilize node runtime + menu sessions
This commit is contained in:
@@ -33,7 +33,8 @@ enum ClawdbotConfigFile {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static func saveDict(_ dict: [String: Any]) {
|
static func saveDict(_ dict: [String: Any]) {
|
||||||
if ProcessInfo.processInfo.isNixMode { return }
|
// Nix mode disables config writes in production, but tests rely on saving temp configs.
|
||||||
|
if ProcessInfo.processInfo.isNixMode, !ProcessInfo.processInfo.isRunningTests { return }
|
||||||
do {
|
do {
|
||||||
let data = try JSONSerialization.data(withJSONObject: dict, options: [.prettyPrinted, .sortedKeys])
|
let data = try JSONSerialization.data(withJSONObject: dict, options: [.prettyPrinted, .sortedKeys])
|
||||||
let url = self.url()
|
let url = self.url()
|
||||||
|
|||||||
@@ -110,8 +110,7 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
|||||||
|
|
||||||
guard let insertIndex = self.findInsertIndex(in: menu) else { return }
|
guard let insertIndex = self.findInsertIndex(in: menu) else { return }
|
||||||
let width = self.initialWidth(for: menu)
|
let width = self.initialWidth(for: menu)
|
||||||
|
let isConnected = self.isControlChannelConnected
|
||||||
guard self.isControlChannelConnected else { return }
|
|
||||||
|
|
||||||
var cursor = insertIndex
|
var cursor = insertIndex
|
||||||
var headerView: NSView?
|
var headerView: NSView?
|
||||||
@@ -132,7 +131,9 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
|||||||
headerItem.tag = self.tag
|
headerItem.tag = self.tag
|
||||||
headerItem.isEnabled = false
|
headerItem.isEnabled = false
|
||||||
let hosted = self.makeHostedView(
|
let hosted = self.makeHostedView(
|
||||||
rootView: AnyView(MenuSessionsHeaderView(count: rows.count, statusText: nil)),
|
rootView: AnyView(MenuSessionsHeaderView(
|
||||||
|
count: rows.count,
|
||||||
|
statusText: isConnected ? nil : "Gateway disconnected")),
|
||||||
width: width,
|
width: width,
|
||||||
highlighted: false)
|
highlighted: false)
|
||||||
headerItem.view = hosted
|
headerItem.view = hosted
|
||||||
@@ -163,16 +164,29 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
|||||||
let headerItem = NSMenuItem()
|
let headerItem = NSMenuItem()
|
||||||
headerItem.tag = self.tag
|
headerItem.tag = self.tag
|
||||||
headerItem.isEnabled = false
|
headerItem.isEnabled = false
|
||||||
|
let statusText = isConnected
|
||||||
|
? (self.cachedErrorText ?? "Loading sessions…")
|
||||||
|
: "Gateway disconnected"
|
||||||
let hosted = self.makeHostedView(
|
let hosted = self.makeHostedView(
|
||||||
rootView: AnyView(MenuSessionsHeaderView(
|
rootView: AnyView(MenuSessionsHeaderView(
|
||||||
count: 0,
|
count: 0,
|
||||||
statusText: self.cachedErrorText ?? "Loading sessions…")),
|
statusText: statusText)),
|
||||||
width: width,
|
width: width,
|
||||||
highlighted: false)
|
highlighted: false)
|
||||||
headerItem.view = hosted
|
headerItem.view = hosted
|
||||||
headerView = hosted
|
headerView = hosted
|
||||||
menu.insertItem(headerItem, at: cursor)
|
menu.insertItem(headerItem, at: cursor)
|
||||||
cursor += 1
|
cursor += 1
|
||||||
|
|
||||||
|
if !isConnected {
|
||||||
|
menu.insertItem(
|
||||||
|
self.makeMessageItem(
|
||||||
|
text: "Connect the gateway to see sessions",
|
||||||
|
symbolName: "bolt.slash",
|
||||||
|
width: width),
|
||||||
|
at: cursor)
|
||||||
|
cursor += 1
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
cursor = self.insertUsageSection(into: menu, at: cursor, width: width)
|
cursor = self.insertUsageSection(into: menu, at: cursor, width: width)
|
||||||
|
|||||||
@@ -1,12 +1,46 @@
|
|||||||
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()
|
||||||
@MainActor private let screenRecorder = ScreenRecordService()
|
private struct LocationPermissionRequired: Error {}
|
||||||
@MainActor private let locationService = MacNodeLocationService()
|
|
||||||
|
@MainActor
|
||||||
|
private static func currentLocation(
|
||||||
|
desiredAccuracy: ClawdbotLocationAccuracy,
|
||||||
|
maxAgeMs: Int?,
|
||||||
|
timeoutMs: Int?) async throws -> (location: CLLocation, isPrecise: Bool)
|
||||||
|
{
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
func handleInvoke(_ req: BridgeInvokeRequest) async -> BridgeInvokeResponse {
|
func handleInvoke(_ req: BridgeInvokeRequest) async -> BridgeInvokeResponse {
|
||||||
let command = req.command
|
let command = req.command
|
||||||
@@ -212,21 +246,11 @@ actor MacNodeRuntime {
|
|||||||
ClawdbotLocationGetParams()
|
ClawdbotLocationGetParams()
|
||||||
let desired = params.desiredAccuracy ??
|
let desired = params.desiredAccuracy ??
|
||||||
(Self.locationPreciseEnabled() ? .precise : .balanced)
|
(Self.locationPreciseEnabled() ? .precise : .balanced)
|
||||||
let status = await self.locationService.authorizationStatus()
|
|
||||||
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 = try await self.locationService.currentLocation(
|
let (location, isPrecise) = try await Self.currentLocation(
|
||||||
desiredAccuracy: desired,
|
desiredAccuracy: desired,
|
||||||
maxAgeMs: params.maxAgeMs,
|
maxAgeMs: params.maxAgeMs,
|
||||||
timeoutMs: params.timeoutMs)
|
timeoutMs: params.timeoutMs)
|
||||||
let isPrecise = await self.locationService.accuracyAuthorization() == .fullAccuracy
|
|
||||||
let payload = ClawdbotLocationPayload(
|
let payload = ClawdbotLocationPayload(
|
||||||
lat: location.coordinate.latitude,
|
lat: location.coordinate.latitude,
|
||||||
lon: location.coordinate.longitude,
|
lon: location.coordinate.longitude,
|
||||||
@@ -239,6 +263,13 @@ 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,
|
||||||
@@ -265,12 +296,11 @@ 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.screenRecorder.record(
|
let res = try await Self.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 {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import Foundation
|
|||||||
import Testing
|
import Testing
|
||||||
@testable import Clawdbot
|
@testable import Clawdbot
|
||||||
|
|
||||||
@Suite
|
@Suite(.serialized)
|
||||||
struct ClawdbotConfigFileTests {
|
struct ClawdbotConfigFileTests {
|
||||||
@Test
|
@Test
|
||||||
func configPathRespectsEnvOverride() {
|
func configPathRespectsEnvOverride() {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import Foundation
|
|||||||
import Testing
|
import Testing
|
||||||
@testable import Clawdbot
|
@testable import Clawdbot
|
||||||
|
|
||||||
@Suite struct GatewayEnvironmentTests {
|
@Suite(.serialized) struct GatewayEnvironmentTests {
|
||||||
@Test func semverParsesCommonForms() {
|
@Test func semverParsesCommonForms() {
|
||||||
#expect(Semver.parse("1.2.3") == Semver(major: 1, minor: 2, patch: 3))
|
#expect(Semver.parse("1.2.3") == Semver(major: 1, minor: 2, patch: 3))
|
||||||
#expect(Semver.parse("v2.0.0") == Semver(major: 2, minor: 0, patch: 0))
|
#expect(Semver.parse("v2.0.0") == Semver(major: 2, minor: 0, patch: 0))
|
||||||
|
|||||||
Reference in New Issue
Block a user