fix(macos): stabilize node runtime + menu sessions

This commit is contained in:
Peter Steinberger
2026-01-07 19:42:22 +00:00
parent 8913bfbcd5
commit f10d1fd9ac
5 changed files with 68 additions and 23 deletions

View File

@@ -33,7 +33,8 @@ enum ClawdbotConfigFile {
}
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 {
let data = try JSONSerialization.data(withJSONObject: dict, options: [.prettyPrinted, .sortedKeys])
let url = self.url()

View File

@@ -110,8 +110,7 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
guard let insertIndex = self.findInsertIndex(in: menu) else { return }
let width = self.initialWidth(for: menu)
guard self.isControlChannelConnected else { return }
let isConnected = self.isControlChannelConnected
var cursor = insertIndex
var headerView: NSView?
@@ -132,7 +131,9 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
headerItem.tag = self.tag
headerItem.isEnabled = false
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,
highlighted: false)
headerItem.view = hosted
@@ -163,16 +164,29 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
let headerItem = NSMenuItem()
headerItem.tag = self.tag
headerItem.isEnabled = false
let statusText = isConnected
? (self.cachedErrorText ?? "Loading sessions…")
: "Gateway disconnected"
let hosted = self.makeHostedView(
rootView: AnyView(MenuSessionsHeaderView(
count: 0,
statusText: self.cachedErrorText ?? "Loading sessions…")),
statusText: statusText)),
width: width,
highlighted: false)
headerItem.view = hosted
headerView = hosted
menu.insertItem(headerItem, at: cursor)
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)

View File

@@ -1,12 +1,46 @@
import AppKit
import ClawdbotIPC
import ClawdbotKit
import CoreLocation
import Foundation
actor MacNodeRuntime {
private let cameraCapture = CameraCaptureService()
@MainActor private let screenRecorder = ScreenRecordService()
@MainActor private let locationService = MacNodeLocationService()
private struct LocationPermissionRequired: Error {}
@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 {
let command = req.command
@@ -212,21 +246,11 @@ actor MacNodeRuntime {
ClawdbotLocationGetParams()
let desired = params.desiredAccuracy ??
(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 {
let location = try await self.locationService.currentLocation(
let (location, isPrecise) = try await Self.currentLocation(
desiredAccuracy: desired,
maxAgeMs: params.maxAgeMs,
timeoutMs: params.timeoutMs)
let isPrecise = await self.locationService.accuracyAuthorization() == .fullAccuracy
let payload = ClawdbotLocationPayload(
lat: location.coordinate.latitude,
lon: location.coordinate.longitude,
@@ -239,6 +263,13 @@ 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,
@@ -265,12 +296,11 @@ actor MacNodeRuntime {
code: .invalidRequest,
message: "INVALID_REQUEST: screen format must be mp4")
}
let res = try await self.screenRecorder.record(
let res = try await Self.recordScreen(
screenIndex: params.screenIndex,
durationMs: params.durationMs,
fps: params.fps,
includeAudio: params.includeAudio,
outPath: nil)
includeAudio: params.includeAudio)
defer { try? FileManager.default.removeItem(atPath: res.path) }
let data = try Data(contentsOf: URL(fileURLWithPath: res.path))
struct ScreenPayload: Encodable {

View File

@@ -2,7 +2,7 @@ import Foundation
import Testing
@testable import Clawdbot
@Suite
@Suite(.serialized)
struct ClawdbotConfigFileTests {
@Test
func configPathRespectsEnvOverride() {

View File

@@ -2,7 +2,7 @@ import Foundation
import Testing
@testable import Clawdbot
@Suite struct GatewayEnvironmentTests {
@Suite(.serialized) struct GatewayEnvironmentTests {
@Test func semverParsesCommonForms() {
#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))