1use std::{
13 collections::BTreeMap,
14 rc::Rc,
15 sync::{Arc, Mutex, OnceLock},
16};
17
18#[cfg(not(target_arch = "wasm32"))]
19mod file;
20#[cfg(not(target_arch = "wasm32"))]
21pub use file::FilePreferences;
22
23use crate::registry::ServiceRegistry;
24
25#[derive(thiserror::Error, Debug, Clone, PartialEq, Eq)]
27pub enum PreferencesError {
28 #[error("preferences storage failed: {0}")]
30 Io(String),
31 #[error("preferences are not available on this platform")]
33 Unavailable,
34}
35
36pub trait PreferencesStore: Send + Sync {
41 fn get(&self, key: &str) -> Option<String>;
43
44 fn set(&self, key: &str, value: &str) -> Result<(), PreferencesError>;
46
47 fn update_many(
51 &self,
52 values: &BTreeMap<String, String>,
53 removed: &[String],
54 ) -> Result<(), PreferencesError> {
55 for key in removed {
56 self.remove(key)?;
57 }
58 for (key, value) in values {
59 self.set(key, value)?;
60 }
61 Ok(())
62 }
63
64 fn flush(&self) -> Result<(), PreferencesError> {
66 Ok(())
67 }
68
69 fn remove(&self, key: &str) -> Result<(), PreferencesError>;
71
72 fn keys(&self) -> Vec<String>;
74
75 fn clear(&self) -> Result<(), PreferencesError>;
77}
78
79pub type PreferencesRef = Arc<dyn PreferencesStore>;
81
82static PLATFORM_PREFERENCES: ServiceRegistry<dyn PreferencesStore> = ServiceRegistry::new();
83
84pub fn set_platform_preferences(store: PreferencesRef) {
86 PLATFORM_PREFERENCES.set(store);
87}
88
89pub fn clear_platform_preferences() {
91 PLATFORM_PREFERENCES.clear();
92}
93
94pub fn preferences() -> PreferencesRef {
98 if let Some(store) = PLATFORM_PREFERENCES.get() {
99 return store;
100 }
101 default_preferences()
102}
103
104#[cfg(not(target_arch = "wasm32"))]
105fn default_preferences() -> PreferencesRef {
106 static DEFAULT: OnceLock<PreferencesRef> = OnceLock::new();
107 DEFAULT
108 .get_or_init(|| Arc::new(FilePreferences::new()) as PreferencesRef)
109 .clone()
110}
111
112#[cfg(all(target_arch = "wasm32", feature = "preferences-web"))]
113fn default_preferences() -> PreferencesRef {
114 static DEFAULT: OnceLock<PreferencesRef> = OnceLock::new();
115 DEFAULT
116 .get_or_init(|| Arc::new(BrowserPreferences) as PreferencesRef)
117 .clone()
118}
119
120#[cfg(all(target_arch = "wasm32", not(feature = "preferences-web")))]
121fn default_preferences() -> PreferencesRef {
122 static DEFAULT: OnceLock<PreferencesRef> = OnceLock::new();
123 DEFAULT
124 .get_or_init(|| Arc::new(MemoryPreferences::default()) as PreferencesRef)
125 .clone()
126}
127
128#[cfg(all(target_arch = "wasm32", feature = "preferences-web"))]
134pub struct BrowserPreferences;
135
136#[cfg(all(target_arch = "wasm32", feature = "preferences-web"))]
137impl BrowserPreferences {
138 fn storage() -> Result<web_sys::Storage, PreferencesError> {
139 web_sys::window()
140 .and_then(|window| window.local_storage().ok().flatten())
141 .ok_or(PreferencesError::Unavailable)
142 }
143}
144
145#[cfg(all(target_arch = "wasm32", feature = "preferences-web"))]
146fn storage_error(value: wasm_bindgen::JsValue) -> PreferencesError {
147 PreferencesError::Io(
148 value
149 .as_string()
150 .unwrap_or_else(|| "localStorage rejected the operation".to_string()),
151 )
152}
153
154#[cfg(all(target_arch = "wasm32", feature = "preferences-web"))]
155impl PreferencesStore for BrowserPreferences {
156 fn get(&self, key: &str) -> Option<String> {
157 Self::storage().ok()?.get_item(key).ok().flatten()
158 }
159
160 fn set(&self, key: &str, value: &str) -> Result<(), PreferencesError> {
161 Self::storage()?.set_item(key, value).map_err(storage_error)
162 }
163
164 fn remove(&self, key: &str) -> Result<(), PreferencesError> {
165 Self::storage()?.remove_item(key).map_err(storage_error)
166 }
167
168 fn keys(&self) -> Vec<String> {
169 let Ok(storage) = Self::storage() else {
170 return Vec::new();
171 };
172 let count = storage.length().unwrap_or(0);
173 let mut keys: Vec<String> = (0..count)
174 .filter_map(|index| storage.key(index).ok().flatten())
175 .collect();
176 keys.sort();
177 keys
178 }
179
180 fn clear(&self) -> Result<(), PreferencesError> {
181 Self::storage()?.clear().map_err(storage_error)
182 }
183}
184
185#[derive(Default)]
188pub struct MemoryPreferences {
189 entries: Mutex<BTreeMap<String, String>>,
190}
191
192impl MemoryPreferences {
193 pub fn new() -> Self {
195 Self::default()
196 }
197}
198
199impl PreferencesStore for MemoryPreferences {
200 fn get(&self, key: &str) -> Option<String> {
201 self.entries
202 .lock()
203 .ok()
204 .and_then(|entries| entries.get(key).cloned())
205 }
206
207 fn set(&self, key: &str, value: &str) -> Result<(), PreferencesError> {
208 let mut entries = self
209 .entries
210 .lock()
211 .map_err(|_| PreferencesError::Io("preferences lock poisoned".into()))?;
212 entries.insert(key.to_string(), value.to_string());
213 Ok(())
214 }
215
216 fn remove(&self, key: &str) -> Result<(), PreferencesError> {
217 let mut entries = self
218 .entries
219 .lock()
220 .map_err(|_| PreferencesError::Io("preferences lock poisoned".into()))?;
221 entries.remove(key);
222 Ok(())
223 }
224
225 fn keys(&self) -> Vec<String> {
226 self.entries
227 .lock()
228 .map(|entries| entries.keys().cloned().collect())
229 .unwrap_or_default()
230 }
231
232 fn clear(&self) -> Result<(), PreferencesError> {
233 let mut entries = self
234 .entries
235 .lock()
236 .map_err(|_| PreferencesError::Io("preferences lock poisoned".into()))?;
237 entries.clear();
238 Ok(())
239 }
240}
241
242#[cfg(not(target_arch = "wasm32"))]
243fn encode(entries: &BTreeMap<String, String>) -> String {
244 let mut text = String::new();
245 for (key, value) in entries {
246 escape_into(key, &mut text);
247 text.push('=');
248 escape_into(value, &mut text);
249 text.push('\n');
250 }
251 text
252}
253
254#[cfg(not(target_arch = "wasm32"))]
255fn parse(text: &str) -> BTreeMap<String, String> {
256 text.lines()
257 .filter_map(|line| {
258 let (key, value) = line.split_once('=')?;
259 Some((unescape(key), unescape(value)))
260 })
261 .collect()
262}
263
264#[cfg(not(target_arch = "wasm32"))]
265fn escape_into(value: &str, out: &mut String) {
266 for character in value.chars() {
267 match character {
268 '%' => out.push_str("%25"),
269 '=' => out.push_str("%3D"),
270 '\n' => out.push_str("%0A"),
271 '\r' => out.push_str("%0D"),
272 other => out.push(other),
273 }
274 }
275}
276
277#[cfg(not(target_arch = "wasm32"))]
278fn unescape(value: &str) -> String {
279 let mut out = String::with_capacity(value.len());
280 let mut characters = value.chars();
281 while let Some(character) = characters.next() {
282 if character != '%' {
283 out.push(character);
284 continue;
285 }
286 let high = characters.next();
287 let low = characters.next();
288 match (high, low) {
289 (Some(high), Some(low)) => match u8::from_str_radix(&format!("{high}{low}"), 16) {
290 Ok(byte) => out.push(byte as char),
291 Err(_) => {
292 out.push('%');
293 out.push(high);
294 out.push(low);
295 }
296 },
297 _ => out.push('%'),
298 }
299 }
300 out
301}
302
303pub struct Saver<T> {
308 save: SaveFn<T>,
309 restore: RestoreFn<T>,
310}
311
312type SaveFn<T> = Box<dyn Fn(&T) -> String + 'static>;
313type RestoreFn<T> = Box<dyn Fn(&str) -> Option<T> + 'static>;
314
315impl<T> Saver<T> {
316 pub fn new(
318 save: impl Fn(&T) -> String + 'static,
319 restore: impl Fn(&str) -> Option<T> + 'static,
320 ) -> Self {
321 Self {
322 save: Box::new(save),
323 restore: Box::new(restore),
324 }
325 }
326
327 pub fn save(&self, value: &T) -> String {
329 (self.save)(value)
330 }
331
332 pub fn restore(&self, stored: &str) -> Option<T> {
335 (self.restore)(stored)
336 }
337}
338
339impl<T> Saver<T>
340where
341 T: std::fmt::Display + std::str::FromStr + 'static,
342{
343 pub fn of_display() -> Self {
346 Self::new(
347 |value: &T| value.to_string(),
348 |stored: &str| stored.parse::<T>().ok(),
349 )
350 }
351}
352
353#[expect(non_snake_case)]
365#[track_caller]
366pub fn rememberSaveable<T>(
367 key: &'static str,
368 saver: Saver<T>,
369 initial: impl FnOnce() -> T,
370) -> cranpose_core::MutableState<T>
371where
372 T: Clone + 'static,
373{
374 cranpose_core::remember(move || {
375 let scope = SaveableScope::current();
376 let (store, key) = (scope.store(), scope.key(key));
377 let restored = crate::durable_save::saved(store.as_ref(), &key)
378 .and_then(|stored| saver.restore(&stored))
379 .unwrap_or_else(initial);
380 let owner = cranpose_core::mutableStateOfNeverEqual(restored);
381 let state = owner;
382 let registration = crate::durable_save::register_local(store, key, move || {
383 state.try_with(|value| saver.save(value))
384 });
385 (owner, registration)
386 })
387 .with(|(state, _)| *state)
388}
389
390#[derive(Clone, Debug, PartialEq, Eq)]
395pub struct SaveableScope {
396 namespace: Option<Rc<str>>,
397 durable: bool,
398}
399
400fn local_saveable_scope() -> cranpose_core::CompositionLocal<SaveableScope> {
401 crate::composition_locals::cached_local(
402 |locals| &locals.saveable_scope,
403 || {
404 cranpose_core::compositionLocalOf(|| SaveableScope {
405 namespace: None,
406 durable: true,
407 })
408 },
409 )
410}
411
412impl SaveableScope {
413 pub fn current() -> Self {
417 local_saveable_scope().current()
418 }
419
420 pub fn child(&self, name: impl std::fmt::Display) -> Self {
422 Self {
423 namespace: Some(self.key(name).into()),
424 durable: self.durable,
425 }
426 }
427
428 #[must_use]
431 pub fn in_memory(self) -> Self {
432 Self {
433 durable: false,
434 ..self
435 }
436 }
437
438 pub fn namespace(&self) -> Option<&str> {
441 self.namespace.as_deref()
442 }
443
444 pub fn is_durable(&self) -> bool {
447 self.durable
448 }
449
450 pub fn restore(&self, key: &str) -> Option<String> {
452 crate::durable_save::saved(self.store().as_ref(), &self.key(key))
453 }
454
455 pub fn register(
460 &self,
461 key: &str,
462 read: impl Fn() -> Option<String> + 'static,
463 ) -> crate::durable_save::SaveableRegistration {
464 crate::durable_save::register_local(self.store(), self.key(key), read)
465 }
466
467 pub fn forget(&self) {
471 self.forget_children_except(std::iter::empty::<&str>());
472 }
473
474 pub fn forget_children_except<N: std::fmt::Display>(&self, keep: impl IntoIterator<Item = N>) {
477 let Some(namespace) = &self.namespace else {
478 return;
479 };
480 let keep = keep
481 .into_iter()
482 .map(|name| format!("{namespace}/{name}/"))
483 .collect();
484 crate::durable_save::forget(self.store(), format!("{namespace}/"), keep);
485 }
486
487 fn key(&self, key: impl std::fmt::Display) -> String {
488 match &self.namespace {
489 Some(namespace) => format!("{namespace}/{key}"),
490 None => key.to_string(),
491 }
492 }
493
494 fn store(&self) -> Option<PreferencesRef> {
495 self.durable.then(preferences)
496 }
497}
498
499#[expect(non_snake_case)]
501#[track_caller]
502pub fn ProvideSaveableScope(scope: SaveableScope, content: impl FnOnce()) {
503 cranpose_core::CompositionLocalProvider([local_saveable_scope().provides(scope)], content);
504}
505
506#[cfg(test)]
507#[path = "tests/preferences_tests.rs"]
508mod tests;