From 510e2a1d17a9c6f95737cc592b4b1257a9ac17ae Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 29 Dec 2025 17:31:23 +0100 Subject: [PATCH] fix: menu devices list --- CHANGELOG.md | 1 + .../Clawdis/MenuSessionsInjector.swift | 163 +++--------------- apps/macos/Sources/Clawdis/NodesMenu.swift | 61 ++++--- apps/macos/Sources/Clawdis/NodesStore.swift | 84 +++++++++ docs/mac/menu-bar.md | 1 + 5 files changed, 142 insertions(+), 168 deletions(-) create mode 100644 apps/macos/Sources/Clawdis/NodesStore.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index ab4509c82..7cf8b81e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### Fixes - macOS: Voice Wake now fully tears down the Speech pipeline when disabled (cancel pending restarts, drop stale callbacks) to avoid high CPU in the background. - iOS/Android nodes: enable scrolling for loaded web pages in the Canvas WebView (default scaffold stays touch-first). +- macOS menu: device list now uses `node.list` (devices only; no agent/tool presence entries). ## 2.0.0-beta4 — 2025-12-27 diff --git a/apps/macos/Sources/Clawdis/MenuSessionsInjector.swift b/apps/macos/Sources/Clawdis/MenuSessionsInjector.swift index a66b1a38d..fb066d303 100644 --- a/apps/macos/Sources/Clawdis/MenuSessionsInjector.swift +++ b/apps/macos/Sources/Clawdis/MenuSessionsInjector.swift @@ -22,8 +22,7 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate { private var cachedErrorText: String? private var cacheUpdatedAt: Date? private let refreshIntervalSeconds: TimeInterval = 12 - private let nodesStore = InstancesStore.shared - private let gatewayDiscovery = GatewayDiscoveryModel() + private let nodesStore = NodesStore.shared #if DEBUG private var testControlChannelConnected: Bool? #endif @@ -43,7 +42,6 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate { } self.nodesStore.start() - self.gatewayDiscovery.start() } func menuWillOpen(_ menu: NSMenu) { @@ -218,7 +216,7 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate { } if entries.isEmpty { - let title = self.nodesStore.isLoading ? "Loading nodes..." : "No nodes yet" + let title = self.nodesStore.isLoading ? "Loading devices..." : "No devices yet" menu.insertItem(self.makeMessageItem(text: title, symbolName: "circle.dashed", width: width), at: cursor) cursor += 1 } else { @@ -239,7 +237,7 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate { if entries.count > 8 { let moreItem = NSMenuItem() moreItem.tag = self.nodesTag - moreItem.title = "More Nodes..." + moreItem.title = "More Devices..." moreItem.image = NSImage(systemSymbolName: "ellipsis.circle", accessibilityDescription: nil) let overflow = Array(entries.dropFirst(8)) moreItem.submenu = self.buildNodesOverflowMenu(entries: overflow, width: width) @@ -436,7 +434,7 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate { return menu } - private func buildNodesOverflowMenu(entries: [InstanceInfo], width: CGFloat) -> NSMenu { + private func buildNodesOverflowMenu(entries: [NodeInfo], width: CGFloat) -> NSMenu { let menu = NSMenu() for entry in entries { let item = NSMenuItem() @@ -452,21 +450,21 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate { return menu } - private func buildNodeSubmenu(entry: InstanceInfo) -> NSMenu { + private func buildNodeSubmenu(entry: NodeInfo) -> NSMenu { let menu = NSMenu() menu.autoenablesItems = false - menu.addItem(self.makeNodeCopyItem(label: "ID", value: entry.id)) + menu.addItem(self.makeNodeCopyItem(label: "Node ID", value: entry.nodeId)) - if let host = entry.host?.nonEmpty { - menu.addItem(self.makeNodeCopyItem(label: "Host", value: host)) + if let name = entry.displayName?.nonEmpty { + menu.addItem(self.makeNodeCopyItem(label: "Name", value: name)) } - if let ip = entry.ip?.nonEmpty { + if let ip = entry.remoteIp?.nonEmpty { menu.addItem(self.makeNodeCopyItem(label: "IP", value: ip)) } - menu.addItem(self.makeNodeCopyItem(label: "Role", value: NodeMenuEntryFormatter.roleText(entry))) + menu.addItem(self.makeNodeCopyItem(label: "Status", value: NodeMenuEntryFormatter.roleText(entry))) if let platform = NodeMenuEntryFormatter.platformText(entry) { menu.addItem(self.makeNodeCopyItem(label: "Platform", value: platform)) @@ -476,19 +474,17 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate { menu.addItem(self.makeNodeCopyItem(label: "Version", value: self.formatVersionLabel(version))) } - menu.addItem(self.makeNodeDetailItem(label: "Last seen", value: entry.ageDescription)) + menu.addItem(self.makeNodeDetailItem(label: "Connected", value: entry.isConnected ? "Yes" : "No")) + menu.addItem(self.makeNodeDetailItem(label: "Paired", value: entry.isPaired ? "Yes" : "No")) - if entry.lastInputSeconds != nil { - menu.addItem(self.makeNodeDetailItem(label: "Last input", value: entry.lastInputDescription)) + if let caps = entry.caps?.filter({ !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }), + !caps.isEmpty { + menu.addItem(self.makeNodeCopyItem(label: "Caps", value: caps.joined(separator: ", "))) } - if let reason = entry.reason?.nonEmpty { - menu.addItem(self.makeNodeDetailItem(label: "Reason", value: reason)) - } - - if let sshURL = self.sshURL(for: entry) { - menu.addItem(.separator()) - menu.addItem(self.makeNodeActionItem(title: "Open SSH", url: sshURL)) + if let commands = entry.commands?.filter({ !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }), + !commands.isEmpty { + menu.addItem(self.makeNodeCopyItem(label: "Commands", value: commands.joined(separator: ", "))) } return menu @@ -507,12 +503,6 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate { return item } - private func makeNodeActionItem(title: String, url: URL) -> NSMenuItem { - let item = NSMenuItem(title: title, action: #selector(self.openNodeSSH(_:)), keyEquivalent: "") - item.target = self - item.representedObject = url - return item - } private func formatVersionLabel(_ version: String) -> String { let trimmed = version.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return version } @@ -638,104 +628,6 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate { NSPasteboard.general.setString(value, forType: .string) } - @objc - private func openNodeSSH(_ sender: NSMenuItem) { - guard let url = sender.representedObject as? URL else { return } - - if let appURL = self.preferredTerminalAppURL() { - NSWorkspace.shared.open( - [url], - withApplicationAt: appURL, - configuration: NSWorkspace.OpenConfiguration(), - completionHandler: nil) - } else { - NSWorkspace.shared.open(url) - } - } - - private func preferredTerminalAppURL() -> URL? { - if let ghosty = self.ghostyAppURL() { return ghosty } - return NSWorkspace.shared.urlForApplication(withBundleIdentifier: "com.apple.Terminal") - } - - private func ghostyAppURL() -> URL? { - let candidates = [ - "/Applications/Ghosty.app", - ("~/Applications/Ghosty.app" as NSString).expandingTildeInPath, - ] - for path in candidates where FileManager.default.fileExists(atPath: path) { - return URL(fileURLWithPath: path) - } - return nil - } - - private func sshURL(for entry: InstanceInfo) -> URL? { - guard NodeMenuEntryFormatter.isGateway(entry) else { return nil } - guard let gateway = self.matchingGateway(for: entry) else { return nil } - guard let host = self.sanitizedTailnetHost(gateway.tailnetDns) ?? gateway.lanHost else { return nil } - let user = NSUserName() - return self.buildSSHURL(user: user, host: host, port: gateway.sshPort) - } - - private func matchingGateway(for entry: InstanceInfo) -> GatewayDiscoveryModel.DiscoveredGateway? { - let candidates = self.entryHostCandidates(entry) - guard !candidates.isEmpty else { return nil } - return self.gatewayDiscovery.gateways.first { gateway in - let gatewayTokens = self.gatewayHostTokens(gateway) - return candidates.contains { gatewayTokens.contains($0) } - } - } - - private func entryHostCandidates(_ entry: InstanceInfo) -> [String] { - let raw: [String?] = [ - entry.host, - entry.ip, - NodeMenuEntryFormatter.primaryName(entry), - ] - return raw.compactMap(self.normalizedHostToken(_:)) - } - - private func gatewayHostTokens(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) -> [String] { - let raw: [String?] = [ - gateway.displayName, - gateway.lanHost, - gateway.tailnetDns, - ] - return raw.compactMap(self.normalizedHostToken(_:)) - } - - private func normalizedHostToken(_ value: String?) -> String? { - guard let value else { return nil } - let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) - if trimmed.isEmpty { return nil } - let lower = trimmed.lowercased().trimmingCharacters(in: CharacterSet(charactersIn: ".")) - if lower.hasSuffix(".localdomain") { - return lower.replacingOccurrences(of: ".localdomain", with: ".local") - } - return lower - } - - private func sanitizedTailnetHost(_ host: String?) -> String? { - guard let host else { return nil } - let trimmed = host.trimmingCharacters(in: .whitespacesAndNewlines) - if trimmed.isEmpty { return nil } - if trimmed.hasSuffix(".internal.") || trimmed.hasSuffix(".internal") { - return nil - } - return trimmed - } - - private func buildSSHURL(user: String, host: String, port: Int) -> URL? { - var components = URLComponents() - components.scheme = "ssh" - components.user = user - components.host = host - if port != 22 { - components.port = port - } - return components.url - } - // MARK: - Width + placement private func findInsertIndex(in menu: NSMenu) -> Int? { @@ -790,23 +682,14 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate { return width } - private func sortedNodeEntries() -> [InstanceInfo] { - let entries = self.nodesStore.instances.filter { entry in - let mode = entry.mode?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - return mode != "health" - } + private func sortedNodeEntries() -> [NodeInfo] { + let entries = self.nodesStore.nodes return entries.sorted { lhs, rhs in - let lhsGateway = NodeMenuEntryFormatter.isGateway(lhs) - let rhsGateway = NodeMenuEntryFormatter.isGateway(rhs) - if lhsGateway != rhsGateway { return lhsGateway } - - let lhsLocal = NodeMenuEntryFormatter.isLocal(lhs) - let rhsLocal = NodeMenuEntryFormatter.isLocal(rhs) - if lhsLocal != rhsLocal { return lhsLocal } - + if lhs.isConnected != rhs.isConnected { return lhs.isConnected } + if lhs.isPaired != rhs.isPaired { return lhs.isPaired } let lhsName = NodeMenuEntryFormatter.primaryName(lhs).lowercased() let rhsName = NodeMenuEntryFormatter.primaryName(rhs).lowercased() - if lhsName == rhsName { return lhs.ts > rhs.ts } + if lhsName == rhsName { return lhs.nodeId < rhs.nodeId } return lhsName < rhsName } } diff --git a/apps/macos/Sources/Clawdis/NodesMenu.swift b/apps/macos/Sources/Clawdis/NodesMenu.swift index ec068ad8b..45e1b0c44 100644 --- a/apps/macos/Sources/Clawdis/NodesMenu.swift +++ b/apps/macos/Sources/Clawdis/NodesMenu.swift @@ -2,40 +2,44 @@ import AppKit import SwiftUI struct NodeMenuEntryFormatter { - static func isGateway(_ entry: InstanceInfo) -> Bool { - entry.mode?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == "gateway" + static func isConnected(_ entry: NodeInfo) -> Bool { + entry.isConnected } - static func isLocal(_ entry: InstanceInfo) -> Bool { - entry.mode?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == "local" + static func primaryName(_ entry: NodeInfo) -> String { + entry.displayName?.nonEmpty ?? entry.nodeId } - static func primaryName(_ entry: InstanceInfo) -> String { - if self.isGateway(entry) { - let host = entry.host?.nonEmpty - if let host, host.lowercased() != "gateway" { return host } - return "Gateway" + static func summaryText(_ entry: NodeInfo) -> String { + let name = self.primaryName(entry) + var prefix = "Node: \(name)" + if let ip = entry.remoteIp?.nonEmpty { + prefix += " (\(ip))" } - return entry.host?.nonEmpty ?? entry.id + var parts = [prefix] + if let platform = self.platformText(entry) { + parts.append("platform \(platform)") + } + if let version = entry.version?.nonEmpty { + parts.append("app \(self.compactVersion(version))") + } + parts.append("status \(self.roleText(entry))") + return parts.joined(separator: " · ") } - static func summaryText(_ entry: InstanceInfo) -> String { - entry.text.nonEmpty ?? self.primaryName(entry) + static func roleText(_ entry: NodeInfo) -> String { + if entry.isConnected { return "connected" } + if entry.isPaired { return "paired" } + return "unpaired" } - static func roleText(_ entry: InstanceInfo) -> String { - if self.isGateway(entry) { return "gateway" } - if let mode = entry.mode?.nonEmpty { return mode } - return "node" - } - - static func detailLeft(_ entry: InstanceInfo) -> String { + static func detailLeft(_ entry: NodeInfo) -> String { let role = self.roleText(entry) - if let ip = entry.ip?.nonEmpty { return "\(ip) · \(role)" } + if let ip = entry.remoteIp?.nonEmpty { return "\(ip) · \(role)" } return role } - static func detailRight(_ entry: InstanceInfo) -> String? { + static func detailRight(_ entry: NodeInfo) -> String? { var parts: [String] = [] if let platform = self.platformText(entry) { parts.append(platform) } if let version = entry.version?.nonEmpty { @@ -46,7 +50,7 @@ struct NodeMenuEntryFormatter { return parts.joined(separator: " · ") } - static func platformText(_ entry: InstanceInfo) -> String? { + static func platformText(_ entry: NodeInfo) -> String? { if let raw = entry.platform?.nonEmpty { return self.prettyPlatform(raw) ?? raw } @@ -99,8 +103,7 @@ struct NodeMenuEntryFormatter { return trimmed } - static func leadingSymbol(_ entry: InstanceInfo) -> String { - if self.isGateway(entry) { return self.safeSystemSymbol("dot.radiowaves.left.and.right", fallback: "network") } + static func leadingSymbol(_ entry: NodeInfo) -> String { if let family = entry.deviceFamily?.lowercased() { if family.contains("mac") { return self.safeSystemSymbol("laptopcomputer", fallback: "laptopcomputer") @@ -116,9 +119,11 @@ struct NodeMenuEntryFormatter { return "cpu" } - static func isAndroid(_ entry: InstanceInfo) -> Bool { + static func isAndroid(_ entry: NodeInfo) -> Bool { let family = entry.deviceFamily?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - return family == "android" + 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 { @@ -128,7 +133,7 @@ struct NodeMenuEntryFormatter { } struct NodeMenuRowView: View { - let entry: InstanceInfo + let entry: NodeInfo let width: CGFloat @Environment(\.menuItemHighlighted) private var isHighlighted @@ -147,7 +152,7 @@ struct NodeMenuRowView: View { VStack(alignment: .leading, spacing: 2) { Text(NodeMenuEntryFormatter.primaryName(self.entry)) - .font(.callout.weight(NodeMenuEntryFormatter.isGateway(self.entry) ? .semibold : .regular)) + .font(.callout.weight(NodeMenuEntryFormatter.isConnected(self.entry) ? .semibold : .regular)) .foregroundStyle(self.primaryColor) .lineLimit(1) .truncationMode(.middle) diff --git a/apps/macos/Sources/Clawdis/NodesStore.swift b/apps/macos/Sources/Clawdis/NodesStore.swift new file mode 100644 index 000000000..2c00e15f7 --- /dev/null +++ b/apps/macos/Sources/Clawdis/NodesStore.swift @@ -0,0 +1,84 @@ +import Foundation +import Observation +import OSLog + +struct NodeInfo: Identifiable, Codable { + let nodeId: String + let displayName: String? + let platform: String? + let version: String? + let deviceFamily: String? + let modelIdentifier: String? + let remoteIp: String? + let caps: [String]? + let commands: [String]? + let permissions: [String: Bool]? + let paired: Bool? + let connected: Bool? + + var id: String { self.nodeId } + var isConnected: Bool { self.connected ?? false } + var isPaired: Bool { self.paired ?? false } +} + +private struct NodeListResponse: Codable { + let ts: Double? + let nodes: [NodeInfo] +} + +@MainActor +@Observable +final class NodesStore { + static let shared = NodesStore() + + var nodes: [NodeInfo] = [] + var lastError: String? + var statusMessage: String? + var isLoading = false + + private let logger = Logger(subsystem: "com.steipete.clawdis", category: "nodes") + private var task: Task? + private let interval: TimeInterval = 30 + private var startCount = 0 + + func start() { + self.startCount += 1 + guard self.startCount == 1 else { return } + guard self.task == nil else { return } + self.task = Task.detached { [weak self] in + guard let self else { return } + await self.refresh() + while !Task.isCancelled { + try? await Task.sleep(nanoseconds: UInt64(self.interval * 1_000_000_000)) + await self.refresh() + } + } + } + + func stop() { + guard self.startCount > 0 else { return } + self.startCount -= 1 + guard self.startCount == 0 else { return } + self.task?.cancel() + self.task = nil + } + + func refresh() async { + if self.isLoading { return } + self.statusMessage = nil + self.isLoading = true + defer { self.isLoading = false } + do { + let data = try await GatewayConnection.shared.requestRaw(method: "node.list", params: nil, timeoutMs: 8000) + let decoded = try JSONDecoder().decode(NodeListResponse.self, from: data) + self.nodes = decoded.nodes + self.lastError = nil + self.statusMessage = nil + } catch { + self.logger.error("node.list failed \(error.localizedDescription, privacy: .public)") + self.nodes = [] + self.lastError = error.localizedDescription + self.statusMessage = nil + } + } +} diff --git a/docs/mac/menu-bar.md b/docs/mac/menu-bar.md index b4a672629..bfe9a8c36 100644 --- a/docs/mac/menu-bar.md +++ b/docs/mac/menu-bar.md @@ -8,6 +8,7 @@ read_when: ## What is shown - We surface the current agent work state in the menu bar icon and in the first status row of the menu. - Health status is hidden while work is active; it returns when all sessions are idle. +- The “Nodes” block in the menu lists **devices** only (gateway bridge nodes via `node.list`), not client/presence entries. ## State model - Sessions: events arrive with `runId` (session key). The “main” session is the key `main`; if absent, we fall back to the most recently updated session.