@@ -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