firebase_rs_sdk/installations/
persistence.rs

1use std::time::{Duration, SystemTime, UNIX_EPOCH};
2
3#[cfg(not(all(feature = "wasm-web", target_arch = "wasm32")))]
4use percent_encoding::{percent_encode, NON_ALPHANUMERIC};
5use serde::{Deserialize, Serialize};
6
7use crate::installations::error::{internal_error, InstallationsResult};
8use crate::installations::types::InstallationToken;
9
10#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
11pub struct PersistedAuthToken {
12    token: String,
13    expires_at_ms: u64,
14}
15
16impl PersistedAuthToken {
17    pub fn from_runtime(token: &InstallationToken) -> InstallationsResult<Self> {
18        let millis = system_time_to_millis(token.expires_at)?;
19        Ok(Self {
20            token: token.token.clone(),
21            expires_at_ms: millis,
22        })
23    }
24
25    pub fn into_runtime(self) -> InstallationToken {
26        InstallationToken {
27            token: self.token,
28            expires_at: UNIX_EPOCH + Duration::from_millis(self.expires_at_ms),
29        }
30    }
31}
32
33#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
34pub struct PersistedInstallation {
35    pub fid: String,
36    pub refresh_token: String,
37    pub auth_token: PersistedAuthToken,
38}
39
40#[cfg_attr(
41    all(feature = "wasm-web", target_arch = "wasm32"),
42    async_trait::async_trait(?Send)
43)]
44#[cfg_attr(
45    not(all(feature = "wasm-web", target_arch = "wasm32")),
46    async_trait::async_trait
47)]
48pub trait InstallationsPersistence: Send + Sync {
49    async fn read(&self, app_name: &str) -> InstallationsResult<Option<PersistedInstallation>>;
50    async fn write(&self, app_name: &str, entry: &PersistedInstallation)
51        -> InstallationsResult<()>;
52    async fn clear(&self, app_name: &str) -> InstallationsResult<()>;
53
54    async fn try_acquire_registration_lock(&self, app_name: &str) -> InstallationsResult<bool> {
55        let _ = app_name;
56        Ok(true)
57    }
58
59    async fn release_registration_lock(&self, app_name: &str) -> InstallationsResult<()> {
60        let _ = app_name;
61        Ok(())
62    }
63}
64
65#[cfg(not(all(feature = "wasm-web", target_arch = "wasm32")))]
66use std::fs;
67#[cfg(not(all(feature = "wasm-web", target_arch = "wasm32")))]
68use std::path::PathBuf;
69#[cfg(not(all(feature = "wasm-web", target_arch = "wasm32")))]
70use std::sync::Arc;
71
72#[cfg(not(all(feature = "wasm-web", target_arch = "wasm32")))]
73#[derive(Clone, Debug)]
74pub struct FilePersistence {
75    base_dir: Arc<PathBuf>,
76}
77
78#[cfg(not(all(feature = "wasm-web", target_arch = "wasm32")))]
79impl FilePersistence {
80    pub fn new(base_dir: PathBuf) -> InstallationsResult<Self> {
81        fs::create_dir_all(&base_dir).map_err(|err| {
82            internal_error(format!(
83                "Failed to create installations cache directory '{}': {}",
84                base_dir.display(),
85                err
86            ))
87        })?;
88        Ok(Self {
89            base_dir: Arc::new(base_dir),
90        })
91    }
92
93    pub fn default() -> InstallationsResult<Self> {
94        if let Ok(dir) = std::env::var("FIREBASE_INSTALLATIONS_CACHE_DIR") {
95            return Self::new(PathBuf::from(dir));
96        }
97
98        let dir = std::env::current_dir()
99            .map_err(|err| internal_error(format!("Failed to obtain working directory: {}", err)))?
100            .join(".firebase/installations");
101        Self::new(dir)
102    }
103
104    fn file_for(&self, app_name: &str) -> PathBuf {
105        let encoded = percent_encode(app_name.as_bytes(), NON_ALPHANUMERIC).to_string();
106        self.base_dir.join(format!("{}.json", encoded))
107    }
108}
109
110#[cfg(not(all(feature = "wasm-web", target_arch = "wasm32")))]
111#[cfg_attr(
112    all(feature = "wasm-web", target_arch = "wasm32"),
113    async_trait::async_trait(?Send)
114)]
115#[cfg_attr(
116    not(all(feature = "wasm-web", target_arch = "wasm32")),
117    async_trait::async_trait
118)]
119impl InstallationsPersistence for FilePersistence {
120    async fn read(&self, app_name: &str) -> InstallationsResult<Option<PersistedInstallation>> {
121        let path = self.file_for(app_name);
122        if !path.exists() {
123            return Ok(None);
124        }
125        let bytes = fs::read(&path).map_err(|err| {
126            internal_error(format!(
127                "Failed to read installations cache '{}': {}",
128                path.display(),
129                err
130            ))
131        })?;
132        let entry = serde_json::from_slice(&bytes).map_err(|err| {
133            internal_error(format!(
134                "Failed to parse installations cache '{}': {}",
135                path.display(),
136                err
137            ))
138        })?;
139        Ok(Some(entry))
140    }
141
142    async fn write(
143        &self,
144        app_name: &str,
145        entry: &PersistedInstallation,
146    ) -> InstallationsResult<()> {
147        let path = self.file_for(app_name);
148        let bytes = serde_json::to_vec(entry).map_err(|err| {
149            internal_error(format!(
150                "Failed to serialize installations cache '{}': {}",
151                path.display(),
152                err
153            ))
154        })?;
155        fs::write(&path, bytes).map_err(|err| {
156            internal_error(format!(
157                "Failed to write installations cache '{}': {}",
158                path.display(),
159                err
160            ))
161        })
162    }
163
164    async fn clear(&self, app_name: &str) -> InstallationsResult<()> {
165        let path = self.file_for(app_name);
166        if path.exists() {
167            fs::remove_file(&path).map_err(|err| {
168                internal_error(format!(
169                    "Failed to delete installations cache '{}': {}",
170                    path.display(),
171                    err
172                ))
173            })?;
174        }
175        Ok(())
176    }
177}
178
179#[cfg(all(
180    feature = "wasm-web",
181    target_arch = "wasm32",
182    feature = "experimental-indexed-db"
183))]
184mod wasm_persistence {
185    #[cfg(test)]
186    use super::PersistedAuthToken;
187    use super::{
188        internal_error, InstallationsPersistence, InstallationsResult, PersistedInstallation,
189    };
190    use crate::platform::browser::indexed_db;
191    use serde::{Deserialize, Serialize};
192    use std::cell::RefCell;
193    use std::collections::HashMap;
194    use wasm_bindgen::closure::Closure;
195    use wasm_bindgen::{JsCast, JsValue};
196    use web_sys::{BroadcastChannel, MessageEvent};
197
198    const DATABASE_NAME: &str = "firebase-installations-database";
199    const DATABASE_VERSION: u32 = 1;
200    const STORE_NAME: &str = "firebase-installations-store";
201    const BROADCAST_CHANNEL: &str = "firebase-installations-updates";
202    const PENDING_PREFIX: &str = "pending::";
203    const PENDING_TIMEOUT_MS: u64 = 60_000;
204
205    #[derive(Clone, Debug, Default)]
206    pub struct IndexedDbPersistence;
207
208    impl IndexedDbPersistence {
209        pub fn new() -> Self {
210            Self
211        }
212    }
213
214    #[cfg_attr(all(feature = "wasm-web", target_arch = "wasm32"), async_trait::async_trait(?Send))]
215    impl InstallationsPersistence for IndexedDbPersistence {
216        async fn read(&self, app_name: &str) -> InstallationsResult<Option<PersistedInstallation>> {
217            ensure_broadcast_channel();
218            if let Some(cached) = cache_get(app_name) {
219                return Ok(cached);
220            }
221
222            let db = open_db().await?;
223            let stored = indexed_db::get_string(&db, STORE_NAME, app_name)
224                .await
225                .map_err(map_indexed_db_error)?;
226            let entry = match stored {
227                Some(json) => {
228                    let parsed = serde_json::from_str(&json).map_err(|err| {
229                        internal_error(format!("Failed to parse stored installation: {err}"))
230                    })?;
231                    Some(parsed)
232                }
233                None => None,
234            };
235            cache_set(app_name, entry.clone());
236            Ok(entry)
237        }
238
239        async fn write(
240            &self,
241            app_name: &str,
242            entry: &PersistedInstallation,
243        ) -> InstallationsResult<()> {
244            ensure_broadcast_channel();
245            let json = serde_json::to_string(entry).map_err(|err| {
246                internal_error(format!("Failed to serialize installation: {err}"))
247            })?;
248            let db = open_db().await?;
249            indexed_db::put_string(&db, STORE_NAME, app_name, &json)
250                .await
251                .map_err(map_indexed_db_error)?;
252            cache_set(app_name, Some(entry.clone()));
253            broadcast_update(app_name, BroadcastPayload::Set(entry.clone()));
254            Ok(())
255        }
256
257        async fn clear(&self, app_name: &str) -> InstallationsResult<()> {
258            ensure_broadcast_channel();
259            let db = open_db().await?;
260            let existed = indexed_db::get_string(&db, STORE_NAME, app_name)
261                .await
262                .map_err(map_indexed_db_error)?
263                .is_some();
264            if existed {
265                indexed_db::delete_key(&db, STORE_NAME, app_name)
266                    .await
267                    .map_err(map_indexed_db_error)?;
268            }
269            let pending_key = pending_key(app_name);
270            let _ = indexed_db::delete_key(&db, STORE_NAME, &pending_key).await;
271            cache_set(app_name, None);
272            broadcast_update(app_name, BroadcastPayload::Remove);
273            Ok(())
274        }
275
276        async fn try_acquire_registration_lock(&self, app_name: &str) -> InstallationsResult<bool> {
277            ensure_broadcast_channel();
278            let db = open_db().await?;
279            let key = pending_key(app_name);
280            let now = current_timestamp_ms();
281            if let Some(raw) = indexed_db::get_string(&db, STORE_NAME, &key)
282                .await
283                .map_err(map_indexed_db_error)?
284            {
285                if let Ok(timestamp) = raw.parse::<u64>() {
286                    if now.saturating_sub(timestamp) < PENDING_TIMEOUT_MS {
287                        return Ok(false);
288                    }
289                }
290            }
291
292            indexed_db::put_string(&db, STORE_NAME, &key, &now.to_string())
293                .await
294                .map_err(map_indexed_db_error)?;
295            Ok(true)
296        }
297
298        async fn release_registration_lock(&self, app_name: &str) -> InstallationsResult<()> {
299            let db = open_db().await?;
300            let key = pending_key(app_name);
301            let _ = indexed_db::delete_key(&db, STORE_NAME, &key)
302                .await
303                .map_err(map_indexed_db_error)?;
304            Ok(())
305        }
306    }
307
308    #[derive(Serialize, Deserialize, Clone, Debug)]
309    struct BroadcastMessage {
310        app_name: String,
311        payload: BroadcastPayload,
312    }
313
314    #[derive(Serialize, Deserialize, Clone, Debug)]
315    enum BroadcastPayload {
316        Set(PersistedInstallation),
317        Remove,
318    }
319
320    thread_local! {
321        static CACHE: RefCell<HashMap<String, Option<PersistedInstallation>>> = RefCell::new(HashMap::new());
322        static CHANNEL: RefCell<Option<BroadcastChannel>> = RefCell::new(None);
323        static HANDLER: RefCell<Option<Closure<dyn FnMut(MessageEvent)>>> = RefCell::new(None);
324    }
325
326    async fn open_db() -> InstallationsResult<web_sys::IdbDatabase> {
327        indexed_db::open_database_with_store(DATABASE_NAME, DATABASE_VERSION, STORE_NAME)
328            .await
329            .map_err(map_indexed_db_error)
330    }
331
332    fn pending_key(app_name: &str) -> String {
333        format!("{PENDING_PREFIX}{app_name}")
334    }
335
336    fn cache_get(app_name: &str) -> Option<Option<PersistedInstallation>> {
337        CACHE.with(|cache| cache.borrow().get(app_name).cloned())
338    }
339
340    fn cache_set(app_name: &str, value: Option<PersistedInstallation>) {
341        CACHE.with(|cache| {
342            cache.borrow_mut().insert(app_name.to_string(), value);
343        });
344    }
345
346    fn ensure_broadcast_channel() {
347        CHANNEL.with(|cell| {
348            if cell.borrow().is_some() {
349                return;
350            }
351            match BroadcastChannel::new(BROADCAST_CHANNEL) {
352                Ok(channel) => {
353                    let handler = Closure::wrap(Box::new(|event: MessageEvent| {
354                        if let Some(text) = event.data().as_string() {
355                            if let Ok(message) = serde_json::from_str::<BroadcastMessage>(&text) {
356                                handle_broadcast(message);
357                            }
358                        }
359                    }) as Box<dyn FnMut(_)>);
360                    channel.set_onmessage(Some(handler.as_ref().unchecked_ref()));
361                    HANDLER.with(|slot| {
362                        slot.replace(Some(handler));
363                    });
364                    cell.replace(Some(channel));
365                }
366                Err(err) => {
367                    log_warning(
368                        "Failed to initialise installations BroadcastChannel",
369                        Some(&err),
370                    );
371                }
372            }
373        });
374    }
375
376    fn handle_broadcast(message: BroadcastMessage) {
377        match message.payload {
378            BroadcastPayload::Set(entry) => cache_set(&message.app_name, Some(entry)),
379            BroadcastPayload::Remove => cache_set(&message.app_name, None),
380        }
381    }
382
383    fn broadcast_update(app_name: &str, payload: BroadcastPayload) {
384        CHANNEL.with(|cell| {
385            if cell.borrow().is_none() {
386                ensure_broadcast_channel();
387            }
388        });
389
390        CHANNEL.with(|cell| {
391            if let Some(channel) = cell.borrow().as_ref() {
392                let message = BroadcastMessage {
393                    app_name: app_name.to_string(),
394                    payload,
395                };
396                if let Ok(serialized) = serde_json::to_string(&message) {
397                    if let Err(err) = channel.post_message(&JsValue::from_str(&serialized)) {
398                        log_warning("Failed to broadcast installations update", Some(&err));
399                    }
400                }
401            }
402        });
403    }
404
405    fn log_warning(message: &str, err: Option<&JsValue>) {
406        if let Some(err) = err {
407            web_sys::console::warn_2(&JsValue::from_str(message), err);
408        } else {
409            web_sys::console::warn_1(&JsValue::from_str(message));
410        }
411    }
412
413    #[cfg(all(
414        test,
415        feature = "wasm-web",
416        feature = "experimental-indexed-db",
417        target_arch = "wasm32"
418    ))]
419    mod tests {
420        use super::*;
421        use wasm_bindgen_test::{wasm_bindgen_test, wasm_bindgen_test_configure};
422
423        wasm_bindgen_test_configure!(run_in_browser);
424
425        #[wasm_bindgen_test(async)]
426        async fn indexed_db_roundtrip_persists_installation() {
427            let persistence = IndexedDbPersistence::new();
428            let _ = persistence.clear("wasm-app").await;
429            let entry = sample_entry();
430
431            persistence
432                .write("wasm-app", &entry)
433                .await
434                .expect("write entry");
435            let loaded = persistence.read("wasm-app").await.expect("read entry");
436            assert_eq!(loaded, Some(entry.clone()));
437
438            persistence.clear("wasm-app").await.expect("clear entry");
439            let cleared = persistence
440                .read("wasm-app")
441                .await
442                .expect("read after clear");
443            assert!(cleared.is_none());
444        }
445
446        fn sample_entry() -> PersistedInstallation {
447            PersistedInstallation {
448                fid: "wasm-fid".into(),
449                refresh_token: "wasm-refresh".into(),
450                auth_token: PersistedAuthToken {
451                    token: "wasm-token".into(),
452                    expires_at_ms: 0,
453                },
454            }
455        }
456    }
457
458    fn map_indexed_db_error<E: std::fmt::Display>(
459        err: E,
460    ) -> crate::installations::error::InstallationsError {
461        internal_error(format!("IndexedDB error: {err}"))
462    }
463
464    fn current_timestamp_ms() -> u64 {
465        js_sys::Date::now() as u64
466    }
467}
468
469#[cfg(all(
470    feature = "wasm-web",
471    target_arch = "wasm32",
472    feature = "experimental-indexed-db"
473))]
474pub use wasm_persistence::IndexedDbPersistence;
475
476#[cfg(all(
477    feature = "wasm-web",
478    target_arch = "wasm32",
479    not(feature = "experimental-indexed-db")
480))]
481mod wasm_stub {
482    use super::{InstallationsPersistence, InstallationsResult, PersistedInstallation};
483
484    #[derive(Clone, Debug, Default)]
485    pub struct IndexedDbPersistence;
486
487    impl IndexedDbPersistence {
488        pub fn new() -> Self {
489            Self
490        }
491    }
492
493    #[cfg_attr(all(feature = "wasm-web", target_arch = "wasm32"), async_trait::async_trait(?Send))]
494    impl InstallationsPersistence for IndexedDbPersistence {
495        async fn read(
496            &self,
497            _app_name: &str,
498        ) -> InstallationsResult<Option<PersistedInstallation>> {
499            Ok(None)
500        }
501
502        async fn write(
503            &self,
504            _app_name: &str,
505            _entry: &PersistedInstallation,
506        ) -> InstallationsResult<()> {
507            Ok(())
508        }
509
510        async fn clear(&self, _app_name: &str) -> InstallationsResult<()> {
511            Ok(())
512        }
513    }
514}
515
516#[cfg(all(
517    feature = "wasm-web",
518    target_arch = "wasm32",
519    not(feature = "experimental-indexed-db")
520))]
521pub use wasm_stub::IndexedDbPersistence;
522
523fn system_time_to_millis(time: SystemTime) -> InstallationsResult<u64> {
524    let duration = time
525        .duration_since(UNIX_EPOCH)
526        .map_err(|_| internal_error("Token expiration must be after UNIX epoch"))?;
527    Ok(duration.as_millis() as u64)
528}
529
530#[cfg(all(test, not(all(feature = "wasm-web", target_arch = "wasm32"))))]
531mod tests {
532    use super::*;
533    use crate::installations::types::InstallationToken;
534    use std::time::{Duration, SystemTime};
535
536    #[cfg(not(all(feature = "wasm-web", target_arch = "wasm32")))]
537    fn temp_dir() -> std::path::PathBuf {
538        let mut path = std::env::temp_dir();
539        let unique = format!("installations-persistence-{}", uuid());
540        path.push(unique);
541        path
542    }
543
544    #[cfg(not(all(feature = "wasm-web", target_arch = "wasm32")))]
545    fn uuid() -> String {
546        use std::sync::atomic::{AtomicUsize, Ordering};
547        static COUNTER: AtomicUsize = AtomicUsize::new(0);
548        format!("{}", COUNTER.fetch_add(1, Ordering::SeqCst))
549    }
550
551    #[cfg(not(all(feature = "wasm-web", target_arch = "wasm32")))]
552    #[tokio::test(flavor = "current_thread")]
553    async fn file_persistence_round_trip() {
554        let dir = temp_dir();
555        let persistence = FilePersistence::new(dir.clone()).unwrap();
556        let token = InstallationToken {
557            token: "token".into(),
558            expires_at: SystemTime::now() + Duration::from_secs(60),
559        };
560        let entry = PersistedInstallation {
561            fid: "fid".into(),
562            refresh_token: "refresh".into(),
563            auth_token: PersistedAuthToken::from_runtime(&token).unwrap(),
564        };
565
566        persistence.write("app", &entry).await.unwrap();
567        let loaded = persistence.read("app").await.unwrap().unwrap();
568        assert_eq!(loaded, entry);
569
570        persistence.clear("app").await.unwrap();
571        assert!(persistence.read("app").await.unwrap().is_none());
572        let _ = std::fs::remove_dir_all(dir);
573    }
574}