From 561395fca6ec2af815f2977290e0a947ae6650fa Mon Sep 17 00:00:00 2001 From: funman300 Date: Wed, 27 May 2026 17:30:35 -0700 Subject: [PATCH] feat(data,engine): implement NativeStorage and WasmStorage backends (closes #48) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Cargo.lock | 4 + solitaire_engine/Cargo.toml | 6 + solitaire_engine/src/core_game_plugin.rs | 10 + solitaire_engine/src/platform/mod.rs | 12 +- solitaire_engine/src/platform/storage.rs | 264 ++++++++++++++++++++++- 5 files changed, 291 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b9d76c0..5f7e516 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7015,9 +7015,11 @@ version = "0.1.0" dependencies = [ "arboard", "async-trait", + "base64", "bevy", "chrono", "dirs", + "getrandom 0.3.4", "image", "jni 0.21.1", "kira", @@ -7035,6 +7037,8 @@ dependencies = [ "tokio", "usvg", "uuid", + "wasm-bindgen", + "web-sys", "zip", ] diff --git a/solitaire_engine/Cargo.toml b/solitaire_engine/Cargo.toml index 82c11c2..374cf4d 100644 --- a/solitaire_engine/Cargo.toml +++ b/solitaire_engine/Cargo.toml @@ -38,6 +38,12 @@ arboard = { workspace = true } [target.'cfg(target_os = "android")'.dependencies] jni = { workspace = true } +[target.'cfg(target_arch = "wasm32")'.dependencies] +base64 = "0.22" +getrandom = { version = "0.3", features = ["wasm_js"] } +wasm-bindgen = "0.2" +web-sys = { version = "0.3", features = ["Storage", "Window"] } + [dev-dependencies] async-trait = { workspace = true } tempfile = { workspace = true } diff --git a/solitaire_engine/src/core_game_plugin.rs b/solitaire_engine/src/core_game_plugin.rs index 3e9ef2d..7b5116f 100644 --- a/solitaire_engine/src/core_game_plugin.rs +++ b/solitaire_engine/src/core_game_plugin.rs @@ -8,6 +8,7 @@ use std::sync::Mutex; use bevy::prelude::*; +use crate::platform::{StorageBackendResource, default_storage_backend}; use crate::{ AchievementPlugin, AnalyticsPlugin, AnimationPlugin, AssetSourcesPlugin, AudioPlugin, AutoCompletePlugin, AvatarPlugin, CardAnimationPlugin, CardPlugin, ChallengePlugin, @@ -44,6 +45,15 @@ impl Plugin for CoreGamePlugin { .take() .expect("CoreGamePlugin::build called twice"); + match default_storage_backend() { + Ok(storage) => { + app.insert_resource(StorageBackendResource(storage)); + } + Err(err) => { + warn!("storage: failed to initialize platform backend: {err}"); + } + } + app.add_plugins(AssetSourcesPlugin) .add_plugins(ThemePlugin) .add_plugins(ThemeRegistryPlugin) diff --git a/solitaire_engine/src/platform/mod.rs b/solitaire_engine/src/platform/mod.rs index 1e7ebaa..b9cafa0 100644 --- a/solitaire_engine/src/platform/mod.rs +++ b/solitaire_engine/src/platform/mod.rs @@ -1,11 +1,15 @@ //! Platform abstraction layer. //! -//! Traits defined here are implemented by: -//! - `solitaire_data` for native targets (filesystem, `std::time`) -//! - future WASM-specific impls for browser targets (`localStorage`, `js_sys::Date`) +//! Traits defined here are implemented per target: +//! - native builds use filesystem-backed storage +//! - browser builds use `localStorage` pub mod storage; pub mod time; -pub use storage::StorageBackend; +#[cfg(not(target_arch = "wasm32"))] +pub use storage::NativeStorage; +#[cfg(target_arch = "wasm32")] +pub use storage::WasmStorage; +pub use storage::{StorageBackend, StorageBackendResource, default_storage_backend}; pub use time::PlatformTime; diff --git a/solitaire_engine/src/platform/storage.rs b/solitaire_engine/src/platform/storage.rs index d5edc2e..d9d2097 100644 --- a/solitaire_engine/src/platform/storage.rs +++ b/solitaire_engine/src/platform/storage.rs @@ -1,9 +1,23 @@ 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` / `sessionStorage`. +/// 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>>; @@ -17,3 +31,251 @@ pub trait StorageBackend: Send + Sync + 'static { /// 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 + ); + } +}