Skip to content

Commit 608b976

Browse files
committed
feat(macos): add tailscale serve gateway discovery
1 parent 2370ea5 commit 608b976

File tree

4 files changed

+421
-5
lines changed

4 files changed

+421
-5
lines changed

apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -134,10 +134,10 @@ extension OnboardingView {
134134
if self.gatewayDiscovery.gateways.isEmpty {
135135
ProgressView().controlSize(.small)
136136
Button("Refresh") {
137-
self.gatewayDiscovery.refreshWideAreaFallbackNow(timeoutSeconds: 5.0)
137+
self.gatewayDiscovery.refreshRemoteFallbackNow(timeoutSeconds: 5.0)
138138
}
139139
.buttonStyle(.link)
140-
.help("Retry Tailscale discovery (DNS-SD).")
140+
.help("Retry remote discovery (Tailscale DNS-SD + Serve probe).")
141141
}
142142
Spacer(minLength: 0)
143143
}

apps/macos/Sources/OpenClawDiscovery/GatewayDiscoveryModel.swift

Lines changed: 86 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,8 @@ public final class GatewayDiscoveryModel {
7676
private var pendingServiceResolvers: [String: GatewayServiceResolver] = [:]
7777
private var wideAreaFallbackTask: Task<Void, Never>?
7878
private var wideAreaFallbackGateways: [DiscoveredGateway] = []
79+
private var tailscaleServeFallbackTask: Task<Void, Never>?
80+
private var tailscaleServeFallbackGateways: [DiscoveredGateway] = []
7981
private let logger = Logger(subsystem: "ai.openclaw", category: "gateway-discovery")
8082

8183
public init(
@@ -111,6 +113,7 @@ public final class GatewayDiscoveryModel {
111113
}
112114

113115
self.scheduleWideAreaFallback()
116+
self.scheduleTailscaleServeFallback()
114117
}
115118

116119
public func refreshWideAreaFallbackNow(timeoutSeconds: TimeInterval = 5.0) {
@@ -126,6 +129,23 @@ public final class GatewayDiscoveryModel {
126129
}
127130
}
128131

132+
public func refreshTailscaleServeFallbackNow(timeoutSeconds: TimeInterval = 5.0) {
133+
Task.detached(priority: .utility) { [weak self] in
134+
guard let self else { return }
135+
let beacons = await TailscaleServeGatewayDiscovery.discover(timeoutSeconds: timeoutSeconds)
136+
await MainActor.run { [weak self] in
137+
guard let self else { return }
138+
self.tailscaleServeFallbackGateways = self.mapTailscaleServeBeacons(beacons)
139+
self.recomputeGateways()
140+
}
141+
}
142+
}
143+
144+
public func refreshRemoteFallbackNow(timeoutSeconds: TimeInterval = 5.0) {
145+
self.refreshWideAreaFallbackNow(timeoutSeconds: timeoutSeconds)
146+
self.refreshTailscaleServeFallbackNow(timeoutSeconds: timeoutSeconds)
147+
}
148+
129149
public func stop() {
130150
for browser in self.browsers.values {
131151
browser.cancel()
@@ -140,6 +160,9 @@ public final class GatewayDiscoveryModel {
140160
self.wideAreaFallbackTask?.cancel()
141161
self.wideAreaFallbackTask = nil
142162
self.wideAreaFallbackGateways = []
163+
self.tailscaleServeFallbackTask?.cancel()
164+
self.tailscaleServeFallbackTask = nil
165+
self.tailscaleServeFallbackGateways = []
143166
self.gateways = []
144167
self.statusText = "Stopped"
145168
}
@@ -168,6 +191,32 @@ public final class GatewayDiscoveryModel {
168191
}
169192
}
170193

194+
private func mapTailscaleServeBeacons(
195+
_ beacons: [TailscaleServeGatewayBeacon]) -> [DiscoveredGateway]
196+
{
197+
beacons.map { beacon in
198+
let stableID = "tailscale-serve|\(beacon.tailnetDns.lowercased())"
199+
let isLocal = Self.isLocalGateway(
200+
lanHost: nil,
201+
tailnetDns: beacon.tailnetDns,
202+
displayName: beacon.displayName,
203+
serviceName: nil,
204+
local: self.localIdentity)
205+
return DiscoveredGateway(
206+
displayName: beacon.displayName,
207+
serviceHost: beacon.host,
208+
servicePort: beacon.port,
209+
lanHost: nil,
210+
tailnetDns: beacon.tailnetDns,
211+
sshPort: 22,
212+
gatewayPort: beacon.port,
213+
cliPath: nil,
214+
stableID: stableID,
215+
debugID: "\(beacon.host):\(beacon.port)",
216+
isLocal: isLocal)
217+
}
218+
}
219+
171220
private func recomputeGateways() {
172221
let primary = self.sortedDeduped(gateways: self.gatewaysByDomain.values.flatMap(\.self))
173222
let primaryFiltered = self.filterLocalGateways ? primary.filter { !$0.isLocal } : primary
@@ -177,13 +226,14 @@ public final class GatewayDiscoveryModel {
177226
}
178227

179228
// Bonjour can return only "local" results for the wide-area domain (or no results at all),
180-
// which makes onboarding look empty even though Tailscale DNS-SD can already see gateways.
181-
guard !self.wideAreaFallbackGateways.isEmpty else {
229+
// and cross-network setups may rely on Tailscale Serve without DNS-SD.
230+
let fallback = self.wideAreaFallbackGateways + self.tailscaleServeFallbackGateways
231+
guard !fallback.isEmpty else {
182232
self.gateways = primaryFiltered
183233
return
184234
}
185235

186-
let combined = self.sortedDeduped(gateways: primary + self.wideAreaFallbackGateways)
236+
let combined = self.sortedDeduped(gateways: primary + fallback)
187237
self.gateways = self.filterLocalGateways ? combined.filter { !$0.isLocal } : combined
188238
}
189239

@@ -284,6 +334,39 @@ public final class GatewayDiscoveryModel {
284334
}
285335
}
286336

337+
private func scheduleTailscaleServeFallback() {
338+
if Self.isRunningTests { return }
339+
guard self.tailscaleServeFallbackTask == nil else { return }
340+
self.tailscaleServeFallbackTask = Task.detached(priority: .utility) { [weak self] in
341+
guard let self else { return }
342+
var attempt = 0
343+
let startedAt = Date()
344+
while !Task.isCancelled, Date().timeIntervalSince(startedAt) < 35.0 {
345+
let hasResults = await MainActor.run {
346+
if self.filterLocalGateways {
347+
return !self.gateways.isEmpty
348+
}
349+
return self.gateways.contains(where: { !$0.isLocal })
350+
}
351+
if hasResults { return }
352+
353+
let beacons = await TailscaleServeGatewayDiscovery.discover(timeoutSeconds: 2.4)
354+
if !beacons.isEmpty {
355+
await MainActor.run { [weak self] in
356+
guard let self else { return }
357+
self.tailscaleServeFallbackGateways = self.mapTailscaleServeBeacons(beacons)
358+
self.recomputeGateways()
359+
}
360+
return
361+
}
362+
363+
attempt += 1
364+
let backoff = min(8.0, 0.8 + (Double(attempt) * 0.8))
365+
try? await Task.sleep(nanoseconds: UInt64(backoff * 1_000_000_000))
366+
}
367+
}
368+
}
369+
287370
private var hasUsableWideAreaResults: Bool {
288371
guard let domain = OpenClawBonjour.wideAreaGatewayServiceDomain else { return false }
289372
guard let gateways = self.gatewaysByDomain[domain], !gateways.isEmpty else { return false }

0 commit comments

Comments
 (0)