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    rc::Rc,
15    sync::{Arc, Mutex, OnceLock},
16};
17
18use cranpose_core::collections::map::HashMap;
19
20#[cfg(not(target_arch = "wasm32"))]
21mod file;
22#[cfg(not(target_arch = "wasm32"))]
23pub use file::FilePreferences;
24
25use crate::registry::ServiceRegistry;
26
27/// Errors produced by a preferences backend.
28#[derive(thiserror::Error, Debug, Clone, PartialEq, Eq)]
29pub enum PreferencesError {
30    /// The backing store could not be read or written.
31    #[error("preferences storage failed: {0}")]
32    Io(String),
33    /// No durable storage is available on this platform or build.
34    #[error("preferences are not available on this platform")]
35    Unavailable,
36}
37
38/// Durable key/value storage owned by the application.
39///
40/// Implementations are `Send + Sync`: preferences are written from wherever the
41/// decision is made, including a worker thread.
42pub trait PreferencesStore: Send + Sync {
43    /// Reads `key`, or `None` when it has never been written.
44    fn get(&self, key: &str) -> Option<String>;
45
46    /// Writes `key`.
47    fn set(&self, key: &str, value: &str) -> Result<(), PreferencesError>;
48
49    /// Removes `removed`, then writes `values`, as one group. File-backed
50    /// stores replace the file once. A failure may leave pending changes
51    /// visible; retry this batch or call `flush`.
52    fn update_many(
53        &self,
54        values: &BTreeMap<String, String>,
55        removed: &[String],
56    ) -> Result<(), PreferencesError> {
57        for key in removed {
58            self.remove(key)?;
59        }
60        for (key, value) in values {
61            self.set(key, value)?;
62        }
63        Ok(())
64    }
65
66    /// Retries pending writes. Success means the backend accepted all pending data.
67    fn flush(&self) -> Result<(), PreferencesError> {
68        Ok(())
69    }
70
71    /// Removes `key`. Succeeds if it is already absent.
72    fn remove(&self, key: &str) -> Result<(), PreferencesError>;
73
74    /// Every key currently stored, in sorted order.
75    fn keys(&self) -> Vec<String>;
76
77    /// Removes everything.
78    fn clear(&self) -> Result<(), PreferencesError>;
79}
80
81/// Shared handle to a [`PreferencesStore`].
82pub type PreferencesRef = Arc<dyn PreferencesStore>;
83
84static PLATFORM_PREFERENCES: ServiceRegistry<dyn PreferencesStore> = ServiceRegistry::new();
85
86/// Installs the platform preferences backend, replacing any previous one.
87pub fn set_platform_preferences(store: PreferencesRef) {
88    PLATFORM_PREFERENCES.set(store);
89}
90
91/// Removes any installed platform backend (tests and teardown).
92pub fn clear_platform_preferences() {
93    PLATFORM_PREFERENCES.clear();
94}
95
96/// The active preferences store: the platform backend if one is installed,
97/// otherwise the framework's own file-backed store under the application's
98/// config directory.
99pub fn preferences() -> PreferencesRef {
100    if let Some(store) = PLATFORM_PREFERENCES.get() {
101        return store;
102    }
103    default_preferences()
104}
105
106#[cfg(not(target_arch = "wasm32"))]
107fn default_preferences() -> PreferencesRef {
108    static DEFAULT: OnceLock<PreferencesRef> = OnceLock::new();
109    Arc::clone(DEFAULT.get_or_init(|| Arc::new(FilePreferences::new()) as PreferencesRef))
110}
111
112#[cfg(all(target_arch = "wasm32", feature = "preferences-web"))]
113fn default_preferences() -> PreferencesRef {
114    static DEFAULT: OnceLock<PreferencesRef> = OnceLock::new();
115    Arc::clone(DEFAULT.get_or_init(|| Arc::new(BrowserPreferences) as PreferencesRef))
116}
117
118#[cfg(all(target_arch = "wasm32", not(feature = "preferences-web")))]
119fn default_preferences() -> PreferencesRef {
120    static DEFAULT: OnceLock<PreferencesRef> = OnceLock::new();
121    Arc::clone(DEFAULT.get_or_init(|| Arc::new(MemoryPreferences::default()) as PreferencesRef))
122}
123
124/// The browser's `localStorage`, which is where preferences outlive a reload.
125///
126/// Holds no handle of its own: `localStorage` is reached through the window each
127/// call, so the store is `Send + Sync` like every other backend even though the
128/// object it talks to is not.
129#[cfg(all(target_arch = "wasm32", feature = "preferences-web"))]
130pub struct BrowserPreferences;
131
132#[cfg(all(target_arch = "wasm32", feature = "preferences-web"))]
133impl BrowserPreferences {
134    fn storage() -> Result<web_sys::Storage, PreferencesError> {
135        web_sys::window()
136            .and_then(|window| window.local_storage().ok().flatten())
137            .ok_or(PreferencesError::Unavailable)
138    }
139}
140
141#[cfg(all(target_arch = "wasm32", feature = "preferences-web"))]
142fn storage_error(value: wasm_bindgen::JsValue) -> PreferencesError {
143    PreferencesError::Io(
144        value
145            .as_string()
146            .unwrap_or_else(|| "localStorage rejected the operation".to_string()),
147    )
148}
149
150#[cfg(all(target_arch = "wasm32", feature = "preferences-web"))]
151impl PreferencesStore for BrowserPreferences {
152    fn get(&self, key: &str) -> Option<String> {
153        Self::storage().ok()?.get_item(key).ok().flatten()
154    }
155
156    fn set(&self, key: &str, value: &str) -> Result<(), PreferencesError> {
157        Self::storage()?.set_item(key, value).map_err(storage_error)
158    }
159
160    fn remove(&self, key: &str) -> Result<(), PreferencesError> {
161        Self::storage()?.remove_item(key).map_err(storage_error)
162    }
163
164    fn keys(&self) -> Vec<String> {
165        let Ok(storage) = Self::storage() else {
166            return Vec::new();
167        };
168        let count = storage.length().unwrap_or(0);
169        let mut keys: Vec<String> = (0..count)
170            .filter_map(|index| storage.key(index).ok().flatten())
171            .collect();
172        keys.sort();
173        keys
174    }
175
176    fn clear(&self) -> Result<(), PreferencesError> {
177        Self::storage()?.clear().map_err(storage_error)
178    }
179}
180
181/// An in-memory store. The web default until a platform backend registers, and
182/// what tests use.
183#[derive(Default)]
184pub struct MemoryPreferences {
185    entries: Mutex<BTreeMap<String, String>>,
186}
187
188impl MemoryPreferences {
189    /// An empty store.
190    pub fn new() -> Self {
191        Self::default()
192    }
193}
194
195impl PreferencesStore for MemoryPreferences {
196    fn get(&self, key: &str) -> Option<String> {
197        self.entries
198            .lock()
199            .ok()
200            .and_then(|entries| entries.get(key).cloned())
201    }
202
203    fn set(&self, key: &str, value: &str) -> Result<(), PreferencesError> {
204        let mut entries = self
205            .entries
206            .lock()
207            .map_err(|_| PreferencesError::Io("preferences lock poisoned".into()))?;
208        entries.insert(key.to_string(), value.to_string());
209        Ok(())
210    }
211
212    fn remove(&self, key: &str) -> Result<(), PreferencesError> {
213        let mut entries = self
214            .entries
215            .lock()
216            .map_err(|_| PreferencesError::Io("preferences lock poisoned".into()))?;
217        entries.remove(key);
218        Ok(())
219    }
220
221    fn keys(&self) -> Vec<String> {
222        self.entries
223            .lock()
224            .map(|entries| entries.keys().cloned().collect())
225            .unwrap_or_default()
226    }
227
228    fn clear(&self) -> Result<(), PreferencesError> {
229        let mut entries = self
230            .entries
231            .lock()
232            .map_err(|_| PreferencesError::Io("preferences lock poisoned".into()))?;
233        entries.clear();
234        Ok(())
235    }
236}
237
238#[cfg(not(target_arch = "wasm32"))]
239fn encode(entries: &BTreeMap<String, String>) -> String {
240    let mut text = String::new();
241    for (key, value) in entries {
242        escape_into(key, &mut text);
243        text.push('=');
244        escape_into(value, &mut text);
245        text.push('\n');
246    }
247    text
248}
249
250#[cfg(not(target_arch = "wasm32"))]
251fn parse(text: &str) -> BTreeMap<String, String> {
252    text.lines()
253        .filter_map(|line| {
254            let (key, value) = line.split_once('=')?;
255            Some((unescape(key), unescape(value)))
256        })
257        .collect()
258}
259
260#[cfg(not(target_arch = "wasm32"))]
261fn escape_into(value: &str, out: &mut String) {
262    for character in value.chars() {
263        match character {
264            '%' => out.push_str("%25"),
265            '=' => out.push_str("%3D"),
266            '\n' => out.push_str("%0A"),
267            '\r' => out.push_str("%0D"),
268            other => out.push(other),
269        }
270    }
271}
272
273#[cfg(not(target_arch = "wasm32"))]
274fn unescape(value: &str) -> String {
275    let mut out = String::with_capacity(value.len());
276    let mut characters = value.chars();
277    while let Some(character) = characters.next() {
278        if character != '%' {
279            out.push(character);
280            continue;
281        }
282        let high = characters.next();
283        let low = characters.next();
284        match (high, low) {
285            (Some(high), Some(low)) => match u8::from_str_radix(&format!("{high}{low}"), 16) {
286                Ok(byte) => out.push(byte as char),
287                Err(_) => {
288                    out.push('%');
289                    out.push(high);
290                    out.push(low);
291                }
292            },
293            _ => out.push('%'),
294        }
295    }
296    out
297}
298
299/// Converts a value to and from the string form preferences store.
300///
301/// A saver is deliberately explicit rather than derived: the stored form is a
302/// compatibility surface, and the framework will not guess it for you.
303pub struct Saver<T> {
304    save: SaveFn<T>,
305    restore: RestoreFn<T>,
306}
307
308type SaveFn<T> = Box<dyn Fn(&T) -> String + 'static>;
309type RestoreFn<T> = Box<dyn Fn(&str) -> Option<T> + 'static>;
310
311impl<T> Saver<T> {
312    /// Builds a saver from a pair of conversions.
313    pub fn new(
314        save: impl Fn(&T) -> String + 'static,
315        restore: impl Fn(&str) -> Option<T> + 'static,
316    ) -> Self {
317        Self {
318            save: Box::new(save),
319            restore: Box::new(restore),
320        }
321    }
322
323    /// The stored form of `value`.
324    pub fn save(&self, value: &T) -> String {
325        (self.save)(value)
326    }
327
328    /// The value `stored` represents, or `None` when it cannot be read — a
329    /// stored form written by an older build, or a corrupted entry.
330    pub fn restore(&self, stored: &str) -> Option<T> {
331        (self.restore)(stored)
332    }
333}
334
335impl<T: 'static> Saver<T> {
336    /// A saver that stores a value as the list of strings `save` gives —
337    /// Compose's `listSaver`. Each string may hold any text, the stored form
338    /// of another [`Saver`] too. `restore` gets the strings back in order.
339    pub fn list(
340        save: impl Fn(&T) -> Vec<String> + 'static,
341        restore: impl Fn(&[String]) -> Option<T> + 'static,
342    ) -> Self {
343        Self::new(
344            move |value| encode_list(&save(value)),
345            move |stored| restore(&decode_list(stored)?),
346        )
347    }
348
349    /// A saver that stores a value as the named strings `save` gives —
350    /// Compose's `mapSaver`. `restore` gets them back by name; a name given
351    /// twice keeps its last string.
352    pub fn map(
353        save: impl Fn(&T) -> Vec<(String, String)> + 'static,
354        restore: impl Fn(&HashMap<String, String>) -> Option<T> + 'static,
355    ) -> Self {
356        Self::new(
357            move |value| {
358                let pairs = save(value);
359                let mut items = Vec::with_capacity(pairs.len() * 2);
360                for (name, text) in pairs {
361                    items.push(name);
362                    items.push(text);
363                }
364                encode_list(&items)
365            },
366            move |stored| {
367                let items = decode_list(stored)?;
368                if items.len() % 2 != 0 {
369                    return None;
370                }
371                let mut map =
372                    HashMap::with_capacity_and_hasher(items.len() / 2, Default::default());
373                let mut items = items.into_iter();
374                while let (Some(name), Some(text)) = (items.next(), items.next()) {
375                    map.insert(name, text);
376                }
377                restore(&map)
378            },
379        )
380    }
381}
382
383/// `items` as one string: each item's byte length, a colon, then the item.
384fn encode_list(items: &[String]) -> String {
385    use std::fmt::Write;
386
387    let mut stored = String::with_capacity(items.iter().map(|item| item.len() + 4).sum());
388    for item in items {
389        let _ = write!(stored, "{}:{item}", item.len());
390    }
391    stored
392}
393
394/// The items [`encode_list`] stored, or `None` for a string it did not write.
395fn decode_list(mut stored: &str) -> Option<Vec<String>> {
396    let mut items = Vec::new();
397    while !stored.is_empty() {
398        let (length, rest) = stored.split_once(':')?;
399        let (item, rest) = rest.split_at_checked(length.parse().ok()?)?;
400        items.push(item.to_owned());
401        stored = rest;
402    }
403    Some(items)
404}
405
406impl<T> Saver<T>
407where
408    T: std::fmt::Display + std::str::FromStr + 'static,
409{
410    /// The saver for a type that already round-trips through `Display` and
411    /// `FromStr` — numbers, booleans, strings, enums with a parse impl.
412    pub fn of_display() -> Self {
413        Self::new(
414            |value: &T| value.to_string(),
415            |stored: &str| stored.parse::<T>().ok(),
416        )
417    }
418}
419
420/// State restored from preferences on first composition and saved at a flush.
421///
422/// Native hosts flush when leaving the foreground. Call [`crate::run_durable_saves`]
423/// on the composition thread for an explicit durability boundary. Mutations before
424/// that boundary are captured without another frame; abrupt termination before a
425/// successful flush can lose them. Failed flushes can be retried.
426///
427/// `key` is a key in the [`SaveableScope`] where this is composed, so a
428/// navigation entry's state stays apart from another's. When the composable
429/// leaves the composition its last value is kept and saved at the next
430/// boundary, and a composable that comes back restores it.
431#[expect(non_snake_case, reason = "names follow Compose's casing")]
432#[track_caller]
433pub fn rememberSaveable<T>(
434    key: &'static str,
435    saver: Saver<T>,
436    initial: impl FnOnce() -> T,
437) -> cranpose_core::MutableState<T>
438where
439    T: Clone + 'static,
440{
441    cranpose_core::remember(move || {
442        let scope = SaveableScope::current();
443        let (store, key) = (scope.store(), scope.key(key));
444        let restored = crate::durable_save::saved(store.as_ref(), &key)
445            .and_then(|stored| saver.restore(&stored))
446            .unwrap_or_else(initial);
447        let owner = cranpose_core::mutableStateOfNeverEqual(restored);
448        let state = owner;
449        let registration = crate::durable_save::register_local(store, key, move || {
450            state.try_with(|value| saver.save(value))
451        });
452        (owner, registration)
453    })
454    .with(|(state, _)| *state)
455}
456
457/// Where the [`rememberSaveable`] calls in some content keep their values: a
458/// key namespace, and whether the values are saved in [`preferences`] or kept
459/// only in memory. A navigation host gives each back-stack entry its own, so
460/// two visits of one screen keep apart.
461#[derive(Clone, Debug, PartialEq, Eq)]
462pub struct SaveableScope {
463    namespace: Option<Rc<str>>,
464    durable: bool,
465}
466
467fn local_saveable_scope() -> cranpose_core::CompositionLocal<SaveableScope> {
468    crate::composition_locals::cached_local(
469        |locals| &locals.saveable_scope,
470        || {
471            cranpose_core::compositionLocalOf(|| SaveableScope {
472                namespace: None,
473                durable: true,
474            })
475        },
476    )
477}
478
479impl SaveableScope {
480    /// The scope where this is composed: the one [`ProvideSaveableScope`]
481    /// gives, else the root scope, which saves each key as it is in
482    /// [`preferences`]. Call it while composing.
483    pub fn current() -> Self {
484        local_saveable_scope().current()
485    }
486
487    /// The scope named `name` inside this one. It saves where this one does.
488    pub fn child(&self, name: impl std::fmt::Display) -> Self {
489        Self {
490            namespace: Some(self.key(name).into()),
491            durable: self.durable,
492        }
493    }
494
495    /// This scope with its values kept only in memory, until the scope is
496    /// forgotten or the process ends.
497    #[must_use]
498    pub fn in_memory(self) -> Self {
499        Self {
500            durable: false,
501            ..self
502        }
503    }
504
505    /// The prefix of the keys this scope saves under; `None` for the root
506    /// scope, whose keys stand as they are.
507    pub fn namespace(&self) -> Option<&str> {
508        self.namespace.as_deref()
509    }
510
511    /// Whether this scope saves its values in [`preferences`], rather than
512    /// keeping them in memory.
513    pub fn is_durable(&self) -> bool {
514        self.durable
515    }
516
517    /// The value last saved under `key` in this scope.
518    pub fn restore(&self, key: &str) -> Option<String> {
519        crate::durable_save::saved(self.store().as_ref(), &self.key(key))
520    }
521
522    /// Saves what `read` returns under `key` in this scope at each save
523    /// boundary while the registration lives, and keeps its last value once
524    /// it is dropped, as [`rememberSaveable`] does. `read` must not perform
525    /// storage I/O.
526    pub fn register(
527        &self,
528        key: &str,
529        read: impl Fn() -> Option<String> + 'static,
530    ) -> crate::durable_save::SaveableRegistration {
531        crate::durable_save::register_local(self.store(), self.key(key), read)
532    }
533
534    /// Forgets everything saved in this scope: the state of a back-stack
535    /// entry once it is popped. Saves in it stop, and stored values are
536    /// removed at the next save boundary. The root scope forgets nothing.
537    pub fn forget(&self) {
538        self.forget_children_except(std::iter::empty::<&str>());
539    }
540
541    /// Forgets everything saved in this scope apart from the child scopes
542    /// named in `keep`, as [`SaveableScope::forget`] does.
543    pub fn forget_children_except<N: std::fmt::Display>(&self, keep: impl IntoIterator<Item = N>) {
544        let Some(namespace) = &self.namespace else {
545            return;
546        };
547        let keep = keep
548            .into_iter()
549            .map(|name| format!("{namespace}/{name}/"))
550            .collect();
551        crate::durable_save::forget(self.store(), format!("{namespace}/"), keep);
552    }
553
554    pub(crate) fn key(&self, key: impl std::fmt::Display) -> String {
555        match &self.namespace {
556            Some(namespace) => format!("{namespace}/{key}"),
557            None => key.to_string(),
558        }
559    }
560
561    fn store(&self) -> Option<PreferencesRef> {
562        self.durable.then(preferences)
563    }
564}
565
566/// Gives the [`rememberSaveable`] calls in `content` the saveable `scope`.
567#[expect(non_snake_case, reason = "names follow Compose's casing")]
568#[track_caller]
569pub fn ProvideSaveableScope(scope: SaveableScope, content: impl FnOnce()) {
570    cranpose_core::CompositionLocalProvider([local_saveable_scope().provides(scope)], content);
571}
572
573#[cfg(test)]
574#[path = "tests/preferences_tests.rs"]
575mod tests;