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) {
|
||||
|
||||
Reference in New Issue
Block a user