//! Platform-specific persistent storage backends. //! //! Native builds persist bytes under the app data directory, while browser //! builds route the same engine API through `localStorage`. use std::io; use std::sync::Arc; use bevy::prelude::Resource; #[cfg(not(target_arch = "wasm32"))] use std::{ fs, path::{Path, PathBuf}, }; #[cfg(target_arch = "wasm32")] use base64::{Engine as _, engine::general_purpose::STANDARD}; #[cfg(target_arch = "wasm32")] use wasm_bindgen::JsValue; /// Abstracts platform-specific key-value / file storage. /// /// Native: backed by the filesystem (via `solitaire_data`). /// WASM: backed by `localStorage`. pub trait StorageBackend: Send + Sync + 'static { /// Read bytes for the given key. Returns `None` if the key does not exist. fn read(&self, key: &str) -> io::Result>>; /// Write bytes for the given key atomically. fn write(&self, key: &str, data: &[u8]) -> io::Result<()>; /// Delete a key. No-op if the key does not exist. fn delete(&self, key: &str) -> io::Result<()>; /// List all known keys (for migration / debug purposes). fn keys(&self) -> io::Result>; } /// Bevy resource that exposes the active platform storage backend. #[derive(Resource, Clone)] pub struct StorageBackendResource(pub Arc); /// Construct the default storage backend for the current platform. pub fn default_storage_backend() -> io::Result> { #[cfg(target_arch = "wasm32")] { let storage = WasmStorage; storage.local_storage()?; Ok(Arc::new(storage)) } #[cfg(not(target_arch = "wasm32"))] { Ok(Arc::new(NativeStorage::platform_default()?)) } } /// Filesystem-backed [`StorageBackend`] for native targets. #[cfg(not(target_arch = "wasm32"))] #[derive(Debug, Clone)] pub struct NativeStorage { base_dir: PathBuf, } #[cfg(not(target_arch = "wasm32"))] impl NativeStorage { /// Create a storage backend rooted at `base_dir`. pub fn new(base_dir: impl Into) -> Self { Self { base_dir: base_dir.into(), } } /// Create a storage backend rooted at the app's platform data directory. pub fn platform_default() -> io::Result { let base_dir = solitaire_data::game_state_file_path() .and_then(|path| path.parent().map(|parent| parent.to_path_buf())) .ok_or_else(|| { io::Error::new(io::ErrorKind::NotFound, "platform data dir unavailable") })?; Ok(Self::new(base_dir)) } fn key_path(&self, key: &str) -> PathBuf { let safe = sanitize_native_key(key); self.base_dir.join(safe) } } #[cfg(not(target_arch = "wasm32"))] impl StorageBackend for NativeStorage { fn read(&self, key: &str) -> io::Result>> { let path = self.key_path(key); match fs::read(&path) { Ok(data) => Ok(Some(data)), Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(None), Err(err) => Err(err), } } fn write(&self, key: &str, data: &[u8]) -> io::Result<()> { let path = self.key_path(key); if let Some(parent) = path.parent() { fs::create_dir_all(parent)?; } let tmp_path = tmp_path_for(&path); fs::write(&tmp_path, data)?; fs::rename(&tmp_path, path)?; Ok(()) } fn delete(&self, key: &str) -> io::Result<()> { let path = self.key_path(key); match fs::remove_file(&path) { Ok(()) => Ok(()), Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(()), Err(err) => Err(err), } } fn keys(&self) -> io::Result> { let mut keys = Vec::new(); let entries = match fs::read_dir(&self.base_dir) { Ok(entries) => entries, Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(keys), Err(err) => return Err(err), }; for entry in entries { let entry = entry?; if !entry.file_type()?.is_file() { continue; } if let Some(name) = entry.file_name().to_str() { keys.push(name.to_string()); } } keys.sort(); Ok(keys) } } #[cfg(not(target_arch = "wasm32"))] fn sanitize_native_key(key: &str) -> String { let safe: String = key .chars() .map(|ch| match ch { '/' | '\\' | ':' => '_', _ => ch, }) .collect(); if safe.is_empty() || safe == "." || safe == ".." { String::from("_") } else { safe } } #[cfg(not(target_arch = "wasm32"))] fn tmp_path_for(path: &Path) -> PathBuf { match path.extension().and_then(|ext| ext.to_str()) { Some(ext) => path.with_extension(format!("{ext}.tmp")), None => path.with_extension("tmp"), } } /// `localStorage`-backed [`StorageBackend`] for browser builds. #[cfg(target_arch = "wasm32")] #[derive(Debug, Default, Clone, Copy)] pub struct WasmStorage; #[cfg(target_arch = "wasm32")] impl WasmStorage { fn local_storage(&self) -> io::Result { let window = web_sys::window().ok_or_else(|| io::Error::other("window unavailable"))?; let storage = window .local_storage() .map_err(js_error)? .ok_or_else(|| io::Error::other("localStorage unavailable"))?; Ok(storage) } } #[cfg(target_arch = "wasm32")] impl StorageBackend for WasmStorage { fn read(&self, key: &str) -> io::Result>> { match self.local_storage()?.get_item(key).map_err(js_error)? { Some(encoded) => STANDARD .decode(encoded) .map(Some) .map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err)), None => Ok(None), } } fn write(&self, key: &str, data: &[u8]) -> io::Result<()> { let encoded = STANDARD.encode(data); let storage = self.local_storage()?; storage.set_item(key, &encoded).map_err(js_error) } fn delete(&self, key: &str) -> io::Result<()> { let storage = self.local_storage()?; storage.remove_item(key).map_err(js_error) } fn keys(&self) -> io::Result> { let storage = self.local_storage()?; let len = storage.length().map_err(js_error)?; let mut keys = Vec::with_capacity(len as usize); for idx in 0..len { let key = storage.key(idx).map_err(js_error)?.ok_or_else(|| { io::Error::new( io::ErrorKind::NotFound, format!("localStorage key missing at index {idx}"), ) })?; keys.push(key); } keys.sort(); Ok(keys) } } #[cfg(target_arch = "wasm32")] fn js_error(err: JsValue) -> io::Error { let message = err .as_string() .map_or_else(|| format!("{err:?}"), |value| value); io::Error::other(message) } #[cfg(all(test, not(target_arch = "wasm32")))] mod tests { use tempfile::tempdir; use super::{NativeStorage, StorageBackend}; #[test] fn native_storage_round_trips_binary_bytes() { let dir = tempdir().expect("tempdir should be available"); let storage = NativeStorage::new(dir.path()); let key = "state/save:1.json"; let data = [0_u8, 1, 2, 127, 255]; storage.write(key, &data).expect("write should succeed"); let loaded = storage .read(key) .expect("read should succeed") .expect("key should exist"); assert_eq!(loaded, data); assert_eq!( storage.keys().expect("keys should succeed"), vec!["state_save_1.json"] ); } #[test] fn native_storage_delete_and_missing_keys_are_noops() { let dir = tempdir().expect("tempdir should be available"); let storage = NativeStorage::new(dir.path()); assert_eq!( storage.keys().expect("keys should succeed"), Vec::::new() ); assert_eq!(storage.read("missing").expect("read should succeed"), None); storage.delete("missing").expect("delete should succeed"); storage .write("session.bin", &[1, 2, 3]) .expect("write should succeed"); storage .delete("session.bin") .expect("delete should succeed"); assert_eq!( storage.read("session.bin").expect("read should succeed"), None ); } }