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}