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