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(std::collections::BTreeMap::clear)
322 }
323}
324
325#[cfg(not(target_arch = "wasm32"))]
326fn encode(entries: &BTreeMap<String, String>) -> String {
327 let mut text = String::new();
328 for (key, value) in entries {
329 text.push_str(&escape(key));
330 text.push('=');
331 text.push_str(&escape(value));
332 text.push('\n');
333 }
334 text
335}
336
337#[cfg(not(target_arch = "wasm32"))]
338fn parse(text: &str) -> BTreeMap<String, String> {
339 text.lines()
340 .filter_map(|line| {
341 let (key, value) = line.split_once('=')?;
342 Some((unescape(key), unescape(value)))
343 })
344 .collect()
345}
346
347#[cfg(not(target_arch = "wasm32"))]
348fn escape(value: &str) -> String {
349 let mut out = String::with_capacity(value.len());
350 for character in value.chars() {
351 match character {
352 '%' => out.push_str("%25"),
353 '=' => out.push_str("%3D"),
354 '\n' => out.push_str("%0A"),
355 '\r' => out.push_str("%0D"),
356 other => out.push(other),
357 }
358 }
359 out
360}
361
362#[cfg(not(target_arch = "wasm32"))]
363fn unescape(value: &str) -> String {
364 let mut out = String::with_capacity(value.len());
365 let mut characters = value.chars();
366 while let Some(character) = characters.next() {
367 if character != '%' {
368 out.push(character);
369 continue;
370 }
371 let high = characters.next();
372 let low = characters.next();
373 match (high, low) {
374 (Some(high), Some(low)) => match u8::from_str_radix(&format!("{high}{low}"), 16) {
375 Ok(byte) => out.push(byte as char),
376 Err(_) => {
377 out.push('%');
378 out.push(high);
379 out.push(low);
380 }
381 },
382 _ => out.push('%'),
383 }
384 }
385 out
386}
387
388pub struct Saver<T> {
393 save: SaveFn<T>,
394 restore: RestoreFn<T>,
395}
396
397type SaveFn<T> = Box<dyn Fn(&T) -> String + 'static>;
398type RestoreFn<T> = Box<dyn Fn(&str) -> Option<T> + 'static>;
399
400impl<T> Saver<T> {
401 pub fn new(
403 save: impl Fn(&T) -> String + 'static,
404 restore: impl Fn(&str) -> Option<T> + 'static,
405 ) -> Self {
406 Self {
407 save: Box::new(save),
408 restore: Box::new(restore),
409 }
410 }
411
412 pub fn save(&self, value: &T) -> String {
414 (self.save)(value)
415 }
416
417 pub fn restore(&self, stored: &str) -> Option<T> {
420 (self.restore)(stored)
421 }
422}
423
424impl<T> Saver<T>
425where
426 T: std::fmt::Display + std::str::FromStr + 'static,
427{
428 pub fn of_display() -> Self {
431 Self::new(
432 |value: &T| value.to_string(),
433 |stored: &str| stored.parse::<T>().ok(),
434 )
435 }
436}
437
438#[allow(non_snake_case)]
443#[track_caller]
444pub fn rememberSaveable<T>(
445 key: &'static str,
446 saver: Saver<T>,
447 initial: impl FnOnce() -> T,
448) -> cranpose_core::MutableState<T>
449where
450 T: Clone + 'static,
451{
452 let store = preferences();
453 let restored = store
454 .get(key)
455 .and_then(|stored| saver.restore(&stored))
456 .unwrap_or_else(initial);
457 let state =
458 cranpose_core::remember(|| cranpose_core::mutableStateOfNeverEqual(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}