fix: menu devices list
This commit is contained in:
@@ -5,6 +5,7 @@
|
|||||||
### Fixes
|
### 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.
|
- 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).
|
- 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
|
## 2.0.0-beta4 — 2025-12-27
|
||||||
|
|
||||||
|
|||||||
@@ -22,8 +22,7 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
|||||||
private var cachedErrorText: String?
|
private var cachedErrorText: String?
|
||||||
private var cacheUpdatedAt: Date?
|
private var cacheUpdatedAt: Date?
|
||||||
private let refreshIntervalSeconds: TimeInterval = 12
|
private let refreshIntervalSeconds: TimeInterval = 12
|
||||||
private let nodesStore = InstancesStore.shared
|
private let nodesStore = NodesStore.shared
|
||||||
private let gatewayDiscovery = GatewayDiscoveryModel()
|
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
private var testControlChannelConnected: Bool?
|
private var testControlChannelConnected: Bool?
|
||||||
#endif
|
#endif
|
||||||
@@ -43,7 +42,6 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
self.nodesStore.start()
|
self.nodesStore.start()
|
||||||
self.gatewayDiscovery.start()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func menuWillOpen(_ menu: NSMenu) {
|
func menuWillOpen(_ menu: NSMenu) {
|
||||||
@@ -218,7 +216,7 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if entries.isEmpty {
|
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)
|
menu.insertItem(self.makeMessageItem(text: title, symbolName: "circle.dashed", width: width), at: cursor)
|
||||||
cursor += 1
|
cursor += 1
|
||||||
} else {
|
} else {
|
||||||
@@ -239,7 +237,7 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
|||||||
if entries.count > 8 {
|
if entries.count > 8 {
|
||||||
let moreItem = NSMenuItem()
|
let moreItem = NSMenuItem()
|
||||||
moreItem.tag = self.nodesTag
|
moreItem.tag = self.nodesTag
|
||||||
moreItem.title = "More Nodes..."
|
moreItem.title = "More Devices..."
|
||||||
moreItem.image = NSImage(systemSymbolName: "ellipsis.circle", accessibilityDescription: nil)
|
moreItem.image = NSImage(systemSymbolName: "ellipsis.circle", accessibilityDescription: nil)
|
||||||
let overflow = Array(entries.dropFirst(8))
|
let overflow = Array(entries.dropFirst(8))
|
||||||
moreItem.submenu = self.buildNodesOverflowMenu(entries: overflow, width: width)
|
moreItem.submenu = self.buildNodesOverflowMenu(entries: overflow, width: width)
|
||||||
@@ -436,7 +434,7 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
|||||||
return menu
|
return menu
|
||||||
}
|
}
|
||||||
|
|
||||||
private func buildNodesOverflowMenu(entries: [InstanceInfo], width: CGFloat) -> NSMenu {
|
private func buildNodesOverflowMenu(entries: [NodeInfo], width: CGFloat) -> NSMenu {
|
||||||
let menu = NSMenu()
|
let menu = NSMenu()
|
||||||
for entry in entries {
|
for entry in entries {
|
||||||
let item = NSMenuItem()
|
let item = NSMenuItem()
|
||||||
@@ -452,21 +450,21 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
|||||||
return menu
|
return menu
|
||||||
}
|
}
|
||||||
|
|
||||||
private func buildNodeSubmenu(entry: InstanceInfo) -> NSMenu {
|
private func buildNodeSubmenu(entry: NodeInfo) -> NSMenu {
|
||||||
let menu = NSMenu()
|
let menu = NSMenu()
|
||||||
menu.autoenablesItems = false
|
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 {
|
if let name = entry.displayName?.nonEmpty {
|
||||||
menu.addItem(self.makeNodeCopyItem(label: "Host", value: host))
|
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: "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) {
|
if let platform = NodeMenuEntryFormatter.platformText(entry) {
|
||||||
menu.addItem(self.makeNodeCopyItem(label: "Platform", value: platform))
|
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.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 {
|
if let caps = entry.caps?.filter({ !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }),
|
||||||
menu.addItem(self.makeNodeDetailItem(label: "Last input", value: entry.lastInputDescription))
|
!caps.isEmpty {
|
||||||
|
menu.addItem(self.makeNodeCopyItem(label: "Caps", value: caps.joined(separator: ", ")))
|
||||||
}
|
}
|
||||||
|
|
||||||
if let reason = entry.reason?.nonEmpty {
|
if let commands = entry.commands?.filter({ !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }),
|
||||||
menu.addItem(self.makeNodeDetailItem(label: "Reason", value: reason))
|
!commands.isEmpty {
|
||||||
}
|
menu.addItem(self.makeNodeCopyItem(label: "Commands", value: commands.joined(separator: ", ")))
|
||||||
|
|
||||||
if let sshURL = self.sshURL(for: entry) {
|
|
||||||
menu.addItem(.separator())
|
|
||||||
menu.addItem(self.makeNodeActionItem(title: "Open SSH", url: sshURL))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return menu
|
return menu
|
||||||
@@ -507,12 +503,6 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
|||||||
return item
|
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 {
|
private func formatVersionLabel(_ version: String) -> String {
|
||||||
let trimmed = version.trimmingCharacters(in: .whitespacesAndNewlines)
|
let trimmed = version.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
guard !trimmed.isEmpty else { return version }
|
guard !trimmed.isEmpty else { return version }
|
||||||
@@ -638,104 +628,6 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
|||||||
NSPasteboard.general.setString(value, forType: .string)
|
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
|
// MARK: - Width + placement
|
||||||
|
|
||||||
private func findInsertIndex(in menu: NSMenu) -> Int? {
|
private func findInsertIndex(in menu: NSMenu) -> Int? {
|
||||||
@@ -790,23 +682,14 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
|||||||
return width
|
return width
|
||||||
}
|
}
|
||||||
|
|
||||||
private func sortedNodeEntries() -> [InstanceInfo] {
|
private func sortedNodeEntries() -> [NodeInfo] {
|
||||||
let entries = self.nodesStore.instances.filter { entry in
|
let entries = self.nodesStore.nodes
|
||||||
let mode = entry.mode?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
|
||||||
return mode != "health"
|
|
||||||
}
|
|
||||||
return entries.sorted { lhs, rhs in
|
return entries.sorted { lhs, rhs in
|
||||||
let lhsGateway = NodeMenuEntryFormatter.isGateway(lhs)
|
if lhs.isConnected != rhs.isConnected { return lhs.isConnected }
|
||||||
let rhsGateway = NodeMenuEntryFormatter.isGateway(rhs)
|
if lhs.isPaired != rhs.isPaired { return lhs.isPaired }
|
||||||
if lhsGateway != rhsGateway { return lhsGateway }
|
|
||||||
|
|
||||||
let lhsLocal = NodeMenuEntryFormatter.isLocal(lhs)
|
|
||||||
let rhsLocal = NodeMenuEntryFormatter.isLocal(rhs)
|
|
||||||
if lhsLocal != rhsLocal { return lhsLocal }
|
|
||||||
|
|
||||||
let lhsName = NodeMenuEntryFormatter.primaryName(lhs).lowercased()
|
let lhsName = NodeMenuEntryFormatter.primaryName(lhs).lowercased()
|
||||||
let rhsName = NodeMenuEntryFormatter.primaryName(rhs).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
|
return lhsName < rhsName
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,40 +2,44 @@ import AppKit
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct NodeMenuEntryFormatter {
|
struct NodeMenuEntryFormatter {
|
||||||
static func isGateway(_ entry: InstanceInfo) -> Bool {
|
static func isConnected(_ entry: NodeInfo) -> Bool {
|
||||||
entry.mode?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == "gateway"
|
entry.isConnected
|
||||||
}
|
}
|
||||||
|
|
||||||
static func isLocal(_ entry: InstanceInfo) -> Bool {
|
static func primaryName(_ entry: NodeInfo) -> String {
|
||||||
entry.mode?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == "local"
|
entry.displayName?.nonEmpty ?? entry.nodeId
|
||||||
}
|
}
|
||||||
|
|
||||||
static func primaryName(_ entry: InstanceInfo) -> String {
|
static func summaryText(_ entry: NodeInfo) -> String {
|
||||||
if self.isGateway(entry) {
|
let name = self.primaryName(entry)
|
||||||
let host = entry.host?.nonEmpty
|
var prefix = "Node: \(name)"
|
||||||
if let host, host.lowercased() != "gateway" { return host }
|
if let ip = entry.remoteIp?.nonEmpty {
|
||||||
return "Gateway"
|
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 {
|
static func roleText(_ entry: NodeInfo) -> String {
|
||||||
entry.text.nonEmpty ?? self.primaryName(entry)
|
if entry.isConnected { return "connected" }
|
||||||
|
if entry.isPaired { return "paired" }
|
||||||
|
return "unpaired"
|
||||||
}
|
}
|
||||||
|
|
||||||
static func roleText(_ entry: InstanceInfo) -> String {
|
static func detailLeft(_ entry: NodeInfo) -> String {
|
||||||
if self.isGateway(entry) { return "gateway" }
|
|
||||||
if let mode = entry.mode?.nonEmpty { return mode }
|
|
||||||
return "node"
|
|
||||||
}
|
|
||||||
|
|
||||||
static func detailLeft(_ entry: InstanceInfo) -> String {
|
|
||||||
let role = self.roleText(entry)
|
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
|
return role
|
||||||
}
|
}
|
||||||
|
|
||||||
static func detailRight(_ entry: InstanceInfo) -> String? {
|
static func detailRight(_ entry: NodeInfo) -> String? {
|
||||||
var parts: [String] = []
|
var parts: [String] = []
|
||||||
if let platform = self.platformText(entry) { parts.append(platform) }
|
if let platform = self.platformText(entry) { parts.append(platform) }
|
||||||
if let version = entry.version?.nonEmpty {
|
if let version = entry.version?.nonEmpty {
|
||||||
@@ -46,7 +50,7 @@ struct NodeMenuEntryFormatter {
|
|||||||
return parts.joined(separator: " · ")
|
return parts.joined(separator: " · ")
|
||||||
}
|
}
|
||||||
|
|
||||||
static func platformText(_ entry: InstanceInfo) -> String? {
|
static func platformText(_ entry: NodeInfo) -> String? {
|
||||||
if let raw = entry.platform?.nonEmpty {
|
if let raw = entry.platform?.nonEmpty {
|
||||||
return self.prettyPlatform(raw) ?? raw
|
return self.prettyPlatform(raw) ?? raw
|
||||||
}
|
}
|
||||||
@@ -99,8 +103,7 @@ struct NodeMenuEntryFormatter {
|
|||||||
return trimmed
|
return trimmed
|
||||||
}
|
}
|
||||||
|
|
||||||
static func leadingSymbol(_ entry: InstanceInfo) -> String {
|
static func leadingSymbol(_ entry: NodeInfo) -> String {
|
||||||
if self.isGateway(entry) { return self.safeSystemSymbol("dot.radiowaves.left.and.right", fallback: "network") }
|
|
||||||
if let family = entry.deviceFamily?.lowercased() {
|
if let family = entry.deviceFamily?.lowercased() {
|
||||||
if family.contains("mac") {
|
if family.contains("mac") {
|
||||||
return self.safeSystemSymbol("laptopcomputer", fallback: "laptopcomputer")
|
return self.safeSystemSymbol("laptopcomputer", fallback: "laptopcomputer")
|
||||||
@@ -116,9 +119,11 @@ struct NodeMenuEntryFormatter {
|
|||||||
return "cpu"
|
return "cpu"
|
||||||
}
|
}
|
||||||
|
|
||||||
static func isAndroid(_ entry: InstanceInfo) -> Bool {
|
static func isAndroid(_ entry: NodeInfo) -> Bool {
|
||||||
let family = entry.deviceFamily?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
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 {
|
private static func safeSystemSymbol(_ preferred: String, fallback: String) -> String {
|
||||||
@@ -128,7 +133,7 @@ struct NodeMenuEntryFormatter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
struct NodeMenuRowView: View {
|
struct NodeMenuRowView: View {
|
||||||
let entry: InstanceInfo
|
let entry: NodeInfo
|
||||||
let width: CGFloat
|
let width: CGFloat
|
||||||
@Environment(\.menuItemHighlighted) private var isHighlighted
|
@Environment(\.menuItemHighlighted) private var isHighlighted
|
||||||
|
|
||||||
@@ -147,7 +152,7 @@ struct NodeMenuRowView: View {
|
|||||||
|
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
Text(NodeMenuEntryFormatter.primaryName(self.entry))
|
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)
|
.foregroundStyle(self.primaryColor)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
.truncationMode(.middle)
|
.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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ read_when:
|
|||||||
## What is shown
|
## What is shown
|
||||||
- We surface the current agent work state in the menu bar icon and in the first status row of the menu.
|
- 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.
|
- 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
|
## 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.
|
- 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.
|
||||||
|
|||||||
Reference in New Issue
Block a user