a3b9293cd9
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
287 lines
8.5 KiB
Rust
287 lines
8.5 KiB
Rust
//! 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<Option<Vec<u8>>>;
|
|
|
|
/// 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<Vec<String>>;
|
|
}
|
|
|
|
/// Bevy resource that exposes the active platform storage backend.
|
|
#[derive(Resource, Clone)]
|
|
pub struct StorageBackendResource(pub Arc<dyn StorageBackend>);
|
|
|
|
/// Construct the default storage backend for the current platform.
|
|
pub fn default_storage_backend() -> io::Result<Arc<dyn StorageBackend>> {
|
|
#[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<PathBuf>) -> 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<Self> {
|
|
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<Option<Vec<u8>>> {
|
|
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<Vec<String>> {
|
|
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<web_sys::Storage> {
|
|
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<Option<Vec<u8>>> {
|
|
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<Vec<String>> {
|
|
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::<String>::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
|
|
);
|
|
}
|
|
}
|