From f10d1fd9ac8dbf5f66088a999020a6da10727a26 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 7 Jan 2026 19:42:22 +0000 Subject: [PATCH] fix(macos): stabilize node runtime + menu sessions --- .../Sources/Clawdbot/ClawdbotConfigFile.swift | 3 +- .../Clawdbot/MenuSessionsInjector.swift | 22 +++++-- .../Clawdbot/NodeMode/MacNodeRuntime.swift | 62 ++++++++++++++----- .../ClawdbotConfigFileTests.swift | 2 +- .../GatewayEnvironmentTests.swift | 2 +- 5 files changed, 68 insertions(+), 23 deletions(-) diff --git a/apps/macos/Sources/Clawdbot/ClawdbotConfigFile.swift b/apps/macos/Sources/Clawdbot/ClawdbotConfigFile.swift index a5fad5f0a..83d38b79a 100644 --- a/apps/macos/Sources/Clawdbot/ClawdbotConfigFile.swift +++ b/apps/macos/Sources/Clawdbot/ClawdbotConfigFile.swift @@ -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() diff --git a/apps/macos/Sources/Clawdbot/MenuSessionsInjector.swift b/apps/macos/Sources/Clawdbot/MenuSessionsInjector.swift index c7fdcb545..286f460f7 100644 --- a/apps/macos/Sources/Clawdbot/MenuSessionsInjector.swift +++ b/apps/macos/Sources/Clawdbot/MenuSessionsInjector.swift @@ -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) diff --git a/apps/macos/Sources/Clawdbot/NodeMode/MacNodeRuntime.swift b/apps/macos/Sources/Clawdbot/NodeMode/MacNodeRuntime.swift index cf0e28372..dc4ae53e1 100644 --- a/apps/macos/Sources/Clawdbot/NodeMode/MacNodeRuntime.swift +++ b/apps/macos/Sources/Clawdbot/NodeMode/MacNodeRuntime.swift @@ -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 { diff --git a/apps/macos/Tests/ClawdbotIPCTests/ClawdbotConfigFileTests.swift b/apps/macos/Tests/ClawdbotIPCTests/ClawdbotConfigFileTests.swift index b976541f6..2a5c70d60 100644 --- a/apps/macos/Tests/ClawdbotIPCTests/ClawdbotConfigFileTests.swift +++ b/apps/macos/Tests/ClawdbotIPCTests/ClawdbotConfigFileTests.swift @@ -2,7 +2,7 @@ import Foundation import Testing @testable import Clawdbot -@Suite +@Suite(.serialized) struct ClawdbotConfigFileTests { @Test func configPathRespectsEnvOverride() { diff --git a/apps/macos/Tests/ClawdbotIPCTests/GatewayEnvironmentTests.swift b/apps/macos/Tests/ClawdbotIPCTests/GatewayEnvironmentTests.swift index e27df5b21..00ce5ab1d 100644 --- a/apps/macos/Tests/ClawdbotIPCTests/GatewayEnvironmentTests.swift +++ b/apps/macos/Tests/ClawdbotIPCTests/GatewayEnvironmentTests.swift @@ -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))