Skip to main content

incular_runtime/
restoration.rs

1//! Versioned, opt-in declarative application restoration.
2//!
3//! This module deliberately stores only application-supplied JSON values and
4//! stable descriptors. Retained widgets, native windows, GPU objects, tasks,
5//! callbacks, gestures, and semantics are never part of a snapshot.
6
7use std::{
8    collections::{BTreeMap, BTreeSet, HashSet},
9    fmt,
10    fs::{self, File},
11    io::Read,
12    path::{Path, PathBuf},
13    rc::{Rc, Weak},
14    sync::{Arc, Mutex},
15    time::Duration,
16};
17
18use serde::{Deserialize, Serialize, de::DeserializeOwned};
19use serde_json::Value;
20
21use incular_core::{RestorationBackend, RestorationKey, RestorationScope};
22
23use crate::{Signal, tasks};
24
25/// The Incular-owned snapshot format. It is intentionally independent from
26/// the application's own restoration schema version.
27pub const FRAMEWORK_RESTORATION_FORMAT_VERSION: u32 = 1;
28
29/// Normal local-state debounce. Writes are coalesced and happen on Tokio's
30/// blocking pool, never on an ordinary UI mutation.
31pub const DEFAULT_RESTORATION_DEBOUNCE: Duration = Duration::from_millis(250);
32
33/// A deliberately generous bound for small declarative session state. This is
34/// not a database or a place for files, images, credentials, or tokens.
35pub const DEFAULT_RESTORATION_SNAPSHOT_LIMIT: usize = 8 * 1024 * 1024;
36
37/// Maximum number of automatic retry attempts after a failed save. Retries
38/// use bounded exponential backoff; a new mutation always schedules a normal
39/// debounced save regardless of this counter.
40pub const MAX_RESTORATION_SAVE_RETRIES: u32 = 3;
41
42/// Base delay for the bounded retry backoff.
43pub const RESTORATION_RETRY_BACKOFF: Duration = Duration::from_millis(500);
44
45/// Error reported by a persistence store. Store errors are diagnostics, not
46/// application-fatal failures.
47#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
48#[error("{message}")]
49pub struct RestorationStoreError {
50    message: String,
51}
52
53impl RestorationStoreError {
54    #[must_use]
55    pub fn new(message: impl Into<String>) -> Self {
56        Self {
57            message: message.into(),
58        }
59    }
60}
61
62/// Small persistence boundary for restoration snapshots. Implementations must
63/// leave the previous valid snapshot untouched when `save` returns an error.
64pub trait RestorationStore: Send + Sync + 'static {
65    fn load(&self) -> Result<Option<Vec<u8>>, RestorationStoreError>;
66    fn save(&self, bytes: &[u8]) -> Result<(), RestorationStoreError>;
67    fn remove(&self) -> Result<(), RestorationStoreError>;
68
69    /// Returns a human-readable location when one exists. In-memory stores
70    /// intentionally return `None`.
71    fn location(&self) -> Option<PathBuf> {
72        None
73    }
74}
75
76/// Deterministic store for tests and embedding. It also supports injected
77/// write failures so crash-safety behaviour can be verified without a real
78/// user directory.
79#[derive(Default)]
80pub struct InMemoryRestorationStore {
81    bytes: Mutex<Option<Vec<u8>>>,
82    fail_next_save: Mutex<Option<String>>,
83}
84
85impl InMemoryRestorationStore {
86    #[must_use]
87    pub fn new() -> Self {
88        Self::default()
89    }
90
91    pub fn fail_next_save(&self, message: impl Into<String>) {
92        *self
93            .fail_next_save
94            .lock()
95            .expect("restoration test-store failure mutex") = Some(message.into());
96    }
97
98    #[must_use]
99    pub fn bytes(&self) -> Option<Vec<u8>> {
100        self.bytes
101            .lock()
102            .expect("restoration test-store bytes mutex")
103            .clone()
104    }
105
106    pub fn set_bytes(&self, bytes: Option<Vec<u8>>) {
107        *self
108            .bytes
109            .lock()
110            .expect("restoration test-store bytes mutex") = bytes;
111    }
112}
113
114impl RestorationStore for InMemoryRestorationStore {
115    fn load(&self) -> Result<Option<Vec<u8>>, RestorationStoreError> {
116        Ok(self.bytes())
117    }
118
119    fn save(&self, bytes: &[u8]) -> Result<(), RestorationStoreError> {
120        if let Some(message) = self
121            .fail_next_save
122            .lock()
123            .expect("restoration test-store failure mutex")
124            .take()
125        {
126            return Err(RestorationStoreError::new(message));
127        }
128        *self
129            .bytes
130            .lock()
131            .expect("restoration test-store bytes mutex") = Some(bytes.to_vec());
132        Ok(())
133    }
134
135    fn remove(&self) -> Result<(), RestorationStoreError> {
136        *self
137            .bytes
138            .lock()
139            .expect("restoration test-store bytes mutex") = None;
140        Ok(())
141    }
142}
143
144/// File-backed default persistence store. A new snapshot is completely
145/// flushed to a temporary sibling and then renamed into place. On platforms
146/// where replacement cannot be atomic, the rename fails rather than deleting
147/// the only known-good file; the next mutation may retry it. This guarantees
148/// that a reported failed save never overwrites the previous valid snapshot,
149/// while filesystem/power-loss durability remains platform/filesystem-specific.
150#[derive(Clone, Debug)]
151pub struct FileRestorationStore {
152    path: PathBuf,
153}
154
155impl FileRestorationStore {
156    #[must_use]
157    pub fn new(path: impl Into<PathBuf>) -> Self {
158        Self { path: path.into() }
159    }
160
161    /// Derives an application-specific user state path with the operating
162    /// system's standard project-directory conventions. `application_id` must
163    /// be stable across launches and filesystem-safe: empty, path-separator,
164    /// current/parent-directory, NUL, and control-character ids are rejected
165    /// rather than escaping the application data directory.
166    pub fn for_application(application_id: &str) -> Result<Self, RestorationStoreError> {
167        validate_application_id(application_id)?;
168        let base = incular_platform::application_data_local_directory(application_id).ok_or_else(
169            || {
170                RestorationStoreError::new(
171                    "standard application data directory is unavailable for restoration",
172                )
173            },
174        )?;
175        Ok(Self::new(base.join("restoration.json")))
176    }
177
178    #[must_use]
179    pub fn path(&self) -> &Path {
180        &self.path
181    }
182}
183
184impl RestorationStore for FileRestorationStore {
185    fn load(&self) -> Result<Option<Vec<u8>>, RestorationStoreError> {
186        match File::open(&self.path) {
187            Ok(mut file) => {
188                let mut bytes = Vec::new();
189                file.read_to_end(&mut bytes)
190                    .map_err(|error| RestorationStoreError::new(error.to_string()))?;
191                Ok(Some(bytes))
192            }
193            Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
194            Err(error) => Err(RestorationStoreError::new(error.to_string())),
195        }
196    }
197
198    #[cfg(target_arch = "wasm32")]
199    fn save(&self, _bytes: &[u8]) -> Result<(), RestorationStoreError> {
200        Err(RestorationStoreError::new(
201            "file restoration snapshots are unavailable in browsers",
202        ))
203    }
204
205    #[cfg(not(target_arch = "wasm32"))]
206    fn save(&self, bytes: &[u8]) -> Result<(), RestorationStoreError> {
207        let parent = self.path.parent().ok_or_else(|| {
208            RestorationStoreError::new("restoration snapshot path has no parent directory")
209        })?;
210        fs::create_dir_all(parent)
211            .map_err(|error| RestorationStoreError::new(error.to_string()))?;
212        use std::io::Write as _;
213
214        let mut temporary = tempfile::NamedTempFile::new_in(parent)
215            .map_err(|error| RestorationStoreError::new(error.to_string()))?;
216        temporary
217            .write_all(bytes)
218            .and_then(|()| temporary.flush())
219            .and_then(|()| temporary.as_file().sync_all())
220            .map_err(|error| RestorationStoreError::new(error.to_string()))?;
221        temporary
222            .persist(&self.path)
223            .map_err(|error| RestorationStoreError::new(error.error.to_string()))?;
224        // Syncing the directory is unavailable on some desktop platforms; the
225        // snapshot file itself has been flushed before replacement.
226        Ok(())
227    }
228
229    fn remove(&self) -> Result<(), RestorationStoreError> {
230        match fs::remove_file(&self.path) {
231            Ok(()) => Ok(()),
232            Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
233            Err(error) => Err(RestorationStoreError::new(error.to_string())),
234        }
235    }
236
237    fn location(&self) -> Option<PathBuf> {
238        Some(self.path.clone())
239    }
240}
241
242/// Browser persistence store: the snapshot is a JSON string in the page
243/// origin's `localStorage`, under a key derived from the application ID.
244/// `localStorage` replaces a value in one step, so a failed save (for
245/// example a full quota) leaves the previous snapshot in place.
246#[cfg(target_arch = "wasm32")]
247#[derive(Clone, Debug)]
248pub struct BrowserRestorationStore {
249    key: String,
250}
251
252#[cfg(target_arch = "wasm32")]
253impl BrowserRestorationStore {
254    /// Uses the storage key `incular.restoration.<application_id>`, with the
255    /// same application ID rules as [`FileRestorationStore::for_application`].
256    pub fn for_application(application_id: &str) -> Result<Self, RestorationStoreError> {
257        validate_application_id(application_id)?;
258        Ok(Self {
259            key: format!("incular.restoration.{application_id}"),
260        })
261    }
262
263    #[must_use]
264    pub fn key(&self) -> &str {
265        &self.key
266    }
267
268    fn storage() -> Result<web_sys::Storage, RestorationStoreError> {
269        web_sys::window()
270            .and_then(|window| window.local_storage().ok().flatten())
271            .ok_or_else(|| RestorationStoreError::new("localStorage is unavailable"))
272    }
273}
274
275#[cfg(target_arch = "wasm32")]
276fn storage_error(error: wasm_bindgen::JsValue) -> RestorationStoreError {
277    RestorationStoreError::new(format!("localStorage: {error:?}"))
278}
279
280#[cfg(target_arch = "wasm32")]
281impl RestorationStore for BrowserRestorationStore {
282    fn load(&self) -> Result<Option<Vec<u8>>, RestorationStoreError> {
283        Ok(Self::storage()?
284            .get_item(&self.key)
285            .map_err(storage_error)?
286            .map(String::into_bytes))
287    }
288
289    fn save(&self, bytes: &[u8]) -> Result<(), RestorationStoreError> {
290        let text = std::str::from_utf8(bytes)
291            .map_err(|_| RestorationStoreError::new("restoration snapshot is not UTF-8"))?;
292        Self::storage()?
293            .set_item(&self.key, text)
294            .map_err(storage_error)
295    }
296
297    fn remove(&self) -> Result<(), RestorationStoreError> {
298        Self::storage()?
299            .remove_item(&self.key)
300            .map_err(storage_error)
301    }
302
303    fn location(&self) -> Option<PathBuf> {
304        Some(PathBuf::from(format!("localStorage:{}", self.key)))
305    }
306}
307
308/// Stable configuration for an opt-in application restoration session.
309#[derive(Clone)]
310pub struct RestorationConfig {
311    application_id: String,
312    application_schema_version: u32,
313    store: Arc<dyn RestorationStore>,
314    snapshot_limit: usize,
315    debounce: Duration,
316    migration: Option<RestorationMigration>,
317}
318
319impl fmt::Debug for RestorationConfig {
320    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
321        formatter
322            .debug_struct("RestorationConfig")
323            .field("application_id", &self.application_id)
324            .field(
325                "application_schema_version",
326                &self.application_schema_version,
327            )
328            .field("snapshot_limit", &self.snapshot_limit)
329            .field("debounce", &self.debounce)
330            .field("store_location", &self.store.location())
331            .finish_non_exhaustive()
332    }
333}
334
335/// Application-owned migration invoked before any restored value is exposed to
336/// a UI build. It receives the old schema version and the complete JSON
337/// document; failures safely fall back to default state.
338pub type RestorationMigration = Arc<dyn Fn(u32, &mut Value) -> Result<(), String> + 'static>;
339
340impl RestorationConfig {
341    #[must_use]
342    pub fn new(
343        application_id: impl Into<String>,
344        application_schema_version: u32,
345        store: Arc<dyn RestorationStore>,
346    ) -> Self {
347        Self {
348            application_id: application_id.into(),
349            application_schema_version,
350            store,
351            snapshot_limit: DEFAULT_RESTORATION_SNAPSHOT_LIMIT,
352            debounce: DEFAULT_RESTORATION_DEBOUNCE,
353            migration: None,
354        }
355    }
356
357    /// Uses the platform's default persistent store: a
358    /// [`FileRestorationStore`] in the application data directory natively,
359    /// and a `BrowserRestorationStore` in browsers, which have no files.
360    pub fn file_backed(
361        application_id: impl Into<String>,
362        application_schema_version: u32,
363    ) -> Result<Self, RestorationStoreError> {
364        let application_id = application_id.into();
365        #[cfg(not(target_arch = "wasm32"))]
366        let store = Arc::new(FileRestorationStore::for_application(&application_id)?);
367        #[cfg(target_arch = "wasm32")]
368        let store = Arc::new(BrowserRestorationStore::for_application(&application_id)?);
369        Ok(Self::new(application_id, application_schema_version, store))
370    }
371
372    #[must_use]
373    pub fn with_snapshot_limit(mut self, bytes: usize) -> Self {
374        self.snapshot_limit = bytes.max(1);
375        self
376    }
377
378    #[must_use]
379    pub fn with_debounce(mut self, debounce: Duration) -> Self {
380        self.debounce = debounce;
381        self
382    }
383
384    #[must_use]
385    pub fn with_migration(mut self, migration: RestorationMigration) -> Self {
386        self.migration = Some(migration);
387        self
388    }
389
390    #[must_use]
391    pub fn application_id(&self) -> &str {
392        &self.application_id
393    }
394
395    #[must_use]
396    pub const fn application_schema_version(&self) -> u32 {
397        self.application_schema_version
398    }
399}
400
401/// Native-free, stable data for a restorable window. DPI, physical framebuffer
402/// dimensions, positions, native handles, and runtime `WindowId`s are omitted.
403#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
404pub struct RestoredWindow {
405    pub restoration_id: String,
406    pub kind: String,
407    pub logical_width: f32,
408    pub logical_height: f32,
409    pub maximized: bool,
410    pub fullscreen: bool,
411}
412
413#[derive(Clone, Debug, Serialize, Deserialize)]
414struct SnapshotDocument {
415    framework_format_version: u32,
416    application_schema_version: u32,
417    snapshot_generation: u64,
418    application_id: String,
419    #[serde(default)]
420    state: BTreeMap<String, Value>,
421    #[serde(default)]
422    windows: Vec<RestoredWindow>,
423}
424
425impl SnapshotDocument {
426    fn empty(config: &RestorationConfig) -> Self {
427        Self {
428            framework_format_version: FRAMEWORK_RESTORATION_FORMAT_VERSION,
429            application_schema_version: config.application_schema_version,
430            snapshot_generation: 0,
431            application_id: config.application_id.clone(),
432            state: BTreeMap::new(),
433            windows: Vec::new(),
434        }
435    }
436}
437
438/// Value-free restoration health and write-coalescing diagnostics.
439///
440/// Non-exhaustive so future counters do not break downstream construction;
441/// read the fields and compare with `==` as before.
442#[derive(Clone, Debug, Default, PartialEq, Eq)]
443#[non_exhaustive]
444pub struct RestorationDiagnostics {
445    pub restoration_loads: u64,
446    pub restoration_load_failures: u64,
447    pub restoration_migrations: u64,
448    pub restoration_values: usize,
449    pub restoration_scopes: usize,
450    pub dirty_generation: u64,
451    pub persisted_generation: u64,
452    pub save_requests: u64,
453    pub save_coalesced: u64,
454    pub saves_started: u64,
455    pub saves_completed: u64,
456    pub save_failures: u64,
457    pub snapshot_bytes: usize,
458    pub peak_snapshot_bytes: usize,
459    pub restored_windows: u64,
460    pub skipped_windows: u64,
461    pub restored_routes: u64,
462    pub invalid_routes: u64,
463    pub restored_scroll_positions: u64,
464    pub stale_or_invalid_values: u64,
465    /// Message of the most recent save failure, if any. Cleared after the
466    /// next successful save. This makes permanent conditions such as an
467    /// oversized snapshot diagnosable without a debugger.
468    pub last_save_error: Option<String>,
469}
470
471struct ManagerState {
472    snapshot: SnapshotDocument,
473    diagnostics: RestorationDiagnostics,
474    dirty: bool,
475    debounce_pending: bool,
476    save_in_flight: bool,
477    consecutive_save_failures: u32,
478    retry_pending: bool,
479    persist_migrated_snapshot: bool,
480    active_scope_paths: HashSet<String>,
481}
482
483struct RestorationManagerInner {
484    config: RestorationConfig,
485    state: std::cell::RefCell<ManagerState>,
486    scheduler: std::cell::RefCell<Option<Weak<std::cell::RefCell<tasks::TaskScheduler>>>>,
487}
488
489/// Runtime-owned manager. The core restoration scope adapts to this manager;
490/// applications normally interact through `BuildContext` and `Restorable<T>`.
491#[derive(Clone)]
492pub(crate) struct RestorationManager {
493    inner: Rc<RestorationManagerInner>,
494}
495
496/// Cloneable UI-thread capability for explicit restoration actions. It is safe
497/// to retain in callbacks: operations update only the in-memory declarative
498/// snapshot and schedule normal Tokio-backed persistence work.
499#[derive(Clone)]
500pub struct RestorationHandle {
501    manager: RestorationManager,
502}
503
504impl RestorationHandle {
505    pub(crate) fn new(manager: RestorationManager) -> Self {
506        Self { manager }
507    }
508
509    pub fn reset(&self) {
510        self.manager.reset();
511    }
512
513    pub fn flush(&self) {
514        self.manager.flush();
515    }
516
517    /// Explicitly removes all values below a stable scope. This is suitable
518    /// for a permanently popped restorable route; temporary rebuilds should
519    /// keep their scope intact.
520    pub fn remove_scope(&self, scope: &RestorationScope) -> usize {
521        self.manager.remove_scope(scope.path())
522    }
523
524    /// Records navigation recovery without exposing live navigator internals.
525    pub fn note_navigation_restore(&self, restored_routes: u64, invalid_routes: u64) {
526        self.manager
527            .note_navigation_restore(restored_routes, invalid_routes);
528    }
529
530    /// Claims one stable scope path for the guard's lifetime. A second live
531    /// claim of the same path fails instead of silently sharing persistence
532    /// state; windows already hold claims for their own scopes, so use this
533    /// for application-owned dynamic scopes that must stay unique while
534    /// mounted. Dropping the guard releases the path.
535    pub fn claim_scope(&self, scope: &RestorationScope) -> Result<ClaimedRestorationScope, String> {
536        self.manager
537            .acquire_scope(scope.path())
538            .map(|lease| ClaimedRestorationScope { _lease: lease })
539    }
540
541    #[must_use]
542    pub fn diagnostics(&self) -> RestorationDiagnostics {
543        self.manager.diagnostics()
544    }
545
546    #[must_use]
547    pub fn path(&self) -> Option<PathBuf> {
548        self.manager.store_location()
549    }
550}
551
552/// Live claim on one stable restoration scope path. Dropping releases the
553/// path so a later mount may claim it; see
554/// [`RestorationHandle::claim_scope`].
555pub struct ClaimedRestorationScope {
556    _lease: ScopeLease,
557}
558
559impl RestorationManager {
560    pub(crate) fn load(config: RestorationConfig) -> Self {
561        let mut diagnostics = RestorationDiagnostics::default();
562        let mut snapshot = SnapshotDocument::empty(&config);
563        match config.store.load() {
564            Ok(Some(bytes)) => {
565                diagnostics.restoration_loads = 1;
566                if bytes.len() > config.snapshot_limit {
567                    // Fail safe without destroying data: an oversized snapshot
568                    // cannot be reasoned about in memory, so expose defaults
569                    // and let the next debounced save report the size error.
570                    diagnostics.restoration_load_failures = 1;
571                } else {
572                    diagnostics.snapshot_bytes = bytes.len();
573                    diagnostics.peak_snapshot_bytes = bytes.len();
574                    match decode_snapshot(&config, &bytes, &mut diagnostics) {
575                        Ok(decoded) => snapshot = decoded,
576                        Err(()) => diagnostics.restoration_load_failures += 1,
577                    }
578                }
579            }
580            Ok(None) => diagnostics.restoration_loads = 1,
581            Err(_) => {
582                diagnostics.restoration_loads = 1;
583                diagnostics.restoration_load_failures = 1;
584            }
585        }
586        diagnostics.restoration_values = snapshot.state.len();
587        diagnostics.restoration_scopes = scope_count(&snapshot.state);
588        diagnostics.dirty_generation = snapshot.snapshot_generation;
589        diagnostics.persisted_generation = snapshot.snapshot_generation;
590        let migrated = diagnostics.restoration_migrations > 0;
591        Self {
592            inner: Rc::new(RestorationManagerInner {
593                config,
594                state: std::cell::RefCell::new(ManagerState {
595                    snapshot,
596                    diagnostics,
597                    dirty: false,
598                    debounce_pending: false,
599                    save_in_flight: false,
600                    consecutive_save_failures: 0,
601                    retry_pending: false,
602                    persist_migrated_snapshot: migrated,
603                    active_scope_paths: HashSet::new(),
604                }),
605                scheduler: std::cell::RefCell::new(None),
606            }),
607        }
608    }
609
610    pub(crate) fn attach_scheduler(
611        &self,
612        scheduler: &Rc<std::cell::RefCell<tasks::TaskScheduler>>,
613    ) {
614        *self.inner.scheduler.borrow_mut() = Some(Rc::downgrade(scheduler));
615        // A load-time migration produced a document the store does not yet
616        // hold; persist it once so a write-less session does not re-run the
617        // migration on every launch.
618        if self.inner.state.borrow().persist_migrated_snapshot {
619            self.inner.state.borrow_mut().persist_migrated_snapshot = false;
620            self.mark_dirty();
621        }
622    }
623
624    pub(crate) fn read(&self, path: &[RestorationKey]) -> Option<Value> {
625        self.inner
626            .state
627            .borrow()
628            .snapshot
629            .state
630            .get(&encode_path(path))
631            .cloned()
632    }
633
634    pub(crate) fn write(&self, path: &[RestorationKey], value: Value) {
635        let encoded = encode_path(path);
636        let mut state = self.inner.state.borrow_mut();
637        if state.snapshot.state.get(&encoded) == Some(&value) {
638            return;
639        }
640        state.snapshot.state.insert(encoded, value);
641        refresh_counts(&mut state);
642        drop(state);
643        self.mark_dirty();
644    }
645
646    pub(crate) fn remove(&self, path: &[RestorationKey]) -> bool {
647        let mut state = self.inner.state.borrow_mut();
648        let removed = state.snapshot.state.remove(&encode_path(path)).is_some();
649        if removed {
650            refresh_counts(&mut state);
651        }
652        drop(state);
653        if removed {
654            self.mark_dirty();
655        }
656        removed
657    }
658
659    pub(crate) fn remove_scope(&self, path: &[RestorationKey]) -> usize {
660        let prefix = encode_path(path);
661        let prefix_with_separator = format!("{prefix}/");
662        let mut state = self.inner.state.borrow_mut();
663        let before = state.snapshot.state.len();
664        state
665            .snapshot
666            .state
667            .retain(|key, _| key != &prefix && !key.starts_with(&prefix_with_separator));
668        let removed = before - state.snapshot.state.len();
669        if removed > 0 {
670            refresh_counts(&mut state);
671        }
672        drop(state);
673        if removed > 0 {
674            self.mark_dirty();
675        }
676        removed
677    }
678
679    pub(crate) fn reset(&self) {
680        let mut state = self.inner.state.borrow_mut();
681        state.snapshot.state.clear();
682        state.snapshot.windows.clear();
683        refresh_counts(&mut state);
684        drop(state);
685        self.mark_dirty();
686    }
687
688    pub(crate) fn replace_windows(&self, windows: Vec<RestoredWindow>) {
689        let mut state = self.inner.state.borrow_mut();
690        if state.snapshot.windows == windows {
691            return;
692        }
693        state.snapshot.windows = windows;
694        drop(state);
695        self.mark_dirty();
696    }
697
698    #[must_use]
699    pub(crate) fn windows(&self) -> Vec<RestoredWindow> {
700        self.inner.state.borrow().snapshot.windows.clone()
701    }
702
703    #[must_use]
704    pub(crate) fn diagnostics(&self) -> RestorationDiagnostics {
705        self.inner.state.borrow().diagnostics.clone()
706    }
707
708    #[must_use]
709    pub(crate) fn store_location(&self) -> Option<PathBuf> {
710        self.inner.config.store.location()
711    }
712
713    pub(crate) fn note_restored_window(&self) {
714        self.inner.state.borrow_mut().diagnostics.restored_windows += 1;
715    }
716
717    pub(crate) fn note_skipped_window(&self) {
718        self.inner.state.borrow_mut().diagnostics.skipped_windows += 1;
719    }
720
721    pub(crate) fn note_navigation_restore(&self, restored_routes: u64, invalid_routes: u64) {
722        let mut state = self.inner.state.borrow_mut();
723        state.diagnostics.restored_routes += restored_routes;
724        state.diagnostics.invalid_routes += invalid_routes;
725    }
726
727    pub(crate) fn record_restoration_outcome(&self, restored: u64, invalid: u64) {
728        let mut state = self.inner.state.borrow_mut();
729        state.diagnostics.restored_scroll_positions += restored;
730        state.diagnostics.stale_or_invalid_values += invalid;
731    }
732
733    /// Removes one persisted auxiliary-window descriptor by stable id.
734    /// Returns true when a descriptor was removed and persistence was marked
735    /// dirty; unopened descriptors are otherwise left intact.
736    pub(crate) fn remove_window_descriptor(&self, restoration_id: &str) -> bool {
737        let mut state = self.inner.state.borrow_mut();
738        let before = state.snapshot.windows.len();
739        state
740            .snapshot
741            .windows
742            .retain(|window| window.restoration_id != restoration_id);
743        let removed = state.snapshot.windows.len() != before;
744        drop(state);
745        if removed {
746            self.mark_dirty();
747        }
748        removed
749    }
750
751    pub(crate) fn scope(&self) -> RestorationScope {
752        RestorationScope::root(Rc::new(self.clone()))
753    }
754
755    pub(crate) fn application_scope(&self) -> RestorationScope {
756        self.scope()
757            .child_unchecked(RestorationKey::new("application").expect("static key"))
758    }
759
760    /// Acquires a sibling scope identity for the duration of the returned
761    /// lease. A second concurrently mounted scope at the same stable path is
762    /// a programming error, never an auto-numbered persistence target.
763    pub(crate) fn acquire_scope(&self, path: &[RestorationKey]) -> Result<ScopeLease, String> {
764        let path = encode_path(path);
765        let mut state = self.inner.state.borrow_mut();
766        if !state.active_scope_paths.insert(path.clone()) {
767            return Err(format!("duplicate restoration sibling scope `{path}`"));
768        }
769        Ok(ScopeLease {
770            manager: Rc::downgrade(&self.inner),
771            path,
772        })
773    }
774
775    fn mark_dirty(&self) {
776        {
777            let mut state = self.inner.state.borrow_mut();
778            state.dirty = true;
779            state.diagnostics.dirty_generation = state.diagnostics.dirty_generation.wrapping_add(1);
780            state.snapshot.snapshot_generation = state.diagnostics.dirty_generation;
781            state.diagnostics.save_requests += 1;
782            state.retry_pending = false;
783            if state.save_in_flight || state.debounce_pending {
784                state.diagnostics.save_coalesced += 1;
785                return;
786            }
787            state.debounce_pending = true;
788        }
789        self.schedule_debounce();
790    }
791
792    fn schedule_debounce(&self) {
793        let Some(scheduler) = self
794            .inner
795            .scheduler
796            .borrow()
797            .as_ref()
798            .and_then(Weak::upgrade)
799        else {
800            return;
801        };
802        let manager = self.clone();
803        let debounce = self.inner.config.debounce;
804        tasks::TaskScheduler::spawner(&scheduler).spawn_into(
805            crate::executor::sleep(debounce),
806            move |result, _runtime| {
807                if result.is_ok() {
808                    manager.start_save();
809                }
810            },
811        );
812    }
813
814    pub(crate) fn flush(&self) {
815        {
816            let mut state = self.inner.state.borrow_mut();
817            state.debounce_pending = false;
818        }
819        self.start_save();
820    }
821
822    #[must_use]
823    #[cfg(not(target_arch = "wasm32"))]
824    pub(crate) fn is_clean(&self) -> bool {
825        let state = self.inner.state.borrow();
826        !state.dirty && !state.save_in_flight && !state.debounce_pending
827    }
828
829    fn start_save(&self) {
830        let (snapshot, generation) = {
831            let mut state = self.inner.state.borrow_mut();
832            state.debounce_pending = false;
833            if !state.dirty || state.save_in_flight {
834                return;
835            }
836            state.save_in_flight = true;
837            state.diagnostics.saves_started += 1;
838            (state.snapshot.clone(), state.diagnostics.dirty_generation)
839        };
840        let Some(scheduler) = self
841            .inner
842            .scheduler
843            .borrow()
844            .as_ref()
845            .and_then(Weak::upgrade)
846        else {
847            self.finish_save(
848                generation,
849                Err(RestorationStoreError::new(
850                    "runtime scheduler is unavailable",
851                )),
852                0,
853            );
854            return;
855        };
856        let store = self.inner.config.store.clone();
857        let limit = self.inner.config.snapshot_limit;
858        let manager = self.clone();
859        tasks::TaskScheduler::spawner(&scheduler).spawn_blocking(
860            move || {
861                let bytes = serde_json::to_vec(&snapshot)
862                    .map_err(|error| RestorationStoreError::new(error.to_string()))?;
863                if bytes.len() > limit {
864                    return Err(RestorationStoreError::new(format!(
865                        "restoration snapshot is {} bytes, exceeding configured {limit}-byte limit",
866                        bytes.len()
867                    )));
868                }
869                store.save(&bytes)?;
870                Ok(bytes.len())
871            },
872            move |result, _runtime| match result {
873                Ok(Ok(bytes)) => manager.finish_save(generation, Ok(()), bytes),
874                Ok(Err(error)) => manager.finish_save(generation, Err(error), 0),
875                Err(_) => manager.finish_save(
876                    generation,
877                    Err(RestorationStoreError::new(
878                        "restoration save task did not complete",
879                    )),
880                    0,
881                ),
882            },
883        );
884    }
885
886    fn finish_save(
887        &self,
888        generation: u64,
889        result: Result<(), RestorationStoreError>,
890        bytes: usize,
891    ) {
892        let needs_latest;
893        {
894            let mut state = self.inner.state.borrow_mut();
895            state.save_in_flight = false;
896            match result {
897                Ok(()) => {
898                    state.diagnostics.saves_completed += 1;
899                    state.diagnostics.last_save_error = None;
900                    state.consecutive_save_failures = 0;
901                    state.retry_pending = false;
902                    state.diagnostics.snapshot_bytes = bytes;
903                    state.diagnostics.peak_snapshot_bytes =
904                        state.diagnostics.peak_snapshot_bytes.max(bytes);
905                    state.diagnostics.persisted_generation = generation;
906                    state.dirty = state.diagnostics.dirty_generation != generation;
907                    needs_latest = state.dirty;
908                }
909                Err(error) => {
910                    state.diagnostics.save_failures += 1;
911                    state.diagnostics.last_save_error = Some(error.to_string());
912                    state.dirty = true;
913                    // Preserve the last known-good file and retry with bounded
914                    // backoff. A tight retry loop would defeat coalescing and
915                    // obscure crash-safety diagnostics, so a retry is scheduled
916                    // only while the bounded budget remains.
917                    state.consecutive_save_failures += 1;
918                    needs_latest = state.consecutive_save_failures <= MAX_RESTORATION_SAVE_RETRIES;
919                }
920            }
921        }
922        if needs_latest {
923            let retry = {
924                let mut state = self.inner.state.borrow_mut();
925                if !state.dirty || state.debounce_pending || state.save_in_flight {
926                    return;
927                }
928                let failed = state.consecutive_save_failures > 0;
929                state.retry_pending = failed;
930                failed
931            };
932            if retry {
933                self.schedule_retry();
934            } else {
935                self.schedule_after_in_flight();
936            }
937        }
938    }
939
940    fn schedule_retry(&self) {
941        let attempt = self
942            .inner
943            .state
944            .borrow()
945            .consecutive_save_failures
946            .clamp(1, MAX_RESTORATION_SAVE_RETRIES);
947        let delay = RESTORATION_RETRY_BACKOFF * attempt;
948        let Some(scheduler) = self
949            .inner
950            .scheduler
951            .borrow()
952            .as_ref()
953            .and_then(Weak::upgrade)
954        else {
955            return;
956        };
957        let manager = self.clone();
958        tasks::TaskScheduler::spawner(&scheduler).spawn_into(
959            crate::executor::sleep(delay),
960            move |result, _runtime| {
961                if result.is_ok() {
962                    let mut state = manager.inner.state.borrow_mut();
963                    state.retry_pending = false;
964                    if state.dirty && !state.save_in_flight && !state.debounce_pending {
965                        drop(state);
966                        manager.start_save();
967                    }
968                }
969            },
970        );
971    }
972
973    fn schedule_after_in_flight(&self) {
974        let mut state = self.inner.state.borrow_mut();
975        if state.debounce_pending || state.save_in_flight {
976            return;
977        }
978        state.debounce_pending = true;
979        state.diagnostics.save_coalesced += 1;
980        drop(state);
981        self.schedule_debounce();
982    }
983
984    pub(crate) fn debug_dump(&self) -> String {
985        let state = self.inner.state.borrow();
986        let diagnostics = &state.diagnostics;
987        let mut lines = vec![format!(
988            "Restoration generation={} persisted={} bytes={}",
989            diagnostics.dirty_generation,
990            diagnostics.persisted_generation,
991            diagnostics.snapshot_bytes
992        )];
993        let mut scopes: BTreeMap<String, usize> = BTreeMap::new();
994        for key in state.snapshot.state.keys() {
995            let mut segments = key.split('/').collect::<Vec<_>>();
996            let _ = segments.pop();
997            let scope = if segments.is_empty() {
998                "application".to_owned()
999            } else {
1000                segments.join("/")
1001            };
1002            *scopes.entry(scope).or_default() += 1;
1003        }
1004        for (scope, values) in scopes {
1005            lines.push(format!("  {scope}/ ({values} values)"));
1006        }
1007        lines.join("\n")
1008    }
1009}
1010
1011/// Duplicate-sibling guard held by a mounted restoration scope.
1012pub(crate) struct ScopeLease {
1013    manager: Weak<RestorationManagerInner>,
1014    path: String,
1015}
1016
1017impl Drop for ScopeLease {
1018    fn drop(&mut self) {
1019        if let Some(manager) = self.manager.upgrade() {
1020            manager
1021                .state
1022                .borrow_mut()
1023                .active_scope_paths
1024                .remove(&self.path);
1025        }
1026    }
1027}
1028
1029fn validate_application_id(application_id: &str) -> Result<(), RestorationStoreError> {
1030    let rejected = application_id.trim().is_empty()
1031        || application_id.contains(['/', '\\', '\0'])
1032        || application_id.chars().any(char::is_control)
1033        || application_id
1034            .split(['/', '\\'])
1035            .any(|segment| segment == "." || segment == "..");
1036    if rejected {
1037        return Err(RestorationStoreError::new(
1038            "restoration application id must be a stable filesystem-safe identifier",
1039        ));
1040    }
1041    Ok(())
1042}
1043
1044fn decode_snapshot(
1045    config: &RestorationConfig,
1046    bytes: &[u8],
1047    diagnostics: &mut RestorationDiagnostics,
1048) -> Result<SnapshotDocument, ()> {
1049    let mut value: Value = serde_json::from_slice(bytes).map_err(|_| ())?;
1050    let document: SnapshotDocument = serde_json::from_value(value.clone()).map_err(|_| ())?;
1051    if document.framework_format_version != FRAMEWORK_RESTORATION_FORMAT_VERSION
1052        || document.application_id != config.application_id
1053    {
1054        return Err(());
1055    }
1056    if document.application_schema_version != config.application_schema_version {
1057        let Some(migration) = &config.migration else {
1058            return Err(());
1059        };
1060        migration(document.application_schema_version, &mut value).map_err(|_| ())?;
1061        let mut migrated: SnapshotDocument = serde_json::from_value(value).map_err(|_| ())?;
1062        migrated.framework_format_version = FRAMEWORK_RESTORATION_FORMAT_VERSION;
1063        migrated.application_schema_version = config.application_schema_version;
1064        migrated.application_id = config.application_id.clone();
1065        diagnostics.restoration_migrations += 1;
1066        Ok(migrated)
1067    } else {
1068        Ok(document)
1069    }
1070}
1071
1072fn encode_path(path: &[RestorationKey]) -> String {
1073    path.iter()
1074        .map(|segment| segment.as_str().replace('~', "~0").replace('/', "~1"))
1075        .collect::<Vec<_>>()
1076        .join("/")
1077}
1078
1079fn scope_count(values: &BTreeMap<String, Value>) -> usize {
1080    values
1081        .keys()
1082        .filter_map(|key| {
1083            key.rsplit_once('/')
1084                .map(|(scope, _)| scope)
1085                .or(Some("application"))
1086        })
1087        .collect::<BTreeSet<_>>()
1088        .len()
1089}
1090
1091fn refresh_counts(state: &mut ManagerState) {
1092    state.diagnostics.restoration_values = state.snapshot.state.len();
1093    state.diagnostics.restoration_scopes = scope_count(&state.snapshot.state);
1094}
1095
1096/// Flutter's `RestorableInt`.
1097pub type RestorableInt = Restorable<i64>;
1098/// Flutter's `RestorableIntN`.
1099pub type RestorableIntN = Restorable<Option<i64>>;
1100/// Flutter's `RestorableDouble`.
1101pub type RestorableDouble = Restorable<f64>;
1102/// Flutter's `RestorableDoubleN`.
1103pub type RestorableDoubleN = Restorable<Option<f64>>;
1104/// Flutter's `RestorableBool`.
1105pub type RestorableBool = Restorable<bool>;
1106/// Flutter's `RestorableBoolN`.
1107pub type RestorableBoolN = Restorable<Option<bool>>;
1108/// Flutter's `RestorableString`.
1109pub type RestorableString = Restorable<String>;
1110/// Flutter's `RestorableStringN`.
1111pub type RestorableStringN = Restorable<Option<String>>;
1112
1113/// A typed, opt-in persistent value built on the existing local [`Signal`]
1114/// primitive. Mutations through this wrapper update the snapshot; an ordinary
1115/// `Signal` stays non-persistent by default.
1116#[derive(Clone)]
1117pub struct Restorable<T> {
1118    signal: Signal<T>,
1119    write: Rc<dyn Fn(&T)>,
1120    remove: Rc<dyn Fn()>,
1121}
1122
1123impl<T> Restorable<T>
1124where
1125    T: Clone + PartialEq + Serialize + DeserializeOwned + 'static,
1126{
1127    pub(crate) fn new(
1128        initial: T,
1129        write: impl Fn(&T) + 'static,
1130        remove: impl Fn() + 'static,
1131    ) -> Self {
1132        Self {
1133            signal: Signal::new(initial),
1134            write: Rc::new(write),
1135            remove: Rc::new(remove),
1136        }
1137    }
1138
1139    /// Reads `key` before returning the normal reactive signal wrapper. A
1140    /// missing, corrupt, or type-incompatible stored value exposes `default`
1141    /// without failing the build that requested it; a present but
1142    /// type-incompatible value is additionally counted as stale.
1143    #[must_use]
1144    pub fn from_scope(scope: RestorationScope, key: RestorationKey, default: T) -> Self {
1145        let stored = scope.get_json(&key);
1146        let initial = match stored {
1147            None => default,
1148            Some(value) => match serde_json::from_value(value) {
1149                Ok(initial) => initial,
1150                Err(_) => {
1151                    scope.note_restoration_outcome(0, 1);
1152                    default
1153                }
1154            },
1155        };
1156        let write_scope = scope.clone();
1157        let write_key = key.clone();
1158        let remove_scope = scope;
1159        Self::new(
1160            initial,
1161            move |value| {
1162                if let Ok(value) = serde_json::to_value(value) {
1163                    write_scope.set_json(&write_key, value);
1164                }
1165            },
1166            move || remove_scope.remove(&key),
1167        )
1168    }
1169
1170    #[must_use]
1171    pub fn get(&self) -> T {
1172        self.signal.get()
1173    }
1174
1175    pub fn set(&self, value: T) -> bool {
1176        if !self.signal.set(value.clone()) {
1177            return false;
1178        }
1179        (self.write)(&value);
1180        true
1181    }
1182
1183    pub fn update(&self, update: impl FnOnce(&mut T)) {
1184        let mut value = self.signal.get();
1185        update(&mut value);
1186        let _ = self.set(value);
1187    }
1188
1189    /// Stops restoring this value and removes its current persisted entry.
1190    pub fn remove(&self) {
1191        (self.remove)();
1192    }
1193
1194    /// Exposes the reactive read primitive. Write through `Restorable::set`
1195    /// or `update` so persistence remains opt-in and observable.
1196    #[must_use]
1197    pub fn signal(&self) -> Signal<T> {
1198        self.signal.clone()
1199    }
1200}
1201
1202impl RestorationBackend for RestorationManager {
1203    fn read_value(&self, path: &[RestorationKey]) -> Option<Value> {
1204        self.read(path)
1205    }
1206
1207    fn write_value(&self, path: &[RestorationKey], value: Value) {
1208        self.write(path, value);
1209    }
1210
1211    fn remove_value(&self, path: &[RestorationKey]) {
1212        let _ = self.remove(path);
1213    }
1214
1215    fn note_restoration_outcome(&self, restored: u64, invalid: u64) {
1216        self.record_restoration_outcome(restored, invalid);
1217    }
1218}