refactor: unify gateway discovery on bridge

This commit is contained in:
Peter Steinberger
2025-12-19 23:12:52 +01:00
parent bcced90f11
commit bc2a66da32
13 changed files with 489 additions and 286 deletions

View File

@@ -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)
}
}
}

View File

@@ -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)
}
}

View File

@@ -1,12 +1,10 @@
import SwiftUI
// master is part of the discovery protocol naming; keep UI components consistent.
// swiftlint:disable:next inclusive_language
struct MasterDiscoveryInlineList: View {
var discovery: MasterDiscoveryModel
struct GatewayDiscoveryInlineList: View {
var discovery: GatewayDiscoveryModel
var currentTarget: String?
var onSelect: (MasterDiscoveryModel.DiscoveredMaster) -> Void
@State private var hoveredGatewayID: MasterDiscoveryModel.DiscoveredMaster.ID?
var onSelect: (GatewayDiscoveryModel.DiscoveredGateway) -> Void
@State private var hoveredGatewayID: GatewayDiscoveryModel.DiscoveredGateway.ID?
var body: some View {
VStack(alignment: .leading, spacing: 6) {
@@ -19,16 +17,16 @@ struct MasterDiscoveryInlineList: View {
.foregroundStyle(.secondary)
}
if self.discovery.masters.isEmpty {
if self.discovery.gateways.isEmpty {
Text("No gateways found yet.")
.font(.caption)
.foregroundStyle(.secondary)
} else {
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 selected = target != nil && self.currentTarget?
.trimmingCharacters(in: .whitespacesAndNewlines) == target
let selected = (target != nil && self.currentTarget?
.trimmingCharacters(in: .whitespacesAndNewlines) == target)
Button {
withAnimation(.spring(response: 0.25, dampingFraction: 0.9)) {
@@ -41,13 +39,11 @@ struct MasterDiscoveryInlineList: View {
.font(.callout.weight(.semibold))
.lineLimit(1)
.truncationMode(.tail)
if let target {
Text(target)
.font(.caption.monospaced())
.foregroundStyle(.secondary)
.lineLimit(1)
.truncationMode(.middle)
}
Text(target ?? "Bridge pairing only")
.font(.caption.monospaced())
.foregroundStyle(.secondary)
.lineLimit(1)
.truncationMode(.middle)
}
Spacer(minLength: 0)
if selected {
@@ -89,7 +85,7 @@ struct MasterDiscoveryInlineList: View {
.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
guard let host else { return nil }
let user = NSUserName()
@@ -107,24 +103,23 @@ struct MasterDiscoveryInlineList: View {
}
}
// swiftlint:disable:next inclusive_language
struct MasterDiscoveryMenu: View {
var discovery: MasterDiscoveryModel
var onSelect: (MasterDiscoveryModel.DiscoveredMaster) -> Void
struct GatewayDiscoveryMenu: View {
var discovery: GatewayDiscoveryModel
var onSelect: (GatewayDiscoveryModel.DiscoveredGateway) -> Void
var body: some View {
Menu {
if self.discovery.masters.isEmpty {
if self.discovery.gateways.isEmpty {
Button(self.discovery.statusText) {}
.disabled(true)
} else {
ForEach(self.discovery.masters) { gateway in
ForEach(self.discovery.gateways) { gateway in
Button(gateway.displayName) { self.onSelect(gateway) }
}
}
} label: {
Image(systemName: "dot.radiowaves.left.and.right")
}
.help("Discover Clawdis masters on your LAN")
.help("Discover Clawdis gateways on your LAN")
}
}

View File

@@ -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)
}
}

View File

@@ -8,7 +8,7 @@ struct GeneralSettings: View {
private let healthStore = HealthStore.shared
private let gatewayManager = GatewayProcessManager.shared
// swiftlint:disable:next inclusive_language
@State private var masterDiscovery = MasterDiscoveryModel()
@State private var gatewayDiscovery = GatewayDiscoveryModel()
@State private var isInstallingCLI = false
@State private var cliStatus: String?
@State private var cliInstalled = false
@@ -152,11 +152,11 @@ struct GeneralSettings: View {
.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
MasterDiscoveryInlineList(
discovery: self.masterDiscovery,
GatewayDiscoveryInlineList(
discovery: self.gatewayDiscovery,
currentTarget: self.state.remoteTarget)
{ master in
self.applyDiscoveredMaster(master)
{ gateway in
self.applyDiscoveredGateway(gateway)
}
.padding(.leading, 58)
@@ -210,8 +210,8 @@ struct GeneralSettings: View {
.lineLimit(1)
}
.transition(.opacity)
.onAppear { self.masterDiscovery.start() }
.onDisappear { self.masterDiscovery.stop() }
.onAppear { self.gatewayDiscovery.start() }
.onDisappear { self.gatewayDiscovery.stop() }
}
private var controlStatusLine: String {
@@ -599,13 +599,15 @@ extension GeneralSettings {
}
// swiftlint:disable:next inclusive_language
private func applyDiscoveredMaster(_ master: MasterDiscoveryModel.DiscoveredMaster) {
let host = master.tailnetDns ?? master.lanHost
private func applyDiscoveredGateway(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) {
MacNodeModeCoordinator.shared.setPreferredBridgeStableID(gateway.stableID)
let host = gateway.tailnetDns ?? gateway.lanHost
guard let host else { return }
let user = NSUserName()
var target = "\(user)@\(host)"
if master.sshPort != 22 {
target += ":\(master.sshPort)"
if gateway.sshPort != 22 {
target += ":\(gateway.sshPort)"
}
self.state.remoteTarget = target
}

View File

@@ -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)
}
}

View File

@@ -28,6 +28,11 @@ final class MacNodeModeCoordinator {
self.tunnel = nil
}
func setPreferredBridgeStableID(_ stableID: String?) {
BridgeDiscoveryPreferences.setPreferredStableID(stableID)
Task { await self.session.disconnect() }
}
private func run() async {
var retryDelay: UInt64 = 1_000_000_000
var lastCameraEnabled: Bool? = nil
@@ -132,10 +137,13 @@ final class MacNodeModeCoordinator {
guard text.contains("NOT_PAIRED") || text.contains("UNAUTHORIZED") else { return false }
do {
let shouldSilent = await MainActor.run {
AppStateStore.shared.connectionMode == .remote
}
let token = try await MacNodeBridgePairingClient().pairAndHello(
endpoint: endpoint,
hello: self.makeHello(),
silent: true,
silent: shouldSilent,
onStatus: { [weak self] status in
self?.logger.info("mac node pairing: \(status, privacy: .public)")
})
@@ -209,6 +217,19 @@ final class MacNodeModeCoordinator {
for: .bonjour(type: ClawdisBonjour.bridgeServiceType, domain: domain),
using: params)
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 } }) {
state.finish(result.endpoint)
}

View File

@@ -517,20 +517,22 @@ final class NodePairingApprovalPrompter {
return SSHTarget(host: host, port: port)
}
let model = MasterDiscoveryModel()
let model = GatewayDiscoveryModel()
model.start()
defer { model.stop() }
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)
}
guard let master = model.masters.first else { return nil }
let host = (master.tailnetDns?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty ??
master.lanHost?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty)
let preferred = BridgeDiscoveryPreferences.preferredStableID()
let gateway = model.gateways.first { $0.stableID == preferred } ?? model.gateways.first
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 }
let port = master.sshPort > 0 ? master.sshPort : 22
let port = gateway.sshPort > 0 ? gateway.sshPort : 22
return SSHTarget(host: host, port: port)
}

View File

@@ -75,8 +75,10 @@ struct OnboardingView: View {
@State private var gatewayStatus: GatewayEnvironmentStatus = .checking
@State private var gatewayInstalling = false
@State private var gatewayInstallMessage: String?
@State private var showAdvancedConnection = false
@State private var preferredGatewayID: String?
// swiftlint:disable:next inclusive_language
@State private var masterDiscovery: MasterDiscoveryModel
@State private var gatewayDiscovery: GatewayDiscoveryModel
@Bindable private var state: AppState
private var permissionMonitor: PermissionMonitor
@@ -107,11 +109,11 @@ struct OnboardingView: View {
init(
state: AppState = AppStateStore.shared,
permissionMonitor: PermissionMonitor = .shared,
discoveryModel: MasterDiscoveryModel = MasterDiscoveryModel())
discoveryModel: GatewayDiscoveryModel = GatewayDiscoveryModel())
{
self.state = state
self.permissionMonitor = permissionMonitor
self._masterDiscovery = State(initialValue: discoveryModel)
self._gatewayDiscovery = State(initialValue: discoveryModel)
}
var body: some View {
@@ -165,6 +167,7 @@ struct OnboardingView: View {
self.loadWorkspaceDefaults()
self.refreshAnthropicOAuthStatus()
self.loadIdentityDefaults()
self.preferredGatewayID = BridgeDiscoveryPreferences.preferredStableID()
}
}
@@ -260,11 +263,11 @@ struct OnboardingView: View {
private func connectionPage() -> some View {
self.onboardingPage {
Text("Where Clawdis runs")
Text("Choose your Gateway")
.font(.largeTitle.weight(.semibold))
Text(
"Clawdis uses a single Gateway (“master”) that stays running. Run it on this Mac, " +
"or connect to one on another Mac over SSH/Tailscale.")
"Clawdis uses a single Gateway that stays running. Pick this Mac, " +
"or connect to a discovered Gateway nearby.")
.font(.body)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
@@ -273,64 +276,184 @@ struct OnboardingView: View {
.fixedSize(horizontal: false, vertical: true)
self.onboardingCard(spacing: 12, padding: 14) {
Picker("Gateway runs", selection: self.$state.connectionMode) {
Text("This Mac").tag(AppState.ConnectionMode.local)
Text("Remote (SSH)").tag(AppState.ConnectionMode.remote)
}
.pickerStyle(.segmented)
.frame(width: 360)
VStack(alignment: .leading, spacing: 10) {
self.connectionChoiceButton(
title: "This Mac",
subtitle: "Run the Gateway locally.",
selected: self.state.connectionMode == .local)
{
self.selectLocalGateway()
}
if self.state.connectionMode == .remote {
let labelWidth: CGFloat = 90
let fieldWidth: CGFloat = 300
let contentLeading: CGFloat = labelWidth + 12
Divider().padding(.vertical, 4)
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)
HStack(spacing: 8) {
Image(systemName: "dot.radiowaves.left.and.right")
.font(.caption)
.foregroundStyle(.secondary)
Text(self.gatewayDiscovery.statusText)
.font(.caption)
.foregroundStyle(.secondary)
if self.gatewayDiscovery.gateways.isEmpty {
ProgressView().controlSize(.small)
}
Spacer(minLength: 0)
}
MasterDiscoveryInlineList(
discovery: self.masterDiscovery,
currentTarget: self.state.remoteTarget)
{ master in
self.applyDiscoveredMaster(master)
}
.frame(width: fieldWidth, alignment: .leading)
.padding(.leading, contentLeading)
DisclosureGroup("Advanced") {
VStack(alignment: .leading, spacing: 8) {
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)
if self.gatewayDiscovery.gateways.isEmpty {
Text("Searching for nearby gateways…")
.font(.caption)
.foregroundStyle(.secondary)
.padding(.leading, 4)
} else {
VStack(alignment: .leading, spacing: 6) {
ForEach(self.gatewayDiscovery.gateways.prefix(6)) { gateway in
self.connectionChoiceButton(
title: gateway.displayName,
subtitle: self.gatewaySubtitle(for: gateway),
selected: self.isSelectedGateway(gateway))
{
self.selectRemoteGateway(gateway)
}
}
.padding(.top, 4)
}
Text("Tip: keep Tailscale enabled so your gateway stays reachable.")
.font(.footnote)
.foregroundStyle(.secondary)
.lineLimit(1)
.padding(8)
.background(
RoundedRectangle(cornerRadius: 10, style: .continuous)
.fill(Color(NSColor.controlBackgroundColor)))
}
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 {
self.onboardingPage {
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 {
self.onboardingPage {
Text("Grant permissions")
@@ -1165,13 +1276,13 @@ struct OnboardingView: View {
private func updateDiscoveryMonitoring(for pageIndex: Int) {
let isConnectionPage = pageIndex == self.connectionPageIndex
let shouldMonitor = isConnectionPage && self.state.connectionMode == .remote
let shouldMonitor = isConnectionPage
if shouldMonitor, !self.monitoringDiscovery {
self.monitoringDiscovery = true
self.masterDiscovery.start()
self.gatewayDiscovery.start()
} else if !shouldMonitor, self.monitoringDiscovery {
self.monitoringDiscovery = false
self.masterDiscovery.stop()
self.gatewayDiscovery.stop()
}
}
@@ -1190,7 +1301,7 @@ struct OnboardingView: View {
private func stopDiscovery() {
guard self.monitoringDiscovery else { return }
self.monitoringDiscovery = false
self.masterDiscovery.stop()
self.gatewayDiscovery.stop()
}
private func updateAuthMonitoring(for pageIndex: Int) {

View File

@@ -6,7 +6,7 @@ read_when:
---
# 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
@@ -81,14 +81,13 @@ Only the **Node Gateway** (`clawd` / `clawdis gateway`) advertises Bonjour beaco
## Service types
- `_clawdis-master._tcp`“master gateway” discovery beacon (primarily for macOS remote-control UX).
- `_clawdis-bridge._tcp` — bridge transport beacon (used by iOS/Android nodes).
- `_clawdis-bridge._tcp`bridge transport beacon (used by macOS/iOS/Android nodes).
## TXT keys (non-secret hints)
The Gateway advertises small non-secret hints to make UI flows convenient:
- `role=master`
- `role=gateway`
- `lanHost=<hostname>.local`
- `sshPort=<port>` (defaults to 22 when not overridden)
- `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:
- Browse instances:
- `dns-sd -B _clawdis-master._tcp local.`
- `dns-sd -B _clawdis-bridge._tcp local.`
- Resolve one instance (replace `<instance>`):
- `dns-sd -L "<instance>" _clawdis-master._tcp local.`
- `dns-sd -L "<instance>" _clawdis-bridge._tcp local.`
If browsing shows instances but resolving fails, youre 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).
- `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_SSH_PORT` overrides the SSH port advertised in `_clawdis-master._tcp`.
- `CLAWDIS_TAILNET_DNS` publishes a `tailnetDns` hint (MagicDNS) in `_clawdis-master._tcp` (wide-area discovery uses `clawdis.internal.` automatically when enabled).
- `CLAWDIS_SSH_PORT` overrides the SSH port advertised in `_clawdis-bridge._tcp`.
- `CLAWDIS_TAILNET_DNS` publishes a `tailnetDns` hint (MagicDNS) in `_clawdis-bridge._tcp` (wide-area discovery uses `clawdis.internal.` automatically when enabled).
## Related docs

View File

@@ -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:
- Implementing or changing Bonjour discovery/advertising
- Adjusting remote connection modes (direct vs SSH)
@@ -9,14 +9,14 @@ read_when:
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.
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
- **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`.
- **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.
@@ -32,25 +32,24 @@ The design goal is to keep all network discovery/advertising in the **Node Gatew
- survives multicast/mDNS issues
- 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)
Bonjour is best-effort and does not cross networks. It is only used for “same LAN” convenience.
Target direction:
- The **gateway** advertises itself (and/or its bridge) via Bonjour.
- Clients browse and show a “pick a master” list, then store the chosen endpoint.
- The **gateway** advertises its bridge via Bonjour.
- Clients browse and show a “pick a gateway” list, then store the chosen endpoint.
Troubleshooting and beacon details: `docs/bonjour.md`.
#### Current implementation
- Service types:
- `_clawdis-master._tcp` (gateway “master” beacon)
- `_clawdis-bridge._tcp` (optional; bridge transport beacon)
- `_clawdis-bridge._tcp` (bridge transport beacon)
- TXT keys (non-secret):
- `role=master`
- `role=gateway`
- `lanHost=<hostname>.local`
- `sshPort=22` (or whatever is advertised)
- `gatewayPort=18789` (loopback WS port; informational)
@@ -63,8 +62,8 @@ Disable/override:
- `CLAWDIS_BRIDGE_ENABLED=0` disables the bridge listener.
- `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_SSH_PORT` overrides the SSH port advertised in the master beacon (defaults to 22).
- `CLAWDIS_TAILNET_DNS` publishes a `tailnetDns` hint (MagicDNS) in the master beacon.
- `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 bridge beacon.
### 2) Tailnet (cross-network)
@@ -84,7 +83,7 @@ See `docs/remote.md`.
Recommended client behavior:
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.
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
- runs the bridge listener (direct transport)
- 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
- iOS node:
- browses Bonjour (LAN) as a convenience only

View File

@@ -99,37 +99,32 @@ describe("gateway bonjour advertiser", () => {
tailnetDns: "host.tailnet.ts.net",
});
expect(createService).toHaveBeenCalledTimes(2);
const [masterCall, bridgeCall] = createService.mock.calls as Array<
expect(createService).toHaveBeenCalledTimes(1);
const [bridgeCall] = createService.mock.calls as Array<
[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]?.port).toBe(18790);
expect(bridgeCall?.[0]?.domain).toBe("local");
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(
"18790",
);
expect((bridgeCall?.[0]?.txt as Record<string, string>)?.sshPort).toBe(
"2222",
);
expect((bridgeCall?.[0]?.txt as Record<string, string>)?.transport).toBe(
"bridge",
);
// 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();
expect(destroy).toHaveBeenCalledTimes(2);
expect(destroy).toHaveBeenCalledTimes(1);
expect(shutdown).toHaveBeenCalledTimes(1);
});
@@ -166,12 +161,10 @@ describe("gateway bonjour advertiser", () => {
bridgePort: 18790,
});
// 2 services × 2 listeners each
// 1 service × 2 listeners
expect(onCalls.map((c) => c.event)).toEqual([
"name-change",
"hostname-change",
"name-change",
"hostname-change",
]);
await started.stop();
@@ -207,7 +200,7 @@ describe("gateway bonjour advertiser", () => {
const started = await startGatewayBonjourAdvertiser({
gatewayPort: 18789,
sshPort: 2222,
bridgePort: 0,
bridgePort: 18790,
});
// initial advertise attempt happens immediately
@@ -257,7 +250,7 @@ describe("gateway bonjour advertiser", () => {
const started = await startGatewayBonjourAdvertiser({
gatewayPort: 18789,
sshPort: 2222,
bridgePort: 0,
bridgePort: 18790,
});
expect(advertise).toHaveBeenCalledTimes(1);
@@ -296,11 +289,11 @@ describe("gateway bonjour advertiser", () => {
bridgePort: 18790,
});
const [masterCall] = createService.mock.calls as Array<[ServiceCall]>;
expect(masterCall?.[0]?.name).toBe("Mac (Clawdis)");
expect(masterCall?.[0]?.domain).toBe("local");
expect(masterCall?.[0]?.hostname).toBe("Mac");
expect((masterCall?.[0]?.txt as Record<string, string>)?.lanHost).toBe(
const [bridgeCall] = createService.mock.calls as Array<[ServiceCall]>;
expect(bridgeCall?.[0]?.name).toBe("Mac (Clawdis)");
expect(bridgeCall?.[0]?.domain).toBe("local");
expect(bridgeCall?.[0]?.hostname).toBe("Mac");
expect((bridgeCall?.[0]?.txt as Record<string, string>)?.lanHost).toBe(
"Mac.local",
);

View File

@@ -101,7 +101,7 @@ export async function startGatewayBonjourAdvertiser(
const displayName = prettifyInstanceName(instanceName);
const txtBase: Record<string, string> = {
role: "master",
role: "gateway",
gatewayPort: String(opts.gatewayPort),
lanHost: `${hostname}.local`,
displayName,
@@ -118,26 +118,7 @@ export async function startGatewayBonjourAdvertiser(
const services: Array<{ label: string; svc: BonjourService }> = [];
// Master beacon: used for discovery (auto-fill SSH/direct targets).
// 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).
// Bridge beacon (used by macOS/iOS/Android nodes and the mac app onboarding flow).
if (typeof opts.bridgePort === "number" && opts.bridgePort > 0) {
const bridge = responder.createService({
name: safeServiceName(instanceName),
@@ -148,6 +129,7 @@ export async function startGatewayBonjourAdvertiser(
hostname,
txt: {
...txtBase,
sshPort: String(opts.sshPort ?? 22),
transport: "bridge",
},
});