Skip to main content

sim_lib_web_bridge/
glasses_session.rs

1//! Dual-glasses co-use over one synchronized surface hub.
2
3use sim_kernel::{Error, Expr, Result, Symbol};
4use sim_lib_view::SurfaceCaps;
5use sim_lib_view_device::{ConsentReceipt, EdgeId, FrameClock, StalePolicy};
6use sim_lib_view_spatial::{
7    GlassesCoUseRole, GlassesPeer, HaloGlanceLoop, VitureReprojectLoop, glasses_peer_config,
8    halo_loop, viture_loop,
9};
10use sim_value::{access, build};
11
12use crate::{Broadcast, EditRow, SurfaceBinding, SurfaceHub, SurfaceRole};
13
14const WORKSPACE_PANE: &str = "glasses-workspace";
15const DEFAULT_MAX_PREDICT_MS: u64 = 12;
16
17/// One worn-together Viture+Halo session.
18///
19/// The session owns one [`SurfaceHub`] and one consent receipt. Viture and Halo
20/// are independent surface peers in that hub, so a decoded Intent commits once
21/// to the canonical workspace and fans out to every attached projection.
22pub struct GlassesCoUseSession {
23    hub: SurfaceHub,
24    session: EdgeId,
25    consent: ConsentReceipt,
26    resource: Symbol,
27    pane: Symbol,
28    viture_surface: Symbol,
29    halo_surface: Symbol,
30    viture_loop: Option<VitureReprojectLoop>,
31    halo_loop: Option<HaloGlanceLoop>,
32    viture_clock: Option<FrameClock>,
33    halo_clock: Option<FrameClock>,
34}
35
36impl GlassesCoUseSession {
37    /// Builds a co-use session around an initial canonical workspace value.
38    pub fn new(
39        session: EdgeId,
40        consent: ConsentReceipt,
41        resource: Symbol,
42        initial_workspace: Expr,
43    ) -> Result<Self> {
44        if consent.session != session {
45            return Err(Error::HostError(
46                "glasses co-use consent receipt is bound to a different session".to_owned(),
47            ));
48        }
49        let mut hub = SurfaceHub::new();
50        hub.seed(resource.clone(), initial_workspace);
51        let viture_surface = glasses_surface(&session, GlassesPeer::Viture);
52        let halo_surface = glasses_surface(&session, GlassesPeer::Halo);
53        Ok(Self {
54            hub,
55            session,
56            consent,
57            resource,
58            pane: Symbol::new(WORKSPACE_PANE),
59            viture_surface,
60            halo_surface,
61            viture_loop: None,
62            halo_loop: None,
63            viture_clock: None,
64            halo_clock: None,
65        })
66    }
67
68    /// Attach the Viture main surface and open the shared workspace there.
69    pub fn attach_viture(&mut self, caps: SurfaceCaps) -> Result<Expr> {
70        let config = glasses_peer_config(GlassesPeer::Viture, &caps)?;
71        let (loop_, clock) = viture_loop(&config.profile, DEFAULT_MAX_PREDICT_MS);
72        self.hub.register_surface_with_role(
73            self.viture_surface.clone(),
74            caps,
75            surface_role_for(config.role),
76        );
77        self.viture_loop = Some(loop_);
78        self.viture_clock = Some(clock);
79        self.hub.open(
80            &self.viture_surface,
81            self.pane.clone(),
82            self.resource.clone(),
83        )
84    }
85
86    /// Attach the Halo peer surface and open the shared workspace there.
87    pub fn attach_halo(&mut self, caps: SurfaceCaps) -> Result<Expr> {
88        let config = glasses_peer_config(GlassesPeer::Halo, &caps)?;
89        let (loop_, clock) = halo_loop(&config.profile);
90        self.hub.register_surface_with_role(
91            self.halo_surface.clone(),
92            caps,
93            surface_role_for(config.role),
94        );
95        self.halo_loop = Some(loop_);
96        self.halo_clock = Some(clock);
97        self.hub
98            .open(&self.halo_surface, self.pane.clone(), self.resource.clone())
99    }
100
101    /// Detach the Viture main surface without ending the shared session.
102    pub fn detach_viture(&mut self) -> Vec<SurfaceBinding> {
103        self.viture_loop = None;
104        self.viture_clock = None;
105        self.hub.detach_surface(&self.viture_surface)
106    }
107
108    /// Submit a standard editor Intent from the selected glasses peer.
109    pub fn submit_from(&mut self, peer: GlassesPeer, intent: &Expr) -> Result<Vec<Broadcast>> {
110        let surface = self.surface(peer).clone();
111        self.hub.submit(&surface, &self.pane, intent)
112    }
113
114    /// Commit a coordinator-decoded value update from the selected glasses peer.
115    pub fn commit_from(
116        &mut self,
117        peer: GlassesPeer,
118        intent: &Expr,
119        new_workspace: Expr,
120    ) -> Result<Vec<Broadcast>> {
121        let surface = self.surface(peer).clone();
122        self.hub
123            .commit_value_from(&surface, &self.pane, intent, new_workspace)
124    }
125
126    /// Acknowledge the focused Viture review card from a Halo tap Intent.
127    pub fn acknowledge_review_from_halo(
128        &mut self,
129        tap_intent: &Expr,
130        mission: Symbol,
131    ) -> Result<Vec<Broadcast>> {
132        let current = self.workspace()?.clone();
133        let review = access::field(&current, "review")
134            .cloned()
135            .unwrap_or_else(|| build::map(Vec::new()));
136        let review = access::set(&review, "mission", Expr::Symbol(mission));
137        let review = access::set(&review, "status", build::sym("acked"));
138        let review = access::set(&review, "acked-by", Expr::Symbol(self.halo_surface.clone()));
139        let next = access::set(&current, "review", review);
140        self.commit_from(GlassesPeer::Halo, tap_intent, next)
141    }
142
143    /// Returns the shared device-edge session id.
144    pub fn session(&self) -> &EdgeId {
145        &self.session
146    }
147
148    /// Returns the session-bound consent receipt.
149    pub fn consent(&self) -> &ConsentReceipt {
150        &self.consent
151    }
152
153    /// Returns the canonical shared workspace.
154    pub fn workspace(&self) -> Result<&Expr> {
155        self.hub.canonical(&self.resource).ok_or_else(|| {
156            Error::HostError(format!(
157                "glasses workspace '{}' is not seeded",
158                self.resource
159            ))
160        })
161    }
162
163    /// Returns the append-only shared edit ledger.
164    pub fn ledger(&self) -> &[EditRow] {
165        self.hub.ledger()
166    }
167
168    /// Returns live bindings currently viewing the shared workspace.
169    pub fn live_bindings(&self) -> Vec<SurfaceBinding> {
170        self.hub.bindings_for_resource(&self.resource)
171    }
172
173    /// Returns the surface id for `peer`.
174    pub fn surface(&self, peer: GlassesPeer) -> &Symbol {
175        match peer {
176            GlassesPeer::Viture => &self.viture_surface,
177            GlassesPeer::Halo => &self.halo_surface,
178        }
179    }
180
181    /// Returns the role recorded for a peer surface.
182    pub fn role(&self, peer: GlassesPeer) -> Option<SurfaceRole> {
183        self.hub.surface_role(self.surface(peer))
184    }
185
186    /// Returns the Viture adapter-loop staleness policy when attached.
187    pub fn viture_loop_policy(&self) -> Option<StalePolicy> {
188        self.viture_loop.as_ref().map(|loop_| loop_.policy())
189    }
190
191    /// Returns the Halo adapter-loop staleness policy when attached.
192    pub fn halo_loop_policy(&self) -> Option<StalePolicy> {
193        self.halo_loop.as_ref().map(|loop_| loop_.policy())
194    }
195}
196
197/// Returns the stable surface id for a glasses peer in `session`.
198pub fn glasses_surface(session: &EdgeId, peer: GlassesPeer) -> Symbol {
199    Symbol::qualified(
200        "device/peer",
201        format!(
202            "{}:{}",
203            session.as_symbol().as_qualified_str(),
204            peer.label()
205        ),
206    )
207}
208
209fn surface_role_for(role: GlassesCoUseRole) -> SurfaceRole {
210    match role {
211        GlassesCoUseRole::Main => SurfaceRole::Main,
212        GlassesCoUseRole::Peer => SurfaceRole::Peer,
213    }
214}
215
216#[cfg(test)]
217mod tests {
218    use super::*;
219
220    use sim_lib_intent::{Origin, intent};
221    use sim_lib_view_device::{DeviceCapability, StalePolicy};
222
223    fn session() -> GlassesCoUseSession {
224        let edge = EdgeId::named("wear-session");
225        let consent = ConsentReceipt::new(
226            vec![
227                DeviceCapability::Pose.grant_symbol(),
228                DeviceCapability::Mic.grant_symbol(),
229            ],
230            60_000,
231            Vec::new(),
232            edge.clone(),
233            7,
234        );
235        GlassesCoUseSession::new(edge, consent, build::keyword("workspace"), workspace()).unwrap()
236    }
237
238    fn workspace() -> Expr {
239        build::map(vec![
240            ("title", Expr::String("Bridge review".to_owned())),
241            ("status", build::sym("pending")),
242            ("focus", build::sym("main-panel")),
243            (
244                "review",
245                build::map(vec![
246                    ("mission", build::qsym("bridge", "packet-review")),
247                    ("status", build::sym("pending")),
248                ]),
249            ),
250        ])
251    }
252
253    fn attach_both(session: &mut GlassesCoUseSession) {
254        let viture = SurfaceCaps::from_preset("glasses-luma-ultra", "viture.co-use").unwrap();
255        let halo = SurfaceCaps::from_preset("glasses-hud", "halo.co-use").unwrap();
256        sim_lib_scene::validate_scene(&session.attach_viture(viture).unwrap()).unwrap();
257        sim_lib_scene::validate_scene(&session.attach_halo(halo).unwrap()).unwrap();
258    }
259
260    fn edit_field(tick: u64, field: &str, value: Expr) -> Expr {
261        intent(
262            "edit-field",
263            Origin::human(tick),
264            vec![
265                ("target", workspace()),
266                (
267                    "path",
268                    Expr::List(vec![Expr::Vector(vec![
269                        Expr::Symbol(build::keyword("k")),
270                        Expr::Symbol(build::keyword(field)),
271                    ])]),
272                ),
273                ("value", value),
274            ],
275        )
276    }
277
278    fn invoke(tick: u64, op: &str) -> Expr {
279        intent(
280            "invoke",
281            Origin::human(tick),
282            vec![
283                ("target", build::sym("workspace")),
284                ("op", Expr::Symbol(Symbol::qualified("glasses/input", op))),
285                ("args", Expr::List(Vec::new())),
286            ],
287        )
288    }
289
290    #[test]
291    fn viture_and_halo_share_one_session() {
292        let mut session = session();
293        attach_both(&mut session);
294
295        assert_eq!(session.role(GlassesPeer::Viture), Some(SurfaceRole::Main));
296        assert_eq!(session.role(GlassesPeer::Halo), Some(SurfaceRole::Peer));
297        assert_eq!(
298            session.viture_loop_policy(),
299            Some(StalePolicy::Predict),
300            "Viture keeps its own reprojector loop"
301        );
302        assert_eq!(
303            session.halo_loop_policy(),
304            Some(StalePolicy::HoldLast),
305            "Halo keeps its own glance loop"
306        );
307        assert_eq!(session.live_bindings().len(), 2);
308
309        let halo_voice = edit_field(1, "title", Expr::String("Halo voice edit".to_owned()));
310        let broadcasts = session
311            .submit_from(GlassesPeer::Halo, &halo_voice)
312            .expect("Halo ASR edit commits through the hub");
313        assert_broadcasts_to_both(&session, &broadcasts);
314        assert_eq!(
315            access::field(session.workspace().unwrap(), "title"),
316            Some(&Expr::String("Halo voice edit".to_owned()))
317        );
318        assert_eq!(session.ledger().len(), 1);
319
320        let viture_intent = invoke(2, "pinch");
321        let next = access::set(
322            session.workspace().unwrap(),
323            "focus",
324            build::sym("viture-hand"),
325        );
326        let broadcasts = session
327            .commit_from(GlassesPeer::Viture, &viture_intent, next)
328            .expect("decoded Viture hand Intent commits once");
329        assert_broadcasts_to_both(&session, &broadcasts);
330        assert_eq!(
331            access::field(session.workspace().unwrap(), "focus"),
332            Some(&build::sym("viture-hand"))
333        );
334        assert_eq!(session.ledger().len(), 2);
335        assert_eq!(session.ledger()[1].tick, 2);
336    }
337
338    #[test]
339    fn halo_tap_acks_viture_card_and_detach_keeps_session_alive() {
340        let mut session = session();
341        attach_both(&mut session);
342        let consent = session.consent().clone();
343        let edge = session.session().clone();
344
345        let tap = invoke(3, "double-tap");
346        let broadcasts = session
347            .acknowledge_review_from_halo(&tap, Symbol::qualified("bridge", "packet-review"))
348            .expect("Halo tap acknowledges the Viture review card");
349        assert_broadcasts_to_both(&session, &broadcasts);
350        let review = access::field(session.workspace().unwrap(), "review").unwrap();
351        assert_eq!(access::field(review, "status"), Some(&build::sym("acked")));
352        assert_eq!(
353            access::field(review, "acked-by"),
354            Some(&Expr::Symbol(session.surface(GlassesPeer::Halo).clone()))
355        );
356        assert_eq!(session.ledger().len(), 1);
357
358        let removed = session.detach_viture();
359        assert_eq!(removed.len(), 1);
360        assert_eq!(removed[0].surface, *session.surface(GlassesPeer::Viture));
361        assert_eq!(session.viture_loop_policy(), None);
362        assert_eq!(session.live_bindings().len(), 1);
363        assert_eq!(
364            session.live_bindings()[0].surface,
365            *session.surface(GlassesPeer::Halo)
366        );
367        assert_eq!(session.consent(), &consent);
368        assert_eq!(session.session(), &edge);
369
370        let halo_voice = edit_field(4, "status", build::sym("halo-only"));
371        let broadcasts = session
372            .submit_from(GlassesPeer::Halo, &halo_voice)
373            .expect("Halo keeps editing after Viture detaches");
374        assert_eq!(broadcasts.len(), 1);
375        assert_eq!(broadcasts[0].surface, *session.surface(GlassesPeer::Halo));
376        assert_eq!(session.ledger().len(), 2);
377
378        let before_reattach_rows = session.ledger().len();
379        let viture = SurfaceCaps::from_preset("glasses-luma-ultra", "viture.co-use").unwrap();
380        sim_lib_scene::validate_scene(&session.attach_viture(viture).unwrap()).unwrap();
381        assert_eq!(session.role(GlassesPeer::Viture), Some(SurfaceRole::Main));
382        assert_eq!(session.live_bindings().len(), 2);
383        assert_eq!(
384            session.ledger().len(),
385            before_reattach_rows,
386            "reattach opens a projection but does not append an edit row"
387        );
388        assert_eq!(session.consent(), &consent);
389    }
390
391    fn assert_broadcasts_to_both(session: &GlassesCoUseSession, broadcasts: &[Broadcast]) {
392        assert!(
393            broadcasts
394                .iter()
395                .any(|broadcast| broadcast.surface == *session.surface(GlassesPeer::Viture)),
396            "Viture did not receive a broadcast"
397        );
398        assert!(
399            broadcasts
400                .iter()
401                .any(|broadcast| broadcast.surface == *session.surface(GlassesPeer::Halo)),
402            "Halo did not receive a broadcast"
403        );
404    }
405}