Files
openclaw/apps/macos/Sources/OpenClaw/MenuBar.swift
Peter Steinberger 2b0da0e193 refactor(macos): single-owner hybrid status menu with live session cards and exec approvals (#130041)
* 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.
2026-08-26 06:16:16 -07:00

501 lines
20 KiB
Swift

import AppKit
import Darwin
import Dispatch
import Foundation
import OpenClawKit
import OSLog
import SwiftUI
/// Routes private maintenance commands before SwiftUI constructs or activates the application.
@main
enum OpenClawProcessMain {
static func main() {
if let status = OpenClawProcessEntrypoint.run(arguments: CommandLine.arguments, launchApplication: {
OpenClawApp.main()
}) {
Darwin.exit(status)
}
}
}
enum OpenClawProcessEntrypoint {
static func run(arguments: [String], launchApplication: () -> Void) -> Int32? {
if let status = ElevationExclusiveRename.runIfRequested(arguments: arguments) {
return status
}
if let status = ElevationFilesystemSync.runIfRequested(arguments: arguments) {
return status
}
launchApplication()
return nil
}
}
struct OpenClawApp: App {
@NSApplicationDelegateAdaptor(AppDelegate.self) private var delegate
@Environment(\.openWindow) private var openWindow
@State private var state: AppState
private static let logger = Logger(subsystem: "ai.openclaw", category: "app")
private var tailscaleService: TailscaleService {
.shared
}
init() {
let launchPlan = AppLaunchRuntimePlan.current
if let error = AppProfile.current.validationError {
if launchPlan.isElevationHost {
fputs("OpenClaw elevation host profile is invalid: \(error.localizedDescription)\n", stderr)
Darwin.exit(2)
}
let alert = NSAlert()
alert.alertStyle = .critical
alert.messageText = "OpenClaw profile is invalid"
alert.informativeText = error.localizedDescription
alert.runModal()
Darwin.exit(2)
}
if AppProfile.current.isActive,
!DeviceIdentityStore.configureStateDirectory(OpenClawPaths.stateDirURL)
{
fatalError("Device identity state root was already used before app profile configuration")
}
guard GatewayTLSStore.configureKeychainServiceSuffix(AppProfile.current.keychainServiceSuffix) else {
fatalError("Gateway TLS Keychain namespace was already used by another app profile")
}
OpenClawLogging.bootstrapIfNeeded()
Self.applyAttachOnlyOverrideIfNeeded(plan: launchPlan)
_state = State(initialValue: AppStateStore.shared)
}
var body: some Scene {
Window("OpenClaw Settings", id: SettingsWindowOpener.windowID) {
SettingsRootView(state: self.state, updater: self.delegate.updaterController)
.frame(width: SettingsTab.windowWidth, height: SettingsTab.windowHeight, alignment: .topLeading)
.environment(self.tailscaleService)
.background(SettingsWindowOpenRegistrar())
}
.defaultLaunchBehavior(.suppressed)
.restorationBehavior(.disabled)
.defaultSize(width: SettingsTab.windowWidth, height: SettingsTab.windowHeight)
.windowResizability(.contentSize)
.commands {
CommandGroup(replacing: .newItem) {
Button("New Gateway Window…") {
WebChatManager.shared.newGatewayWindow()
}
.keyboardShortcut("n", modifiers: .command)
Button("New Thread") {
DashboardManager.shared.dispatchNativeCommand(.newSession)
}
.keyboardShortcut("n", modifiers: [.command, .shift])
}
CommandGroup(replacing: .appSettings) {
Button("Settings...") {
self.openWindow(id: SettingsWindowOpener.windowID)
}
.keyboardShortcut(",", modifiers: .command)
}
DashboardGatewayCommands(dashboardManager: DashboardManager.shared)
SidebarCommands()
CommandMenu("Navigate") {
Button("Back") {
DashboardManager.shared.navigateBack()
}
.keyboardShortcut("[", modifiers: .command)
Button("Forward") {
DashboardManager.shared.navigateForward()
}
.keyboardShortcut("]", modifiers: .command)
Divider()
Button("Command Palette…") {
DashboardManager.shared.dispatchNativeCommand(.commandPalette)
}
.keyboardShortcut("k", modifiers: .command)
}
}
}
private static func applyAttachOnlyOverrideIfNeeded(plan: AppLaunchRuntimePlan) {
guard plan.attachOnly else { return }
if let error = GatewayLaunchAgentManager.applyAttachOnlyRuntimeOverride() {
self.logger.error("attach-only flag failed: \(error, privacy: .public)")
return
}
self.logger.info("attach-only flag enabled")
}
}
struct SettingsWindowOpenRegistrar: View {
@Environment(\.openWindow) private var openWindow
var body: some View {
Color.clear
.frame(width: 0, height: 0)
.onAppear {
let openWindow = self.openWindow
SettingsWindowOpener.shared.register {
openWindow(id: SettingsWindowOpener.windowID)
}
}
}
}
@MainActor
final class AppDelegate: NSObject, NSApplicationDelegate {
private var state: AppState?
private var statusMenuController: StatusMenuController?
private var terminationCleanupTask: Task<Void, Never>?
private var terminationDeadlineTask: Task<Void, Never>?
private var terminationCleanupFinished = false
private var profileInstanceLock: AppInstanceLock?
private let webChatAutoLogger = Logger(subsystem: "ai.openclaw", category: "Chat")
var nodeTerminationCleanup: @MainActor () async -> Void = {
// CUA shutdown drains the worker before closing the daemon socket; run it
// first so other cleanup cannot consume the app termination deadline.
if AppLaunchRuntimePlan.current.allowsCuaComputerControl {
await CuaDriverHostCoordinator.shared.shutdown()
}
await TalkMLXSpeechSynthesizer.shared.shutdown()
await MacNodeModeCoordinator.shared.stopAndWait()
}
var peekabooBridgeTerminationCleanup: @MainActor () async -> Void = {
await PeekabooBridgeHostCoordinator.shared.shutdown()
}
var waitForTerminationCleanupDeadline: @MainActor () async -> Void = {
try? await Task.sleep(for: .seconds(AppTerminationTiming.cleanupDeadlineSeconds))
}
var applicationTerminationReply: @MainActor (NSApplication, Bool) -> Void = { app, allow in
app.reply(toApplicationShouldTerminate: allow)
}
var openDashboardAction: @MainActor () -> Void = { AppNavigationActions.openDashboard() }
let updaterController: UpdaterProviding
override init() {
let environment = ProcessInfo.processInfo.environment
let hasReplacementMetadata = ApplicationRelocator.hasReplacementHandoffMetadata(
environment: environment)
let isReplacementHandoff = hasReplacementMetadata &&
ApplicationRelocator.acceptReplacementHandoff(environment: environment)
if hasReplacementMetadata, !isReplacementHandoff {
fputs("OpenClaw replacement handoff authentication failed.\n", stderr)
Darwin.exit(2)
}
let ownership = AppInstanceLock.acquire(
url: AppProfile.current.instanceLockURL(),
waitMilliseconds: isReplacementHandoff ? 5000 : 0)
if let exitCode = Self.processExitCode(for: ownership) {
fputs("OpenClaw profile is already running.\n", stderr)
Darwin.exit(exitCode)
}
var profileInstanceLock: AppInstanceLock?
var instanceOwnershipFailure: String?
switch ownership {
case let .acquired(lock):
profileInstanceLock = lock
case .busy:
break
case let .failed(message):
instanceOwnershipFailure = message
}
self.profileInstanceLock = profileInstanceLock
self.updaterController = instanceOwnershipFailure == nil
? makeUpdaterController()
: DisabledUpdaterController()
super.init()
if let instanceOwnershipFailure {
if AppLaunchRuntimePlan.current.isElevationHost {
fputs(
"OpenClaw elevation host could not claim its instance lock: \(instanceOwnershipFailure)\n",
stderr)
Darwin.exit(2)
}
let alert = NSAlert()
alert.alertStyle = .critical
alert.messageText = "OpenClaw could not claim its instance lock"
alert.informativeText = instanceOwnershipFailure
alert.runModal()
Darwin.exit(2)
}
}
static func processExitCode(for ownership: AppInstanceLockAcquisition) -> Int32? {
if case .busy = ownership { return 0 }
return nil
}
func applicationWillFinishLaunching(_: Notification) {
// URL/reopen callbacks can create the dashboard before didFinishLaunching.
DashboardManager.shared.configure(updater: self.updaterController)
}
func applicationDockMenu(_: NSApplication) -> NSMenu? {
let menu = NSMenu()
menu.autoenablesItems = false
menu.addItem(self.dockMenuItem(
title: "Open Dashboard",
systemImage: "gauge",
action: #selector(self.openDashboardFromDockMenu(_:))))
let canvasTitle = AppStateStore.shared.canvasPanelVisible ? "Close Canvas" : "Open Canvas"
let canvasItem = self.dockMenuItem(
title: canvasTitle,
systemImage: "rectangle.inset.filled.on.rectangle",
action: #selector(self.toggleCanvasFromDockMenu(_:)))
canvasItem.isEnabled = AppStateStore.shared.canvasEnabled
menu.addItem(canvasItem)
menu.addItem(.separator())
menu.addItem(self.dockMenuItem(
title: "Settings…",
systemImage: "gearshape",
action: #selector(self.openSettingsFromDockMenu(_:))))
return menu
}
private func dockMenuItem(title: String, systemImage: String, action: Selector) -> NSMenuItem {
let item = NSMenuItem(title: title, action: action, keyEquivalent: "")
item.target = self
item.image = NSImage(systemSymbolName: systemImage, accessibilityDescription: title)
return item
}
@objc
private func openDashboardFromDockMenu(_: Any?) {
self.openDashboardAction()
}
@objc
private func toggleCanvasFromDockMenu(_: Any?) {
AppNavigationActions.toggleCanvas()
}
@objc
private func openSettingsFromDockMenu(_: Any?) {
AppNavigationActions.openSettings()
}
func application(_: NSApplication, open urls: [URL]) {
guard !AppLaunchRuntimePlan.current.isElevationHost else { return }
Task { @MainActor in
for url in urls {
await DeepLinkHandler.shared.handle(url: url)
}
}
}
func applicationShouldHandleReopen(_: NSApplication, hasVisibleWindows flag: Bool) -> Bool {
guard AppLaunchRuntimePlan.current.allowsAutomaticPresentation else { return false }
if flag {
return true
}
self.openDashboardAction()
return false
}
func applicationShouldTerminateAfterLastWindowClosed(_: NSApplication) -> Bool {
false
}
@MainActor
func applicationDidFinishLaunching(_: Notification) {
#if DEBUG
if CommandLine.arguments.contains("--swarm-chat-fixture") {
AppActivationPolicy.apply(showDockIcon: true)
WebChatManager.shared.showSwarmFixture()
return
}
#endif
let launchPlan = AppLaunchRuntimePlan.current
if !AppProfile.current.isActive, !launchPlan.isElevationHost {
switch ApplicationRelocator.handleLaunch() {
case .terminating:
return
case let .continueLaunch(startUpdater):
if startUpdater, launchPlan.allowsUpdater {
if OpenClawConfigFile.gatewayUpdateChannel() == nil {
self.updaterController.startAfterResolvingGatewayUpdateChannel()
} else {
self.updaterController.start()
}
}
}
}
// Remote startup can spawn an SSH child. Admit tunnel work only after the
// singleton check so a short-lived handoff process cannot orphan that child.
GatewayEndpointStore.admitPrimaryAppLaunch()
GatewayConnectivityCoordinator.shared.start()
self.state = AppStateStore.shared
if let state {
MacNodeModeCoordinator.prepareNodeIdentityProfile(
isExistingInstallation: state.onboardingSeen || state.connectionMode != .unconfigured)
}
AppActivationPolicy.apply(showDockIcon: launchPlan.allowsDockIcon && (state?.showDockIcon ?? false))
if launchPlan.allowsInteractiveServices, let state {
let controller = StatusMenuController(state: state, updater: self.updaterController)
controller.start()
self.statusMenuController = controller
}
if let state {
let shouldWaitForConnection = state.connectionMode != .unconfigured
if !shouldWaitForConnection, launchPlan.allowsAutomaticPresentation {
Task { @MainActor in
await self.scheduleFirstRunOnboardingIfNeeded()
}
}
Task { @MainActor in
// Validate PATH selection before local startup. Existing installs may not
// have the validation cache yet, and a stale external CLI must not win.
if state.connectionMode == .local {
_ = await CLIInstaller.status()
}
await ConnectionModeCoordinator.shared.apply(
mode: state.connectionMode,
paused: state.isPaused)
guard shouldWaitForConnection, launchPlan.allowsAutomaticPresentation else { return }
await self.scheduleFirstRunOnboardingIfNeeded()
}
}
TerminationSignalWatcher.shared.start()
MacNodeModeCoordinator.shared.start()
if launchPlan.allowsInteractiveServices {
NodePairingApprovalPrompter.shared.start()
DevicePairingApprovalPrompter.shared.start()
ExecApprovalsPromptServer.shared.start()
ExecApprovalsGatewayPrompter.shared.start()
if let state {
CookieSyncManager.shared.start(state: state)
}
VoiceWakeGlobalSettingsSync.shared.start()
QuickChatController.shared.start()
}
Task { PresenceReporter.shared.start() }
Task { await HealthStore.shared.refresh(onDemand: true) }
Task { await PortGuardian.shared.reapOrphanedTunnels() }
AppStateStore.shared.applyComputerControlHostState()
if launchPlan.allowsAutomaticPresentation {
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
if !PostUpdateController.shared.startIfNeeded() {
CLIInstallPrompter.shared.checkAndPromptIfNeeded(reason: "launch")
}
}
}
if launchPlan.allowsAutomaticPresentation {
Task {
try? await Task.sleep(for: .seconds(2))
DashboardManager.shared.preloadIfConfigured()
}
}
#if DEBUG
// Screenshot/demo helper: show the pairing panel with sample requests.
if launchPlan.allowsAutomaticPresentation,
ProcessInfo.processInfo.environment["OPENCLAW_DEBUG_PAIRING_DEMO"] == "1"
{
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
DebugActions.showPairingPanelDemo()
}
}
#endif
// Developer/testing helper: auto-open chat when launched with --chat (or legacy --webchat).
if launchPlan.shouldAutoOpenChat(arguments: CommandLine.arguments) {
self.webChatAutoLogger.debug("Auto-opening chat via CLI flag")
Task { @MainActor in
let sessionKey = await WebChatManager.shared.preferredSessionKey()
WebChatManager.shared.show(sessionKey: sessionKey)
}
}
if launchPlan.shouldAutoOpenDashboard(arguments: CommandLine.arguments) {
self.webChatAutoLogger.info("Auto-opening dashboard via CLI flag")
self.openDashboardAction()
}
}
func applicationWillTerminate(_: Notification) {
self.statusMenuController?.stop()
QuickChatController.shared.stop()
PresenceReporter.shared.stop()
NodePairingApprovalPrompter.shared.stop()
DevicePairingApprovalPrompter.shared.stop()
ExecApprovalsPromptServer.shared.stop()
ExecApprovalsGatewayPrompter.shared.stop()
MacNodeModeCoordinator.shared.stop()
CookieSyncManager.shared.stop()
TerminationSignalWatcher.shared.stop()
VoiceWakeGlobalSettingsSync.shared.stop()
DashboardManager.shared.close()
WebChatManager.shared.close()
WebChatManager.shared.resetTunnels()
Task { await RemoteTunnelManager.shared.stopAll() }
Task { await GatewayConnection.shared.shutdown() }
}
func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply {
if self.terminationCleanupFinished {
return .terminateNow
}
guard self.terminationCleanupTask == nil else {
return .terminateLater
}
let nodeCleanup = self.nodeTerminationCleanup
let bridgeCleanup = self.peekabooBridgeTerminationCleanup
self.terminationCleanupTask = Task { @MainActor [weak self] in
async let nodeCleanupResult: Void = nodeCleanup()
async let bridgeCleanupResult: Void = bridgeCleanup()
_ = await (nodeCleanupResult, bridgeCleanupResult)
self?.finishTerminationCleanup(for: sender)
}
let waitForDeadline = self.waitForTerminationCleanupDeadline
self.terminationDeadlineTask = Task { @MainActor [weak self] in
await waitForDeadline()
guard !Task.isCancelled else { return }
self?.finishTerminationCleanup(for: sender)
}
return .terminateLater
}
private func finishTerminationCleanup(for sender: NSApplication) {
guard !self.terminationCleanupFinished else { return }
// Cleanup may ignore cancellation while transport or input teardown is stuck.
// The deadline replies without awaiting that loser; this gate keeps the reply single.
self.terminationCleanupFinished = true
self.terminationCleanupTask?.cancel()
self.terminationDeadlineTask?.cancel()
self.terminationCleanupTask = nil
self.terminationDeadlineTask = nil
self.applicationTerminationReply(sender, true)
}
static func shouldPresentScheduledFirstRunOnboarding(onboardingSeen: Bool) -> Bool {
!onboardingSeen
}
private func scheduleFirstRunOnboardingIfNeeded() async {
let connectionMode = AppStateStore.shared.connectionMode
let onboardingSeen = AppStateStore.shared.onboardingSeen
if connectionMode != .unconfigured, onboardingSeen {
OnboardingController.markComplete()
return
}
self.scheduleFirstRunOnboardingPresentation()
}
private func scheduleFirstRunOnboardingPresentation() {
let seenVersion = AppDefaults.standard.integer(forKey: onboardingVersionKey)
let shouldShow = seenVersion < currentOnboardingVersion || !AppStateStore.shared.onboardingSeen
guard shouldShow else { return }
DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) {
guard Self.shouldPresentScheduledFirstRunOnboarding(
onboardingSeen: AppStateStore.shared.onboardingSeen)
else { return }
OnboardingController.shared.show()
}
}
}