From 37c67b0d27b3096892bbcecc0d2fceb2d4fcaab6 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 5 Jul 2026 23:35:50 +0100 Subject: [PATCH] fix(macos): reduce idle CPU wakeups (#100463) * fix(macos): reduce idle CPU wakeups * chore(macos): sync native i18n inventory * chore(macos): sync native i18n inventory * chore(macos): sync native i18n inventory * chore(macos): sync native i18n inventory * chore(macos): sync native i18n inventory * chore(macos): sync native i18n inventory --- apps/.i18n/native-source.json | 4 +- apps/macos/Sources/OpenClaw/AppState.swift | 1 + .../CritterStatusLabel+Behavior.swift | 38 +++++- .../NodeMode/MacNodeModeCoordinator.swift | 114 ++++++++++++++---- .../Sources/OpenClaw/PermissionManager.swift | 10 ++ .../CritterIconRendererTests.swift | 25 ++++ 6 files changed, 161 insertions(+), 31 deletions(-) diff --git a/apps/.i18n/native-source.json b/apps/.i18n/native-source.json index 4e18006f86af..f6a466b8cde3 100644 --- a/apps/.i18n/native-source.json +++ b/apps/.i18n/native-source.json @@ -13027,7 +13027,7 @@ }, { "kind": "conditional-branch", - "line": 641, + "line": 642, "path": "apps/macos/Sources/OpenClaw/AppState.swift", "source": "\\(user)@\\(host)", "surface": "apple", @@ -13035,7 +13035,7 @@ }, { "kind": "conditional-branch", - "line": 641, + "line": 642, "path": "apps/macos/Sources/OpenClaw/AppState.swift", "source": "\\(user)@\\(host):\\(port)", "surface": "apple", diff --git a/apps/macos/Sources/OpenClaw/AppState.swift b/apps/macos/Sources/OpenClaw/AppState.swift index 54fb97d07ffe..2543d3e81d5c 100644 --- a/apps/macos/Sources/OpenClaw/AppState.swift +++ b/apps/macos/Sources/OpenClaw/AppState.swift @@ -580,6 +580,7 @@ final class AppState { private func applyConfigFromDisk() { let root = OpenClawConfigFile.loadDict() self.applyConfigOverrides(root) + MacNodeModeCoordinator.shared.refresh() } private func applyConfigOverrides(_ root: [String: Any]) { diff --git a/apps/macos/Sources/OpenClaw/CritterStatusLabel+Behavior.swift b/apps/macos/Sources/OpenClaw/CritterStatusLabel+Behavior.swift index 5401254dbf78..ddc11b3b465f 100644 --- a/apps/macos/Sources/OpenClaw/CritterStatusLabel+Behavior.swift +++ b/apps/macos/Sources/OpenClaw/CritterStatusLabel+Behavior.swift @@ -7,7 +7,7 @@ extension CritterStatusLabel { } private var effectiveAnimationsEnabled: Bool { - self.animationsEnabled && !self.isSleeping + self.animationsEnabled && !self.isSleeping && !self.isPaused } var body: some View { @@ -24,10 +24,14 @@ extension CritterStatusLabel { return } + await MainActor.run { self.rescheduleElapsedAnimationTimers(from: Date()) } while !Task.isCancelled { let now = Date() - await MainActor.run { self.tick(now) } - try? await Task.sleep(nanoseconds: 350_000_000) + let delay = await MainActor.run { + self.tick(now) + return self.nextTickDelay(after: now) + } + try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) } } .onChange(of: self.isPaused) { _, _ in self.resetMotion() } @@ -77,7 +81,27 @@ extension CritterStatusLabel { private var tickTaskID: Int { // Ensure SwiftUI restarts (and cancels) the task when these change. - (self.effectiveAnimationsEnabled ? 1 : 0) | (self.earBoostActive ? 2 : 0) + (self.effectiveAnimationsEnabled ? 1 : 0) | + (self.earBoostActive ? 2 : 0) | + (self.isWorkingNow ? 4 : 0) + } + + private func nextTickDelay(after now: Date) -> TimeInterval { + Self.nextAnimationTickDelay( + now: now, + isWorking: self.isWorkingNow, + deadlines: [self.nextBlink, self.nextWiggle, self.nextLegWiggle, self.nextEarWiggle]) + } + + static func nextAnimationTickDelay( + now: Date, + isWorking: Bool, + deadlines: [Date]) -> TimeInterval + { + // Working motion needs a steady cadence; idle motion only wakes for its next visible event. + if isWorking { return 0.35 } + guard let nextDeadline = deadlines.min() else { return 1 } + return max(0.05, nextDeadline.timeIntervalSince(now)) } private func tick(_ now: Date) { @@ -215,6 +239,12 @@ extension CritterStatusLabel { self.nextEarWiggle = date.addingTimeInterval(Double.random(in: 7.0...14.0)) } + private func rescheduleElapsedAnimationTimers(from date: Date) { + let deadlines = [self.nextBlink, self.nextWiggle, self.nextLegWiggle, self.nextEarWiggle] + guard deadlines.contains(where: { $0 <= date }) else { return } + self.scheduleRandomTimers(from: date) + } + private var gatewayNeedsAttention: Bool { if self.isSleeping { return false } switch self.gatewayStatus { diff --git a/apps/macos/Sources/OpenClaw/NodeMode/MacNodeModeCoordinator.swift b/apps/macos/Sources/OpenClaw/NodeMode/MacNodeModeCoordinator.swift index e78d656179d5..99ff22c68a3b 100644 --- a/apps/macos/Sources/OpenClaw/NodeMode/MacNodeModeCoordinator.swift +++ b/apps/macos/Sources/OpenClaw/NodeMode/MacNodeModeCoordinator.swift @@ -1,3 +1,4 @@ +import AppKit import Foundation import OpenClawKit import OSLog @@ -40,7 +41,7 @@ struct MacNodeGatewayTLSSessionCache { } @MainActor -final class MacNodeModeCoordinator { +final class MacNodeModeCoordinator: NSObject { static let shared = MacNodeModeCoordinator() static var nodeIdentityProfile: GatewayDeviceIdentityProfile { self.resolveNodeIdentityProfile( @@ -73,17 +74,46 @@ final class MacNodeModeCoordinator { private let logger = Logger(subsystem: "ai.openclaw", category: "mac-node") private var task: Task? + private var endpointRefreshTask: Task? + private var reconnectProbeTask: Task? private let runtime: MacNodeRuntime private let session: GatewayNodeSession + private let refreshEvents: AsyncStream + private let refreshContinuation: AsyncStream.Continuation private var autoRepairedTLSFingerprintsByStoreKey: [String: String] = [:] private var tlsSessionCache = MacNodeGatewayTLSSessionCache() - private init() { + override private init() { let session = GatewayNodeSession() + let refreshEvents = AsyncStream.makeStream(of: Void.self, bufferingPolicy: .bufferingNewest(1)) self.session = session self.runtime = MacNodeRuntime( canvasSurfaceUrl: { await session.currentCanvasHostUrl() }, refreshCanvasSurfaceUrl: { await session.refreshCanvasHostUrl() }) + self.refreshEvents = refreshEvents.stream + self.refreshContinuation = refreshEvents.continuation + super.init() + + NotificationCenter.default.addObserver( + self, + selector: #selector(self.refreshNodeConfiguration), + name: UserDefaults.didChangeNotification, + object: UserDefaults.standard) + NotificationCenter.default.addObserver( + self, + selector: #selector(self.refreshNodeConfiguration), + name: NSApplication.didBecomeActiveNotification, + object: nil) + NotificationCenter.default.addObserver( + self, + selector: #selector(self.refreshNodeConfiguration), + name: .openclawPermissionsChanged, + object: nil) + } + + deinit { + NotificationCenter.default.removeObserver(self) + self.refreshContinuation.finish() } func start() { @@ -91,53 +121,61 @@ final class MacNodeModeCoordinator { self.task = Task { [weak self] in await self?.run() } + self.endpointRefreshTask = Task { [weak self] in + let states = await GatewayEndpointStore.shared.subscribe() + var previousState: GatewayEndpointState? + for await state in states { + if let previousState, state != previousState { + self?.refresh() + } + previousState = state + } + } } func stop() { self.task?.cancel() self.task = nil + self.endpointRefreshTask?.cancel() + self.endpointRefreshTask = nil + self.reconnectProbeTask?.cancel() + self.reconnectProbeTask = nil Task { await self.session.disconnect() } } func setPreferredGatewayStableID(_ stableID: String?) { GatewayDiscoveryPreferences.setPreferredStableID(stableID) - Task { await self.session.disconnect() } + Task { + await self.session.disconnect() + self.refresh() + } + } + + func refresh() { + self.refreshContinuation.yield() } private func run() async { var retryDelay: UInt64 = 1_000_000_000 - var lastCameraEnabled: Bool? - var lastBrowserControlEnabled: Bool? + var refreshIterator = self.refreshEvents.makeAsyncIterator() let defaults = UserDefaults.standard while !Task.isCancelled { if await MainActor.run(body: { AppStateStore.shared.isPaused }) { - try? await Task.sleep(nanoseconds: 1_000_000_000) + guard await refreshIterator.next() != nil else { return } continue } let cameraEnabled = defaults.object(forKey: cameraEnabledKey) as? Bool ?? false - if lastCameraEnabled == nil { - lastCameraEnabled = cameraEnabled - } else if lastCameraEnabled != cameraEnabled { - lastCameraEnabled = cameraEnabled - await self.session.disconnect() - try? await Task.sleep(nanoseconds: 200_000_000) - } let browserControlEnabled = OpenClawConfigFile.browserControlEnabled() - if lastBrowserControlEnabled == nil { - lastBrowserControlEnabled = browserControlEnabled - } else if lastBrowserControlEnabled != browserControlEnabled { - lastBrowserControlEnabled = browserControlEnabled - await self.session.disconnect() - try? await Task.sleep(nanoseconds: 200_000_000) - } var attemptedURL: URL? do { let config = try await GatewayEndpointStore.shared.requireConfig() attemptedURL = config.url - let caps = self.currentCaps() + let caps = self.currentCaps( + browserControlEnabled: browserControlEnabled, + cameraEnabled: cameraEnabled) let commands = self.currentCommands(caps: caps) let permissions = await self.currentPermissions() let connectOptions = GatewayConnectOptions( @@ -163,6 +201,7 @@ final class MacNodeModeCoordinator { sessionBox: sessionBox, onConnected: { [weak self] in guard let self else { return } + await self.cancelReconnectProbe() self.logger.info("mac node connected to gateway") let mainSessionKey = await GatewayConnection.shared.mainSessionKey() await self.runtime.updateMainSessionKey(mainSessionKey) @@ -174,6 +213,7 @@ final class MacNodeModeCoordinator { onDisconnected: { [weak self] reason in guard let self else { return } await self.runtime.setEventSender(nil) + await self.scheduleReconnectProbe() self.logger.error("mac node disconnected: \(reason, privacy: .public)") }, onInvoke: { [weak self] req in @@ -187,7 +227,9 @@ final class MacNodeModeCoordinator { }) retryDelay = 1_000_000_000 - try? await Task.sleep(nanoseconds: 1_000_000_000) + // GatewayNodeSession owns transport reconnects. Wait until inputs can + // actually change instead of rereading config and TCC state every second. + guard await refreshIterator.next() != nil else { return } } catch { if await self.autoRepairStaleTLSPinIfNeeded(error: error, url: attemptedURL) { retryDelay = 1_000_000_000 @@ -200,6 +242,28 @@ final class MacNodeModeCoordinator { } } + private func scheduleReconnectProbe() { + self.reconnectProbeTask?.cancel() + // GatewayChannel reconnects normally, but pauses after auth or pairing failures. + // Probe only while disconnected so recovery does not restore steady idle polling. + self.reconnectProbeTask = Task { [weak self] in + try? await Task.sleep(for: .seconds(30)) + guard !Task.isCancelled else { return } + self?.refresh() + } + } + + private func cancelReconnectProbe() { + self.reconnectProbeTask?.cancel() + self.reconnectProbeTask = nil + } + + @objc private nonisolated func refreshNodeConfiguration(_: Notification) { + Task { @MainActor [weak self] in + self?.refresh() + } + } + nonisolated static func resolvedCaps( browserControlEnabled: Bool, cameraEnabled: Bool, @@ -222,11 +286,11 @@ final class MacNodeModeCoordinator { return caps } - private func currentCaps() -> [String] { + private func currentCaps(browserControlEnabled: Bool, cameraEnabled: Bool) -> [String] { let rawLocationMode = UserDefaults.standard.string(forKey: locationModeKey) ?? "off" return Self.resolvedCaps( - browserControlEnabled: OpenClawConfigFile.browserControlEnabled(), - cameraEnabled: UserDefaults.standard.object(forKey: cameraEnabledKey) as? Bool ?? false, + browserControlEnabled: browserControlEnabled, + cameraEnabled: cameraEnabled, locationMode: OpenClawLocationMode(rawValue: rawLocationMode) ?? .off, connectionMode: AppStateStore.shared.connectionMode) } diff --git a/apps/macos/Sources/OpenClaw/PermissionManager.swift b/apps/macos/Sources/OpenClaw/PermissionManager.swift index 1d4901063763..d36557d926e0 100644 --- a/apps/macos/Sources/OpenClaw/PermissionManager.swift +++ b/apps/macos/Sources/OpenClaw/PermissionManager.swift @@ -9,6 +9,10 @@ import OpenClawIPC import Speech import UserNotifications +extension Notification.Name { + static let openclawPermissionsChanged = Notification.Name("openclaw.permissions.changed") +} + enum PermissionManager { static func isLocationAuthorized(status: CLAuthorizationStatus, requireAlways: Bool) -> Bool { if requireAlways { return status == .authorizedAlways } @@ -27,6 +31,11 @@ enum PermissionManager { for cap in caps { results[cap] = await self.ensureCapability(cap, interactive: interactive) } + if interactive { + await MainActor.run { + NotificationCenter.default.post(name: .openclawPermissionsChanged, object: nil) + } + } return results } @@ -458,6 +467,7 @@ final class PermissionMonitor { let latest = await PermissionManager.status() if latest != self.status { self.status = latest + NotificationCenter.default.post(name: .openclawPermissionsChanged, object: nil) } self.lastCheck = Date() diff --git a/apps/macos/Tests/OpenClawIPCTests/CritterIconRendererTests.swift b/apps/macos/Tests/OpenClawIPCTests/CritterIconRendererTests.swift index 3e1893438cab..1384ab6320ef 100644 --- a/apps/macos/Tests/OpenClawIPCTests/CritterIconRendererTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/CritterIconRendererTests.swift @@ -33,4 +33,29 @@ struct CritterIconRendererTests { @Test func `critter status label exercises helpers`() async { await CritterStatusLabel.exerciseForTesting() } + + @Test func `idle critter sleeps until its next animation`() { + let now = Date(timeIntervalSinceReferenceDate: 100) + let delay = CritterStatusLabel.nextAnimationTickDelay( + now: now, + isWorking: false, + deadlines: [ + now.addingTimeInterval(8), + now.addingTimeInterval(5), + now.addingTimeInterval(11), + now.addingTimeInterval(7), + ]) + + #expect(delay == 5) + } + + @Test func `working critter keeps its animation cadence`() { + let now = Date(timeIntervalSinceReferenceDate: 100) + let delay = CritterStatusLabel.nextAnimationTickDelay( + now: now, + isWorking: true, + deadlines: [now.addingTimeInterval(8)]) + + #expect(delay == 0.35) + } }