Skip to main content

opentele2_versions/
lib.rs

1//! Current official Telegram client versions, fetched from their public
2//! release feeds, plus the stable Chrome and Firefox versions the web
3//! fingerprints use, as a patch onto [`opentele2_core::api::Versions`].
4//!
5//! [`fetch_all`] queries the nine sources (Telegram Desktop, Android,
6//! Telegram X, iOS, macOS, Web K, Web A, Chrome, Firefox) in parallel over
7//! blocking HTTP with rustls and merges whatever answered into a
8//! [`VersionsPatch`]. A source that fails or times out only leaves its fields
9//! unset. [`VersionsPatch::apply`] writes the patch onto a [`Versions`] field
10//! by field.
11//!
12//! The parsing of every response lives in [`parse`] as pure functions, so it
13//! is testable without the network.
14
15pub mod parse;
16
17use std::thread;
18use std::time::Duration;
19
20use opentele2_core::api::Versions;
21use serde_json::{Map, Value};
22use ureq::Agent;
23
24/// The default per-request timeout.
25pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10);
26
27/// The user agent sent to the JSON and plain-text sources.
28pub const API_USER_AGENT: &str = "opentele2";
29
30/// The browser user agent sent to `t.me`.
31pub const TG_USER_AGENT: &str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) \
32    AppleWebKit/537.36 (KHTML, like Gecko) Chrome/155.0.0.0 Safari/537.36";
33
34const API_ACCEPT: &str = "application/json, text/plain, */*";
35const TG_ACCEPT: &str = "text/html,application/xhtml+xml,*/*;q=0.8";
36
37const TDESKTOP_URL: &str = "https://api.github.com/repos/telegramdesktop/tdesktop/releases/latest";
38const TELEGRAM_X_URL: &str = "https://api.github.com/repos/TGX-Android/Telegram-X/releases/latest";
39// The percent-encoded searches "android versions:" and "ios versions:".
40const TG_ANDROID_URL: &str = "https://t.me/s/tgstable?q=android%20versions%3A";
41const TG_IOS_URL: &str = "https://t.me/s/tgstable?q=ios%20versions%3A";
42const PLAY_ANDROID_URL: &str = "https://play.rajkumaar.co.in/json?id=org.telegram.messenger";
43const ITUNES_IOS_URL: &str = "https://itunes.apple.com/lookup?id=686449807";
44const BREW_MACOS_URL: &str = "https://formulae.brew.sh/api/cask/telegram.json";
45const ITUNES_MACOS_URL: &str = "https://itunes.apple.com/lookup?id=747648890";
46const WEB_K_URL: &str =
47    "https://raw.githubusercontent.com/morethanwords/tweb/master/public/version";
48const WEB_A_URL: &str =
49    "https://raw.githubusercontent.com/Ajaxy/telegram-tt/refs/heads/master/public/version.txt";
50const CHROME_URL: &str =
51    "https://googlechromelabs.github.io/chrome-for-testing/last-known-good-versions.json";
52const FIREFOX_URL: &str = "https://product-details.mozilla.org/1.0/firefox_versions.json";
53
54/// A [`VersionsPatch`] field type, convertible to and from a JSON value for
55/// [`VersionsPatch::to_map`] and [`VersionsPatch::from_map`].
56trait PatchField: Sized {
57    /// What [`VersionsPatch::from_map`] reports a wrongly typed value as.
58    const EXPECTED: &'static str;
59
60    fn to_value(&self) -> Value;
61
62    fn from_value(value: &Value) -> Option<Self>;
63}
64
65impl PatchField for String {
66    const EXPECTED: &'static str = "a string";
67
68    fn to_value(&self) -> Value {
69        Value::String(self.clone())
70    }
71
72    fn from_value(value: &Value) -> Option<Self> {
73        value.as_str().map(str::to_owned)
74    }
75}
76
77impl PatchField for u32 {
78    const EXPECTED: &'static str = "an unsigned 32-bit integer";
79
80    fn to_value(&self) -> Value {
81        Value::from(*self)
82    }
83
84    fn from_value(value: &Value) -> Option<Self> {
85        value.as_u64().and_then(|n| Self::try_from(n).ok())
86    }
87}
88
89/// Declares [`VersionsPatch`] from one field list, so the struct,
90/// [`VersionsPatch::FIELDS`], `merge`, `to_map` and `from_map` never drift
91/// apart.
92macro_rules! versions_patch {
93    ($($(#[$doc:meta])* $field:ident: $ty:ty,)*) => {
94        /// The fields of [`Versions`] the sources returned; `None` for every
95        /// field no source answered with a non-empty value.
96        #[derive(Debug, Clone, Default, PartialEq, Eq, Hash)]
97        pub struct VersionsPatch {
98            $($(#[$doc])* pub $field: Option<$ty>,)*
99        }
100
101        impl VersionsPatch {
102            /// Every field name, in declaration order: the keys of
103            /// [`VersionsPatch::to_map`] and [`VersionsPatch::from_map`].
104            pub const FIELDS: &[&str] = &[$(stringify!($field)),*];
105
106            /// Takes every field `other` sets, keeping the rest of `self`.
107            pub fn merge(&mut self, other: Self) {
108                $(
109                    if other.$field.is_some() {
110                        self.$field = other.$field;
111                    }
112                )*
113            }
114
115            /// The set fields keyed by field name, strings as JSON strings
116            /// and numbers as JSON numbers; unset fields are absent.
117            #[must_use]
118            pub fn to_map(&self) -> Map<String, Value> {
119                let mut map = Map::new();
120                $(
121                    if let Some(value) = &self.$field {
122                        map.insert(stringify!($field).to_owned(), value.to_value());
123                    }
124                )*
125                map
126            }
127
128            /// The patch [`VersionsPatch::to_map`] gives `map`; a missing key
129            /// or a JSON `null` leaves the field unset.
130            ///
131            /// # Errors
132            ///
133            /// A key that is not in [`VersionsPatch::FIELDS`], or a value of
134            /// the wrong type, is an error naming the key.
135            pub fn from_map(map: &Map<String, Value>) -> Result<Self, String> {
136                let mut patch = Self::default();
137                for (key, value) in map {
138                    match key.as_str() {
139                        $(
140                            stringify!($field) => {
141                                patch.$field = from_json(key, value)?;
142                            }
143                        )*
144                        _ => return Err(format!("unknown VersionsPatch field `{key}`")),
145                    }
146                }
147                Ok(patch)
148            }
149        }
150    };
151}
152
153fn from_json<T: PatchField>(key: &str, value: &Value) -> Result<Option<T>, String> {
154    if value.is_null() {
155        return Ok(None);
156    }
157    T::from_value(value)
158        .map(Some)
159        .ok_or_else(|| format!("VersionsPatch field `{key}` must be {}", T::EXPECTED))
160}
161
162versions_patch! {
163    /// Latest Telegram Desktop release. Reported but never applied: the
164    /// desktop version is bound to the version written into tdata.
165    desktop_app_version: String,
166    /// Telegram Android version.
167    android_app_version: String,
168    /// Telegram Android version code.
169    android_app_version_code: u32,
170    /// Telegram X version.
171    android_x_app_version: String,
172    /// Telegram iOS version.
173    ios_app_version: String,
174    /// Telegram iOS build number.
175    ios_build_number: u32,
176    /// Telegram for macOS version.
177    macos_app_version: String,
178    /// Telegram for macOS build number.
179    macos_build_number: u32,
180    /// Telegram Web K version, with its ` K` suffix.
181    web_k_version: String,
182    /// Telegram Web A version, with its ` A` suffix.
183    web_a_version: String,
184    /// Stable Chrome version as its user agent reports it, `<major>.0.0.0`.
185    chrome_version: String,
186    /// Stable Firefox version as its user agent reports it, `<major>.0`.
187    firefox_version: String,
188}
189
190impl VersionsPatch {
191    /// Whether no field is set.
192    #[must_use]
193    pub fn is_empty(&self) -> bool {
194        *self == Self::default()
195    }
196
197    /// Writes every set field onto `versions`: each field lands on the [`Versions`] field of the same name, except
198    /// `desktop_app_version`, which stays bound to the tdata version, and
199    /// `chrome_version`, which goes through
200    /// [`Versions::set_chrome_version`] so the user agent follows it.
201    pub fn apply(&self, versions: &mut Versions) {
202        fn set<T: Clone>(into: &mut T, from: Option<&T>) {
203            if let Some(value) = from {
204                into.clone_from(value);
205            }
206        }
207        set(
208            &mut versions.android_app_version,
209            self.android_app_version.as_ref(),
210        );
211        set(
212            &mut versions.android_app_version_code,
213            self.android_app_version_code.as_ref(),
214        );
215        set(
216            &mut versions.android_x_app_version,
217            self.android_x_app_version.as_ref(),
218        );
219        set(&mut versions.ios_app_version, self.ios_app_version.as_ref());
220        set(
221            &mut versions.ios_build_number,
222            self.ios_build_number.as_ref(),
223        );
224        set(
225            &mut versions.macos_app_version,
226            self.macos_app_version.as_ref(),
227        );
228        set(
229            &mut versions.macos_build_number,
230            self.macos_build_number.as_ref(),
231        );
232        set(&mut versions.web_k_version, self.web_k_version.as_ref());
233        set(&mut versions.web_a_version, self.web_a_version.as_ref());
234        if let Some(chrome_version) = &self.chrome_version {
235            versions.set_chrome_version(chrome_version.as_str());
236        }
237        set(&mut versions.firefox_version, self.firefox_version.as_ref());
238    }
239}
240
241type Source = fn(&Agent) -> Option<VersionsPatch>;
242
243const SOURCES: [Source; 9] = [
244    fetch_tdesktop,
245    fetch_android,
246    fetch_telegram_x,
247    fetch_ios,
248    fetch_macos,
249    fetch_web_k,
250    fetch_web_a,
251    fetch_chrome,
252    fetch_firefox,
253];
254
255/// Fetches every source in parallel, one thread each, and merges the
256/// answers. `timeout` bounds each HTTP request; a source with a fallback
257/// makes at most two.
258///
259/// Never fails: a source that errors, times out, or returns nothing usable
260/// leaves its fields `None`, and with no network the patch is empty.
261#[must_use]
262pub fn fetch_all(timeout: Duration) -> VersionsPatch {
263    let agent: Agent = Agent::config_builder()
264        .timeout_global(Some(timeout))
265        .build()
266        .into();
267    let agent = &agent;
268    thread::scope(|scope| {
269        let handles: Vec<_> = SOURCES
270            .into_iter()
271            .map(|source| scope.spawn(move || source(agent)))
272            .collect();
273        let mut patch = VersionsPatch::default();
274        // The sources set disjoint fields, so the merge order is irrelevant;
275        // a panicking source is joined as an error and skipped.
276        for handle in handles {
277            if let Ok(Some(answer)) = handle.join() {
278                patch.merge(answer);
279            }
280        }
281        patch
282    })
283}
284
285fn get(agent: &Agent, url: &str, user_agent: &str, accept: &str) -> Option<String> {
286    agent
287        .get(url)
288        .header("User-Agent", user_agent)
289        .header("Accept", accept)
290        .call()
291        .ok()?
292        .body_mut()
293        .read_to_string()
294        .ok()
295}
296
297fn get_api(agent: &Agent, url: &str) -> Option<String> {
298    get(agent, url, API_USER_AGENT, API_ACCEPT)
299}
300
301fn get_tg(agent: &Agent, url: &str) -> Option<String> {
302    get(agent, url, TG_USER_AGENT, TG_ACCEPT)
303}
304
305fn fetch_tdesktop(agent: &Agent) -> Option<VersionsPatch> {
306    parse::parse_tdesktop(&get_api(agent, TDESKTOP_URL)?)
307}
308
309fn fetch_android(agent: &Agent) -> Option<VersionsPatch> {
310    get_tg(agent, TG_ANDROID_URL)
311        .as_deref()
312        .and_then(parse::parse_android_tgstable)
313        .or_else(|| parse::parse_android_play(&get_api(agent, PLAY_ANDROID_URL)?))
314}
315
316fn fetch_telegram_x(agent: &Agent) -> Option<VersionsPatch> {
317    parse::parse_telegram_x(&get_api(agent, TELEGRAM_X_URL)?)
318}
319
320fn fetch_ios(agent: &Agent) -> Option<VersionsPatch> {
321    get_tg(agent, TG_IOS_URL)
322        .as_deref()
323        .and_then(parse::parse_ios_tgstable)
324        .or_else(|| parse::parse_ios_itunes(&get_api(agent, ITUNES_IOS_URL)?))
325}
326
327fn fetch_macos(agent: &Agent) -> Option<VersionsPatch> {
328    get_api(agent, BREW_MACOS_URL)
329        .as_deref()
330        .and_then(parse::parse_macos_brew)
331        .or_else(|| parse::parse_macos_itunes(&get_api(agent, ITUNES_MACOS_URL)?))
332}
333
334fn fetch_web_k(agent: &Agent) -> Option<VersionsPatch> {
335    parse::parse_web_k(&get_api(agent, WEB_K_URL)?)
336}
337
338fn fetch_web_a(agent: &Agent) -> Option<VersionsPatch> {
339    parse::parse_web_a(&get_api(agent, WEB_A_URL)?)
340}
341
342fn fetch_chrome(agent: &Agent) -> Option<VersionsPatch> {
343    parse::parse_chrome(&get_api(agent, CHROME_URL)?)
344}
345
346fn fetch_firefox(agent: &Agent) -> Option<VersionsPatch> {
347    parse::parse_firefox(&get_api(agent, FIREFOX_URL)?)
348}