feat(discovery): gateway bonjour + node pairing bridge

This commit is contained in:
Peter Steinberger
2025-12-13 03:47:27 +00:00
parent 163080b609
commit eace21dcae
18 changed files with 1780 additions and 29 deletions

View File

@@ -28,13 +28,6 @@ actor BridgeServer {
params.includePeerToPeer = true
let listener = try NWListener(using: params, on: .any)
let name = Host.current().localizedName ?? ProcessInfo.processInfo.hostName
listener.service = NWListener.Service(
name: "\(name) (Clawdis)",
type: ClawdisBonjour.bridgeServiceType,
domain: ClawdisBonjour.bridgeServiceDomain,
txtRecord: nil)
listener.newConnectionHandler = { [weak self] connection in
guard let self else { return }
Task { await self.handle(connection: connection) }

View File

@@ -5,6 +5,7 @@ struct GeneralSettings: View {
@ObservedObject var state: AppState
@ObservedObject private var healthStore = HealthStore.shared
@ObservedObject private var gatewayManager = GatewayProcessManager.shared
@StateObject private var masterDiscovery = MasterDiscoveryModel()
@State private var isInstallingCLI = false
@State private var cliStatus: String?
@State private var cliInstalled = false
@@ -124,6 +125,18 @@ struct GeneralSettings: View {
TextField("user@host[:22]", text: self.$state.remoteTarget)
.textFieldStyle(.roundedBorder)
.frame(maxWidth: .infinity)
Menu {
if self.masterDiscovery.masters.isEmpty {
Button(self.masterDiscovery.statusText) {}.disabled(true)
} else {
ForEach(self.masterDiscovery.masters) { master in
Button(master.displayName) { self.applyDiscoveredMaster(master) }
}
}
} label: {
Image(systemName: "dot.radiowaves.left.and.right")
}
.help("Discover Clawdis masters on your LAN")
Button {
Task { await self.testRemote() }
} label: {
@@ -188,6 +201,8 @@ struct GeneralSettings: View {
.lineLimit(1)
}
.transition(.opacity)
.onAppear { self.masterDiscovery.start() }
.onDisappear { self.masterDiscovery.stop() }
}
private var controlStatusLine: String {
@@ -562,6 +577,17 @@ extension GeneralSettings {
alert.addButton(withTitle: "OK")
alert.runModal()
}
private func applyDiscoveredMaster(_ master: MasterDiscoveryModel.DiscoveredMaster) {
let host = master.tailnetDns ?? master.lanHost
guard let host else { return }
let user = NSUserName()
var target = "\(user)@\(host)"
if master.sshPort != 22 {
target += ":\(master.sshPort)"
}
self.state.remoteTarget = target
}
}
private func healthAgeString(_ ms: Double?) -> String {

View File

@@ -0,0 +1,103 @@
import Foundation
import Network
@MainActor
final class MasterDiscoveryModel: ObservableObject {
struct DiscoveredMaster: Identifiable, Equatable {
var id: String { self.debugID }
var displayName: String
var lanHost: String?
var tailnetDns: String?
var sshPort: Int
var debugID: String
}
@Published var masters: [DiscoveredMaster] = []
@Published var statusText: String = "Idle"
private var browser: NWBrowser?
private static let serviceType = "_clawdis-master._tcp"
private static let serviceDomain = "local."
func start() {
if self.browser != nil { return }
let params = NWParameters.tcp
params.includePeerToPeer = true
let browser = NWBrowser(for: .bonjour(type: Self.serviceType, domain: Self.serviceDomain), using: params)
browser.stateUpdateHandler = { [weak self] state in
Task { @MainActor in
guard let self else { return }
switch state {
case .setup:
self.statusText = "Setup"
case .ready:
self.statusText = "Searching…"
case let .failed(err):
self.statusText = "Failed: \(err)"
case .cancelled:
self.statusText = "Stopped"
case let .waiting(err):
self.statusText = "Waiting: \(err)"
@unknown default:
self.statusText = "Unknown"
}
}
}
browser.browseResultsChangedHandler = { [weak self] results, _ in
Task { @MainActor in
guard let self else { return }
self.masters = results.compactMap { result -> DiscoveredMaster? in
guard case let .service(name, _, _, _) = result.endpoint else { return nil }
var lanHost: String?
var tailnetDns: String?
var sshPort = 22
if case let .bonjour(txt) = result.metadata {
let dict = txt.dictionary
if let value = dict["lanHost"] {
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
lanHost = trimmed.isEmpty ? nil : trimmed
}
if let value = dict["tailnetDns"] {
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
tailnetDns = trimmed.isEmpty ? nil : trimmed
}
if let value = dict["sshPort"],
let parsed = Int(value.trimmingCharacters(in: .whitespacesAndNewlines)),
parsed > 0
{
sshPort = parsed
}
}
return DiscoveredMaster(
displayName: name,
lanHost: lanHost,
tailnetDns: tailnetDns,
sshPort: sshPort,
debugID: Self.prettyEndpointDebugID(result.endpoint))
}
.sorted { $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending }
}
}
self.browser = browser
browser.start(queue: DispatchQueue(label: "com.steipete.clawdis.macos.master-discovery"))
}
func stop() {
self.browser?.cancel()
self.browser = nil
self.masters = []
self.statusText = "Stopped"
}
private static func prettyEndpointDebugID(_ endpoint: NWEndpoint) -> String {
String(describing: endpoint)
}
}

View File

@@ -182,7 +182,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
Task { await HealthStore.shared.refresh(onDemand: true) }
Task { await PortGuardian.shared.sweep(mode: AppStateStore.shared.connectionMode) }
Task { await self.socketServer.start() }
Task { await BridgeServer.shared.start() }
self.scheduleFirstRunOnboardingIfNeeded()
// Developer/testing helper: auto-open WebChat when launched with --webchat
@@ -201,7 +200,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
Task { await AgentRPC.shared.shutdown() }
Task { await GatewayConnection.shared.shutdown() }
Task { await self.socketServer.stop() }
Task { await BridgeServer.shared.stop() }
}
@MainActor