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