* refactor: introduce provider plugin registry * refactor: move provider CLI to plugins * docs: add provider plugin implementation notes * refactor: shift provider runtime logic into plugins * refactor: add plugin defaults and summaries * docs: update provider plugin notes * feat(commands): add /commands slash list * Auto-reply: tidy help message * Auto-reply: fix status command lint * Tests: align google shared expectations * Auto-reply: tidy help message * Auto-reply: fix status command lint * refactor: move provider routing into plugins * test: align agent routing expectations * docs: update provider plugin notes * refactor: route replies via provider plugins * docs: note route-reply plugin hooks * refactor: extend provider plugin contract * refactor: derive provider status from plugins * refactor: unify gateway provider control * refactor: use plugin metadata in auto-reply * fix: parenthesize cron target selection * refactor: derive gateway methods from plugins * refactor: generalize provider logout * refactor: route provider logout through plugins * refactor: move WhatsApp web login methods into plugin * refactor: generalize provider log prefixes * refactor: centralize default chat provider * refactor: derive provider lists from registry * refactor: move provider reload noops into plugins * refactor: resolve web login provider via alias * refactor: derive CLI provider options from plugins * refactor: derive prompt provider list from plugins * style: apply biome lint fixes * fix: resolve provider routing edge cases * docs: update provider plugin refactor notes * fix(gateway): harden agent provider routing * refactor: move provider routing into plugins * refactor: move provider CLI to plugins * refactor: derive provider lists from registry * fix: restore slash command parsing * refactor: align provider ids for schema * refactor: unify outbound target resolution * fix: keep outbound labels stable * feat: add msteams to cron surfaces * fix: clean up lint build issues * refactor: localize chat provider alias normalization * refactor: drive gateway provider lists from plugins * docs: update provider plugin notes * style: format message-provider * fix: avoid provider registry init cycles * style: sort message-provider imports * fix: relax provider alias map typing * refactor: move provider routing into plugins * refactor: add plugin pairing/config adapters * refactor: route pairing and provider removal via plugins * refactor: align auto-reply provider typing * test: stabilize telegram media mocks * docs: update provider plugin refactor notes * refactor: pluginize outbound targets * refactor: pluginize provider selection * refactor: generalize text chunk limits * docs: update provider plugin notes * refactor: generalize group session/config * fix: normalize provider id for room detection * fix: avoid provider init in system prompt * style: formatting cleanup * refactor: normalize agent delivery targets * test: update outbound delivery labels * chore: fix lint regressions * refactor: extend provider plugin adapters * refactor: move elevated/block streaming defaults to plugins * refactor: defer outbound send deps to plugins * docs: note plugin-driven streaming/elevated defaults * refactor: centralize webchat provider constant * refactor: add provider setup adapters * refactor: delegate provider add config to plugins * docs: document plugin-driven provider add * refactor: add plugin state/binding metadata * refactor: build agent provider status from plugins * docs: note plugin-driven agent bindings * refactor: centralize internal provider constant usage * fix: normalize WhatsApp targets for groups and E.164 (#631) (thanks @imfing) * refactor: centralize default chat provider * refactor: centralize WhatsApp target normalization * refactor: move provider routing into plugins * refactor: normalize agent delivery targets * chore: fix lint regressions * fix: normalize WhatsApp targets for groups and E.164 (#631) (thanks @imfing) * feat: expand provider plugin adapters * refactor: route auto-reply via provider plugins * fix: align WhatsApp target normalization * fix: normalize WhatsApp targets for groups and E.164 (#631) (thanks @imfing) * refactor: centralize WhatsApp target normalization * feat: add /config chat config updates * docs: add /config get alias * feat(commands): add /commands slash list * refactor: centralize default chat provider * style: apply biome lint fixes * chore: fix lint regressions * fix: clean up whatsapp allowlist typing * style: format config command helpers * refactor: pluginize tool threading context * refactor: normalize session announce targets * docs: note new plugin threading and announce hooks * refactor: pluginize message actions * docs: update provider plugin actions notes * fix: align provider action adapters * refactor: centralize webchat checks * style: format message provider helpers * refactor: move provider onboarding into adapters * docs: note onboarding provider adapters * feat: add msteams onboarding adapter * style: organize onboarding imports * fix: normalize msteams allowFrom types * feat: add plugin text chunk limits * refactor: use plugin chunk limit fallbacks * feat: add provider mention stripping hooks * style: organize provider plugin type imports * refactor: generalize health snapshots * refactor: update macOS health snapshot handling * docs: refresh health snapshot notes * style: format health snapshot updates * refactor: drive security warnings via plugins * docs: note provider security adapter * style: format provider security adapters * refactor: centralize provider account defaults * refactor: type gateway client identity constants * chore: regen gateway protocol swift * fix: degrade health on failed provider probe * refactor: centralize pairing approve hint * docs: add plugin CLI command references * refactor: route auth and tool sends through plugins * docs: expand provider plugin hooks * refactor: document provider docking touchpoints * refactor: normalize internal provider defaults * refactor: streamline outbound delivery wiring * refactor: make provider onboarding plugin-owned * refactor: support provider-owned agent tools * refactor: move telegram draft chunking into telegram module * refactor: infer provider tool sends via extractToolSend * fix: repair plugin onboarding imports * refactor: de-dup outbound target normalization * style: tidy plugin and agent imports * refactor: data-drive provider selection line * fix: satisfy lint after provider plugin rebase * test: deflake gateway-cli coverage * style: format gateway-cli coverage test * refactor(provider-plugins): simplify provider ids * test(pairing-cli): avoid provider-specific ternary * style(macos): swiftformat HealthStore * refactor(sandbox): derive provider tool denylist * fix(sandbox): avoid plugin init in defaults * refactor(provider-plugins): centralize provider aliases * style(test): satisfy biome * refactor(protocol): v3 providers.status maps * refactor(ui): adapt to protocol v3 * refactor(macos): adapt to protocol v3 * test: update providers.status v3 fixtures * refactor(gateway): map provider runtime snapshot * test(gateway): update reload runtime snapshot * refactor(whatsapp): normalize heartbeat provider id * docs(refactor): update provider plugin notes * style: satisfy biome after rebase * fix: describe sandboxed elevated in prompt * feat(gateway): add agent image attachments + live probe * refactor: derive CLI provider options from plugins * fix(gateway): harden agent provider routing * fix(gateway): harden agent provider routing * refactor: align provider ids for schema * fix(protocol): keep agent provider string * fix(gateway): harden agent provider routing * fix(protocol): keep agent provider string * refactor: normalize agent delivery targets * refactor: support provider-owned agent tools * refactor(config): provider-keyed elevated allowFrom * style: satisfy biome * fix(gateway): appease provider narrowing * style: satisfy biome * refactor(reply): move group intro hints into plugin * fix(reply): avoid plugin registry init cycle * refactor(providers): add lightweight provider dock * refactor(gateway): use typed client id in connect * refactor(providers): document docks and avoid init cycles * refactor(providers): make media limit helper generic * fix(providers): break plugin registry import cycles * style: satisfy biome * refactor(status-all): build providers table from plugins * refactor(gateway): delegate web login to provider plugin * refactor(provider): drop web alias * refactor(provider): lazy-load monitors * style: satisfy lint/format * style: format status-all providers table * style: swiftformat gateway discovery model * test: make reload plan plugin-driven * fix: avoid token stringification in status-all * refactor: make provider IDs explicit in status * feat: warn on signal/imessage provider runtime errors * test: cover gateway provider runtime warnings in status * fix: add runtime kind to provider status issues * test: cover health degradation on probe failure * fix: keep routeReply lightweight * style: organize routeReply imports * refactor(web): extract auth-store helpers * refactor(whatsapp): lazy login imports * refactor(outbound): route replies via plugin outbound * docs: update provider plugin notes * style: format provider status issues * fix: make sandbox scope warning wrap-safe * refactor: load outbound adapters from provider plugins * docs: update provider plugin outbound notes * style(macos): fix swiftformat lint * docs: changelog for provider plugins * fix(macos): satisfy swiftformat * fix(macos): open settings via menu action * style: format after rebase * fix(macos): open Settings via menu action --------- Co-authored-by: LK <luke@kyohere.com> Co-authored-by: Luke K (pr-0f3t) <2609441+lc0rp@users.noreply.github.com> Co-authored-by: Xin <xin@imfing.com>
684 lines
25 KiB
Swift
684 lines
25 KiB
Swift
import ClawdbotKit
|
|
import Foundation
|
|
import Network
|
|
import Observation
|
|
import OSLog
|
|
|
|
@MainActor
|
|
@Observable
|
|
public final class GatewayDiscoveryModel {
|
|
public struct LocalIdentity: Equatable, Sendable {
|
|
public var hostTokens: Set<String>
|
|
public var displayTokens: Set<String>
|
|
|
|
public init(hostTokens: Set<String>, displayTokens: Set<String>) {
|
|
self.hostTokens = hostTokens
|
|
self.displayTokens = displayTokens
|
|
}
|
|
}
|
|
|
|
public struct DiscoveredGateway: Identifiable, Equatable, Sendable {
|
|
public var id: String { self.stableID }
|
|
public var displayName: String
|
|
public var lanHost: String?
|
|
public var tailnetDns: String?
|
|
public var sshPort: Int
|
|
public var gatewayPort: Int?
|
|
public var cliPath: String?
|
|
public var stableID: String
|
|
public var debugID: String
|
|
public var isLocal: Bool
|
|
|
|
public init(
|
|
displayName: String,
|
|
lanHost: String? = nil,
|
|
tailnetDns: String? = nil,
|
|
sshPort: Int,
|
|
gatewayPort: Int? = nil,
|
|
cliPath: String? = nil,
|
|
stableID: String,
|
|
debugID: String,
|
|
isLocal: Bool)
|
|
{
|
|
self.displayName = displayName
|
|
self.lanHost = lanHost
|
|
self.tailnetDns = tailnetDns
|
|
self.sshPort = sshPort
|
|
self.gatewayPort = gatewayPort
|
|
self.cliPath = cliPath
|
|
self.stableID = stableID
|
|
self.debugID = debugID
|
|
self.isLocal = isLocal
|
|
}
|
|
}
|
|
|
|
public var gateways: [DiscoveredGateway] = []
|
|
public var statusText: String = "Idle"
|
|
|
|
private var browsers: [String: NWBrowser] = [:]
|
|
private var resultsByDomain: [String: Set<NWBrowser.Result>] = [:]
|
|
private var gatewaysByDomain: [String: [DiscoveredGateway]] = [:]
|
|
private var statesByDomain: [String: NWBrowser.State] = [:]
|
|
private var localIdentity: LocalIdentity
|
|
private let localDisplayName: String?
|
|
private let filterLocalGateways: Bool
|
|
private var resolvedTXTByID: [String: [String: String]] = [:]
|
|
private var pendingTXTResolvers: [String: GatewayTXTResolver] = [:]
|
|
private var wideAreaFallbackTask: Task<Void, Never>?
|
|
private var wideAreaFallbackGateways: [DiscoveredGateway] = []
|
|
private let logger = Logger(subsystem: "com.clawdbot", category: "gateway-discovery")
|
|
|
|
public init(
|
|
localDisplayName: String? = nil,
|
|
filterLocalGateways: Bool = true)
|
|
{
|
|
self.localDisplayName = localDisplayName
|
|
self.filterLocalGateways = filterLocalGateways
|
|
self.localIdentity = Self.buildLocalIdentityFast(displayName: localDisplayName)
|
|
self.refreshLocalIdentity()
|
|
}
|
|
|
|
public func start() {
|
|
if !self.browsers.isEmpty { return }
|
|
|
|
for domain in ClawdbotBonjour.bridgeServiceDomains {
|
|
let params = NWParameters.tcp
|
|
params.includePeerToPeer = true
|
|
let browser = NWBrowser(
|
|
for: .bonjour(type: ClawdbotBonjour.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.resultsByDomain[domain] = results
|
|
self.updateGateways(for: domain)
|
|
self.recomputeGateways()
|
|
}
|
|
}
|
|
|
|
self.browsers[domain] = browser
|
|
browser.start(queue: DispatchQueue(label: "com.clawdbot.macos.gateway-discovery.\(domain)"))
|
|
}
|
|
|
|
self.scheduleWideAreaFallback()
|
|
}
|
|
|
|
public func refreshWideAreaFallbackNow(timeoutSeconds: TimeInterval = 5.0) {
|
|
let domain = ClawdbotBonjour.wideAreaBridgeServiceDomain
|
|
Task.detached(priority: .utility) { [weak self] in
|
|
guard let self else { return }
|
|
let beacons = WideAreaGatewayDiscovery.discover(timeoutSeconds: timeoutSeconds)
|
|
await MainActor.run { [weak self] in
|
|
guard let self else { return }
|
|
self.wideAreaFallbackGateways = self.mapWideAreaBeacons(beacons, domain: domain)
|
|
self.recomputeGateways()
|
|
}
|
|
}
|
|
}
|
|
|
|
public func stop() {
|
|
for browser in self.browsers.values {
|
|
browser.cancel()
|
|
}
|
|
self.browsers = [:]
|
|
self.resultsByDomain = [:]
|
|
self.gatewaysByDomain = [:]
|
|
self.statesByDomain = [:]
|
|
self.resolvedTXTByID = [:]
|
|
self.pendingTXTResolvers.values.forEach { $0.cancel() }
|
|
self.pendingTXTResolvers = [:]
|
|
self.wideAreaFallbackTask?.cancel()
|
|
self.wideAreaFallbackTask = nil
|
|
self.wideAreaFallbackGateways = []
|
|
self.gateways = []
|
|
self.statusText = "Stopped"
|
|
}
|
|
|
|
private func mapWideAreaBeacons(_ beacons: [WideAreaGatewayBeacon], domain: String) -> [DiscoveredGateway] {
|
|
beacons.map { beacon in
|
|
let stableID = "wide-area|\(domain)|\(beacon.instanceName)"
|
|
let isLocal = Self.isLocalGateway(
|
|
lanHost: beacon.lanHost,
|
|
tailnetDns: beacon.tailnetDns,
|
|
displayName: beacon.displayName,
|
|
serviceName: beacon.instanceName,
|
|
local: self.localIdentity)
|
|
return DiscoveredGateway(
|
|
displayName: beacon.displayName,
|
|
lanHost: beacon.lanHost,
|
|
tailnetDns: beacon.tailnetDns,
|
|
sshPort: beacon.sshPort ?? 22,
|
|
gatewayPort: beacon.gatewayPort,
|
|
cliPath: beacon.cliPath,
|
|
stableID: stableID,
|
|
debugID: "\(beacon.instanceName)@\(beacon.host):\(beacon.port)",
|
|
isLocal: isLocal)
|
|
}
|
|
}
|
|
|
|
private func recomputeGateways() {
|
|
let primary = self.sortedDeduped(gateways: self.gatewaysByDomain.values.flatMap(\.self))
|
|
let primaryFiltered = self.filterLocalGateways ? primary.filter { !$0.isLocal } : primary
|
|
if !primaryFiltered.isEmpty {
|
|
self.gateways = primaryFiltered
|
|
return
|
|
}
|
|
|
|
// Bonjour can return only "local" results for the wide-area domain (or no results at all),
|
|
// which makes onboarding look empty even though Tailscale DNS-SD can already see bridges.
|
|
guard !self.wideAreaFallbackGateways.isEmpty else {
|
|
self.gateways = primaryFiltered
|
|
return
|
|
}
|
|
|
|
let combined = self.sortedDeduped(gateways: primary + self.wideAreaFallbackGateways)
|
|
self.gateways = self.filterLocalGateways ? combined.filter { !$0.isLocal } : combined
|
|
}
|
|
|
|
private func updateGateways(for domain: String) {
|
|
guard let results = self.resultsByDomain[domain] else {
|
|
self.gatewaysByDomain[domain] = []
|
|
return
|
|
}
|
|
|
|
self.gatewaysByDomain[domain] = results.compactMap { result -> DiscoveredGateway? in
|
|
guard case let .service(name, type, resultDomain, _) = result.endpoint else { return nil }
|
|
|
|
let decodedName = BonjourEscapes.decode(name)
|
|
let stableID = BridgeEndpointID.stableID(result.endpoint)
|
|
let resolvedTXT = self.resolvedTXTByID[stableID] ?? [:]
|
|
let txt = Self.txtDictionary(from: result).merging(
|
|
resolvedTXT,
|
|
uniquingKeysWith: { _, new in new })
|
|
|
|
let advertisedName = txt["displayName"]
|
|
.map(Self.prettifyInstanceName)
|
|
.flatMap { $0.isEmpty ? nil : $0 }
|
|
let prettyName =
|
|
advertisedName ?? Self.prettifyServiceName(decodedName)
|
|
|
|
let parsedTXT = Self.parseGatewayTXT(txt)
|
|
|
|
if parsedTXT.lanHost == nil || parsedTXT.tailnetDns == nil {
|
|
self.ensureTXTResolution(
|
|
stableID: stableID,
|
|
serviceName: name,
|
|
type: type,
|
|
domain: resultDomain)
|
|
}
|
|
|
|
let isLocal = Self.isLocalGateway(
|
|
lanHost: parsedTXT.lanHost,
|
|
tailnetDns: parsedTXT.tailnetDns,
|
|
displayName: prettyName,
|
|
serviceName: decodedName,
|
|
local: self.localIdentity)
|
|
return DiscoveredGateway(
|
|
displayName: prettyName,
|
|
lanHost: parsedTXT.lanHost,
|
|
tailnetDns: parsedTXT.tailnetDns,
|
|
sshPort: parsedTXT.sshPort,
|
|
gatewayPort: parsedTXT.gatewayPort,
|
|
cliPath: parsedTXT.cliPath,
|
|
stableID: stableID,
|
|
debugID: BridgeEndpointID.prettyDescription(result.endpoint),
|
|
isLocal: isLocal)
|
|
}
|
|
.sorted { $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending }
|
|
|
|
if domain == ClawdbotBonjour.wideAreaBridgeServiceDomain,
|
|
self.hasUsableWideAreaResults
|
|
{
|
|
self.wideAreaFallbackGateways = []
|
|
}
|
|
}
|
|
|
|
private func scheduleWideAreaFallback() {
|
|
let domain = ClawdbotBonjour.wideAreaBridgeServiceDomain
|
|
if Self.isRunningTests { return }
|
|
guard self.wideAreaFallbackTask == nil else { return }
|
|
self.wideAreaFallbackTask = Task.detached(priority: .utility) { [weak self] in
|
|
guard let self else { return }
|
|
var attempt = 0
|
|
let startedAt = Date()
|
|
while !Task.isCancelled, Date().timeIntervalSince(startedAt) < 35.0 {
|
|
let hasResults = await MainActor.run {
|
|
self.hasUsableWideAreaResults
|
|
}
|
|
if hasResults { return }
|
|
|
|
// Wide-area discovery can be racy (Tailscale not yet up, DNS zone not
|
|
// published yet). Retry with a short backoff while onboarding is open.
|
|
let beacons = WideAreaGatewayDiscovery.discover(timeoutSeconds: 2.0)
|
|
if !beacons.isEmpty {
|
|
await MainActor.run { [weak self] in
|
|
guard let self else { return }
|
|
self.wideAreaFallbackGateways = self.mapWideAreaBeacons(beacons, domain: domain)
|
|
self.recomputeGateways()
|
|
}
|
|
return
|
|
}
|
|
|
|
attempt += 1
|
|
let backoff = min(8.0, 0.6 + (Double(attempt) * 0.7))
|
|
try? await Task.sleep(nanoseconds: UInt64(backoff * 1_000_000_000))
|
|
}
|
|
}
|
|
}
|
|
|
|
private var hasUsableWideAreaResults: Bool {
|
|
let domain = ClawdbotBonjour.wideAreaBridgeServiceDomain
|
|
guard let gateways = self.gatewaysByDomain[domain], !gateways.isEmpty else { return false }
|
|
if !self.filterLocalGateways { return true }
|
|
return gateways.contains(where: { !$0.isLocal })
|
|
}
|
|
|
|
private func sortedDeduped(gateways: [DiscoveredGateway]) -> [DiscoveredGateway] {
|
|
var seen = Set<String>()
|
|
let deduped = gateways.filter { gateway in
|
|
if seen.contains(gateway.stableID) { return false }
|
|
seen.insert(gateway.stableID)
|
|
return true
|
|
}
|
|
return deduped.sorted {
|
|
$0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending
|
|
}
|
|
}
|
|
|
|
private nonisolated static var isRunningTests: Bool {
|
|
// Keep discovery background work from running forever during SwiftPM test runs.
|
|
if Bundle.allBundles.contains(where: { $0.bundleURL.pathExtension == "xctest" }) { return true }
|
|
|
|
let env = ProcessInfo.processInfo.environment
|
|
return env["XCTestConfigurationFilePath"] != nil
|
|
|| env["XCTestBundlePath"] != nil
|
|
|| env["XCTestSessionIdentifier"] != nil
|
|
}
|
|
|
|
private func updateGatewaysForAllDomains() {
|
|
for domain in self.resultsByDomain.keys {
|
|
self.updateGateways(for: domain)
|
|
}
|
|
}
|
|
|
|
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] {
|
|
var merged: [String: String] = [:]
|
|
|
|
if case let .bonjour(txt) = result.metadata {
|
|
merged.merge(txt.dictionary, uniquingKeysWith: { _, new in new })
|
|
}
|
|
|
|
if let endpointTxt = result.endpoint.txtRecord?.dictionary {
|
|
merged.merge(endpointTxt, uniquingKeysWith: { _, new in new })
|
|
}
|
|
|
|
return merged
|
|
}
|
|
|
|
public struct GatewayTXT: Equatable {
|
|
public var lanHost: String?
|
|
public var tailnetDns: String?
|
|
public var sshPort: Int
|
|
public var gatewayPort: Int?
|
|
public var cliPath: String?
|
|
}
|
|
|
|
public static func parseGatewayTXT(_ txt: [String: String]) -> GatewayTXT {
|
|
var lanHost: String?
|
|
var tailnetDns: String?
|
|
var sshPort = 22
|
|
var gatewayPort: Int?
|
|
var cliPath: String?
|
|
|
|
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
|
|
}
|
|
if let value = txt["gatewayPort"],
|
|
let parsed = Int(value.trimmingCharacters(in: .whitespacesAndNewlines)),
|
|
parsed > 0
|
|
{
|
|
gatewayPort = parsed
|
|
}
|
|
if let value = txt["cliPath"] {
|
|
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
cliPath = trimmed.isEmpty ? nil : trimmed
|
|
}
|
|
|
|
return GatewayTXT(
|
|
lanHost: lanHost,
|
|
tailnetDns: tailnetDns,
|
|
sshPort: sshPort,
|
|
gatewayPort: gatewayPort,
|
|
cliPath: cliPath)
|
|
}
|
|
|
|
public static func buildSSHTarget(user: String, host: String, port: Int) -> String {
|
|
var target = "\(user)@\(host)"
|
|
if port != 22 {
|
|
target += ":\(port)"
|
|
}
|
|
return target
|
|
}
|
|
|
|
private func ensureTXTResolution(
|
|
stableID: String,
|
|
serviceName: String,
|
|
type: String,
|
|
domain: String)
|
|
{
|
|
guard self.resolvedTXTByID[stableID] == nil else { return }
|
|
guard self.pendingTXTResolvers[stableID] == nil else { return }
|
|
|
|
let resolver = GatewayTXTResolver(
|
|
name: serviceName,
|
|
type: type,
|
|
domain: domain,
|
|
logger: self.logger)
|
|
{ [weak self] result in
|
|
Task { @MainActor in
|
|
guard let self else { return }
|
|
self.pendingTXTResolvers[stableID] = nil
|
|
switch result {
|
|
case let .success(txt):
|
|
self.resolvedTXTByID[stableID] = txt
|
|
self.updateGatewaysForAllDomains()
|
|
self.recomputeGateways()
|
|
case .failure:
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
self.pendingTXTResolvers[stableID] = resolver
|
|
resolver.start()
|
|
}
|
|
|
|
private nonisolated static func prettifyInstanceName(_ decodedName: String) -> String {
|
|
let normalized = decodedName.split(whereSeparator: \.isWhitespace).joined(separator: " ")
|
|
let stripped = normalized.replacingOccurrences(of: " (Clawdbot)", with: "")
|
|
.replacingOccurrences(of: #"\s+\(\d+\)$"#, with: "", options: .regularExpression)
|
|
return stripped.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
}
|
|
|
|
private nonisolated static func prettifyServiceName(_ decodedName: String) -> String {
|
|
let normalized = Self.prettifyInstanceName(decodedName)
|
|
var cleaned = normalized.replacingOccurrences(of: #"\s*-?bridge$"#, with: "", options: .regularExpression)
|
|
cleaned = cleaned
|
|
.replacingOccurrences(of: "_", with: " ")
|
|
.replacingOccurrences(of: "-", with: " ")
|
|
.replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression)
|
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
if cleaned.isEmpty {
|
|
cleaned = normalized
|
|
}
|
|
let words = cleaned.split(separator: " ")
|
|
let titled = words.map { word -> String in
|
|
let lower = word.lowercased()
|
|
guard let first = lower.first else { return "" }
|
|
return String(first).uppercased() + lower.dropFirst()
|
|
}.joined(separator: " ")
|
|
return titled.isEmpty ? normalized : titled
|
|
}
|
|
|
|
public nonisolated static func isLocalGateway(
|
|
lanHost: String?,
|
|
tailnetDns: String?,
|
|
displayName: String?,
|
|
serviceName: String?,
|
|
local: LocalIdentity) -> Bool
|
|
{
|
|
if let host = normalizeHostToken(lanHost),
|
|
local.hostTokens.contains(host)
|
|
{
|
|
return true
|
|
}
|
|
if let host = normalizeHostToken(tailnetDns),
|
|
local.hostTokens.contains(host)
|
|
{
|
|
return true
|
|
}
|
|
if let name = normalizeDisplayToken(displayName),
|
|
local.displayTokens.contains(name)
|
|
{
|
|
return true
|
|
}
|
|
if let serviceHost = normalizeServiceHostToken(serviceName),
|
|
local.hostTokens.contains(serviceHost)
|
|
{
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
private func refreshLocalIdentity() {
|
|
let fastIdentity = self.localIdentity
|
|
let displayName = self.localDisplayName
|
|
Task.detached(priority: .utility) {
|
|
let slowIdentity = Self.buildLocalIdentitySlow(displayName: displayName)
|
|
let merged = Self.mergeLocalIdentity(fast: fastIdentity, slow: slowIdentity)
|
|
await MainActor.run { [weak self] in
|
|
guard let self else { return }
|
|
guard self.localIdentity != merged else { return }
|
|
self.localIdentity = merged
|
|
self.recomputeGateways()
|
|
}
|
|
}
|
|
}
|
|
|
|
private nonisolated static func mergeLocalIdentity(
|
|
fast: LocalIdentity,
|
|
slow: LocalIdentity) -> LocalIdentity
|
|
{
|
|
LocalIdentity(
|
|
hostTokens: fast.hostTokens.union(slow.hostTokens),
|
|
displayTokens: fast.displayTokens.union(slow.displayTokens))
|
|
}
|
|
|
|
private nonisolated static func buildLocalIdentityFast(displayName: String?) -> LocalIdentity {
|
|
var hostTokens: Set<String> = []
|
|
var displayTokens: Set<String> = []
|
|
|
|
let hostName = ProcessInfo.processInfo.hostName
|
|
if let token = normalizeHostToken(hostName) {
|
|
hostTokens.insert(token)
|
|
}
|
|
|
|
if let token = normalizeDisplayToken(displayName) {
|
|
displayTokens.insert(token)
|
|
}
|
|
|
|
return LocalIdentity(hostTokens: hostTokens, displayTokens: displayTokens)
|
|
}
|
|
|
|
private nonisolated static func buildLocalIdentitySlow(displayName: String?) -> LocalIdentity {
|
|
var hostTokens: Set<String> = []
|
|
var displayTokens: Set<String> = []
|
|
|
|
if let host = Host.current().name,
|
|
let token = normalizeHostToken(host)
|
|
{
|
|
hostTokens.insert(token)
|
|
}
|
|
|
|
if let token = normalizeDisplayToken(displayName) {
|
|
displayTokens.insert(token)
|
|
}
|
|
|
|
if let token = normalizeDisplayToken(Host.current().localizedName) {
|
|
displayTokens.insert(token)
|
|
}
|
|
|
|
return LocalIdentity(hostTokens: hostTokens, displayTokens: displayTokens)
|
|
}
|
|
|
|
private nonisolated static func normalizeHostToken(_ raw: String?) -> String? {
|
|
guard let raw else { return nil }
|
|
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
if trimmed.isEmpty { return nil }
|
|
let lower = trimmed.lowercased()
|
|
let strippedTrailingDot = lower.hasSuffix(".")
|
|
? String(lower.dropLast())
|
|
: lower
|
|
let withoutLocal = strippedTrailingDot.hasSuffix(".local")
|
|
? String(strippedTrailingDot.dropLast(6))
|
|
: strippedTrailingDot
|
|
let firstLabel = withoutLocal.split(separator: ".").first.map(String.init)
|
|
let token = (firstLabel ?? withoutLocal).trimmingCharacters(in: .whitespacesAndNewlines)
|
|
return token.isEmpty ? nil : token
|
|
}
|
|
|
|
private nonisolated static func normalizeDisplayToken(_ raw: String?) -> String? {
|
|
guard let raw else { return nil }
|
|
let prettified = Self.prettifyInstanceName(raw)
|
|
let trimmed = prettified.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
if trimmed.isEmpty { return nil }
|
|
return trimmed.lowercased()
|
|
}
|
|
|
|
private nonisolated static func normalizeServiceHostToken(_ raw: String?) -> String? {
|
|
guard let raw else { return nil }
|
|
let prettified = Self.prettifyInstanceName(raw)
|
|
let strippedBridge = prettified.replacingOccurrences(
|
|
of: #"\s*-?\s*bridge$"#,
|
|
with: "",
|
|
options: .regularExpression)
|
|
return self.normalizeHostToken(strippedBridge)
|
|
}
|
|
}
|
|
|
|
final class GatewayTXTResolver: NSObject, NetServiceDelegate {
|
|
private let service: NetService
|
|
private let completion: (Result<[String: String], Error>) -> Void
|
|
private let logger: Logger
|
|
private var didFinish = false
|
|
|
|
init(
|
|
name: String,
|
|
type: String,
|
|
domain: String,
|
|
logger: Logger,
|
|
completion: @escaping (Result<[String: String], Error>) -> Void)
|
|
{
|
|
self.service = NetService(domain: domain, type: type, name: name)
|
|
self.completion = completion
|
|
self.logger = logger
|
|
super.init()
|
|
self.service.delegate = self
|
|
}
|
|
|
|
func start(timeout: TimeInterval = 2.0) {
|
|
self.service.schedule(in: .main, forMode: .common)
|
|
self.service.resolve(withTimeout: timeout)
|
|
}
|
|
|
|
func cancel() {
|
|
self.finish(result: .failure(GatewayTXTResolverError.cancelled))
|
|
}
|
|
|
|
func netServiceDidResolveAddress(_ sender: NetService) {
|
|
let txt = Self.decodeTXT(sender.txtRecordData())
|
|
if !txt.isEmpty {
|
|
let payload = self.formatTXT(txt)
|
|
self.logger.debug(
|
|
"discovery: resolved TXT for \(sender.name, privacy: .public): \(payload, privacy: .public)")
|
|
}
|
|
self.finish(result: .success(txt))
|
|
}
|
|
|
|
func netService(_ sender: NetService, didNotResolve errorDict: [String: NSNumber]) {
|
|
self.finish(result: .failure(GatewayTXTResolverError.resolveFailed(errorDict)))
|
|
}
|
|
|
|
private func finish(result: Result<[String: String], Error>) {
|
|
guard !self.didFinish else { return }
|
|
self.didFinish = true
|
|
self.service.stop()
|
|
self.service.remove(from: .main, forMode: .common)
|
|
self.completion(result)
|
|
}
|
|
|
|
private static func decodeTXT(_ data: Data?) -> [String: String] {
|
|
guard let data else { return [:] }
|
|
let dict = NetService.dictionary(fromTXTRecord: data)
|
|
var out: [String: String] = [:]
|
|
out.reserveCapacity(dict.count)
|
|
for (key, value) in dict {
|
|
if let str = String(data: value, encoding: .utf8) {
|
|
out[key] = str
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
private func formatTXT(_ txt: [String: String]) -> String {
|
|
txt.sorted(by: { $0.key < $1.key })
|
|
.map { "\($0.key)=\($0.value)" }
|
|
.joined(separator: " ")
|
|
}
|
|
}
|
|
|
|
enum GatewayTXTResolverError: Error {
|
|
case cancelled
|
|
case resolveFailed([String: NSNumber])
|
|
}
|