Files
openclaw/apps/macos/Sources/OpenClaw/NodesMenu.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

271 lines
10 KiB
Swift

import AppKit
import SwiftUI
struct NodeMenuEntryFormatter {
static func isGateway(_ entry: NodeInfo) -> Bool {
entry.nodeId == "gateway"
}
static func isConnected(_ entry: NodeInfo) -> Bool {
entry.isConnected
}
static func primaryName(_ entry: NodeInfo) -> String {
if self.isGateway(entry) {
return entry.displayName?.nonEmpty ?? "Gateway"
}
return entry.displayName?.nonEmpty ?? entry.nodeId
}
static func summaryText(_ entry: NodeInfo) -> String {
if self.isGateway(entry) {
let role = self.roleText(entry)
let name = self.primaryName(entry)
var parts = ["\(name) · \(role)"]
if let ip = entry.remoteIp?.nonEmpty { parts.append("host \(ip)") }
if let platform = self.platformText(entry) { parts.append(platform) }
return parts.joined(separator: " · ")
}
let name = self.primaryName(entry)
var prefix = "Node: \(name)"
if let ip = entry.remoteIp?.nonEmpty {
prefix += " (\(ip))"
}
var parts = [prefix]
if let platform = self.platformText(entry) {
parts.append("platform \(platform)")
}
let versionLabels = self.versionLabels(entry)
if !versionLabels.isEmpty {
parts.append(versionLabels.joined(separator: " · "))
}
parts.append("status \(self.roleText(entry))")
return parts.joined(separator: " · ")
}
static func roleText(_ entry: NodeInfo) -> String {
if self.isGateway(entry) {
return entry.isConnected ? "connected" : "disconnected"
}
let pairing = entry.isPaired ? "paired" : "unpaired"
let connection = entry.isConnected ? "connected" : "disconnected"
return "\(pairing) · \(connection)"
}
static func detailLeft(_ entry: NodeInfo) -> String {
let role = self.roleText(entry)
if let ip = entry.remoteIp?.nonEmpty { return "\(ip) · \(role)" }
return role
}
static func headlineRight(_ entry: NodeInfo) -> String? {
self.platformText(entry)
}
static func detailRightVersion(_ entry: NodeInfo) -> String? {
let labels = self.versionLabels(entry, compact: false)
if labels.isEmpty { return nil }
return labels.joined(separator: " · ")
}
static func platformText(_ entry: NodeInfo) -> String? {
if let raw = entry.platform?.nonEmpty {
return PlatformLabelFormatter.pretty(raw) ?? raw
}
if let family = entry.deviceFamily?.lowercased() {
if family.contains("mac") { return "macOS" }
if family.contains("iphone") { return "iOS" }
if family.contains("ipad") { return "iPadOS" }
if family.contains("android") { return "Android" }
}
return nil
}
private static func compactVersion(_ raw: String) -> String {
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return trimmed }
if let range = trimmed.range(
of: #"\s*\([^)]*\d[^)]*\)$"#,
options: .regularExpression)
{
return String(trimmed[..<range.lowerBound])
}
return trimmed
}
private static func shortVersionLabel(_ raw: String) -> String {
let compact = self.compactVersion(raw)
if compact.isEmpty { return compact }
if compact.lowercased().hasPrefix("v") { return compact }
if let first = compact.unicodeScalars.first, CharacterSet.decimalDigits.contains(first) {
return "v\(compact)"
}
return compact
}
private static func versionLabels(_ entry: NodeInfo, compact: Bool = true) -> [String] {
let (core, ui) = self.resolveVersions(entry)
var labels: [String] = []
if let core {
let label = compact ? self.compactVersion(core) : self.shortVersionLabel(core)
labels.append("core \(label)")
}
if let ui {
let label = compact ? self.compactVersion(ui) : self.shortVersionLabel(ui)
labels.append("ui \(label)")
}
return labels
}
private static func resolveVersions(_ entry: NodeInfo) -> (core: String?, ui: String?) {
let core = entry.coreVersion?.nonEmpty
let ui = entry.uiVersion?.nonEmpty
if core != nil || ui != nil {
return (core, ui)
}
guard let legacy = entry.version?.nonEmpty else { return (nil, nil) }
if self.isHeadlessPlatform(entry) {
return (legacy, nil)
}
return (nil, legacy)
}
private static func isHeadlessPlatform(_ entry: NodeInfo) -> Bool {
let raw = entry.platform?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() ?? ""
if raw == "darwin" || raw == "linux" || raw == "win32" || raw == "windows" { return true }
return false
}
static func leadingSymbol(_ entry: NodeInfo) -> String {
if self.isGateway(entry) {
return self.safeSystemSymbol(
"antenna.radiowaves.left.and.right",
fallback: "dot.radiowaves.left.and.right")
}
if let family = entry.deviceFamily?.lowercased() {
if family.contains("mac") {
return self.safeSystemSymbol("laptopcomputer", fallback: "laptopcomputer")
}
if family.contains("iphone") { return self.safeSystemSymbol("iphone", fallback: "iphone") }
if family.contains("ipad") { return self.safeSystemSymbol("ipad", fallback: "ipad") }
}
if let platform = entry.platform?.lowercased() {
if platform.contains("mac") { return self.safeSystemSymbol("laptopcomputer", fallback: "laptopcomputer") }
if platform.contains("ios") { return self.safeSystemSymbol("iphone", fallback: "iphone") }
if platform.contains("android") { return self.safeSystemSymbol("cpu", fallback: "cpu") }
}
return "cpu"
}
static func isAndroid(_ entry: NodeInfo) -> Bool {
let family = entry.deviceFamily?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
if family == "android" { return true }
let platform = entry.platform?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
return platform?.contains("android") == true
}
private static func safeSystemSymbol(_ preferred: String, fallback: String) -> String {
if NSImage(systemSymbolName: preferred, accessibilityDescription: nil) != nil { return preferred }
return fallback
}
}
struct NodeMenuRowView: View {
let entry: NodeInfo
let width: CGFloat
@Environment(\.menuItemHighlighted) private var isHighlighted
private var palette: MenuItemHighlightColors.Palette {
MenuItemHighlightColors.palette(self.isHighlighted)
}
var body: some View {
HStack(alignment: .center, spacing: 10) {
self.leadingIcon
.frame(width: 22, height: 22, alignment: .center)
VStack(alignment: .leading, spacing: 2) {
HStack(alignment: .firstTextBaseline, spacing: 8) {
Text(NodeMenuEntryFormatter.primaryName(self.entry))
.font(.callout.weight(NodeMenuEntryFormatter.isConnected(self.entry) ? .semibold : .regular))
.foregroundStyle(self.palette.primary)
.lineLimit(1)
.truncationMode(.middle)
.layoutPriority(1)
Spacer(minLength: 8)
HStack(alignment: .firstTextBaseline, spacing: 6) {
if let right = NodeMenuEntryFormatter.headlineRight(self.entry) {
Text(right)
.font(.caption.monospacedDigit())
.foregroundStyle(self.palette.secondary)
.lineLimit(1)
.truncationMode(.middle)
.layoutPriority(2)
}
Image(systemName: "chevron.right")
.font(.caption.weight(.semibold))
.foregroundStyle(self.palette.secondary)
.padding(.leading, 2)
}
}
HStack(alignment: .firstTextBaseline, spacing: 8) {
Text(NodeMenuEntryFormatter.detailLeft(self.entry))
.font(.caption)
.foregroundStyle(self.palette.secondary)
.lineLimit(1)
.truncationMode(.middle)
Spacer(minLength: 0)
if let version = NodeMenuEntryFormatter.detailRightVersion(self.entry) {
Text(version)
.font(.caption.monospacedDigit())
.foregroundStyle(self.palette.secondary)
.lineLimit(1)
.truncationMode(.middle)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
.padding(.vertical, 8)
.padding(.leading, 18)
.padding(.trailing, 12)
.frame(width: max(1, self.width), alignment: .leading)
}
@ViewBuilder
private var leadingIcon: some View {
if NodeMenuEntryFormatter.isAndroid(self.entry) {
AndroidMark()
.foregroundStyle(self.palette.secondary)
} else {
Image(systemName: NodeMenuEntryFormatter.leadingSymbol(self.entry))
.font(.system(size: 18, weight: .regular))
.foregroundStyle(self.palette.secondary)
}
}
}
struct AndroidMark: View {
var body: some View {
GeometryReader { geo in
let w = geo.size.width
let h = geo.size.height
let headHeight = h * 0.68
let headWidth = w * 0.92
let headX = (w - headWidth) * 0.5
let headY = (h - headHeight) * 0.5
let corner = min(w, h) * 0.18
RoundedRectangle(cornerRadius: corner, style: .continuous)
.frame(width: headWidth, height: headHeight)
.position(x: headX + headWidth * 0.5, y: headY + headHeight * 0.5)
}
}
}