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