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