whatsapp_rust/
version.rs

1use crate::store::commands::DeviceCommand;
2use crate::store::persistence_manager::PersistenceManager;
3use anyhow::{Result, anyhow};
4use log::{info, warn};
5use std::io::Read;
6use std::sync::Arc;
7
8const SW_URL: &str = "https://web.whatsapp.com/sw.js";
9const REVISION_KEY: &str = "client_revision";
10const ASSETS_KEY: &str = "assets-manifest-";
11
12pub fn fetch_latest_app_version() -> Result<(u32, u32, u32)> {
13    let resp = ureq::get(SW_URL)
14        .call()
15        .map_err(|e| anyhow!("HTTP request to {} failed: {}", SW_URL, e))?;
16
17    let mut body = resp.into_body();
18    let mut reader = body.as_reader();
19
20    let mut body_str = String::new();
21    reader
22        .read_to_string(&mut body_str)
23        .map_err(|e| anyhow!("Failed to read response body: {}", e))?;
24
25    parse_sw_js(&body_str)
26        .ok_or_else(|| anyhow!("Could not find 'client_revision' version in sw.js response"))
27}
28
29fn parse_sw_js(s: &str) -> Option<(u32, u32, u32)> {
30    if let Some(start_index) = s.find(REVISION_KEY) {
31        let suffix = &s[start_index + REVISION_KEY.len()..];
32
33        if let Some(first_digit_index) = suffix.find(|c: char| c.is_ascii_digit()) {
34            let number_slice = &suffix[first_digit_index..];
35
36            let end_of_number_index = number_slice
37                .find(|c: char| !c.is_ascii_digit())
38                .unwrap_or(number_slice.len());
39
40            let version_str = &number_slice[..end_of_number_index];
41
42            if let Ok(revision) = version_str.parse::<u32>() {
43                return Some((2, 3000, revision));
44            }
45        }
46    }
47
48    if let Some(start_index) = s.find(ASSETS_KEY) {
49        let suffix = &s[start_index + ASSETS_KEY.len()..];
50        if let Some(end_index) = suffix.find(|c: char| !c.is_ascii_digit()) {
51            let version_str = &suffix[..end_index];
52            if !s.contains(&format!("wa{}.canary", version_str)) {
53                return Some((2, 3000, 0));
54            }
55        }
56    }
57
58    None
59}
60
61pub async fn resolve_and_update_version(
62    persistence_manager: &Arc<PersistenceManager>,
63    override_version: Option<(u32, u32, u32)>,
64) {
65    if let Some((p, s, t)) = override_version {
66        info!("Using user-provided override version: {}.{}.{}", p, s, t);
67        persistence_manager
68            .process_command(DeviceCommand::SetAppVersion((p, s, t)))
69            .await;
70        return;
71    }
72
73    let device = persistence_manager.get_device_snapshot().await;
74    let last_fetched_ms = device.app_version_last_fetched_ms;
75
76    let needs_fetch = if last_fetched_ms == 0 {
77        true
78    } else {
79        match chrono::DateTime::from_timestamp_millis(last_fetched_ms) {
80            Some(last_fetched_dt) => {
81                chrono::Utc::now().signed_duration_since(last_fetched_dt)
82                    > chrono::Duration::hours(24)
83            }
84            None => true,
85        }
86    };
87
88    if needs_fetch {
89        info!("WhatsApp version is stale or missing, fetching latest...");
90        match tokio::task::spawn_blocking(fetch_latest_app_version).await {
91            Ok(Ok((p, s, t))) => {
92                info!("Fetched latest version: {}.{}.{}", p, s, t);
93                persistence_manager
94                    .process_command(DeviceCommand::SetAppVersion((p, s, t)))
95                    .await;
96            }
97            Ok(Err(e)) => {
98                warn!(
99                    "Failed to fetch latest version, using cached/default: {}",
100                    e
101                );
102            }
103            Err(e) => {
104                warn!("Version fetch task panicked: {}", e);
105            }
106        }
107    } else {
108        info!(
109            "Using cached version: {}.{}.{}",
110            device.app_version_primary, device.app_version_secondary, device.app_version_tertiary
111        );
112    }
113}
114
115#[cfg(test)]
116mod tests {
117    use super::*;
118
119    #[test]
120    fn test_parse_sw_js_client_revision_quoted() {
121        let s = r#"var x = {"client_revision": "123456"};"#;
122        assert_eq!(parse_sw_js(s), Some((2, 3000, 123456)));
123    }
124
125    #[test]
126    fn test_parse_sw_js_client_revision_unquoted() {
127        let s = r#"client_revision:12345;"#;
128        assert_eq!(parse_sw_js(s), Some((2, 3000, 12345)));
129    }
130
131    #[test]
132    fn test_parse_sw_js_assets_fallback() {
133        let s = "... assets-manifest-98765 ...";
134        assert_eq!(parse_sw_js(s), Some((2, 3000, 0)));
135    }
136
137    #[test]
138    fn test_parse_sw_js_realistic_sw_js() {
139        let s = r#"__DEV__=0;/*FB_PKG_DELIM*/
140self.__swData=JSON.parse(/*BTDS*/"{\"dynamic_data\":{\"dynamic_modules\":{\"cr:375\":{\"__rc\":[\"WAWebFtsLightClient\",null]},\"cr:1126\":{\"__rc\":[\"TimeSliceSham\",null]},\"cr:4122\":{\"__rc\":[null,null]},\"cr:4324\":{\"__rc\":[null,null]},\"cr:4533\":{\"__rc\":[null,null]},\"cr:4722\":{\"__rc\":[null,null]},\"cr:4941\":{\"__rc\":[null,null]},\"cr:5151\":{\"__rc\":[null,null]},\"cr:5292\":{\"__rc\":[null,null]},\"cr:5411\":{\"__rc\":[null,null]},\"cr:5664\":{\"__rc\":[null,null]},\"cr:6640\":{\"__rc\":[null,null]},\"cr:8978\":{\"__rc\":[null,null]},\"cr:9565\":{\"__rc\":[null,null]},\"cr:10197\":{\"__rc\":[null,null]},\"cr:10198\":{\"__rc\":[null,null]},\"cr:17160\":{\"__rc\":[null,null]},\"cr:17219\":{\"__rc\":[null,null]},\"cr:21223\":{\"__rc\":[null,null]},\"IntlCurrentLocale\":{\"code\":\"en_US\"},\"WAWebSwResources\":{\"wa_default_notification_icon\":\"https:\\\/\\\/static.whatsapp.net\\\/rsrc.php\\\/v4\\\/yX\\\/r\\\/JYPizEwERE4.png\"},\"SiteData\":{\"server_revision\":1026131876,\"client_revision\":1026131876,\"push_phase\":\"C3\",\"pkg_cohort\":\"BP:DEFAULT\",\"haste_session\":\"20320.BP:DEFAULT.2.0...0\",\"pr\":1,\"manifest_base_uri\":\"https:\\\/\\\/static.whatsapp.net\",\"manifest_origin\":null,\"manifest_version_prefix\":null,\"be_one_ahead\":false,\"is_rtl\":false,\"is_experimental_tier\":false,\"is_jit_warmed_up\":true,\"hsi\":\"7540800780599698108\",\"semr_host_bucket\":\"3\",\"bl_hash_version\":2,\"comet_env\":0,\"wbloks_env\":false,\"ef_page\":null,\"compose_bootloads\":false,\"spin\":4,\"__spin_r\":1026131876,\"__spin_b\":\"trunk\",\"__spin_t\":1755729499,\"vip\":\"2a03:2880:f205:c5:face:b00c:0:167\"}},\"hsdp\":{\"bxData\":{\"32186\":{\"uri\":\"https:\\\/\\\/static.whatsapp.net\\\/rsrc.php\\\/v4\\\/yR\\\/r\\\/aCneqBxOSs-.png\"},\"32187\":{\"uri\":\"https:\\\/\\\/static.whatsapp.net\\\/rsrc.php\\\/v4\\\/yT\\\/r\\\/s0hoT-Vu8xP.png\"}},\"gkxData\":{\"4112\":{\"result\":false,\"hash\":null},\"5943\":{\"result\":false,\"hash\":null},\"7685\":{\"result\":false,\"hash\":null},\"10314\":{\"result\":false,\"hash\":null},\"16915\":{\"result\":false,\"hash\":null},\"16928\":{\"result\":false,\"hash\":null},\"17038\":{\"result\":false,\"hash\":null},\"26256\":{\"result\":false,\"hash\":null},\"26258\":{\"result\":true,\"hash\":null},\"26259\":{\"result\":false,\"hash\":null}},\"justknobxData\":{\"371\":{\"r\":true},\"1050\":{\"r\":false},\"1617\":{\"r\":165},\"1618\":{\"r\":8},\"1619\":{\"r\":1},\"1620\":{\"r\":2},\"1621\":{\"r\":4},\"1622\":{\"r\":0},\"1623\":{\"r\":6},\"1624\":{\"r\":1},\"1662\":{\"r\":2},\"1663\":{\"r\":14},\"1664\":{\"r\":2},\"1854\":{\"r\":false},\"2237\":{\"r\":false},\"2337\":{\"r\":false},\"2517\":{\"r\":true},\"3717\":{\"r\":1},\"4952\":{\"r\":true}}}}}");
141
142      if (self.trustedTypes && self.trustedTypes.createPolicy) {
143        const escapeScriptURLPolicy = self.trustedTypes.createPolicy("workerPolicy", {
144          createScriptURL: url => url
145        });
146        importScripts(escapeScriptURLPolicy.createScriptURL("https:\/\/static.whatsapp.net\/rsrc.php\/v4\/yq\/r\/odrxy-7zVX8.js"));
147      } else {
148         importScripts("https:\/\/static.whatsapp.net\/rsrc.php\/v4\/yq\/r\/odrxy-7zVX8.js");
149      }"#;
150
151        assert_eq!(parse_sw_js(s), Some((2, 3000, 1026131876)));
152    }
153}