feat(discovery): gateway bonjour + node pairing bridge
This commit is contained in:
@@ -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) }
|
||||
|
||||
@@ -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 {
|
||||
|
||||
103
apps/macos/Sources/Clawdis/MasterDiscoveryModel.swift
Normal file
103
apps/macos/Sources/Clawdis/MasterDiscoveryModel.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user