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}