1use 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#[derive(thiserror::Error, Debug, Clone, PartialEq, Eq)]
26pub enum PreferencesError {
27 #[error("preferences storage failed: {0}")]
29 Io(String),
30 #[error("preferences are not available on this platform")]
32 Unavailable,
33}
34
35pub trait PreferencesStore: Send + Sync {
40 fn get(&self, key: &str) -> Option<String>;
42
43 fn set(&self, key: &str, value: &str) -> Result<(), PreferencesError>;
45
46 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 fn flush(&self) -> Result<(), PreferencesError> {
57 Ok(())
58 }
59
60 fn remove(&self, key: &str) -> Result<(), PreferencesError>;
62
63 fn keys(&self) -> Vec<String>;
65
66 fn clear(&self) -> Result<(), PreferencesError>;
68}
69
70pub type PreferencesRef = Arc<dyn PreferencesStore>;
72
73static PLATFORM_PREFERENCES: ServiceRegistry<dyn PreferencesStore> = ServiceRegistry::new();
74
75pub fn set_platform_preferences(store: PreferencesRef) {
77 PLATFORM_PREFERENCES.set(store);
78}
79
80pub fn clear_platform_preferences() {
82 PLATFORM_PREFERENCES.clear();
83}
84
85pub 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#[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#[derive(Default)]
179pub struct MemoryPreferences {
180 entries: Mutex<BTreeMap<String, String>>,
181}
182
183impl MemoryPreferences {
184 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
294pub 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 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 pub fn save(&self, value: &T) -> String {
320 (self.save)(value)
321 }
322
323 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 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#[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;