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 Arc::clone(DEFAULT.get_or_init(|| Arc::new(FilePreferences::new()) as PreferencesRef))
108}
109
110#[cfg(all(target_arch = "wasm32", feature = "preferences-web"))]
111fn default_preferences() -> PreferencesRef {
112 static DEFAULT: OnceLock<PreferencesRef> = OnceLock::new();
113 Arc::clone(DEFAULT.get_or_init(|| Arc::new(BrowserPreferences) as PreferencesRef))
114}
115
116#[cfg(all(target_arch = "wasm32", not(feature = "preferences-web")))]
117fn default_preferences() -> PreferencesRef {
118 static DEFAULT: OnceLock<PreferencesRef> = OnceLock::new();
119 Arc::clone(DEFAULT.get_or_init(|| Arc::new(MemoryPreferences::default()) as PreferencesRef))
120}
121
122#[cfg(all(target_arch = "wasm32", feature = "preferences-web"))]
128pub struct BrowserPreferences;
129
130#[cfg(all(target_arch = "wasm32", feature = "preferences-web"))]
131impl BrowserPreferences {
132 fn storage() -> Result<web_sys::Storage, PreferencesError> {
133 web_sys::window()
134 .and_then(|window| window.local_storage().ok().flatten())
135 .ok_or(PreferencesError::Unavailable)
136 }
137}
138
139#[cfg(all(target_arch = "wasm32", feature = "preferences-web"))]
140fn storage_error(value: wasm_bindgen::JsValue) -> PreferencesError {
141 PreferencesError::Io(
142 value
143 .as_string()
144 .unwrap_or_else(|| "localStorage rejected the operation".to_string()),
145 )
146}
147
148#[cfg(all(target_arch = "wasm32", feature = "preferences-web"))]
149impl PreferencesStore for BrowserPreferences {
150 fn get(&self, key: &str) -> Option<String> {
151 Self::storage().ok()?.get_item(key).ok().flatten()
152 }
153
154 fn set(&self, key: &str, value: &str) -> Result<(), PreferencesError> {
155 Self::storage()?.set_item(key, value).map_err(storage_error)
156 }
157
158 fn remove(&self, key: &str) -> Result<(), PreferencesError> {
159 Self::storage()?.remove_item(key).map_err(storage_error)
160 }
161
162 fn keys(&self) -> Vec<String> {
163 let Ok(storage) = Self::storage() else {
164 return Vec::new();
165 };
166 let count = storage.length().unwrap_or(0);
167 let mut keys: Vec<String> = (0..count)
168 .filter_map(|index| storage.key(index).ok().flatten())
169 .collect();
170 keys.sort();
171 keys
172 }
173
174 fn clear(&self) -> Result<(), PreferencesError> {
175 Self::storage()?.clear().map_err(storage_error)
176 }
177}
178
179#[derive(Default)]
182pub struct MemoryPreferences {
183 entries: Mutex<BTreeMap<String, String>>,
184}
185
186impl MemoryPreferences {
187 pub fn new() -> Self {
189 Self::default()
190 }
191}
192
193impl PreferencesStore for MemoryPreferences {
194 fn get(&self, key: &str) -> Option<String> {
195 self.entries
196 .lock()
197 .ok()
198 .and_then(|entries| entries.get(key).cloned())
199 }
200
201 fn set(&self, key: &str, value: &str) -> Result<(), PreferencesError> {
202 let mut entries = self
203 .entries
204 .lock()
205 .map_err(|_| PreferencesError::Io("preferences lock poisoned".into()))?;
206 entries.insert(key.to_string(), value.to_string());
207 Ok(())
208 }
209
210 fn remove(&self, key: &str) -> Result<(), PreferencesError> {
211 let mut entries = self
212 .entries
213 .lock()
214 .map_err(|_| PreferencesError::Io("preferences lock poisoned".into()))?;
215 entries.remove(key);
216 Ok(())
217 }
218
219 fn keys(&self) -> Vec<String> {
220 self.entries
221 .lock()
222 .map(|entries| entries.keys().cloned().collect())
223 .unwrap_or_default()
224 }
225
226 fn clear(&self) -> Result<(), PreferencesError> {
227 let mut entries = self
228 .entries
229 .lock()
230 .map_err(|_| PreferencesError::Io("preferences lock poisoned".into()))?;
231 entries.clear();
232 Ok(())
233 }
234}
235
236#[cfg(not(target_arch = "wasm32"))]
237fn encode(entries: &BTreeMap<String, String>) -> String {
238 let mut text = String::new();
239 for (key, value) in entries {
240 escape_into(key, &mut text);
241 text.push('=');
242 escape_into(value, &mut text);
243 text.push('\n');
244 }
245 text
246}
247
248#[cfg(not(target_arch = "wasm32"))]
249fn parse(text: &str) -> BTreeMap<String, String> {
250 text.lines()
251 .filter_map(|line| {
252 let (key, value) = line.split_once('=')?;
253 Some((unescape(key), unescape(value)))
254 })
255 .collect()
256}
257
258#[cfg(not(target_arch = "wasm32"))]
259fn escape_into(value: &str, out: &mut String) {
260 for character in value.chars() {
261 match character {
262 '%' => out.push_str("%25"),
263 '=' => out.push_str("%3D"),
264 '\n' => out.push_str("%0A"),
265 '\r' => out.push_str("%0D"),
266 other => out.push(other),
267 }
268 }
269}
270
271#[cfg(not(target_arch = "wasm32"))]
272fn unescape(value: &str) -> String {
273 let mut out = String::with_capacity(value.len());
274 let mut characters = value.chars();
275 while let Some(character) = characters.next() {
276 if character != '%' {
277 out.push(character);
278 continue;
279 }
280 let high = characters.next();
281 let low = characters.next();
282 match (high, low) {
283 (Some(high), Some(low)) => match u8::from_str_radix(&format!("{high}{low}"), 16) {
284 Ok(byte) => out.push(byte as char),
285 Err(_) => {
286 out.push('%');
287 out.push(high);
288 out.push(low);
289 }
290 },
291 _ => out.push('%'),
292 }
293 }
294 out
295}
296
297pub struct Saver<T> {
302 save: SaveFn<T>,
303 restore: RestoreFn<T>,
304}
305
306type SaveFn<T> = Box<dyn Fn(&T) -> String + 'static>;
307type RestoreFn<T> = Box<dyn Fn(&str) -> Option<T> + 'static>;
308
309impl<T> Saver<T> {
310 pub fn new(
312 save: impl Fn(&T) -> String + 'static,
313 restore: impl Fn(&str) -> Option<T> + 'static,
314 ) -> Self {
315 Self {
316 save: Box::new(save),
317 restore: Box::new(restore),
318 }
319 }
320
321 pub fn save(&self, value: &T) -> String {
323 (self.save)(value)
324 }
325
326 pub fn restore(&self, stored: &str) -> Option<T> {
329 (self.restore)(stored)
330 }
331}
332
333impl<T: 'static> Saver<T> {
334 pub fn list(
338 save: impl Fn(&T) -> Vec<String> + 'static,
339 restore: impl Fn(&[String]) -> Option<T> + 'static,
340 ) -> Self {
341 Self::new(
342 move |value| encode_list(&save(value)),
343 move |stored| restore(&decode_list(stored)?),
344 )
345 }
346
347 pub fn map(
351 save: impl Fn(&T) -> Vec<(String, String)> + 'static,
352 restore: impl Fn(&std::collections::HashMap<String, String>) -> Option<T> + 'static,
353 ) -> Self {
354 Self::new(
355 move |value| {
356 let pairs = save(value);
357 let mut items = Vec::with_capacity(pairs.len() * 2);
358 for (name, text) in pairs {
359 items.push(name);
360 items.push(text);
361 }
362 encode_list(&items)
363 },
364 move |stored| {
365 let items = decode_list(stored)?;
366 if items.len() % 2 != 0 {
367 return None;
368 }
369 let mut map = std::collections::HashMap::with_capacity(items.len() / 2);
370 let mut items = items.into_iter();
371 while let (Some(name), Some(text)) = (items.next(), items.next()) {
372 map.insert(name, text);
373 }
374 restore(&map)
375 },
376 )
377 }
378}
379
380fn encode_list(items: &[String]) -> String {
382 use std::fmt::Write;
383
384 let mut stored = String::with_capacity(items.iter().map(|item| item.len() + 4).sum());
385 for item in items {
386 let _ = write!(stored, "{}:{item}", item.len());
387 }
388 stored
389}
390
391fn decode_list(mut stored: &str) -> Option<Vec<String>> {
393 let mut items = Vec::new();
394 while !stored.is_empty() {
395 let (length, rest) = stored.split_once(':')?;
396 let (item, rest) = rest.split_at_checked(length.parse().ok()?)?;
397 items.push(item.to_owned());
398 stored = rest;
399 }
400 Some(items)
401}
402
403impl<T> Saver<T>
404where
405 T: std::fmt::Display + std::str::FromStr + 'static,
406{
407 pub fn of_display() -> Self {
410 Self::new(
411 |value: &T| value.to_string(),
412 |stored: &str| stored.parse::<T>().ok(),
413 )
414 }
415}
416
417#[expect(non_snake_case, reason = "names follow Compose's casing")]
429#[track_caller]
430pub fn rememberSaveable<T>(
431 key: &'static str,
432 saver: Saver<T>,
433 initial: impl FnOnce() -> T,
434) -> cranpose_core::MutableState<T>
435where
436 T: Clone + 'static,
437{
438 cranpose_core::remember(move || {
439 let scope = SaveableScope::current();
440 let (store, key) = (scope.store(), scope.key(key));
441 let restored = crate::durable_save::saved(store.as_ref(), &key)
442 .and_then(|stored| saver.restore(&stored))
443 .unwrap_or_else(initial);
444 let owner = cranpose_core::mutableStateOfNeverEqual(restored);
445 let state = owner;
446 let registration = crate::durable_save::register_local(store, key, move || {
447 state.try_with(|value| saver.save(value))
448 });
449 (owner, registration)
450 })
451 .with(|(state, _)| *state)
452}
453
454#[derive(Clone, Debug, PartialEq, Eq)]
459pub struct SaveableScope {
460 namespace: Option<Rc<str>>,
461 durable: bool,
462}
463
464fn local_saveable_scope() -> cranpose_core::CompositionLocal<SaveableScope> {
465 crate::composition_locals::cached_local(
466 |locals| &locals.saveable_scope,
467 || {
468 cranpose_core::compositionLocalOf(|| SaveableScope {
469 namespace: None,
470 durable: true,
471 })
472 },
473 )
474}
475
476impl SaveableScope {
477 pub fn current() -> Self {
481 local_saveable_scope().current()
482 }
483
484 pub fn child(&self, name: impl std::fmt::Display) -> Self {
486 Self {
487 namespace: Some(self.key(name).into()),
488 durable: self.durable,
489 }
490 }
491
492 #[must_use]
495 pub fn in_memory(self) -> Self {
496 Self {
497 durable: false,
498 ..self
499 }
500 }
501
502 pub fn namespace(&self) -> Option<&str> {
505 self.namespace.as_deref()
506 }
507
508 pub fn is_durable(&self) -> bool {
511 self.durable
512 }
513
514 pub fn restore(&self, key: &str) -> Option<String> {
516 crate::durable_save::saved(self.store().as_ref(), &self.key(key))
517 }
518
519 pub fn register(
524 &self,
525 key: &str,
526 read: impl Fn() -> Option<String> + 'static,
527 ) -> crate::durable_save::SaveableRegistration {
528 crate::durable_save::register_local(self.store(), self.key(key), read)
529 }
530
531 pub fn forget(&self) {
535 self.forget_children_except(std::iter::empty::<&str>());
536 }
537
538 pub fn forget_children_except<N: std::fmt::Display>(&self, keep: impl IntoIterator<Item = N>) {
541 let Some(namespace) = &self.namespace else {
542 return;
543 };
544 let keep = keep
545 .into_iter()
546 .map(|name| format!("{namespace}/{name}/"))
547 .collect();
548 crate::durable_save::forget(self.store(), format!("{namespace}/"), keep);
549 }
550
551 pub(crate) fn key(&self, key: impl std::fmt::Display) -> String {
552 match &self.namespace {
553 Some(namespace) => format!("{namespace}/{key}"),
554 None => key.to_string(),
555 }
556 }
557
558 fn store(&self) -> Option<PreferencesRef> {
559 self.durable.then(preferences)
560 }
561}
562
563#[expect(non_snake_case, reason = "names follow Compose's casing")]
565#[track_caller]
566pub fn ProvideSaveableScope(scope: SaveableScope, content: impl FnOnce()) {
567 cranpose_core::CompositionLocalProvider([local_saveable_scope().provides(scope)], content);
568}
569
570#[cfg(test)]
571#[path = "tests/preferences_tests.rs"]
572mod tests;