browser_control/session/
freshness.rs1use 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 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}