Skip to main content

verso_tile/
scry.rs

1//! The scry (system-WebView) flip receiver (was the `verso-scry` crate).
2//!
3//! scry is a black-box secondary (charter §3, asymmetric fidelity): the host cannot
4//! inject a live document into a system WebView, only *navigate* it and then nudge it
5//! by script. So a forward flip into scry re-fetches the URL faithfully (the WebView
6//! runs the page's real JS) and carries the cheap layers across:
7//!
8//! * SESSION — cookies set on the WebView store *before* navigation, so the page
9//!   loads already-authenticated (a login made in serval comes across).
10//! * NAV — the scroll offset, restored by script once the load completes.
11//! * FORM — field values, refilled by script, best-effort.
12//!
13//! It does **not** take the DOM snapshot (it re-fetches from source) or the visual
14//! frame (the host compositor owns the cross-fade). The DOM-snapshot degrade path
15//! (`navigate_to_string`) is for url-less documents and is left to the host.
16//!
17//! ## Two-phase, host-driven
18//!
19//! A WebView loads across frames (the §1 finding): the receiver cannot do its work in
20//! one synchronous call. [`ScryForward`] is therefore a small state machine the host
21//! frame loop drives: [`begin`](ScryForward::begin) sets cookies and navigates;
22//! [`on_nav`](ScryForward::on_nav) runs the scroll/form restore when the host reports
23//! the load `Completed`. The host bridges its concrete producer to the [`ScrySurface`]
24//! seam and translates its nav-event stream into [`NavSignal`]s, so this module stays
25//! free of the platform WebView dep and is unit-testable on its own.
26
27use crate::api::{Carry, Cookie, FlipReceiver, FormValues, LayerSet, PortableViewState};
28
29/// The handful of WebView operations a forward flip needs, abstracted so this crate
30/// does not depend on the concrete (Windows-only) producer. The host implements it
31/// over its WebView, mapping [`Cookie`] to the engine's cookie type and running script
32/// synchronously (the engine's blocking, message-pumped execute-script is fine for the
33/// one-shot restore).
34pub trait ScrySurface {
35    /// Set one cookie on the WebView's store. Called before navigation.
36    fn set_cookie(&mut self, cookie: &Cookie) -> Result<(), String>;
37    /// Begin a (non-blocking) navigation to `url`.
38    fn navigate(&mut self, url: &str) -> Result<(), String>;
39    /// Run `js` in the loaded document. The result is ignored by the restore, but the
40    /// signature mirrors the producer's `execute_script_with_result` so the host need
41    /// not invent a void variant.
42    fn run_script(&mut self, js: &str) -> Result<String, String>;
43}
44
45/// A navigation signal the host pumps in from its producer's nav-event stream
46/// (scrying's `NavigationEvent::Completed { success }` maps to
47/// `NavSignal::Completed { success }`).
48#[derive(Clone, Copy, Debug, PartialEq, Eq)]
49pub enum NavSignal {
50    /// A navigation started. Informational; the receiver waits for completion.
51    Started,
52    /// The navigation finished. `success` is the top-level load result.
53    Completed { success: bool },
54}
55
56#[derive(Clone, Copy, PartialEq, Eq, Debug)]
57enum Phase {
58    /// Built, not yet begun. `begin` moves it to `Navigating`.
59    Pending,
60    /// Cookies set and navigation kicked off; waiting for `Completed`.
61    Navigating,
62    /// Restore script run (or nothing to restore). Terminal.
63    Done,
64}
65
66/// The receiving side of a forward flip into a scry tile. Holds the carried view-state
67/// and drives the cookies → navigate → restore choreography across host frames.
68pub struct ScryForward {
69    url: Option<String>,
70    scroll: (f32, f32),
71    form: Option<FormValues>,
72    cookies: Vec<Cookie>,
73    phase: Phase,
74}
75
76impl ScryForward {
77    /// The layers scry can apply: SESSION (cookies), NAV (scroll; the URL is the
78    /// navigation key), and FORM (refill). Not DOM (it re-fetches) nor VISUAL.
79    pub const RECEIVES: LayerSet = LayerSet::NAV.union(LayerSet::SESSION).union(LayerSet::FORM);
80
81    /// Build a receiver from the carried state. The carrier has already masked it to
82    /// the intersection of the donor's offer and [`RECEIVES`](Self::RECEIVES), so this
83    /// keeps whatever survived.
84    pub fn new(state: PortableViewState) -> Self {
85        Self {
86            url: state.url,
87            scroll: state.scroll,
88            form: state.form,
89            cookies: state.cookies,
90            phase: Phase::Pending,
91        }
92    }
93
94    /// Whether there is a URL to navigate to. A flip with no URL cannot use the
95    /// faithful re-fetch path; the host should fall back to the DOM-snapshot degrade
96    /// path (or skip the flip) rather than drive this receiver.
97    pub fn has_target(&self) -> bool {
98        self.url.is_some()
99    }
100
101    /// Phase 1: set the carried cookies on the WebView store, then navigate. Cookies
102    /// go on *before* navigation so the page loads authenticated. A no-op once begun.
103    /// Cookie failures are non-fatal (logged by the host); a missing cookie degrades
104    /// the session, it does not block the flip.
105    pub fn begin(&mut self, surface: &mut dyn ScrySurface) -> Result<(), String> {
106        if self.phase != Phase::Pending {
107            return Ok(());
108        }
109        for cookie in &self.cookies {
110            if let Err(err) = surface.set_cookie(cookie) {
111                // Degrade, never block: one cookie that won't set is not a reason to
112                // abandon the flip. The host decides whether to surface it.
113                tracing_warn(&format!("scry flip: set_cookie failed: {err}"));
114            }
115        }
116        match &self.url {
117            Some(url) => {
118                surface.navigate(url)?;
119                self.phase = Phase::Navigating;
120                Ok(())
121            }
122            // No URL: nothing to navigate. Terminal; the host handles the degrade path.
123            None => {
124                self.phase = Phase::Done;
125                Ok(())
126            }
127        }
128    }
129
130    /// Phase 2: feed a nav signal. On the first successful `Completed`, run the
131    /// scroll + form restore by script and finish. A failed load also finishes (there
132    /// is nothing to restore onto a failed page). Ignored unless navigating.
133    pub fn on_nav(&mut self, signal: NavSignal, surface: &mut dyn ScrySurface) {
134        if self.phase != Phase::Navigating {
135            return;
136        }
137        match signal {
138            NavSignal::Started => {}
139            NavSignal::Completed { success } => {
140                if success {
141                    if let Some(js) = self.restore_script() {
142                        if let Err(err) = surface.run_script(&js) {
143                            tracing_warn(&format!("scry flip: restore script failed: {err}"));
144                        }
145                    }
146                }
147                self.phase = Phase::Done;
148            }
149        }
150    }
151
152    /// Whether the flip has run to completion (restored, failed, or had no URL).
153    pub fn is_done(&self) -> bool {
154        self.phase == Phase::Done
155    }
156
157    /// The post-load restore: scroll the viewport, then refill known form fields by
158    /// name (falling back to id). Returns `None` when there is nothing to restore.
159    fn restore_script(&self) -> Option<String> {
160        let has_scroll = self.scroll != (0.0, 0.0);
161        let fields = self.form.as_ref().map(|f| f.0.as_slice()).unwrap_or(&[]);
162        if !has_scroll && fields.is_empty() {
163            return None;
164        }
165        let mut js = String::from("(function(){");
166        if has_scroll {
167            js.push_str(&format!(
168                "window.scrollTo({},{});",
169                self.scroll.0, self.scroll.1
170            ));
171        }
172        for (key, value) in fields {
173            // getElementsByName takes the raw name (no CSS escaping); fall back to id.
174            // Both strings are JSON-encoded for a safe JS string literal.
175            js.push_str(&format!(
176                "(function(k,v){{var es=document.getElementsByName(k);\
177                 if(es.length){{for(var i=0;i<es.length;i++)es[i].value=v;}}\
178                 else{{var e=document.getElementById(k);if(e)e.value=v;}}}})({},{});",
179                js_string(key),
180                js_string(value),
181            ));
182        }
183        js.push_str("})();");
184        Some(js)
185    }
186}
187
188/// scry receives a [`Carry::Forward`] by storing it for the host-driven pump. A
189/// [`Carry::Back`] is meaningless for a secondary (it never re-roots), so it is
190/// ignored. `present` only stages the flip; [`begin`](ScryForward::begin) /
191/// [`on_nav`](ScryForward::on_nav) do the work across frames.
192impl FlipReceiver for ScryForward {
193    fn receives(&self) -> LayerSet {
194        Self::RECEIVES
195    }
196
197    fn present(&mut self, carry: Carry) {
198        if let Carry::Forward(state) = carry {
199            *self = ScryForward::new(state);
200        }
201    }
202}
203
204/// JSON-encode a string into a double-quoted JS string literal (escapes `"`, `\`,
205/// and control characters). Enough for embedding form keys/values in a script.
206fn js_string(s: &str) -> String {
207    let mut out = String::with_capacity(s.len() + 2);
208    out.push('"');
209    for c in s.chars() {
210        match c {
211            '"' => out.push_str("\\\""),
212            '\\' => out.push_str("\\\\"),
213            '\n' => out.push_str("\\n"),
214            '\r' => out.push_str("\\r"),
215            '\t' => out.push_str("\\t"),
216            c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
217            c => out.push(c),
218        }
219    }
220    out.push('"');
221    out
222}
223
224// `tracing` is not a dep of this crate (the default build stays dependency-free); the host owns
225// logging. This thin shim keeps the degrade-path warnings visible in debug builds
226// without taking the dep. The host's nav pump is where real telemetry lives.
227#[inline]
228fn tracing_warn(msg: &str) {
229    #[cfg(debug_assertions)]
230    eprintln!("[verso-scry] {msg}");
231    #[cfg(not(debug_assertions))]
232    let _ = msg;
233}
234
235#[cfg(test)]
236mod tests {
237    use super::*;
238
239    /// A scriptable stand-in for the host's WebView: records the calls the flip makes.
240    #[derive(Default)]
241    struct MockSurface {
242        cookies: Vec<Cookie>,
243        navigated: Option<String>,
244        scripts: Vec<String>,
245    }
246    impl ScrySurface for MockSurface {
247        fn set_cookie(&mut self, cookie: &Cookie) -> Result<(), String> {
248            self.cookies.push(cookie.clone());
249            Ok(())
250        }
251        fn navigate(&mut self, url: &str) -> Result<(), String> {
252            self.navigated = Some(url.to_string());
253            Ok(())
254        }
255        fn run_script(&mut self, js: &str) -> Result<String, String> {
256            self.scripts.push(js.to_string());
257            Ok(String::new())
258        }
259    }
260
261    fn forward_state() -> PortableViewState {
262        PortableViewState {
263            url: Some("https://example.com/app".into()),
264            scroll: (0.0, 200.0),
265            form: Some(FormValues(vec![("user".into(), "ada".into())])),
266            cookies: vec![Cookie {
267                name: "sid".into(),
268                value: "tok".into(),
269                ..Cookie::default()
270            }],
271            dom_snapshot: None,
272            visual: None,
273        }
274    }
275
276    #[test]
277    fn begin_sets_cookies_before_navigating() {
278        let mut surface = MockSurface::default();
279        let mut flip = ScryForward::new(forward_state());
280        flip.begin(&mut surface).unwrap();
281        assert_eq!(surface.cookies.len(), 1); // session set
282        assert_eq!(
283            surface.navigated.as_deref(),
284            Some("https://example.com/app")
285        ); // then navigate
286        assert!(!flip.is_done()); // waiting for the load
287    }
288
289    #[test]
290    fn restore_runs_once_on_successful_completion() {
291        let mut surface = MockSurface::default();
292        let mut flip = ScryForward::new(forward_state());
293        flip.begin(&mut surface).unwrap();
294        flip.on_nav(NavSignal::Started, &mut surface);
295        assert!(surface.scripts.is_empty()); // nothing restored mid-load
296        flip.on_nav(NavSignal::Completed { success: true }, &mut surface);
297        assert_eq!(surface.scripts.len(), 1);
298        let js = &surface.scripts[0];
299        assert!(js.contains("scrollTo(0,200)")); // NAV restored
300        assert!(js.contains("\"user\"") && js.contains("\"ada\"")); // FORM refilled
301        assert!(flip.is_done());
302        // A second Completed does not re-run the restore.
303        flip.on_nav(NavSignal::Completed { success: true }, &mut surface);
304        assert_eq!(surface.scripts.len(), 1);
305    }
306
307    #[test]
308    fn failed_load_finishes_without_restoring() {
309        let mut surface = MockSurface::default();
310        let mut flip = ScryForward::new(forward_state());
311        flip.begin(&mut surface).unwrap();
312        flip.on_nav(NavSignal::Completed { success: false }, &mut surface);
313        assert!(surface.scripts.is_empty()); // nothing to restore onto a failed page
314        assert!(flip.is_done());
315    }
316
317    #[test]
318    fn no_url_is_terminal_after_begin() {
319        let mut surface = MockSurface::default();
320        let state = PortableViewState {
321            url: None,
322            ..forward_state()
323        };
324        let mut flip = ScryForward::new(state);
325        assert!(!flip.has_target());
326        flip.begin(&mut surface).unwrap();
327        assert!(surface.navigated.is_none()); // nothing to navigate
328        assert!(flip.is_done()); // host takes the degrade path
329    }
330
331    #[test]
332    fn nothing_to_restore_skips_the_script() {
333        let mut surface = MockSurface::default();
334        let state = PortableViewState {
335            url: Some("https://example.com/".into()),
336            scroll: (0.0, 0.0),
337            form: None,
338            cookies: vec![],
339            dom_snapshot: None,
340            visual: None,
341        };
342        let mut flip = ScryForward::new(state);
343        flip.begin(&mut surface).unwrap();
344        flip.on_nav(NavSignal::Completed { success: true }, &mut surface);
345        assert!(surface.scripts.is_empty()); // no scroll, no forms -> no script
346        assert!(flip.is_done());
347    }
348
349    #[test]
350    fn present_stages_a_forward_carry() {
351        let mut flip = ScryForward::new(PortableViewState::default());
352        flip.present(Carry::Forward(forward_state()));
353        assert!(flip.has_target());
354        assert_eq!(flip.receives(), ScryForward::RECEIVES);
355    }
356}