mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-09 06:43:56 +00:00
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:
committed by
GitHub
parent
927bbeb58c
commit
37c67b0d27
@@ -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",
|
||||
|
||||
@@ -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]) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user