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> Saver<T>
340where
341    T: std::fmt::Display + std::str::FromStr + 'static,
342{
343    /// The saver for a type that already round-trips through `Display` and
344    /// `FromStr` — numbers, booleans, strings, enums with a parse impl.
345    pub fn of_display() -> Self {
346        Self::new(
347            |value: &T| value.to_string(),
348            |stored: &str| stored.parse::<T>().ok(),
349        )
350    }
351}
352
353/// State restored from preferences on first composition and saved at a flush.
354///
355/// Native hosts flush when leaving the foreground. Call [`crate::run_durable_saves`]
356/// on the composition thread for an explicit durability boundary. Mutations before
357/// that boundary are captured without another frame; abrupt termination before a
358/// successful flush can lose them. Failed flushes can be retried.
359///
360/// `key` is a key in the [`SaveableScope`] where this is composed, so a
361/// navigation entry's state stays apart from another's. When the composable
362/// leaves the composition its last value is kept and saved at the next
363/// boundary, and a composable that comes back restores it.
364#[expect(non_snake_case)]
365#[track_caller]
366pub fn rememberSaveable<T>(
367    key: &'static str,
368    saver: Saver<T>,
369    initial: impl FnOnce() -> T,
370) -> cranpose_core::MutableState<T>
371where
372    T: Clone + 'static,
373{
374    cranpose_core::remember(move || {
375        let scope = SaveableScope::current();
376        let (store, key) = (scope.store(), scope.key(key));
377        let restored = crate::durable_save::saved(store.as_ref(), &key)
378            .and_then(|stored| saver.restore(&stored))
379            .unwrap_or_else(initial);
380        let owner = cranpose_core::mutableStateOfNeverEqual(restored);
381        let state = owner;
382        let registration = crate::durable_save::register_local(store, key, move || {
383            state.try_with(|value| saver.save(value))
384        });
385        (owner, registration)
386    })
387    .with(|(state, _)| *state)
388}
389
390/// Where the [`rememberSaveable`] calls in some content keep their values: a
391/// key namespace, and whether the values are saved in [`preferences`] or kept
392/// only in memory. A navigation host gives each back-stack entry its own, so
393/// two visits of one screen keep apart.
394#[derive(Clone, Debug, PartialEq, Eq)]
395pub struct SaveableScope {
396    namespace: Option<Rc<str>>,
397    durable: bool,
398}
399
400fn local_saveable_scope() -> cranpose_core::CompositionLocal<SaveableScope> {
401    crate::composition_locals::cached_local(
402        |locals| &locals.saveable_scope,
403        || {
404            cranpose_core::compositionLocalOf(|| SaveableScope {
405                namespace: None,
406                durable: true,
407            })
408        },
409    )
410}
411
412impl SaveableScope {
413    /// The scope where this is composed: the one [`ProvideSaveableScope`]
414    /// gives, else the root scope, which saves each key as it is in
415    /// [`preferences`]. Call it while composing.
416    pub fn current() -> Self {
417        local_saveable_scope().current()
418    }
419
420    /// The scope named `name` inside this one. It saves where this one does.
421    pub fn child(&self, name: impl std::fmt::Display) -> Self {
422        Self {
423            namespace: Some(self.key(name).into()),
424            durable: self.durable,
425        }
426    }
427
428    /// This scope with its values kept only in memory, until the scope is
429    /// forgotten or the process ends.
430    #[must_use]
431    pub fn in_memory(self) -> Self {
432        Self {
433            durable: false,
434            ..self
435        }
436    }
437
438    /// The prefix of the keys this scope saves under; `None` for the root
439    /// scope, whose keys stand as they are.
440    pub fn namespace(&self) -> Option<&str> {
441        self.namespace.as_deref()
442    }
443
444    /// Whether this scope saves its values in [`preferences`], rather than
445    /// keeping them in memory.
446    pub fn is_durable(&self) -> bool {
447        self.durable
448    }
449
450    /// The value last saved under `key` in this scope.
451    pub fn restore(&self, key: &str) -> Option<String> {
452        crate::durable_save::saved(self.store().as_ref(), &self.key(key))
453    }
454
455    /// Saves what `read` returns under `key` in this scope at each save
456    /// boundary while the registration lives, and keeps its last value once
457    /// it is dropped, as [`rememberSaveable`] does. `read` must not perform
458    /// storage I/O.
459    pub fn register(
460        &self,
461        key: &str,
462        read: impl Fn() -> Option<String> + 'static,
463    ) -> crate::durable_save::SaveableRegistration {
464        crate::durable_save::register_local(self.store(), self.key(key), read)
465    }
466
467    /// Forgets everything saved in this scope: the state of a back-stack
468    /// entry once it is popped. Saves in it stop, and stored values are
469    /// removed at the next save boundary. The root scope forgets nothing.
470    pub fn forget(&self) {
471        self.forget_children_except(std::iter::empty::<&str>());
472    }
473
474    /// Forgets everything saved in this scope apart from the child scopes
475    /// named in `keep`, as [`SaveableScope::forget`] does.
476    pub fn forget_children_except<N: std::fmt::Display>(&self, keep: impl IntoIterator<Item = N>) {
477        let Some(namespace) = &self.namespace else {
478            return;
479        };
480        let keep = keep
481            .into_iter()
482            .map(|name| format!("{namespace}/{name}/"))
483            .collect();
484        crate::durable_save::forget(self.store(), format!("{namespace}/"), keep);
485    }
486
487    fn key(&self, key: impl std::fmt::Display) -> String {
488        match &self.namespace {
489            Some(namespace) => format!("{namespace}/{key}"),
490            None => key.to_string(),
491        }
492    }
493
494    fn store(&self) -> Option<PreferencesRef> {
495        self.durable.then(preferences)
496    }
497}
498
499/// Gives the [`rememberSaveable`] calls in `content` the saveable `scope`.
500#[expect(non_snake_case)]
501#[track_caller]
502pub fn ProvideSaveableScope(scope: SaveableScope, content: impl FnOnce()) {
503    cranpose_core::CompositionLocalProvider([local_saveable_scope().provides(scope)], content);
504}
505
506#[cfg(test)]
507#[path = "tests/preferences_tests.rs"]
508mod tests;