refactor: unify gateway discovery on bridge
This commit is contained in:
20
apps/macos/Sources/Clawdis/BridgeDiscoveryPreferences.swift
Normal file
20
apps/macos/Sources/Clawdis/BridgeDiscoveryPreferences.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
26
apps/macos/Sources/Clawdis/BridgeEndpointID.swift
Normal file
26
apps/macos/Sources/Clawdis/BridgeEndpointID.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
164
apps/macos/Sources/Clawdis/GatewayDiscoveryModel.swift
Normal file
164
apps/macos/Sources/Clawdis/GatewayDiscoveryModel.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -1,109 +0,0 @@
|
||||
import Foundation
|
||||
import Network
|
||||
import Observation
|
||||
|
||||
// We use “master” as the on-the-wire service name; keep the model aligned with the protocol/docs.
|
||||
@MainActor
|
||||
@Observable
|
||||
// swiftlint:disable:next inclusive_language
|
||||
final class MasterDiscoveryModel {
|
||||
// swiftlint:disable:next inclusive_language
|
||||
struct DiscoveredMaster: Identifiable, Equatable {
|
||||
var id: String { self.debugID }
|
||||
var displayName: String
|
||||
var lanHost: String?
|
||||
var tailnetDns: String?
|
||||
var sshPort: Int
|
||||
var debugID: String
|
||||
}
|
||||
|
||||
// swiftlint:disable:next inclusive_language
|
||||
var masters: [DiscoveredMaster] = []
|
||||
var statusText: String = "Idle"
|
||||
|
||||
private var browser: NWBrowser?
|
||||
|
||||
private static let serviceType = "_clawdis-master._tcp"
|
||||
private static let serviceDomain = "local."
|
||||
|
||||
func start() {
|
||||
if self.browser != nil { return }
|
||||
|
||||
let params = NWParameters.tcp
|
||||
params.includePeerToPeer = true
|
||||
|
||||
let browser = NWBrowser(for: .bonjour(type: Self.serviceType, domain: Self.serviceDomain), using: params)
|
||||
|
||||
browser.stateUpdateHandler = { [weak self] state in
|
||||
Task { @MainActor in
|
||||
guard let self else { return }
|
||||
switch state {
|
||||
case .setup:
|
||||
self.statusText = "Setup"
|
||||
case .ready:
|
||||
self.statusText = "Searching…"
|
||||
case let .failed(err):
|
||||
self.statusText = "Failed: \(err)"
|
||||
case .cancelled:
|
||||
self.statusText = "Stopped"
|
||||
case let .waiting(err):
|
||||
self.statusText = "Waiting: \(err)"
|
||||
@unknown default:
|
||||
self.statusText = "Unknown"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
browser.browseResultsChangedHandler = { [weak self] results, _ in
|
||||
Task { @MainActor in
|
||||
guard let self else { return }
|
||||
self.masters = results.compactMap { result -> DiscoveredMaster? in
|
||||
guard case let .service(name, _, _, _) = result.endpoint else { return nil }
|
||||
|
||||
var lanHost: String?
|
||||
var tailnetDns: String?
|
||||
var sshPort = 22
|
||||
if case let .bonjour(txt) = result.metadata {
|
||||
let dict = txt.dictionary
|
||||
if let value = dict["lanHost"] {
|
||||
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
lanHost = trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
if let value = dict["tailnetDns"] {
|
||||
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
tailnetDns = trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
if let value = dict["sshPort"],
|
||||
let parsed = Int(value.trimmingCharacters(in: .whitespacesAndNewlines)),
|
||||
parsed > 0
|
||||
{
|
||||
sshPort = parsed
|
||||
}
|
||||
}
|
||||
|
||||
return DiscoveredMaster(
|
||||
displayName: name,
|
||||
lanHost: lanHost,
|
||||
tailnetDns: tailnetDns,
|
||||
sshPort: sshPort,
|
||||
debugID: Self.prettyEndpointDebugID(result.endpoint))
|
||||
}
|
||||
.sorted { $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending }
|
||||
}
|
||||
}
|
||||
|
||||
self.browser = browser
|
||||
browser.start(queue: DispatchQueue(label: "com.steipete.clawdis.macos.master-discovery"))
|
||||
}
|
||||
|
||||
func stop() {
|
||||
self.browser?.cancel()
|
||||
self.browser = nil
|
||||
self.masters = []
|
||||
self.statusText = "Stopped"
|
||||
}
|
||||
|
||||
private static func prettyEndpointDebugID(_ endpoint: NWEndpoint) -> String {
|
||||
String(describing: endpoint)
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,11 @@ final class MacNodeModeCoordinator {
|
||||
self.tunnel = nil
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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, 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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user