fix: menu devices list

This commit is contained in:
Peter Steinberger
2025-12-29 17:31:23 +01:00
parent ebfe55f909
commit 510e2a1d17
5 changed files with 142 additions and 168 deletions

View File

@@ -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
}
}

View File

@@ -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)

View File

@@ -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<Void, Never>?
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
}
}
}