mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 04:47:03 -06:00
2b0da0e193
* test(macos): add OPENCLAW_DEBUG_OPEN_MENU screenshot hook * feat(macos): add live execution approval queue * refactor(macos): replace menu injectors with owned status menu * fix(macos): keep unconfigured menu header calm * fix(macos): route status-item right-clicks through a local event monitor NSControl's send-action mask ignores right mouse buttons, so the previous sendAction(on: [.rightMouseUp]) wiring never fired and the menu was unreachable by pointer. A local monitor now owns pointer routing (left = dashboard, right = menu) — the same mechanism the shipped StatusItemMouseRouter used — and menuWillOpen gained the re-entrancy guard the old injector carried, since reconciling tracked rows can re-enter the callback without a close. * chore(i18n): refresh native inventory for status menu strings * chore(macos): remove menu-refactor dead code Periphery flagged the orphans the status-menu refactor left behind: the ExecApprovalQuickMode enum and AppState's entire quick-mode read/retry surface (its only consumer was the deleted menu picker; the Settings pane owns exec-approval policy UI), SessionMenuLabelView, TrackingAreaSupport, NodeMenuMultilineView, UpdateStatus.disabled, and two fixture-only initializers. StatusMenuController.stop() is now wired into applicationWillTerminate. The menu-highlight environment key moved from the deleted view file into MenuItemHighlightColors. * chore(macos): fix status-menu lint style and refresh i18n inventory * fix(macos): converge approval cards after losing a resolution race The status-menu queue and the modal prompter intentionally share the gateway approval event stream: the gateway resolves each approval exactly once, the resolved broadcast removes the card, and the modal stays the active presentation owner while the menu is the passive, ambient one. What was missing: when the menu's resolve loses the race (modal or another client answered first), the gateway rejection left a zombie card if the resolved event was dropped. Resolve failures now re-list from the authoritative queue. Regression test simulates the race at the socket boundary and fails pre-fix.
385 lines
15 KiB
Swift
385 lines
15 KiB
Swift
import AppKit
|
|
import Foundation
|
|
import Observation
|
|
import SwiftUI
|
|
|
|
@MainActor
|
|
final class StatusMenuController: NSObject, NSMenuDelegate {
|
|
private let state: AppState
|
|
private let updater: UpdaterProviding
|
|
private let menu = NSMenu()
|
|
private let gatewayManager = GatewayProcessManager.shared
|
|
private let controlChannel = ControlChannel.shared
|
|
private let activityStore = WorkActivityStore.shared
|
|
private let sessions = StatusMenuSessions.shared
|
|
private let approvals = ExecApprovalQueueStore.shared
|
|
private let summaries = StatusMenuSummaries.shared
|
|
private let nodes = NodesStore.shared
|
|
private let cron = CronJobsStore.shared
|
|
private let dashboard = DashboardManager.shared
|
|
|
|
private var statusItem: NSStatusItem?
|
|
private var clickMonitor: Any?
|
|
private var hostingView: NSHostingView<StatusMenuIconView>?
|
|
private var renderer: StatusMenuRenderer?
|
|
private var refreshTask: Task<Void, Never>?
|
|
private var observationGeneration: UInt64 = 0
|
|
private var isMenuOpen = false
|
|
private var isChatWindowVisible = false
|
|
private var observedPaused: Bool
|
|
private var observedConnectionMode: AppState.ConnectionMode
|
|
private var observedPushToTalk: Bool
|
|
|
|
init(state: AppState, updater: UpdaterProviding) {
|
|
self.state = state
|
|
self.updater = updater
|
|
self.observedPaused = state.isPaused
|
|
self.observedConnectionMode = state.connectionMode
|
|
self.observedPushToTalk = state.voicePushToTalkEnabled
|
|
super.init()
|
|
}
|
|
|
|
func start() {
|
|
guard self.statusItem == nil else { return }
|
|
|
|
self.menu.autoenablesItems = false
|
|
self.menu.delegate = self
|
|
StatusMenuAppearance.pin(self.menu)
|
|
|
|
let renderer = StatusMenuRenderer(menu: self.menu, state: self.state)
|
|
renderer.onInstallUpdate = { [weak self] in
|
|
self?.updater.checkForUpdates(nil)
|
|
}
|
|
self.renderer = renderer
|
|
|
|
let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.squareLength)
|
|
self.statusItem = item
|
|
self.installButton(in: item)
|
|
self.installWindowCallbacks()
|
|
self.approvals.start()
|
|
VoicePushToTalkHotkey.shared.setEnabled(voiceWakeSupported && self.state.voicePushToTalkEnabled)
|
|
|
|
self.renderCachedMenu()
|
|
self.updateStatusAppearance()
|
|
self.observeChanges()
|
|
self.scheduleDebugMenuOpen()
|
|
}
|
|
|
|
func stop() {
|
|
self.observationGeneration &+= 1
|
|
self.refreshTask?.cancel()
|
|
self.refreshTask = nil
|
|
self.sessions.cancelPreviewTasks()
|
|
self.summaries.menuDidClose()
|
|
self.approvals.stop()
|
|
if let clickMonitor = self.clickMonitor {
|
|
NSEvent.removeMonitor(clickMonitor)
|
|
self.clickMonitor = nil
|
|
}
|
|
guard let statusItem else { return }
|
|
self.statusItem = nil
|
|
NSStatusBar.system.removeStatusItem(statusItem)
|
|
}
|
|
|
|
func menuWillOpen(_ menu: NSMenu) {
|
|
StatusMenuAppearance.pin(menu)
|
|
guard menu === self.menu, menu.supermenu == nil else { return }
|
|
// Reconciling tracked rows can re-enter this callback without a close;
|
|
// a second pass here would cancel refreshes and re-reconcile forever.
|
|
guard !self.isMenuOpen else { return }
|
|
|
|
// Cache projection must precede network work: AppKit begins tracking immediately.
|
|
self.renderCachedMenu()
|
|
self.isMenuOpen = true
|
|
self.refreshTask?.cancel()
|
|
self.refreshTask = Task { [weak self] in
|
|
guard let self else { return }
|
|
async let sessionRefresh: Void = self.sessions.refresh(force: true)
|
|
async let approvalRefresh: Void = self.approvals.refresh()
|
|
async let healthRefresh: Void = HealthStore.shared.refresh(onDemand: true)
|
|
_ = await (sessionRefresh, approvalRefresh, healthRefresh)
|
|
}
|
|
self.summaries.refresh { [weak self] in
|
|
guard let self, self.isMenuOpen else { return }
|
|
self.renderCachedMenu()
|
|
}
|
|
}
|
|
|
|
func menuDidClose(_ menu: NSMenu) {
|
|
guard menu === self.menu else { return }
|
|
self.isMenuOpen = false
|
|
self.sessions.cancelPreviewTasks()
|
|
self.summaries.menuDidClose()
|
|
// Leaving the menu attached makes subsequent left clicks open AppKit's menu.
|
|
self.statusItem?.menu = nil
|
|
self.statusItem?.button?.highlight(self.isChatWindowVisible)
|
|
}
|
|
|
|
/// Accessibility/keyboard activation path; pointer clicks are consumed by the
|
|
/// local event monitor below, so an action firing here has no mouse event.
|
|
@objc
|
|
private func handleStatusClick(_: Any?) {
|
|
AppNavigationActions.openDashboard()
|
|
}
|
|
|
|
private func installButton(in item: NSStatusItem) {
|
|
guard let button = item.button else { return }
|
|
let host = NSHostingView(rootView: self.makeIconView())
|
|
host.translatesAutoresizingMaskIntoConstraints = false
|
|
button.addSubview(host)
|
|
NSLayoutConstraint.activate([
|
|
host.centerXAnchor.constraint(equalTo: button.centerXAnchor),
|
|
host.centerYAnchor.constraint(equalTo: button.centerYAnchor),
|
|
host.widthAnchor.constraint(equalToConstant: 18),
|
|
host.heightAnchor.constraint(equalToConstant: 18),
|
|
])
|
|
self.hostingView = host
|
|
button.target = self
|
|
button.action = #selector(self.handleStatusClick(_:))
|
|
self.installClickMonitor()
|
|
}
|
|
|
|
/// NSControl's send-action mask ignores right mouse buttons, so a local
|
|
/// monitor owns pointer routing: left opens the dashboard, right the menu.
|
|
private func installClickMonitor() {
|
|
guard self.clickMonitor == nil else { return }
|
|
self.clickMonitor = NSEvent.addLocalMonitorForEvents(
|
|
matching: [.leftMouseDown, .rightMouseDown])
|
|
{ [weak self] event in
|
|
guard let self, let button = self.statusItem?.button,
|
|
let window = button.window, event.windowNumber == window.windowNumber
|
|
else { return event }
|
|
let point = button.convert(event.locationInWindow, from: nil)
|
|
guard button.bounds.contains(point) else { return event }
|
|
switch event.type {
|
|
case .leftMouseDown:
|
|
AppNavigationActions.openDashboard()
|
|
return nil
|
|
case .rightMouseDown:
|
|
self.presentMenu()
|
|
return nil
|
|
default:
|
|
return event
|
|
}
|
|
}
|
|
}
|
|
|
|
private func presentMenu() {
|
|
guard let item = self.statusItem, let button = item.button else { return }
|
|
item.menu = self.menu
|
|
button.performClick(nil)
|
|
}
|
|
|
|
private func installWindowCallbacks() {
|
|
WebChatManager.shared.onChatWindowVisibilityChanged = { [weak self] visible in
|
|
guard let self else { return }
|
|
self.isChatWindowVisible = visible
|
|
self.statusItem?.button?.highlight(visible || self.isMenuOpen)
|
|
}
|
|
CanvasManager.shared.onPanelVisibilityChanged = { [weak self] visible in
|
|
self?.state.canvasPanelVisible = visible
|
|
}
|
|
CanvasManager.shared.defaultAnchorProvider = { [weak self] in
|
|
guard let button = self?.statusItem?.button, let window = button.window else { return nil }
|
|
let frame = button.convert(button.bounds, to: nil)
|
|
return window.convertToScreen(frame)
|
|
}
|
|
}
|
|
|
|
private func makeIconView() -> StatusMenuIconView {
|
|
let sleeping = self.isGatewaySleeping
|
|
return StatusMenuIconView(label: CritterStatusLabel(
|
|
isPaused: self.state.isPaused,
|
|
isSleeping: sleeping,
|
|
isWorking: self.state.isWorking,
|
|
earBoostActive: self.state.earBoostActive,
|
|
blinkTick: self.state.blinkTick,
|
|
sendCelebrationTick: self.state.sendCelebrationTick,
|
|
gatewayStatus: self.gatewayManager.status,
|
|
connectionMode: self.state.connectionMode,
|
|
controlChannelState: self.controlChannel.state,
|
|
animationsEnabled: self.state.iconAnimationsEnabled && !sleeping,
|
|
iconState: self.effectiveIconState,
|
|
voiceWakeMeterActive: self.state.voiceWakeMeterActive))
|
|
}
|
|
|
|
private func renderCachedMenu() {
|
|
let connection: StatusMenuDescriptor.Connection = if self.state.connectionMode == .unconfigured {
|
|
.unconfigured
|
|
} else {
|
|
switch self.controlChannel.state {
|
|
case .connected: .connected
|
|
case .connecting: .connecting
|
|
case .disconnected: .disconnected
|
|
case .degraded: .degraded
|
|
}
|
|
}
|
|
let snapshot = StatusMenuDescriptor.Snapshot(
|
|
isPaused: self.state.isPaused,
|
|
connection: connection,
|
|
quickChatEnabled: self.state.quickChatEnabled,
|
|
canvasEnabled: self.state.canvasEnabled,
|
|
voiceWakeSupported: voiceWakeSupported,
|
|
debugEnabled: self.state.debugPaneEnabled,
|
|
updateReady: self.updater.isAvailable && self.updater.updateStatus.isUpdateReady,
|
|
hasUsage: self.summaries.hasUsage,
|
|
isUsageStalled: self.summaries.isUsageStalled,
|
|
sessions: self.sessions.rows,
|
|
sessionError: self.sessions.errorText,
|
|
mainSessionKey: self.activityStore.mainSessionKey,
|
|
approvals: self.approvals.requests,
|
|
gateways: DashboardGatewayMenuModel.items(from: self.dashboard.gatewayEntries))
|
|
self.renderer?.isSleeping = self.isGatewaySleeping
|
|
self.renderer?.reconcile(StatusMenuDescriptor.build(from: snapshot))
|
|
}
|
|
|
|
private func observeChanges() {
|
|
let generation = self.observationGeneration
|
|
withObservationTracking {
|
|
_ = self.makeIconView()
|
|
_ = self.state.voicePushToTalkEnabled
|
|
_ = self.state.quickChatEnabled
|
|
_ = self.state.canvasEnabled
|
|
_ = self.state.canvasPanelVisible
|
|
_ = self.state.talkEnabled
|
|
_ = self.state.debugPaneEnabled
|
|
_ = self.updater.updateStatus.isUpdateReady
|
|
_ = self.activityStore.current
|
|
_ = self.activityStore.mainSessionKey
|
|
_ = HealthStore.shared.state
|
|
_ = HealthStore.shared.isRefreshing
|
|
_ = HealthStore.shared.lastSuccess
|
|
_ = HealthStore.shared.degradedSummary
|
|
_ = MacNodeChannelStatusStore.shared.state
|
|
_ = self.nodes.nodes
|
|
_ = self.nodes.isLoading
|
|
_ = self.nodes.lastError
|
|
_ = self.nodes.localNodeIdentityState
|
|
_ = self.cron.jobs
|
|
_ = self.cron.schedulerNextWakeAtMs
|
|
_ = self.dashboard.gatewayEntries
|
|
_ = self.sessions.rows
|
|
_ = self.sessions.errorText
|
|
_ = self.approvals.requests
|
|
_ = self.summaries.hasUsage
|
|
_ = self.summaries.isUsageStalled
|
|
} onChange: { [weak self] in
|
|
Task { @MainActor [weak self] in
|
|
guard let self, self.observationGeneration == generation else { return }
|
|
self.applyStateSideEffects()
|
|
self.hostingView?.rootView = self.makeIconView()
|
|
self.updateStatusAppearance()
|
|
if self.isMenuOpen {
|
|
self.renderCachedMenu()
|
|
}
|
|
self.observeChanges()
|
|
}
|
|
}
|
|
}
|
|
|
|
private func applyStateSideEffects() {
|
|
let paused = self.state.isPaused
|
|
if paused != self.observedPaused {
|
|
self.observedPaused = paused
|
|
if self.state.connectionMode == .local {
|
|
self.gatewayManager.setActive(!paused)
|
|
} else {
|
|
self.gatewayManager.stop()
|
|
}
|
|
}
|
|
|
|
let mode = self.state.connectionMode
|
|
if mode != self.observedConnectionMode {
|
|
self.observedConnectionMode = mode
|
|
Task { await ConnectionModeCoordinator.shared.apply(mode: mode, paused: self.state.isPaused) }
|
|
if AppLaunchRuntimePlan.current.allowsAutomaticPresentation {
|
|
CLIInstallPrompter.shared.checkAndPromptIfNeeded(reason: "connection-mode")
|
|
}
|
|
BrowserProfileImportModel.shared.handleConnectionModeChange()
|
|
}
|
|
|
|
let pushToTalk = self.state.voicePushToTalkEnabled
|
|
if pushToTalk != self.observedPushToTalk {
|
|
self.observedPushToTalk = pushToTalk
|
|
VoicePushToTalkHotkey.shared.setEnabled(voiceWakeSupported && pushToTalk)
|
|
}
|
|
}
|
|
|
|
private func updateStatusAppearance() {
|
|
guard let button = self.statusItem?.button else { return }
|
|
button.appearsDisabled = false
|
|
button.toolTip = self.state.voiceWakeMeterActive
|
|
? String(localized: "OpenClaw - Voice Wake live meter active")
|
|
: String(localized: "OpenClaw")
|
|
}
|
|
|
|
private var isGatewaySleeping: Bool {
|
|
guard !self.state.isPaused else { return false }
|
|
return switch self.state.connectionMode {
|
|
case .unconfigured:
|
|
true
|
|
case .remote:
|
|
self.controlChannel.state != .connected
|
|
case .local:
|
|
switch self.gatewayManager.status {
|
|
case .running, .starting, .attachedExisting:
|
|
self.controlChannel.state != .connected
|
|
case .failed, .stopped:
|
|
true
|
|
}
|
|
}
|
|
}
|
|
|
|
private var effectiveIconState: IconState {
|
|
let selection = self.state.iconOverride
|
|
guard selection != .system else { return self.activityStore.iconState }
|
|
return switch selection.toIconState() {
|
|
case let .workingMain(kind), let .workingOther(kind), let .overridden(kind):
|
|
.overridden(kind)
|
|
case .idle:
|
|
.idle
|
|
}
|
|
}
|
|
|
|
private func scheduleDebugMenuOpen() {
|
|
#if DEBUG
|
|
let environment = ProcessInfo.processInfo.environment
|
|
if environment["OPENCLAW_DEBUG_OPEN_MENU"] == "1" {
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { [weak self] in
|
|
guard let self, let button = self.statusItem?.button else { return }
|
|
self.statusItem?.menu = self.menu
|
|
button.performClick(nil)
|
|
}
|
|
}
|
|
if environment["OPENCLAW_DEBUG_PROBE_RIGHTCLICK"] == "1" {
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { [weak self] in
|
|
guard let self, let button = self.statusItem?.button,
|
|
let window = button.window else { return }
|
|
let center = NSPoint(x: button.bounds.midX, y: button.bounds.midY)
|
|
let inWindow = button.convert(center, to: nil)
|
|
guard let event = NSEvent.mouseEvent(
|
|
with: .rightMouseDown,
|
|
location: inWindow,
|
|
modifierFlags: [],
|
|
timestamp: ProcessInfo.processInfo.systemUptime,
|
|
windowNumber: window.windowNumber,
|
|
context: nil,
|
|
eventNumber: 0,
|
|
clickCount: 1,
|
|
pressure: 1) else { return }
|
|
// Route through the local monitor path a real click takes.
|
|
NSApp.postEvent(event, atStart: false)
|
|
}
|
|
}
|
|
#endif
|
|
}
|
|
}
|
|
|
|
private struct StatusMenuIconView: View {
|
|
let label: CritterStatusLabel
|
|
|
|
var body: some View {
|
|
self.label.background(SettingsWindowOpenRegistrar())
|
|
}
|
|
}
|