diff --git a/apps/macos/Package.swift b/apps/macos/Package.swift index cf63e7352..3973bff61 100644 --- a/apps/macos/Package.swift +++ b/apps/macos/Package.swift @@ -10,7 +10,9 @@ let package = Package( ], products: [ .library(name: "ClawdbotIPC", targets: ["ClawdbotIPC"]), + .library(name: "ClawdbotDiscovery", targets: ["ClawdbotDiscovery"]), .executable(name: "Clawdbot", targets: ["Clawdbot"]), + .executable(name: "clawdbot-mac-discovery", targets: ["ClawdbotDiscoveryCLI"]), ], dependencies: [ .package(url: "https://github.com/orchetect/MenuBarExtraAccess", exact: "1.2.2"), @@ -36,10 +38,20 @@ let package = Package( swiftSettings: [ .enableUpcomingFeature("StrictConcurrency"), ]), + .target( + name: "ClawdbotDiscovery", + dependencies: [ + .product(name: "ClawdbotKit", package: "ClawdbotKit"), + ], + path: "Sources/ClawdbotDiscovery", + swiftSettings: [ + .enableUpcomingFeature("StrictConcurrency"), + ]), .executableTarget( name: "Clawdbot", dependencies: [ "ClawdbotIPC", + "ClawdbotDiscovery", "ClawdbotProtocol", .product(name: "ClawdbotKit", package: "ClawdbotKit"), .product(name: "ClawdbotChatUI", package: "ClawdbotKit"), @@ -61,11 +73,21 @@ let package = Package( swiftSettings: [ .enableUpcomingFeature("StrictConcurrency"), ]), + .executableTarget( + name: "ClawdbotDiscoveryCLI", + dependencies: [ + "ClawdbotDiscovery", + ], + path: "Sources/ClawdbotDiscoveryCLI", + swiftSettings: [ + .enableUpcomingFeature("StrictConcurrency"), + ]), .testTarget( name: "ClawdbotIPCTests", dependencies: [ "ClawdbotIPC", "Clawdbot", + "ClawdbotDiscovery", "ClawdbotProtocol", .product(name: "SwabbleKit", package: "swabble"), ], diff --git a/apps/macos/Sources/Clawdbot/GatewayDiscoveryMenu.swift b/apps/macos/Sources/Clawdbot/GatewayDiscoveryMenu.swift index de0715a34..f0e5a40a0 100644 --- a/apps/macos/Sources/Clawdbot/GatewayDiscoveryMenu.swift +++ b/apps/macos/Sources/Clawdbot/GatewayDiscoveryMenu.swift @@ -1,3 +1,4 @@ +import ClawdbotDiscovery import SwiftUI struct GatewayDiscoveryInlineList: View { diff --git a/apps/macos/Sources/Clawdbot/GeneralSettings.swift b/apps/macos/Sources/Clawdbot/GeneralSettings.swift index 7f5368cb4..e12041803 100644 --- a/apps/macos/Sources/Clawdbot/GeneralSettings.swift +++ b/apps/macos/Sources/Clawdbot/GeneralSettings.swift @@ -1,4 +1,5 @@ import AppKit +import ClawdbotDiscovery import ClawdbotIPC import ClawdbotKit import CoreLocation @@ -12,7 +13,8 @@ struct GeneralSettings: View { @AppStorage(locationPreciseKey) private var locationPreciseEnabled: Bool = true private let healthStore = HealthStore.shared private let gatewayManager = GatewayProcessManager.shared - @State private var gatewayDiscovery = GatewayDiscoveryModel() + @State private var gatewayDiscovery = GatewayDiscoveryModel( + localDisplayName: InstanceIdentity.displayName) @State private var isInstallingCLI = false @State private var cliStatus: String? @State private var cliInstalled = false @@ -187,7 +189,8 @@ struct GeneralSettings: View { } SettingsToggleRow( title: "Attach only", - subtitle: "Use this when the gateway runs externally; the mac app will only attach to an already-running gateway and won't start one locally.", + subtitle: "Use this when the gateway runs externally; the mac app will only attach " + + "to an already-running gateway and won't start one locally.", binding: self.$state.attachExistingGatewayOnly) TailscaleIntegrationSection( connectionMode: self.state.connectionMode, diff --git a/apps/macos/Sources/Clawdbot/NodeMode/MacNodeModeCoordinator.swift b/apps/macos/Sources/Clawdbot/NodeMode/MacNodeModeCoordinator.swift index dd87f506e..b03a0f809 100644 --- a/apps/macos/Sources/Clawdbot/NodeMode/MacNodeModeCoordinator.swift +++ b/apps/macos/Sources/Clawdbot/NodeMode/MacNodeModeCoordinator.swift @@ -1,3 +1,4 @@ +import ClawdbotDiscovery import ClawdbotKit import Foundation import Network @@ -315,7 +316,9 @@ final class MacNodeModeCoordinator { let port = NWEndpoint.Port(rawValue: localPort) { self.logger.info( - "mac node bridge tunnel ready localPort=\(localPort, privacy: .public) remotePort=\(remotePort, privacy: .public)") + "mac node bridge tunnel ready " + + "localPort=\(localPort, privacy: .public) " + + "remotePort=\(remotePort, privacy: .public)") return .hostPort(host: "127.0.0.1", port: port) } } catch { diff --git a/apps/macos/Sources/Clawdbot/NodePairingApprovalPrompter.swift b/apps/macos/Sources/Clawdbot/NodePairingApprovalPrompter.swift index 17532ec8e..01f0879a5 100644 --- a/apps/macos/Sources/Clawdbot/NodePairingApprovalPrompter.swift +++ b/apps/macos/Sources/Clawdbot/NodePairingApprovalPrompter.swift @@ -1,4 +1,5 @@ import AppKit +import ClawdbotDiscovery import ClawdbotIPC import ClawdbotProtocol import Foundation @@ -533,7 +534,7 @@ final class NodePairingApprovalPrompter { return SSHTarget(host: host, port: port) } - let model = GatewayDiscoveryModel() + let model = GatewayDiscoveryModel(localDisplayName: InstanceIdentity.displayName) model.start() defer { model.stop() } diff --git a/apps/macos/Sources/Clawdbot/Onboarding.swift b/apps/macos/Sources/Clawdbot/Onboarding.swift index 44b218518..fea9f1085 100644 --- a/apps/macos/Sources/Clawdbot/Onboarding.swift +++ b/apps/macos/Sources/Clawdbot/Onboarding.swift @@ -1,5 +1,6 @@ import AppKit import ClawdbotChatUI +import ClawdbotDiscovery import ClawdbotIPC import Combine import Observation @@ -156,7 +157,8 @@ struct OnboardingView: View { init( state: AppState = AppStateStore.shared, permissionMonitor: PermissionMonitor = .shared, - discoveryModel: GatewayDiscoveryModel = GatewayDiscoveryModel()) + discoveryModel: GatewayDiscoveryModel = GatewayDiscoveryModel( + localDisplayName: InstanceIdentity.displayName)) { self.state = state self.permissionMonitor = permissionMonitor diff --git a/apps/macos/Sources/Clawdbot/OnboardingView+Actions.swift b/apps/macos/Sources/Clawdbot/OnboardingView+Actions.swift index fb661edd1..12dee200e 100644 --- a/apps/macos/Sources/Clawdbot/OnboardingView+Actions.swift +++ b/apps/macos/Sources/Clawdbot/OnboardingView+Actions.swift @@ -1,4 +1,5 @@ import AppKit +import ClawdbotDiscovery import ClawdbotIPC import SwiftUI diff --git a/apps/macos/Sources/Clawdbot/OnboardingView+Pages.swift b/apps/macos/Sources/Clawdbot/OnboardingView+Pages.swift index 326504ec6..55ef37f65 100644 --- a/apps/macos/Sources/Clawdbot/OnboardingView+Pages.swift +++ b/apps/macos/Sources/Clawdbot/OnboardingView+Pages.swift @@ -1,5 +1,6 @@ import AppKit import ClawdbotChatUI +import ClawdbotDiscovery import ClawdbotIPC import SwiftUI diff --git a/apps/macos/Sources/Clawdbot/OnboardingView+Testing.swift b/apps/macos/Sources/Clawdbot/OnboardingView+Testing.swift index 64d22ffef..f7a5920e4 100644 --- a/apps/macos/Sources/Clawdbot/OnboardingView+Testing.swift +++ b/apps/macos/Sources/Clawdbot/OnboardingView+Testing.swift @@ -1,3 +1,4 @@ +import ClawdbotDiscovery import SwiftUI #if DEBUG @@ -5,7 +6,7 @@ import SwiftUI extension OnboardingView { static func exerciseForTesting() { let state = AppState(preview: true) - let discovery = GatewayDiscoveryModel() + let discovery = GatewayDiscoveryModel(localDisplayName: InstanceIdentity.displayName) discovery.statusText = "Searching..." let gateway = GatewayDiscoveryModel.DiscoveredGateway( displayName: "Test Bridge", diff --git a/apps/macos/Sources/Clawdbot/BridgeEndpointID.swift b/apps/macos/Sources/ClawdbotDiscovery/BridgeEndpointID.swift similarity index 81% rename from apps/macos/Sources/Clawdbot/BridgeEndpointID.swift rename to apps/macos/Sources/ClawdbotDiscovery/BridgeEndpointID.swift index 43a5705d7..c89348122 100644 --- a/apps/macos/Sources/Clawdbot/BridgeEndpointID.swift +++ b/apps/macos/Sources/ClawdbotDiscovery/BridgeEndpointID.swift @@ -2,8 +2,8 @@ import ClawdbotKit import Foundation import Network -enum BridgeEndpointID { - static func stableID(_ endpoint: NWEndpoint) -> String { +public enum BridgeEndpointID { + public static func stableID(_ endpoint: NWEndpoint) -> String { switch endpoint { case let .service(name, type, domain, _): // Keep stable across encoded/decoded differences (e.g. \032 for spaces). @@ -14,7 +14,7 @@ enum BridgeEndpointID { } } - static func prettyDescription(_ endpoint: NWEndpoint) -> String { + public static func prettyDescription(_ endpoint: NWEndpoint) -> String { BonjourEscapes.decode(String(describing: endpoint)) } diff --git a/apps/macos/Sources/Clawdbot/GatewayDiscoveryModel.swift b/apps/macos/Sources/ClawdbotDiscovery/GatewayDiscoveryModel.swift similarity index 73% rename from apps/macos/Sources/Clawdbot/GatewayDiscoveryModel.swift rename to apps/macos/Sources/ClawdbotDiscovery/GatewayDiscoveryModel.swift index 384f9ca4f..72a1a2517 100644 --- a/apps/macos/Sources/Clawdbot/GatewayDiscoveryModel.swift +++ b/apps/macos/Sources/ClawdbotDiscovery/GatewayDiscoveryModel.swift @@ -6,43 +6,79 @@ import OSLog @MainActor @Observable -final class GatewayDiscoveryModel { - struct LocalIdentity: Equatable { - var hostTokens: Set - var displayTokens: Set +public final class GatewayDiscoveryModel { + public struct LocalIdentity: Equatable, Sendable { + public var hostTokens: Set + public var displayTokens: Set + + public init(hostTokens: Set, displayTokens: Set) { + self.hostTokens = hostTokens + self.displayTokens = displayTokens + } } - struct DiscoveredGateway: Identifiable, Equatable { - var id: String { self.stableID } - var displayName: String - var lanHost: String? - var tailnetDns: String? - var sshPort: Int - var gatewayPort: Int? - var cliPath: String? - var stableID: String - var debugID: String - var isLocal: Bool + public struct DiscoveredGateway: Identifiable, Equatable, Sendable { + public var id: String { self.stableID } + public var displayName: String + public var lanHost: String? + public var tailnetDns: String? + public var sshPort: Int + public var gatewayPort: Int? + public var cliPath: String? + public var stableID: String + public var debugID: String + public var isLocal: Bool + + public init( + displayName: String, + lanHost: String? = nil, + tailnetDns: String? = nil, + sshPort: Int, + gatewayPort: Int? = nil, + cliPath: String? = nil, + stableID: String, + debugID: String, + isLocal: Bool) + { + self.displayName = displayName + self.lanHost = lanHost + self.tailnetDns = tailnetDns + self.sshPort = sshPort + self.gatewayPort = gatewayPort + self.cliPath = cliPath + self.stableID = stableID + self.debugID = debugID + self.isLocal = isLocal + } } - var gateways: [DiscoveredGateway] = [] - var statusText: String = "Idle" + public var gateways: [DiscoveredGateway] = [] + public var statusText: String = "Idle" private var browsers: [String: NWBrowser] = [:] private var resultsByDomain: [String: Set] = [:] private var gatewaysByDomain: [String: [DiscoveredGateway]] = [:] private var statesByDomain: [String: NWBrowser.State] = [:] private var localIdentity: LocalIdentity + private let localDisplayName: String? + private let filterLocalGateways: Bool private var resolvedTXTByID: [String: [String: String]] = [:] private var pendingTXTResolvers: [String: GatewayTXTResolver] = [:] + private var wideAreaFallbackTask: Task? + private var wideAreaFallbackGateways: [DiscoveredGateway] = [] private let logger = Logger(subsystem: "com.clawdbot", category: "gateway-discovery") - init() { - self.localIdentity = Self.buildLocalIdentityFast() + public init( + localDisplayName: String? = nil, + filterLocalGateways: Bool = true) + { + self.localDisplayName = localDisplayName + self.filterLocalGateways = filterLocalGateways + self.localIdentity = Self.buildLocalIdentityFast(displayName: localDisplayName) self.refreshLocalIdentity() } - func start() { + public func start() { if !self.browsers.isEmpty { return } for domain in ClawdbotBonjour.bridgeServiceDomains { @@ -72,9 +108,11 @@ final class GatewayDiscoveryModel { self.browsers[domain] = browser browser.start(queue: DispatchQueue(label: "com.clawdbot.macos.gateway-discovery.\(domain)")) } + + self.scheduleWideAreaFallback() } - func stop() { + public func stop() { for browser in self.browsers.values { browser.cancel() } @@ -85,15 +123,34 @@ final class GatewayDiscoveryModel { self.resolvedTXTByID = [:] self.pendingTXTResolvers.values.forEach { $0.cancel() } self.pendingTXTResolvers = [:] + self.wideAreaFallbackTask?.cancel() + self.wideAreaFallbackTask = nil + self.wideAreaFallbackGateways = [] self.gateways = [] self.statusText = "Stopped" } private func recomputeGateways() { - self.gateways = self.gatewaysByDomain.values + var next = self.gatewaysByDomain.values .flatMap(\.self) - .filter { !$0.isLocal } .sorted { $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending } + if self.gatewaysByDomain[ClawdbotBonjour.wideAreaBridgeServiceDomain]?.isEmpty ?? true, + !self.wideAreaFallbackGateways.isEmpty + { + next.append(contentsOf: self.wideAreaFallbackGateways) + } + var seen = Set() + let deduped = next.filter { gateway in + if seen.contains(gateway.stableID) { return false } + seen.insert(gateway.stableID) + return true + } + let sorted = deduped.sorted { + $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending + } + self.gateways = self.filterLocalGateways + ? sorted.filter { !$0.isLocal } + : sorted } private func updateGateways(for domain: String) { @@ -146,6 +203,52 @@ final class GatewayDiscoveryModel { isLocal: isLocal) } .sorted { $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending } + + if domain == ClawdbotBonjour.wideAreaBridgeServiceDomain, + !(self.gatewaysByDomain[domain]?.isEmpty ?? true) + { + self.wideAreaFallbackGateways = [] + } + } + + private func scheduleWideAreaFallback() { + let domain = ClawdbotBonjour.wideAreaBridgeServiceDomain + guard self.wideAreaFallbackTask == nil else { return } + self.wideAreaFallbackTask = Task.detached(priority: .utility) { [weak self] in + guard let self else { return } + if Task.isCancelled { return } + let hasResults = await MainActor.run { + !(self.gatewaysByDomain[domain]?.isEmpty ?? true) + } + if hasResults { return } + + let beacons = WideAreaGatewayDiscovery.discover(timeoutSeconds: 3.0) + if beacons.isEmpty { return } + + await MainActor.run { [weak self] in + guard let self else { return } + self.wideAreaFallbackGateways = beacons.map { beacon in + let stableID = "wide-area|\(domain)|\(beacon.instanceName)" + let isLocal = Self.isLocalGateway( + lanHost: beacon.lanHost, + tailnetDns: beacon.tailnetDns, + displayName: beacon.displayName, + serviceName: beacon.instanceName, + local: self.localIdentity) + return DiscoveredGateway( + displayName: beacon.displayName, + lanHost: beacon.lanHost, + tailnetDns: beacon.tailnetDns, + sshPort: beacon.sshPort ?? 22, + gatewayPort: beacon.gatewayPort, + cliPath: beacon.cliPath, + stableID: stableID, + debugID: "\(beacon.instanceName)@\(beacon.host):\(beacon.port)", + isLocal: isLocal) + } + self.recomputeGateways() + } + } } private func updateGatewaysForAllDomains() { @@ -208,15 +311,15 @@ final class GatewayDiscoveryModel { return merged } - struct GatewayTXT: Equatable { - var lanHost: String? - var tailnetDns: String? - var sshPort: Int - var gatewayPort: Int? - var cliPath: String? + public struct GatewayTXT: Equatable { + public var lanHost: String? + public var tailnetDns: String? + public var sshPort: Int + public var gatewayPort: Int? + public var cliPath: String? } - static func parseGatewayTXT(_ txt: [String: String]) -> GatewayTXT { + public static func parseGatewayTXT(_ txt: [String: String]) -> GatewayTXT { var lanHost: String? var tailnetDns: String? var sshPort = 22 @@ -256,7 +359,7 @@ final class GatewayDiscoveryModel { cliPath: cliPath) } - static func buildSSHTarget(user: String, host: String, port: Int) -> String { + public static func buildSSHTarget(user: String, host: String, port: Int) -> String { var target = "\(user)@\(host)" if port != 22 { target += ":\(port)" @@ -324,7 +427,7 @@ final class GatewayDiscoveryModel { return titled.isEmpty ? normalized : titled } - nonisolated static func isLocalGateway( + public nonisolated static func isLocalGateway( lanHost: String?, tailnetDns: String?, displayName: String?, @@ -356,8 +459,9 @@ final class GatewayDiscoveryModel { private func refreshLocalIdentity() { let fastIdentity = self.localIdentity + let displayName = self.localDisplayName Task.detached(priority: .utility) { - let slowIdentity = Self.buildLocalIdentitySlow() + let slowIdentity = Self.buildLocalIdentitySlow(displayName: displayName) let merged = Self.mergeLocalIdentity(fast: fastIdentity, slow: slowIdentity) await MainActor.run { [weak self] in guard let self else { return } @@ -377,7 +481,7 @@ final class GatewayDiscoveryModel { displayTokens: fast.displayTokens.union(slow.displayTokens)) } - private nonisolated static func buildLocalIdentityFast() -> LocalIdentity { + private nonisolated static func buildLocalIdentityFast(displayName: String?) -> LocalIdentity { var hostTokens: Set = [] var displayTokens: Set = [] @@ -386,14 +490,14 @@ final class GatewayDiscoveryModel { hostTokens.insert(token) } - if let token = normalizeDisplayToken(InstanceIdentity.displayName) { + if let token = normalizeDisplayToken(displayName) { displayTokens.insert(token) } return LocalIdentity(hostTokens: hostTokens, displayTokens: displayTokens) } - private nonisolated static func buildLocalIdentitySlow() -> LocalIdentity { + private nonisolated static func buildLocalIdentitySlow(displayName: String?) -> LocalIdentity { var hostTokens: Set = [] var displayTokens: Set = [] @@ -403,6 +507,10 @@ final class GatewayDiscoveryModel { hostTokens.insert(token) } + if let token = normalizeDisplayToken(displayName) { + displayTokens.insert(token) + } + if let token = normalizeDisplayToken(Host.current().localizedName) { displayTokens.insert(token) } diff --git a/apps/macos/Sources/ClawdbotDiscovery/WideAreaGatewayDiscovery.swift b/apps/macos/Sources/ClawdbotDiscovery/WideAreaGatewayDiscovery.swift new file mode 100644 index 000000000..f541070b9 --- /dev/null +++ b/apps/macos/Sources/ClawdbotDiscovery/WideAreaGatewayDiscovery.swift @@ -0,0 +1,335 @@ +import ClawdbotKit +import Foundation + +struct WideAreaGatewayBeacon: Sendable, Equatable { + var instanceName: String + var displayName: String + var host: String + var port: Int + var lanHost: String? + var tailnetDns: String? + var gatewayPort: Int? + var bridgePort: Int? + var sshPort: Int? + var cliPath: String? +} + +enum WideAreaGatewayDiscovery { + private static let maxCandidates = 40 + private static let digPath = "/usr/bin/dig" + private static let defaultTimeoutSeconds: TimeInterval = 0.2 + + struct DiscoveryContext: Sendable { + var tailscaleStatus: @Sendable () -> String? + var dig: @Sendable (_ args: [String], _ timeout: TimeInterval) -> String? + + static let live = DiscoveryContext( + tailscaleStatus: { readTailscaleStatus() }, + dig: { args, timeout in + runDig(args: args, timeout: timeout) + }) + } + + static func discover( + timeoutSeconds: TimeInterval = 2.0, + context: DiscoveryContext = .live) -> [WideAreaGatewayBeacon] + { + let startedAt = Date() + let remaining = { + timeoutSeconds - Date().timeIntervalSince(startedAt) + } + + guard let ips = collectTailnetIPv4s( + statusJson: context.tailscaleStatus()).nonEmpty else { return [] } + var candidates = Array(ips.prefix(self.maxCandidates)) + guard let nameserver = findNameserver( + candidates: &candidates, + remaining: remaining, + dig: context.dig) + else { + return [] + } + + let domain = ClawdbotBonjour.wideAreaBridgeServiceDomain + let domainTrimmed = domain.trimmingCharacters(in: CharacterSet(charactersIn: ".")) + let probeName = "_clawdbot-bridge._tcp.\(domainTrimmed)" + guard let ptrLines = context.dig( + ["+short", "+time=1", "+tries=1", "@\(nameserver)", probeName, "PTR"], + min(defaultTimeoutSeconds, remaining()))?.split(whereSeparator: \.isNewline), + !ptrLines.isEmpty + else { + return [] + } + + var beacons: [WideAreaGatewayBeacon] = [] + for raw in ptrLines { + let ptr = raw.trimmingCharacters(in: .whitespacesAndNewlines) + if ptr.isEmpty { continue } + let ptrName = ptr.hasSuffix(".") ? String(ptr.dropLast()) : ptr + let suffix = "._clawdbot-bridge._tcp.\(domainTrimmed)" + let rawInstanceName = ptrName.hasSuffix(suffix) + ? String(ptrName.dropLast(suffix.count)) + : ptrName + let instanceName = self.decodeDnsSdEscapes(rawInstanceName) + + guard let srv = context.dig( + ["+short", "+time=1", "+tries=1", "@\(nameserver)", ptrName, "SRV"], + min(defaultTimeoutSeconds, remaining())) + else { continue } + guard let (host, port) = parseSrv(srv) else { continue } + + let txtRaw = context.dig( + ["+short", "+time=1", "+tries=1", "@\(nameserver)", ptrName, "TXT"], + min(self.defaultTimeoutSeconds, remaining())) + let txtTokens = txtRaw.map(self.parseTxtTokens) ?? [] + let txt = self.mapTxt(tokens: txtTokens) + + let displayName = txt["displayName"] ?? instanceName + let beacon = WideAreaGatewayBeacon( + instanceName: instanceName, + displayName: displayName, + host: host, + port: port, + lanHost: txt["lanHost"], + tailnetDns: txt["tailnetDns"], + gatewayPort: parseInt(txt["gatewayPort"]), + bridgePort: parseInt(txt["bridgePort"]), + sshPort: parseInt(txt["sshPort"]), + cliPath: txt["cliPath"]) + beacons.append(beacon) + } + + return beacons + } + + private static func collectTailnetIPv4s(statusJson: String?) -> [String] { + guard let statusJson else { return [] } + let decoder = JSONDecoder() + guard let data = statusJson.data(using: .utf8), + let status = try? decoder.decode(TailscaleStatus.self, from: data) + else { return [] } + + var ips: [String] = [] + ips.append(contentsOf: status.selfNode?.resolvedIPs ?? []) + if let peers = status.peer { + for peer in peers.values { + ips.append(contentsOf: peer.resolvedIPs) + } + } + + var seen = Set() + let ordered = ips.filter { value in + guard self.isTailnetIPv4(value) else { return false } + if seen.contains(value) { return false } + seen.insert(value) + return true + } + return ordered + } + + private static func readTailscaleStatus() -> String? { + let candidates = [ + "/usr/local/bin/tailscale", + "/opt/homebrew/bin/tailscale", + "/Applications/Tailscale.app/Contents/MacOS/Tailscale", + "tailscale", + ] + + var output: String? + for candidate in candidates { + if let result = run( + path: candidate, + args: ["status", "--json"], + timeout: 0.7) + { + output = result + break + } + } + + return output + } + + private static func findNameserver( + candidates: inout [String], + remaining: () -> TimeInterval, + dig: @Sendable (_ args: [String], _ timeout: TimeInterval) -> String?) -> String? + { + let domain = ClawdbotBonjour.wideAreaBridgeServiceDomain + let domainTrimmed = domain.trimmingCharacters(in: CharacterSet(charactersIn: ".")) + let probeName = "_clawdbot-bridge._tcp.\(domainTrimmed)" + + while !candidates.isEmpty { + if remaining() <= 0 { break } + let ip = candidates.removeFirst() + if let stdout = dig( + ["+short", "+time=1", "+tries=1", "@\(ip)", probeName, "PTR"], + min(defaultTimeoutSeconds, remaining())), + stdout.split(whereSeparator: \.isNewline).isEmpty == false + { + return ip + } + } + + return nil + } + + private static func runDig(args: [String], timeout: TimeInterval) -> String? { + self.run(path: self.digPath, args: args, timeout: timeout) + } + + private static func run(path: String, args: [String], timeout: TimeInterval) -> String? { + let process = Process() + process.executableURL = URL(fileURLWithPath: path) + process.arguments = args + let outPipe = Pipe() + let errPipe = Pipe() + process.standardOutput = outPipe + process.standardError = errPipe + + do { + try process.run() + } catch { + return nil + } + + let deadline = Date().addingTimeInterval(timeout) + while process.isRunning, Date() < deadline { + Thread.sleep(forTimeInterval: 0.02) + } + if process.isRunning { + process.terminate() + } + process.waitUntilExit() + + let data = outPipe.fileHandleForReading.readDataToEndOfFile() + let output = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) + return output?.isEmpty == false ? output : nil + } + + private static func parseSrv(_ stdout: String) -> (String, Int)? { + let line = stdout + .split(whereSeparator: \.isNewline) + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .first(where: { !$0.isEmpty }) + guard let line else { return nil } + let parts = line.split(whereSeparator: { $0 == " " || $0 == "\t" }).map(String.init) + guard parts.count >= 4 else { return nil } + guard let port = Int(parts[2]), port > 0 else { return nil } + let host = parts[3].hasSuffix(".") ? String(parts[3].dropLast()) : parts[3] + return (host, port) + } + + private static func parseTxtTokens(_ stdout: String) -> [String] { + let lines = stdout.split(whereSeparator: \.isNewline) + var tokens: [String] = [] + for raw in lines { + let line = raw.trimmingCharacters(in: .whitespacesAndNewlines) + if line.isEmpty { continue } + let matches = line.matches(of: /"([^"]*)"/) + for match in matches { + tokens.append(self.unescapeTxt(String(match.1))) + } + } + return tokens + } + + private static func unescapeTxt(_ value: String) -> String { + value + .replacingOccurrences(of: "\\\\", with: "\\") + .replacingOccurrences(of: "\\\"", with: "\"") + .replacingOccurrences(of: "\\n", with: "\n") + } + + private static func mapTxt(tokens: [String]) -> [String: String] { + var out: [String: String] = [:] + for token in tokens { + guard let idx = token.firstIndex(of: "=") else { continue } + let key = String(token[.. Int? { + guard let value else { return nil } + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + return Int(trimmed) + } + + private static func isTailnetIPv4(_ value: String) -> Bool { + let parts = value.split(separator: ".") + if parts.count != 4 { return false } + let octets = parts.compactMap { Int($0) } + if octets.count != 4 { return false } + let a = octets[0] + let b = octets[1] + return a == 100 && b >= 64 && b <= 127 + } + + private static func decodeDnsSdEscapes(_ value: String) -> String { + var bytes: [UInt8] = [] + var pending = "" + + func flushPending() { + guard !pending.isEmpty else { return } + bytes.append(contentsOf: pending.utf8) + pending = "" + } + + let chars = Array(value) + var i = 0 + while i < chars.count { + let ch = chars[i] + if ch == "\\", i + 3 < chars.count { + let digits = String(chars[(i + 1)...(i + 3)]) + if digits.allSatisfy(\.isNumber), + let byte = UInt8(digits) + { + flushPending() + bytes.append(byte) + i += 4 + continue + } + } + pending.append(ch) + i += 1 + } + flushPending() + + if bytes.isEmpty { return value } + if let decoded = String(bytes: bytes, encoding: .utf8) { + return decoded + } + return value + } +} + +private struct TailscaleStatus: Decodable { + struct Node: Decodable { + let tailscaleIPs: [String]? + + var resolvedIPs: [String] { + self.tailscaleIPs ?? [] + } + + private enum CodingKeys: String, CodingKey { + case tailscaleIPs = "TailscaleIPs" + } + } + + let selfNode: Node? + let peer: [String: Node]? + + private enum CodingKeys: String, CodingKey { + case selfNode = "Self" + case peer = "Peer" + } +} + +extension Collection { + fileprivate var nonEmpty: Self? { isEmpty ? nil : self } +} diff --git a/apps/macos/Sources/ClawdbotDiscoveryCLI/main.swift b/apps/macos/Sources/ClawdbotDiscoveryCLI/main.swift new file mode 100644 index 000000000..d5fc5789c --- /dev/null +++ b/apps/macos/Sources/ClawdbotDiscoveryCLI/main.swift @@ -0,0 +1,150 @@ +import ClawdbotDiscovery +import Foundation + +struct DiscoveryOptions { + var timeoutMs: Int = 2000 + var json: Bool = false + var includeLocal: Bool = false + var help: Bool = false + + static func parse(_ args: [String]) -> DiscoveryOptions { + var opts = DiscoveryOptions() + var i = 0 + while i < args.count { + let arg = args[i] + switch arg { + case "-h", "--help": + opts.help = true + case "--json": + opts.json = true + case "--include-local": + opts.includeLocal = true + case "--timeout": + let next = (i + 1 < args.count) ? args[i + 1] : nil + if let next, let parsed = Int(next.trimmingCharacters(in: .whitespacesAndNewlines)) { + opts.timeoutMs = max(100, parsed) + i += 1 + } + default: + break + } + i += 1 + } + return opts + } +} + +struct DiscoveryOutput: Encodable { + struct Gateway: Encodable { + var displayName: String + var lanHost: String? + var tailnetDns: String? + var sshPort: Int + var gatewayPort: Int? + var cliPath: String? + var stableID: String + var debugID: String + var isLocal: Bool + } + + var status: String + var timeoutMs: Int + var includeLocal: Bool + var count: Int + var gateways: [Gateway] +} + +@main +struct ClawdbotDiscoveryCLI { + static func main() async { + let opts = DiscoveryOptions.parse(Array(CommandLine.arguments.dropFirst())) + if opts.help { + print(""" + clawdbot-mac-discovery + + Usage: + clawdbot-mac-discovery [--timeout ] [--json] [--include-local] + + Options: + --timeout Discovery window in milliseconds (default: 2000) + --json Emit JSON + --include-local Include gateways considered local + -h, --help Show help + """) + return + } + + let displayName = Host.current().localizedName ?? ProcessInfo.processInfo.hostName + let model = GatewayDiscoveryModel( + localDisplayName: displayName, + filterLocalGateways: !opts.includeLocal) + + await MainActor.run { + model.start() + } + + let nanos = UInt64(max(100, opts.timeoutMs)) * 1_000_000 + try? await Task.sleep(nanoseconds: nanos) + + let gateways = await MainActor.run { model.gateways } + let status = await MainActor.run { model.statusText } + + await MainActor.run { + model.stop() + } + + if opts.json { + let payload = DiscoveryOutput( + status: status, + timeoutMs: opts.timeoutMs, + includeLocal: opts.includeLocal, + count: gateways.count, + gateways: gateways.map { + DiscoveryOutput.Gateway( + displayName: $0.displayName, + lanHost: $0.lanHost, + tailnetDns: $0.tailnetDns, + sshPort: $0.sshPort, + gatewayPort: $0.gatewayPort, + cliPath: $0.cliPath, + stableID: $0.stableID, + debugID: $0.debugID, + isLocal: $0.isLocal) + }) + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + if let data = try? encoder.encode(payload), + let json = String(data: data, encoding: .utf8) + { + print(json) + } else { + print("{\"error\":\"failed to encode JSON\"}") + } + return + } + + print("Gateway Discovery (macOS NWBrowser)") + print("Status: \(status)") + print("Found \(gateways.count) gateway(s)\(opts.includeLocal ? "" : " (local filtered)")") + if gateways.isEmpty { return } + + for gateway in gateways { + let hosts = [gateway.tailnetDns, gateway.lanHost] + .compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + .joined(separator: ", ") + print("- \(gateway.displayName)") + print(" hosts: \(hosts.isEmpty ? "(none)" : hosts)") + print(" ssh: \(gateway.sshPort)") + if let port = gateway.gatewayPort { + print(" gatewayPort: \(port)") + } + if let cliPath = gateway.cliPath { + print(" cliPath: \(cliPath)") + } + print(" isLocal: \(gateway.isLocal)") + print(" stableID: \(gateway.stableID)") + print(" debugID: \(gateway.debugID)") + } + } +} diff --git a/apps/macos/Tests/ClawdbotIPCTests/GatewayDiscoveryModelTests.swift b/apps/macos/Tests/ClawdbotIPCTests/GatewayDiscoveryModelTests.swift index 9dbef7601..c585db438 100644 --- a/apps/macos/Tests/ClawdbotIPCTests/GatewayDiscoveryModelTests.swift +++ b/apps/macos/Tests/ClawdbotIPCTests/GatewayDiscoveryModelTests.swift @@ -1,5 +1,5 @@ +import ClawdbotDiscovery import Testing -@testable import Clawdbot @Suite @MainActor diff --git a/apps/macos/Tests/ClawdbotIPCTests/MasterDiscoveryMenuSmokeTests.swift b/apps/macos/Tests/ClawdbotIPCTests/MasterDiscoveryMenuSmokeTests.swift index f8a36fdde..10630c202 100644 --- a/apps/macos/Tests/ClawdbotIPCTests/MasterDiscoveryMenuSmokeTests.swift +++ b/apps/macos/Tests/ClawdbotIPCTests/MasterDiscoveryMenuSmokeTests.swift @@ -1,3 +1,4 @@ +import ClawdbotDiscovery import SwiftUI import Testing @testable import Clawdbot @@ -6,7 +7,7 @@ import Testing @MainActor struct MasterDiscoveryMenuSmokeTests { @Test func inlineListBuildsBodyWhenEmpty() { - let discovery = GatewayDiscoveryModel() + let discovery = GatewayDiscoveryModel(localDisplayName: InstanceIdentity.displayName) discovery.statusText = "Searching…" discovery.gateways = [] @@ -15,7 +16,7 @@ struct MasterDiscoveryMenuSmokeTests { } @Test func inlineListBuildsBodyWithMasterAndSelection() { - let discovery = GatewayDiscoveryModel() + let discovery = GatewayDiscoveryModel(localDisplayName: InstanceIdentity.displayName) discovery.statusText = "Found 1" discovery.gateways = [ GatewayDiscoveryModel.DiscoveredGateway( @@ -23,6 +24,8 @@ struct MasterDiscoveryMenuSmokeTests { lanHost: "office.local", tailnetDns: "office.tailnet-123.ts.net", sshPort: 2222, + gatewayPort: nil, + cliPath: nil, stableID: "office", debugID: "office", isLocal: false), @@ -34,7 +37,7 @@ struct MasterDiscoveryMenuSmokeTests { } @Test func menuBuildsBodyWithMasters() { - let discovery = GatewayDiscoveryModel() + let discovery = GatewayDiscoveryModel(localDisplayName: InstanceIdentity.displayName) discovery.statusText = "Found 2" discovery.gateways = [ GatewayDiscoveryModel.DiscoveredGateway( @@ -42,6 +45,8 @@ struct MasterDiscoveryMenuSmokeTests { lanHost: "a.local", tailnetDns: nil, sshPort: 22, + gatewayPort: nil, + cliPath: nil, stableID: "a", debugID: "a", isLocal: false), @@ -50,6 +55,8 @@ struct MasterDiscoveryMenuSmokeTests { lanHost: nil, tailnetDns: "b.ts.net", sshPort: 22, + gatewayPort: nil, + cliPath: nil, stableID: "b", debugID: "b", isLocal: false), diff --git a/apps/macos/Tests/ClawdbotIPCTests/OnboardingViewSmokeTests.swift b/apps/macos/Tests/ClawdbotIPCTests/OnboardingViewSmokeTests.swift index ed2ff775e..37bc17c6f 100644 --- a/apps/macos/Tests/ClawdbotIPCTests/OnboardingViewSmokeTests.swift +++ b/apps/macos/Tests/ClawdbotIPCTests/OnboardingViewSmokeTests.swift @@ -1,3 +1,4 @@ +import ClawdbotDiscovery import SwiftUI import Testing @testable import Clawdbot @@ -10,7 +11,7 @@ struct OnboardingViewSmokeTests { let view = OnboardingView( state: state, permissionMonitor: PermissionMonitor.shared, - discoveryModel: GatewayDiscoveryModel()) + discoveryModel: GatewayDiscoveryModel(localDisplayName: InstanceIdentity.displayName)) _ = view.body } diff --git a/apps/macos/Tests/ClawdbotIPCTests/WideAreaGatewayDiscoveryTests.swift b/apps/macos/Tests/ClawdbotIPCTests/WideAreaGatewayDiscoveryTests.swift new file mode 100644 index 000000000..4f081538b --- /dev/null +++ b/apps/macos/Tests/ClawdbotIPCTests/WideAreaGatewayDiscoveryTests.swift @@ -0,0 +1,49 @@ +import Testing +@testable import ClawdbotDiscovery + +@Suite +struct WideAreaGatewayDiscoveryTests { + @Test func discoversBeaconFromTailnetDnsSdFallback() { + let statusJson = """ + { + "Self": { "TailscaleIPs": ["100.69.232.64"] }, + "Peer": { + "peer-1": { "TailscaleIPs": ["100.123.224.76"] } + } + } + """ + + let context = WideAreaGatewayDiscovery.DiscoveryContext( + tailscaleStatus: { statusJson }, + dig: { args, _ in + let recordType = args.last ?? "" + let nameserver = args.first(where: { $0.hasPrefix("@") }) ?? "" + if recordType == "PTR" { + if nameserver == "@100.123.224.76" { + return "steipetacstudio-bridge._clawdbot-bridge._tcp.clawdbot.internal.\n" + } + return "" + } + if recordType == "SRV" { + return "0 0 18790 steipetacstudio.clawdbot.internal." + } + if recordType == "TXT" { + return "\"displayName=Peter\\226\\128\\153s Mac Studio (Clawdbot)\" \"transport=bridge\" \"bridgePort=18790\" \"gatewayPort=18789\" \"tailnetDns=peters-mac-studio-1.sheep-coho.ts.net\" \"cliPath=/Users/steipete/clawdbot/src/entry.ts\"" + } + return "" + }) + + let beacons = WideAreaGatewayDiscovery.discover( + timeoutSeconds: 2.0, + context: context) + + #expect(beacons.count == 1) + let beacon = beacons[0] + let expectedDisplay = "Peter\u{2019}s Mac Studio (Clawdbot)" + #expect(beacon.displayName == expectedDisplay) + #expect(beacon.bridgePort == 18790) + #expect(beacon.gatewayPort == 18789) + #expect(beacon.tailnetDns == "peters-mac-studio-1.sheep-coho.ts.net") + #expect(beacon.cliPath == "/Users/steipete/clawdbot/src/entry.ts") + } +} diff --git a/docs/platforms/macos.md b/docs/platforms/macos.md index e6b3ce646..1575c3f49 100644 --- a/docs/platforms/macos.md +++ b/docs/platforms/macos.md @@ -90,6 +90,25 @@ Safety: - Package app + CLI: `scripts/package-mac-app.sh` - Switch bundled gateway runtime with `BUNDLED_RUNTIME=node|bun` (default: node). +## Debug gateway discovery (macOS CLI) + +Use the debug CLI to exercise the same Bonjour + wide‑area discovery code that the +macOS app uses, without launching the app. + +```bash +cd apps/macos +swift run clawdbot-mac-discovery --timeout 3000 --json +``` + +Options: +- `--include-local`: include gateways that would be filtered as “local” +- `--timeout `: overall discovery window (default `2000`) +- `--json`: structured output for diffing + +Tip: compare against `pnpm clawdbot gateway discover --json` to see whether the +macOS app’s discovery pipeline (NWBrowser + tailnet DNS‑SD fallback) differs from +the Node CLI’s `dns-sd` based discovery. + ## Related docs - [Gateway runbook](/gateway) diff --git a/src/cli/update-cli.test.ts b/src/cli/update-cli.test.ts index bde1dd6bf..9ac0901e6 100644 --- a/src/cli/update-cli.test.ts +++ b/src/cli/update-cli.test.ts @@ -7,6 +7,11 @@ vi.mock("../infra/update-runner.js", () => ({ runGatewayUpdate: vi.fn(), })); +// Mock the doctor command to avoid loading heavy dependencies +vi.mock("../commands/doctor.js", () => ({ + doctorCommand: vi.fn(), +})); + // Mock the daemon-cli module vi.mock("./daemon-cli.js", () => ({ runDaemonRestart: vi.fn(),