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.
488 lines
18 KiB
Swift
488 lines
18 KiB
Swift
import AppKit
|
|
import Foundation
|
|
import KeyboardShortcuts
|
|
import OpenClawKit
|
|
import QuartzCore
|
|
import SwiftUI
|
|
|
|
@MainActor
|
|
final class StatusMenuRenderer: NSObject {
|
|
static let cardWidth: CGFloat = 330
|
|
|
|
private enum RenderEntry {
|
|
case content(StatusMenuDescriptor.Entry)
|
|
case separator(String)
|
|
|
|
var id: String {
|
|
switch self {
|
|
case let .content(entry): entry.id
|
|
case let .separator(id): id
|
|
}
|
|
}
|
|
|
|
var isSeparator: Bool {
|
|
if case .separator = self {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
}
|
|
|
|
private let menu: NSMenu
|
|
private let state: AppState
|
|
private var testNotificationPending = false
|
|
var isSleeping = false
|
|
var onInstallUpdate: (@MainActor () -> Void)?
|
|
|
|
init(menu: NSMenu, state: AppState = AppStateStore.shared) {
|
|
self.menu = menu
|
|
self.state = state
|
|
super.init()
|
|
menu.autoenablesItems = false
|
|
StatusMenuAppearance.pin(menu)
|
|
}
|
|
|
|
func render(_ descriptor: StatusMenuDescriptor) {
|
|
self.reconcile(descriptor)
|
|
}
|
|
|
|
func reconcile(_ descriptor: StatusMenuDescriptor) {
|
|
let entries = self.flatten(descriptor)
|
|
let liveItems = self.menu.items
|
|
|
|
func matches(_ item: NSMenuItem, _ entry: RenderEntry) -> Bool {
|
|
item.isSeparatorItem == entry.isSeparator && item.representedObject as? String == entry.id
|
|
}
|
|
|
|
var prefix = 0
|
|
while prefix < min(liveItems.count, entries.count), matches(liveItems[prefix], entries[prefix]) {
|
|
prefix += 1
|
|
}
|
|
|
|
var suffix = 0
|
|
while suffix < min(liveItems.count, entries.count) - prefix,
|
|
matches(liveItems[liveItems.count - suffix - 1], entries[entries.count - suffix - 1])
|
|
{
|
|
suffix += 1
|
|
}
|
|
|
|
CATransaction.begin()
|
|
CATransaction.setDisableActions(true)
|
|
defer { CATransaction.commit() }
|
|
|
|
for index in 0..<prefix {
|
|
self.configure(liveItems[index], as: entries[index])
|
|
}
|
|
for offset in 0..<suffix {
|
|
self.configure(
|
|
liveItems[liveItems.count - offset - 1],
|
|
as: entries[entries.count - offset - 1])
|
|
}
|
|
|
|
// Preserve the tracked prefix/suffix so AppKit never observes an empty open menu.
|
|
for _ in 0..<(liveItems.count - prefix - suffix) {
|
|
self.menu.removeItem(at: prefix)
|
|
}
|
|
for (offset, entry) in entries[prefix..<(entries.count - suffix)].enumerated() {
|
|
self.menu.insertItem(self.makeItem(for: entry), at: prefix + offset)
|
|
}
|
|
}
|
|
|
|
private func flatten(_ descriptor: StatusMenuDescriptor) -> [RenderEntry] {
|
|
var entries: [RenderEntry] = []
|
|
for section in descriptor.sections where !section.entries.isEmpty {
|
|
if !entries.isEmpty {
|
|
entries.append(.separator("separator.\(section.id)"))
|
|
}
|
|
entries.append(contentsOf: section.entries.map(RenderEntry.content))
|
|
}
|
|
return entries
|
|
}
|
|
|
|
private func makeItem(for entry: RenderEntry) -> NSMenuItem {
|
|
let item = switch entry {
|
|
case .separator:
|
|
NSMenuItem.separator()
|
|
case let .content(content):
|
|
if case .gatewayHeader = content.kind {
|
|
NSMenuItem.sectionHeader(title: String(localized: "Gateways"))
|
|
} else {
|
|
NSMenuItem()
|
|
}
|
|
}
|
|
item.representedObject = entry.id
|
|
self.configure(item, as: entry)
|
|
return item
|
|
}
|
|
|
|
private func configure(_ item: NSMenuItem, as entry: RenderEntry) {
|
|
guard case let .content(content) = entry else { return }
|
|
item.representedObject = content.id
|
|
|
|
switch content.kind {
|
|
case .header:
|
|
self.configureHeader(item)
|
|
case let .session(row):
|
|
StatusMenuSessions.shared.configureSessionItem(item, row: row)
|
|
case let .approval(request):
|
|
StatusMenuSessions.shared.configureApprovalItem(item, request: request)
|
|
case let .placeholder(title):
|
|
item.title = title
|
|
item.isEnabled = false
|
|
case let .action(action):
|
|
self.configureAction(item, action: action)
|
|
case let .summary(summary):
|
|
switch summary {
|
|
case .automations: StatusMenuSummaries.shared.configureAutomations(item)
|
|
case .usage: StatusMenuSummaries.shared.configureUsage(item)
|
|
case .devices: StatusMenuSummaries.shared.configureDevices(item)
|
|
}
|
|
case let .gateway(gateway, isAlternate):
|
|
StatusMenuSummaries.shared.configureGateway(item, gatewayID: gateway.id, isAlternate: isAlternate)
|
|
case .gatewayHeader:
|
|
item.title = String(localized: "Gateways")
|
|
case .updateReady:
|
|
self.configureNative(
|
|
item,
|
|
title: String(localized: "Update ready, restart now?"),
|
|
symbol: "arrow.down.circle",
|
|
action: #selector(self.installUpdate(_:)))
|
|
}
|
|
}
|
|
|
|
private func configureHeader(_ item: NSMenuItem) {
|
|
let rootView = StatusMenuHeaderView(state: state, isSleeping: isSleeping)
|
|
if let hosting = item.view as? NSHostingView<StatusMenuHeaderView> {
|
|
// Replacing rootView leaves the live AppKit view attached during tracking.
|
|
hosting.rootView = rootView
|
|
self.sizeHostingView(hosting)
|
|
return
|
|
}
|
|
|
|
let hosting = NSHostingView(rootView: rootView)
|
|
self.sizeHostingView(hosting)
|
|
item.view = hosting
|
|
}
|
|
|
|
private func sizeHostingView(_ hosting: NSHostingView<StatusMenuHeaderView>) {
|
|
hosting.frame = NSRect(x: 0, y: 0, width: Self.cardWidth, height: hosting.fittingSize.height)
|
|
}
|
|
|
|
private func configureAction(_ item: NSMenuItem, action: StatusMenuDescriptor.Action) {
|
|
let title: String
|
|
let symbol: String
|
|
|
|
switch action {
|
|
case .dashboard:
|
|
title = String(localized: "Open Dashboard")
|
|
symbol = "gauge"
|
|
case .quickChat:
|
|
title = String(localized: "Quick Chat")
|
|
symbol = "text.bubble"
|
|
case .talkMode:
|
|
title = self.state.talkEnabled
|
|
? String(localized: "Stop Talk Mode")
|
|
: String(localized: "Start Talk Mode")
|
|
symbol = "waveform.circle.fill"
|
|
case .canvas:
|
|
title = self.state.canvasPanelVisible
|
|
? String(localized: "Close Canvas")
|
|
: String(localized: "Open Canvas")
|
|
symbol = "rectangle.inset.filled.on.rectangle"
|
|
case .allSessions:
|
|
title = String(localized: "All Sessions…")
|
|
symbol = "rectangle.stack"
|
|
case .settings:
|
|
title = String(localized: "Settings…")
|
|
symbol = "gearshape"
|
|
case .debug:
|
|
title = String(localized: "Debug")
|
|
symbol = "ladybug"
|
|
case .about:
|
|
title = String(localized: "About OpenClaw")
|
|
symbol = "info.circle"
|
|
case .quit:
|
|
title = String(localized: "Quit")
|
|
symbol = "power"
|
|
}
|
|
|
|
self.configureNative(item, title: title, symbol: symbol, action: #selector(self.performAction(_:)))
|
|
item.isEnabled = action != .talkMode || voiceWakeSupported
|
|
item.keyEquivalent = ""
|
|
|
|
switch action {
|
|
case .settings:
|
|
item.keyEquivalent = ","
|
|
item.keyEquivalentModifierMask = [.command]
|
|
case .quit:
|
|
item.keyEquivalent = "q"
|
|
item.keyEquivalentModifierMask = [.command]
|
|
case .quickChat:
|
|
if let shortcut = KeyboardShortcuts.getShortcut(for: .toggleQuickChat),
|
|
let key = shortcut.nsMenuItemKeyEquivalent
|
|
{
|
|
item.keyEquivalent = key
|
|
item.keyEquivalentModifierMask = shortcut.modifiers
|
|
}
|
|
case .debug:
|
|
self.configureDebugMenu(item)
|
|
default:
|
|
break
|
|
}
|
|
}
|
|
|
|
private func configureNative(_ item: NSMenuItem, title: String, symbol: String, action: Selector) {
|
|
item.title = title
|
|
item.image = NSImage(systemSymbolName: symbol, accessibilityDescription: title)
|
|
item.target = self
|
|
item.action = action
|
|
item.isEnabled = true
|
|
}
|
|
|
|
@objc private func performAction(_ sender: NSMenuItem) {
|
|
guard let id = sender.representedObject as? String,
|
|
let action = StatusMenuDescriptor.Action(rawValue: String(id.dropFirst("action.".count)))
|
|
else { return }
|
|
|
|
switch action {
|
|
case .dashboard:
|
|
AppNavigationActions.openDashboard()
|
|
case .quickChat:
|
|
QuickChatController.shared.toggle()
|
|
case .talkMode:
|
|
Task { await self.state.setTalkEnabled(!self.state.talkEnabled) }
|
|
case .canvas:
|
|
AppNavigationActions.toggleCanvas()
|
|
case .allSessions:
|
|
Task { await DashboardManager.shared.show(atPath: DashboardRouteMap.sessionsPagePath) }
|
|
case .settings:
|
|
AppNavigationActions.openSettings()
|
|
case .about:
|
|
AppNavigationActions.openSettings(tab: .about)
|
|
case .quit:
|
|
NSApplication.shared.terminate(nil)
|
|
case .debug:
|
|
break
|
|
}
|
|
}
|
|
|
|
@objc private func installUpdate(_: NSMenuItem) {
|
|
self.onInstallUpdate?()
|
|
}
|
|
|
|
private func configureDebugMenu(_ item: NSMenuItem) {
|
|
let submenu = item.submenu ?? NSMenu(title: String(localized: "Debug"))
|
|
submenu.autoenablesItems = false
|
|
StatusMenuAppearance.pin(submenu)
|
|
|
|
var entries = [
|
|
debugItem("config", String(localized: "Open Config Folder"), "folder"),
|
|
debugItem("health", String(localized: "Run Health Check Now"), "stethoscope"),
|
|
debugItem("heartbeat", String(localized: "Send Test Heartbeat"), "waveform.path.ecg"),
|
|
]
|
|
|
|
#if DEBUG
|
|
entries.append(self.debugItem(
|
|
"pairing",
|
|
String(localized: "Show Pairing Panel (Demo)"),
|
|
"checkmark.shield"))
|
|
#endif
|
|
|
|
if self.state.connectionMode == .remote {
|
|
entries.append(self.debugItem(
|
|
"tunnel",
|
|
String(localized: "Reset Remote Tunnel"),
|
|
"arrow.triangle.2.circlepath"))
|
|
}
|
|
|
|
let verboseTitle = DebugActions.verboseLoggingEnabledMain
|
|
? String(localized: "Verbose Logging (Main): On")
|
|
: String(localized: "Verbose Logging (Main): Off")
|
|
entries.append(self.debugItem("verbose", verboseTitle, "text.alignleft"))
|
|
|
|
let logging = self.debugItem("logging", String(localized: "App Logging"), "doc.text")
|
|
self.configureLoggingMenu(logging)
|
|
entries.append(logging)
|
|
entries.append(self.debugItem("sessions", String(localized: "Open Session Store"), "externaldrive"))
|
|
entries.append(self.debugSeparator("inspect"))
|
|
entries.append(self.debugItem("events", String(localized: "Open Agent Events…"), "bolt.horizontal.circle"))
|
|
entries.append(self.debugItem("log", String(localized: "Open Log"), "doc.text.magnifyingglass"))
|
|
entries.append(self.debugItem("voice", String(localized: "Send Debug Voice Text"), "waveform.circle"))
|
|
|
|
let notification = self.debugItem("notification", String(localized: "Send Test Notification"), "bell")
|
|
notification.isEnabled = !self.testNotificationPending
|
|
entries.append(notification)
|
|
entries.append(self.debugSeparator("restart"))
|
|
|
|
if self.state.connectionMode == .local {
|
|
entries.append(self.debugItem("gateway", String(localized: "Restart Gateway"), "arrow.clockwise"))
|
|
}
|
|
entries.append(self.debugItem(
|
|
"onboarding",
|
|
String(localized: "Restart Onboarding"),
|
|
"arrow.counterclockwise"))
|
|
entries.append(self.debugItem("app", String(localized: "Restart App"), "arrow.triangle.2.circlepath"))
|
|
|
|
self.reconcileNativeMenu(submenu, with: entries)
|
|
item.submenu = submenu
|
|
}
|
|
|
|
private func configureLoggingMenu(_ item: NSMenuItem) {
|
|
let submenu = item.submenu ?? NSMenu(title: String(localized: "App Logging"))
|
|
submenu.autoenablesItems = false
|
|
StatusMenuAppearance.pin(submenu)
|
|
|
|
var entries = Logger.Level.allCases.map { level in
|
|
let entry = self.debugItem("level.\(level.rawValue)", level.title, "slider.horizontal.3")
|
|
entry.state = AppLogSettings.logLevel() == level ? .on : .off
|
|
return entry
|
|
}
|
|
|
|
entries.append(self.debugSeparator("logging"))
|
|
let enabled = AppLogSettings.fileLoggingEnabled()
|
|
let title = enabled ? String(localized: "File Logging: On") : String(localized: "File Logging: Off")
|
|
let fileLogging = self.debugItem("fileLogging", title, "doc.text.magnifyingglass")
|
|
fileLogging.state = enabled ? .on : .off
|
|
entries.append(fileLogging)
|
|
|
|
self.reconcileNativeMenu(submenu, with: entries)
|
|
item.submenu = submenu
|
|
}
|
|
|
|
private func debugItem(_ id: String, _ title: String, _ symbol: String) -> NSMenuItem {
|
|
let item = NSMenuItem()
|
|
item.representedObject = "debug.\(id)"
|
|
self.configureNative(item, title: title, symbol: symbol, action: #selector(self.performDebugAction(_:)))
|
|
return item
|
|
}
|
|
|
|
private func debugSeparator(_ id: String) -> NSMenuItem {
|
|
let item = NSMenuItem.separator()
|
|
item.representedObject = "debug.separator.\(id)"
|
|
return item
|
|
}
|
|
|
|
private func reconcileNativeMenu(_ menu: NSMenu, with desired: [NSMenuItem]) {
|
|
var index = 0
|
|
while index < desired.count {
|
|
let candidate = desired[index]
|
|
let candidateID = candidate.representedObject as? String
|
|
|
|
if index < menu.items.count,
|
|
menu.items[index].representedObject as? String == candidateID
|
|
{
|
|
let existing = menu.items[index]
|
|
if !existing.isSeparatorItem {
|
|
existing.title = candidate.title
|
|
existing.image = candidate.image
|
|
existing.state = candidate.state
|
|
existing.isEnabled = candidate.isEnabled
|
|
existing.action = candidate.action
|
|
existing.target = candidate.target
|
|
if let desiredSubmenu = candidate.submenu {
|
|
if let existingSubmenu = existing.submenu {
|
|
self.reconcileNativeMenu(existingSubmenu, with: desiredSubmenu.items)
|
|
} else {
|
|
candidate.submenu = nil
|
|
existing.submenu = desiredSubmenu
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
if let oldIndex = menu.items.firstIndex(where: { $0.representedObject as? String == candidateID }) {
|
|
for _ in index..<oldIndex {
|
|
menu.removeItem(at: index)
|
|
}
|
|
continue
|
|
}
|
|
candidate.menu?.removeItem(candidate)
|
|
menu.insertItem(candidate, at: index)
|
|
}
|
|
index += 1
|
|
}
|
|
while menu.items.count > desired.count {
|
|
menu.removeItem(at: desired.count)
|
|
}
|
|
}
|
|
|
|
@objc private func performDebugAction(_ sender: NSMenuItem) {
|
|
guard let represented = sender.representedObject as? String else { return }
|
|
let id = String(represented.dropFirst("debug.".count))
|
|
|
|
if id.hasPrefix("level."), let level = Logger.Level(rawValue: String(id.dropFirst("level.".count))) {
|
|
AppLogSettings.setLogLevel(level)
|
|
sender.menu?.items.forEach { item in
|
|
if (item.representedObject as? String)?.hasPrefix("debug.level.") == true {
|
|
item.state = item === sender ? .on : .off
|
|
}
|
|
}
|
|
return
|
|
}
|
|
|
|
switch id {
|
|
case "config": DebugActions.openConfigFolder()
|
|
case "health": Task { await DebugActions.runHealthCheckNow() }
|
|
case "heartbeat": Task { _ = await DebugActions.sendTestHeartbeat() }
|
|
case "pairing":
|
|
#if DEBUG
|
|
DebugActions.showPairingPanelDemo()
|
|
#endif
|
|
case "tunnel":
|
|
Task { await self.presentTunnelResult() }
|
|
case "verbose":
|
|
Task { _ = await DebugActions.toggleVerboseLoggingMain() }
|
|
case "fileLogging":
|
|
let enabled = !AppLogSettings.fileLoggingEnabled()
|
|
AppDefaults.standard.set(enabled, forKey: debugFileLogEnabledKey)
|
|
sender.state = enabled ? .on : .off
|
|
sender.title = enabled ? String(localized: "File Logging: On") : String(localized: "File Logging: Off")
|
|
case "sessions": DebugActions.openSessionStore()
|
|
case "events": DebugActions.openAgentEventsWindow()
|
|
case "log": DebugActions.openLog()
|
|
case "voice": Task { _ = await DebugActions.sendDebugVoice() }
|
|
case "notification": Task { await self.sendTestNotification(sender) }
|
|
case "gateway": DebugActions.restartGateway()
|
|
case "onboarding": DebugActions.restartOnboarding()
|
|
case "app": DebugActions.restartApp()
|
|
default: break
|
|
}
|
|
}
|
|
|
|
private func presentTunnelResult() async {
|
|
let result = await DebugActions.resetGatewayTunnel()
|
|
let alert = NSAlert()
|
|
alert.messageText = String(localized: "Remote Tunnel")
|
|
switch result {
|
|
case let .success(message):
|
|
alert.informativeText = message
|
|
alert.alertStyle = .informational
|
|
case let .failure(error):
|
|
alert.informativeText = error.localizedDescription
|
|
alert.alertStyle = .warning
|
|
}
|
|
alert.runModal()
|
|
}
|
|
|
|
private func sendTestNotification(_ sender: NSMenuItem) async {
|
|
guard !self.testNotificationPending else { return }
|
|
self.testNotificationPending = true
|
|
sender.isEnabled = false
|
|
let outcome = await DebugActions.sendTestNotification()
|
|
self.testNotificationPending = false
|
|
sender.isEnabled = true
|
|
|
|
let alert = NSAlert()
|
|
alert.messageText = String(localized: "Test Notification")
|
|
switch outcome {
|
|
case .pending: return
|
|
case .sent:
|
|
alert.informativeText = String(localized: "The notification request was queued.")
|
|
alert.alertStyle = .informational
|
|
case let .error(message):
|
|
alert.informativeText = message
|
|
alert.alertStyle = .warning
|
|
}
|
|
alert.runModal()
|
|
}
|
|
}
|