Skip to main content

cranpose_services/
preferences.rs

1//! Small durable key/value storage, and the composition state built on it.
2//!
3//! Preferences are for the handful of values an application must remember
4//! across launches — the chosen theme, the last opened document, a sync folder
5//! handle. They are read and written synchronously and are thread-safe, so a
6//! worker can persist without hopping to the UI thread.
7//!
8//! On top of the store sits [`rememberSaveable`], the state that survives host
9//! recreation and process death. It stores through a [`Saver`], so a type that
10//! is not a string still has one obvious, testable way to become one.
11
12use 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/// Errors produced by a preferences backend.
22#[derive(thiserror::Error, Debug, Clone, PartialEq, Eq)]
23pub enum PreferencesError {
24    /// The backing store could not be read or written.
25    #[error("preferences storage failed: {0}")]
26    Io(String),
27    /// No durable storage is available on this platform or build.
28    #[error("preferences are not available on this platform")]
29    Unavailable,
30}
31
32/// Durable key/value storage owned by the application.
33///
34/// Implementations are `Send + Sync`: preferences are written from wherever the
35/// decision is made, including a worker thread.
36pub trait PreferencesStore: Send + Sync {
37    /// Reads `key`, or `None` when it has never been written.
38    fn get(&self, key: &str) -> Option<String>;
39
40    /// Writes `key`.
41    fn set(&self, key: &str, value: &str) -> Result<(), PreferencesError>;
42
43    /// Removes `key`. Succeeds if it is already absent.
44    fn remove(&self, key: &str) -> Result<(), PreferencesError>;
45
46    /// Every key currently stored, in sorted order.
47    fn keys(&self) -> Vec<String>;
48
49    /// Removes everything.
50    fn clear(&self) -> Result<(), PreferencesError>;
51}
52
53/// Shared handle to a [`PreferencesStore`].
54pub type PreferencesRef = Arc<dyn PreferencesStore>;
55
56static PLATFORM_PREFERENCES: ServiceRegistry<dyn PreferencesStore> = ServiceRegistry::new();
57
58/// Installs the platform preferences backend, replacing any previous one.
59pub fn set_platform_preferences(store: PreferencesRef) {
60    PLATFORM_PREFERENCES.set(store);
61}
62
63/// Removes any installed platform backend (tests and teardown).
64pub fn clear_platform_preferences() {
65    PLATFORM_PREFERENCES.clear();
66}
67
68/// The active preferences store: the platform backend if one is installed,
69/// otherwise the framework's own file-backed store under the application's
70/// config directory.
71pub 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/// The browser's `localStorage`, which is where preferences outlive a reload.
103///
104/// Holds no handle of its own: `localStorage` is reached through the window each
105/// call, so the store is `Send + Sync` like every other backend even though the
106/// object it talks to is not.
107#[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/// An in-memory store. The web default until a platform backend registers, and
160/// what tests use.
161#[derive(Default)]
162pub struct MemoryPreferences {
163    entries: Mutex<BTreeMap<String, String>>,
164}
165
166impl MemoryPreferences {
167    /// An empty store.
168    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/// The framework's file-backed store: one line per entry, `key=value` with the
217/// value percent-escaped so newlines and equals signs round-trip.
218#[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    /// A store that loads lazily from the application's config directory.
233    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
388/// Converts a value to and from the string form preferences store.
389///
390/// A saver is deliberately explicit rather than derived: the stored form is a
391/// compatibility surface, and the framework will not guess it for you.
392pub 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    /// Builds a saver from a pair of conversions.
402    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    /// The stored form of `value`.
413    pub fn save(&self, value: &T) -> String {
414        (self.save)(value)
415    }
416
417    /// The value `stored` represents, or `None` when it cannot be read — a
418    /// stored form written by an older build, or a corrupted entry.
419    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    /// The saver for a type that already round-trips through `Display` and
429    /// `FromStr` — numbers, booleans, strings, enums with a parse impl.
430    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/// State that survives host recreation and process death, stored under `key`.
439///
440/// Reads restore through the saver on first composition; every write is stored
441/// immediately, so nothing is lost to a process the OS kills without warning.
442#[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}