1use std::{
13 collections::BTreeMap,
14 rc::Rc,
15 sync::{Arc, Mutex, OnceLock},
16};
17
18use cranpose_core::collections::map::HashMap;
19
20#[cfg(not(target_arch = "wasm32"))]
21mod file;
22#[cfg(not(target_arch = "wasm32"))]
23pub use file::FilePreferences;
24
25use crate::registry::ServiceRegistry;
26
27#[derive(thiserror::Error, Debug, Clone, PartialEq, Eq)]
29pub enum PreferencesError {
30 #[error("preferences storage failed: {0}")]
32 Io(String),
33 #[error("preferences are not available on this platform")]
35 Unavailable,
36}
37
38pub trait PreferencesStore: Send + Sync {
43 fn get(&self, key: &str) -> Option<String>;
45
46 fn set(&self, key: &str, value: &str) -> Result<(), PreferencesError>;
48
49 fn update_many(
53 &self,
54 values: &BTreeMap<String, String>,
55 removed: &[String],
56 ) -> Result<(), PreferencesError> {
57 for key in removed {
58 self.remove(key)?;
59 }
60 for (key, value) in values {
61 self.set(key, value)?;
62 }
63 Ok(())
64 }
65
66 fn flush(&self) -> Result<(), PreferencesError> {
68 Ok(())
69 }
70
71 fn remove(&self, key: &str) -> Result<(), PreferencesError>;
73
74 fn keys(&self) -> Vec<String>;
76
77 fn clear(&self) -> Result<(), PreferencesError>;
79}
80
81pub type PreferencesRef = Arc<dyn PreferencesStore>;
83
84static PLATFORM_PREFERENCES: ServiceRegistry<dyn PreferencesStore> = ServiceRegistry::new();
85
86pub fn set_platform_preferences(store: PreferencesRef) {
88 PLATFORM_PREFERENCES.set(store);
89}
90
91pub fn clear_platform_preferences() {
93 PLATFORM_PREFERENCES.clear();
94}
95
96pub fn preferences() -> PreferencesRef {
100 if let Some(store) = PLATFORM_PREFERENCES.get() {
101 return store;
102 }
103 default_preferences()
104}
105
106#[cfg(not(target_arch = "wasm32"))]
107fn default_preferences() -> PreferencesRef {
108 static DEFAULT: OnceLock<PreferencesRef> = OnceLock::new();
109 Arc::clone(DEFAULT.get_or_init(|| Arc::new(FilePreferences::new()) as PreferencesRef))
110}
111
112#[cfg(all(target_arch = "wasm32", feature = "preferences-web"))]
113fn default_preferences() -> PreferencesRef {
114 static DEFAULT: OnceLock<PreferencesRef> = OnceLock::new();
115 Arc::clone(DEFAULT.get_or_init(|| Arc::new(BrowserPreferences) as PreferencesRef))
116}
117
118#[cfg(all(target_arch = "wasm32", not(feature = "preferences-web")))]
119fn default_preferences() -> PreferencesRef {
120 static DEFAULT: OnceLock<PreferencesRef> = OnceLock::new();
121 Arc::clone(DEFAULT.get_or_init(|| Arc::new(MemoryPreferences::default()) as PreferencesRef))
122}
123
124#[cfg(all(target_arch = "wasm32", feature = "preferences-web"))]
130pub struct BrowserPreferences;
131
132#[cfg(all(target_arch = "wasm32", feature = "preferences-web"))]
133impl BrowserPreferences {
134 fn storage() -> Result<web_sys::Storage, PreferencesError> {
135 web_sys::window()
136 .and_then(|window| window.local_storage().ok().flatten())
137 .ok_or(PreferencesError::Unavailable)
138 }
139}
140
141#[cfg(all(target_arch = "wasm32", feature = "preferences-web"))]
142fn storage_error(value: wasm_bindgen::JsValue) -> PreferencesError {
143 PreferencesError::Io(
144 value
145 .as_string()
146 .unwrap_or_else(|| "localStorage rejected the operation".to_string()),
147 )
148}
149
150#[cfg(all(target_arch = "wasm32", feature = "preferences-web"))]
151impl PreferencesStore for BrowserPreferences {
152 fn get(&self, key: &str) -> Option<String> {
153 Self::storage().ok()?.get_item(key).ok().flatten()
154 }
155
156 fn set(&self, key: &str, value: &str) -> Result<(), PreferencesError> {
157 Self::storage()?.set_item(key, value).map_err(storage_error)
158 }
159
160 fn remove(&self, key: &str) -> Result<(), PreferencesError> {
161 Self::storage()?.remove_item(key).map_err(storage_error)
162 }
163
164 fn keys(&self) -> Vec<String> {
165 let Ok(storage) = Self::storage() else {
166 return Vec::new();
167 };
168 let count = storage.length().unwrap_or(0);
169 let mut keys: Vec<String> = (0..count)
170 .filter_map(|index| storage.key(index).ok().flatten())
171 .collect();
172 keys.sort();
173 keys
174 }
175
176 fn clear(&self) -> Result<(), PreferencesError> {
177 Self::storage()?.clear().map_err(storage_error)
178 }
179}
180
181#[derive(Default)]
184pub struct MemoryPreferences {
185 entries: Mutex<BTreeMap<String, String>>,
186}
187
188impl MemoryPreferences {
189 pub fn new() -> Self {
191 Self::default()
192 }
193}
194
195impl PreferencesStore for MemoryPreferences {
196 fn get(&self, key: &str) -> Option<String> {
197 self.entries
198 .lock()
199 .ok()
200 .and_then(|entries| entries.get(key).cloned())
201 }
202
203 fn set(&self, key: &str, value: &str) -> Result<(), PreferencesError> {
204 let mut entries = self
205 .entries
206 .lock()
207 .map_err(|_| PreferencesError::Io("preferences lock poisoned".into()))?;
208 entries.insert(key.to_string(), value.to_string());
209 Ok(())
210 }
211
212 fn remove(&self, key: &str) -> Result<(), PreferencesError> {
213 let mut entries = self
214 .entries
215 .lock()
216 .map_err(|_| PreferencesError::Io("preferences lock poisoned".into()))?;
217 entries.remove(key);
218 Ok(())
219 }
220
221 fn keys(&self) -> Vec<String> {
222 self.entries
223 .lock()
224 .map(|entries| entries.keys().cloned().collect())
225 .unwrap_or_default()
226 }
227
228 fn clear(&self) -> Result<(), PreferencesError> {
229 let mut entries = self
230 .entries
231 .lock()
232 .map_err(|_| PreferencesError::Io("preferences lock poisoned".into()))?;
233 entries.clear();
234 Ok(())
235 }
236}
237
238#[cfg(not(target_arch = "wasm32"))]
239fn encode(entries: &BTreeMap<String, String>) -> String {
240 let mut text = String::new();
241 for (key, value) in entries {
242 escape_into(key, &mut text);
243 text.push('=');
244 escape_into(value, &mut text);
245 text.push('\n');
246 }
247 text
248}
249
250#[cfg(not(target_arch = "wasm32"))]
251fn parse(text: &str) -> BTreeMap<String, String> {
252 text.lines()
253 .filter_map(|line| {
254 let (key, value) = line.split_once('=')?;
255 Some((unescape(key), unescape(value)))
256 })
257 .collect()
258}
259
260#[cfg(not(target_arch = "wasm32"))]
261fn escape_into(value: &str, out: &mut String) {
262 for character in value.chars() {
263 match character {
264 '%' => out.push_str("%25"),
265 '=' => out.push_str("%3D"),
266 '\n' => out.push_str("%0A"),
267 '\r' => out.push_str("%0D"),
268 other => out.push(other),
269 }
270 }
271}
272
273#[cfg(not(target_arch = "wasm32"))]
274fn unescape(value: &str) -> String {
275 let mut out = String::with_capacity(value.len());
276 let mut characters = value.chars();
277 while let Some(character) = characters.next() {
278 if character != '%' {
279 out.push(character);
280 continue;
281 }
282 let high = characters.next();
283 let low = characters.next();
284 match (high, low) {
285 (Some(high), Some(low)) => match u8::from_str_radix(&format!("{high}{low}"), 16) {
286 Ok(byte) => out.push(byte as char),
287 Err(_) => {
288 out.push('%');
289 out.push(high);
290 out.push(low);
291 }
292 },
293 _ => out.push('%'),
294 }
295 }
296 out
297}
298
299pub struct Saver<T> {
304 save: SaveFn<T>,
305 restore: RestoreFn<T>,
306}
307
308type SaveFn<T> = Box<dyn Fn(&T) -> String + 'static>;
309type RestoreFn<T> = Box<dyn Fn(&str) -> Option<T> + 'static>;
310
311impl<T> Saver<T> {
312 pub fn new(
314 save: impl Fn(&T) -> String + 'static,
315 restore: impl Fn(&str) -> Option<T> + 'static,
316 ) -> Self {
317 Self {
318 save: Box::new(save),
319 restore: Box::new(restore),
320 }
321 }
322
323 pub fn save(&self, value: &T) -> String {
325 (self.save)(value)
326 }
327
328 pub fn restore(&self, stored: &str) -> Option<T> {
331 (self.restore)(stored)
332 }
333}
334
335impl<T: 'static> Saver<T> {
336 pub fn list(
340 save: impl Fn(&T) -> Vec<String> + 'static,
341 restore: impl Fn(&[String]) -> Option<T> + 'static,
342 ) -> Self {
343 Self::new(
344 move |value| encode_list(&save(value)),
345 move |stored| restore(&decode_list(stored)?),
346 )
347 }
348
349 pub fn map(
353 save: impl Fn(&T) -> Vec<(String, String)> + 'static,
354 restore: impl Fn(&HashMap<String, String>) -> Option<T> + 'static,
355 ) -> Self {
356 Self::new(
357 move |value| {
358 let pairs = save(value);
359 let mut items = Vec::with_capacity(pairs.len() * 2);
360 for (name, text) in pairs {
361 items.push(name);
362 items.push(text);
363 }
364 encode_list(&items)
365 },
366 move |stored| {
367 let items = decode_list(stored)?;
368 if items.len() % 2 != 0 {
369 return None;
370 }
371 let mut map =
372 HashMap::with_capacity_and_hasher(items.len() / 2, Default::default());
373 let mut items = items.into_iter();
374 while let (Some(name), Some(text)) = (items.next(), items.next()) {
375 map.insert(name, text);
376 }
377 restore(&map)
378 },
379 )
380 }
381}
382
383fn encode_list(items: &[String]) -> String {
385 use std::fmt::Write;
386
387 let mut stored = String::with_capacity(items.iter().map(|item| item.len() + 4).sum());
388 for item in items {
389 let _ = write!(stored, "{}:{item}", item.len());
390 }
391 stored
392}
393
394fn decode_list(mut stored: &str) -> Option<Vec<String>> {
396 let mut items = Vec::new();
397 while !stored.is_empty() {
398 let (length, rest) = stored.split_once(':')?;
399 let (item, rest) = rest.split_at_checked(length.parse().ok()?)?;
400 items.push(item.to_owned());
401 stored = rest;
402 }
403 Some(items)
404}
405
406impl<T> Saver<T>
407where
408 T: std::fmt::Display + std::str::FromStr + 'static,
409{
410 pub fn of_display() -> Self {
413 Self::new(
414 |value: &T| value.to_string(),
415 |stored: &str| stored.parse::<T>().ok(),
416 )
417 }
418}
419
420#[expect(non_snake_case, reason = "names follow Compose's casing")]
432#[track_caller]
433pub fn rememberSaveable<T>(
434 key: &'static str,
435 saver: Saver<T>,
436 initial: impl FnOnce() -> T,
437) -> cranpose_core::MutableState<T>
438where
439 T: Clone + 'static,
440{
441 cranpose_core::remember(move || {
442 let scope = SaveableScope::current();
443 let (store, key) = (scope.store(), scope.key(key));
444 let restored = crate::durable_save::saved(store.as_ref(), &key)
445 .and_then(|stored| saver.restore(&stored))
446 .unwrap_or_else(initial);
447 let owner = cranpose_core::mutableStateOfNeverEqual(restored);
448 let state = owner;
449 let registration = crate::durable_save::register_local(store, key, move || {
450 state.try_with(|value| saver.save(value))
451 });
452 (owner, registration)
453 })
454 .with(|(state, _)| *state)
455}
456
457#[derive(Clone, Debug, PartialEq, Eq)]
462pub struct SaveableScope {
463 namespace: Option<Rc<str>>,
464 durable: bool,
465}
466
467fn local_saveable_scope() -> cranpose_core::CompositionLocal<SaveableScope> {
468 crate::composition_locals::cached_local(
469 |locals| &locals.saveable_scope,
470 || {
471 cranpose_core::compositionLocalOf(|| SaveableScope {
472 namespace: None,
473 durable: true,
474 })
475 },
476 )
477}
478
479impl SaveableScope {
480 pub fn current() -> Self {
484 local_saveable_scope().current()
485 }
486
487 pub fn child(&self, name: impl std::fmt::Display) -> Self {
489 Self {
490 namespace: Some(self.key(name).into()),
491 durable: self.durable,
492 }
493 }
494
495 #[must_use]
498 pub fn in_memory(self) -> Self {
499 Self {
500 durable: false,
501 ..self
502 }
503 }
504
505 pub fn namespace(&self) -> Option<&str> {
508 self.namespace.as_deref()
509 }
510
511 pub fn is_durable(&self) -> bool {
514 self.durable
515 }
516
517 pub fn restore(&self, key: &str) -> Option<String> {
519 crate::durable_save::saved(self.store().as_ref(), &self.key(key))
520 }
521
522 pub fn register(
527 &self,
528 key: &str,
529 read: impl Fn() -> Option<String> + 'static,
530 ) -> crate::durable_save::SaveableRegistration {
531 crate::durable_save::register_local(self.store(), self.key(key), read)
532 }
533
534 pub fn forget(&self) {
538 self.forget_children_except(std::iter::empty::<&str>());
539 }
540
541 pub fn forget_children_except<N: std::fmt::Display>(&self, keep: impl IntoIterator<Item = N>) {
544 let Some(namespace) = &self.namespace else {
545 return;
546 };
547 let keep = keep
548 .into_iter()
549 .map(|name| format!("{namespace}/{name}/"))
550 .collect();
551 crate::durable_save::forget(self.store(), format!("{namespace}/"), keep);
552 }
553
554 pub(crate) fn key(&self, key: impl std::fmt::Display) -> String {
555 match &self.namespace {
556 Some(namespace) => format!("{namespace}/{key}"),
557 None => key.to_string(),
558 }
559 }
560
561 fn store(&self) -> Option<PreferencesRef> {
562 self.durable.then(preferences)
563 }
564}
565
566#[expect(non_snake_case, reason = "names follow Compose's casing")]
568#[track_caller]
569pub fn ProvideSaveableScope(scope: SaveableScope, content: impl FnOnce()) {
570 cranpose_core::CompositionLocalProvider([local_saveable_scope().provides(scope)], content);
571}
572
573#[cfg(test)]
574#[path = "tests/preferences_tests.rs"]
575mod tests;