Skip to main content

browser_control/session/
freshness.rs

1//! Page freshness helpers used before reading browser-backed auth state.
2
3use std::time::Duration;
4
5use anyhow::{bail, Result};
6use serde::Deserialize;
7use serde_json::Value;
8
9pub const DEFAULT_MAX_AGE: Duration = Duration::from_secs(10 * 60);
10pub const DEFAULT_MAX_AGE_STR: &str = "10m";
11pub const CHECK_TIMEOUT: Duration = Duration::from_secs(10);
12pub const RELOAD_READY_TIMEOUT: Duration = Duration::from_secs(20);
13pub const READY_POLL_INTERVAL: Duration = Duration::from_millis(200);
14
15pub const PAGE_FRESHNESS_EXPR: &str = r#"(() => ({
16  href: location.href,
17  ageMs: Math.max(0, Date.now() - performance.timeOrigin),
18  readyState: document.readyState
19}))()"#;
20
21pub const READY_STATE_EXPR: &str = "document.readyState";
22
23#[derive(Debug, Clone, Deserialize, PartialEq)]
24pub struct PageFreshness {
25    pub href: String,
26    #[serde(rename = "ageMs")]
27    pub age_ms: f64,
28}
29
30impl PageFreshness {
31    pub fn should_reload(&self, max_age: Duration) -> bool {
32        is_reloadable_url(&self.href) && self.age_ms >= max_age.as_millis() as f64
33    }
34}
35
36pub fn parse_page_freshness(value: Value) -> Result<PageFreshness> {
37    Ok(serde_json::from_value(value)?)
38}
39
40pub fn is_ready(value: &Value) -> bool {
41    matches!(value.as_str(), Some("complete"))
42}
43
44fn is_reloadable_url(url: &str) -> bool {
45    url.starts_with("http://") || url.starts_with("https://")
46}
47
48pub fn parse_max_age(raw: &str) -> Result<Duration> {
49    let raw = raw.trim();
50    if raw.is_empty() {
51        bail!("--max-age must not be empty");
52    }
53
54    let mut rest = raw;
55    let mut total_ms: u128 = 0;
56    while !rest.is_empty() {
57        let digits_len = rest
58            .char_indices()
59            .take_while(|(_, ch)| ch.is_ascii_digit())
60            .map(|(idx, ch)| idx + ch.len_utf8())
61            .last()
62            .unwrap_or(0);
63        if digits_len == 0 {
64            bail!("invalid --max-age `{raw}`: expected a number");
65        }
66        let n: u128 = rest[..digits_len].parse()?;
67        rest = rest[digits_len..].trim_start();
68
69        let unit_len = rest
70            .char_indices()
71            .take_while(|(_, ch)| ch.is_ascii_alphabetic())
72            .map(|(idx, ch)| idx + ch.len_utf8())
73            .last()
74            .unwrap_or(0);
75        let unit = if unit_len == 0 {
76            "s"
77        } else {
78            &rest[..unit_len]
79        };
80        rest = rest[unit_len..].trim_start();
81
82        let factor = match unit {
83            "ms" => 1,
84            "s" | "sec" | "secs" | "second" | "seconds" => 1_000,
85            "m" | "min" | "mins" | "minute" | "minutes" => 60_000,
86            "h" | "hr" | "hrs" | "hour" | "hours" => 3_600_000,
87            other => bail!("invalid --max-age unit `{other}`: expected ms, s, m, or h"),
88        };
89        total_ms = total_ms
90            .checked_add(n.checked_mul(factor).ok_or_else(|| {
91                anyhow::anyhow!("invalid --max-age `{raw}`: duration is too large")
92            })?)
93            .ok_or_else(|| anyhow::anyhow!("invalid --max-age `{raw}`: duration is too large"))?;
94    }
95
96    Ok(Duration::from_millis(total_ms.try_into()?))
97}
98
99#[cfg(test)]
100mod tests {
101    use super::*;
102    use serde_json::json;
103
104    #[test]
105    fn parse_max_age_accepts_common_units() {
106        assert_eq!(parse_max_age("500ms").unwrap(), Duration::from_millis(500));
107        assert_eq!(parse_max_age("30s").unwrap(), Duration::from_secs(30));
108        assert_eq!(parse_max_age("10m").unwrap(), Duration::from_secs(600));
109        assert_eq!(parse_max_age("1h").unwrap(), Duration::from_secs(3600));
110        assert_eq!(parse_max_age("1h 30m").unwrap(), Duration::from_secs(5400));
111        assert_eq!(parse_max_age("42").unwrap(), Duration::from_secs(42));
112    }
113
114    #[test]
115    fn page_freshness_only_reloads_http_pages_over_age() {
116        let info = parse_page_freshness(json!({
117            "href": "https://example.com/app",
118            "ageMs": 700_000.0,
119            "readyState": "complete"
120        }))
121        .unwrap();
122        assert!(info.should_reload(Duration::from_secs(600)));
123
124        let blank = PageFreshness {
125            href: "about:blank".to_string(),
126            age_ms: 700_000.0,
127        };
128        assert!(!blank.should_reload(Duration::from_secs(600)));
129    }
130}