@@ -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,22 +191,45 @@ 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
174- if !primaryFiltered. isEmpty {
175- self . gateways = primaryFiltered
176- return
177- }
178223
179224 // 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 {
225+ // and cross-network setups may rely on Tailscale Serve without DNS-SD.
226+ let fallback = self . wideAreaFallbackGateways + self . tailscaleServeFallbackGateways
227+ guard !fallback. isEmpty else {
182228 self . gateways = primaryFiltered
183229 return
184230 }
185231
186- let combined = self . sortedDeduped ( gateways: primary + self . wideAreaFallbackGateways )
232+ let combined = self . sortedDeduped ( gateways: primary + fallback )
187233 self . gateways = self . filterLocalGateways ? combined. filter { !$0. isLocal } : combined
188234 }
189235
@@ -284,18 +330,65 @@ public final class GatewayDiscoveryModel {
284330 }
285331 }
286332
333+ private func scheduleTailscaleServeFallback( ) {
334+ if Self . isRunningTests { return }
335+ guard self . tailscaleServeFallbackTask == nil else { return }
336+ self . tailscaleServeFallbackTask = Task . detached ( priority: . utility) { [ weak self] in
337+ guard let self else { return }
338+ var attempt = 0
339+ let startedAt = Date ( )
340+ while !Task. isCancelled, Date ( ) . timeIntervalSince ( startedAt) < 35.0 {
341+ let hasResults = await MainActor . run {
342+ if self . filterLocalGateways {
343+ return !self . gateways. isEmpty
344+ }
345+ return self . gateways. contains ( where: { !$0. isLocal } )
346+ }
347+ if hasResults { return }
348+
349+ let beacons = await TailscaleServeGatewayDiscovery . discover ( timeoutSeconds: 2.4 )
350+ if !beacons. isEmpty {
351+ await MainActor . run { [ weak self] in
352+ guard let self else { return }
353+ self . tailscaleServeFallbackGateways = self . mapTailscaleServeBeacons ( beacons)
354+ self . recomputeGateways ( )
355+ }
356+ return
357+ }
358+
359+ attempt += 1
360+ let backoff = min ( 8.0 , 0.8 + ( Double ( attempt) * 0.8 ) )
361+ try ? await Task . sleep ( nanoseconds: UInt64 ( backoff * 1_000_000_000 ) )
362+ }
363+ }
364+ }
365+
287366 private var hasUsableWideAreaResults : Bool {
288367 guard let domain = OpenClawBonjour . wideAreaGatewayServiceDomain else { return false }
289368 guard let gateways = self . gatewaysByDomain [ domain] , !gateways. isEmpty else { return false }
290369 if !self . filterLocalGateways { return true }
291370 return gateways. contains ( where: { !$0. isLocal } )
292371 }
293372
373+ static func dedupeKey( for gateway: DiscoveredGateway ) -> String {
374+ if let host = gateway. serviceHost?
375+ . trimmingCharacters ( in: . whitespacesAndNewlines)
376+ . lowercased ( ) ,
377+ !host. isEmpty,
378+ let port = gateway. servicePort,
379+ port > 0
380+ {
381+ return " endpoint| \( host) : \( port) "
382+ }
383+ return " stable| \( gateway. stableID) "
384+ }
385+
294386 private func sortedDeduped( gateways: [ DiscoveredGateway ] ) -> [ DiscoveredGateway ] {
295387 var seen = Set < String > ( )
296388 let deduped = gateways. filter { gateway in
297- if seen. contains ( gateway. stableID) { return false }
298- seen. insert ( gateway. stableID)
389+ let key = Self . dedupeKey ( for: gateway)
390+ if seen. contains ( key) { return false }
391+ seen. insert ( key)
299392 return true
300393 }
301394 return deduped. sorted {
0 commit comments