use std::fmt::Display;
use std::path::PathBuf;
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
pub type StorageResult<T> = Result<T, StorageError>;
#[derive(Debug, Clone)]
pub enum StorageError {
NotFound,
ReadError(String),
WriteError(String),
DeleteError(String),
NotAvailable,
SerializationError(String),
#[cfg(target_arch = "wasm32")]
IndexedDbError(String),
}
impl Display for StorageError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
StorageError::NotFound => write!(f, "Key not found"),
StorageError::ReadError(e) => write!(f, "Read error: {}", e),
StorageError::WriteError(e) => write!(f, "Write error: {}", e),
StorageError::DeleteError(e) => write!(f, "Delete error: {}", e),
StorageError::NotAvailable => write!(f, "Storage not available"),
StorageError::SerializationError(e) => write!(f, "Serialization error: {}", e),
#[cfg(target_arch = "wasm32")]
StorageError::IndexedDbError(e) => write!(f, "IndexedDB error: {}", e),
}
}
}
impl std::error::Error for StorageError {}
#[derive(Debug, Clone)]
pub struct StorageMetadata {
pub key: StorageKey,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Hash)]
pub enum StorageCategory {
Config,
Data,
Cache,
Root,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Hash)]
pub struct StorageKey {
pub category: StorageCategory,
pub sub_path: String,
}
impl Display for StorageKey {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}/{}", self.category.prefix(), self.sub_path)
}
}
impl From<&String> for StorageKey {
fn from(value: &String) -> Self {
let (prefix, mut sub) = value.split_once("/").unzip();
let valid_prefixes = [
StorageCategory::Config,
StorageCategory::Data,
StorageCategory::Cache,
];
let category = if let Some(prefix) = prefix {
valid_prefixes
.iter()
.find(|p| p.prefix() == prefix)
.unwrap_or(&StorageCategory::Root)
} else {
sub = Some(value.as_str());
&StorageCategory::Root
};
StorageKey {
category: *category,
sub_path: sub.unwrap_or("").to_string(),
}
}
}
impl StorageCategory {
pub fn prefix(&self) -> &'static str {
match self {
StorageCategory::Config => "config",
StorageCategory::Data => "data",
StorageCategory::Cache => "cache",
StorageCategory::Root => "/",
}
}
}
#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
pub trait Storage: Send + Sync {
async fn get(&self, key: &StorageKey) -> StorageResult<Vec<u8>>;
async fn set(&self, key: &StorageKey, data: Vec<u8>) -> StorageResult<()>;
async fn delete(&self, key: &StorageKey) -> StorageResult<()>;
async fn exists(&self, key: &StorageKey) -> StorageResult<bool>;
async fn list(&self, prefix: &StorageKey) -> StorageResult<Vec<StorageMetadata>>;
fn get_display_path(&self, key: &StorageKey) -> String;
fn key_to_path_opt(&self, key: Option<&StorageKey>) -> Option<PathBuf>;
fn key_to_path(&self, key: &StorageKey) -> Option<PathBuf>;
}
#[cfg(not(target_arch = "wasm32"))]
mod native {
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
use std::sync::OnceLock;
use async_trait::async_trait;
use directories::ProjectDirs;
use crate::frontend::storage::{
Storage, StorageCategory, StorageError, StorageKey, StorageMetadata, StorageResult,
};
const APP_QUALIFIER: &str = "com";
const APP_ORGANIZATION: &str = "Lightsong";
const APP_NAME: &str = "MonsoonEmulator";
static PROJECT_DIRS: OnceLock<Option<ProjectDirs>> = OnceLock::new();
fn get_project_dirs() -> Option<&'static ProjectDirs> {
PROJECT_DIRS
.get_or_init(|| ProjectDirs::from(APP_QUALIFIER, APP_ORGANIZATION, APP_NAME))
.as_ref()
}
pub struct NativeStorage;
impl Default for NativeStorage {
fn default() -> Self { Self::new() }
}
impl NativeStorage {
pub fn new() -> Self { NativeStorage }
fn get_base_dir(&self, category: &StorageCategory) -> Option<PathBuf> {
let dirs = get_project_dirs()?;
let base = match category {
StorageCategory::Config => dirs.config_dir(),
StorageCategory::Data => dirs.data_dir(),
StorageCategory::Cache => dirs.cache_dir(),
StorageCategory::Root => Path::new("/"),
};
Some(base.to_path_buf())
}
}
#[async_trait]
impl Storage for NativeStorage {
async fn get(&self, key: &StorageKey) -> StorageResult<Vec<u8>> {
let path = self.key_to_path(key).ok_or(StorageError::NotAvailable)?;
if !path.exists() {
return Err(StorageError::NotFound);
}
let mut file =
std::fs::File::open(&path).map_err(|e| StorageError::ReadError(e.to_string()))?;
let mut data = Vec::new();
file.read_to_end(&mut data)
.map_err(|e| StorageError::ReadError(e.to_string()))?;
Ok(data)
}
async fn set(&self, key: &StorageKey, data: Vec<u8>) -> StorageResult<()> {
let path = self.key_to_path(key).ok_or(StorageError::NotAvailable)?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.map_err(|e| StorageError::WriteError(e.to_string()))?;
}
let mut file = std::fs::File::create(&path)
.map_err(|e| StorageError::WriteError(e.to_string()))?;
file.write_all(&data)
.map_err(|e| StorageError::WriteError(e.to_string()))?;
Ok(())
}
async fn delete(&self, key: &StorageKey) -> StorageResult<()> {
let path = self.key_to_path(key).ok_or(StorageError::NotAvailable)?;
if path.exists() {
std::fs::remove_file(&path)
.map_err(|e| StorageError::DeleteError(e.to_string()))?;
}
Ok(())
}
async fn exists(&self, key: &StorageKey) -> StorageResult<bool> {
let path = self.key_to_path(key).ok_or(StorageError::NotAvailable)?;
Ok(path.exists())
}
async fn list(&self, prefix: &StorageKey) -> StorageResult<Vec<StorageMetadata>> {
let base_path = self.key_to_path(prefix).ok_or(StorageError::NotAvailable)?;
if !base_path.exists() {
return Ok(Vec::new());
}
let mut results = Vec::new();
if base_path.is_dir() {
Self::collect_files(&base_path, prefix, &mut results)?;
} else if base_path.is_file() {
results.push(StorageMetadata {
key: prefix.clone(),
});
}
Ok(results)
}
fn get_display_path(&self, key: &StorageKey) -> String {
self.key_to_path(key)
.map(|p| p.display().to_string())
.unwrap_or_else(|| key.sub_path.to_string())
}
fn key_to_path_opt(&self, key: Option<&StorageKey>) -> Option<PathBuf> {
if let Some(key) = key {
self.key_to_path(key)
} else {
None
}
}
fn key_to_path(&self, key: &StorageKey) -> Option<PathBuf> {
let base = self.get_base_dir(&key.category)?;
Some(base.join(key.sub_path.clone()))
}
}
impl NativeStorage {
fn collect_files(
dir: &PathBuf,
prefix: &StorageKey,
results: &mut Vec<StorageMetadata>,
) -> StorageResult<()> {
let entries =
std::fs::read_dir(dir).map_err(|e| StorageError::ReadError(e.to_string()))?;
for entry in entries.flatten() {
let path = entry.path();
let name = entry.file_name().to_string_lossy().to_string();
let sub = if prefix.sub_path.ends_with('/') {
format!("{}{}", prefix.sub_path, name)
} else {
format!("{}/{}", prefix.sub_path, name)
};
let key = StorageKey {
category: prefix.category,
sub_path: sub,
};
if path.is_file() {
results.push(StorageMetadata {
key,
});
} else if path.is_dir() {
Self::collect_files(&path, &key, results)?;
}
}
Ok(())
}
}
}
#[cfg(target_arch = "wasm32")]
mod wasm {
use js_sys::Uint8Array;
use rexie::{KeyRange, Rexie, TransactionMode};
use wasm_bindgen::JsValue;
use super::*;
const DB_NAME: &str = "monsoon_emulator";
const DB_VERSION: u32 = 1;
const STORE_NAME: &str = "storage";
pub struct WasmStorage;
impl WasmStorage {
pub fn new() -> Self { WasmStorage }
fn key_string(key: &StorageKey) -> String {
format!("{}/{}", key.category.prefix(), key.sub_path)
}
}
impl Default for WasmStorage {
fn default() -> Self { Self::new() }
}
async fn open_db() -> Result<Rexie, StorageError> {
Rexie::builder(DB_NAME)
.version(DB_VERSION)
.add_object_store(rexie::ObjectStore::new(STORE_NAME))
.build()
.await
.map_err(|e| StorageError::IndexedDbError(e.to_string()))
}
#[async_trait(?Send)]
impl Storage for WasmStorage {
async fn get(&self, key: &StorageKey) -> StorageResult<Vec<u8>> {
let db = open_db().await?;
let tx = db
.transaction(&[STORE_NAME], TransactionMode::ReadOnly)
.map_err(|e| StorageError::ReadError(e.to_string()))?;
let store = tx
.store(STORE_NAME)
.map_err(|e| StorageError::ReadError(e.to_string()))?;
let key_js = JsValue::from_str(&Self::key_string(key));
match store
.get(key_js)
.await
.map_err(|e| StorageError::ReadError(e.to_string()))?
{
Some(val) => {
let array = Uint8Array::new(&val);
Ok(array.to_vec())
}
None => Err(StorageError::NotFound),
}
}
async fn set(&self, key: &StorageKey, data: Vec<u8>) -> StorageResult<()> {
let db = open_db().await?;
let tx = db
.transaction(&[STORE_NAME], TransactionMode::ReadWrite)
.map_err(|e| StorageError::WriteError(e.to_string()))?;
let store = tx
.store(STORE_NAME)
.map_err(|e| StorageError::WriteError(e.to_string()))?;
let key_js = JsValue::from_str(&Self::key_string(key));
let value_js: JsValue = Uint8Array::from(data.as_slice()).into();
store
.put(&value_js, Some(&key_js))
.await
.map_err(|e| StorageError::WriteError(e.to_string()))?;
tx.done()
.await
.map_err(|e| StorageError::WriteError(e.to_string()))?;
Ok(())
}
async fn delete(&self, key: &StorageKey) -> StorageResult<()> {
let db = open_db().await?;
let tx = db
.transaction(&[STORE_NAME], TransactionMode::ReadWrite)
.map_err(|e| StorageError::DeleteError(e.to_string()))?;
let store = tx
.store(STORE_NAME)
.map_err(|e| StorageError::DeleteError(e.to_string()))?;
let key_js = JsValue::from_str(&Self::key_string(key));
store
.delete(key_js)
.await
.map_err(|e| StorageError::DeleteError(e.to_string()))?;
tx.done()
.await
.map_err(|e| StorageError::DeleteError(e.to_string()))?;
Ok(())
}
async fn exists(&self, key: &StorageKey) -> StorageResult<bool> {
let db = open_db().await?;
let tx = db
.transaction(&[STORE_NAME], TransactionMode::ReadOnly)
.map_err(|e| StorageError::ReadError(e.to_string()))?;
let store = tx
.store(STORE_NAME)
.map_err(|e| StorageError::ReadError(e.to_string()))?;
let key_js = JsValue::from_str(&Self::key_string(key));
store
.key_exists(key_js)
.await
.map_err(|e| StorageError::ReadError(e.to_string()))
}
async fn list(&self, prefix: &StorageKey) -> StorageResult<Vec<StorageMetadata>> {
let db = open_db().await?;
let tx = db
.transaction(&[STORE_NAME], TransactionMode::ReadOnly)
.map_err(|e| StorageError::ReadError(e.to_string()))?;
let store = tx
.store(STORE_NAME)
.map_err(|e| StorageError::ReadError(e.to_string()))?;
let prefix_str = Self::key_string(prefix);
let lower = JsValue::from_str(&prefix_str);
let upper = JsValue::from_str(&format!("{}\u{ffff}", prefix_str));
let range = KeyRange::bound(&lower, &upper, Some(false), Some(false))
.map_err(|e| StorageError::ReadError(format!("{:?}", e)))?;
let keys = store
.get_all_keys(Some(range), None)
.await
.map_err(|e| StorageError::ReadError(e.to_string()))?;
Ok(keys
.into_iter()
.filter_map(|k| {
k.as_string().map(|s| StorageMetadata {
key: StorageKey::from(&s),
})
})
.collect())
}
fn get_display_path(&self, key: &StorageKey) -> String {
format!("indexeddb://monsoon_emulator/{}", Self::key_string(key))
}
fn key_to_path_opt(&self, _key: Option<&StorageKey>) -> Option<PathBuf> {
None
}
fn key_to_path(&self, _key: &StorageKey) -> Option<PathBuf> {
None
}
}
}
#[cfg(not(target_arch = "wasm32"))]
pub use native::NativeStorage;
#[cfg(target_arch = "wasm32")]
pub use wasm::WasmStorage;
#[cfg(not(target_arch = "wasm32"))]
pub fn get_storage() -> impl Storage { NativeStorage::new() }
#[cfg(target_arch = "wasm32")]
pub fn get_storage() -> impl Storage { WasmStorage::new() }
pub fn quicksave_key(game_name: &str, timestamp: &str) -> StorageKey {
let sub = format!("saves/{}/quicksaves/quicksave_{}.sav", game_name, timestamp);
StorageKey {
category: StorageCategory::Data,
sub_path: sub,
}
}
pub fn autosave_key(game_name: &str, timestamp: &str) -> StorageKey {
let sub = format!("saves/{}/autosaves/autosaves_{}.sav", game_name, timestamp);
StorageKey {
category: StorageCategory::Data,
sub_path: sub,
}
}
pub fn autosaves_prefix(game_name: &str) -> StorageKey {
let sub = format!("saves/{}/autosaves/", game_name);
StorageKey {
category: StorageCategory::Data,
sub_path: sub,
}
}
pub fn quicksaves_prefix(game_name: &str) -> StorageKey {
let sub = format!("saves/{}/quicksaves/", game_name);
StorageKey {
category: StorageCategory::Data,
sub_path: sub,
}
}
pub fn config_key() -> StorageKey {
StorageKey {
category: StorageCategory::Config,
sub_path: "config/config.toml".to_string(),
}
}
pub fn egui_state_key() -> StorageKey {
StorageKey {
category: StorageCategory::Config,
sub_path: "egui_state".to_string(),
}
}
pub fn rom_cache_key(filename: &str) -> StorageKey {
StorageKey {
category: StorageCategory::Data,
sub_path: format!("roms/{}", filename),
}
}
pub fn roms_prefix() -> StorageKey {
StorageKey {
category: StorageCategory::Data,
sub_path: "roms/".to_string(),
}
}
pub fn uploaded_savestate_key(filename: &str) -> StorageKey {
StorageKey {
category: StorageCategory::Data,
sub_path: format!("uploads/savestates/{}", filename),
}
}
#[cfg(not(target_arch = "wasm32"))]
mod sync_wrappers {
use crate::frontend::storage::{
NativeStorage, Storage, StorageError, StorageKey, StorageMetadata, StorageResult,
};
static STORAGE: std::sync::OnceLock<NativeStorage> = std::sync::OnceLock::new();
fn get_storage_instance() -> &'static NativeStorage { STORAGE.get_or_init(NativeStorage::new) }
pub fn get_path_for_key(key: &StorageKey) -> Option<std::path::PathBuf> {
get_storage_instance().key_to_path(key)
}
pub fn read_sync(key: &StorageKey) -> StorageResult<Vec<u8>> {
let storage = get_storage_instance();
let path = storage.key_to_path(key).ok_or(StorageError::NotAvailable)?;
if !path.exists() {
return Err(StorageError::NotFound);
}
std::fs::read(&path).map_err(|e| StorageError::ReadError(e.to_string()))
}
pub fn write_sync(key: &StorageKey, data: &[u8]) -> StorageResult<()> {
let storage = get_storage_instance();
let path = storage.key_to_path(key).ok_or(StorageError::NotAvailable)?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|e| StorageError::WriteError(e.to_string()))?;
}
std::fs::write(&path, data).map_err(|e| StorageError::WriteError(e.to_string()))
}
pub fn delete_sync(key: &StorageKey) -> StorageResult<()> {
let storage = get_storage_instance();
let path = storage.key_to_path(key).ok_or(StorageError::NotAvailable)?;
if path.exists() {
std::fs::remove_file(&path).map_err(|e| StorageError::DeleteError(e.to_string()))?;
}
Ok(())
}
pub fn exists_sync(key: &StorageKey) -> StorageResult<bool> {
let storage = get_storage_instance();
let path = storage.key_to_path(key).ok_or(StorageError::NotAvailable)?;
Ok(path.exists())
}
pub fn list_sync(prefix: &StorageKey) -> StorageResult<Vec<StorageMetadata>> {
let storage = get_storage_instance();
let base_path = storage
.key_to_path(prefix)
.ok_or(StorageError::NotAvailable)?;
if !base_path.exists() {
return Ok(Vec::new());
}
let mut results = Vec::new();
if base_path.is_dir() {
collect_files_sync(&base_path, prefix, &mut results)?;
} else if base_path.is_file() {
results.push(StorageMetadata {
key: prefix.clone(),
});
}
Ok(results)
}
pub fn get_display_path(key: &StorageKey) -> String {
get_storage_instance().get_display_path(key)
}
fn collect_files_sync(
dir: &std::path::Path,
prefix: &StorageKey,
results: &mut Vec<StorageMetadata>,
) -> StorageResult<()> {
let entries = std::fs::read_dir(dir).map_err(|e| StorageError::ReadError(e.to_string()))?;
for entry in entries.flatten() {
let path = entry.path();
let name = entry.file_name().to_string_lossy().to_string();
let sub = if prefix.sub_path.ends_with('/') {
format!("{}{}", prefix.sub_path, name)
} else {
format!("{}/{}", prefix.sub_path, name)
};
let key = StorageKey {
category: prefix.category,
sub_path: sub,
};
if path.is_file() {
results.push(StorageMetadata {
key,
});
} else if path.is_dir() {
collect_files_sync(&path, &key, results)?;
}
}
Ok(())
}
}
#[cfg(not(target_arch = "wasm32"))]
pub use sync_wrappers::*;