1#[cfg(not(target_arch = "wasm32"))]
13use crate::host::application_directories;
14use crate::registry::ServiceRegistry;
15use std::collections::BTreeMap;
16use std::sync::{Arc, Mutex, OnceLock};
17
18#[derive(thiserror::Error, Debug, Clone, PartialEq, Eq)]
20pub enum PreferencesError {
21 #[error("preferences storage failed: {0}")]
23 Io(String),
24 #[error("preferences are not available on this platform")]
26 Unavailable,
27}
28
29pub trait PreferencesStore: Send + Sync {
34 fn get(&self, key: &str) -> Option<String>;
36
37 fn set(&self, key: &str, value: &str) -> Result<(), PreferencesError>;
39
40 fn remove(&self, key: &str) -> Result<(), PreferencesError>;
42
43 fn keys(&self) -> Vec<String>;
45
46 fn clear(&self) -> Result<(), PreferencesError>;
48}
49
50pub type PreferencesRef = Arc<dyn PreferencesStore>;
52
53static PLATFORM_PREFERENCES: ServiceRegistry<dyn PreferencesStore> = ServiceRegistry::new();
54
55pub fn set_platform_preferences(store: PreferencesRef) {
57 PLATFORM_PREFERENCES.set(store);
58}
59
60pub fn clear_platform_preferences() {
62 PLATFORM_PREFERENCES.clear();
63}
64
65pub fn preferences() -> PreferencesRef {
69 if let Some(store) = PLATFORM_PREFERENCES.get() {
70 return store;
71 }
72 default_preferences()
73}
74
75#[cfg(not(target_arch = "wasm32"))]
76fn default_preferences() -> PreferencesRef {
77 static DEFAULT: OnceLock<PreferencesRef> = OnceLock::new();
78 DEFAULT
79 .get_or_init(|| Arc::new(FilePreferences::new()) as PreferencesRef)
80 .clone()
81}
82
83#[cfg(all(target_arch = "wasm32", feature = "preferences-web"))]
84fn default_preferences() -> PreferencesRef {
85 static DEFAULT: OnceLock<PreferencesRef> = OnceLock::new();
86 DEFAULT
87 .get_or_init(|| Arc::new(BrowserPreferences) as PreferencesRef)
88 .clone()
89}
90
91#[cfg(all(target_arch = "wasm32", not(feature = "preferences-web")))]
92fn default_preferences() -> PreferencesRef {
93 static DEFAULT: OnceLock<PreferencesRef> = OnceLock::new();
94 DEFAULT
95 .get_or_init(|| Arc::new(MemoryPreferences::default()) as PreferencesRef)
96 .clone()
97}
98
99#[cfg(all(target_arch = "wasm32", feature = "preferences-web"))]
105pub struct BrowserPreferences;
106
107#[cfg(all(target_arch = "wasm32", feature = "preferences-web"))]
108impl BrowserPreferences {
109 fn storage() -> Result<web_sys::Storage, PreferencesError> {
110 web_sys::window()
111 .and_then(|window| window.local_storage().ok().flatten())
112 .ok_or(PreferencesError::Unavailable)
113 }
114}
115
116#[cfg(all(target_arch = "wasm32", feature = "preferences-web"))]
117fn storage_error(value: wasm_bindgen::JsValue) -> PreferencesError {
118 PreferencesError::Io(
119 value
120 .as_string()
121 .unwrap_or_else(|| "localStorage rejected the operation".to_string()),
122 )
123}
124
125#[cfg(all(target_arch = "wasm32", feature = "preferences-web"))]
126impl PreferencesStore for BrowserPreferences {
127 fn get(&self, key: &str) -> Option<String> {
128 Self::storage().ok()?.get_item(key).ok().flatten()
129 }
130
131 fn set(&self, key: &str, value: &str) -> Result<(), PreferencesError> {
132 Self::storage()?.set_item(key, value).map_err(storage_error)
133 }
134
135 fn remove(&self, key: &str) -> Result<(), PreferencesError> {
136 Self::storage()?.remove_item(key).map_err(storage_error)
137 }
138
139 fn keys(&self) -> Vec<String> {
140 let Ok(storage) = Self::storage() else {
141 return Vec::new();
142 };
143 let count = storage.length().unwrap_or(0);
144 let mut keys: Vec<String> = (0..count)
145 .filter_map(|index| storage.key(index).ok().flatten())
146 .collect();
147 keys.sort();
148 keys
149 }
150
151 fn clear(&self) -> Result<(), PreferencesError> {
152 Self::storage()?.clear().map_err(storage_error)
153 }
154}
155
156#[derive(Default)]
159pub struct MemoryPreferences {
160 entries: Mutex<BTreeMap<String, String>>,
161}
162
163impl MemoryPreferences {
164 pub fn new() -> Self {
166 Self::default()
167 }
168}
169
170impl PreferencesStore for MemoryPreferences {
171 fn get(&self, key: &str) -> Option<String> {
172 self.entries
173 .lock()
174 .ok()
175 .and_then(|entries| entries.get(key).cloned())
176 }
177
178 fn set(&self, key: &str, value: &str) -> Result<(), PreferencesError> {
179 let mut entries = self
180 .entries
181 .lock()
182 .map_err(|_| PreferencesError::Io("preferences lock poisoned".into()))?;
183 entries.insert(key.to_string(), value.to_string());
184 Ok(())
185 }
186
187 fn remove(&self, key: &str) -> Result<(), PreferencesError> {
188 let mut entries = self
189 .entries
190 .lock()
191 .map_err(|_| PreferencesError::Io("preferences lock poisoned".into()))?;
192 entries.remove(key);
193 Ok(())
194 }
195
196 fn keys(&self) -> Vec<String> {
197 self.entries
198 .lock()
199 .map(|entries| entries.keys().cloned().collect())
200 .unwrap_or_default()
201 }
202
203 fn clear(&self) -> Result<(), PreferencesError> {
204 let mut entries = self
205 .entries
206 .lock()
207 .map_err(|_| PreferencesError::Io("preferences lock poisoned".into()))?;
208 entries.clear();
209 Ok(())
210 }
211}
212
213#[cfg(not(target_arch = "wasm32"))]
216pub struct FilePreferences {
217 entries: Mutex<Option<BTreeMap<String, String>>>,
218}
219
220#[cfg(not(target_arch = "wasm32"))]
221impl Default for FilePreferences {
222 fn default() -> Self {
223 Self::new()
224 }
225}
226
227#[cfg(not(target_arch = "wasm32"))]
228impl FilePreferences {
229 pub fn new() -> Self {
231 Self {
232 entries: Mutex::new(None),
233 }
234 }
235
236 fn path() -> Result<std::path::PathBuf, PreferencesError> {
237 let directories =
238 application_directories().map_err(|error| PreferencesError::Io(error.to_string()))?;
239 Ok(directories.config.join("preferences"))
240 }
241
242 fn with_entries<T>(
243 &self,
244 body: impl FnOnce(&mut BTreeMap<String, String>) -> T,
245 ) -> Result<T, PreferencesError> {
246 let mut slot = self
247 .entries
248 .lock()
249 .map_err(|_| PreferencesError::Io("preferences lock poisoned".into()))?;
250 if slot.is_none() {
251 *slot = Some(Self::load()?);
252 }
253 let entries = slot
254 .as_mut()
255 .ok_or_else(|| PreferencesError::Io("preferences were not loaded".into()))?;
256 Ok(body(entries))
257 }
258
259 fn load() -> Result<BTreeMap<String, String>, PreferencesError> {
260 let path = Self::path()?;
261 let text = match std::fs::read_to_string(&path) {
262 Ok(text) => text,
263 Err(error) if error.kind() == std::io::ErrorKind::NotFound => String::new(),
264 Err(error) => return Err(PreferencesError::Io(error.to_string())),
265 };
266 Ok(parse(&text))
267 }
268
269 fn store(entries: &BTreeMap<String, String>) -> Result<(), PreferencesError> {
270 let path = Self::path()?;
271 if let Some(parent) = path.parent() {
272 std::fs::create_dir_all(parent).map_err(|e| PreferencesError::Io(e.to_string()))?;
273 }
274 let staging = path.with_extension("partial");
275 std::fs::write(&staging, encode(entries))
276 .map_err(|error| PreferencesError::Io(error.to_string()))?;
277 std::fs::rename(&staging, &path).map_err(|error| PreferencesError::Io(error.to_string()))
278 }
279
280 fn mutate(
281 &self,
282 body: impl FnOnce(&mut BTreeMap<String, String>),
283 ) -> Result<(), PreferencesError> {
284 let snapshot = self.with_entries(|entries| {
285 body(entries);
286 entries.clone()
287 })?;
288 Self::store(&snapshot)
289 }
290}
291
292#[cfg(not(target_arch = "wasm32"))]
293impl PreferencesStore for FilePreferences {
294 fn get(&self, key: &str) -> Option<String> {
295 self.with_entries(|entries| entries.get(key).cloned())
296 .ok()
297 .flatten()
298 }
299
300 fn set(&self, key: &str, value: &str) -> Result<(), PreferencesError> {
301 self.mutate(|entries| {
302 entries.insert(key.to_string(), value.to_string());
303 })
304 }
305
306 fn remove(&self, key: &str) -> Result<(), PreferencesError> {
307 self.mutate(|entries| {
308 entries.remove(key);
309 })
310 }
311
312 fn keys(&self) -> Vec<String> {
313 self.with_entries(|entries| entries.keys().cloned().collect())
314 .unwrap_or_default()
315 }
316
317 fn clear(&self) -> Result<(), PreferencesError> {
318 self.mutate(|entries| entries.clear())
319 }
320}
321
322#[cfg(not(target_arch = "wasm32"))]
324fn encode(entries: &BTreeMap<String, String>) -> String {
325 let mut text = String::new();
326 for (key, value) in entries {
327 text.push_str(&escape(key));
328 text.push('=');
329 text.push_str(&escape(value));
330 text.push('\n');
331 }
332 text
333}
334
335#[cfg(not(target_arch = "wasm32"))]
336fn parse(text: &str) -> BTreeMap<String, String> {
337 text.lines()
338 .filter_map(|line| {
339 let (key, value) = line.split_once('=')?;
340 Some((unescape(key), unescape(value)))
341 })
342 .collect()
343}
344
345#[cfg(not(target_arch = "wasm32"))]
346fn escape(value: &str) -> String {
347 let mut out = String::with_capacity(value.len());
348 for character in value.chars() {
349 match character {
350 '%' => out.push_str("%25"),
351 '=' => out.push_str("%3D"),
352 '\n' => out.push_str("%0A"),
353 '\r' => out.push_str("%0D"),
354 other => out.push(other),
355 }
356 }
357 out
358}
359
360#[cfg(not(target_arch = "wasm32"))]
361fn unescape(value: &str) -> String {
362 let mut out = String::with_capacity(value.len());
363 let mut characters = value.chars();
364 while let Some(character) = characters.next() {
365 if character != '%' {
366 out.push(character);
367 continue;
368 }
369 let high = characters.next();
370 let low = characters.next();
371 match (high, low) {
372 (Some(high), Some(low)) => match u8::from_str_radix(&format!("{high}{low}"), 16) {
373 Ok(byte) => out.push(byte as char),
374 Err(_) => {
375 out.push('%');
376 out.push(high);
377 out.push(low);
378 }
379 },
380 _ => out.push('%'),
381 }
382 }
383 out
384}
385
386pub struct Saver<T> {
393 save: SaveFn<T>,
394 restore: RestoreFn<T>,
395}
396
397type SaveFn<T> = Box<dyn Fn(&T) -> String + 'static>;
399type RestoreFn<T> = Box<dyn Fn(&str) -> Option<T> + 'static>;
401
402impl<T> Saver<T> {
403 pub fn new(
405 save: impl Fn(&T) -> String + 'static,
406 restore: impl Fn(&str) -> Option<T> + 'static,
407 ) -> Self {
408 Self {
409 save: Box::new(save),
410 restore: Box::new(restore),
411 }
412 }
413
414 pub fn save(&self, value: &T) -> String {
416 (self.save)(value)
417 }
418
419 pub fn restore(&self, stored: &str) -> Option<T> {
422 (self.restore)(stored)
423 }
424}
425
426impl<T> Saver<T>
427where
428 T: std::fmt::Display + std::str::FromStr + 'static,
429{
430 pub fn of_display() -> Self {
433 Self::new(
434 |value: &T| value.to_string(),
435 |stored: &str| stored.parse::<T>().ok(),
436 )
437 }
438}
439
440#[allow(non_snake_case)]
445pub fn rememberSaveable<T>(
446 key: &'static str,
447 saver: Saver<T>,
448 initial: impl FnOnce() -> T,
449) -> cranpose_core::MutableState<T>
450where
451 T: Clone + 'static,
452{
453 let store = preferences();
454 let restored = store
455 .get(key)
456 .and_then(|stored| saver.restore(&stored))
457 .unwrap_or_else(initial);
458 let state = cranpose_core::remember(|| cranpose_core::mutableStateOf(restored)).with(|s| *s);
459
460 let saved = cranpose_core::remember(|| std::cell::RefCell::new(Option::<String>::None));
461 let stored = saver.save(&state.get());
462 saved.with(|slot| {
463 let mut slot = slot.borrow_mut();
464 if slot.as_deref() != Some(stored.as_str()) {
465 if let Err(error) = store.set(key, &stored) {
466 log::warn!("cranpose: could not store `{key}`: {error}");
467 }
468 *slot = Some(stored);
469 }
470 });
471 state
472}
473
474#[cfg(test)]
475mod tests {
476 use super::*;
477
478 #[test]
479 fn memory_preferences_round_trip() {
480 let store = MemoryPreferences::new();
481 assert!(store.get("theme").is_none());
482 store.set("theme", "dark").expect("set");
483 assert_eq!(store.get("theme").as_deref(), Some("dark"));
484 assert_eq!(store.keys(), vec!["theme".to_string()]);
485 store.remove("theme").expect("remove");
486 assert!(store.keys().is_empty());
487 }
488
489 #[cfg(not(target_arch = "wasm32"))]
490 #[test]
491 fn encoding_round_trips_separators_and_escapes() {
492 let mut entries = BTreeMap::new();
493 entries.insert("a=b".to_string(), "line1\nline2".to_string());
494 entries.insert("percent".to_string(), "100%".to_string());
495 let text = encode(&entries);
496 assert!(!text.trim_end().contains('\n') || text.lines().count() == 2);
497 assert_eq!(parse(&text), entries);
498 }
499
500 #[cfg(not(target_arch = "wasm32"))]
501 #[test]
502 fn a_corrupt_entry_falls_back_to_the_raw_text() {
503 assert_eq!(unescape("50%"), "50%");
504 assert_eq!(unescape("%ZZ"), "%ZZ");
505 }
506
507 #[test]
508 fn display_savers_round_trip_and_reject_junk() {
509 let saver = Saver::<u32>::of_display();
510 assert_eq!(saver.save(&42), "42");
511 assert_eq!(saver.restore("42"), Some(42));
512 assert_eq!(saver.restore("not a number"), None);
513 }
514
515 #[test]
516 fn a_custom_saver_states_its_own_stored_form() {
517 let saver = Saver::new(
518 |value: &Vec<u8>| {
519 value
520 .iter()
521 .map(u8::to_string)
522 .collect::<Vec<_>>()
523 .join(",")
524 },
525 |stored: &str| {
526 stored
527 .split(',')
528 .filter(|part| !part.is_empty())
529 .map(|part| part.parse().ok())
530 .collect()
531 },
532 );
533 assert_eq!(saver.save(&vec![1, 2, 3]), "1,2,3");
534 assert_eq!(saver.restore("1,2,3"), Some(vec![1, 2, 3]));
535 assert_eq!(saver.restore("1,x"), None);
536 }
537}