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(|entries| entries.clear())
322    }
323}
324
325/// Encodes entries as `key=value` lines, escaping the separators.
326#[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
389// ---- Saveable state ------------------------------------------------------
390
391/// Converts a value to and from the string form preferences store.
392///
393/// A saver is deliberately explicit rather than derived: the stored form is a
394/// compatibility surface, and the framework will not guess it for you.
395pub struct Saver<T> {
396    save: SaveFn<T>,
397    restore: RestoreFn<T>,
398}
399
400/// Turns a value into its stored form.
401type SaveFn<T> = Box<dyn Fn(&T) -> String + 'static>;
402/// Reads a value back out of its stored form.
403type RestoreFn<T> = Box<dyn Fn(&str) -> Option<T> + 'static>;
404
405impl<T> Saver<T> {
406    /// Builds a saver from a pair of conversions.
407    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    /// The stored form of `value`.
418    pub fn save(&self, value: &T) -> String {
419        (self.save)(value)
420    }
421
422    /// The value `stored` represents, or `None` when it cannot be read — a
423    /// stored form written by an older build, or a corrupted entry.
424    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    /// The saver for a type that already round-trips through `Display` and
434    /// `FromStr` — numbers, booleans, strings, enums with a parse impl.
435    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/// State that survives host recreation and process death, stored under `key`.
444///
445/// Reads restore through the saver on first composition; every write is stored
446/// immediately, so nothing is lost to a process the OS kills without warning.
447#[allow(non_snake_case)]
448#[track_caller]
449pub fn rememberSaveable<T>(
450    key: &'static str,
451    saver: Saver<T>,
452    initial: impl FnOnce() -> T,
453) -> cranpose_core::MutableState<T>
454where
455    T: Clone + 'static,
456{
457    let store = preferences();
458    let restored = store
459        .get(key)
460        .and_then(|stored| saver.restore(&stored))
461        .unwrap_or_else(initial);
462    let state = cranpose_core::remember(|| cranpose_core::mutableStateOf(restored)).with(|s| *s);
463
464    let saved = cranpose_core::remember(|| std::cell::RefCell::new(Option::<String>::None));
465    let stored = saver.save(&state.get());
466    saved.with(|slot| {
467        let mut slot = slot.borrow_mut();
468        if slot.as_deref() != Some(stored.as_str()) {
469            if let Err(error) = store.set(key, &stored) {
470                log::warn!("cranpose: could not store `{key}`: {error}");
471            }
472            *slot = Some(stored);
473        }
474    });
475    state
476}
477
478#[cfg(test)]
479mod tests {
480    use super::*;
481
482    #[test]
483    fn memory_preferences_round_trip() {
484        let store = MemoryPreferences::new();
485        assert!(store.get("theme").is_none());
486        store.set("theme", "dark").expect("set");
487        assert_eq!(store.get("theme").as_deref(), Some("dark"));
488        assert_eq!(store.keys(), vec!["theme".to_string()]);
489        store.remove("theme").expect("remove");
490        assert!(store.keys().is_empty());
491    }
492
493    #[cfg(not(target_arch = "wasm32"))]
494    #[test]
495    fn encoding_round_trips_separators_and_escapes() {
496        let mut entries = BTreeMap::new();
497        entries.insert("a=b".to_string(), "line1\nline2".to_string());
498        entries.insert("percent".to_string(), "100%".to_string());
499        let text = encode(&entries);
500        assert!(!text.trim_end().contains('\n') || text.lines().count() == 2);
501        assert_eq!(parse(&text), entries);
502    }
503
504    #[cfg(not(target_arch = "wasm32"))]
505    #[test]
506    fn a_corrupt_entry_falls_back_to_the_raw_text() {
507        assert_eq!(unescape("50%"), "50%");
508        assert_eq!(unescape("%ZZ"), "%ZZ");
509    }
510
511    #[test]
512    fn display_savers_round_trip_and_reject_junk() {
513        let saver = Saver::<u32>::of_display();
514        assert_eq!(saver.save(&42), "42");
515        assert_eq!(saver.restore("42"), Some(42));
516        assert_eq!(saver.restore("not a number"), None);
517    }
518
519    #[test]
520    fn a_custom_saver_states_its_own_stored_form() {
521        let saver = Saver::new(
522            |value: &Vec<u8>| {
523                value
524                    .iter()
525                    .map(u8::to_string)
526                    .collect::<Vec<_>>()
527                    .join(",")
528            },
529            |stored: &str| {
530                stored
531                    .split(',')
532                    .filter(|part| !part.is_empty())
533                    .map(|part| part.parse().ok())
534                    .collect()
535            },
536        );
537        assert_eq!(saver.save(&vec![1, 2, 3]), "1,2,3");
538        assert_eq!(saver.restore("1,2,3"), Some(vec![1, 2, 3]));
539        assert_eq!(saver.restore("1,x"), None);
540    }
541}