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