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
This commit is contained in:
Peter Steinberger
2026-07-05 23:35:50 +01:00
committed by GitHub
parent 927bbeb58c
commit 37c67b0d27
6 changed files with 161 additions and 31 deletions

View File

@@ -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",

View File

@@ -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]) {

View File

@@ -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 {

View File

@@ -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<Void, Never>?
private var endpointRefreshTask: Task<Void, Never>?
private var reconnectProbeTask: Task<Void, Never>?
private let runtime: MacNodeRuntime
private let session: GatewayNodeSession
private let refreshEvents: AsyncStream<Void>
private let refreshContinuation: AsyncStream<Void>.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)
}

View File

@@ -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()

View File

@@ -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)
}
}