From ec392dc8705a13ced1f3cc6d71ed1b4fef04e12f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 26 Dec 2025 20:42:49 +0000 Subject: [PATCH] feat(mac): add node ssh and compact versions --- CHANGELOG.md | 2 + .../Clawdis/MenuSessionsInjector.swift | 112 ++++++++++++++++++ apps/macos/Sources/Clawdis/NodesMenu.swift | 17 ++- 3 files changed, 130 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d42d8f14..fccf07642 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -60,6 +60,8 @@ - Update-ready state surfaced in the menu; menu sections regrouped with session submenus. - Menu bar now shows a dedicated Nodes section under Context with inline rows, overflow submenu, and iconized actions. - Nodes now expose consistent inline details with per-node submenus for quick copy of key fields. +- Node rows now show compact app versions (build numbers moved to submenus) and offer SSH launch from Bonjour when available. +- Menu actions are grouped below toggles; Open Canvas hides when disabled and Voice Wake now anchors the mic picker. - Session list polish: sleeping/disconnected/error states, usage bar restored, padding + bar sizing tuned, syncing menu removed, header hidden when disconnected. - Chat UI polish: tool call cards + merged tool results, glass background, tighter composer spacing, visual effect host tweaks. - OAuth storage moved; legacy session syncing metadata removed. diff --git a/apps/macos/Sources/Clawdis/MenuSessionsInjector.swift b/apps/macos/Sources/Clawdis/MenuSessionsInjector.swift index 24eeb04af..38d2a05c4 100644 --- a/apps/macos/Sources/Clawdis/MenuSessionsInjector.swift +++ b/apps/macos/Sources/Clawdis/MenuSessionsInjector.swift @@ -22,6 +22,7 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate { private var cacheUpdatedAt: Date? private let refreshIntervalSeconds: TimeInterval = 12 private let nodesStore = InstancesStore.shared + private let gatewayDiscovery = GatewayDiscoveryModel() #if DEBUG private var testControlChannelConnected: Bool? #endif @@ -41,6 +42,7 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate { } self.nodesStore.start() + self.gatewayDiscovery.start() } func menuWillOpen(_ menu: NSMenu) { @@ -458,6 +460,11 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate { 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)) + } + return menu } @@ -474,6 +481,13 @@ 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 + } + @objc private func patchThinking(_ sender: NSMenuItem) { guard let dict = sender.representedObject as? [String: Any], @@ -590,6 +604,104 @@ 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? { diff --git a/apps/macos/Sources/Clawdis/NodesMenu.swift b/apps/macos/Sources/Clawdis/NodesMenu.swift index ae2980808..ec068ad8b 100644 --- a/apps/macos/Sources/Clawdis/NodesMenu.swift +++ b/apps/macos/Sources/Clawdis/NodesMenu.swift @@ -38,7 +38,10 @@ struct NodeMenuEntryFormatter { static func detailRight(_ entry: InstanceInfo) -> String? { var parts: [String] = [] if let platform = self.platformText(entry) { parts.append(platform) } - if let version = entry.version?.nonEmpty { parts.append("v\(version)") } + if let version = entry.version?.nonEmpty { + let short = self.compactVersion(version) + parts.append("v\(short)") + } if parts.isEmpty { return nil } return parts.joined(separator: " ยท ") } @@ -84,6 +87,18 @@ struct NodeMenuEntryFormatter { return (prefix, versionToken) } + 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[.. String { if self.isGateway(entry) { return self.safeSystemSymbol("dot.radiowaves.left.and.right", fallback: "network") } if let family = entry.deviceFamily?.lowercased() {