fix(mac): add tailnet discovery fallback and debug CLI
This commit is contained in:
@@ -10,7 +10,9 @@ let package = Package(
|
|||||||
],
|
],
|
||||||
products: [
|
products: [
|
||||||
.library(name: "ClawdbotIPC", targets: ["ClawdbotIPC"]),
|
.library(name: "ClawdbotIPC", targets: ["ClawdbotIPC"]),
|
||||||
|
.library(name: "ClawdbotDiscovery", targets: ["ClawdbotDiscovery"]),
|
||||||
.executable(name: "Clawdbot", targets: ["Clawdbot"]),
|
.executable(name: "Clawdbot", targets: ["Clawdbot"]),
|
||||||
|
.executable(name: "clawdbot-mac-discovery", targets: ["ClawdbotDiscoveryCLI"]),
|
||||||
],
|
],
|
||||||
dependencies: [
|
dependencies: [
|
||||||
.package(url: "https://github.com/orchetect/MenuBarExtraAccess", exact: "1.2.2"),
|
.package(url: "https://github.com/orchetect/MenuBarExtraAccess", exact: "1.2.2"),
|
||||||
@@ -36,10 +38,20 @@ let package = Package(
|
|||||||
swiftSettings: [
|
swiftSettings: [
|
||||||
.enableUpcomingFeature("StrictConcurrency"),
|
.enableUpcomingFeature("StrictConcurrency"),
|
||||||
]),
|
]),
|
||||||
|
.target(
|
||||||
|
name: "ClawdbotDiscovery",
|
||||||
|
dependencies: [
|
||||||
|
.product(name: "ClawdbotKit", package: "ClawdbotKit"),
|
||||||
|
],
|
||||||
|
path: "Sources/ClawdbotDiscovery",
|
||||||
|
swiftSettings: [
|
||||||
|
.enableUpcomingFeature("StrictConcurrency"),
|
||||||
|
]),
|
||||||
.executableTarget(
|
.executableTarget(
|
||||||
name: "Clawdbot",
|
name: "Clawdbot",
|
||||||
dependencies: [
|
dependencies: [
|
||||||
"ClawdbotIPC",
|
"ClawdbotIPC",
|
||||||
|
"ClawdbotDiscovery",
|
||||||
"ClawdbotProtocol",
|
"ClawdbotProtocol",
|
||||||
.product(name: "ClawdbotKit", package: "ClawdbotKit"),
|
.product(name: "ClawdbotKit", package: "ClawdbotKit"),
|
||||||
.product(name: "ClawdbotChatUI", package: "ClawdbotKit"),
|
.product(name: "ClawdbotChatUI", package: "ClawdbotKit"),
|
||||||
@@ -61,11 +73,21 @@ let package = Package(
|
|||||||
swiftSettings: [
|
swiftSettings: [
|
||||||
.enableUpcomingFeature("StrictConcurrency"),
|
.enableUpcomingFeature("StrictConcurrency"),
|
||||||
]),
|
]),
|
||||||
|
.executableTarget(
|
||||||
|
name: "ClawdbotDiscoveryCLI",
|
||||||
|
dependencies: [
|
||||||
|
"ClawdbotDiscovery",
|
||||||
|
],
|
||||||
|
path: "Sources/ClawdbotDiscoveryCLI",
|
||||||
|
swiftSettings: [
|
||||||
|
.enableUpcomingFeature("StrictConcurrency"),
|
||||||
|
]),
|
||||||
.testTarget(
|
.testTarget(
|
||||||
name: "ClawdbotIPCTests",
|
name: "ClawdbotIPCTests",
|
||||||
dependencies: [
|
dependencies: [
|
||||||
"ClawdbotIPC",
|
"ClawdbotIPC",
|
||||||
"Clawdbot",
|
"Clawdbot",
|
||||||
|
"ClawdbotDiscovery",
|
||||||
"ClawdbotProtocol",
|
"ClawdbotProtocol",
|
||||||
.product(name: "SwabbleKit", package: "swabble"),
|
.product(name: "SwabbleKit", package: "swabble"),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import ClawdbotDiscovery
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct GatewayDiscoveryInlineList: View {
|
struct GatewayDiscoveryInlineList: View {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import AppKit
|
import AppKit
|
||||||
|
import ClawdbotDiscovery
|
||||||
import ClawdbotIPC
|
import ClawdbotIPC
|
||||||
import ClawdbotKit
|
import ClawdbotKit
|
||||||
import CoreLocation
|
import CoreLocation
|
||||||
@@ -12,7 +13,8 @@ struct GeneralSettings: View {
|
|||||||
@AppStorage(locationPreciseKey) private var locationPreciseEnabled: Bool = true
|
@AppStorage(locationPreciseKey) private var locationPreciseEnabled: Bool = true
|
||||||
private let healthStore = HealthStore.shared
|
private let healthStore = HealthStore.shared
|
||||||
private let gatewayManager = GatewayProcessManager.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 isInstallingCLI = false
|
||||||
@State private var cliStatus: String?
|
@State private var cliStatus: String?
|
||||||
@State private var cliInstalled = false
|
@State private var cliInstalled = false
|
||||||
@@ -187,7 +189,8 @@ struct GeneralSettings: View {
|
|||||||
}
|
}
|
||||||
SettingsToggleRow(
|
SettingsToggleRow(
|
||||||
title: "Attach only",
|
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)
|
binding: self.$state.attachExistingGatewayOnly)
|
||||||
TailscaleIntegrationSection(
|
TailscaleIntegrationSection(
|
||||||
connectionMode: self.state.connectionMode,
|
connectionMode: self.state.connectionMode,
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import ClawdbotDiscovery
|
||||||
import ClawdbotKit
|
import ClawdbotKit
|
||||||
import Foundation
|
import Foundation
|
||||||
import Network
|
import Network
|
||||||
@@ -315,7 +316,9 @@ final class MacNodeModeCoordinator {
|
|||||||
let port = NWEndpoint.Port(rawValue: localPort)
|
let port = NWEndpoint.Port(rawValue: localPort)
|
||||||
{
|
{
|
||||||
self.logger.info(
|
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)
|
return .hostPort(host: "127.0.0.1", port: port)
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import AppKit
|
import AppKit
|
||||||
|
import ClawdbotDiscovery
|
||||||
import ClawdbotIPC
|
import ClawdbotIPC
|
||||||
import ClawdbotProtocol
|
import ClawdbotProtocol
|
||||||
import Foundation
|
import Foundation
|
||||||
@@ -533,7 +534,7 @@ final class NodePairingApprovalPrompter {
|
|||||||
return SSHTarget(host: host, port: port)
|
return SSHTarget(host: host, port: port)
|
||||||
}
|
}
|
||||||
|
|
||||||
let model = GatewayDiscoveryModel()
|
let model = GatewayDiscoveryModel(localDisplayName: InstanceIdentity.displayName)
|
||||||
model.start()
|
model.start()
|
||||||
defer { model.stop() }
|
defer { model.stop() }
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import AppKit
|
import AppKit
|
||||||
import ClawdbotChatUI
|
import ClawdbotChatUI
|
||||||
|
import ClawdbotDiscovery
|
||||||
import ClawdbotIPC
|
import ClawdbotIPC
|
||||||
import Combine
|
import Combine
|
||||||
import Observation
|
import Observation
|
||||||
@@ -156,7 +157,8 @@ struct OnboardingView: View {
|
|||||||
init(
|
init(
|
||||||
state: AppState = AppStateStore.shared,
|
state: AppState = AppStateStore.shared,
|
||||||
permissionMonitor: PermissionMonitor = .shared,
|
permissionMonitor: PermissionMonitor = .shared,
|
||||||
discoveryModel: GatewayDiscoveryModel = GatewayDiscoveryModel())
|
discoveryModel: GatewayDiscoveryModel = GatewayDiscoveryModel(
|
||||||
|
localDisplayName: InstanceIdentity.displayName))
|
||||||
{
|
{
|
||||||
self.state = state
|
self.state = state
|
||||||
self.permissionMonitor = permissionMonitor
|
self.permissionMonitor = permissionMonitor
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import AppKit
|
import AppKit
|
||||||
|
import ClawdbotDiscovery
|
||||||
import ClawdbotIPC
|
import ClawdbotIPC
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import AppKit
|
import AppKit
|
||||||
import ClawdbotChatUI
|
import ClawdbotChatUI
|
||||||
|
import ClawdbotDiscovery
|
||||||
import ClawdbotIPC
|
import ClawdbotIPC
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import ClawdbotDiscovery
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
@@ -5,7 +6,7 @@ import SwiftUI
|
|||||||
extension OnboardingView {
|
extension OnboardingView {
|
||||||
static func exerciseForTesting() {
|
static func exerciseForTesting() {
|
||||||
let state = AppState(preview: true)
|
let state = AppState(preview: true)
|
||||||
let discovery = GatewayDiscoveryModel()
|
let discovery = GatewayDiscoveryModel(localDisplayName: InstanceIdentity.displayName)
|
||||||
discovery.statusText = "Searching..."
|
discovery.statusText = "Searching..."
|
||||||
let gateway = GatewayDiscoveryModel.DiscoveredGateway(
|
let gateway = GatewayDiscoveryModel.DiscoveredGateway(
|
||||||
displayName: "Test Bridge",
|
displayName: "Test Bridge",
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ import ClawdbotKit
|
|||||||
import Foundation
|
import Foundation
|
||||||
import Network
|
import Network
|
||||||
|
|
||||||
enum BridgeEndpointID {
|
public enum BridgeEndpointID {
|
||||||
static func stableID(_ endpoint: NWEndpoint) -> String {
|
public static func stableID(_ endpoint: NWEndpoint) -> String {
|
||||||
switch endpoint {
|
switch endpoint {
|
||||||
case let .service(name, type, domain, _):
|
case let .service(name, type, domain, _):
|
||||||
// Keep stable across encoded/decoded differences (e.g. \032 for spaces).
|
// 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))
|
BonjourEscapes.decode(String(describing: endpoint))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -6,43 +6,79 @@ import OSLog
|
|||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@Observable
|
@Observable
|
||||||
final class GatewayDiscoveryModel {
|
public final class GatewayDiscoveryModel {
|
||||||
struct LocalIdentity: Equatable {
|
public struct LocalIdentity: Equatable, Sendable {
|
||||||
var hostTokens: Set<String>
|
public var hostTokens: Set<String>
|
||||||
var displayTokens: Set<String>
|
public var displayTokens: Set<String>
|
||||||
|
|
||||||
|
public init(hostTokens: Set<String>, displayTokens: Set<String>) {
|
||||||
|
self.hostTokens = hostTokens
|
||||||
|
self.displayTokens = displayTokens
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct DiscoveredGateway: Identifiable, Equatable {
|
public struct DiscoveredGateway: Identifiable, Equatable, Sendable {
|
||||||
var id: String { self.stableID }
|
public var id: String { self.stableID }
|
||||||
var displayName: String
|
public var displayName: String
|
||||||
var lanHost: String?
|
public var lanHost: String?
|
||||||
var tailnetDns: String?
|
public var tailnetDns: String?
|
||||||
var sshPort: Int
|
public var sshPort: Int
|
||||||
var gatewayPort: Int?
|
public var gatewayPort: Int?
|
||||||
var cliPath: String?
|
public var cliPath: String?
|
||||||
var stableID: String
|
public var stableID: String
|
||||||
var debugID: String
|
public var debugID: String
|
||||||
var isLocal: Bool
|
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] = []
|
public var gateways: [DiscoveredGateway] = []
|
||||||
var statusText: String = "Idle"
|
public var statusText: String = "Idle"
|
||||||
|
|
||||||
private var browsers: [String: NWBrowser] = [:]
|
private var browsers: [String: NWBrowser] = [:]
|
||||||
private var resultsByDomain: [String: Set<NWBrowser.Result>] = [:]
|
private var resultsByDomain: [String: Set<NWBrowser.Result>] = [:]
|
||||||
private var gatewaysByDomain: [String: [DiscoveredGateway]] = [:]
|
private var gatewaysByDomain: [String: [DiscoveredGateway]] = [:]
|
||||||
private var statesByDomain: [String: NWBrowser.State] = [:]
|
private var statesByDomain: [String: NWBrowser.State] = [:]
|
||||||
private var localIdentity: LocalIdentity
|
private var localIdentity: LocalIdentity
|
||||||
|
private let localDisplayName: String?
|
||||||
|
private let filterLocalGateways: Bool
|
||||||
private var resolvedTXTByID: [String: [String: String]] = [:]
|
private var resolvedTXTByID: [String: [String: String]] = [:]
|
||||||
private var pendingTXTResolvers: [String: GatewayTXTResolver] = [:]
|
private var pendingTXTResolvers: [String: GatewayTXTResolver] = [:]
|
||||||
|
private var wideAreaFallbackTask: Task<Void, Never>?
|
||||||
|
private var wideAreaFallbackGateways: [DiscoveredGateway] = []
|
||||||
private let logger = Logger(subsystem: "com.clawdbot", category: "gateway-discovery")
|
private let logger = Logger(subsystem: "com.clawdbot", category: "gateway-discovery")
|
||||||
|
|
||||||
init() {
|
public init(
|
||||||
self.localIdentity = Self.buildLocalIdentityFast()
|
localDisplayName: String? = nil,
|
||||||
|
filterLocalGateways: Bool = true)
|
||||||
|
{
|
||||||
|
self.localDisplayName = localDisplayName
|
||||||
|
self.filterLocalGateways = filterLocalGateways
|
||||||
|
self.localIdentity = Self.buildLocalIdentityFast(displayName: localDisplayName)
|
||||||
self.refreshLocalIdentity()
|
self.refreshLocalIdentity()
|
||||||
}
|
}
|
||||||
|
|
||||||
func start() {
|
public func start() {
|
||||||
if !self.browsers.isEmpty { return }
|
if !self.browsers.isEmpty { return }
|
||||||
|
|
||||||
for domain in ClawdbotBonjour.bridgeServiceDomains {
|
for domain in ClawdbotBonjour.bridgeServiceDomains {
|
||||||
@@ -72,9 +108,11 @@ final class GatewayDiscoveryModel {
|
|||||||
self.browsers[domain] = browser
|
self.browsers[domain] = browser
|
||||||
browser.start(queue: DispatchQueue(label: "com.clawdbot.macos.gateway-discovery.\(domain)"))
|
browser.start(queue: DispatchQueue(label: "com.clawdbot.macos.gateway-discovery.\(domain)"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
self.scheduleWideAreaFallback()
|
||||||
}
|
}
|
||||||
|
|
||||||
func stop() {
|
public func stop() {
|
||||||
for browser in self.browsers.values {
|
for browser in self.browsers.values {
|
||||||
browser.cancel()
|
browser.cancel()
|
||||||
}
|
}
|
||||||
@@ -85,15 +123,34 @@ final class GatewayDiscoveryModel {
|
|||||||
self.resolvedTXTByID = [:]
|
self.resolvedTXTByID = [:]
|
||||||
self.pendingTXTResolvers.values.forEach { $0.cancel() }
|
self.pendingTXTResolvers.values.forEach { $0.cancel() }
|
||||||
self.pendingTXTResolvers = [:]
|
self.pendingTXTResolvers = [:]
|
||||||
|
self.wideAreaFallbackTask?.cancel()
|
||||||
|
self.wideAreaFallbackTask = nil
|
||||||
|
self.wideAreaFallbackGateways = []
|
||||||
self.gateways = []
|
self.gateways = []
|
||||||
self.statusText = "Stopped"
|
self.statusText = "Stopped"
|
||||||
}
|
}
|
||||||
|
|
||||||
private func recomputeGateways() {
|
private func recomputeGateways() {
|
||||||
self.gateways = self.gatewaysByDomain.values
|
var next = self.gatewaysByDomain.values
|
||||||
.flatMap(\.self)
|
.flatMap(\.self)
|
||||||
.filter { !$0.isLocal }
|
|
||||||
.sorted { $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending }
|
.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<String>()
|
||||||
|
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) {
|
private func updateGateways(for domain: String) {
|
||||||
@@ -146,6 +203,52 @@ final class GatewayDiscoveryModel {
|
|||||||
isLocal: isLocal)
|
isLocal: isLocal)
|
||||||
}
|
}
|
||||||
.sorted { $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending }
|
.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() {
|
private func updateGatewaysForAllDomains() {
|
||||||
@@ -208,15 +311,15 @@ final class GatewayDiscoveryModel {
|
|||||||
return merged
|
return merged
|
||||||
}
|
}
|
||||||
|
|
||||||
struct GatewayTXT: Equatable {
|
public struct GatewayTXT: Equatable {
|
||||||
var lanHost: String?
|
public var lanHost: String?
|
||||||
var tailnetDns: String?
|
public var tailnetDns: String?
|
||||||
var sshPort: Int
|
public var sshPort: Int
|
||||||
var gatewayPort: Int?
|
public var gatewayPort: Int?
|
||||||
var cliPath: String?
|
public var cliPath: String?
|
||||||
}
|
}
|
||||||
|
|
||||||
static func parseGatewayTXT(_ txt: [String: String]) -> GatewayTXT {
|
public static func parseGatewayTXT(_ txt: [String: String]) -> GatewayTXT {
|
||||||
var lanHost: String?
|
var lanHost: String?
|
||||||
var tailnetDns: String?
|
var tailnetDns: String?
|
||||||
var sshPort = 22
|
var sshPort = 22
|
||||||
@@ -256,7 +359,7 @@ final class GatewayDiscoveryModel {
|
|||||||
cliPath: cliPath)
|
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)"
|
var target = "\(user)@\(host)"
|
||||||
if port != 22 {
|
if port != 22 {
|
||||||
target += ":\(port)"
|
target += ":\(port)"
|
||||||
@@ -324,7 +427,7 @@ final class GatewayDiscoveryModel {
|
|||||||
return titled.isEmpty ? normalized : titled
|
return titled.isEmpty ? normalized : titled
|
||||||
}
|
}
|
||||||
|
|
||||||
nonisolated static func isLocalGateway(
|
public nonisolated static func isLocalGateway(
|
||||||
lanHost: String?,
|
lanHost: String?,
|
||||||
tailnetDns: String?,
|
tailnetDns: String?,
|
||||||
displayName: String?,
|
displayName: String?,
|
||||||
@@ -356,8 +459,9 @@ final class GatewayDiscoveryModel {
|
|||||||
|
|
||||||
private func refreshLocalIdentity() {
|
private func refreshLocalIdentity() {
|
||||||
let fastIdentity = self.localIdentity
|
let fastIdentity = self.localIdentity
|
||||||
|
let displayName = self.localDisplayName
|
||||||
Task.detached(priority: .utility) {
|
Task.detached(priority: .utility) {
|
||||||
let slowIdentity = Self.buildLocalIdentitySlow()
|
let slowIdentity = Self.buildLocalIdentitySlow(displayName: displayName)
|
||||||
let merged = Self.mergeLocalIdentity(fast: fastIdentity, slow: slowIdentity)
|
let merged = Self.mergeLocalIdentity(fast: fastIdentity, slow: slowIdentity)
|
||||||
await MainActor.run { [weak self] in
|
await MainActor.run { [weak self] in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
@@ -377,7 +481,7 @@ final class GatewayDiscoveryModel {
|
|||||||
displayTokens: fast.displayTokens.union(slow.displayTokens))
|
displayTokens: fast.displayTokens.union(slow.displayTokens))
|
||||||
}
|
}
|
||||||
|
|
||||||
private nonisolated static func buildLocalIdentityFast() -> LocalIdentity {
|
private nonisolated static func buildLocalIdentityFast(displayName: String?) -> LocalIdentity {
|
||||||
var hostTokens: Set<String> = []
|
var hostTokens: Set<String> = []
|
||||||
var displayTokens: Set<String> = []
|
var displayTokens: Set<String> = []
|
||||||
|
|
||||||
@@ -386,14 +490,14 @@ final class GatewayDiscoveryModel {
|
|||||||
hostTokens.insert(token)
|
hostTokens.insert(token)
|
||||||
}
|
}
|
||||||
|
|
||||||
if let token = normalizeDisplayToken(InstanceIdentity.displayName) {
|
if let token = normalizeDisplayToken(displayName) {
|
||||||
displayTokens.insert(token)
|
displayTokens.insert(token)
|
||||||
}
|
}
|
||||||
|
|
||||||
return LocalIdentity(hostTokens: hostTokens, displayTokens: displayTokens)
|
return LocalIdentity(hostTokens: hostTokens, displayTokens: displayTokens)
|
||||||
}
|
}
|
||||||
|
|
||||||
private nonisolated static func buildLocalIdentitySlow() -> LocalIdentity {
|
private nonisolated static func buildLocalIdentitySlow(displayName: String?) -> LocalIdentity {
|
||||||
var hostTokens: Set<String> = []
|
var hostTokens: Set<String> = []
|
||||||
var displayTokens: Set<String> = []
|
var displayTokens: Set<String> = []
|
||||||
|
|
||||||
@@ -403,6 +507,10 @@ final class GatewayDiscoveryModel {
|
|||||||
hostTokens.insert(token)
|
hostTokens.insert(token)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let token = normalizeDisplayToken(displayName) {
|
||||||
|
displayTokens.insert(token)
|
||||||
|
}
|
||||||
|
|
||||||
if let token = normalizeDisplayToken(Host.current().localizedName) {
|
if let token = normalizeDisplayToken(Host.current().localizedName) {
|
||||||
displayTokens.insert(token)
|
displayTokens.insert(token)
|
||||||
}
|
}
|
||||||
@@ -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<String>()
|
||||||
|
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[..<idx]).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let rawValue = String(token[token.index(after: idx)...])
|
||||||
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let value = self.decodeDnsSdEscapes(rawValue)
|
||||||
|
if !key.isEmpty { out[key] = value }
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func parseInt(_ value: String?) -> 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 }
|
||||||
|
}
|
||||||
150
apps/macos/Sources/ClawdbotDiscoveryCLI/main.swift
Normal file
150
apps/macos/Sources/ClawdbotDiscoveryCLI/main.swift
Normal file
@@ -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 <ms>] [--json] [--include-local]
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--timeout <ms> 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)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
|
import ClawdbotDiscovery
|
||||||
import Testing
|
import Testing
|
||||||
@testable import Clawdbot
|
|
||||||
|
|
||||||
@Suite
|
@Suite
|
||||||
@MainActor
|
@MainActor
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import ClawdbotDiscovery
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import Testing
|
import Testing
|
||||||
@testable import Clawdbot
|
@testable import Clawdbot
|
||||||
@@ -6,7 +7,7 @@ import Testing
|
|||||||
@MainActor
|
@MainActor
|
||||||
struct MasterDiscoveryMenuSmokeTests {
|
struct MasterDiscoveryMenuSmokeTests {
|
||||||
@Test func inlineListBuildsBodyWhenEmpty() {
|
@Test func inlineListBuildsBodyWhenEmpty() {
|
||||||
let discovery = GatewayDiscoveryModel()
|
let discovery = GatewayDiscoveryModel(localDisplayName: InstanceIdentity.displayName)
|
||||||
discovery.statusText = "Searching…"
|
discovery.statusText = "Searching…"
|
||||||
discovery.gateways = []
|
discovery.gateways = []
|
||||||
|
|
||||||
@@ -15,7 +16,7 @@ struct MasterDiscoveryMenuSmokeTests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test func inlineListBuildsBodyWithMasterAndSelection() {
|
@Test func inlineListBuildsBodyWithMasterAndSelection() {
|
||||||
let discovery = GatewayDiscoveryModel()
|
let discovery = GatewayDiscoveryModel(localDisplayName: InstanceIdentity.displayName)
|
||||||
discovery.statusText = "Found 1"
|
discovery.statusText = "Found 1"
|
||||||
discovery.gateways = [
|
discovery.gateways = [
|
||||||
GatewayDiscoveryModel.DiscoveredGateway(
|
GatewayDiscoveryModel.DiscoveredGateway(
|
||||||
@@ -23,6 +24,8 @@ struct MasterDiscoveryMenuSmokeTests {
|
|||||||
lanHost: "office.local",
|
lanHost: "office.local",
|
||||||
tailnetDns: "office.tailnet-123.ts.net",
|
tailnetDns: "office.tailnet-123.ts.net",
|
||||||
sshPort: 2222,
|
sshPort: 2222,
|
||||||
|
gatewayPort: nil,
|
||||||
|
cliPath: nil,
|
||||||
stableID: "office",
|
stableID: "office",
|
||||||
debugID: "office",
|
debugID: "office",
|
||||||
isLocal: false),
|
isLocal: false),
|
||||||
@@ -34,7 +37,7 @@ struct MasterDiscoveryMenuSmokeTests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test func menuBuildsBodyWithMasters() {
|
@Test func menuBuildsBodyWithMasters() {
|
||||||
let discovery = GatewayDiscoveryModel()
|
let discovery = GatewayDiscoveryModel(localDisplayName: InstanceIdentity.displayName)
|
||||||
discovery.statusText = "Found 2"
|
discovery.statusText = "Found 2"
|
||||||
discovery.gateways = [
|
discovery.gateways = [
|
||||||
GatewayDiscoveryModel.DiscoveredGateway(
|
GatewayDiscoveryModel.DiscoveredGateway(
|
||||||
@@ -42,6 +45,8 @@ struct MasterDiscoveryMenuSmokeTests {
|
|||||||
lanHost: "a.local",
|
lanHost: "a.local",
|
||||||
tailnetDns: nil,
|
tailnetDns: nil,
|
||||||
sshPort: 22,
|
sshPort: 22,
|
||||||
|
gatewayPort: nil,
|
||||||
|
cliPath: nil,
|
||||||
stableID: "a",
|
stableID: "a",
|
||||||
debugID: "a",
|
debugID: "a",
|
||||||
isLocal: false),
|
isLocal: false),
|
||||||
@@ -50,6 +55,8 @@ struct MasterDiscoveryMenuSmokeTests {
|
|||||||
lanHost: nil,
|
lanHost: nil,
|
||||||
tailnetDns: "b.ts.net",
|
tailnetDns: "b.ts.net",
|
||||||
sshPort: 22,
|
sshPort: 22,
|
||||||
|
gatewayPort: nil,
|
||||||
|
cliPath: nil,
|
||||||
stableID: "b",
|
stableID: "b",
|
||||||
debugID: "b",
|
debugID: "b",
|
||||||
isLocal: false),
|
isLocal: false),
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import ClawdbotDiscovery
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import Testing
|
import Testing
|
||||||
@testable import Clawdbot
|
@testable import Clawdbot
|
||||||
@@ -10,7 +11,7 @@ struct OnboardingViewSmokeTests {
|
|||||||
let view = OnboardingView(
|
let view = OnboardingView(
|
||||||
state: state,
|
state: state,
|
||||||
permissionMonitor: PermissionMonitor.shared,
|
permissionMonitor: PermissionMonitor.shared,
|
||||||
discoveryModel: GatewayDiscoveryModel())
|
discoveryModel: GatewayDiscoveryModel(localDisplayName: InstanceIdentity.displayName))
|
||||||
_ = view.body
|
_ = view.body
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -90,6 +90,25 @@ Safety:
|
|||||||
- Package app + CLI: `scripts/package-mac-app.sh`
|
- Package app + CLI: `scripts/package-mac-app.sh`
|
||||||
- Switch bundled gateway runtime with `BUNDLED_RUNTIME=node|bun` (default: node).
|
- 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 <ms>`: 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
|
## Related docs
|
||||||
|
|
||||||
- [Gateway runbook](/gateway)
|
- [Gateway runbook](/gateway)
|
||||||
|
|||||||
@@ -7,6 +7,11 @@ vi.mock("../infra/update-runner.js", () => ({
|
|||||||
runGatewayUpdate: vi.fn(),
|
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
|
// Mock the daemon-cli module
|
||||||
vi.mock("./daemon-cli.js", () => ({
|
vi.mock("./daemon-cli.js", () => ({
|
||||||
runDaemonRestart: vi.fn(),
|
runDaemonRestart: vi.fn(),
|
||||||
|
|||||||
Reference in New Issue
Block a user