1use forge_primitives::SessionSlot;
11use forge_primitives::browser::{HandOff, HandOffAnswer, HandOffEnding};
12use serde_json::Value;
13use tokio::sync::oneshot;
14
15use crate::workspace::Workspace;
16
17pub struct BrowserAttachment {
19 pub key: String,
21 pub id: u16,
23 pub tab: u16,
25 pub frames: tokio::sync::watch::Receiver<Option<forge_primitives::browser::BrowserFrame>>,
27 pub view: tokio::sync::watch::Receiver<forge_primitives::browser::BrowserViewState>,
29 pub tabs: tokio::sync::watch::Receiver<forge_primitives::browser::BrowserTabList>,
31 pub nav: tokio::sync::watch::Receiver<Option<forge_primitives::browser::BrowserNavState>>,
34 pub failures: tokio::sync::watch::Receiver<Option<String>>,
36 pub files: tokio::sync::watch::Receiver<Option<forge_primitives::browser::BrowserFileAsk>>,
38 pub downloads: tokio::sync::broadcast::Receiver<forge_primitives::browser::BrowserDownload>,
40 pub audio: tokio::sync::broadcast::Receiver<forge_primitives::browser::BrowserAudio>,
42}
43
44pub struct LiveSeats {
49 pub generation: u64,
50 pub seats: Vec<SessionSlot>,
51}
52
53#[async_trait::async_trait]
55pub trait BrowserHost: Send + Sync {
56 async fn call(
58 &self,
59 seat: &SessionSlot,
60 tool: &str,
61 args: Value,
62 ) -> Result<Vec<forge_primitives::browser::BrowserPart>, String>;
63
64 async fn attach(
74 &self,
75 viewer: u64,
76 seat: &SessionSlot,
77 tab: Option<u16>,
78 ) -> Result<BrowserAttachment, String>;
79
80 async fn attach_page(
83 &self,
84 _viewer: u64,
85 _seat: &SessionSlot,
86 _target: &str,
87 ) -> Result<BrowserAttachment, String> {
88 Err("this host cannot open a page by id".to_owned())
89 }
90
91 async fn detach(&self, viewer: u64);
93
94 async fn input(
96 &self,
97 profile: &str,
98 tab: u16,
99 viewer: u64,
100 seq: u64,
101 event: &forge_primitives::browser::BrowserInputEvent,
102 ) -> Result<(), String>;
103
104 async fn answer_files(
106 &self,
107 profile: &str,
108 tab: u16,
109 id: u64,
110 files: Vec<forge_primitives::browser::BrowserUpload>,
111 ) -> Result<(), String>;
112
113 async fn take_control(&self, profile: &str, tab: u16, viewer: u64) -> Result<(), String>;
115
116 async fn resize(
120 &self,
121 profile: &str,
122 tab: u16,
123 viewer: u64,
124 width: u32,
125 height: u32,
126 scale: f64,
127 );
128
129 async fn release_control(&self, profile: &str, tab: u16, viewer: u64);
131
132 async fn nav(
136 &self,
137 profile: &str,
138 tab: u16,
139 viewer: u64,
140 back: bool,
141 ) -> Result<forge_primitives::browser::BrowserNavState, String>;
142
143 async fn nav_state(
145 &self,
146 profile: &str,
147 tab: u16,
148 ) -> Option<forge_primitives::browser::BrowserNavState>;
149
150 async fn tabs(
152 &self,
153 seat: &SessionSlot,
154 ) -> Result<forge_primitives::browser::BrowserTabList, String>;
155
156 async fn tab_stream(
158 &self,
159 seat: &SessionSlot,
160 ) -> Result<tokio::sync::watch::Receiver<forge_primitives::browser::BrowserTabList>, String>;
161
162 async fn sweep_sessions(&self, seats: &[SessionSlot], generation: u64);
168
169 async fn replace_seat(&self, seat: &SessionSlot);
172
173 fn session_ended(&self, seat: &SessionSlot);
178
179 async fn hand_off_started(&self, seat: &SessionSlot, target: &str) -> Result<u16, String>;
185
186 async fn hand_off_finished(&self, seat: &SessionSlot, tab: u16);
189
190 async fn warm(&self, seat: &SessionSlot);
193
194 async fn stop(&self) -> Vec<String>;
201}
202
203pub const NO_BROWSER_HOST: &str = "this server has no browser host installed";
205
206impl Workspace {
207 pub(crate) async fn register_browser_hand_off(
216 &self,
217 caller: &SessionSlot,
218 reason: &str,
219 target: &str,
220 ) -> Result<(uuid::Uuid, oneshot::Receiver<HandOffEnding>), String> {
221 let (sender, receiver) = oneshot::channel();
222 let id = uuid::Uuid::new_v4();
223 let tab = self.browser_hand_off_started(caller, target).await?;
232 let handoff = HandOff { id, reason: reason.to_owned(), tab, target: target.to_owned() };
233 self.browser_handoffs.lock().insert(id, (caller.clone(), handoff.clone(), sender));
234 let answerable = self.update_sender().send_answering(
235 crate::protocol::SessionUpdate::BrowserHandOffPending { key: caller.clone(), handoff },
236 );
237 if !answerable {
238 self.browser_handoffs.lock().remove(&id);
239 self.tell_browser_hand_off_finished(caller, tab);
242 let (_ignored_sender, dead_receiver) = oneshot::channel();
243 return Ok((id, dead_receiver));
244 }
245 self.note_ask(caller);
246 Ok((id, receiver))
247 }
248
249 async fn browser_hand_off_started(
253 &self,
254 caller: &SessionSlot,
255 target: &str,
256 ) -> Result<u16, String> {
257 let Some(host) = self.browser_host() else {
258 return Err(NO_BROWSER_HOST.to_owned());
259 };
260 host.hand_off_started(caller, target).await
261 }
262
263 pub(crate) fn tell_browser_hand_off_finished(&self, caller: &SessionSlot, tab: u16) {
266 let (Some(host), Ok(runtime)) =
267 (self.browser_host(), tokio::runtime::Handle::try_current())
268 else {
269 return;
270 };
271 let seat = caller.clone();
272 runtime.spawn(async move { host.hand_off_finished(&seat, tab).await });
273 }
274
275 pub(crate) fn browser_handoff_waiting(&self, id: uuid::Uuid, caller: &SessionSlot) -> bool {
279 self.browser_handoffs.lock().get(&id).is_some_and(|(owner, _, _)| owner == caller)
280 }
281
282 pub(crate) fn resolve_browser_hand_off(
285 &self,
286 id: uuid::Uuid,
287 caller: &SessionSlot,
288 answer: HandOffAnswer,
289 ) -> bool {
290 let mut parked = self.browser_handoffs.lock();
291 if !parked.get(&id).is_some_and(|(owner, _, _)| owner == caller) {
292 return false;
293 }
294 let Some((owner, handoff, sender)) = parked.remove(&id) else { return false };
295 drop(parked);
296 self.tell_browser_hand_off_finished(&owner, handoff.tab);
299 tracing::debug!(
304 event_name = "browser_hand_off_resolved",
305 id = %id,
306 ending = ?answer.ending,
307 source = ?answer.source,
308 client = answer.client,
309 seat = %owner.display(),
310 tab = handoff.tab,
311 "a parked browser hand-off left the registry"
312 );
313 let _ = sender.send(answer.ending);
314 let _ = self.update_sender().send(crate::protocol::SessionUpdate::BrowserHandOffResolved {
315 key: owner,
316 id,
317 answer,
318 });
319 true
320 }
321}
322
323#[cfg(test)]
326pub(crate) mod testing {
327 use super::*;
328 use forge_primitives::browser::{BrowserInputEvent, BrowserNavState, BrowserPart};
329 use tokio::sync::mpsc;
330
331 pub(crate) struct StubHost {
332 pub(crate) calls: mpsc::UnboundedSender<(String, Value)>,
333 }
334
335 impl StubHost {
336 fn heard(&self, what: &str, args: Value) {
337 let _ = self.calls.send((what.to_owned(), args));
338 }
339 }
340
341 #[async_trait::async_trait]
342 impl BrowserHost for StubHost {
343 async fn call(
344 &self,
345 _seat: &SessionSlot,
346 tool: &str,
347 args: Value,
348 ) -> Result<Vec<BrowserPart>, String> {
349 self.heard(tool, args);
350 Ok(vec![BrowserPart::Text { text: "clicked".to_owned() }])
351 }
352
353 async fn attach(
354 &self,
355 _viewer: u64,
356 _seat: &SessionSlot,
357 _tab: Option<u16>,
358 ) -> Result<BrowserAttachment, String> {
359 Err("no viewers here".to_owned())
360 }
361
362 async fn detach(&self, _viewer: u64) {}
363
364 async fn input(
365 &self,
366 _profile: &str,
367 _tab: u16,
368 _viewer: u64,
369 _seq: u64,
370 _event: &BrowserInputEvent,
371 ) -> Result<(), String> {
372 Err("no viewers here".to_owned())
373 }
374
375 async fn answer_files(
376 &self,
377 _profile: &str,
378 _tab: u16,
379 _id: u64,
380 _files: Vec<forge_primitives::browser::BrowserUpload>,
381 ) -> Result<(), String> {
382 Err("no viewers here".to_owned())
383 }
384
385 async fn take_control(
386 &self,
387 _profile: &str,
388 _tab: u16,
389 _viewer: u64,
390 ) -> Result<(), String> {
391 Err("no viewers here".to_owned())
392 }
393
394 async fn resize(
395 &self,
396 _profile: &str,
397 _tab: u16,
398 _viewer: u64,
399 _width: u32,
400 _height: u32,
401 _scale: f64,
402 ) {
403 }
404
405 async fn release_control(&self, _profile: &str, _tab: u16, _viewer: u64) {}
406
407 async fn nav(
408 &self,
409 _profile: &str,
410 _tab: u16,
411 _viewer: u64,
412 _back: bool,
413 ) -> Result<BrowserNavState, String> {
414 Err("no viewers here".to_owned())
415 }
416
417 async fn nav_state(&self, _profile: &str, _tab: u16) -> Option<BrowserNavState> {
418 None
419 }
420
421 async fn tabs(
422 &self,
423 _seat: &SessionSlot,
424 ) -> Result<forge_primitives::browser::BrowserTabList, String> {
425 Err("no viewers here".to_owned())
426 }
427
428 async fn tab_stream(
429 &self,
430 _seat: &SessionSlot,
431 ) -> Result<tokio::sync::watch::Receiver<forge_primitives::browser::BrowserTabList>, String>
432 {
433 Err("no viewers here".to_owned())
434 }
435
436 async fn sweep_sessions(&self, seats: &[SessionSlot], generation: u64) {
437 self.heard(
438 "sweep_sessions",
439 serde_json::json!({ "seats": seats, "generation": generation }),
440 );
441 }
442
443 async fn replace_seat(&self, seat: &SessionSlot) {
444 self.heard("replace_seat", serde_json::json!(seat));
445 }
446
447 fn session_ended(&self, seat: &SessionSlot) {
448 self.heard("session_ended", serde_json::json!(seat));
449 }
450
451 async fn hand_off_started(&self, seat: &SessionSlot, target: &str) -> Result<u16, String> {
452 self.heard("hand_off_started", serde_json::json!({ "seat": seat, "page": target }));
453 if target == "gone" {
456 return Err(format!("no tab has the page id '{target}'"));
457 }
458 Ok(2)
459 }
460
461 async fn hand_off_finished(&self, seat: &SessionSlot, tab: u16) {
462 self.heard("hand_off_finished", serde_json::json!({ "seat": seat, "tab": tab }));
463 }
464
465 async fn warm(&self, seat: &SessionSlot) {
466 self.heard("warm", serde_json::json!(seat));
467 }
468
469 async fn stop(&self) -> Vec<String> {
470 Vec::new()
471 }
472 }
473}
474
475#[cfg(test)]
476mod tests {
477 use super::*;
478 use crate::browser::testing::StubHost;
479 use forge_primitives::browser::HandOffSource;
480
481 fn answer(ending: HandOffEnding, source: HandOffSource) -> HandOffAnswer {
483 HandOffAnswer { ending, source, client: Some(4) }
484 }
485
486 fn with_host(
488 ws: &std::sync::Arc<Workspace>,
489 ) -> tokio::sync::mpsc::UnboundedReceiver<(String, serde_json::Value)> {
490 let (calls, heard) = tokio::sync::mpsc::unbounded_channel();
491 ws.install_browser_host(std::sync::Arc::new(StubHost { calls }));
492 heard
493 }
494
495 #[tokio::test]
499 async fn a_hand_off_is_addressed_to_the_session_that_asked() {
500 let (ws, mut rx) = Workspace::testing_stub();
501 let _heard = with_host(&ws);
502 let caller = SessionSlot::from_str_for_test("caller-uuid");
503 let (_id, _answer) =
504 ws.register_browser_hand_off(&caller, "solve the CAPTCHA", "T2").await.expect("parks");
505
506 let mut addressed = Vec::new();
507 while let Ok(frame) = rx.try_recv() {
508 if let crate::protocol::SessionUpdate::BrowserHandOffPending { key, .. } =
509 std::sync::Arc::unwrap_or_clone(frame.update)
510 {
511 addressed.push(key);
512 }
513 }
514 assert_eq!(addressed, vec![caller], "the hand-off is addressed to the asker");
515 }
516
517 #[tokio::test]
521 async fn registering_a_hand_off_holds_its_tab_in_the_fleet() {
522 let (ws, _rx) = Workspace::testing_stub();
523 let (calls, mut heard) = tokio::sync::mpsc::unbounded_channel();
524 ws.install_browser_host(std::sync::Arc::new(StubHost { calls }));
525 let caller = SessionSlot::from_str_for_test("caller-uuid");
526
527 let (_id, _answer) =
528 ws.register_browser_hand_off(&caller, "solve the CAPTCHA", "T2").await.expect("parks");
529
530 let (what, args) = heard.recv().await.expect("the fleet hears the hold");
531 assert_eq!(what, "hand_off_started", "the fleet is told which page is held");
532 assert_eq!(args, serde_json::json!({ "seat": caller, "page": "T2" }));
533 }
534
535 #[tokio::test]
538 async fn resolving_a_hand_off_releases_its_tab() {
539 let (ws, _rx) = Workspace::testing_stub();
540 let (calls, mut heard) = tokio::sync::mpsc::unbounded_channel();
541 ws.install_browser_host(std::sync::Arc::new(StubHost { calls }));
542 let caller = SessionSlot::from_str_for_test("caller-uuid");
543 let (id, _answer) =
544 ws.register_browser_hand_off(&caller, "solve the CAPTCHA", "T2").await.expect("parks");
545 let started = heard.recv().await.map(|(what, _)| what);
546 assert_eq!(started, Some("hand_off_started".to_owned()));
547
548 assert!(ws.resolve_browser_hand_off(
549 id,
550 &caller,
551 answer(HandOffEnding::Done, HandOffSource::Takeover),
552 ));
553
554 let (what, args) = heard.recv().await.expect("the fleet hears the release");
555 assert_eq!(what, "hand_off_finished", "the ending releases the tab it held");
556 assert_eq!(args, serde_json::json!({ "seat": caller, "tab": 2 }));
557 }
558
559 #[tokio::test]
562 async fn a_hand_off_no_view_can_show_releases_its_tab() {
563 let (ws, rx) = Workspace::testing_stub();
564 drop(rx);
565 let (calls, mut heard) = tokio::sync::mpsc::unbounded_channel();
566 ws.install_browser_host(std::sync::Arc::new(StubHost { calls }));
567 let caller = SessionSlot::from_str_for_test("caller-uuid");
568
569 let (_id, answer) =
570 ws.register_browser_hand_off(&caller, "solve the CAPTCHA", "T2").await.expect("parks");
571 assert!(answer.await.is_err(), "the fail-closed read");
572
573 let mut calls = Vec::new();
574 while let Some((what, _)) = heard.recv().await {
575 calls.push(what);
576 if calls.len() == 2 {
577 break;
578 }
579 }
580 assert_eq!(
581 calls,
582 vec!["hand_off_started".to_owned(), "hand_off_finished".to_owned()],
583 "the hold taken for an unanswerable raise is released",
584 );
585 }
586
587 #[tokio::test]
590 async fn answering_a_hand_off_that_is_not_pending_is_refused() {
591 let (ws, _rx) = Workspace::testing_stub();
592 let caller = SessionSlot::from_str_for_test("caller-uuid");
593 assert!(
594 !ws.resolve_browser_hand_off(
595 uuid::Uuid::new_v4(),
596 &caller,
597 answer(HandOffEnding::NotNow, HandOffSource::Takeover),
598 ),
599 "a hand-off that is not parked cannot be resolved",
600 );
601 }
602
603 #[tokio::test]
607 async fn a_hand_off_with_no_ui_to_answer_it_fails_closed() {
608 let (ws, rx) = Workspace::testing_stub();
609 drop(rx);
610 let _heard = with_host(&ws);
611 let caller = SessionSlot::from_str_for_test("caller-uuid");
612 let (_id, answer) =
613 ws.register_browser_hand_off(&caller, "solve the CAPTCHA", "T2").await.expect("parks");
614
615 assert!(
616 ws.browser_handoffs.lock().is_empty(),
617 "an unanswerable hand-off is dropped, not held forever",
618 );
619 assert!(answer.await.is_err(), "a dead receiver is the fail-closed read");
620 }
621
622 #[tokio::test]
626 async fn resolving_a_hand_off_answers_its_waiter_and_tells_the_views() {
627 let (ws, mut rx) = Workspace::testing_stub();
628 let _heard = with_host(&ws);
629 let caller = SessionSlot::from_str_for_test("caller-uuid");
630 let (id, waiter) =
631 ws.register_browser_hand_off(&caller, "solve the CAPTCHA", "T2").await.expect("parks");
632 while rx.try_recv().is_ok() {}
633
634 let taken = answer(HandOffEnding::Done, HandOffSource::Takeover);
635 assert!(ws.resolve_browser_hand_off(id, &caller, taken));
636
637 assert_eq!(
638 waiter.await.expect("the waiter is answered"),
639 HandOffEnding::Done,
640 "the ending the resolve carried is what the blocked handler gets",
641 );
642 let resolved = rx.try_recv().expect("the views hear it left the registry");
643 let crate::protocol::SessionUpdate::BrowserHandOffResolved { key, id: seen, answer: heard } =
644 std::sync::Arc::unwrap_or_clone(resolved.update)
645 else {
646 panic!("the update is the resolved one");
647 };
648 assert_eq!(key, caller);
649 assert_eq!(seen, id);
650 assert_eq!(heard, taken, "the door and the client ride the resolved update");
654 }
655
656 #[tokio::test]
661 async fn a_subscriber_going_away_does_not_answer_a_parked_hand_off() {
662 let (ws, rx) = Workspace::testing_stub();
663 let _heard = with_host(&ws);
664 let caller = SessionSlot::from_str_for_test("caller-uuid");
665 let (id, mut waiter) =
666 ws.register_browser_hand_off(&caller, "solve the CAPTCHA", "T2").await.expect("parks");
667
668 drop(rx);
669
670 assert!(ws.browser_handoff_waiting(id, &caller), "the park is still the core's");
671 assert!(
672 matches!(waiter.try_recv(), Err(tokio::sync::oneshot::error::TryRecvError::Empty)),
673 "and the blocked call is still waiting for the person",
674 );
675 }
676
677 #[tokio::test]
682 async fn a_feedback_answer_reaches_the_waiter_as_feedback() {
683 let (ws, _rx) = Workspace::testing_stub();
684 let _heard = with_host(&ws);
685 let caller = SessionSlot::from_str_for_test("caller-uuid");
686 let (id, waiter) =
687 ws.register_browser_hand_off(&caller, "solve the CAPTCHA", "T2").await.expect("parks");
688
689 assert!(ws.resolve_browser_hand_off(
690 id,
691 &caller,
692 answer(HandOffEnding::Feedback, HandOffSource::Feedback),
693 ));
694
695 assert_eq!(
696 waiter.await.expect("the waiter is answered"),
697 HandOffEnding::Feedback,
698 "the person says something is off: the ending is feedback, not a deferral",
699 );
700 }
701
702 #[tokio::test]
705 async fn only_the_owner_resolves_a_hand_off() {
706 let (ws, _rx) = Workspace::testing_stub();
707 let _heard = with_host(&ws);
708 let owner = SessionSlot::from_str_for_test("owner-uuid");
709 let other = SessionSlot::from_str_for_test("other-uuid");
710 let (id, _answer) =
711 ws.register_browser_hand_off(&owner, "solve the CAPTCHA", "T2").await.expect("parks");
712
713 assert!(
714 !ws.resolve_browser_hand_off(
715 id,
716 &other,
717 answer(HandOffEnding::Done, HandOffSource::Takeover),
718 ),
719 "another seat's answer is refused",
720 );
721 assert!(ws.browser_handoff_waiting(id, &owner), "and the hand-off stays pending");
722 }
723}