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
48/// Human form of a duration using the same units `parse_max_age` accepts
49/// (`2h`, `30m`, `90s`; mixed values fall back to seconds).
50pub fn format_duration(d: Duration) -> String {
51    let secs = d.as_secs();
52    if secs > 0 && secs % 3600 == 0 {
53        format!("{}h", secs / 3600)
54    } else if secs > 0 && secs % 60 == 0 {
55        format!("{}m", secs / 60)
56    } else {
57        format!("{secs}s")
58    }
59}
60
61pub fn parse_max_age(raw: &str) -> Result<Duration> {
62    let raw = raw.trim();
63    if raw.is_empty() {
64        bail!("--max-age must not be empty");
65    }
66
67    let mut rest = raw;
68    let mut total_ms: u128 = 0;
69    while !rest.is_empty() {
70        let digits_len = rest
71            .char_indices()
72            .take_while(|(_, ch)| ch.is_ascii_digit())
73            .map(|(idx, ch)| idx + ch.len_utf8())
74            .last()
75            .unwrap_or(0);
76        if digits_len == 0 {
77            bail!("invalid --max-age `{raw}`: expected a number");
78        }
79        let n: u128 = rest[..digits_len].parse()?;
80        rest = rest[digits_len..].trim_start();
81
82        let unit_len = rest
83            .char_indices()
84            .take_while(|(_, ch)| ch.is_ascii_alphabetic())
85            .map(|(idx, ch)| idx + ch.len_utf8())
86            .last()
87            .unwrap_or(0);
88        let unit = if unit_len == 0 {
89            "s"
90        } else {
91            &rest[..unit_len]
92        };
93        rest = rest[unit_len..].trim_start();
94
95        let factor = match unit {
96            "ms" => 1,
97            "s" | "sec" | "secs" | "second" | "seconds" => 1_000,
98            "m" | "min" | "mins" | "minute" | "minutes" => 60_000,
99            "h" | "hr" | "hrs" | "hour" | "hours" => 3_600_000,
100            other => bail!("invalid --max-age unit `{other}`: expected ms, s, m, or h"),
101        };
102        total_ms = total_ms
103            .checked_add(n.checked_mul(factor).ok_or_else(|| {
104                anyhow::anyhow!("invalid --max-age `{raw}`: duration is too large")
105            })?)
106            .ok_or_else(|| anyhow::anyhow!("invalid --max-age `{raw}`: duration is too large"))?;
107    }
108
109    Ok(Duration::from_millis(total_ms.try_into()?))
110}
111
112#[cfg(test)]
113mod tests {
114    use super::*;
115    use serde_json::json;
116
117    #[test]
118    fn parse_max_age_accepts_common_units() {
119        assert_eq!(parse_max_age("500ms").unwrap(), Duration::from_millis(500));
120        assert_eq!(parse_max_age("30s").unwrap(), Duration::from_secs(30));
121        assert_eq!(parse_max_age("10m").unwrap(), Duration::from_secs(600));
122        assert_eq!(parse_max_age("1h").unwrap(), Duration::from_secs(3600));
123        assert_eq!(parse_max_age("1h 30m").unwrap(), Duration::from_secs(5400));
124        assert_eq!(parse_max_age("42").unwrap(), Duration::from_secs(42));
125    }
126
127    #[test]
128    fn page_freshness_only_reloads_http_pages_over_age() {
129        let info = parse_page_freshness(json!({
130            "href": "https://example.com/app",
131            "ageMs": 700_000.0,
132            "readyState": "complete"
133        }))
134        .unwrap();
135        assert!(info.should_reload(Duration::from_secs(600)));
136
137        let blank = PageFreshness {
138            href: "about:blank".to_string(),
139            age_ms: 700_000.0,
140        };
141        assert!(!blank.should_reload(Duration::from_secs(600)));
142    }
143}