refactor: unify gateway discovery on bridge
parent
bcced90f11
commit
bc2a66da32
|
|
@ -0,0 +1,20 @@
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum BridgeDiscoveryPreferences {
|
||||||
|
private static let preferredStableIDKey = "bridge.preferredStableID"
|
||||||
|
|
||||||
|
static func preferredStableID() -> String? {
|
||||||
|
let raw = UserDefaults.standard.string(forKey: self.preferredStableIDKey)
|
||||||
|
let trimmed = raw?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
return trimmed?.isEmpty == false ? trimmed : nil
|
||||||
|
}
|
||||||
|
|
||||||
|
static func setPreferredStableID(_ stableID: String?) {
|
||||||
|
let trimmed = stableID?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if let trimmed, !trimmed.isEmpty {
|
||||||
|
UserDefaults.standard.set(trimmed, forKey: self.preferredStableIDKey)
|
||||||
|
} else {
|
||||||
|
UserDefaults.standard.removeObject(forKey: self.preferredStableIDKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
import ClawdisKit
|
||||||
|
import Foundation
|
||||||
|
import Network
|
||||||
|
|
||||||
|
enum BridgeEndpointID {
|
||||||
|
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).
|
||||||
|
let normalizedName = Self.normalizeServiceNameForID(name)
|
||||||
|
return "\(type)|\(domain)|\(normalizedName)"
|
||||||
|
default:
|
||||||
|
return String(describing: endpoint)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func prettyDescription(_ endpoint: NWEndpoint) -> String {
|
||||||
|
BonjourEscapes.decode(String(describing: endpoint))
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func normalizeServiceNameForID(_ rawName: String) -> String {
|
||||||
|
let decoded = BonjourEscapes.decode(rawName)
|
||||||
|
let normalized = decoded.split(whereSeparator: \.isWhitespace).joined(separator: " ")
|
||||||
|
return normalized.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,12 +1,10 @@
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
// “master” is part of the discovery protocol naming; keep UI components consistent.
|
struct GatewayDiscoveryInlineList: View {
|
||||||
// swiftlint:disable:next inclusive_language
|
var discovery: GatewayDiscoveryModel
|
||||||
struct MasterDiscoveryInlineList: View {
|
|
||||||
var discovery: MasterDiscoveryModel
|
|
||||||
var currentTarget: String?
|
var currentTarget: String?
|
||||||
var onSelect: (MasterDiscoveryModel.DiscoveredMaster) -> Void
|
var onSelect: (GatewayDiscoveryModel.DiscoveredGateway) -> Void
|
||||||
@State private var hoveredGatewayID: MasterDiscoveryModel.DiscoveredMaster.ID?
|
@State private var hoveredGatewayID: GatewayDiscoveryModel.DiscoveredGateway.ID?
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
|
@ -19,16 +17,16 @@ struct MasterDiscoveryInlineList: View {
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
|
|
||||||
if self.discovery.masters.isEmpty {
|
if self.discovery.gateways.isEmpty {
|
||||||
Text("No gateways found yet.")
|
Text("No gateways found yet.")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
} else {
|
} else {
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
ForEach(self.discovery.masters.prefix(6)) { gateway in
|
ForEach(self.discovery.gateways.prefix(6)) { gateway in
|
||||||
let target = self.suggestedSSHTarget(gateway)
|
let target = self.suggestedSSHTarget(gateway)
|
||||||
let selected = target != nil && self.currentTarget?
|
let selected = (target != nil && self.currentTarget?
|
||||||
.trimmingCharacters(in: .whitespacesAndNewlines) == target
|
.trimmingCharacters(in: .whitespacesAndNewlines) == target)
|
||||||
|
|
||||||
Button {
|
Button {
|
||||||
withAnimation(.spring(response: 0.25, dampingFraction: 0.9)) {
|
withAnimation(.spring(response: 0.25, dampingFraction: 0.9)) {
|
||||||
|
|
@ -41,13 +39,11 @@ struct MasterDiscoveryInlineList: View {
|
||||||
.font(.callout.weight(.semibold))
|
.font(.callout.weight(.semibold))
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
.truncationMode(.tail)
|
.truncationMode(.tail)
|
||||||
if let target {
|
Text(target ?? "Bridge pairing only")
|
||||||
Text(target)
|
.font(.caption.monospaced())
|
||||||
.font(.caption.monospaced())
|
.foregroundStyle(.secondary)
|
||||||
.foregroundStyle(.secondary)
|
.lineLimit(1)
|
||||||
.lineLimit(1)
|
.truncationMode(.middle)
|
||||||
.truncationMode(.middle)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Spacer(minLength: 0)
|
Spacer(minLength: 0)
|
||||||
if selected {
|
if selected {
|
||||||
|
|
@ -89,7 +85,7 @@ struct MasterDiscoveryInlineList: View {
|
||||||
.help("Click a discovered gateway to fill the SSH target.")
|
.help("Click a discovered gateway to fill the SSH target.")
|
||||||
}
|
}
|
||||||
|
|
||||||
private func suggestedSSHTarget(_ gateway: MasterDiscoveryModel.DiscoveredMaster) -> String? {
|
private func suggestedSSHTarget(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) -> String? {
|
||||||
let host = gateway.tailnetDns ?? gateway.lanHost
|
let host = gateway.tailnetDns ?? gateway.lanHost
|
||||||
guard let host else { return nil }
|
guard let host else { return nil }
|
||||||
let user = NSUserName()
|
let user = NSUserName()
|
||||||
|
|
@ -107,24 +103,23 @@ struct MasterDiscoveryInlineList: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// swiftlint:disable:next inclusive_language
|
struct GatewayDiscoveryMenu: View {
|
||||||
struct MasterDiscoveryMenu: View {
|
var discovery: GatewayDiscoveryModel
|
||||||
var discovery: MasterDiscoveryModel
|
var onSelect: (GatewayDiscoveryModel.DiscoveredGateway) -> Void
|
||||||
var onSelect: (MasterDiscoveryModel.DiscoveredMaster) -> Void
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Menu {
|
Menu {
|
||||||
if self.discovery.masters.isEmpty {
|
if self.discovery.gateways.isEmpty {
|
||||||
Button(self.discovery.statusText) {}
|
Button(self.discovery.statusText) {}
|
||||||
.disabled(true)
|
.disabled(true)
|
||||||
} else {
|
} else {
|
||||||
ForEach(self.discovery.masters) { gateway in
|
ForEach(self.discovery.gateways) { gateway in
|
||||||
Button(gateway.displayName) { self.onSelect(gateway) }
|
Button(gateway.displayName) { self.onSelect(gateway) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} label: {
|
} label: {
|
||||||
Image(systemName: "dot.radiowaves.left.and.right")
|
Image(systemName: "dot.radiowaves.left.and.right")
|
||||||
}
|
}
|
||||||
.help("Discover Clawdis masters on your LAN")
|
.help("Discover Clawdis gateways on your LAN")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -0,0 +1,164 @@
|
||||||
|
import ClawdisKit
|
||||||
|
import Foundation
|
||||||
|
import Network
|
||||||
|
import Observation
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Observable
|
||||||
|
final class GatewayDiscoveryModel {
|
||||||
|
struct DiscoveredGateway: Identifiable, Equatable {
|
||||||
|
var id: String { self.stableID }
|
||||||
|
var displayName: String
|
||||||
|
var lanHost: String?
|
||||||
|
var tailnetDns: String?
|
||||||
|
var sshPort: Int
|
||||||
|
var stableID: String
|
||||||
|
var debugID: String
|
||||||
|
}
|
||||||
|
|
||||||
|
var gateways: [DiscoveredGateway] = []
|
||||||
|
var statusText: String = "Idle"
|
||||||
|
|
||||||
|
private var browsers: [String: NWBrowser] = [:]
|
||||||
|
private var gatewaysByDomain: [String: [DiscoveredGateway]] = [:]
|
||||||
|
private var statesByDomain: [String: NWBrowser.State] = [:]
|
||||||
|
|
||||||
|
func start() {
|
||||||
|
if !self.browsers.isEmpty { return }
|
||||||
|
|
||||||
|
for domain in ClawdisBonjour.bridgeServiceDomains {
|
||||||
|
let params = NWParameters.tcp
|
||||||
|
params.includePeerToPeer = true
|
||||||
|
let browser = NWBrowser(
|
||||||
|
for: .bonjour(type: ClawdisBonjour.bridgeServiceType, domain: domain),
|
||||||
|
using: params)
|
||||||
|
|
||||||
|
browser.stateUpdateHandler = { [weak self] state in
|
||||||
|
Task { @MainActor in
|
||||||
|
guard let self else { return }
|
||||||
|
self.statesByDomain[domain] = state
|
||||||
|
self.updateStatusText()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
browser.browseResultsChangedHandler = { [weak self] results, _ in
|
||||||
|
Task { @MainActor in
|
||||||
|
guard let self else { return }
|
||||||
|
self.gatewaysByDomain[domain] = results.compactMap { result -> DiscoveredGateway? in
|
||||||
|
guard case let .service(name, _, _, _) = result.endpoint else { return nil }
|
||||||
|
|
||||||
|
let decodedName = BonjourEscapes.decode(name)
|
||||||
|
let txt = Self.txtDictionary(from: result)
|
||||||
|
|
||||||
|
let advertisedName = txt["displayName"]
|
||||||
|
.map(Self.prettifyInstanceName)
|
||||||
|
.flatMap { $0.isEmpty ? nil : $0 }
|
||||||
|
let prettyName = advertisedName ?? Self.prettifyInstanceName(decodedName)
|
||||||
|
|
||||||
|
var lanHost: String?
|
||||||
|
var tailnetDns: String?
|
||||||
|
var sshPort = 22
|
||||||
|
|
||||||
|
if let value = txt["lanHost"] {
|
||||||
|
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
lanHost = trimmed.isEmpty ? nil : trimmed
|
||||||
|
}
|
||||||
|
if let value = txt["tailnetDns"] {
|
||||||
|
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
tailnetDns = trimmed.isEmpty ? nil : trimmed
|
||||||
|
}
|
||||||
|
if let value = txt["sshPort"],
|
||||||
|
let parsed = Int(value.trimmingCharacters(in: .whitespacesAndNewlines)),
|
||||||
|
parsed > 0
|
||||||
|
{
|
||||||
|
sshPort = parsed
|
||||||
|
}
|
||||||
|
|
||||||
|
return DiscoveredGateway(
|
||||||
|
displayName: prettyName,
|
||||||
|
lanHost: lanHost,
|
||||||
|
tailnetDns: tailnetDns,
|
||||||
|
sshPort: sshPort,
|
||||||
|
stableID: BridgeEndpointID.stableID(result.endpoint),
|
||||||
|
debugID: BridgeEndpointID.prettyDescription(result.endpoint))
|
||||||
|
}
|
||||||
|
.sorted { $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending }
|
||||||
|
|
||||||
|
self.recomputeGateways()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.browsers[domain] = browser
|
||||||
|
browser.start(queue: DispatchQueue(label: "com.steipete.clawdis.macos.gateway-discovery.\(domain)"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func stop() {
|
||||||
|
for browser in self.browsers.values {
|
||||||
|
browser.cancel()
|
||||||
|
}
|
||||||
|
self.browsers = [:]
|
||||||
|
self.gatewaysByDomain = [:]
|
||||||
|
self.statesByDomain = [:]
|
||||||
|
self.gateways = []
|
||||||
|
self.statusText = "Stopped"
|
||||||
|
}
|
||||||
|
|
||||||
|
private func recomputeGateways() {
|
||||||
|
self.gateways = self.gatewaysByDomain.values
|
||||||
|
.flatMap(\.self)
|
||||||
|
.sorted { $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateStatusText() {
|
||||||
|
let states = Array(self.statesByDomain.values)
|
||||||
|
if states.isEmpty {
|
||||||
|
self.statusText = self.browsers.isEmpty ? "Idle" : "Setup"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if let failed = states.first(where: { state in
|
||||||
|
if case .failed = state { return true }
|
||||||
|
return false
|
||||||
|
}) {
|
||||||
|
if case let .failed(err) = failed {
|
||||||
|
self.statusText = "Failed: \(err)"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let waiting = states.first(where: { state in
|
||||||
|
if case .waiting = state { return true }
|
||||||
|
return false
|
||||||
|
}) {
|
||||||
|
if case let .waiting(err) = waiting {
|
||||||
|
self.statusText = "Waiting: \(err)"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if states.contains(where: { if case .ready = $0 { true } else { false } }) {
|
||||||
|
self.statusText = "Searching…"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if states.contains(where: { if case .setup = $0 { true } else { false } }) {
|
||||||
|
self.statusText = "Setup"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
self.statusText = "Searching…"
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func txtDictionary(from result: NWBrowser.Result) -> [String: String] {
|
||||||
|
guard case let .bonjour(txt) = result.metadata else { return [:] }
|
||||||
|
return txt.dictionary
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func prettifyInstanceName(_ decodedName: String) -> String {
|
||||||
|
let normalized = decodedName.split(whereSeparator: \.isWhitespace).joined(separator: " ")
|
||||||
|
let stripped = normalized.replacingOccurrences(of: " (Clawdis)", with: "")
|
||||||
|
.replacingOccurrences(of: #"\s+\(\d+\)$"#, with: "", options: .regularExpression)
|
||||||
|
return stripped.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -8,7 +8,7 @@ struct GeneralSettings: View {
|
||||||
private let healthStore = HealthStore.shared
|
private let healthStore = HealthStore.shared
|
||||||
private let gatewayManager = GatewayProcessManager.shared
|
private let gatewayManager = GatewayProcessManager.shared
|
||||||
// swiftlint:disable:next inclusive_language
|
// swiftlint:disable:next inclusive_language
|
||||||
@State private var masterDiscovery = MasterDiscoveryModel()
|
@State private var gatewayDiscovery = GatewayDiscoveryModel()
|
||||||
@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
|
||||||
|
|
@ -152,11 +152,11 @@ struct GeneralSettings: View {
|
||||||
.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||||
}
|
}
|
||||||
|
|
||||||
MasterDiscoveryInlineList(
|
GatewayDiscoveryInlineList(
|
||||||
discovery: self.masterDiscovery,
|
discovery: self.gatewayDiscovery,
|
||||||
currentTarget: self.state.remoteTarget)
|
currentTarget: self.state.remoteTarget)
|
||||||
{ master in
|
{ gateway in
|
||||||
self.applyDiscoveredMaster(master)
|
self.applyDiscoveredGateway(gateway)
|
||||||
}
|
}
|
||||||
.padding(.leading, 58)
|
.padding(.leading, 58)
|
||||||
|
|
||||||
|
|
@ -210,8 +210,8 @@ struct GeneralSettings: View {
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
}
|
}
|
||||||
.transition(.opacity)
|
.transition(.opacity)
|
||||||
.onAppear { self.masterDiscovery.start() }
|
.onAppear { self.gatewayDiscovery.start() }
|
||||||
.onDisappear { self.masterDiscovery.stop() }
|
.onDisappear { self.gatewayDiscovery.stop() }
|
||||||
}
|
}
|
||||||
|
|
||||||
private var controlStatusLine: String {
|
private var controlStatusLine: String {
|
||||||
|
|
@ -599,13 +599,15 @@ extension GeneralSettings {
|
||||||
}
|
}
|
||||||
|
|
||||||
// swiftlint:disable:next inclusive_language
|
// swiftlint:disable:next inclusive_language
|
||||||
private func applyDiscoveredMaster(_ master: MasterDiscoveryModel.DiscoveredMaster) {
|
private func applyDiscoveredGateway(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) {
|
||||||
let host = master.tailnetDns ?? master.lanHost
|
MacNodeModeCoordinator.shared.setPreferredBridgeStableID(gateway.stableID)
|
||||||
|
|
||||||
|
let host = gateway.tailnetDns ?? gateway.lanHost
|
||||||
guard let host else { return }
|
guard let host else { return }
|
||||||
let user = NSUserName()
|
let user = NSUserName()
|
||||||
var target = "\(user)@\(host)"
|
var target = "\(user)@\(host)"
|
||||||
if master.sshPort != 22 {
|
if gateway.sshPort != 22 {
|
||||||
target += ":\(master.sshPort)"
|
target += ":\(gateway.sshPort)"
|
||||||
}
|
}
|
||||||
self.state.remoteTarget = target
|
self.state.remoteTarget = target
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,109 +0,0 @@
|
||||||
import Foundation
|
|
||||||
import Network
|
|
||||||
import Observation
|
|
||||||
|
|
||||||
// We use “master” as the on-the-wire service name; keep the model aligned with the protocol/docs.
|
|
||||||
@MainActor
|
|
||||||
@Observable
|
|
||||||
// swiftlint:disable:next inclusive_language
|
|
||||||
final class MasterDiscoveryModel {
|
|
||||||
// swiftlint:disable:next inclusive_language
|
|
||||||
struct DiscoveredMaster: Identifiable, Equatable {
|
|
||||||
var id: String { self.debugID }
|
|
||||||
var displayName: String
|
|
||||||
var lanHost: String?
|
|
||||||
var tailnetDns: String?
|
|
||||||
var sshPort: Int
|
|
||||||
var debugID: String
|
|
||||||
}
|
|
||||||
|
|
||||||
// swiftlint:disable:next inclusive_language
|
|
||||||
var masters: [DiscoveredMaster] = []
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -28,6 +28,11 @@ final class MacNodeModeCoordinator {
|
||||||
self.tunnel = nil
|
self.tunnel = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func setPreferredBridgeStableID(_ stableID: String?) {
|
||||||
|
BridgeDiscoveryPreferences.setPreferredStableID(stableID)
|
||||||
|
Task { await self.session.disconnect() }
|
||||||
|
}
|
||||||
|
|
||||||
private func run() async {
|
private func run() async {
|
||||||
var retryDelay: UInt64 = 1_000_000_000
|
var retryDelay: UInt64 = 1_000_000_000
|
||||||
var lastCameraEnabled: Bool? = nil
|
var lastCameraEnabled: Bool? = nil
|
||||||
|
|
@ -132,10 +137,13 @@ final class MacNodeModeCoordinator {
|
||||||
guard text.contains("NOT_PAIRED") || text.contains("UNAUTHORIZED") else { return false }
|
guard text.contains("NOT_PAIRED") || text.contains("UNAUTHORIZED") else { return false }
|
||||||
|
|
||||||
do {
|
do {
|
||||||
|
let shouldSilent = await MainActor.run {
|
||||||
|
AppStateStore.shared.connectionMode == .remote
|
||||||
|
}
|
||||||
let token = try await MacNodeBridgePairingClient().pairAndHello(
|
let token = try await MacNodeBridgePairingClient().pairAndHello(
|
||||||
endpoint: endpoint,
|
endpoint: endpoint,
|
||||||
hello: self.makeHello(),
|
hello: self.makeHello(),
|
||||||
silent: true,
|
silent: shouldSilent,
|
||||||
onStatus: { [weak self] status in
|
onStatus: { [weak self] status in
|
||||||
self?.logger.info("mac node pairing: \(status, privacy: .public)")
|
self?.logger.info("mac node pairing: \(status, privacy: .public)")
|
||||||
})
|
})
|
||||||
|
|
@ -209,6 +217,19 @@ final class MacNodeModeCoordinator {
|
||||||
for: .bonjour(type: ClawdisBonjour.bridgeServiceType, domain: domain),
|
for: .bonjour(type: ClawdisBonjour.bridgeServiceType, domain: domain),
|
||||||
using: params)
|
using: params)
|
||||||
browser.browseResultsChangedHandler = { results, _ in
|
browser.browseResultsChangedHandler = { results, _ in
|
||||||
|
let preferred = BridgeDiscoveryPreferences.preferredStableID()
|
||||||
|
if let preferred,
|
||||||
|
let match = results.first(where: {
|
||||||
|
if case .service = $0.endpoint {
|
||||||
|
return BridgeEndpointID.stableID($0.endpoint) == preferred
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
{
|
||||||
|
state.finish(match.endpoint)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if let result = results.first(where: { if case .service = $0.endpoint { true } else { false } }) {
|
if let result = results.first(where: { if case .service = $0.endpoint { true } else { false } }) {
|
||||||
state.finish(result.endpoint)
|
state.finish(result.endpoint)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -517,20 +517,22 @@ final class NodePairingApprovalPrompter {
|
||||||
return SSHTarget(host: host, port: port)
|
return SSHTarget(host: host, port: port)
|
||||||
}
|
}
|
||||||
|
|
||||||
let model = MasterDiscoveryModel()
|
let model = GatewayDiscoveryModel()
|
||||||
model.start()
|
model.start()
|
||||||
defer { model.stop() }
|
defer { model.stop() }
|
||||||
|
|
||||||
let deadline = Date().addingTimeInterval(5.0)
|
let deadline = Date().addingTimeInterval(5.0)
|
||||||
while model.masters.isEmpty, Date() < deadline {
|
while model.gateways.isEmpty, Date() < deadline {
|
||||||
try? await Task.sleep(nanoseconds: 200_000_000)
|
try? await Task.sleep(nanoseconds: 200_000_000)
|
||||||
}
|
}
|
||||||
|
|
||||||
guard let master = model.masters.first else { return nil }
|
let preferred = BridgeDiscoveryPreferences.preferredStableID()
|
||||||
let host = (master.tailnetDns?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty ??
|
let gateway = model.gateways.first { $0.stableID == preferred } ?? model.gateways.first
|
||||||
master.lanHost?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty)
|
guard let gateway else { return nil }
|
||||||
|
let host = (gateway.tailnetDns?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty ??
|
||||||
|
gateway.lanHost?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty)
|
||||||
guard let host, !host.isEmpty else { return nil }
|
guard let host, !host.isEmpty else { return nil }
|
||||||
let port = master.sshPort > 0 ? master.sshPort : 22
|
let port = gateway.sshPort > 0 ? gateway.sshPort : 22
|
||||||
return SSHTarget(host: host, port: port)
|
return SSHTarget(host: host, port: port)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -75,8 +75,10 @@ struct OnboardingView: View {
|
||||||
@State private var gatewayStatus: GatewayEnvironmentStatus = .checking
|
@State private var gatewayStatus: GatewayEnvironmentStatus = .checking
|
||||||
@State private var gatewayInstalling = false
|
@State private var gatewayInstalling = false
|
||||||
@State private var gatewayInstallMessage: String?
|
@State private var gatewayInstallMessage: String?
|
||||||
|
@State private var showAdvancedConnection = false
|
||||||
|
@State private var preferredGatewayID: String?
|
||||||
// swiftlint:disable:next inclusive_language
|
// swiftlint:disable:next inclusive_language
|
||||||
@State private var masterDiscovery: MasterDiscoveryModel
|
@State private var gatewayDiscovery: GatewayDiscoveryModel
|
||||||
@Bindable private var state: AppState
|
@Bindable private var state: AppState
|
||||||
private var permissionMonitor: PermissionMonitor
|
private var permissionMonitor: PermissionMonitor
|
||||||
|
|
||||||
|
|
@ -107,11 +109,11 @@ struct OnboardingView: View {
|
||||||
init(
|
init(
|
||||||
state: AppState = AppStateStore.shared,
|
state: AppState = AppStateStore.shared,
|
||||||
permissionMonitor: PermissionMonitor = .shared,
|
permissionMonitor: PermissionMonitor = .shared,
|
||||||
discoveryModel: MasterDiscoveryModel = MasterDiscoveryModel())
|
discoveryModel: GatewayDiscoveryModel = GatewayDiscoveryModel())
|
||||||
{
|
{
|
||||||
self.state = state
|
self.state = state
|
||||||
self.permissionMonitor = permissionMonitor
|
self.permissionMonitor = permissionMonitor
|
||||||
self._masterDiscovery = State(initialValue: discoveryModel)
|
self._gatewayDiscovery = State(initialValue: discoveryModel)
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
|
|
@ -165,6 +167,7 @@ struct OnboardingView: View {
|
||||||
self.loadWorkspaceDefaults()
|
self.loadWorkspaceDefaults()
|
||||||
self.refreshAnthropicOAuthStatus()
|
self.refreshAnthropicOAuthStatus()
|
||||||
self.loadIdentityDefaults()
|
self.loadIdentityDefaults()
|
||||||
|
self.preferredGatewayID = BridgeDiscoveryPreferences.preferredStableID()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -260,11 +263,11 @@ struct OnboardingView: View {
|
||||||
|
|
||||||
private func connectionPage() -> some View {
|
private func connectionPage() -> some View {
|
||||||
self.onboardingPage {
|
self.onboardingPage {
|
||||||
Text("Where Clawdis runs")
|
Text("Choose your Gateway")
|
||||||
.font(.largeTitle.weight(.semibold))
|
.font(.largeTitle.weight(.semibold))
|
||||||
Text(
|
Text(
|
||||||
"Clawdis uses a single Gateway (“master”) that stays running. Run it on this Mac, " +
|
"Clawdis uses a single Gateway that stays running. Pick this Mac, " +
|
||||||
"or connect to one on another Mac over SSH/Tailscale.")
|
"or connect to a discovered Gateway nearby.")
|
||||||
.font(.body)
|
.font(.body)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
|
|
@ -273,64 +276,184 @@ struct OnboardingView: View {
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
|
||||||
self.onboardingCard(spacing: 12, padding: 14) {
|
self.onboardingCard(spacing: 12, padding: 14) {
|
||||||
Picker("Gateway runs", selection: self.$state.connectionMode) {
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
Text("This Mac").tag(AppState.ConnectionMode.local)
|
self.connectionChoiceButton(
|
||||||
Text("Remote (SSH)").tag(AppState.ConnectionMode.remote)
|
title: "This Mac",
|
||||||
}
|
subtitle: "Run the Gateway locally.",
|
||||||
.pickerStyle(.segmented)
|
selected: self.state.connectionMode == .local)
|
||||||
.frame(width: 360)
|
{
|
||||||
|
self.selectLocalGateway()
|
||||||
|
}
|
||||||
|
|
||||||
if self.state.connectionMode == .remote {
|
Divider().padding(.vertical, 4)
|
||||||
let labelWidth: CGFloat = 90
|
|
||||||
let fieldWidth: CGFloat = 300
|
|
||||||
let contentLeading: CGFloat = labelWidth + 12
|
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
HStack(alignment: .center, spacing: 12) {
|
Image(systemName: "dot.radiowaves.left.and.right")
|
||||||
Text("SSH target")
|
.font(.caption)
|
||||||
.font(.callout.weight(.semibold))
|
.foregroundStyle(.secondary)
|
||||||
.frame(width: labelWidth, alignment: .leading)
|
Text(self.gatewayDiscovery.statusText)
|
||||||
TextField("user@host[:port]", text: self.$state.remoteTarget)
|
.font(.caption)
|
||||||
.textFieldStyle(.roundedBorder)
|
.foregroundStyle(.secondary)
|
||||||
.frame(width: fieldWidth)
|
if self.gatewayDiscovery.gateways.isEmpty {
|
||||||
|
ProgressView().controlSize(.small)
|
||||||
}
|
}
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
}
|
||||||
|
|
||||||
MasterDiscoveryInlineList(
|
if self.gatewayDiscovery.gateways.isEmpty {
|
||||||
discovery: self.masterDiscovery,
|
Text("Searching for nearby gateways…")
|
||||||
currentTarget: self.state.remoteTarget)
|
.font(.caption)
|
||||||
{ master in
|
.foregroundStyle(.secondary)
|
||||||
self.applyDiscoveredMaster(master)
|
.padding(.leading, 4)
|
||||||
}
|
} else {
|
||||||
.frame(width: fieldWidth, alignment: .leading)
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
.padding(.leading, contentLeading)
|
ForEach(self.gatewayDiscovery.gateways.prefix(6)) { gateway in
|
||||||
|
self.connectionChoiceButton(
|
||||||
DisclosureGroup("Advanced") {
|
title: gateway.displayName,
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
subtitle: self.gatewaySubtitle(for: gateway),
|
||||||
LabeledContent("Identity file") {
|
selected: self.isSelectedGateway(gateway))
|
||||||
TextField("/Users/you/.ssh/id_ed25519", text: self.$state.remoteIdentity)
|
{
|
||||||
.textFieldStyle(.roundedBorder)
|
self.selectRemoteGateway(gateway)
|
||||||
.frame(width: fieldWidth)
|
|
||||||
}
|
|
||||||
LabeledContent("Project root") {
|
|
||||||
TextField("/home/you/Projects/clawdis", text: self.$state.remoteProjectRoot)
|
|
||||||
.textFieldStyle(.roundedBorder)
|
|
||||||
.frame(width: fieldWidth)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.top, 4)
|
|
||||||
}
|
}
|
||||||
|
.padding(8)
|
||||||
Text("Tip: keep Tailscale enabled so your gateway stays reachable.")
|
.background(
|
||||||
.font(.footnote)
|
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||||
.foregroundStyle(.secondary)
|
.fill(Color(NSColor.controlBackgroundColor)))
|
||||||
.lineLimit(1)
|
}
|
||||||
|
|
||||||
|
Button(self.showAdvancedConnection ? "Hide Advanced" : "Advanced…") {
|
||||||
|
withAnimation(.spring(response: 0.25, dampingFraction: 0.9)) {
|
||||||
|
self.showAdvancedConnection.toggle()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.buttonStyle(.link)
|
||||||
|
|
||||||
|
if self.showAdvancedConnection {
|
||||||
|
let labelWidth: CGFloat = 90
|
||||||
|
let fieldWidth: CGFloat = 320
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
HStack(alignment: .center, spacing: 12) {
|
||||||
|
Text("SSH target")
|
||||||
|
.font(.callout.weight(.semibold))
|
||||||
|
.frame(width: labelWidth, alignment: .leading)
|
||||||
|
TextField("user@host[:port]", text: self.$state.remoteTarget)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.frame(width: fieldWidth)
|
||||||
|
}
|
||||||
|
|
||||||
|
LabeledContent("Identity file") {
|
||||||
|
TextField("/Users/you/.ssh/id_ed25519", text: self.$state.remoteIdentity)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.frame(width: fieldWidth)
|
||||||
|
}
|
||||||
|
|
||||||
|
LabeledContent("Project root") {
|
||||||
|
TextField("/home/you/Projects/clawdis", text: self.$state.remoteProjectRoot)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.frame(width: fieldWidth)
|
||||||
|
}
|
||||||
|
|
||||||
|
Text("Tip: keep Tailscale enabled so your gateway stays reachable.")
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
.transition(.opacity.combined(with: .move(edge: .top)))
|
||||||
}
|
}
|
||||||
.transition(.opacity.combined(with: .move(edge: .top)))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func selectLocalGateway() {
|
||||||
|
self.state.connectionMode = .local
|
||||||
|
self.preferredGatewayID = nil
|
||||||
|
BridgeDiscoveryPreferences.setPreferredStableID(nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func selectRemoteGateway(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) {
|
||||||
|
self.preferredGatewayID = gateway.stableID
|
||||||
|
BridgeDiscoveryPreferences.setPreferredStableID(gateway.stableID)
|
||||||
|
|
||||||
|
if let host = gateway.tailnetDns ?? gateway.lanHost {
|
||||||
|
let user = NSUserName()
|
||||||
|
var target = "\(user)@\(host)"
|
||||||
|
if gateway.sshPort != 22 {
|
||||||
|
target += ":\(gateway.sshPort)"
|
||||||
|
}
|
||||||
|
self.state.remoteTarget = target
|
||||||
|
}
|
||||||
|
|
||||||
|
self.state.connectionMode = .remote
|
||||||
|
MacNodeModeCoordinator.shared.setPreferredBridgeStableID(gateway.stableID)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func gatewaySubtitle(for gateway: GatewayDiscoveryModel.DiscoveredGateway) -> String? {
|
||||||
|
if let host = gateway.tailnetDns ?? gateway.lanHost {
|
||||||
|
let portSuffix = gateway.sshPort != 22 ? " · ssh \(gateway.sshPort)" : ""
|
||||||
|
return "\(host)\(portSuffix)"
|
||||||
|
}
|
||||||
|
return "Bridge pairing only"
|
||||||
|
}
|
||||||
|
|
||||||
|
private func isSelectedGateway(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) -> Bool {
|
||||||
|
guard self.state.connectionMode == .remote else { return false }
|
||||||
|
let preferred = self.preferredGatewayID ?? BridgeDiscoveryPreferences.preferredStableID()
|
||||||
|
return preferred == gateway.stableID
|
||||||
|
}
|
||||||
|
|
||||||
|
private func connectionChoiceButton(
|
||||||
|
title: String,
|
||||||
|
subtitle: String?,
|
||||||
|
selected: Bool,
|
||||||
|
action: @escaping () -> Void) -> some View
|
||||||
|
{
|
||||||
|
Button {
|
||||||
|
withAnimation(.spring(response: 0.25, dampingFraction: 0.9)) {
|
||||||
|
action()
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
HStack(alignment: .center, spacing: 10) {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(title)
|
||||||
|
.font(.callout.weight(.semibold))
|
||||||
|
.lineLimit(1)
|
||||||
|
.truncationMode(.tail)
|
||||||
|
if let subtitle {
|
||||||
|
Text(subtitle)
|
||||||
|
.font(.caption.monospaced())
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.lineLimit(1)
|
||||||
|
.truncationMode(.middle)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
if selected {
|
||||||
|
Image(systemName: "checkmark.circle.fill")
|
||||||
|
.foregroundStyle(Color.accentColor)
|
||||||
|
} else {
|
||||||
|
Image(systemName: "arrow.right.circle")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 10)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||||
|
.fill(selected ? Color.accentColor.opacity(0.12) : Color.clear))
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||||
|
.strokeBorder(
|
||||||
|
selected ? Color.accentColor.opacity(0.45) : Color.clear,
|
||||||
|
lineWidth: 1))
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
|
||||||
private func anthropicAuthPage() -> some View {
|
private func anthropicAuthPage() -> some View {
|
||||||
self.onboardingPage {
|
self.onboardingPage {
|
||||||
Text("Connect Claude")
|
Text("Connect Claude")
|
||||||
|
|
@ -705,18 +828,6 @@ struct OnboardingView: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// swiftlint:disable:next inclusive_language
|
|
||||||
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 permissionsPage() -> some View {
|
private func permissionsPage() -> some View {
|
||||||
self.onboardingPage {
|
self.onboardingPage {
|
||||||
Text("Grant permissions")
|
Text("Grant permissions")
|
||||||
|
|
@ -1165,13 +1276,13 @@ struct OnboardingView: View {
|
||||||
|
|
||||||
private func updateDiscoveryMonitoring(for pageIndex: Int) {
|
private func updateDiscoveryMonitoring(for pageIndex: Int) {
|
||||||
let isConnectionPage = pageIndex == self.connectionPageIndex
|
let isConnectionPage = pageIndex == self.connectionPageIndex
|
||||||
let shouldMonitor = isConnectionPage && self.state.connectionMode == .remote
|
let shouldMonitor = isConnectionPage
|
||||||
if shouldMonitor, !self.monitoringDiscovery {
|
if shouldMonitor, !self.monitoringDiscovery {
|
||||||
self.monitoringDiscovery = true
|
self.monitoringDiscovery = true
|
||||||
self.masterDiscovery.start()
|
self.gatewayDiscovery.start()
|
||||||
} else if !shouldMonitor, self.monitoringDiscovery {
|
} else if !shouldMonitor, self.monitoringDiscovery {
|
||||||
self.monitoringDiscovery = false
|
self.monitoringDiscovery = false
|
||||||
self.masterDiscovery.stop()
|
self.gatewayDiscovery.stop()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1190,7 +1301,7 @@ struct OnboardingView: View {
|
||||||
private func stopDiscovery() {
|
private func stopDiscovery() {
|
||||||
guard self.monitoringDiscovery else { return }
|
guard self.monitoringDiscovery else { return }
|
||||||
self.monitoringDiscovery = false
|
self.monitoringDiscovery = false
|
||||||
self.masterDiscovery.stop()
|
self.gatewayDiscovery.stop()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func updateAuthMonitoring(for pageIndex: Int) {
|
private func updateAuthMonitoring(for pageIndex: Int) {
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ read_when:
|
||||||
---
|
---
|
||||||
# Bonjour / mDNS discovery
|
# Bonjour / mDNS discovery
|
||||||
|
|
||||||
Clawdis uses Bonjour (mDNS / DNS-SD) as a **LAN-only convenience** to discover a running Gateway and (optionally) its bridge transport. It is best-effort and does **not** replace SSH or Tailnet-based connectivity.
|
Clawdis uses Bonjour (mDNS / DNS-SD) as a **LAN-only convenience** to discover a running Gateway bridge transport. It is best-effort and does **not** replace SSH or Tailnet-based connectivity.
|
||||||
|
|
||||||
## Wide-Area Bonjour (Unicast DNS-SD) over Tailscale
|
## Wide-Area Bonjour (Unicast DNS-SD) over Tailscale
|
||||||
|
|
||||||
|
|
@ -81,14 +81,13 @@ Only the **Node Gateway** (`clawd` / `clawdis gateway`) advertises Bonjour beaco
|
||||||
|
|
||||||
## Service types
|
## Service types
|
||||||
|
|
||||||
- `_clawdis-master._tcp` — “master gateway” discovery beacon (primarily for macOS remote-control UX).
|
- `_clawdis-bridge._tcp` — bridge transport beacon (used by macOS/iOS/Android nodes).
|
||||||
- `_clawdis-bridge._tcp` — bridge transport beacon (used by iOS/Android nodes).
|
|
||||||
|
|
||||||
## TXT keys (non-secret hints)
|
## TXT keys (non-secret hints)
|
||||||
|
|
||||||
The Gateway advertises small non-secret hints to make UI flows convenient:
|
The Gateway advertises small non-secret hints to make UI flows convenient:
|
||||||
|
|
||||||
- `role=master`
|
- `role=gateway`
|
||||||
- `lanHost=<hostname>.local`
|
- `lanHost=<hostname>.local`
|
||||||
- `sshPort=<port>` (defaults to 22 when not overridden)
|
- `sshPort=<port>` (defaults to 22 when not overridden)
|
||||||
- `gatewayPort=<port>` (informational; the Gateway WS is typically loopback-only)
|
- `gatewayPort=<port>` (informational; the Gateway WS is typically loopback-only)
|
||||||
|
|
@ -101,10 +100,8 @@ The Gateway advertises small non-secret hints to make UI flows convenient:
|
||||||
Useful built-in tools:
|
Useful built-in tools:
|
||||||
|
|
||||||
- Browse instances:
|
- Browse instances:
|
||||||
- `dns-sd -B _clawdis-master._tcp local.`
|
|
||||||
- `dns-sd -B _clawdis-bridge._tcp local.`
|
- `dns-sd -B _clawdis-bridge._tcp local.`
|
||||||
- Resolve one instance (replace `<instance>`):
|
- Resolve one instance (replace `<instance>`):
|
||||||
- `dns-sd -L "<instance>" _clawdis-master._tcp local.`
|
|
||||||
- `dns-sd -L "<instance>" _clawdis-bridge._tcp local.`
|
- `dns-sd -L "<instance>" _clawdis-bridge._tcp local.`
|
||||||
|
|
||||||
If browsing shows instances but resolving fails, you’re usually hitting a LAN policy / multicast issue.
|
If browsing shows instances but resolving fails, you’re usually hitting a LAN policy / multicast issue.
|
||||||
|
|
@ -151,8 +148,8 @@ Bonjour/DNS-SD often escapes bytes in service instance names as decimal `\\DDD`
|
||||||
- `CLAWDIS_BRIDGE_ENABLED=0` disables the bridge listener (and therefore the bridge beacon).
|
- `CLAWDIS_BRIDGE_ENABLED=0` disables the bridge listener (and therefore the bridge beacon).
|
||||||
- `bridge.bind` / `bridge.port` in `~/.clawdis/clawdis.json` control bridge bind/port (preferred).
|
- `bridge.bind` / `bridge.port` in `~/.clawdis/clawdis.json` control bridge bind/port (preferred).
|
||||||
- `CLAWDIS_BRIDGE_HOST` / `CLAWDIS_BRIDGE_PORT` still work as a back-compat override when `bridge.bind` / `bridge.port` are not set.
|
- `CLAWDIS_BRIDGE_HOST` / `CLAWDIS_BRIDGE_PORT` still work as a back-compat override when `bridge.bind` / `bridge.port` are not set.
|
||||||
- `CLAWDIS_SSH_PORT` overrides the SSH port advertised in `_clawdis-master._tcp`.
|
- `CLAWDIS_SSH_PORT` overrides the SSH port advertised in `_clawdis-bridge._tcp`.
|
||||||
- `CLAWDIS_TAILNET_DNS` publishes a `tailnetDns` hint (MagicDNS) in `_clawdis-master._tcp` (wide-area discovery uses `clawdis.internal.` automatically when enabled).
|
- `CLAWDIS_TAILNET_DNS` publishes a `tailnetDns` hint (MagicDNS) in `_clawdis-bridge._tcp` (wide-area discovery uses `clawdis.internal.` automatically when enabled).
|
||||||
|
|
||||||
## Related docs
|
## Related docs
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
---
|
---
|
||||||
summary: "Node discovery and transports (Bonjour, Tailscale, SSH) for finding the master gateway"
|
summary: "Node discovery and transports (Bonjour, Tailscale, SSH) for finding the gateway"
|
||||||
read_when:
|
read_when:
|
||||||
- Implementing or changing Bonjour discovery/advertising
|
- Implementing or changing Bonjour discovery/advertising
|
||||||
- Adjusting remote connection modes (direct vs SSH)
|
- Adjusting remote connection modes (direct vs SSH)
|
||||||
|
|
@ -9,14 +9,14 @@ read_when:
|
||||||
|
|
||||||
Clawdis has two distinct problems that look similar on the surface:
|
Clawdis has two distinct problems that look similar on the surface:
|
||||||
|
|
||||||
1) **Operator remote control**: the macOS menu bar app controlling a “master” gateway running elsewhere.
|
1) **Operator remote control**: the macOS menu bar app controlling a gateway running elsewhere.
|
||||||
2) **Node pairing**: iOS/Android (and future nodes) finding a gateway and pairing securely.
|
2) **Node pairing**: iOS/Android (and future nodes) finding a gateway and pairing securely.
|
||||||
|
|
||||||
The design goal is to keep all network discovery/advertising in the **Node Gateway** (`clawd` / `clawdis gateway`) and keep clients (mac app, iOS) as consumers.
|
The design goal is to keep all network discovery/advertising in the **Node Gateway** (`clawd` / `clawdis gateway`) and keep clients (mac app, iOS) as consumers.
|
||||||
|
|
||||||
## Terms
|
## Terms
|
||||||
|
|
||||||
- **Master gateway**: the single, long-running gateway process that owns state (sessions, pairing, node registry) and runs providers.
|
- **Gateway**: the single, long-running gateway process that owns state (sessions, pairing, node registry) and runs providers.
|
||||||
- **Gateway WS (loopback)**: the existing gateway WebSocket control endpoint on `127.0.0.1:18789`.
|
- **Gateway WS (loopback)**: the existing gateway WebSocket control endpoint on `127.0.0.1:18789`.
|
||||||
- **Bridge (direct transport)**: a LAN/tailnet-facing endpoint owned by the gateway that allows authenticated clients/nodes to call a scoped subset of gateway methods. The bridge exists so the gateway can remain loopback-only.
|
- **Bridge (direct transport)**: a LAN/tailnet-facing endpoint owned by the gateway that allows authenticated clients/nodes to call a scoped subset of gateway methods. The bridge exists so the gateway can remain loopback-only.
|
||||||
- **SSH transport (fallback)**: remote control by forwarding `127.0.0.1:18789` over SSH.
|
- **SSH transport (fallback)**: remote control by forwarding `127.0.0.1:18789` over SSH.
|
||||||
|
|
@ -32,25 +32,24 @@ The design goal is to keep all network discovery/advertising in the **Node Gatew
|
||||||
- survives multicast/mDNS issues
|
- survives multicast/mDNS issues
|
||||||
- requires no new inbound ports besides SSH
|
- requires no new inbound ports besides SSH
|
||||||
|
|
||||||
## Discovery inputs (how clients learn where the master is)
|
## Discovery inputs (how clients learn where the gateway is)
|
||||||
|
|
||||||
### 1) Bonjour / mDNS (LAN only)
|
### 1) Bonjour / mDNS (LAN only)
|
||||||
|
|
||||||
Bonjour is best-effort and does not cross networks. It is only used for “same LAN” convenience.
|
Bonjour is best-effort and does not cross networks. It is only used for “same LAN” convenience.
|
||||||
|
|
||||||
Target direction:
|
Target direction:
|
||||||
- The **gateway** advertises itself (and/or its bridge) via Bonjour.
|
- The **gateway** advertises its bridge via Bonjour.
|
||||||
- Clients browse and show a “pick a master” list, then store the chosen endpoint.
|
- Clients browse and show a “pick a gateway” list, then store the chosen endpoint.
|
||||||
|
|
||||||
Troubleshooting and beacon details: `docs/bonjour.md`.
|
Troubleshooting and beacon details: `docs/bonjour.md`.
|
||||||
|
|
||||||
#### Current implementation
|
#### Current implementation
|
||||||
|
|
||||||
- Service types:
|
- Service types:
|
||||||
- `_clawdis-master._tcp` (gateway “master” beacon)
|
- `_clawdis-bridge._tcp` (bridge transport beacon)
|
||||||
- `_clawdis-bridge._tcp` (optional; bridge transport beacon)
|
|
||||||
- TXT keys (non-secret):
|
- TXT keys (non-secret):
|
||||||
- `role=master`
|
- `role=gateway`
|
||||||
- `lanHost=<hostname>.local`
|
- `lanHost=<hostname>.local`
|
||||||
- `sshPort=22` (or whatever is advertised)
|
- `sshPort=22` (or whatever is advertised)
|
||||||
- `gatewayPort=18789` (loopback WS port; informational)
|
- `gatewayPort=18789` (loopback WS port; informational)
|
||||||
|
|
@ -63,8 +62,8 @@ Disable/override:
|
||||||
- `CLAWDIS_BRIDGE_ENABLED=0` disables the bridge listener.
|
- `CLAWDIS_BRIDGE_ENABLED=0` disables the bridge listener.
|
||||||
- `bridge.bind` / `bridge.port` in `~/.clawdis/clawdis.json` control bridge bind/port (preferred).
|
- `bridge.bind` / `bridge.port` in `~/.clawdis/clawdis.json` control bridge bind/port (preferred).
|
||||||
- `CLAWDIS_BRIDGE_HOST` / `CLAWDIS_BRIDGE_PORT` still work as a back-compat override when `bridge.bind` / `bridge.port` are not set.
|
- `CLAWDIS_BRIDGE_HOST` / `CLAWDIS_BRIDGE_PORT` still work as a back-compat override when `bridge.bind` / `bridge.port` are not set.
|
||||||
- `CLAWDIS_SSH_PORT` overrides the SSH port advertised in the master beacon (defaults to 22).
|
- `CLAWDIS_SSH_PORT` overrides the SSH port advertised in the bridge beacon (defaults to 22).
|
||||||
- `CLAWDIS_TAILNET_DNS` publishes a `tailnetDns` hint (MagicDNS) in the master beacon.
|
- `CLAWDIS_TAILNET_DNS` publishes a `tailnetDns` hint (MagicDNS) in the bridge beacon.
|
||||||
|
|
||||||
### 2) Tailnet (cross-network)
|
### 2) Tailnet (cross-network)
|
||||||
|
|
||||||
|
|
@ -84,7 +83,7 @@ See `docs/remote.md`.
|
||||||
Recommended client behavior:
|
Recommended client behavior:
|
||||||
|
|
||||||
1) If a paired direct endpoint is configured and reachable, use it.
|
1) If a paired direct endpoint is configured and reachable, use it.
|
||||||
2) Else, if Bonjour finds a master on LAN, offer a one-tap “Use this master” choice and save it as the direct endpoint.
|
2) Else, if Bonjour finds a gateway on LAN, offer a one-tap “Use this gateway” choice and save it as the direct endpoint.
|
||||||
3) Else, if a tailnet DNS/IP is configured, try direct.
|
3) Else, if a tailnet DNS/IP is configured, try direct.
|
||||||
4) Else, fall back to SSH.
|
4) Else, fall back to SSH.
|
||||||
|
|
||||||
|
|
@ -105,7 +104,7 @@ The gateway is the source of truth for node/client admission.
|
||||||
- owns pairing storage + decisions
|
- owns pairing storage + decisions
|
||||||
- runs the bridge listener (direct transport)
|
- runs the bridge listener (direct transport)
|
||||||
- macOS app:
|
- macOS app:
|
||||||
- UI for picking a master, showing pairing prompts, and troubleshooting
|
- UI for picking a gateway, showing pairing prompts, and troubleshooting
|
||||||
- SSH tunneling only for the fallback path
|
- SSH tunneling only for the fallback path
|
||||||
- iOS node:
|
- iOS node:
|
||||||
- browses Bonjour (LAN) as a convenience only
|
- browses Bonjour (LAN) as a convenience only
|
||||||
|
|
|
||||||
|
|
@ -99,37 +99,32 @@ describe("gateway bonjour advertiser", () => {
|
||||||
tailnetDns: "host.tailnet.ts.net",
|
tailnetDns: "host.tailnet.ts.net",
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(createService).toHaveBeenCalledTimes(2);
|
expect(createService).toHaveBeenCalledTimes(1);
|
||||||
const [masterCall, bridgeCall] = createService.mock.calls as Array<
|
const [bridgeCall] = createService.mock.calls as Array<
|
||||||
[Record<string, unknown>]
|
[Record<string, unknown>]
|
||||||
>;
|
>;
|
||||||
expect(masterCall?.[0]?.type).toBe("clawdis-master");
|
|
||||||
expect(masterCall?.[0]?.port).toBe(2222);
|
|
||||||
expect(masterCall?.[0]?.domain).toBe("local");
|
|
||||||
expect(masterCall?.[0]?.hostname).toBe("test-host");
|
|
||||||
expect((masterCall?.[0]?.txt as Record<string, string>)?.lanHost).toBe(
|
|
||||||
"test-host.local",
|
|
||||||
);
|
|
||||||
expect((masterCall?.[0]?.txt as Record<string, string>)?.sshPort).toBe(
|
|
||||||
"2222",
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(bridgeCall?.[0]?.type).toBe("clawdis-bridge");
|
expect(bridgeCall?.[0]?.type).toBe("clawdis-bridge");
|
||||||
expect(bridgeCall?.[0]?.port).toBe(18790);
|
expect(bridgeCall?.[0]?.port).toBe(18790);
|
||||||
expect(bridgeCall?.[0]?.domain).toBe("local");
|
expect(bridgeCall?.[0]?.domain).toBe("local");
|
||||||
expect(bridgeCall?.[0]?.hostname).toBe("test-host");
|
expect(bridgeCall?.[0]?.hostname).toBe("test-host");
|
||||||
|
expect((bridgeCall?.[0]?.txt as Record<string, string>)?.lanHost).toBe(
|
||||||
|
"test-host.local",
|
||||||
|
);
|
||||||
expect((bridgeCall?.[0]?.txt as Record<string, string>)?.bridgePort).toBe(
|
expect((bridgeCall?.[0]?.txt as Record<string, string>)?.bridgePort).toBe(
|
||||||
"18790",
|
"18790",
|
||||||
);
|
);
|
||||||
|
expect((bridgeCall?.[0]?.txt as Record<string, string>)?.sshPort).toBe(
|
||||||
|
"2222",
|
||||||
|
);
|
||||||
expect((bridgeCall?.[0]?.txt as Record<string, string>)?.transport).toBe(
|
expect((bridgeCall?.[0]?.txt as Record<string, string>)?.transport).toBe(
|
||||||
"bridge",
|
"bridge",
|
||||||
);
|
);
|
||||||
|
|
||||||
// We don't await `advertise()`, but it should still be called for each service.
|
// We don't await `advertise()`, but it should still be called for each service.
|
||||||
expect(advertise).toHaveBeenCalledTimes(2);
|
expect(advertise).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
await started.stop();
|
await started.stop();
|
||||||
expect(destroy).toHaveBeenCalledTimes(2);
|
expect(destroy).toHaveBeenCalledTimes(1);
|
||||||
expect(shutdown).toHaveBeenCalledTimes(1);
|
expect(shutdown).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -166,12 +161,10 @@ describe("gateway bonjour advertiser", () => {
|
||||||
bridgePort: 18790,
|
bridgePort: 18790,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 2 services × 2 listeners each
|
// 1 service × 2 listeners
|
||||||
expect(onCalls.map((c) => c.event)).toEqual([
|
expect(onCalls.map((c) => c.event)).toEqual([
|
||||||
"name-change",
|
"name-change",
|
||||||
"hostname-change",
|
"hostname-change",
|
||||||
"name-change",
|
|
||||||
"hostname-change",
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
await started.stop();
|
await started.stop();
|
||||||
|
|
@ -207,7 +200,7 @@ describe("gateway bonjour advertiser", () => {
|
||||||
const started = await startGatewayBonjourAdvertiser({
|
const started = await startGatewayBonjourAdvertiser({
|
||||||
gatewayPort: 18789,
|
gatewayPort: 18789,
|
||||||
sshPort: 2222,
|
sshPort: 2222,
|
||||||
bridgePort: 0,
|
bridgePort: 18790,
|
||||||
});
|
});
|
||||||
|
|
||||||
// initial advertise attempt happens immediately
|
// initial advertise attempt happens immediately
|
||||||
|
|
@ -257,7 +250,7 @@ describe("gateway bonjour advertiser", () => {
|
||||||
const started = await startGatewayBonjourAdvertiser({
|
const started = await startGatewayBonjourAdvertiser({
|
||||||
gatewayPort: 18789,
|
gatewayPort: 18789,
|
||||||
sshPort: 2222,
|
sshPort: 2222,
|
||||||
bridgePort: 0,
|
bridgePort: 18790,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(advertise).toHaveBeenCalledTimes(1);
|
expect(advertise).toHaveBeenCalledTimes(1);
|
||||||
|
|
@ -296,11 +289,11 @@ describe("gateway bonjour advertiser", () => {
|
||||||
bridgePort: 18790,
|
bridgePort: 18790,
|
||||||
});
|
});
|
||||||
|
|
||||||
const [masterCall] = createService.mock.calls as Array<[ServiceCall]>;
|
const [bridgeCall] = createService.mock.calls as Array<[ServiceCall]>;
|
||||||
expect(masterCall?.[0]?.name).toBe("Mac (Clawdis)");
|
expect(bridgeCall?.[0]?.name).toBe("Mac (Clawdis)");
|
||||||
expect(masterCall?.[0]?.domain).toBe("local");
|
expect(bridgeCall?.[0]?.domain).toBe("local");
|
||||||
expect(masterCall?.[0]?.hostname).toBe("Mac");
|
expect(bridgeCall?.[0]?.hostname).toBe("Mac");
|
||||||
expect((masterCall?.[0]?.txt as Record<string, string>)?.lanHost).toBe(
|
expect((bridgeCall?.[0]?.txt as Record<string, string>)?.lanHost).toBe(
|
||||||
"Mac.local",
|
"Mac.local",
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -101,7 +101,7 @@ export async function startGatewayBonjourAdvertiser(
|
||||||
const displayName = prettifyInstanceName(instanceName);
|
const displayName = prettifyInstanceName(instanceName);
|
||||||
|
|
||||||
const txtBase: Record<string, string> = {
|
const txtBase: Record<string, string> = {
|
||||||
role: "master",
|
role: "gateway",
|
||||||
gatewayPort: String(opts.gatewayPort),
|
gatewayPort: String(opts.gatewayPort),
|
||||||
lanHost: `${hostname}.local`,
|
lanHost: `${hostname}.local`,
|
||||||
displayName,
|
displayName,
|
||||||
|
|
@ -118,26 +118,7 @@ export async function startGatewayBonjourAdvertiser(
|
||||||
|
|
||||||
const services: Array<{ label: string; svc: BonjourService }> = [];
|
const services: Array<{ label: string; svc: BonjourService }> = [];
|
||||||
|
|
||||||
// Master beacon: used for discovery (auto-fill SSH/direct targets).
|
// Bridge beacon (used by macOS/iOS/Android nodes and the mac app onboarding flow).
|
||||||
// We advertise a TCP service so clients can resolve the host; the port itself is informational.
|
|
||||||
const master = responder.createService({
|
|
||||||
name: safeServiceName(instanceName),
|
|
||||||
type: "clawdis-master",
|
|
||||||
protocol: Protocol.TCP,
|
|
||||||
port: opts.sshPort ?? 22,
|
|
||||||
domain: "local",
|
|
||||||
hostname,
|
|
||||||
txt: {
|
|
||||||
...txtBase,
|
|
||||||
sshPort: String(opts.sshPort ?? 22),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
services.push({
|
|
||||||
label: "master",
|
|
||||||
svc: master as unknown as BonjourService,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Optional bridge beacon (same type used by iOS/Android nodes today).
|
|
||||||
if (typeof opts.bridgePort === "number" && opts.bridgePort > 0) {
|
if (typeof opts.bridgePort === "number" && opts.bridgePort > 0) {
|
||||||
const bridge = responder.createService({
|
const bridge = responder.createService({
|
||||||
name: safeServiceName(instanceName),
|
name: safeServiceName(instanceName),
|
||||||
|
|
@ -148,6 +129,7 @@ export async function startGatewayBonjourAdvertiser(
|
||||||
hostname,
|
hostname,
|
||||||
txt: {
|
txt: {
|
||||||
...txtBase,
|
...txtBase,
|
||||||
|
sshPort: String(opts.sshPort ?? 22),
|
||||||
transport: "bridge",
|
transport: "bridge",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue