use std::{
collections::BTreeMap,
rc::Rc,
sync::{Arc, Mutex, OnceLock},
};
#[cfg(not(target_arch = "wasm32"))]
mod file;
#[cfg(not(target_arch = "wasm32"))]
pub use file::FilePreferences;
use crate::registry::ServiceRegistry;
#[derive(thiserror::Error, Debug, Clone, PartialEq, Eq)]
pub enum PreferencesError {
#[error("preferences storage failed: {0}")]
Io(String),
#[error("preferences are not available on this platform")]
Unavailable,
}
pub trait PreferencesStore: Send + Sync {
fn get(&self, key: &str) -> Option<String>;
fn set(&self, key: &str, value: &str) -> Result<(), PreferencesError>;
fn update_many(
&self,
values: &BTreeMap<String, String>,
removed: &[String],
) -> Result<(), PreferencesError> {
for key in removed {
self.remove(key)?;
}
for (key, value) in values {
self.set(key, value)?;
}
Ok(())
}
fn flush(&self) -> Result<(), PreferencesError> {
Ok(())
}
fn remove(&self, key: &str) -> Result<(), PreferencesError>;
fn keys(&self) -> Vec<String>;
fn clear(&self) -> Result<(), PreferencesError>;
}
pub type PreferencesRef = Arc<dyn PreferencesStore>;
static PLATFORM_PREFERENCES: ServiceRegistry<dyn PreferencesStore> = ServiceRegistry::new();
pub fn set_platform_preferences(store: PreferencesRef) {
PLATFORM_PREFERENCES.set(store);
}
pub fn clear_platform_preferences() {
PLATFORM_PREFERENCES.clear();
}
pub fn preferences() -> PreferencesRef {
if let Some(store) = PLATFORM_PREFERENCES.get() {
return store;
}
default_preferences()
}
#[cfg(not(target_arch = "wasm32"))]
fn default_preferences() -> PreferencesRef {
static DEFAULT: OnceLock<PreferencesRef> = OnceLock::new();
DEFAULT
.get_or_init(|| Arc::new(FilePreferences::new()) as PreferencesRef)
.clone()
}
#[cfg(all(target_arch = "wasm32", feature = "preferences-web"))]
fn default_preferences() -> PreferencesRef {
static DEFAULT: OnceLock<PreferencesRef> = OnceLock::new();
DEFAULT
.get_or_init(|| Arc::new(BrowserPreferences) as PreferencesRef)
.clone()
}
#[cfg(all(target_arch = "wasm32", not(feature = "preferences-web")))]
fn default_preferences() -> PreferencesRef {
static DEFAULT: OnceLock<PreferencesRef> = OnceLock::new();
DEFAULT
.get_or_init(|| Arc::new(MemoryPreferences::default()) as PreferencesRef)
.clone()
}
#[cfg(all(target_arch = "wasm32", feature = "preferences-web"))]
pub struct BrowserPreferences;
#[cfg(all(target_arch = "wasm32", feature = "preferences-web"))]
impl BrowserPreferences {
fn storage() -> Result<web_sys::Storage, PreferencesError> {
web_sys::window()
.and_then(|window| window.local_storage().ok().flatten())
.ok_or(PreferencesError::Unavailable)
}
}
#[cfg(all(target_arch = "wasm32", feature = "preferences-web"))]
fn storage_error(value: wasm_bindgen::JsValue) -> PreferencesError {
PreferencesError::Io(
value
.as_string()
.unwrap_or_else(|| "localStorage rejected the operation".to_string()),
)
}
#[cfg(all(target_arch = "wasm32", feature = "preferences-web"))]
impl PreferencesStore for BrowserPreferences {
fn get(&self, key: &str) -> Option<String> {
Self::storage().ok()?.get_item(key).ok().flatten()
}
fn set(&self, key: &str, value: &str) -> Result<(), PreferencesError> {
Self::storage()?.set_item(key, value).map_err(storage_error)
}
fn remove(&self, key: &str) -> Result<(), PreferencesError> {
Self::storage()?.remove_item(key).map_err(storage_error)
}
fn keys(&self) -> Vec<String> {
let Ok(storage) = Self::storage() else {
return Vec::new();
};
let count = storage.length().unwrap_or(0);
let mut keys: Vec<String> = (0..count)
.filter_map(|index| storage.key(index).ok().flatten())
.collect();
keys.sort();
keys
}
fn clear(&self) -> Result<(), PreferencesError> {
Self::storage()?.clear().map_err(storage_error)
}
}
#[derive(Default)]
pub struct MemoryPreferences {
entries: Mutex<BTreeMap<String, String>>,
}
impl MemoryPreferences {
pub fn new() -> Self {
Self::default()
}
}
impl PreferencesStore for MemoryPreferences {
fn get(&self, key: &str) -> Option<String> {
self.entries
.lock()
.ok()
.and_then(|entries| entries.get(key).cloned())
}
fn set(&self, key: &str, value: &str) -> Result<(), PreferencesError> {
let mut entries = self
.entries
.lock()
.map_err(|_| PreferencesError::Io("preferences lock poisoned".into()))?;
entries.insert(key.to_string(), value.to_string());
Ok(())
}
fn remove(&self, key: &str) -> Result<(), PreferencesError> {
let mut entries = self
.entries
.lock()
.map_err(|_| PreferencesError::Io("preferences lock poisoned".into()))?;
entries.remove(key);
Ok(())
}
fn keys(&self) -> Vec<String> {
self.entries
.lock()
.map(|entries| entries.keys().cloned().collect())
.unwrap_or_default()
}
fn clear(&self) -> Result<(), PreferencesError> {
let mut entries = self
.entries
.lock()
.map_err(|_| PreferencesError::Io("preferences lock poisoned".into()))?;
entries.clear();
Ok(())
}
}
#[cfg(not(target_arch = "wasm32"))]
fn encode(entries: &BTreeMap<String, String>) -> String {
let mut text = String::new();
for (key, value) in entries {
escape_into(key, &mut text);
text.push('=');
escape_into(value, &mut text);
text.push('\n');
}
text
}
#[cfg(not(target_arch = "wasm32"))]
fn parse(text: &str) -> BTreeMap<String, String> {
text.lines()
.filter_map(|line| {
let (key, value) = line.split_once('=')?;
Some((unescape(key), unescape(value)))
})
.collect()
}
#[cfg(not(target_arch = "wasm32"))]
fn escape_into(value: &str, out: &mut String) {
for character in value.chars() {
match character {
'%' => out.push_str("%25"),
'=' => out.push_str("%3D"),
'\n' => out.push_str("%0A"),
'\r' => out.push_str("%0D"),
other => out.push(other),
}
}
}
#[cfg(not(target_arch = "wasm32"))]
fn unescape(value: &str) -> String {
let mut out = String::with_capacity(value.len());
let mut characters = value.chars();
while let Some(character) = characters.next() {
if character != '%' {
out.push(character);
continue;
}
let high = characters.next();
let low = characters.next();
match (high, low) {
(Some(high), Some(low)) => match u8::from_str_radix(&format!("{high}{low}"), 16) {
Ok(byte) => out.push(byte as char),
Err(_) => {
out.push('%');
out.push(high);
out.push(low);
}
},
_ => out.push('%'),
}
}
out
}
pub struct Saver<T> {
save: SaveFn<T>,
restore: RestoreFn<T>,
}
type SaveFn<T> = Box<dyn Fn(&T) -> String + 'static>;
type RestoreFn<T> = Box<dyn Fn(&str) -> Option<T> + 'static>;
impl<T> Saver<T> {
pub fn new(
save: impl Fn(&T) -> String + 'static,
restore: impl Fn(&str) -> Option<T> + 'static,
) -> Self {
Self {
save: Box::new(save),
restore: Box::new(restore),
}
}
pub fn save(&self, value: &T) -> String {
(self.save)(value)
}
pub fn restore(&self, stored: &str) -> Option<T> {
(self.restore)(stored)
}
}
impl<T> Saver<T>
where
T: std::fmt::Display + std::str::FromStr + 'static,
{
pub fn of_display() -> Self {
Self::new(
|value: &T| value.to_string(),
|stored: &str| stored.parse::<T>().ok(),
)
}
}
#[expect(non_snake_case)]
#[track_caller]
pub fn rememberSaveable<T>(
key: &'static str,
saver: Saver<T>,
initial: impl FnOnce() -> T,
) -> cranpose_core::MutableState<T>
where
T: Clone + 'static,
{
cranpose_core::remember(move || {
let scope = SaveableScope::current();
let (store, key) = (scope.store(), scope.key(key));
let restored = crate::durable_save::saved(store.as_ref(), &key)
.and_then(|stored| saver.restore(&stored))
.unwrap_or_else(initial);
let owner = cranpose_core::mutableStateOfNeverEqual(restored);
let state = owner;
let registration = crate::durable_save::register_local(store, key, move || {
state.try_with(|value| saver.save(value))
});
(owner, registration)
})
.with(|(state, _)| *state)
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SaveableScope {
namespace: Option<Rc<str>>,
durable: bool,
}
fn local_saveable_scope() -> cranpose_core::CompositionLocal<SaveableScope> {
crate::composition_locals::cached_local(
|locals| &locals.saveable_scope,
|| {
cranpose_core::compositionLocalOf(|| SaveableScope {
namespace: None,
durable: true,
})
},
)
}
impl SaveableScope {
pub fn current() -> Self {
local_saveable_scope().current()
}
pub fn child(&self, name: impl std::fmt::Display) -> Self {
Self {
namespace: Some(self.key(name).into()),
durable: self.durable,
}
}
#[must_use]
pub fn in_memory(self) -> Self {
Self {
durable: false,
..self
}
}
pub fn namespace(&self) -> Option<&str> {
self.namespace.as_deref()
}
pub fn is_durable(&self) -> bool {
self.durable
}
pub fn restore(&self, key: &str) -> Option<String> {
crate::durable_save::saved(self.store().as_ref(), &self.key(key))
}
pub fn register(
&self,
key: &str,
read: impl Fn() -> Option<String> + 'static,
) -> crate::durable_save::SaveableRegistration {
crate::durable_save::register_local(self.store(), self.key(key), read)
}
pub fn forget(&self) {
self.forget_children_except(std::iter::empty::<&str>());
}
pub fn forget_children_except<N: std::fmt::Display>(&self, keep: impl IntoIterator<Item = N>) {
let Some(namespace) = &self.namespace else {
return;
};
let keep = keep
.into_iter()
.map(|name| format!("{namespace}/{name}/"))
.collect();
crate::durable_save::forget(self.store(), format!("{namespace}/"), keep);
}
fn key(&self, key: impl std::fmt::Display) -> String {
match &self.namespace {
Some(namespace) => format!("{namespace}/{key}"),
None => key.to_string(),
}
}
fn store(&self) -> Option<PreferencesRef> {
self.durable.then(preferences)
}
}
#[expect(non_snake_case)]
#[track_caller]
pub fn ProvideSaveableScope(scope: SaveableScope, content: impl FnOnce()) {
cranpose_core::CompositionLocalProvider([local_saveable_scope().provides(scope)], content);
}
#[cfg(test)]
#[path = "tests/preferences_tests.rs"]
mod tests;