fix: menu devices list
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
84
apps/macos/Sources/Clawdis/NodesStore.swift
Normal file
84
apps/macos/Sources/Clawdis/NodesStore.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user