use crate::api::{Carry, Cookie, FlipReceiver, FormValues, LayerSet, PortableViewState};
pub trait ScrySurface {
fn set_cookie(&mut self, cookie: &Cookie) -> Result<(), String>;
fn navigate(&mut self, url: &str) -> Result<(), String>;
fn run_script(&mut self, js: &str) -> Result<String, String>;
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum NavSignal {
Started,
Completed { success: bool },
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum Phase {
Pending,
Navigating,
Done,
}
pub struct ScryForward {
url: Option<String>,
scroll: (f32, f32),
form: Option<FormValues>,
cookies: Vec<Cookie>,
phase: Phase,
}
impl ScryForward {
pub const RECEIVES: LayerSet = LayerSet::NAV.union(LayerSet::SESSION).union(LayerSet::FORM);
pub fn new(state: PortableViewState) -> Self {
Self {
url: state.url,
scroll: state.scroll,
form: state.form,
cookies: state.cookies,
phase: Phase::Pending,
}
}
pub fn has_target(&self) -> bool {
self.url.is_some()
}
pub fn begin(&mut self, surface: &mut dyn ScrySurface) -> Result<(), String> {
if self.phase != Phase::Pending {
return Ok(());
}
for cookie in &self.cookies {
if let Err(err) = surface.set_cookie(cookie) {
tracing_warn(&format!("scry flip: set_cookie failed: {err}"));
}
}
match &self.url {
Some(url) => {
surface.navigate(url)?;
self.phase = Phase::Navigating;
Ok(())
}
None => {
self.phase = Phase::Done;
Ok(())
}
}
}
pub fn on_nav(&mut self, signal: NavSignal, surface: &mut dyn ScrySurface) {
if self.phase != Phase::Navigating {
return;
}
match signal {
NavSignal::Started => {}
NavSignal::Completed { success } => {
if success {
if let Some(js) = self.restore_script() {
if let Err(err) = surface.run_script(&js) {
tracing_warn(&format!("scry flip: restore script failed: {err}"));
}
}
}
self.phase = Phase::Done;
}
}
}
pub fn is_done(&self) -> bool {
self.phase == Phase::Done
}
fn restore_script(&self) -> Option<String> {
let has_scroll = self.scroll != (0.0, 0.0);
let fields = self.form.as_ref().map(|f| f.0.as_slice()).unwrap_or(&[]);
if !has_scroll && fields.is_empty() {
return None;
}
let mut js = String::from("(function(){");
if has_scroll {
js.push_str(&format!(
"window.scrollTo({},{});",
self.scroll.0, self.scroll.1
));
}
for (key, value) in fields {
js.push_str(&format!(
"(function(k,v){{var es=document.getElementsByName(k);\
if(es.length){{for(var i=0;i<es.length;i++)es[i].value=v;}}\
else{{var e=document.getElementById(k);if(e)e.value=v;}}}})({},{});",
js_string(key),
js_string(value),
));
}
js.push_str("})();");
Some(js)
}
}
impl FlipReceiver for ScryForward {
fn receives(&self) -> LayerSet {
Self::RECEIVES
}
fn present(&mut self, carry: Carry) {
if let Carry::Forward(state) = carry {
*self = ScryForward::new(state);
}
}
}
fn js_string(s: &str) -> String {
let mut out = String::with_capacity(s.len() + 2);
out.push('"');
for c in s.chars() {
match c {
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
c => out.push(c),
}
}
out.push('"');
out
}
#[inline]
fn tracing_warn(msg: &str) {
#[cfg(debug_assertions)]
eprintln!("[verso-scry] {msg}");
#[cfg(not(debug_assertions))]
let _ = msg;
}
#[cfg(test)]
mod tests {
use super::*;
#[derive(Default)]
struct MockSurface {
cookies: Vec<Cookie>,
navigated: Option<String>,
scripts: Vec<String>,
}
impl ScrySurface for MockSurface {
fn set_cookie(&mut self, cookie: &Cookie) -> Result<(), String> {
self.cookies.push(cookie.clone());
Ok(())
}
fn navigate(&mut self, url: &str) -> Result<(), String> {
self.navigated = Some(url.to_string());
Ok(())
}
fn run_script(&mut self, js: &str) -> Result<String, String> {
self.scripts.push(js.to_string());
Ok(String::new())
}
}
fn forward_state() -> PortableViewState {
PortableViewState {
url: Some("https://example.com/app".into()),
scroll: (0.0, 200.0),
form: Some(FormValues(vec![("user".into(), "ada".into())])),
cookies: vec![Cookie {
name: "sid".into(),
value: "tok".into(),
..Cookie::default()
}],
dom_snapshot: None,
visual: None,
}
}
#[test]
fn begin_sets_cookies_before_navigating() {
let mut surface = MockSurface::default();
let mut flip = ScryForward::new(forward_state());
flip.begin(&mut surface).unwrap();
assert_eq!(surface.cookies.len(), 1); assert_eq!(
surface.navigated.as_deref(),
Some("https://example.com/app")
); assert!(!flip.is_done()); }
#[test]
fn restore_runs_once_on_successful_completion() {
let mut surface = MockSurface::default();
let mut flip = ScryForward::new(forward_state());
flip.begin(&mut surface).unwrap();
flip.on_nav(NavSignal::Started, &mut surface);
assert!(surface.scripts.is_empty()); flip.on_nav(NavSignal::Completed { success: true }, &mut surface);
assert_eq!(surface.scripts.len(), 1);
let js = &surface.scripts[0];
assert!(js.contains("scrollTo(0,200)")); assert!(js.contains("\"user\"") && js.contains("\"ada\"")); assert!(flip.is_done());
flip.on_nav(NavSignal::Completed { success: true }, &mut surface);
assert_eq!(surface.scripts.len(), 1);
}
#[test]
fn failed_load_finishes_without_restoring() {
let mut surface = MockSurface::default();
let mut flip = ScryForward::new(forward_state());
flip.begin(&mut surface).unwrap();
flip.on_nav(NavSignal::Completed { success: false }, &mut surface);
assert!(surface.scripts.is_empty()); assert!(flip.is_done());
}
#[test]
fn no_url_is_terminal_after_begin() {
let mut surface = MockSurface::default();
let state = PortableViewState {
url: None,
..forward_state()
};
let mut flip = ScryForward::new(state);
assert!(!flip.has_target());
flip.begin(&mut surface).unwrap();
assert!(surface.navigated.is_none()); assert!(flip.is_done()); }
#[test]
fn nothing_to_restore_skips_the_script() {
let mut surface = MockSurface::default();
let state = PortableViewState {
url: Some("https://example.com/".into()),
scroll: (0.0, 0.0),
form: None,
cookies: vec![],
dom_snapshot: None,
visual: None,
};
let mut flip = ScryForward::new(state);
flip.begin(&mut surface).unwrap();
flip.on_nav(NavSignal::Completed { success: true }, &mut surface);
assert!(surface.scripts.is_empty()); assert!(flip.is_done());
}
#[test]
fn present_stages_a_forward_carry() {
let mut flip = ScryForward::new(PortableViewState::default());
flip.present(Carry::Forward(forward_state()));
assert!(flip.has_target());
assert_eq!(flip.receives(), ScryForward::RECEIVES);
}
}