Files
openclaw/apps/macos/Sources/OpenClaw/MenuContentView.swift
Peter Steinberger af9b0c7616 fix(macos): keep the node channel alive and visible when the node-host worker cannot start (#129925)
* fix(macos): keep the node channel alive and visible when the node-host worker cannot start

A node-host worker that exited before its ready manifest never notified the
retry policy, so the coordinator respawned the broken CLI forever and the
whole node channel silently never dialed the gateway. Startup exits now
consume the crash retry budget and carry the worker's stderr into the start
error; every non-transient worker failure degrades the connect to native
capabilities instead of aborting it; and the menu bar surfaces the recorded
node-channel state with the concrete reason.

* fix(macos): render node-channel status as a top-level menu view

The native-menu extra style flattens multi-view Toggle labels to their first
Text, so status sublines inside the label never rendered — the original
'zero indication' report. Top-level menu views render (exec-approval error
pattern).
2026-08-26 00:02:52 -07:00

682 lines
26 KiB
Swift

import AppKit
import AVFoundation
import Foundation
import KeyboardShortcuts
import Observation
import OpenClawKit
import SwiftUI
/// Menu contents for the OpenClaw menu bar extra.
struct MenuContent: View {
@Bindable var state: AppState
let updater: UpdaterProviding?
@Bindable private var updateStatus: UpdateStatus
private let healthStore = HealthStore.shared
private let heartbeatStore = HeartbeatStore.shared
private let controlChannel = ControlChannel.shared
private let dashboardManager = DashboardManager.shared
private let activityStore = WorkActivityStore.shared
private let nodesStore = NodesStore.shared
private let nodeChannelStatus = MacNodeChannelStatusStore.shared
@Bindable private var pairingPrompter = NodePairingApprovalPrompter.shared
@Bindable private var devicePairingPrompter = DevicePairingApprovalPrompter.shared
@State private var availableMics: [AudioInputDevice] = []
@State private var loadingMics = false
@State private var micObserver = AudioInputDeviceObserver()
@State private var micRefreshTask: Task<Void, Never>?
@State private var browserControlEnabled = true
@State private var testNotificationPending = false
@AppStorage(cameraEnabledKey, store: AppDefaults.standard) private var cameraEnabled: Bool = false
@AppStorage(appLogLevelKey, store: AppDefaults.standard)
private var appLogLevelRaw: String = Logger.Level.info.rawValue
@AppStorage(debugFileLogEnabledKey, store: AppDefaults.standard)
private var appFileLoggingEnabled: Bool = false
init(state: AppState, updater: UpdaterProviding?) {
self._state = Bindable(wrappedValue: state)
self.updater = updater
self._updateStatus = Bindable(wrappedValue: updater?.updateStatus ?? UpdateStatus.disabled)
}
private var execApprovalModeBinding: Binding<ExecApprovalQuickMode> {
Binding(
get: { self.state.execApprovalMode },
set: { self.state.updateExecApprovalMode($0) })
}
var body: some View {
VStack(alignment: .leading, spacing: 8) {
Toggle(isOn: self.activeBinding) {
VStack(alignment: .leading, spacing: 2) {
Text(self.connectionLabel)
self.statusLine(label: self.healthStatus.label, color: self.healthStatus.color)
if self.pairingPrompter.pendingCount > 0 {
self.pairingStatusLine(
label: "Pairing approval pending (\(self.pairingPrompter.pendingCount))")
}
if self.devicePairingPrompter.pendingCount > 0 {
let repairCount = self.devicePairingPrompter.pendingRepairCount
let repairSuffix = repairCount > 0 ? " · \(repairCount) repair" : ""
self.pairingStatusLine(
label: "Device pairing pending (\(self.devicePairingPrompter.pendingCount))\(repairSuffix)")
}
}
}
.disabled(self.state.connectionMode == .unconfigured)
// The native-menu extra style flattens multi-view Toggle labels to
// their first Text, so status sublines inside the label above never
// render. Node-channel state must be a top-level menu view to stay
// operator-visible (same pattern as the exec-approval error lines).
if let macNodeStatus = self.macNodeStatus {
Text(macNodeStatus.label)
.font(.caption)
.foregroundStyle(macNodeStatus.color)
}
Divider()
Toggle(isOn: self.heartbeatsBinding) {
HStack(spacing: 8) {
Label("Send Heartbeats", systemImage: "waveform.path.ecg")
Spacer(minLength: 0)
self.statusLine(label: self.heartbeatStatus.label, color: self.heartbeatStatus.color)
}
}
Toggle(
isOn: Binding(
get: { self.browserControlEnabled },
set: { enabled in
self.browserControlEnabled = enabled
Task { await self.saveBrowserControlEnabled(enabled) }
})) {
Label("Browser Control", systemImage: "globe")
}
Toggle(isOn: self.$cameraEnabled) {
Label("Allow Camera", systemImage: "camera")
}
switch self.state.execApprovalPolicyLoadState {
case .available:
Picker(selection: self.execApprovalModeBinding) {
ForEach(ExecApprovalQuickMode.allCases) { mode in
Text(mode.title).tag(mode)
}
} label: {
Label("Exec Approvals", systemImage: "terminal")
}
case .loading:
Label("Loading Exec Approvals…", systemImage: "terminal")
.foregroundStyle(.secondary)
case .unavailable:
Button {
self.state.retryExecApprovalModeRead()
} label: {
Label("Retry Exec Approvals", systemImage: "arrow.clockwise")
}
}
if let error = self.state.execApprovalLoadError {
Text(error)
.font(.caption)
.foregroundStyle(.orange)
}
if let error = self.state.execApprovalMutationError {
Text(error)
.font(.caption)
.foregroundStyle(.orange)
}
Toggle(isOn: Binding(get: { self.state.canvasEnabled }, set: { self.state.canvasEnabled = $0 })) {
Label("Allow Canvas", systemImage: "rectangle.and.pencil.and.ellipsis")
}
.onChange(of: self.state.canvasEnabled) { _, enabled in
if !enabled {
CanvasManager.shared.hideAll()
}
}
Toggle(isOn: self.voiceWakeBinding) {
Label("Voice Wake", systemImage: "mic.fill")
}
.disabled(!voiceWakeSupported)
.opacity(voiceWakeSupported ? 1 : 0.5)
if self.showVoiceWakeMicPicker {
self.voiceWakeMicMenu
}
Button {
self.open(tab: .voiceWake)
} label: {
Label("Voice & Talk Settings…", systemImage: "slider.horizontal.3")
}
Divider()
Button {
AppNavigationActions.openDashboard()
} label: {
Label("Open Dashboard", systemImage: "gauge")
}
Button {
AppNavigationActions.openChat()
} label: {
Label("Open Chat", systemImage: "bubble.left.and.bubble.right")
}
if self.state.quickChatEnabled {
Button {
QuickChatController.shared.toggle()
} label: {
Label("Quick Chat", systemImage: "text.bubble")
}
.globalKeyboardShortcut(.toggleQuickChat)
}
if self.state.canvasEnabled {
Button {
AppNavigationActions.toggleCanvas()
} label: {
Label(
self.state.canvasPanelVisible ? "Close Canvas" : "Open Canvas",
systemImage: "rectangle.inset.filled.on.rectangle")
}
}
Button {
Task { await self.state.setTalkEnabled(!self.state.talkEnabled) }
} label: {
Label(self.state.talkEnabled ? "Stop Talk Mode" : "Talk Mode", systemImage: "waveform.circle.fill")
}
.disabled(!voiceWakeSupported)
.opacity(voiceWakeSupported ? 1 : 0.5)
Divider()
Button("Settings…") { self.open(tab: .general) }
.keyboardShortcut(",", modifiers: [.command])
self.debugMenu
Button("About OpenClaw") { self.open(tab: .about) }
if let updater, updater.isAvailable, self.updateStatus.isUpdateReady {
Button("Update ready, restart now?") { updater.checkForUpdates(nil) }
}
Button("Quit") { NSApplication.shared.terminate(nil) }
}
.task(id: self.state.swabbleEnabled) {
if self.state.swabbleEnabled {
await self.loadMicrophones(force: true)
}
}
.task {
VoicePushToTalkHotkey.shared.setEnabled(voiceWakeSupported && self.state.voicePushToTalkEnabled)
}
.task {
await self.nodesStore.prepareLocalNodeIdentity()
}
.onChange(of: self.state.voicePushToTalkEnabled) { _, enabled in
VoicePushToTalkHotkey.shared.setEnabled(voiceWakeSupported && enabled)
}
.task(id: self.state.connectionMode) {
await self.loadBrowserControlEnabled()
}
.onAppear {
MicRefreshSupport.startObserver(self.micObserver) {
MicRefreshSupport.schedule(refreshTask: &self.micRefreshTask) {
await self.loadMicrophones(force: true)
}
}
}
.onDisappear {
self.micRefreshTask?.cancel()
self.micRefreshTask = nil
self.micObserver.stop()
}
}
private var connectionLabel: String {
DashboardGatewayMenuModel.connectionLabel(
mode: self.state.connectionMode,
controlState: self.controlChannel.state,
entries: self.dashboardManager.gatewayEntries)
}
private func loadBrowserControlEnabled() async {
let root = await ConfigStore.load()
let browser = root["browser"] as? [String: Any]
let enabled = browser?["enabled"] as? Bool ?? true
await MainActor.run { self.browserControlEnabled = enabled }
}
private func saveBrowserControlEnabled(_ enabled: Bool) async {
let (success, _) = await MenuContent.buildAndSaveBrowserEnabled(enabled)
if !success {
await self.loadBrowserControlEnabled()
}
}
@MainActor
private static func buildAndSaveBrowserEnabled(_ enabled: Bool) async -> (Bool, ()) {
var root = await ConfigStore.load()
var browser = root["browser"] as? [String: Any] ?? [:]
browser["enabled"] = enabled
root["browser"] = browser
do {
try await ConfigStore.save(root)
return (true, ())
} catch {
return (false, ())
}
}
@ViewBuilder
private var debugMenu: some View {
if self.state.debugPaneEnabled {
Menu("Debug") {
Button {
DebugActions.openConfigFolder()
} label: {
Label("Open Config Folder", systemImage: "folder")
}
Button {
Task { await DebugActions.runHealthCheckNow() }
} label: {
Label("Run Health Check Now", systemImage: "stethoscope")
}
Button {
Task { _ = await DebugActions.sendTestHeartbeat() }
} label: {
Label("Send Test Heartbeat", systemImage: "waveform.path.ecg")
}
#if DEBUG
Button {
DebugActions.showPairingPanelDemo()
} label: {
Label("Show Pairing Panel (Demo)", systemImage: "checkmark.shield")
}
#endif
if self.state.connectionMode == .remote {
Button {
Task { @MainActor in
let result = await DebugActions.resetGatewayTunnel()
self.presentDebugResult(result, title: "Remote Tunnel")
}
} label: {
Label("Reset Remote Tunnel", systemImage: "arrow.triangle.2.circlepath")
}
}
Button {
Task { _ = await DebugActions.toggleVerboseLoggingMain() }
} label: {
Label(
DebugActions.verboseLoggingEnabledMain
? "Verbose Logging (Main): On"
: "Verbose Logging (Main): Off",
systemImage: "text.alignleft")
}
Menu {
Picker("Verbosity", selection: self.$appLogLevelRaw) {
ForEach(Logger.Level.allCases, id: \.rawValue) { level in
Text(level.title).tag(level.rawValue)
}
}
Toggle(isOn: self.$appFileLoggingEnabled) {
Label(
self.appFileLoggingEnabled
? "File Logging: On"
: "File Logging: Off",
systemImage: "doc.text.magnifyingglass")
}
} label: {
Label("App Logging", systemImage: "doc.text")
}
Button {
DebugActions.openSessionStore()
} label: {
Label("Open Session Store", systemImage: "externaldrive")
}
Divider()
Button {
DebugActions.openAgentEventsWindow()
} label: {
Label("Open Agent Events…", systemImage: "bolt.horizontal.circle")
}
Button {
DebugActions.openLog()
} label: {
Label("Open Log", systemImage: "doc.text.magnifyingglass")
}
Button {
Task { _ = await DebugActions.sendDebugVoice() }
} label: {
Label("Send Debug Voice Text", systemImage: "waveform.circle")
}
Button {
Task { await self.sendTestNotification() }
} label: {
Label("Send Test Notification", systemImage: "bell")
}
.disabled(self.testNotificationPending)
Divider()
if self.state.connectionMode == .local {
Button {
DebugActions.restartGateway()
} label: {
Label("Restart Gateway", systemImage: "arrow.clockwise")
}
}
Button {
DebugActions.restartOnboarding()
} label: {
Label("Restart Onboarding", systemImage: "arrow.counterclockwise")
}
Button {
DebugActions.restartApp()
} label: {
Label("Restart App", systemImage: "arrow.triangle.2.circlepath")
}
}
}
}
private func open(tab: SettingsTab) {
AppNavigationActions.openSettings(tab: tab)
}
private var macNodeStatus: (label: String, color: Color)? {
guard self.state.connectionMode != .unconfigured else { return nil }
guard case .connected = self.controlChannel.state else { return nil }
// The coordinator records why the node channel is down at the connect
// boundary; prefer that recorded fact over inferring from node listings.
if let line = self.nodeChannelStatus.state.operatorStatusLine {
return (line.label, line.isDegraded ? .orange : .red)
}
let deviceId: String
switch self.nodesStore.localNodeIdentityState {
case .loading:
return nil
case let .available(id):
deviceId = id
case .unavailable:
return ("Mac identity unavailable", .red)
}
if let entry = self.nodesStore.nodes.first(where: { $0.nodeId == deviceId }) {
guard entry.isConnected else {
return ("Mac capabilities offline", .orange)
}
let commands = Set(entry.commands ?? [])
let missingRequiredCommands = [
OpenClawSystemCommand.notify.rawValue,
OpenClawSystemCommand.run.rawValue,
OpenClawSystemCommand.which.rawValue,
].filter { !commands.contains($0) }
if !missingRequiredCommands.isEmpty {
return ("Mac capabilities incomplete", .orange)
}
return nil
}
guard !self.nodesStore.isLoading, !self.nodesStore.nodes.isEmpty else { return nil }
return ("Mac capabilities offline", .orange)
}
private var healthStatus: (label: String, color: Color) {
if self.state.connectionMode == .local,
let failure = GatewayProcessManager.shared.lastFailureReason
{
return (failure, .red)
}
if self.state.connectionMode == .remote {
let live = GatewayConnectionPresentation(state: self.controlChannel.state)
switch live.tone {
case .healthy:
break
case .transient:
return (live.generalSubtitle, .orange)
case .attention:
return (live.generalSubtitle, .red)
}
}
if let activity = self.activityStore.current {
let color: Color = activity.role == .main ? .accentColor : .gray
let roleLabel = activity.role == .main ? "Main" : "Other"
let text = "\(roleLabel) · \(activity.label)"
return (text, color)
}
let health = self.healthStore.state
let isRefreshing = self.healthStore.isRefreshing
let lastAge = self.healthStore.lastSuccess.map { age(from: $0) }
if isRefreshing {
return ("Health check running…", health.tint)
}
switch health {
case .ok:
let ageText = lastAge.map { " · checked \($0)" } ?? ""
return ("Health ok\(ageText)", .green)
case .linkingNeeded:
return ("Health: login required", .red)
case let .degraded(reason):
let detail = HealthStore.shared.degradedSummary ?? reason
let ageText = lastAge.map { " · checked \($0)" } ?? ""
return ("\(detail)\(ageText)", .orange)
case .unknown:
return ("Health pending", .secondary)
}
}
private var heartbeatStatus: (label: String, color: Color) {
if case .degraded = self.controlChannel.state {
return ("Control channel disconnected", .red)
} else if let evt = self.heartbeatStore.lastEvent {
let ageText = age(from: Date(timeIntervalSince1970: evt.ts / 1000))
switch evt.status {
case "sent":
return ("Last heartbeat sent · \(ageText)", .blue)
case "ok-empty", "ok-token":
return ("Heartbeat ok · \(ageText)", .green)
case "skipped":
return ("Heartbeat skipped · \(ageText)", .secondary)
case "failed":
return ("Heartbeat failed · \(ageText)", .red)
default:
return ("Heartbeat · \(ageText)", .secondary)
}
} else {
return ("No heartbeat yet", .secondary)
}
}
private func statusLine(label: String, color: Color) -> some View {
HStack(spacing: 6) {
Circle()
.fill(color)
.frame(width: 6, height: 6)
Text(label)
.font(.caption)
.foregroundStyle(.secondary)
.multilineTextAlignment(.leading)
.lineLimit(nil)
.fixedSize(horizontal: false, vertical: true)
.layoutPriority(1)
}
.padding(.top, 2)
}
/// Pending-pairing status lines reopen the approval panel (e.g. after "Not Now").
private func pairingStatusLine(label: String) -> some View {
Button {
PairingApprovalCenter.shared.showPanel()
} label: {
self.statusLine(label: label, color: .orange)
}
.buttonStyle(.plain)
.help("Show pairing requests")
}
private var activeBinding: Binding<Bool> {
Binding(get: { !self.state.isPaused }, set: { self.state.isPaused = !$0 })
}
private var heartbeatsBinding: Binding<Bool> {
Binding(get: { self.state.heartbeatsEnabled }, set: { self.state.heartbeatsEnabled = $0 })
}
private var voiceWakeBinding: Binding<Bool> {
MicRefreshSupport.voiceWakeBinding(for: self.state)
}
private var showVoiceWakeMicPicker: Bool {
voiceWakeSupported && self.state.swabbleEnabled
}
private var voiceWakeMicMenu: some View {
Menu {
self.microphoneMenuItems
if self.loadingMics {
Divider()
Label("Refreshing microphones…", systemImage: "arrow.triangle.2.circlepath")
.labelStyle(.titleOnly)
.foregroundStyle(.secondary)
.disabled(true)
}
} label: {
HStack {
Text("Microphone")
Spacer()
Text(self.selectedMicLabel)
.foregroundStyle(.secondary)
}
}
.task { await self.loadMicrophones() }
}
private var selectedMicLabel: String {
if self.state.voiceWakeMicID.isEmpty {
return self.defaultMicLabel
}
if let match = self.availableMics.first(where: { $0.uid == self.state.voiceWakeMicID }) {
return match.name
}
if !self.state.voiceWakeMicName.isEmpty {
return self.state.voiceWakeMicName
}
return "Unavailable"
}
@ViewBuilder
private var microphoneMenuItems: some View {
if self.isSelectedMicUnavailable {
Label("Disconnected (using System default)", systemImage: "exclamationmark.triangle")
.labelStyle(.titleAndIcon)
.foregroundStyle(.secondary)
.disabled(true)
Divider()
}
Button {
self.state.voiceWakeMicID = ""
self.state.voiceWakeMicName = ""
} label: {
Label(self.defaultMicLabel, systemImage: self.state.voiceWakeMicID.isEmpty ? "checkmark" : "")
.labelStyle(.titleAndIcon)
}
.buttonStyle(.plain)
ForEach(self.availableMics) { mic in
Button {
self.state.voiceWakeMicID = mic.uid
self.state.voiceWakeMicName = mic.name
} label: {
Label(mic.name, systemImage: self.state.voiceWakeMicID == mic.uid ? "checkmark" : "")
.labelStyle(.titleAndIcon)
}
.buttonStyle(.plain)
}
}
private var isSelectedMicUnavailable: Bool {
let selected = self.state.voiceWakeMicID
guard !selected.isEmpty else { return false }
return !self.availableMics.contains(where: { $0.uid == selected })
}
private var defaultMicLabel: String {
if let host = Host.current().localizedName, !host.isEmpty {
return "Auto-detect (\(host))"
}
return "System default"
}
@MainActor
private func presentDebugResult(_ result: Result<String, DebugActionError>, title: String) {
let alert = NSAlert()
alert.messageText = title
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()
}
@MainActor
private func sendTestNotification() async {
guard !self.testNotificationPending else { return }
self.testNotificationPending = true
let outcome = await DebugActions.sendTestNotification()
self.testNotificationPending = false
let alert = NSAlert()
alert.messageText = "Test Notification"
switch outcome {
case .pending:
return
case .sent:
alert.informativeText = "The notification request was queued."
alert.alertStyle = .informational
case let .error(message):
alert.informativeText = message
alert.alertStyle = .warning
}
alert.runModal()
}
@MainActor
private func loadMicrophones(force: Bool = false) async {
guard self.showVoiceWakeMicPicker else {
self.availableMics = []
self.loadingMics = false
return
}
if !force, !self.availableMics.isEmpty {
return
}
self.loadingMics = true
let discovery = AVCaptureDevice.DiscoverySession(
deviceTypes: [.external, .microphone],
mediaType: .audio,
position: .unspecified)
let connectedDevices = discovery.devices.filter(\.isConnected)
self.availableMics = connectedDevices
.sorted { lhs, rhs in
lhs.localizedName.localizedCaseInsensitiveCompare(rhs.localizedName) == .orderedAscending
}
.map { AudioInputDevice(uid: $0.uniqueID, name: $0.localizedName) }
self.availableMics = self.filterAliveInputs(self.availableMics)
self.state.voiceWakeMicName = MicRefreshSupport.selectedMicName(
selectedID: self.state.voiceWakeMicID,
in: self.availableMics,
uid: \.uid,
name: \.name)
self.loadingMics = false
}
private func filterAliveInputs(_ inputs: [AudioInputDevice]) -> [AudioInputDevice] {
let aliveUIDs = AudioInputDeviceObserver.aliveInputDeviceUIDs()
guard !aliveUIDs.isEmpty else { return inputs }
return inputs.filter { aliveUIDs.contains($0.uid) }
}
private struct AudioInputDevice: Identifiable, Equatable {
let uid: String
let name: String
var id: String {
self.uid
}
}
}