Files
clawdbot/apps/macos/Sources/Clawdis/GatewayDiscoveryModel.swift
2025-12-19 23:12:52 +01:00

165 lines
6.0 KiB
Swift

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