Skip to main content

telegram_webapp_sdk/core/
context.rs

1// SPDX-FileCopyrightText: 2025 RAprogramm <andrey.rozanov.vl@gmail.com>
2// SPDX-License-Identifier: MIT
3
4use once_cell::unsync::OnceCell;
5use percent_encoding::{percent_decode, percent_decode_str};
6use wasm_bindgen::JsValue;
7
8use super::types::{
9    init_data::TelegramInitData, launch_params::LaunchParams, theme_params::TelegramThemeParams
10};
11
12/// Global context of the Telegram Mini App, initialized once per app session.
13#[derive(Clone)]
14pub struct TelegramContext {
15    pub init_data:     TelegramInitData,
16    pub theme_params:  TelegramThemeParams,
17    pub raw_init_data: String
18}
19
20thread_local! {
21    /// Thread-local global TelegramContext instance.
22    static CONTEXT: OnceCell<TelegramContext> = const { OnceCell::new() };
23}
24
25impl TelegramContext {
26    /// Initializes the global Telegram context.
27    ///
28    /// # Errors
29    /// Returns an error if the context was already initialized.
30    pub fn init(
31        init_data: TelegramInitData,
32        theme_params: TelegramThemeParams,
33        raw_init_data: String
34    ) -> Result<(), &'static str> {
35        CONTEXT.with(|cell| {
36            cell.set(TelegramContext {
37                init_data,
38                theme_params,
39                raw_init_data
40            })
41            .map_err(|_| "TelegramContext already initialized")
42        })
43    }
44
45    /// Access the global context if it has been initialized.
46    ///
47    /// Accepts a closure and returns the result of applying it to the context.
48    pub fn get<F, R>(f: F) -> Option<R>
49    where
50        F: FnOnce(&TelegramContext) -> R
51    {
52        CONTEXT.with(|cell| cell.get().map(f))
53    }
54
55    /// Returns the raw initData string as provided by Telegram.
56    ///
57    /// This is the URL-encoded initData string suitable for server-side
58    /// signature validation. The string is captured during SDK initialization
59    /// and remains unchanged.
60    ///
61    /// # Errors
62    ///
63    /// Returns an error if the SDK has not been initialized via
64    /// [`crate::core::init::init_sdk`].
65    ///
66    /// # Examples
67    ///
68    /// ```no_run
69    /// use telegram_webapp_sdk::core::context::TelegramContext;
70    ///
71    /// match TelegramContext::get_raw_init_data() {
72    ///     Ok(raw) => {
73    ///         // Send to backend for validation
74    ///         println!("Raw initData: {}", raw);
75    ///     }
76    ///     Err(e) => eprintln!("Error: {}", e)
77    /// }
78    /// ```
79    pub fn get_raw_init_data() -> Result<String, &'static str> {
80        Self::get(|ctx| ctx.raw_init_data.clone()).ok_or("TelegramContext not initialized")
81    }
82}
83
84/// Returns launch parameters parsed from the current window location.
85///
86/// The `tg_web_app_platform` entry is read from the `tgWebAppPlatform`
87/// query parameter and falls back to `"web"` when it is absent.
88///
89/// # Errors
90/// Returns a [`JsValue`] if the global window object is unavailable.
91///
92/// # Examples
93/// ```no_run
94/// # use telegram_webapp_sdk::core::context::get_launch_params;
95/// let _ = get_launch_params();
96/// ```
97pub fn get_launch_params() -> Result<LaunchParams, JsValue> {
98    let _ = web_sys::window().ok_or_else(|| JsValue::from_str("no window"))?;
99
100    Ok(LaunchParams {
101        tg_web_app_platform:      get_param("tgWebAppPlatform")
102            .or_else(|| Some(String::from("web"))),
103        tg_web_app_version:       get_param("tgWebAppVersion"),
104        tg_web_app_start_param:   get_param("tgWebAppStartParam"),
105        tg_web_app_show_settings: get_param("tgWebAppShowSettings").map(|s| s == "1"),
106        tg_web_app_bot_inline:    get_param("tgWebAppBotInline").map(|s| s == "1")
107    })
108}
109
110fn get_param(key: &str) -> Option<String> {
111    let search = web_sys::window()?.document()?.location()?.search().ok()?;
112
113    let query = search.strip_prefix('?').unwrap_or(search.as_str());
114    extract_param(query, key)
115}
116
117fn extract_param(query: &str, key: &str) -> Option<String> {
118    query.split('&').find_map(|pair| {
119        if pair.is_empty() {
120            return None;
121        }
122
123        let mut parts = pair.splitn(2, '=');
124        let current_key = parts.next()?;
125        if current_key != key {
126            return None;
127        }
128
129        let raw_value = parts.next()?;
130        decode_query_value(raw_value)
131    })
132}
133
134fn decode_query_value(raw_value: &str) -> Option<String> {
135    if raw_value.contains('+') {
136        let mut buffer = Vec::with_capacity(raw_value.len());
137        for byte in raw_value.as_bytes() {
138            if *byte == b'+' {
139                buffer.push(b' ');
140            } else {
141                buffer.push(*byte);
142            }
143        }
144
145        return percent_decode(buffer.as_slice())
146            .decode_utf8()
147            .ok()
148            .map(|cow| cow.into_owned());
149    }
150
151    percent_decode_str(raw_value)
152        .decode_utf8()
153        .ok()
154        .map(|cow| cow.into_owned())
155}
156
157#[cfg(test)]
158mod tests {
159    use super::*;
160
161    #[test]
162    fn extract_param_returns_first_entry() {
163        let query = "tgWebAppPlatform=android&tgWebAppVersion=9.2";
164        let platform = extract_param(query, "tgWebAppPlatform");
165        assert_eq!(platform.as_deref(), Some("android"));
166    }
167
168    #[test]
169    fn decode_query_value_handles_plus_and_percent_sequences() {
170        let query = "tgWebAppStartParam=hello%2Bworld+test";
171        let value = extract_param(query, "tgWebAppStartParam");
172        assert_eq!(value.as_deref(), Some("hello+world test"));
173    }
174
175    #[cfg(target_arch = "wasm32")]
176    mod wasm {
177        use wasm_bindgen::JsValue;
178        use wasm_bindgen_test::wasm_bindgen_test;
179
180        use super::super::get_launch_params;
181
182        #[allow(dead_code)]
183        #[wasm_bindgen_test]
184        fn get_launch_params_returns_error_without_window() {
185            let err = get_launch_params().unwrap_err();
186            assert_eq!(err, JsValue::from_str("no window"));
187        }
188
189        #[wasm_bindgen_test]
190        fn get_launch_params_reads_first_query_parameter() -> Result<(), JsValue> {
191            let window = web_sys::window().ok_or_else(|| JsValue::from_str("no window"))?;
192            let location = window.location();
193            let original_search = location.search().unwrap_or_default();
194
195            location.set_search(
196                "?tgWebAppPlatform=android&tgWebAppVersion=9.2&tgWebAppStartParam=hello%2Bworld+test&tgWebAppShowSettings=1&tgWebAppBotInline=0"
197            )?;
198
199            let params = get_launch_params()?;
200            assert_eq!(params.tg_web_app_platform.as_deref(), Some("android"));
201            assert_eq!(params.tg_web_app_version.as_deref(), Some("9.2"));
202            assert_eq!(
203                params.tg_web_app_start_param.as_deref(),
204                Some("hello+world test")
205            );
206            assert_eq!(params.tg_web_app_show_settings, Some(true));
207            assert_eq!(params.tg_web_app_bot_inline, Some(false));
208
209            location.set_search(&original_search)?;
210            Ok(())
211        }
212    }
213}