1+ import AppKit
12import Foundation
23import OpenClawKit
34import OSLog
@@ -40,7 +41,7 @@ struct MacNodeGatewayTLSSessionCache {
4041}
4142
4243@MainActor
43- final class MacNodeModeCoordinator {
44+ final class MacNodeModeCoordinator : NSObject {
4445 static let shared = MacNodeModeCoordinator ( )
4546 static var nodeIdentityProfile : GatewayDeviceIdentityProfile {
4647 self . resolveNodeIdentityProfile (
@@ -73,71 +74,108 @@ final class MacNodeModeCoordinator {
7374
7475 private let logger = Logger ( subsystem: " ai.openclaw " , category: " mac-node " )
7576 private var task : Task < Void , Never > ?
77+ private var endpointRefreshTask : Task < Void , Never > ?
78+ private var reconnectProbeTask : Task < Void , Never > ?
7679 private let runtime : MacNodeRuntime
7780 private let session : GatewayNodeSession
81+ private let refreshEvents : AsyncStream < Void >
82+ private let refreshContinuation : AsyncStream < Void > . Continuation
7883 private var autoRepairedTLSFingerprintsByStoreKey : [ String : String ] = [ : ]
7984 private var tlsSessionCache = MacNodeGatewayTLSSessionCache ( )
8085
81- private init ( ) {
86+ override private init ( ) {
8287 let session = GatewayNodeSession ( )
88+ let refreshEvents = AsyncStream . makeStream ( of: Void . self, bufferingPolicy: . bufferingNewest( 1 ) )
8389 self . session = session
8490 self . runtime = MacNodeRuntime (
8591 canvasSurfaceUrl: { await session. currentCanvasHostUrl ( ) } ,
8692 refreshCanvasSurfaceUrl: { await session. refreshCanvasHostUrl ( ) } )
93+ self . refreshEvents = refreshEvents. stream
94+ self . refreshContinuation = refreshEvents. continuation
95+ super. init ( )
96+
97+ NotificationCenter . default. addObserver (
98+ self ,
99+ selector: #selector( self . refreshNodeConfiguration) ,
100+ name: UserDefaults . didChangeNotification,
101+ object: UserDefaults . standard)
102+ NotificationCenter . default. addObserver (
103+ self ,
104+ selector: #selector( self . refreshNodeConfiguration) ,
105+ name: NSApplication . didBecomeActiveNotification,
106+ object: nil )
107+ NotificationCenter . default. addObserver (
108+ self ,
109+ selector: #selector( self . refreshNodeConfiguration) ,
110+ name: . openclawPermissionsChanged,
111+ object: nil )
112+ }
113+
114+ deinit {
115+ NotificationCenter . default. removeObserver ( self )
116+ self . refreshContinuation. finish ( )
87117 }
88118
89119 func start( ) {
90120 guard self . task == nil else { return }
91121 self . task = Task { [ weak self] in
92122 await self ? . run ( )
93123 }
124+ self . endpointRefreshTask = Task { [ weak self] in
125+ let states = await GatewayEndpointStore . shared. subscribe ( )
126+ var previousState : GatewayEndpointState ?
127+ for await state in states {
128+ if let previousState, state != previousState {
129+ self ? . refresh ( )
130+ }
131+ previousState = state
132+ }
133+ }
94134 }
95135
96136 func stop( ) {
97137 self . task? . cancel ( )
98138 self . task = nil
139+ self . endpointRefreshTask? . cancel ( )
140+ self . endpointRefreshTask = nil
141+ self . reconnectProbeTask? . cancel ( )
142+ self . reconnectProbeTask = nil
99143 Task { await self . session. disconnect ( ) }
100144 }
101145
102146 func setPreferredGatewayStableID( _ stableID: String ? ) {
103147 GatewayDiscoveryPreferences . setPreferredStableID ( stableID)
104- Task { await self . session. disconnect ( ) }
148+ Task {
149+ await self . session. disconnect ( )
150+ self . refresh ( )
151+ }
152+ }
153+
154+ func refresh( ) {
155+ self . refreshContinuation. yield ( )
105156 }
106157
107158 private func run( ) async {
108159 var retryDelay : UInt64 = 1_000_000_000
109- var lastCameraEnabled : Bool ?
110- var lastBrowserControlEnabled : Bool ?
160+ var refreshIterator = self . refreshEvents. makeAsyncIterator ( )
111161 let defaults = UserDefaults . standard
112162
113163 while !Task. isCancelled {
114164 if await MainActor . run ( body: { AppStateStore . shared. isPaused } ) {
115- try ? await Task . sleep ( nanoseconds : 1_000_000_000 )
165+ guard await refreshIterator . next ( ) != nil else { return }
116166 continue
117167 }
118168
119169 let cameraEnabled = defaults. object ( forKey: cameraEnabledKey) as? Bool ?? false
120- if lastCameraEnabled == nil {
121- lastCameraEnabled = cameraEnabled
122- } else if lastCameraEnabled != cameraEnabled {
123- lastCameraEnabled = cameraEnabled
124- await self . session. disconnect ( )
125- try ? await Task . sleep ( nanoseconds: 200_000_000 )
126- }
127170 let browserControlEnabled = OpenClawConfigFile . browserControlEnabled ( )
128- if lastBrowserControlEnabled == nil {
129- lastBrowserControlEnabled = browserControlEnabled
130- } else if lastBrowserControlEnabled != browserControlEnabled {
131- lastBrowserControlEnabled = browserControlEnabled
132- await self . session. disconnect ( )
133- try ? await Task . sleep ( nanoseconds: 200_000_000 )
134- }
135171
136172 var attemptedURL : URL ?
137173 do {
138174 let config = try await GatewayEndpointStore . shared. requireConfig ( )
139175 attemptedURL = config. url
140- let caps = self . currentCaps ( )
176+ let caps = self . currentCaps (
177+ browserControlEnabled: browserControlEnabled,
178+ cameraEnabled: cameraEnabled)
141179 let commands = self . currentCommands ( caps: caps)
142180 let permissions = await self . currentPermissions ( )
143181 let connectOptions = GatewayConnectOptions (
@@ -163,6 +201,7 @@ final class MacNodeModeCoordinator {
163201 sessionBox: sessionBox,
164202 onConnected: { [ weak self] in
165203 guard let self else { return }
204+ await self . cancelReconnectProbe ( )
166205 self . logger. info ( " mac node connected to gateway " )
167206 let mainSessionKey = await GatewayConnection . shared. mainSessionKey ( )
168207 await self . runtime. updateMainSessionKey ( mainSessionKey)
@@ -174,6 +213,7 @@ final class MacNodeModeCoordinator {
174213 onDisconnected: { [ weak self] reason in
175214 guard let self else { return }
176215 await self . runtime. setEventSender ( nil )
216+ await self . scheduleReconnectProbe ( )
177217 self . logger. error ( " mac node disconnected: \( reason, privacy: . public) " )
178218 } ,
179219 onInvoke: { [ weak self] req in
@@ -187,7 +227,9 @@ final class MacNodeModeCoordinator {
187227 } )
188228
189229 retryDelay = 1_000_000_000
190- try ? await Task . sleep ( nanoseconds: 1_000_000_000 )
230+ // GatewayNodeSession owns transport reconnects. Wait until inputs can
231+ // actually change instead of rereading config and TCC state every second.
232+ guard await refreshIterator. next ( ) != nil else { return }
191233 } catch {
192234 if await self . autoRepairStaleTLSPinIfNeeded ( error: error, url: attemptedURL) {
193235 retryDelay = 1_000_000_000
@@ -200,6 +242,28 @@ final class MacNodeModeCoordinator {
200242 }
201243 }
202244
245+ private func scheduleReconnectProbe( ) {
246+ self . reconnectProbeTask? . cancel ( )
247+ // GatewayChannel reconnects normally, but pauses after auth or pairing failures.
248+ // Probe only while disconnected so recovery does not restore steady idle polling.
249+ self . reconnectProbeTask = Task { [ weak self] in
250+ try ? await Task . sleep ( for: . seconds( 30 ) )
251+ guard !Task. isCancelled else { return }
252+ self ? . refresh ( )
253+ }
254+ }
255+
256+ private func cancelReconnectProbe( ) {
257+ self . reconnectProbeTask? . cancel ( )
258+ self . reconnectProbeTask = nil
259+ }
260+
261+ @objc private nonisolated func refreshNodeConfiguration( _: Notification ) {
262+ Task { @MainActor [ weak self] in
263+ self ? . refresh ( )
264+ }
265+ }
266+
203267 nonisolated static func resolvedCaps(
204268 browserControlEnabled: Bool ,
205269 cameraEnabled: Bool ,
@@ -222,11 +286,11 @@ final class MacNodeModeCoordinator {
222286 return caps
223287 }
224288
225- private func currentCaps( ) -> [ String ] {
289+ private func currentCaps( browserControlEnabled : Bool , cameraEnabled : Bool ) -> [ String ] {
226290 let rawLocationMode = UserDefaults . standard. string ( forKey: locationModeKey) ?? " off "
227291 return Self . resolvedCaps (
228- browserControlEnabled: OpenClawConfigFile . browserControlEnabled ( ) ,
229- cameraEnabled: UserDefaults . standard . object ( forKey : cameraEnabledKey ) as? Bool ?? false ,
292+ browserControlEnabled: browserControlEnabled,
293+ cameraEnabled: cameraEnabled ,
230294 locationMode: OpenClawLocationMode ( rawValue: rawLocationMode) ?? . off,
231295 connectionMode: AppStateStore . shared. connectionMode)
232296 }
0 commit comments