firebase_rs_sdk/messaging/
subscription.rs

1//! Push subscription helpers for Firebase Messaging.
2//!
3//! Mirrors the logic in `packages/messaging/src/internals/token-manager.ts` regarding
4//! interaction with the browser `PushManager`.
5
6#[cfg(any(
7    not(all(feature = "wasm-web", target_arch = "wasm32")),
8    all(
9        feature = "wasm-web",
10        target_arch = "wasm32",
11        not(feature = "experimental-indexed-db")
12    )
13))]
14use crate::messaging::error::{unsupported_browser, MessagingResult};
15
16#[cfg(all(
17    feature = "wasm-web",
18    target_arch = "wasm32",
19    feature = "experimental-indexed-db"
20))]
21mod wasm {
22    use base64::engine::general_purpose::URL_SAFE_NO_PAD;
23    use base64::Engine;
24    use wasm_bindgen::JsCast;
25    use wasm_bindgen::JsValue;
26    use wasm_bindgen_futures::JsFuture;
27
28    use crate::messaging::error::{
29        internal_error, invalid_argument, token_subscribe_failed, token_unsubscribe_failed,
30        unsupported_browser, MessagingResult,
31    };
32    use crate::messaging::sw_manager::ServiceWorkerRegistrationHandle;
33
34    /// Data extracted from an active `PushSubscription`.
35    #[derive(Clone, Debug, PartialEq, Eq)]
36    pub struct PushSubscriptionDetails {
37        pub endpoint: String,
38        pub auth: String,
39        pub p256dh: String,
40    }
41
42    /// Wrapper around `web_sys::PushSubscription` that exposes helper methods.
43    #[derive(Clone)]
44    pub struct PushSubscriptionHandle {
45        inner: web_sys::PushSubscription,
46    }
47
48    impl PushSubscriptionHandle {
49        fn new(inner: web_sys::PushSubscription) -> Self {
50            Self { inner }
51        }
52
53        pub fn as_web_sys(&self) -> &web_sys::PushSubscription {
54            &self.inner
55        }
56
57        pub fn details(&self) -> MessagingResult<PushSubscriptionDetails> {
58            let endpoint = self.inner.endpoint();
59            if endpoint.trim().is_empty() {
60                return Err(token_subscribe_failed("Push subscription missing endpoint"));
61            }
62            let auth = extract_key(&self.inner, "auth")?;
63            let p256dh = extract_key(&self.inner, "p256dh")?;
64
65            Ok(PushSubscriptionDetails {
66                endpoint,
67                auth,
68                p256dh,
69            })
70        }
71
72        pub async fn unsubscribe(self) -> MessagingResult<bool> {
73            let promise = self
74                .inner
75                .unsubscribe()
76                .map_err(|err| token_unsubscribe_failed(format_js_error("unsubscribe", err)))?;
77
78            let result = JsFuture::from(promise)
79                .await
80                .map_err(|err| token_unsubscribe_failed(format_js_error("unsubscribe", err)))?;
81
82            Ok(result.as_bool().unwrap_or(true))
83        }
84    }
85
86    #[derive(Default)]
87    pub struct PushSubscriptionManager {
88        cached: Option<PushSubscriptionHandle>,
89    }
90
91    impl PushSubscriptionManager {
92        pub fn new() -> Self {
93            Self::default()
94        }
95
96        pub fn cached_subscription(&self) -> Option<PushSubscriptionHandle> {
97            self.cached.clone()
98        }
99
100        pub async fn subscribe(
101            &mut self,
102            registration: &ServiceWorkerRegistrationHandle,
103            vapid_key: &str,
104        ) -> MessagingResult<PushSubscriptionHandle> {
105            if let Some(handle) = &self.cached {
106                return Ok(handle.clone());
107            }
108
109            let sw_registration = registration.as_web_sys();
110            let push_manager = sw_registration
111                .push_manager()
112                .map_err(|err| unsupported_browser(format_js_error("pushManager", err)))?;
113
114            let existing = JsFuture::from(
115                push_manager
116                    .get_subscription()
117                    .map_err(|err| internal_error(format_js_error("getSubscription", err)))?,
118            )
119            .await
120            .map_err(|err| internal_error(format_js_error("getSubscription", err)))?;
121
122            let subscription = if existing.is_undefined() || existing.is_null() {
123                let options = web_sys::PushSubscriptionOptionsInit::new();
124                options.set_user_visible_only(true);
125                let application_server_key = vapid_key_to_uint8_array(vapid_key)?;
126                let key_value: JsValue = application_server_key.into();
127                options.set_application_server_key(&key_value);
128
129                let promise = push_manager
130                    .subscribe_with_options(&options)
131                    .map_err(|err| token_subscribe_failed(format_js_error("subscribe", err)))?;
132
133                let value = JsFuture::from(promise)
134                    .await
135                    .map_err(|err| token_subscribe_failed(format_js_error("subscribe", err)))?;
136
137                value.dyn_into().map_err(|_| {
138                    token_subscribe_failed("PushManager.subscribe returned unexpected value")
139                })?
140            } else {
141                existing.dyn_into().map_err(|_| {
142                    token_subscribe_failed("getSubscription returned unexpected value")
143                })?
144            };
145
146            let handle = PushSubscriptionHandle::new(subscription);
147            self.cached = Some(handle.clone());
148            Ok(handle)
149        }
150
151        pub fn clear_cache(&mut self) {
152            self.cached = None;
153        }
154    }
155
156    fn vapid_key_to_uint8_array(vapid_key: &str) -> MessagingResult<js_sys::Uint8Array> {
157        let trimmed = vapid_key.trim();
158        if trimmed.is_empty() {
159            return Err(invalid_argument("VAPID key must not be empty"));
160        }
161        let bytes = URL_SAFE_NO_PAD
162            .decode(trimmed)
163            .map_err(|err| invalid_argument(format!("Invalid VAPID key: {err}")))?;
164        Ok(js_sys::Uint8Array::from(bytes.as_slice()))
165    }
166
167    fn extract_key(
168        subscription: &web_sys::PushSubscription,
169        name: &str,
170    ) -> MessagingResult<String> {
171        let key_name = match name {
172            "p256dh" => web_sys::PushEncryptionKeyName::P256dh,
173            "auth" => web_sys::PushEncryptionKeyName::Auth,
174            other => {
175                return Err(token_subscribe_failed(format!(
176                    "Unsupported push encryption key name: {other}"
177                )))
178            }
179        };
180        let buffer = subscription.get_key(key_name).map_err(|err| {
181            token_subscribe_failed(format_js_error("PushSubscription.getKey", err))
182        })?;
183        if let Some(buffer) = buffer {
184            let view = js_sys::Uint8Array::new(&buffer);
185            Ok(base64::engine::general_purpose::STANDARD.encode(view.to_vec()))
186        } else {
187            Err(token_subscribe_failed(format!(
188                "Push subscription missing {name} key"
189            )))
190        }
191    }
192
193    fn format_js_error(operation: &str, err: JsValue) -> String {
194        let detail = err.as_string().unwrap_or_else(|| format!("{:?}", err));
195        format!("{operation} failed: {detail}")
196    }
197
198    pub use PushSubscriptionDetails as Details;
199    pub use PushSubscriptionHandle as Handle;
200    pub use PushSubscriptionManager as Manager;
201}
202
203#[cfg(all(
204    feature = "wasm-web",
205    target_arch = "wasm32",
206    feature = "experimental-indexed-db"
207))]
208pub use wasm::{
209    Details as PushSubscriptionDetails, Handle as PushSubscriptionHandle,
210    Manager as PushSubscriptionManager,
211};
212
213#[cfg(any(
214    not(all(feature = "wasm-web", target_arch = "wasm32")),
215    all(
216        feature = "wasm-web",
217        target_arch = "wasm32",
218        not(feature = "experimental-indexed-db")
219    )
220))]
221#[derive(Default)]
222pub struct PushSubscriptionManager;
223
224#[cfg(any(
225    not(all(feature = "wasm-web", target_arch = "wasm32")),
226    all(
227        feature = "wasm-web",
228        target_arch = "wasm32",
229        not(feature = "experimental-indexed-db")
230    )
231))]
232impl PushSubscriptionManager {
233    pub fn new() -> Self {
234        Self
235    }
236
237    pub fn cached_subscription(&self) -> Option<PushSubscriptionHandle> {
238        None
239    }
240
241    pub async fn subscribe(
242        &mut self,
243        _registration: &crate::messaging::ServiceWorkerRegistrationHandle,
244        _vapid_key: &str,
245    ) -> MessagingResult<PushSubscriptionHandle> {
246        Err(unsupported_browser(
247            "Push subscriptions are only available when the `wasm-web` feature is enabled.",
248        ))
249    }
250
251    pub fn clear_cache(&mut self) {}
252}
253
254#[cfg(any(
255    not(all(feature = "wasm-web", target_arch = "wasm32")),
256    all(
257        feature = "wasm-web",
258        target_arch = "wasm32",
259        not(feature = "experimental-indexed-db")
260    )
261))]
262#[derive(Clone, Debug)]
263pub struct PushSubscriptionHandle;
264
265#[cfg(any(
266    not(all(feature = "wasm-web", target_arch = "wasm32")),
267    all(
268        feature = "wasm-web",
269        target_arch = "wasm32",
270        not(feature = "experimental-indexed-db")
271    )
272))]
273#[allow(dead_code)]
274#[derive(Clone, Debug, PartialEq, Eq)]
275pub struct PushSubscriptionDetails;
276
277#[cfg(all(
278    test,
279    any(
280        not(all(feature = "wasm-web", target_arch = "wasm32")),
281        all(
282            feature = "wasm-web",
283            target_arch = "wasm32",
284            not(feature = "experimental-indexed-db")
285        )
286    )
287))]
288mod tests {
289    use super::*;
290
291    #[tokio::test(flavor = "current_thread")]
292    async fn native_subscribe_reports_unsupported() {
293        let mut manager = PushSubscriptionManager::new();
294        manager.clear_cache();
295        assert!(manager.cached_subscription().is_none());
296        let registration = crate::messaging::ServiceWorkerRegistrationHandle;
297        let err = manager.subscribe(&registration, "test").await.unwrap_err();
298        assert_eq!(err.code_str(), "messaging/unsupported-browser");
299    }
300}