1use crate::restream_session::{NodeSession, SendPrio, UpstreamKeys};
8use crate::uplink_journal::UplinkJournal;
9use gate4agent_node_protocol::{
10 ClientRole, NodeEventEnvelope, NodeFailureCode, NodeId, NodeIncarnationId, NodeRequest,
11 NodeResponse, ServerFrame,
12};
13use gate4agent_node_wire::{LocalNodeClient, NodeClientError};
14use hatchery_core::{
15 BrowserClientOp, BrowserOpReply, BrowserStreamEvent, C2NodeEvent, C2NodeFailure,
16 C2NodeResponse, ChannelKind, NodeCursor, RoutedNodeEvent, RoutedNodeRequest,
17 RoutedNodeResponse,
18};
19use serde_json::{json, Value};
20use std::net::SocketAddr;
21use std::path::PathBuf;
22use std::sync::Arc;
23use std::time::Duration;
24use tokio::sync::{mpsc, watch};
25
26const HANDSHAKE: Duration = Duration::from_secs(10);
27const CONTROL_CHANNEL: u32 = 1;
28
29pub async fn run(
31 local_endpoint: impl Into<String>,
32 node_id: impl Into<String>,
33 local_token: impl Into<String>,
34 uplink: SocketAddr,
35 keys: UpstreamKeys,
36 journal: Option<Arc<UplinkJournal>>,
37 browser_profile_root: Option<PathBuf>,
38 calls: Option<crate::uplink_calls::NodeCalls>,
39 mut shutdown: watch::Receiver<bool>,
40) -> Result<(), String> {
41 let local_endpoint = local_endpoint.into();
42 let node_id_str = node_id.into();
43 let local_token = local_token.into();
44 let node_id = NodeId::new(node_id_str.clone()).map_err(|e| e.to_string())?;
45 let browser_profile_root = browser_profile_root.unwrap_or_else(|| {
46 std::env::temp_dir().join("hatchery-browser-profiles")
47 });
48
49 let mut client =
50 LocalNodeClient::connect(&local_endpoint, &node_id, ClientRole::Operator, &local_token)
51 .await
52 .map_err(|e| e.to_string())?;
53
54 let mut session = NodeSession::connect(
55 uplink,
56 &node_id_str,
57 CONTROL_CHANNEL,
58 ChannelKind::G4aControl,
59 keys,
60 journal,
61 HANDSHAKE,
62 )
63 .await?;
64
65 let hello = client.hello().clone();
66 let incarnation_id = hello.incarnation_id;
67 let connection_id = hello.connection_id;
68 tracing::info!(%node_id_str, connection_id, "restream g4a bridge up");
69 if let Some(c) = &calls {
71 c.set_uplink(Some(session.uplink())).await;
72 }
73 let result = run_loop(&mut client, &mut session, &node_id, incarnation_id, &local_endpoint, &local_token, browser_profile_root, &mut shutdown).await;
74 if let Some(c) = &calls {
75 c.set_uplink(None).await;
76 }
77 result
78}
79
80#[allow(clippy::too_many_arguments)]
81async fn run_loop(
82 client: &mut LocalNodeClient,
83 session: &mut NodeSession,
84 node_id: &NodeId,
85 incarnation_id: NodeIncarnationId,
86 local_endpoint: &str,
87 local_token: &str,
88 browser_profile_root: PathBuf,
89 shutdown: &mut watch::Receiver<bool>,
90) -> Result<(), String> {
91
92 let worker_client =
96 LocalNodeClient::connect(&local_endpoint, &node_id, ClientRole::Operator, &local_token)
97 .await
98 .map_err(|e| e.to_string())?;
99 let (req_tx, req_rx) = mpsc::unbounded_channel::<Vec<u8>>();
100 let (out_tx, mut out_rx) = mpsc::unbounded_channel::<Vec<u8>>();
101 let (browser_ev_tx, mut browser_ev_rx) = mpsc::unbounded_channel::<BrowserStreamEvent>();
102 let (worker_stop_tx, worker_stop) = watch::channel(false);
103 let mut worker = tokio::spawn(request_worker(
104 worker_client,
105 node_id.clone(),
106 req_rx,
107 out_tx,
108 browser_profile_root.clone(),
109 browser_ev_tx,
110 worker_stop,
111 ));
112 let mut last_pty_input: Option<std::time::Instant> = None;
113 let uplink_watch = session.uplink();
114 let result = loop {
115 if *shutdown.borrow() {
116 break Ok(());
117 }
118 tokio::select! {
119 changed = shutdown.changed() => {
120 if changed.is_err() || *shutdown.borrow() { break Ok(()); }
121 }
122 done = &mut worker => {
123 break match done { Ok(r) => r.and(Err("request worker ended".into())), Err(e) => Err(e.to_string()) };
124 }
125 frame = client.recv() => {
126 match frame {
127 Ok(ServerFrame::Event(envelope)) => {
128 let is_pty = matches!(envelope.event, gate4agent_node_protocol::NodeEvent::TerminalFrame { .. });
129 if is_pty {
130 if let Some(at) = last_pty_input.take() {
131 tracing::debug!(target: "pty_lat", bridge_us = at.elapsed().as_micros() as u64, "bridge: pty input received to echo frame forwarded");
132 }
133 }
134 if envelope.event.requires_harness_mcp_proxy_capability() { continue; }
135 let plain = event_bytes(&node_id, incarnation_id, envelope)?;
136 let prio = if is_pty { SendPrio::Pty } else { SendPrio::Control };
137 if let Err(e) = session.send_prio(&plain, prio).await { break Err(e); }
138 }
139 Ok(_) => break Err("local g4a sent an unexpected frame on the event connection".into()),
140 Err(err) => break Err(err.to_string()),
141 }
142 }
143 out = out_rx.recv() => {
144 let Some(out) = out else { break Err("request worker closed".into()) };
145 if let Err(e) = session.send_prio(&out, SendPrio::Control).await { break Err(e); }
146 }
147 plain = session.recv(Duration::from_secs(120)) => {
148 match plain {
149 Ok(bytes) => {
150 if last_pty_input.is_none() && is_pty_input(&bytes) { last_pty_input = Some(std::time::Instant::now()); }
151 if req_tx.send(bytes).is_err() { break Err("request worker closed".into()); }
152 }
153 Err(err) if err.contains("timeout") => continue,
154 Err(err) => break Err(err),
155 }
156 }
157 _ = uplink_watch.closed() => break Err("uplink write stalled; reconnecting".into()),
158 ev = browser_ev_rx.recv() => {
159 let Some(ev) = ev else { continue };
160 let plain = serde_json::to_vec(&json!({ "browser": ev })).map_err(|e| e.to_string())?;
161 if let Err(e) = session.send_prio(&plain, SendPrio::Browser).await { break Err(e); }
163 }
164 }
165 };
166 let _ = worker_stop_tx.send(true);
168 let _ = tokio::time::timeout(Duration::from_secs(3), worker).await;
169 result
170}
171
172fn is_pty_input(plain: &[u8]) -> bool {
173 let s = String::from_utf8_lossy(&plain[..plain.len().min(512)]);
175 s.contains("TerminalBytes") || s.contains("terminal_bytes")
176}
177
178type Sink = mpsc::UnboundedSender<Vec<u8>>;
179
180async fn request_worker(
184 mut client: LocalNodeClient,
185 node_id: NodeId,
186 mut rx: mpsc::UnboundedReceiver<Vec<u8>>,
187 out: Sink,
188 browser_profile_root: PathBuf,
189 browser_ev_tx: mpsc::UnboundedSender<BrowserStreamEvent>,
190 mut stop: watch::Receiver<bool>,
191) -> Result<(), String> {
192 #[cfg(feature = "d2b")]
193 let browsers = crate::browser_capture::shared_map();
194 let hello = client.hello().clone();
195 let mut incarnation_id = hello.incarnation_id;
196 let connection_id = hello.connection_id;
197 let mut controller_owned = hello.controller.as_ref().is_some_and(|c| c.connection_id == connection_id);
198 let mut inflight: std::collections::HashMap<u64, (u64, NodeRequest)> = std::collections::HashMap::new();
199 let mut retry: Vec<(u64, NodeRequest)> = Vec::new();
200 let fwd = |out: &Sink, incarnation_id: NodeIncarnationId, envelope: NodeEventEnvelope| -> Result<(), String> {
203 if envelope.event.requires_harness_mcp_proxy_capability() {
204 out.send(event_bytes(&node_id, incarnation_id, envelope)?).map_err(|_| "bridge closed".to_string())?;
205 }
206 Ok(())
207 };
208 loop {
209 tokio::select! {
210 _ = stop.changed() => {
211 let _ = release_controller(&mut client, &mut controller_owned).await;
212 return Ok(());
213 }
214 frame = client.recv() => {
215 match frame.map_err(|e| e.to_string())? {
216 ServerFrame::Event(envelope) => fwd(&out, incarnation_id, envelope)?,
217 ServerFrame::Reply(reply) => {
218 on_pipelined_reply(&out, &node_id, &mut incarnation_id, &mut controller_owned, &mut inflight, &mut retry, reply)?;
219 }
220 _ => return Err("local g4a sent an unexpected idle frame".into()),
221 }
222 }
223 plain = rx.recv() => {
224 let Some(bytes) = plain else {
225 let _ = release_controller(&mut client, &mut controller_owned).await;
226 return Ok(());
227 };
228 if controller_owned && retry.is_empty() {
229 if let Some((op_id, request)) = pipelinable(&bytes, &node_id, incarnation_id) {
230 let request_id = client.send(request.clone()).await.map_err(|e| e.to_string())?;
231 inflight.insert(request_id, (op_id, request));
232 continue;
233 }
234 }
235 while !inflight.is_empty() {
236 match client.recv().await.map_err(|e| e.to_string())? {
237 ServerFrame::Event(envelope) => fwd(&out, incarnation_id, envelope)?,
238 ServerFrame::Reply(reply) => on_pipelined_reply(&out, &node_id, &mut incarnation_id, &mut controller_owned, &mut inflight, &mut retry, reply)?,
239 _ => return Err("local g4a sent an unexpected idle frame".into()),
240 }
241 }
242 let mut queue: Vec<Vec<u8>> = Vec::new();
243 for (op_id, request) in std::mem::take(&mut retry) {
244 let routed = RoutedNodeRequest {
246 route: hatchery_core::NodeRoute {
247 node_id: hatchery_core::NodeId::new(node_id.as_str().to_owned()).map_err(|e| e.to_string())?,
248 expected_incarnation_id: NodeIncarnationId::from_bytes([0; 16]),
249 },
250 request,
251 };
252 queue.push(serde_json::to_vec(&json!({ "op_id": op_id, "g4a": routed })).map_err(|e| e.to_string())?);
253 }
254 queue.push(bytes);
255 for plain in queue {
256 let started = std::time::Instant::now();
257 let kind: String = serde_json::from_slice::<Value>(&plain).ok().and_then(|v| v.pointer("/g4a/request").and_then(|r| r.as_object().and_then(|o| o.keys().next().cloned()).or_else(|| r.as_str().map(str::to_owned)))).unwrap_or_else(|| "browser".into());
258 handle_control(&out, &mut client, &node_id, &mut incarnation_id, connection_id, &mut controller_owned, &plain, &browser_profile_root, browser_ev_tx.clone(), #[cfg(feature = "d2b")] &browsers).await?;
259 while let Some(envelope) = client.take_event() {
260 fwd(&out, incarnation_id, envelope)?;
261 }
262 if started.elapsed() > Duration::from_millis(500) {
263 tracing::warn!(request = %kind, elapsed_ms = started.elapsed().as_millis() as u64, "bridge: slow node request held the request queue");
264 }
265 }
266 }
267 }
268 }
269}
270
271fn pipelinable(plain: &[u8], node_id: &NodeId, incarnation_id: NodeIncarnationId) -> Option<(u64, NodeRequest)> {
273 let v: Value = serde_json::from_slice(plain).ok()?;
274 let op_id = v.get("op_id")?.as_u64()?;
275 let routed: RoutedNodeRequest = serde_json::from_value(v.get("g4a")?.clone()).ok()?;
276 if routed.route.node_id.as_str() != node_id.as_str() { return None; }
277 let expected = routed.route.expected_incarnation_id;
278 if expected != NodeIncarnationId::from_bytes([0; 16]) && expected != incarnation_id { return None; }
279 match routed.request {
280 NodeRequest::TerminalBytes { .. } | NodeRequest::TerminalControl { .. } | NodeRequest::Interrupt { .. } => Some((op_id, routed.request)),
281 _ => None,
282 }
283}
284
285fn on_pipelined_reply(
286 out: &Sink,
287 node_id: &NodeId,
288 incarnation_id: &mut NodeIncarnationId,
289 controller_owned: &mut bool,
290 inflight: &mut std::collections::HashMap<u64, (u64, NodeRequest)>,
291 retry: &mut Vec<(u64, NodeRequest)>,
292 reply: gate4agent_node_protocol::ResponseEnvelope,
293) -> Result<(), String> {
294 let Some((op_id, request)) = inflight.remove(&reply.request_id) else {
295 return Err(format!("unexpected local reply id {}", reply.request_id));
296 };
297 if let Err(f) = &reply.result {
298 if f.code == NodeFailureCode::ControllerRequired {
299 *controller_owned = false;
300 retry.push((op_id, request));
301 return Ok(());
302 }
303 }
304 if !retry.is_empty() {
305 retry.push((op_id, request));
307 return Ok(());
308 }
309 let mapped = reply
310 .result
311 .map(|r| C2NodeResponse::from_node_response_with_control_detail(&r))
312 .map_err(|f| C2NodeFailure::from(&f));
313 let plain = serde_json::to_vec(&json!({ "op_id": op_id, "g4a": RoutedNodeResponse { node_id: node_id.clone(), incarnation_id: *incarnation_id, response: mapped } })).map_err(|e| e.to_string())?;
314 out.send(plain).map_err(|_| "bridge closed".to_string())
315}
316
317async fn handle_control(
318 session: &Sink,
319 client: &mut LocalNodeClient,
320 node_id: &NodeId,
321 incarnation_id: &mut NodeIncarnationId,
322 connection_id: u64,
323 controller_owned: &mut bool,
324 plain: &[u8],
325 browser_profile_root: &std::path::Path,
326 browser_ev_tx: mpsc::UnboundedSender<BrowserStreamEvent>,
327 #[cfg(feature = "d2b")] browsers: &std::sync::Arc<crate::browser_capture::BrowserSessionMap>,
328) -> Result<(), String> {
329 let v: Value = serde_json::from_slice(plain).map_err(|e| e.to_string())?;
330 let Some(op_id) = v.get("op_id").and_then(Value::as_u64) else {
331 return Ok(());
332 };
333 if let Some(browser) = v.get("browser") {
334 let op: BrowserClientOp = serde_json::from_value(browser.clone()).map_err(|e| e.to_string())?;
335 let reply = handle_browser_op(
336 &node_id_str_from(node_id),
337 op,
338 browser_profile_root,
339 browser_ev_tx,
340 #[cfg(feature = "d2b")]
341 browsers,
342 )
343 .await;
344 let plain = serde_json::to_vec(&json!({ "op_id": op_id, "browser": reply })).map_err(|e| e.to_string())?;
345 return session.send(plain).map_err(|_| "bridge closed".to_string());
346 }
347 let g4a = v.get("g4a").cloned().unwrap_or(Value::Null);
348 let routed: RoutedNodeRequest = serde_json::from_value(g4a).map_err(|e| e.to_string())?;
349 if routed.route.node_id.as_str() != node_id.as_str() {
350 return send_reply(
351 session,
352 op_id,
353 &failure_response(node_id, *incarnation_id, NodeFailureCode::InvalidRequest, "route node id does not match bridge"),
354 )
355 .await;
356 }
357 let expected = routed.route.expected_incarnation_id;
359 if expected != NodeIncarnationId::from_bytes([0; 16]) && expected != *incarnation_id {
360 return send_reply(
361 session,
362 op_id,
363 &failure_response(
364 node_id,
365 *incarnation_id,
366 NodeFailureCode::InvalidRequest,
367 "node incarnation changed",
368 ),
369 )
370 .await;
371 }
372
373 let request = routed.request;
374 let controller_gated = !is_read_only(&request);
375 if controller_gated && !*controller_owned {
376 match acquire_controller(client, connection_id).await {
377 Ok(true) => *controller_owned = true,
378 Ok(false) => {
379 return send_reply(
380 session,
381 op_id,
382 &failure_response(
383 node_id,
384 *incarnation_id,
385 NodeFailureCode::ControllerBusy,
386 "node controller lease is unavailable",
387 ),
388 )
389 .await;
390 }
391 Err(err) => return Err(err.to_string()),
392 }
393 }
394
395 let deadline = request_deadline(&request);
396 let mut remaining_retries = 1u8;
403 let response = loop {
404 match tokio::time::timeout(deadline, client.request(request.clone())).await {
405 Ok(Ok(response)) => break Ok(response),
406 Ok(Err(NodeClientError::Node(failure))) => {
407 if controller_gated
408 && failure.code == NodeFailureCode::ControllerRequired
409 && remaining_retries > 0
410 {
411 remaining_retries -= 1;
412 *controller_owned = false;
413 match acquire_controller(client, connection_id).await {
414 Ok(true) => {
415 *controller_owned = true;
416 continue;
417 }
418 Ok(false) => break Err(failure),
419 Err(err) => return Err(err.to_string()),
420 }
421 }
422 break Err(failure);
423 }
424 Ok(Err(err)) => return Err(err.to_string()),
425 Err(_) => {
426 return send_reply(
427 session,
428 op_id,
429 &failure_response(
430 node_id,
431 *incarnation_id,
432 NodeFailureCode::HostDirectoryReadTimedOut,
433 "node request deadline exceeded",
434 ),
435 )
436 .await;
437 }
438 }
439 };
440
441 *incarnation_id = client.hello().incarnation_id;
442
443 let mut mapped = response
444 .map(|r| C2NodeResponse::from_node_response_with_control_detail(&r))
445 .map_err(|f| C2NodeFailure::from(&f));
446 if let Ok(C2NodeResponse::Snapshot { snapshot, .. }) = &mut mapped {
447 snapshot.machine = Some(crate::collect_machine_environment());
448 }
449 send_reply(
450 session,
451 op_id,
452 &RoutedNodeResponse {
453 node_id: node_id.clone(),
454 incarnation_id: *incarnation_id,
455 response: mapped,
456 },
457 )
458 .await
459}
460
461fn failure_response(
462 node_id: &NodeId,
463 incarnation_id: NodeIncarnationId,
464 code: NodeFailureCode,
465 message: &str,
466) -> RoutedNodeResponse {
467 RoutedNodeResponse {
468 node_id: node_id.clone(),
469 incarnation_id,
470 response: Err(C2NodeFailure {
471 code,
472 message: message.into(),
473 }),
474 }
475}
476
477async fn send_reply(
478 session: &Sink,
479 op_id: u64,
480 response: &RoutedNodeResponse,
481) -> Result<(), String> {
482 let plain =
483 serde_json::to_vec(&json!({ "op_id": op_id, "g4a": response })).map_err(|e| e.to_string())?;
484 session.send(plain).map_err(|_| "bridge closed".to_string())
485}
486
487fn event_bytes(
488 node_id: &NodeId,
489 incarnation_id: NodeIncarnationId,
490 envelope: NodeEventEnvelope,
491) -> Result<Vec<u8>, String> {
492 let event = RoutedNodeEvent {
493 node_id: node_id.clone(),
494 cursor: NodeCursor {
495 incarnation_id,
496 sequence: envelope.sequence,
497 },
498 event: C2NodeEvent::from_node_event_with_control_detail(&envelope.event),
499 };
500 serde_json::to_vec(&json!({ "g4a": event })).map_err(|e| e.to_string())
501}
502
503async fn acquire_controller(
504 client: &mut LocalNodeClient,
505 connection_id: u64,
506) -> Result<bool, NodeClientError> {
507 match client
508 .request(NodeRequest::AcquireController {
509 lease_ms: gate4agent_node_protocol::MAX_CONTROLLER_LEASE_MS,
510 })
511 .await?
512 {
513 NodeResponse::Controller { controller } => Ok(controller
514 .as_ref()
515 .is_some_and(|state| state.connection_id == connection_id)),
516 _ => Err(NodeClientError::Protocol(
517 "controller acquisition returned a different response".into(),
518 )),
519 }
520}
521
522async fn release_controller(
523 client: &mut LocalNodeClient,
524 controller_owned: &mut bool,
525) -> Result<(), NodeClientError> {
526 if !*controller_owned {
527 return Ok(());
528 }
529 let _ = client.request(NodeRequest::ReleaseController).await?;
530 *controller_owned = false;
531 Ok(())
532}
533
534fn is_read_only(request: &NodeRequest) -> bool {
535 matches!(
536 request,
537 NodeRequest::Snapshot
538 | NodeRequest::Resync { .. }
539 | NodeRequest::BrowseHostDirectories { .. }
540 | NodeRequest::InspectWorkspace { .. }
541 | NodeRequest::ReadWorkspaceFile { .. }
542 | NodeRequest::ReadGitHistory { .. }
543 | NodeRequest::ReadGitDiff { .. }
544 | NodeRequest::CatalogNativeSessions { .. }
545 | NodeRequest::PageNativeSessions { .. }
546 | NodeRequest::PreviewNativeSession { .. }
547 | NodeRequest::PreviewSessionRecord { .. }
548 )
549}
550
551fn request_deadline(request: &NodeRequest) -> Duration {
552 match request {
553 NodeRequest::CreateStandaloneWorkspace { .. }
554 | NodeRequest::CreateWorktree { .. }
555 | NodeRequest::RemoveWorktree { .. }
556 | NodeRequest::CleanupManagedWorktree { .. } => Duration::from_secs(240),
557 NodeRequest::Spawn { .. }
558 | NodeRequest::Resume { .. }
559 | NodeRequest::Stop { .. }
560 | NodeRequest::SpawnSpec { .. }
561 | NodeRequest::SpawnSpecWithHarnessMcp { .. }
562 | NodeRequest::SpawnManagedWorktree { .. } => Duration::from_secs(60),
563 NodeRequest::InspectWorkspace { .. } => Duration::from_secs(15),
564 _ => Duration::from_secs(30),
565 }
566}
567
568
569fn node_id_str_from(node_id: &NodeId) -> String {
570 node_id.as_str().to_owned()
571}
572
573async fn handle_browser_op(
574 node_id: &str,
575 op: BrowserClientOp,
576 browser_profile_root: &std::path::Path,
577 _browser_ev_tx: mpsc::UnboundedSender<BrowserStreamEvent>,
578 #[cfg(feature = "d2b")] browsers: &std::sync::Arc<crate::browser_capture::BrowserSessionMap>,
579) -> BrowserOpReply {
580 #[cfg(feature = "local-server")]
582 {
583 if let Some(reply) = handle_local_server_op(node_id, &op).await {
584 return reply;
585 }
586 }
587 #[cfg(not(feature = "d2b"))]
588 {
589 let _ = (node_id, op, browser_profile_root, _browser_ev_tx);
590 return BrowserOpReply {
591 ok: false,
592 session: String::new(),
593 message: "dig2browser feature (d2b) is not enabled in this node build".into(),
594 body: None,
595 };
596 }
597 #[cfg(feature = "d2b")]
598 {
599 match op {
600 BrowserClientOp::Start {
601 session,
602 url,
603 profile_dir,
604 } => {
605 let dir = match profile_dir {
606 Some(p) if !p.is_empty() => PathBuf::from(p),
607 _ => browser_profile_root.join(&session),
608 };
609 match browsers
610 .start(node_id.to_owned(), session.clone(), url, dir, _browser_ev_tx)
611 .await
612 {
613 Ok(()) => BrowserOpReply {
614 ok: true,
615 session,
616 message: String::new(),
617 body: None,
618 },
619 Err(message) => BrowserOpReply {
620 ok: false,
621 session,
622 message,
623 body: None,
624 },
625 }
626 }
627 BrowserClientOp::Input { session, bytes } => match browsers.input(&session, &bytes).await
628 {
629 Ok(()) => BrowserOpReply {
630 ok: true,
631 session,
632 message: String::new(),
633 body: None,
634 },
635 Err(message) => BrowserOpReply {
636 ok: false,
637 session,
638 message,
639 body: None,
640 },
641 },
642 BrowserClientOp::Stop { session } => {
643 #[cfg(feature = "local-server")]
644 {
645 crate::local_server::LocalServerPool::global()
646 .stop_session(&session)
647 .await;
648 }
649 match browsers.stop(&session).await {
650 Ok(()) => BrowserOpReply {
651 ok: true,
652 session,
653 message: String::new(),
654 body: None,
655 },
656 Err(message) => BrowserOpReply {
657 ok: false,
658 session,
659 message,
660 body: None,
661 },
662 }
663 }
664 BrowserClientOp::LocalServerStart { session, .. }
665 | BrowserClientOp::LocalServerStop { session, .. }
666 | BrowserClientOp::LocalServerList { session } => BrowserOpReply {
667 ok: false,
668 session,
669 message: "local_server op was not handled".into(),
670 body: None,
671 },
672 }
673 }
674}
675
676#[cfg(feature = "local-server")]
677async fn handle_local_server_op(node_id: &str, op: &BrowserClientOp) -> Option<BrowserOpReply> {
678 let _ = node_id;
679 let (session, door_op, payload) = match op {
680 BrowserClientOp::LocalServerList { session } => (
681 session.clone(),
682 "local_server.list",
683 serde_json::json!({ "session": session }),
684 ),
685 BrowserClientOp::LocalServerStop { session, id } => (
686 session.clone(),
687 "local_server.stop",
688 serde_json::json!({ "session": session, "id": id }),
689 ),
690 BrowserClientOp::LocalServerStart {
691 session,
692 kind,
693 root,
694 cwd,
695 bin,
696 open,
697 spa,
698 } => {
699 let mut payload = serde_json::json!({
700 "session": session,
701 "kind": kind,
702 "open": open,
703 });
704 if let Some(r) = root {
705 payload["root"] = serde_json::json!(r);
706 }
707 if let Some(c) = cwd {
708 payload["cwd"] = serde_json::json!(c);
709 }
710 if let Some(b) = bin {
711 payload["bin"] = serde_json::json!(b);
712 }
713 if let Some(s) = spa {
714 payload["spa"] = serde_json::json!(s);
715 }
716 (session.clone(), "local_server.start", payload)
717 }
718 _ => return None,
719 };
720 match crate::local_server::handle_op(door_op, &payload).await {
721 Ok(body) => Some(BrowserOpReply {
722 ok: true,
723 session,
724 message: String::new(),
725 body: Some(body),
726 }),
727 Err(message) => Some(BrowserOpReply {
728 ok: false,
729 session,
730 message,
731 body: None,
732 }),
733 }
734}