refactor: unify gateway discovery on bridge

main
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 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")
} }
} }

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

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

View File

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

View File

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

View File

@ -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, youre usually hitting a LAN policy / multicast issue. 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). - `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

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: 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

View File

@ -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",
); );

View File

@ -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",
}, },
}); });