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    sync::{Arc, Mutex, OnceLock},
15};
16
17#[cfg(not(target_arch = "wasm32"))]
18mod file;
19#[cfg(not(target_arch = "wasm32"))]
20pub use file::FilePreferences;
21
22use crate::registry::ServiceRegistry;
23
24/// Errors produced by a preferences backend.
25#[derive(thiserror::Error, Debug, Clone, PartialEq, Eq)]
26pub enum PreferencesError {
27    /// The backing store could not be read or written.
28    #[error("preferences storage failed: {0}")]
29    Io(String),
30    /// No durable storage is available on this platform or build.
31    #[error("preferences are not available on this platform")]
32    Unavailable,
33}
34
35/// Durable key/value storage owned by the application.
36///
37/// Implementations are `Send + Sync`: preferences are written from wherever the
38/// decision is made, including a worker thread.
39pub trait PreferencesStore: Send + Sync {
40    /// Reads `key`, or `None` when it has never been written.
41    fn get(&self, key: &str) -> Option<String>;
42
43    /// Writes `key`.
44    fn set(&self, key: &str, value: &str) -> Result<(), PreferencesError>;
45
46    /// Writes a group of values. File-backed stores replace the file once.
47    /// A failure may leave pending values visible; retry this batch or call `flush`.
48    fn set_many(&self, values: &BTreeMap<String, String>) -> Result<(), PreferencesError> {
49        for (key, value) in values {
50            self.set(key, value)?;
51        }
52        Ok(())
53    }
54
55    /// Retries pending writes. Success means the backend accepted all pending data.
56    fn flush(&self) -> Result<(), PreferencesError> {
57        Ok(())
58    }
59
60    /// Removes `key`. Succeeds if it is already absent.
61    fn remove(&self, key: &str) -> Result<(), PreferencesError>;
62
63    /// Every key currently stored, in sorted order.
64    fn keys(&self) -> Vec<String>;
65
66    /// Removes everything.
67    fn clear(&self) -> Result<(), PreferencesError>;
68}
69
70/// Shared handle to a [`PreferencesStore`].
71pub type PreferencesRef = Arc<dyn PreferencesStore>;
72
73static PLATFORM_PREFERENCES: ServiceRegistry<dyn PreferencesStore> = ServiceRegistry::new();
74
75/// Installs the platform preferences backend, replacing any previous one.
76pub fn set_platform_preferences(store: PreferencesRef) {
77    PLATFORM_PREFERENCES.set(store);
78}
79
80/// Removes any installed platform backend (tests and teardown).
81pub fn clear_platform_preferences() {
82    PLATFORM_PREFERENCES.clear();
83}
84
85/// The active preferences store: the platform backend if one is installed,
86/// otherwise the framework's own file-backed store under the application's
87/// config directory.
88pub fn preferences() -> PreferencesRef {
89    if let Some(store) = PLATFORM_PREFERENCES.get() {
90        return store;
91    }
92    default_preferences()
93}
94
95#[cfg(not(target_arch = "wasm32"))]
96fn default_preferences() -> PreferencesRef {
97    static DEFAULT: OnceLock<PreferencesRef> = OnceLock::new();
98    DEFAULT
99        .get_or_init(|| Arc::new(FilePreferences::new()) as PreferencesRef)
100        .clone()
101}
102
103#[cfg(all(target_arch = "wasm32", feature = "preferences-web"))]
104fn default_preferences() -> PreferencesRef {
105    static DEFAULT: OnceLock<PreferencesRef> = OnceLock::new();
106    DEFAULT
107        .get_or_init(|| Arc::new(BrowserPreferences) as PreferencesRef)
108        .clone()
109}
110
111#[cfg(all(target_arch = "wasm32", not(feature = "preferences-web")))]
112fn default_preferences() -> PreferencesRef {
113    static DEFAULT: OnceLock<PreferencesRef> = OnceLock::new();
114    DEFAULT
115        .get_or_init(|| Arc::new(MemoryPreferences::default()) as PreferencesRef)
116        .clone()
117}
118
119/// The browser's `localStorage`, which is where preferences outlive a reload.
120///
121/// Holds no handle of its own: `localStorage` is reached through the window each
122/// call, so the store is `Send + Sync` like every other backend even though the
123/// object it talks to is not.
124#[cfg(all(target_arch = "wasm32", feature = "preferences-web"))]
125pub struct BrowserPreferences;
126
127#[cfg(all(target_arch = "wasm32", feature = "preferences-web"))]
128impl BrowserPreferences {
129    fn storage() -> Result<web_sys::Storage, PreferencesError> {
130        web_sys::window()
131            .and_then(|window| window.local_storage().ok().flatten())
132            .ok_or(PreferencesError::Unavailable)
133    }
134}
135
136#[cfg(all(target_arch = "wasm32", feature = "preferences-web"))]
137fn storage_error(value: wasm_bindgen::JsValue) -> PreferencesError {
138    PreferencesError::Io(
139        value
140            .as_string()
141            .unwrap_or_else(|| "localStorage rejected the operation".to_string()),
142    )
143}
144
145#[cfg(all(target_arch = "wasm32", feature = "preferences-web"))]
146impl PreferencesStore for BrowserPreferences {
147    fn get(&self, key: &str) -> Option<String> {
148        Self::storage().ok()?.get_item(key).ok().flatten()
149    }
150
151    fn set(&self, key: &str, value: &str) -> Result<(), PreferencesError> {
152        Self::storage()?.set_item(key, value).map_err(storage_error)
153    }
154
155    fn remove(&self, key: &str) -> Result<(), PreferencesError> {
156        Self::storage()?.remove_item(key).map_err(storage_error)
157    }
158
159    fn keys(&self) -> Vec<String> {
160        let Ok(storage) = Self::storage() else {
161            return Vec::new();
162        };
163        let count = storage.length().unwrap_or(0);
164        let mut keys: Vec<String> = (0..count)
165            .filter_map(|index| storage.key(index).ok().flatten())
166            .collect();
167        keys.sort();
168        keys
169    }
170
171    fn clear(&self) -> Result<(), PreferencesError> {
172        Self::storage()?.clear().map_err(storage_error)
173    }
174}
175
176/// An in-memory store. The web default until a platform backend registers, and
177/// what tests use.
178#[derive(Default)]
179pub struct MemoryPreferences {
180    entries: Mutex<BTreeMap<String, String>>,
181}
182
183impl MemoryPreferences {
184    /// An empty store.
185    pub fn new() -> Self {
186        Self::default()
187    }
188}
189
190impl PreferencesStore for MemoryPreferences {
191    fn get(&self, key: &str) -> Option<String> {
192        self.entries
193            .lock()
194            .ok()
195            .and_then(|entries| entries.get(key).cloned())
196    }
197
198    fn set(&self, key: &str, value: &str) -> Result<(), PreferencesError> {
199        let mut entries = self
200            .entries
201            .lock()
202            .map_err(|_| PreferencesError::Io("preferences lock poisoned".into()))?;
203        entries.insert(key.to_string(), value.to_string());
204        Ok(())
205    }
206
207    fn remove(&self, key: &str) -> Result<(), PreferencesError> {
208        let mut entries = self
209            .entries
210            .lock()
211            .map_err(|_| PreferencesError::Io("preferences lock poisoned".into()))?;
212        entries.remove(key);
213        Ok(())
214    }
215
216    fn keys(&self) -> Vec<String> {
217        self.entries
218            .lock()
219            .map(|entries| entries.keys().cloned().collect())
220            .unwrap_or_default()
221    }
222
223    fn clear(&self) -> Result<(), PreferencesError> {
224        let mut entries = self
225            .entries
226            .lock()
227            .map_err(|_| PreferencesError::Io("preferences lock poisoned".into()))?;
228        entries.clear();
229        Ok(())
230    }
231}
232
233#[cfg(not(target_arch = "wasm32"))]
234fn encode(entries: &BTreeMap<String, String>) -> String {
235    let mut text = String::new();
236    for (key, value) in entries {
237        escape_into(key, &mut text);
238        text.push('=');
239        escape_into(value, &mut text);
240        text.push('\n');
241    }
242    text
243}
244
245#[cfg(not(target_arch = "wasm32"))]
246fn parse(text: &str) -> BTreeMap<String, String> {
247    text.lines()
248        .filter_map(|line| {
249            let (key, value) = line.split_once('=')?;
250            Some((unescape(key), unescape(value)))
251        })
252        .collect()
253}
254
255#[cfg(not(target_arch = "wasm32"))]
256fn escape_into(value: &str, out: &mut String) {
257    for character in value.chars() {
258        match character {
259            '%' => out.push_str("%25"),
260            '=' => out.push_str("%3D"),
261            '\n' => out.push_str("%0A"),
262            '\r' => out.push_str("%0D"),
263            other => out.push(other),
264        }
265    }
266}
267
268#[cfg(not(target_arch = "wasm32"))]
269fn unescape(value: &str) -> String {
270    let mut out = String::with_capacity(value.len());
271    let mut characters = value.chars();
272    while let Some(character) = characters.next() {
273        if character != '%' {
274            out.push(character);
275            continue;
276        }
277        let high = characters.next();
278        let low = characters.next();
279        match (high, low) {
280            (Some(high), Some(low)) => match u8::from_str_radix(&format!("{high}{low}"), 16) {
281                Ok(byte) => out.push(byte as char),
282                Err(_) => {
283                    out.push('%');
284                    out.push(high);
285                    out.push(low);
286                }
287            },
288            _ => out.push('%'),
289        }
290    }
291    out
292}
293
294/// Converts a value to and from the string form preferences store.
295///
296/// A saver is deliberately explicit rather than derived: the stored form is a
297/// compatibility surface, and the framework will not guess it for you.
298pub struct Saver<T> {
299    save: SaveFn<T>,
300    restore: RestoreFn<T>,
301}
302
303type SaveFn<T> = Box<dyn Fn(&T) -> String + 'static>;
304type RestoreFn<T> = Box<dyn Fn(&str) -> Option<T> + 'static>;
305
306impl<T> Saver<T> {
307    /// Builds a saver from a pair of conversions.
308    pub fn new(
309        save: impl Fn(&T) -> String + 'static,
310        restore: impl Fn(&str) -> Option<T> + 'static,
311    ) -> Self {
312        Self {
313            save: Box::new(save),
314            restore: Box::new(restore),
315        }
316    }
317
318    /// The stored form of `value`.
319    pub fn save(&self, value: &T) -> String {
320        (self.save)(value)
321    }
322
323    /// The value `stored` represents, or `None` when it cannot be read — a
324    /// stored form written by an older build, or a corrupted entry.
325    pub fn restore(&self, stored: &str) -> Option<T> {
326        (self.restore)(stored)
327    }
328}
329
330impl<T> Saver<T>
331where
332    T: std::fmt::Display + std::str::FromStr + 'static,
333{
334    /// The saver for a type that already round-trips through `Display` and
335    /// `FromStr` — numbers, booleans, strings, enums with a parse impl.
336    pub fn of_display() -> Self {
337        Self::new(
338            |value: &T| value.to_string(),
339            |stored: &str| stored.parse::<T>().ok(),
340        )
341    }
342}
343
344/// State restored from preferences on first composition and saved at a flush.
345///
346/// Native hosts flush when leaving the foreground. Call [`crate::run_durable_saves`]
347/// on the composition thread for an explicit durability boundary. Mutations before
348/// that boundary are captured without another frame; abrupt termination before a
349/// successful flush can lose them. Failed flushes can be retried.
350#[expect(non_snake_case)]
351#[track_caller]
352pub fn rememberSaveable<T>(
353    key: &'static str,
354    saver: Saver<T>,
355    initial: impl FnOnce() -> T,
356) -> cranpose_core::MutableState<T>
357where
358    T: Clone + 'static,
359{
360    cranpose_core::remember(move || {
361        let store = preferences();
362        let restored = store
363            .get(key)
364            .and_then(|stored| saver.restore(&stored))
365            .unwrap_or_else(initial);
366        let owner = cranpose_core::mutableStateOfNeverEqual(restored);
367        let state = owner;
368        let registration =
369            crate::durable_save::register_local_preference_save(store, key.to_owned(), move || {
370                state.try_with(|value| saver.save(value))
371            });
372        (owner, registration)
373    })
374    .with(|(state, _)| *state)
375}
376
377#[cfg(test)]
378#[path = "tests/preferences_tests.rs"]
379mod tests;