Skip to main content

sim_lib_web_bridge/
host.rs

1//! Phone and desktop host wrappers over the session bus.
2//!
3//! These are thin facades over [`Session`]: they reuse the same Intent/Scene
4//! bus, transport, and pump, and add only the host-shaped policy each device
5//! needs. Nothing here re-implements transport, rendering, or diffing.
6//!
7//! - [`PhoneHost`] is a single-pane facade that caches the last rendered frame
8//!   and, while the transport is offline, QUEUES Intents and replays them on
9//!   [`PhoneHost::resume`] -- an offline-safe phone that never drops an edit.
10//! - [`DesktopHost`] is a many-pane facade: it opens several panes/windows over
11//!   one session, so an edit in one pane fans out (through [`Session::pump`]) to
12//!   every pane that shares the edited resource.
13//!
14//! Both stay generic over `T: [`Transport`]`, so they drive the deterministic
15//! [`FixtureTransport`](crate::fixture::FixtureTransport) in tests and a real
16//! transport later without change.
17
18use std::collections::BTreeMap;
19
20use sim_kernel::{Cx, Error, Expr, Result, Symbol};
21use sim_lib_view::surface::SurfaceCaps;
22use sim_lib_view::{LensRegistry, UNIVERSAL_SURFACE_CODEC_ID, surface};
23
24use crate::session::{SceneUpdate, Session};
25use crate::transport::{SessionStatus, Transport};
26
27/// The single pane a [`PhoneHost`] renders into.
28///
29/// A phone shows one resource at a time; this is the pane name
30/// [`PhoneHost::open`] subscribes and the one to pass to
31/// [`PhoneHost::last_scene`].
32pub const PHONE_PANE: &str = "phone:main";
33
34/// The maximum number of offline Intents a phone host may buffer.
35pub const MAX_PHONE_OFFLINE_QUEUE: usize = 128;
36
37fn universal_surface_codec() -> Symbol {
38    Symbol::new(UNIVERSAL_SURFACE_CODEC_ID)
39}
40
41/// A phone host facade: a single-pane [`Session`] that caches the last rendered
42/// frame and queues Intents while offline, flushing them on resume.
43///
44/// The phone reuses the session bus wholesale. Its only added policy is offline
45/// safety: [`PhoneHost::submit`] commits immediately when connected, but parks
46/// Intents in an in-memory queue when the transport is down, and
47/// [`PhoneHost::resume`] replays that queue in order once the caller has
48/// restored the connection.
49pub struct PhoneHost<T: Transport> {
50    session: Session<T>,
51    surface_codec: Symbol,
52    caps: SurfaceCaps,
53    queue: Vec<Expr>,
54    scenes: BTreeMap<Symbol, Expr>,
55}
56
57impl<T: Transport> PhoneHost<T> {
58    /// Starts a phone host over `transport`, adopting the `phone` surface preset.
59    pub fn new(transport: T) -> Self {
60        Self::with_surface_codec(transport, universal_surface_codec())
61    }
62
63    /// Starts a phone host with an injected reversible surface codec.
64    ///
65    /// Product hosts use this constructor to retain the phone lifecycle and
66    /// offline policy without replacing the generic Scene/Intent session bus.
67    pub fn with_surface_codec(transport: T, surface_codec: Symbol) -> Self {
68        Self {
69            session: Session::new(transport),
70            surface_codec,
71            caps: surface::preset("phone").expect("phone is a known surface preset"),
72            queue: Vec::new(),
73            scenes: BTreeMap::new(),
74        }
75    }
76
77    fn pane() -> Symbol {
78        Symbol::new(PHONE_PANE)
79    }
80
81    /// Opens `resource` into the phone's single pane with the selected codec,
82    /// caches the initial Scene, and returns it.
83    pub fn open(&mut self, cx: &mut Cx, registry: &LensRegistry, resource: Symbol) -> Result<Expr> {
84        let pane = Self::pane();
85        let scene = self.session.open_codec(
86            cx,
87            registry,
88            pane.clone(),
89            resource,
90            self.surface_codec.clone(),
91            self.caps.clone(),
92        )?;
93        self.scenes.insert(pane, scene.clone());
94        Ok(scene)
95    }
96
97    /// Submits an Intent against the open pane.
98    ///
99    /// When the transport is [`SessionStatus::Connected`], this commits the
100    /// Intent and pumps, caching and returning the resulting frames. Otherwise
101    /// the Intent is queued offline and an empty update list is returned -- no
102    /// error -- so a flaky link never drops or fails an edit.
103    pub fn submit(
104        &mut self,
105        cx: &mut Cx,
106        registry: &LensRegistry,
107        intent: Expr,
108    ) -> Result<Vec<SceneUpdate>> {
109        match self.session.status() {
110            SessionStatus::Connected => {
111                self.session.submit_intent_at_rendered_revision(
112                    cx,
113                    registry,
114                    &Self::pane(),
115                    &intent,
116                )?;
117                let updates = self.session.pump(cx, registry)?;
118                self.cache(&updates);
119                Ok(updates)
120            }
121            _ => {
122                if self.queue.len() >= MAX_PHONE_OFFLINE_QUEUE {
123                    return Err(Error::HostError(format!(
124                        "phone offline queue is full ({MAX_PHONE_OFFLINE_QUEUE}); resume before submitting another intent"
125                    )));
126                }
127                self.queue.push(intent);
128                Ok(Vec::new())
129            }
130        }
131    }
132
133    /// Drains the offline queue in order, replaying each Intent through the
134    /// session, then pumps once and returns the resulting frames.
135    ///
136    /// The queue is drained incrementally: the front Intent is removed only once
137    /// it commits. If a queued Intent fails (for example one that never
138    /// validated against the now-current value), the drain stops with that
139    /// Intent still at the front of the queue and the unprocessed tail intact --
140    /// no edit is lost, and a later [`resume`](Self::resume) retries from there.
141    /// Frames for the edits that did commit are pumped, cached, and returned; if
142    /// nothing committed before the failure the error is propagated.
143    ///
144    /// Reconnecting the underlying transport is the caller's concern; reach it
145    /// via [`PhoneHost::transport_mut`] before calling this.
146    pub fn resume(&mut self, cx: &mut Cx, registry: &LensRegistry) -> Result<Vec<SceneUpdate>> {
147        let pane = Self::pane();
148        let mut applied = 0usize;
149        let mut updates = Vec::new();
150        let mut failure = None;
151        while let Some(intent) = self.queue.first().cloned() {
152            match self
153                .session
154                .submit_intent_at_rendered_revision(cx, registry, &pane, &intent)
155            {
156                Ok(()) => {
157                    self.queue.remove(0);
158                    applied += 1;
159                    let next = self.session.pump(cx, registry)?;
160                    self.cache(&next);
161                    updates.extend(next);
162                }
163                Err(err) => {
164                    // Leave the failed Intent and the rest of the queue in place,
165                    // in order, so the edit is retried rather than dropped.
166                    failure = Some(err);
167                    break;
168                }
169            }
170        }
171        if applied == 0
172            && let Some(err) = failure
173        {
174            return Err(err);
175        }
176        Ok(updates)
177    }
178
179    fn cache(&mut self, updates: &[SceneUpdate]) {
180        for update in updates {
181            self.scenes
182                .insert(update.pane.clone(), update.scene.clone());
183        }
184    }
185
186    /// The phone's advertised surface capabilities (the `phone` preset).
187    pub fn caps(&self) -> &SurfaceCaps {
188        &self.caps
189    }
190
191    /// The reversible surface codec selected for this phone host.
192    pub fn surface_codec(&self) -> &Symbol {
193        &self.surface_codec
194    }
195
196    /// The number of Intents waiting in the offline queue.
197    pub fn queued(&self) -> usize {
198        self.queue.len()
199    }
200
201    /// The most recently cached Scene for `pane`, if one was rendered.
202    pub fn last_scene(&self, pane: &Symbol) -> Option<&Expr> {
203        self.scenes.get(pane)
204    }
205
206    /// Mutable access to the underlying transport, e.g. to drive reconnection.
207    pub fn transport_mut(&mut self) -> &mut T {
208        self.session.transport_mut()
209    }
210}
211
212/// A desktop host facade: many panes/windows over one [`Session`].
213///
214/// The desktop reuses one session for every open pane, so panes that share a
215/// resource stay coherent for free: an edit submitted on one pane fans out
216/// through [`Session::pump`] to every pane subscribed to the same resource.
217pub struct DesktopHost<T: Transport> {
218    session: Session<T>,
219    surface_codec: Symbol,
220    caps: SurfaceCaps,
221    panes: Vec<Symbol>,
222}
223
224impl<T: Transport> DesktopHost<T> {
225    /// Starts a desktop host over `transport`, adopting the `desktop` preset.
226    pub fn new(transport: T) -> Self {
227        Self::with_surface_codec(transport, universal_surface_codec())
228    }
229
230    /// Starts a desktop host with an injected reversible surface codec.
231    pub fn with_surface_codec(transport: T, surface_codec: Symbol) -> Self {
232        Self {
233            session: Session::new(transport),
234            surface_codec,
235            caps: surface::preset("desktop").expect("desktop is a known surface preset"),
236            panes: Vec::new(),
237        }
238    }
239
240    /// Opens `resource` into the named `pane` with the selected codec, tracks
241    /// the pane, and returns its initial Scene.
242    ///
243    /// Opening the same resource into several panes subscribes each of them;
244    /// re-opening an already-tracked pane re-subscribes it without duplicating
245    /// it in [`DesktopHost::panes`].
246    pub fn open_pane(
247        &mut self,
248        cx: &mut Cx,
249        registry: &LensRegistry,
250        pane: Symbol,
251        resource: Symbol,
252    ) -> Result<Expr> {
253        let scene = self.session.open_codec(
254            cx,
255            registry,
256            pane.clone(),
257            resource,
258            self.surface_codec.clone(),
259            self.caps.clone(),
260        )?;
261        if !self.panes.contains(&pane) {
262            self.panes.push(pane);
263        }
264        Ok(scene)
265    }
266
267    /// Submits an Intent against `pane` and pumps.
268    ///
269    /// The returned updates may span several panes when they share the edited
270    /// resource.
271    pub fn submit(
272        &mut self,
273        cx: &mut Cx,
274        registry: &LensRegistry,
275        pane: &Symbol,
276        intent: Expr,
277    ) -> Result<Vec<SceneUpdate>> {
278        self.session
279            .submit_intent_at_rendered_revision(cx, registry, pane, &intent)?;
280        self.session.pump(cx, registry)
281    }
282
283    /// The panes currently open, in the order they were first opened.
284    pub fn panes(&self) -> Vec<Symbol> {
285        self.panes.clone()
286    }
287
288    /// The desktop's advertised surface capabilities (the `desktop` preset).
289    pub fn caps(&self) -> &SurfaceCaps {
290        &self.caps
291    }
292
293    /// The reversible surface codec selected for this desktop host.
294    pub fn surface_codec(&self) -> &Symbol {
295        &self.surface_codec
296    }
297
298    /// Mutable access to the underlying transport, e.g. to drive reconnection.
299    pub fn transport_mut(&mut self) -> &mut T {
300        self.session.transport_mut()
301    }
302}
303
304#[cfg(test)]
305mod tests {
306
307    use sim_kernel::{Expr, NumberLiteral, Symbol};
308    use sim_lib_intent::{Origin, intent};
309    use sim_lib_view::{LensRegistry, register_universal_default};
310
311    use super::{DesktopHost, MAX_PHONE_OFFLINE_QUEUE, PHONE_PANE, PhoneHost};
312    use crate::fixture::FixtureTransport;
313    use crate::transport::Transport;
314
315    use sim_kernel::testing::eager_cx as cx;
316
317    fn registry() -> LensRegistry {
318        let mut registry = LensRegistry::new();
319        register_universal_default(&mut registry, false);
320        registry
321    }
322
323    use sim_value::build::keyword as sym;
324
325    fn number(value: &str) -> Expr {
326        Expr::Number(NumberLiteral {
327            domain: sym("i64"),
328            canonical: value.to_owned(),
329        })
330    }
331
332    fn doc() -> Expr {
333        Expr::Map(vec![
334            (Expr::Symbol(sym("a")), number("1")),
335            (Expr::Symbol(sym("b")), number("2")),
336        ])
337    }
338
339    /// An `edit-field` Intent that sets map field `name` to `value`.
340    fn edit(name: &str, value: &str) -> Expr {
341        intent(
342            "edit-field",
343            Origin::human(1),
344            vec![
345                ("target", doc()),
346                (
347                    "path",
348                    Expr::List(vec![Expr::Vector(vec![
349                        Expr::Symbol(sym("k")),
350                        Expr::Symbol(sym(name)),
351                    ])]),
352                ),
353                ("value", number(value)),
354            ],
355        )
356    }
357
358    /// A structurally valid `edit-field` Intent whose path indexes into the map
359    /// as if it were a sequence, so `set_at` rejects it and the editor refuses to
360    /// commit -- a queued Intent that fails on replay.
361    fn broken_edit() -> Expr {
362        intent(
363            "edit-field",
364            Origin::human(1),
365            vec![
366                ("target", doc()),
367                (
368                    "path",
369                    Expr::List(vec![Expr::Vector(vec![
370                        Expr::Symbol(sym("i")),
371                        Expr::String("0".to_owned()),
372                    ])]),
373                ),
374                ("value", number("99")),
375            ],
376        )
377    }
378
379    fn field_of(value: &Expr, name: &str) -> Option<Expr> {
380        let Expr::Map(entries) = value else {
381            return None;
382        };
383        entries
384            .iter()
385            .find(|(k, _)| matches!(k, Expr::Symbol(s) if &*s.name == name))
386            .map(|(_, v)| v.clone())
387    }
388
389    #[test]
390    fn phone_caches_online_edits_and_queues_offline_ones_until_resume() {
391        let mut cx = cx();
392        let registry = registry();
393        let mut phone = PhoneHost::new(FixtureTransport::new().with(sym("doc"), doc()));
394        let pane = sym(PHONE_PANE);
395
396        // Open and render the resource.
397        let initial = phone.open(&mut cx, &registry, sym("doc")).unwrap();
398        sim_lib_scene::validate_scene(&initial).expect("initial scene is valid");
399        assert_eq!(phone.last_scene(&pane), Some(&initial));
400
401        // A connected edit commits, pumps, and caches the new frame.
402        let online = phone.submit(&mut cx, &registry, edit("a", "9")).unwrap();
403        assert_eq!(online.len(), 1, "the open pane updates");
404        assert_eq!(phone.queued(), 0, "nothing is queued while connected");
405        assert_eq!(phone.last_scene(&pane), Some(&online[0].scene));
406        assert_ne!(online[0].scene, initial, "the frame changed");
407
408        // Offline: two edits queue instead of committing -- no error, no frames.
409        phone.transport_mut().disconnect();
410        let q1 = phone.submit(&mut cx, &registry, edit("b", "8")).unwrap();
411        let q2 = phone.submit(&mut cx, &registry, edit("a", "30")).unwrap();
412        assert!(
413            q1.is_empty() && q2.is_empty(),
414            "offline edits return no frames"
415        );
416        assert_eq!(phone.queued(), 2, "both offline edits are queued");
417
418        // Reconnect and resume: the queued edits replay in order.
419        phone.transport_mut().begin_reconnect();
420        phone.transport_mut().reconnect();
421        let resumed = phone.resume(&mut cx, &registry).unwrap();
422        assert_eq!(phone.queued(), 0, "the queue drained");
423        assert_eq!(resumed.len(), 2, "one frame per replayed edit, in order");
424
425        // The final value reflects BOTH queued edits (b := 8 then a := 30).
426        let value = phone.transport_mut().read(&mut cx, &sym("doc")).unwrap();
427        assert_eq!(field_of(&value, "a"), Some(number("30")));
428        assert_eq!(field_of(&value, "b"), Some(number("8")));
429
430        // last_scene reflects the latest frame.
431        let latest = resumed.last().expect("resume produced frames");
432        assert_eq!(phone.last_scene(&pane), Some(&latest.scene));
433    }
434
435    #[test]
436    fn resume_stops_at_a_failing_intent_and_keeps_the_tail() {
437        let mut cx = cx();
438        let registry = registry();
439        let mut phone = PhoneHost::new(FixtureTransport::new().with(sym("doc"), doc()));
440        phone.open(&mut cx, &registry, sym("doc")).unwrap();
441
442        // Offline: queue a good edit, a broken Intent, then another good edit.
443        phone.transport_mut().disconnect();
444        phone.submit(&mut cx, &registry, edit("b", "8")).unwrap();
445        phone.submit(&mut cx, &registry, broken_edit()).unwrap();
446        phone.submit(&mut cx, &registry, edit("a", "30")).unwrap();
447        assert_eq!(phone.queued(), 3, "all three edits are queued offline");
448
449        // Reconnect and resume: the first edit commits, the broken Intent halts
450        // the drain, and the broken+trailing edits stay queued in order.
451        phone.transport_mut().begin_reconnect();
452        phone.transport_mut().reconnect();
453        let updates = phone.resume(&mut cx, &registry).unwrap();
454        assert!(!updates.is_empty(), "the committed edit produced a frame");
455        assert_eq!(
456            phone.queued(),
457            2,
458            "the failed Intent and its tail are NOT dropped"
459        );
460
461        // Only the first edit took effect; the trailing edit never ran.
462        let value = phone.transport_mut().read(&mut cx, &sym("doc")).unwrap();
463        assert_eq!(field_of(&value, "b"), Some(number("8")), "b := 8 applied");
464        assert_eq!(
465            field_of(&value, "a"),
466            Some(number("1")),
467            "a is untouched -- the post-failure edit did not apply"
468        );
469    }
470
471    #[test]
472    fn phone_offline_queue_has_a_backpressure_cap() {
473        let mut cx = cx();
474        let registry = registry();
475        let mut phone = PhoneHost::new(FixtureTransport::new().with(sym("doc"), doc()));
476        phone.open(&mut cx, &registry, sym("doc")).unwrap();
477        phone.transport_mut().disconnect();
478
479        for index in 0..MAX_PHONE_OFFLINE_QUEUE {
480            phone
481                .submit(&mut cx, &registry, edit("a", &index.to_string()))
482                .unwrap();
483        }
484
485        let err = phone
486            .submit(&mut cx, &registry, edit("a", "999"))
487            .unwrap_err();
488
489        assert!(
490            err.to_string().contains("phone offline queue is full"),
491            "unexpected error: {err}"
492        );
493        assert_eq!(phone.queued(), MAX_PHONE_OFFLINE_QUEUE);
494    }
495
496    #[test]
497    fn desktop_fans_a_shared_resource_edit_out_to_every_pane() {
498        let mut cx = cx();
499        let registry = registry();
500        let mut desktop = DesktopHost::new(FixtureTransport::new().with(sym("doc"), doc()));
501
502        // Open the SAME resource into two panes.
503        let scene_a = desktop
504            .open_pane(&mut cx, &registry, sym("pane-a"), sym("doc"))
505            .unwrap();
506        let scene_b = desktop
507            .open_pane(&mut cx, &registry, sym("pane-b"), sym("doc"))
508            .unwrap();
509        assert_eq!(desktop.panes(), vec![sym("pane-a"), sym("pane-b")]);
510
511        // Edit on pane A; pump fans out to BOTH panes sharing the resource.
512        let updates = desktop
513            .submit(&mut cx, &registry, &sym("pane-a"), edit("a", "9"))
514            .unwrap();
515        assert_eq!(updates.len(), 2, "both panes share the resource");
516        let panes: Vec<Symbol> = updates.iter().map(|u| u.pane.clone()).collect();
517        assert!(panes.contains(&sym("pane-a")) && panes.contains(&sym("pane-b")));
518
519        // Each pane's diff reconstructs its new Scene from its initial one.
520        for update in &updates {
521            let initial = if update.pane == sym("pane-a") {
522                &scene_a
523            } else {
524                &scene_b
525            };
526            let rebuilt = sim_lib_scene::apply(initial, &update.diff).unwrap();
527            assert_eq!(rebuilt, update.scene, "the diff reconstructs the new Scene");
528        }
529    }
530}