diff --git a/apps/macos/Sources/Clawdis/BridgeDiscoveryPreferences.swift b/apps/macos/Sources/Clawdis/BridgeDiscoveryPreferences.swift new file mode 100644 index 000000000..6147bbfd2 --- /dev/null +++ b/apps/macos/Sources/Clawdis/BridgeDiscoveryPreferences.swift @@ -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) + } + } +} diff --git a/apps/macos/Sources/Clawdis/BridgeEndpointID.swift b/apps/macos/Sources/Clawdis/BridgeEndpointID.swift new file mode 100644 index 000000000..189e6775a --- /dev/null +++ b/apps/macos/Sources/Clawdis/BridgeEndpointID.swift @@ -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) + } +} diff --git a/apps/macos/Sources/Clawdis/MasterDiscoveryMenu.swift b/apps/macos/Sources/Clawdis/GatewayDiscoveryMenu.swift similarity index 75% rename from apps/macos/Sources/Clawdis/MasterDiscoveryMenu.swift rename to apps/macos/Sources/Clawdis/GatewayDiscoveryMenu.swift index 3247556ff..f486d413e 100644 --- a/apps/macos/Sources/Clawdis/MasterDiscoveryMenu.swift +++ b/apps/macos/Sources/Clawdis/GatewayDiscoveryMenu.swift @@ -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") } } diff --git a/apps/macos/Sources/Clawdis/GatewayDiscoveryModel.swift b/apps/macos/Sources/Clawdis/GatewayDiscoveryModel.swift new file mode 100644 index 000000000..173a6f56e --- /dev/null +++ b/apps/macos/Sources/Clawdis/GatewayDiscoveryModel.swift @@ -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) + } +} diff --git a/apps/macos/Sources/Clawdis/GeneralSettings.swift b/apps/macos/Sources/Clawdis/GeneralSettings.swift index 9b0a5aee0..a1fbc56e1 100644 --- a/apps/macos/Sources/Clawdis/GeneralSettings.swift +++ b/apps/macos/Sources/Clawdis/GeneralSettings.swift @@ -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 } diff --git a/apps/macos/Sources/Clawdis/MasterDiscoveryModel.swift b/apps/macos/Sources/Clawdis/MasterDiscoveryModel.swift deleted file mode 100644 index 128468d35..000000000 --- a/apps/macos/Sources/Clawdis/MasterDiscoveryModel.swift +++ /dev/null @@ -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) - } -} diff --git a/apps/macos/Sources/Clawdis/NodeMode/MacNodeModeCoordinator.swift b/apps/macos/Sources/Clawdis/NodeMode/MacNodeModeCoordinator.swift index c95883fd9..cd2f02d98 100644 --- a/apps/macos/Sources/Clawdis/NodeMode/MacNodeModeCoordinator.swift +++ b/apps/macos/Sources/Clawdis/NodeMode/MacNodeModeCoordinator.swift @@ -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) } diff --git a/apps/macos/Sources/Clawdis/NodePairingApprovalPrompter.swift b/apps/macos/Sources/Clawdis/NodePairingApprovalPrompter.swift index 6ec06468e..d9f44752b 100644 --- a/apps/macos/Sources/Clawdis/NodePairingApprovalPrompter.swift +++ b/apps/macos/Sources/Clawdis/NodePairingApprovalPrompter.swift @@ -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) } diff --git a/apps/macos/Sources/Clawdis/Onboarding.swift b/apps/macos/Sources/Clawdis/Onboarding.swift index bf7ca6780..ead25547f 100644 --- a/apps/macos/Sources/Clawdis/Onboarding.swift +++ b/apps/macos/Sources/Clawdis/Onboarding.swift @@ -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) { diff --git a/docs/bonjour.md b/docs/bonjour.md index ca64a61d1..43b458c97 100644 --- a/docs/bonjour.md +++ b/docs/bonjour.md @@ -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=.local` - `sshPort=` (defaults to 22 when not overridden) - `gatewayPort=` (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 ``): - - `dns-sd -L "" _clawdis-master._tcp local.` - `dns-sd -L "" _clawdis-bridge._tcp local.` 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). - `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 diff --git a/docs/discovery.md b/docs/discovery.md index 6cf192fda..b1c00a872 100644 --- a/docs/discovery.md +++ b/docs/discovery.md @@ -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=.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 diff --git a/src/infra/bonjour.test.ts b/src/infra/bonjour.test.ts index 3b604c78f..cda234706 100644 --- a/src/infra/bonjour.test.ts +++ b/src/infra/bonjour.test.ts @@ -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] >; - 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)?.lanHost).toBe( - "test-host.local", - ); - expect((masterCall?.[0]?.txt as Record)?.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)?.lanHost).toBe( + "test-host.local", + ); expect((bridgeCall?.[0]?.txt as Record)?.bridgePort).toBe( "18790", ); + expect((bridgeCall?.[0]?.txt as Record)?.sshPort).toBe( + "2222", + ); expect((bridgeCall?.[0]?.txt as Record)?.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)?.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)?.lanHost).toBe( "Mac.local", ); diff --git a/src/infra/bonjour.ts b/src/infra/bonjour.ts index 822f4899c..818220695 100644 --- a/src/infra/bonjour.ts +++ b/src/infra/bonjour.ts @@ -101,7 +101,7 @@ export async function startGatewayBonjourAdvertiser( const displayName = prettifyInstanceName(instanceName); const txtBase: Record = { - 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", }, });