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