Skip to main content

sim_lib_web_bridge/
host.rs

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