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}