mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-06 05:51:15 +08:00
fix(macos): prevent duplicate menu bar icons
Fix macOS menu bar status-item storms during rapid gateway connection churn by removing stale SwiftUI-vended status items before adopting replacements and debouncing transient control-channel states. Surface: macOS menu bar app, `MenuBarExtra` status item ownership, `ControlChannel` UI-observed connection state. Proof: - `git diff --check origin/main...pr/82739` - `swift test --package-path apps/macos --filter ControlChannelStateDebouncerTests` - PR CI: preflight, security-fast, macos-node, macos-swift, dependency-guard, changed-path scan, real behavior proof, Socket checks Co-authored-by: Alexander Falk <al@falk.us>
This commit is contained in:
@@ -39,6 +39,48 @@ enum ControlChannelError: Error, LocalizedError {
|
||||
}
|
||||
}
|
||||
|
||||
struct ControlChannelStateDebouncer {
|
||||
private let interval: TimeInterval
|
||||
private var lastAppliedAt: Date
|
||||
|
||||
init(interval: TimeInterval = 0.5, lastAppliedAt: Date = .distantPast) {
|
||||
self.interval = interval
|
||||
self.lastAppliedAt = lastAppliedAt
|
||||
}
|
||||
|
||||
mutating func delayBeforeApplying(
|
||||
currentState: ControlChannel.ConnectionState,
|
||||
newState: ControlChannel.ConnectionState,
|
||||
now: Date) -> TimeInterval?
|
||||
{
|
||||
if Self.isTerminal(currentState) || Self.isTerminal(newState) {
|
||||
self.lastAppliedAt = now
|
||||
return nil
|
||||
}
|
||||
|
||||
let elapsed = now.timeIntervalSince(self.lastAppliedAt)
|
||||
guard elapsed < self.interval else {
|
||||
self.lastAppliedAt = now
|
||||
return nil
|
||||
}
|
||||
|
||||
return self.interval - max(0, elapsed)
|
||||
}
|
||||
|
||||
mutating func recordDeferredApply(at date: Date) {
|
||||
self.lastAppliedAt = date
|
||||
}
|
||||
|
||||
private static func isTerminal(_ state: ControlChannel.ConnectionState) -> Bool {
|
||||
switch state {
|
||||
case .connected, .disconnected:
|
||||
true
|
||||
case .connecting, .degraded:
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
final class ControlChannel {
|
||||
@@ -85,6 +127,46 @@ final class ControlChannel {
|
||||
private var recoveryTask: Task<Void, Never>?
|
||||
private var lastRecoveryAt: Date?
|
||||
|
||||
// Coalesce rapid connecting/degraded oscillations so SwiftUI does not churn
|
||||
// MenuBarExtra status items while the gateway connection is unstable.
|
||||
private var pendingStateTask: Task<Void, Never>?
|
||||
private var stateDebouncer = ControlChannelStateDebouncer()
|
||||
|
||||
private func setStateThrottled(_ newState: ConnectionState) {
|
||||
let now = Date()
|
||||
if let delay = self.stateDebouncer.delayBeforeApplying(
|
||||
currentState: self.state,
|
||||
newState: newState,
|
||||
now: now)
|
||||
{
|
||||
self.pendingStateTask?.cancel()
|
||||
self.pendingStateTask = Task { [weak self] in
|
||||
try? await Task.sleep(nanoseconds: Self.nanoseconds(for: delay))
|
||||
guard let self, !Task.isCancelled else { return }
|
||||
self.pendingStateTask = nil
|
||||
self.stateDebouncer.recordDeferredApply(at: Date())
|
||||
self.applyState(newState)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
self.cancelPendingStateTask()
|
||||
self.applyState(newState)
|
||||
}
|
||||
|
||||
private func cancelPendingStateTask() {
|
||||
self.pendingStateTask?.cancel()
|
||||
self.pendingStateTask = nil
|
||||
}
|
||||
|
||||
private func applyState(_ newState: ConnectionState) {
|
||||
self.state = newState
|
||||
}
|
||||
|
||||
private static func nanoseconds(for interval: TimeInterval) -> UInt64 {
|
||||
UInt64(max(0, interval) * 1_000_000_000)
|
||||
}
|
||||
|
||||
private init() {
|
||||
self.startEventStream()
|
||||
}
|
||||
@@ -105,11 +187,11 @@ final class ControlChannel {
|
||||
self.logger.info(
|
||||
"control channel configure mode=remote " +
|
||||
"target=\(target, privacy: .public) identitySet=\(idSet, privacy: .public)")
|
||||
self.state = .connecting
|
||||
self.setStateThrottled(.connecting)
|
||||
_ = try await GatewayEndpointStore.shared.ensureRemoteControlTunnel()
|
||||
await self.refreshEndpoint(reason: "configure")
|
||||
} catch {
|
||||
self.state = .degraded(error.localizedDescription)
|
||||
self.setStateThrottled(.degraded(error.localizedDescription))
|
||||
throw error
|
||||
}
|
||||
}
|
||||
@@ -117,20 +199,20 @@ final class ControlChannel {
|
||||
|
||||
func refreshEndpoint(reason: String) async {
|
||||
self.logger.info("control channel refresh endpoint reason=\(reason, privacy: .public)")
|
||||
self.state = .connecting
|
||||
self.setStateThrottled(.connecting)
|
||||
do {
|
||||
try await self.establishGatewayConnection()
|
||||
self.state = .connected
|
||||
self.setStateThrottled(.connected)
|
||||
PresenceReporter.shared.sendImmediate(reason: "connect")
|
||||
} catch {
|
||||
let message = self.friendlyGatewayMessage(error)
|
||||
self.state = .degraded(message)
|
||||
self.setStateThrottled(.degraded(message))
|
||||
}
|
||||
}
|
||||
|
||||
func disconnect() async {
|
||||
await GatewayConnection.shared.shutdown()
|
||||
self.state = .disconnected
|
||||
self.setStateThrottled(.disconnected)
|
||||
self.lastPingMs = nil
|
||||
self.authSourceLabel = nil
|
||||
}
|
||||
@@ -146,11 +228,11 @@ final class ControlChannel {
|
||||
let payload = try await self.request(method: "health", params: params, timeoutMs: timeoutMs)
|
||||
let ms = Date().timeIntervalSince(start) * 1000
|
||||
self.lastPingMs = ms
|
||||
self.state = .connected
|
||||
self.setStateThrottled(.connected)
|
||||
return payload
|
||||
} catch {
|
||||
let message = self.friendlyGatewayMessage(error)
|
||||
self.state = .degraded(message)
|
||||
self.setStateThrottled(.degraded(message))
|
||||
throw ControlChannelError.badResponse(message)
|
||||
}
|
||||
}
|
||||
@@ -173,11 +255,11 @@ final class ControlChannel {
|
||||
method: method,
|
||||
params: rawParams,
|
||||
timeoutMs: timeoutMs)
|
||||
self.state = .connected
|
||||
self.setStateThrottled(.connected)
|
||||
return data
|
||||
} catch {
|
||||
let message = self.friendlyGatewayMessage(error)
|
||||
self.state = .degraded(message)
|
||||
self.setStateThrottled(.degraded(message))
|
||||
throw ControlChannelError.badResponse(message)
|
||||
}
|
||||
}
|
||||
@@ -386,9 +468,9 @@ final class ControlChannel {
|
||||
NotificationCenter.default.post(name: .controlHeartbeat, object: data)
|
||||
}
|
||||
case let .event(evt) where evt.event == "shutdown":
|
||||
self.state = .degraded("gateway shutdown")
|
||||
self.setStateThrottled(.degraded("gateway shutdown"))
|
||||
case .snapshot:
|
||||
self.state = .connected
|
||||
self.setStateThrottled(.connected)
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
@@ -55,6 +55,13 @@ struct OpenClawApp: App {
|
||||
.background(SettingsWindowOpenRegistrar())
|
||||
}
|
||||
.menuBarExtraAccess(isPresented: self.$isMenuPresented) { item in
|
||||
// SwiftUI can vend a replacement status item during connection churn.
|
||||
// Keep ownership to one item so stale menu bar icons are removed.
|
||||
if let currentStatusItem = self.statusItem {
|
||||
guard currentStatusItem !== item else { return }
|
||||
Self.logger.warning("Replacing stale menu bar status item")
|
||||
NSStatusBar.system.removeStatusItem(currentStatusItem)
|
||||
}
|
||||
self.statusItem = item
|
||||
MenuSessionsInjector.shared.install(into: item)
|
||||
self.applyStatusItemAppearance(paused: self.state.isPaused, sleeping: self.isGatewaySleeping)
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import OpenClaw
|
||||
|
||||
struct ControlChannelStateDebouncerTests {
|
||||
@Test func `terminal states apply immediately`() {
|
||||
let start = Date(timeIntervalSince1970: 1_000)
|
||||
var debouncer = ControlChannelStateDebouncer(interval: 0.5, lastAppliedAt: start)
|
||||
|
||||
let degradedDelay = debouncer.delayBeforeApplying(
|
||||
currentState: .connecting,
|
||||
newState: .degraded("gateway unavailable"),
|
||||
now: start.addingTimeInterval(0.1))
|
||||
#expect(degradedDelay != nil)
|
||||
|
||||
let connectedDelay = debouncer.delayBeforeApplying(
|
||||
currentState: .connecting,
|
||||
newState: .connected,
|
||||
now: start.addingTimeInterval(0.2))
|
||||
#expect(connectedDelay == nil)
|
||||
|
||||
let afterTerminalDelay = debouncer.delayBeforeApplying(
|
||||
currentState: .connected,
|
||||
newState: .connecting,
|
||||
now: start.addingTimeInterval(0.3))
|
||||
#expect(afterTerminalDelay == nil)
|
||||
}
|
||||
|
||||
@Test func `nonterminal states are debounced within interval`() {
|
||||
let start = Date(timeIntervalSince1970: 1_000)
|
||||
var debouncer = ControlChannelStateDebouncer(interval: 0.5, lastAppliedAt: start)
|
||||
|
||||
let soonDelay = debouncer.delayBeforeApplying(
|
||||
currentState: .connecting,
|
||||
newState: .degraded("gateway unavailable"),
|
||||
now: start.addingTimeInterval(0.1))
|
||||
#expect(soonDelay != nil)
|
||||
#expect(abs((soonDelay ?? 0) - 0.4) < 0.001)
|
||||
|
||||
let afterWindowDelay = debouncer.delayBeforeApplying(
|
||||
currentState: .connecting,
|
||||
newState: .degraded("gateway unavailable"),
|
||||
now: start.addingTimeInterval(0.6))
|
||||
#expect(afterWindowDelay == nil)
|
||||
}
|
||||
|
||||
@Test func `deferred apply resets debounce window`() {
|
||||
let start = Date(timeIntervalSince1970: 1_000)
|
||||
var debouncer = ControlChannelStateDebouncer(interval: 0.5, lastAppliedAt: start)
|
||||
|
||||
debouncer.recordDeferredApply(at: start.addingTimeInterval(0.5))
|
||||
|
||||
let delayAfterDeferredUpdate = debouncer.delayBeforeApplying(
|
||||
currentState: .degraded("gateway unavailable"),
|
||||
newState: .connecting,
|
||||
now: start.addingTimeInterval(0.7))
|
||||
#expect(delayAfterDeferredUpdate != nil)
|
||||
#expect(abs((delayAfterDeferredUpdate ?? 0) - 0.3) < 0.001)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user