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
12#[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/// Errors produced by a preferences backend.
19#[derive(thiserror::Error, Debug, Clone, PartialEq, Eq)]
20pub enum PreferencesError {
21    /// The backing store could not be read or written.
22    #[error("preferences storage failed: {0}")]
23    Io(String),
24    /// No durable storage is available on this platform or build.
25    #[error("preferences are not available on this platform")]
26    Unavailable,
27}
28
29/// Durable key/value storage owned by the application.
30///
31/// Implementations are `Send + Sync`: preferences are written from wherever the
32/// decision is made, including a worker thread.
33pub trait PreferencesStore: Send + Sync {
34    /// Reads `key`, or `None` when it has never been written.
35    fn get(&self, key: &str) -> Option<String>;
36
37    /// Writes `key`.
38    fn set(&self, key: &str, value: &str) -> Result<(), PreferencesError>;
39
40    /// Removes `key`. Succeeds if it is already absent.
41    fn remove(&self, key: &str) -> Result<(), PreferencesError>;
42
43    /// Every key currently stored, in sorted order.
44    fn keys(&self) -> Vec<String>;
45
46    /// Removes everything.
47    fn clear(&self) -> Result<(), PreferencesError>;
48}
49
50/// Shared handle to a [`PreferencesStore`].
51pub type PreferencesRef = Arc<dyn PreferencesStore>;
52
53static PLATFORM_PREFERENCES: ServiceRegistry<dyn PreferencesStore> = ServiceRegistry::new();
54
55/// Installs the platform preferences backend, replacing any previous one.
56pub fn set_platform_preferences(store: PreferencesRef) {
57    PLATFORM_PREFERENCES.set(store);
58}
59
60/// Removes any installed platform backend (tests and teardown).
61pub fn clear_platform_preferences() {
62    PLATFORM_PREFERENCES.clear();
63}
64
65/// The active preferences store: the platform backend if one is installed,
66/// otherwise the framework's own file-backed store under the application's
67/// config directory.
68pub 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/// The browser's `localStorage`, which is where preferences outlive a reload.
100///
101/// Holds no handle of its own: `localStorage` is reached through the window each
102/// call, so the store is `Send + Sync` like every other backend even though the
103/// object it talks to is not.
104#[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/// An in-memory store. The web default until a platform backend registers, and
157/// what tests use.
158#[derive(Default)]
159pub struct MemoryPreferences {
160    entries: Mutex<BTreeMap<String, String>>,
161}
162
163impl MemoryPreferences {
164    /// An empty store.
165    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/// The framework's file-backed store: one line per entry, `key=value` with the
214/// value percent-escaped so newlines and equals signs round-trip.
215#[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    /// A store that loads lazily from the application's config directory.
230    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/// Encodes entries as `key=value` lines, escaping the separators.
323#[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
386// ---- Saveable state ------------------------------------------------------
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
397/// Turns a value into its stored form.
398type SaveFn<T> = Box<dyn Fn(&T) -> String + 'static>;
399/// Reads a value back out of its stored form.
400type RestoreFn<T> = Box<dyn Fn(&str) -> Option<T> + 'static>;
401
402impl<T> Saver<T> {
403    /// Builds a saver from a pair of conversions.
404    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    /// The stored form of `value`.
415    pub fn save(&self, value: &T) -> String {
416        (self.save)(value)
417    }
418
419    /// The value `stored` represents, or `None` when it cannot be read — a
420    /// stored form written by an older build, or a corrupted entry.
421    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    /// The saver for a type that already round-trips through `Display` and
431    /// `FromStr` — numbers, booleans, strings, enums with a parse impl.
432    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/// State that survives host recreation and process death, stored under `key`.
441///
442/// Reads restore through the saver on first composition; every write is stored
443/// immediately, so nothing is lost to a process the OS kills without warning.
444#[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}