Skip to content

Commit 37c67b0

Browse files
authored
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
1 parent 927bbeb commit 37c67b0

6 files changed

Lines changed: 161 additions & 31 deletions

File tree

apps/.i18n/native-source.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13027,15 +13027,15 @@
1302713027
},
1302813028
{
1302913029
"kind": "conditional-branch",
13030-
"line": 641,
13030+
"line": 642,
1303113031
"path": "apps/macos/Sources/OpenClaw/AppState.swift",
1303213032
"source": "\\(user)@\\(host)",
1303313033
"surface": "apple",
1303413034
"id": "native.apple.9cd2ced909f0952d"
1303513035
},
1303613036
{
1303713037
"kind": "conditional-branch",
13038-
"line": 641,
13038+
"line": 642,
1303913039
"path": "apps/macos/Sources/OpenClaw/AppState.swift",
1304013040
"source": "\\(user)@\\(host):\\(port)",
1304113041
"surface": "apple",

apps/macos/Sources/OpenClaw/AppState.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -580,6 +580,7 @@ final class AppState {
580580
private func applyConfigFromDisk() {
581581
let root = OpenClawConfigFile.loadDict()
582582
self.applyConfigOverrides(root)
583+
MacNodeModeCoordinator.shared.refresh()
583584
}
584585

585586
private func applyConfigOverrides(_ root: [String: Any]) {

apps/macos/Sources/OpenClaw/CritterStatusLabel+Behavior.swift

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ extension CritterStatusLabel {
77
}
88

99
private var effectiveAnimationsEnabled: Bool {
10-
self.animationsEnabled && !self.isSleeping
10+
self.animationsEnabled && !self.isSleeping && !self.isPaused
1111
}
1212

1313
var body: some View {
@@ -24,10 +24,14 @@ extension CritterStatusLabel {
2424
return
2525
}
2626

27+
await MainActor.run { self.rescheduleElapsedAnimationTimers(from: Date()) }
2728
while !Task.isCancelled {
2829
let now = Date()
29-
await MainActor.run { self.tick(now) }
30-
try? await Task.sleep(nanoseconds: 350_000_000)
30+
let delay = await MainActor.run {
31+
self.tick(now)
32+
return self.nextTickDelay(after: now)
33+
}
34+
try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
3135
}
3236
}
3337
.onChange(of: self.isPaused) { _, _ in self.resetMotion() }
@@ -77,7 +81,27 @@ extension CritterStatusLabel {
7781

7882
private var tickTaskID: Int {
7983
// Ensure SwiftUI restarts (and cancels) the task when these change.
80-
(self.effectiveAnimationsEnabled ? 1 : 0) | (self.earBoostActive ? 2 : 0)
84+
(self.effectiveAnimationsEnabled ? 1 : 0) |
85+
(self.earBoostActive ? 2 : 0) |
86+
(self.isWorkingNow ? 4 : 0)
87+
}
88+
89+
private func nextTickDelay(after now: Date) -> TimeInterval {
90+
Self.nextAnimationTickDelay(
91+
now: now,
92+
isWorking: self.isWorkingNow,
93+
deadlines: [self.nextBlink, self.nextWiggle, self.nextLegWiggle, self.nextEarWiggle])
94+
}
95+
96+
static func nextAnimationTickDelay(
97+
now: Date,
98+
isWorking: Bool,
99+
deadlines: [Date]) -> TimeInterval
100+
{
101+
// Working motion needs a steady cadence; idle motion only wakes for its next visible event.
102+
if isWorking { return 0.35 }
103+
guard let nextDeadline = deadlines.min() else { return 1 }
104+
return max(0.05, nextDeadline.timeIntervalSince(now))
81105
}
82106

83107
private func tick(_ now: Date) {
@@ -215,6 +239,12 @@ extension CritterStatusLabel {
215239
self.nextEarWiggle = date.addingTimeInterval(Double.random(in: 7.0...14.0))
216240
}
217241

242+
private func rescheduleElapsedAnimationTimers(from date: Date) {
243+
let deadlines = [self.nextBlink, self.nextWiggle, self.nextLegWiggle, self.nextEarWiggle]
244+
guard deadlines.contains(where: { $0 <= date }) else { return }
245+
self.scheduleRandomTimers(from: date)
246+
}
247+
218248
private var gatewayNeedsAttention: Bool {
219249
if self.isSleeping { return false }
220250
switch self.gatewayStatus {

apps/macos/Sources/OpenClaw/NodeMode/MacNodeModeCoordinator.swift

Lines changed: 89 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import AppKit
12
import Foundation
23
import OpenClawKit
34
import OSLog
@@ -40,7 +41,7 @@ struct MacNodeGatewayTLSSessionCache {
4041
}
4142

4243
@MainActor
43-
final class MacNodeModeCoordinator {
44+
final class MacNodeModeCoordinator: NSObject {
4445
static let shared = MacNodeModeCoordinator()
4546
static var nodeIdentityProfile: GatewayDeviceIdentityProfile {
4647
self.resolveNodeIdentityProfile(
@@ -73,71 +74,108 @@ final class MacNodeModeCoordinator {
7374

7475
private let logger = Logger(subsystem: "ai.openclaw", category: "mac-node")
7576
private var task: Task<Void, Never>?
77+
private var endpointRefreshTask: Task<Void, Never>?
78+
private var reconnectProbeTask: Task<Void, Never>?
7679
private let runtime: MacNodeRuntime
7780
private let session: GatewayNodeSession
81+
private let refreshEvents: AsyncStream<Void>
82+
private let refreshContinuation: AsyncStream<Void>.Continuation
7883
private var autoRepairedTLSFingerprintsByStoreKey: [String: String] = [:]
7984
private var tlsSessionCache = MacNodeGatewayTLSSessionCache()
8085

81-
private init() {
86+
override private init() {
8287
let session = GatewayNodeSession()
88+
let refreshEvents = AsyncStream.makeStream(of: Void.self, bufferingPolicy: .bufferingNewest(1))
8389
self.session = session
8490
self.runtime = MacNodeRuntime(
8591
canvasSurfaceUrl: { await session.currentCanvasHostUrl() },
8692
refreshCanvasSurfaceUrl: { await session.refreshCanvasHostUrl() })
93+
self.refreshEvents = refreshEvents.stream
94+
self.refreshContinuation = refreshEvents.continuation
95+
super.init()
96+
97+
NotificationCenter.default.addObserver(
98+
self,
99+
selector: #selector(self.refreshNodeConfiguration),
100+
name: UserDefaults.didChangeNotification,
101+
object: UserDefaults.standard)
102+
NotificationCenter.default.addObserver(
103+
self,
104+
selector: #selector(self.refreshNodeConfiguration),
105+
name: NSApplication.didBecomeActiveNotification,
106+
object: nil)
107+
NotificationCenter.default.addObserver(
108+
self,
109+
selector: #selector(self.refreshNodeConfiguration),
110+
name: .openclawPermissionsChanged,
111+
object: nil)
112+
}
113+
114+
deinit {
115+
NotificationCenter.default.removeObserver(self)
116+
self.refreshContinuation.finish()
87117
}
88118

89119
func start() {
90120
guard self.task == nil else { return }
91121
self.task = Task { [weak self] in
92122
await self?.run()
93123
}
124+
self.endpointRefreshTask = Task { [weak self] in
125+
let states = await GatewayEndpointStore.shared.subscribe()
126+
var previousState: GatewayEndpointState?
127+
for await state in states {
128+
if let previousState, state != previousState {
129+
self?.refresh()
130+
}
131+
previousState = state
132+
}
133+
}
94134
}
95135

96136
func stop() {
97137
self.task?.cancel()
98138
self.task = nil
139+
self.endpointRefreshTask?.cancel()
140+
self.endpointRefreshTask = nil
141+
self.reconnectProbeTask?.cancel()
142+
self.reconnectProbeTask = nil
99143
Task { await self.session.disconnect() }
100144
}
101145

102146
func setPreferredGatewayStableID(_ stableID: String?) {
103147
GatewayDiscoveryPreferences.setPreferredStableID(stableID)
104-
Task { await self.session.disconnect() }
148+
Task {
149+
await self.session.disconnect()
150+
self.refresh()
151+
}
152+
}
153+
154+
func refresh() {
155+
self.refreshContinuation.yield()
105156
}
106157

107158
private func run() async {
108159
var retryDelay: UInt64 = 1_000_000_000
109-
var lastCameraEnabled: Bool?
110-
var lastBrowserControlEnabled: Bool?
160+
var refreshIterator = self.refreshEvents.makeAsyncIterator()
111161
let defaults = UserDefaults.standard
112162

113163
while !Task.isCancelled {
114164
if await MainActor.run(body: { AppStateStore.shared.isPaused }) {
115-
try? await Task.sleep(nanoseconds: 1_000_000_000)
165+
guard await refreshIterator.next() != nil else { return }
116166
continue
117167
}
118168

119169
let cameraEnabled = defaults.object(forKey: cameraEnabledKey) as? Bool ?? false
120-
if lastCameraEnabled == nil {
121-
lastCameraEnabled = cameraEnabled
122-
} else if lastCameraEnabled != cameraEnabled {
123-
lastCameraEnabled = cameraEnabled
124-
await self.session.disconnect()
125-
try? await Task.sleep(nanoseconds: 200_000_000)
126-
}
127170
let browserControlEnabled = OpenClawConfigFile.browserControlEnabled()
128-
if lastBrowserControlEnabled == nil {
129-
lastBrowserControlEnabled = browserControlEnabled
130-
} else if lastBrowserControlEnabled != browserControlEnabled {
131-
lastBrowserControlEnabled = browserControlEnabled
132-
await self.session.disconnect()
133-
try? await Task.sleep(nanoseconds: 200_000_000)
134-
}
135171

136172
var attemptedURL: URL?
137173
do {
138174
let config = try await GatewayEndpointStore.shared.requireConfig()
139175
attemptedURL = config.url
140-
let caps = self.currentCaps()
176+
let caps = self.currentCaps(
177+
browserControlEnabled: browserControlEnabled,
178+
cameraEnabled: cameraEnabled)
141179
let commands = self.currentCommands(caps: caps)
142180
let permissions = await self.currentPermissions()
143181
let connectOptions = GatewayConnectOptions(
@@ -163,6 +201,7 @@ final class MacNodeModeCoordinator {
163201
sessionBox: sessionBox,
164202
onConnected: { [weak self] in
165203
guard let self else { return }
204+
await self.cancelReconnectProbe()
166205
self.logger.info("mac node connected to gateway")
167206
let mainSessionKey = await GatewayConnection.shared.mainSessionKey()
168207
await self.runtime.updateMainSessionKey(mainSessionKey)
@@ -174,6 +213,7 @@ final class MacNodeModeCoordinator {
174213
onDisconnected: { [weak self] reason in
175214
guard let self else { return }
176215
await self.runtime.setEventSender(nil)
216+
await self.scheduleReconnectProbe()
177217
self.logger.error("mac node disconnected: \(reason, privacy: .public)")
178218
},
179219
onInvoke: { [weak self] req in
@@ -187,7 +227,9 @@ final class MacNodeModeCoordinator {
187227
})
188228

189229
retryDelay = 1_000_000_000
190-
try? await Task.sleep(nanoseconds: 1_000_000_000)
230+
// GatewayNodeSession owns transport reconnects. Wait until inputs can
231+
// actually change instead of rereading config and TCC state every second.
232+
guard await refreshIterator.next() != nil else { return }
191233
} catch {
192234
if await self.autoRepairStaleTLSPinIfNeeded(error: error, url: attemptedURL) {
193235
retryDelay = 1_000_000_000
@@ -200,6 +242,28 @@ final class MacNodeModeCoordinator {
200242
}
201243
}
202244

245+
private func scheduleReconnectProbe() {
246+
self.reconnectProbeTask?.cancel()
247+
// GatewayChannel reconnects normally, but pauses after auth or pairing failures.
248+
// Probe only while disconnected so recovery does not restore steady idle polling.
249+
self.reconnectProbeTask = Task { [weak self] in
250+
try? await Task.sleep(for: .seconds(30))
251+
guard !Task.isCancelled else { return }
252+
self?.refresh()
253+
}
254+
}
255+
256+
private func cancelReconnectProbe() {
257+
self.reconnectProbeTask?.cancel()
258+
self.reconnectProbeTask = nil
259+
}
260+
261+
@objc private nonisolated func refreshNodeConfiguration(_: Notification) {
262+
Task { @MainActor [weak self] in
263+
self?.refresh()
264+
}
265+
}
266+
203267
nonisolated static func resolvedCaps(
204268
browserControlEnabled: Bool,
205269
cameraEnabled: Bool,
@@ -222,11 +286,11 @@ final class MacNodeModeCoordinator {
222286
return caps
223287
}
224288

225-
private func currentCaps() -> [String] {
289+
private func currentCaps(browserControlEnabled: Bool, cameraEnabled: Bool) -> [String] {
226290
let rawLocationMode = UserDefaults.standard.string(forKey: locationModeKey) ?? "off"
227291
return Self.resolvedCaps(
228-
browserControlEnabled: OpenClawConfigFile.browserControlEnabled(),
229-
cameraEnabled: UserDefaults.standard.object(forKey: cameraEnabledKey) as? Bool ?? false,
292+
browserControlEnabled: browserControlEnabled,
293+
cameraEnabled: cameraEnabled,
230294
locationMode: OpenClawLocationMode(rawValue: rawLocationMode) ?? .off,
231295
connectionMode: AppStateStore.shared.connectionMode)
232296
}

apps/macos/Sources/OpenClaw/PermissionManager.swift

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@ import OpenClawIPC
99
import Speech
1010
import UserNotifications
1111

12+
extension Notification.Name {
13+
static let openclawPermissionsChanged = Notification.Name("openclaw.permissions.changed")
14+
}
15+
1216
enum PermissionManager {
1317
static func isLocationAuthorized(status: CLAuthorizationStatus, requireAlways: Bool) -> Bool {
1418
if requireAlways { return status == .authorizedAlways }
@@ -27,6 +31,11 @@ enum PermissionManager {
2731
for cap in caps {
2832
results[cap] = await self.ensureCapability(cap, interactive: interactive)
2933
}
34+
if interactive {
35+
await MainActor.run {
36+
NotificationCenter.default.post(name: .openclawPermissionsChanged, object: nil)
37+
}
38+
}
3039
return results
3140
}
3241

@@ -458,6 +467,7 @@ final class PermissionMonitor {
458467
let latest = await PermissionManager.status()
459468
if latest != self.status {
460469
self.status = latest
470+
NotificationCenter.default.post(name: .openclawPermissionsChanged, object: nil)
461471
}
462472
self.lastCheck = Date()
463473

apps/macos/Tests/OpenClawIPCTests/CritterIconRendererTests.swift

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,4 +33,29 @@ struct CritterIconRendererTests {
3333
@Test func `critter status label exercises helpers`() async {
3434
await CritterStatusLabel.exerciseForTesting()
3535
}
36+
37+
@Test func `idle critter sleeps until its next animation`() {
38+
let now = Date(timeIntervalSinceReferenceDate: 100)
39+
let delay = CritterStatusLabel.nextAnimationTickDelay(
40+
now: now,
41+
isWorking: false,
42+
deadlines: [
43+
now.addingTimeInterval(8),
44+
now.addingTimeInterval(5),
45+
now.addingTimeInterval(11),
46+
now.addingTimeInterval(7),
47+
])
48+
49+
#expect(delay == 5)
50+
}
51+
52+
@Test func `working critter keeps its animation cadence`() {
53+
let now = Date(timeIntervalSinceReferenceDate: 100)
54+
let delay = CritterStatusLabel.nextAnimationTickDelay(
55+
now: now,
56+
isWorking: true,
57+
deadlines: [now.addingTimeInterval(8)])
58+
59+
#expect(delay == 0.35)
60+
}
3661
}

0 commit comments

Comments
 (0)