diff --git a/infrastructure/eid-wallet/src-tauri/src/lib.rs b/infrastructure/eid-wallet/src-tauri/src/lib.rs index 455a18806..f3c3dba3a 100644 --- a/infrastructure/eid-wallet/src-tauri/src/lib.rs +++ b/infrastructure/eid-wallet/src-tauri/src/lib.rs @@ -2,6 +2,10 @@ mod errors; mod funcs; use std::env; +use std::fs; +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::sync::OnceLock; use uuid::Uuid; // Learn more about Tauri commands at https://tauri.app/develop/calling-rust/ @@ -89,15 +93,148 @@ fn log_to_terminal(level: String, message: String) { } } +// --------------------------------------------------------------------------- +// Crash-safe persistence for the settings store. +// +// Closing the app from the background intermittently signed the user out. +// `tauri-plugin-store` saves with `fs::write`, which opens the file with +// O_TRUNC, so the old contents are discarded before the new bytes land. Its +// auto-save is debounced, and Android kills backgrounded apps with SIGKILL, so +// a save is often still in flight when the kill arrives. Landing in that +// window leaves `global-state.json` at zero bytes with every setting gone, and +// the next launch reads an empty store and starts onboarding from scratch. +// +// An app is only killed after it has been backgrounded, and the webview still +// receives `visibilitychange` at that point. That is a guaranteed safe moment: +// the frontend flushes the store there and then calls `backup_store_file`, so +// no pending write is left for a kill to interrupt and a complete copy exists +// beside the real file. +// +// Nothing here runs during startup or normal use, which keeps the launch path +// free of extra filesystem work. +// --------------------------------------------------------------------------- + +/// Absolute path of the store file, resolved once during setup. +/// +/// `DeserializeFn` is a plain `fn` pointer and cannot capture state, so the +/// location has to come from a global. The app opens a single store, so one +/// slot is unambiguous. +static STORE_PATH: OnceLock = OnceLock::new(); + +const STORE_FILE_NAME: &str = "global-state.json"; + +fn backup_path_for(primary: &Path) -> PathBuf { + let mut name = primary.file_name().unwrap_or_default().to_os_string(); + name.push(".bak"); + primary.with_file_name(name) +} + +/// Write `bytes` to `path` so that a crash can never expose a partial file. +/// +/// The data lands in a temp file which is fsynced before being renamed over +/// the destination. `rename` is atomic on POSIX, so a reader sees either the +/// previous file or the complete new one, never a half-written one. +fn write_file_atomically(path: &Path, bytes: &[u8]) -> std::io::Result<()> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + + let mut temp_name = path.file_name().unwrap_or_default().to_os_string(); + temp_name.push(".tmp"); + let temp_path = path.with_file_name(temp_name); + + { + let mut file = fs::File::create(&temp_path)?; + file.write_all(bytes)?; + // A rename is only useful if the bytes it publishes have actually been + // committed to storage. + file.sync_all()?; + } + + fs::rename(&temp_path, path) +} + +/// Copy the current store file into its sidecar backup. +/// +/// Called when the app is backgrounded, after the frontend has flushed any +/// pending save, so the bytes being copied are the settled ones. Unparseable +/// content is refused: a damaged primary must never overwrite a good backup. +#[tauri::command] +fn backup_store_file() -> Result<(), String> { + let primary = STORE_PATH + .get() + .ok_or_else(|| "store path unavailable".to_string())?; + + let bytes = fs::read(primary).map_err(|error| format!("read failed: {error}"))?; + + if serde_json::from_slice::(&bytes).is_err() { + return Err(format!( + "primary is not valid JSON ({} bytes), keeping previous backup", + bytes.len() + )); + } + + write_file_atomically(&backup_path_for(primary), &bytes) + .map_err(|error| format!("backup write failed: {error}")) +} + +/// Decode the store, falling back to the sidecar backup when the primary file +/// cannot be parsed. +/// +/// The fallback keys off *unparseable bytes*, never off an empty or missing +/// cache. Clearing the store on logout serialises to `{}`, which is valid JSON +/// and is passed through untouched, so a session the user ended deliberately is +/// never resurrected. Only a half-written file fails to parse, and no code path +/// writes one intentionally. With no readable backup the original parse error +/// is returned so a genuine first launch still runs normal setup rather than +/// receiving invented state. +fn deserialize_with_recovery( + bytes: &[u8], +) -> std::result::Result< + std::collections::HashMap, + Box, +> { + let primary_error = match serde_json::from_slice(bytes) { + Ok(cache) => return Ok(cache), + Err(error) => error, + }; + + let Some(primary) = STORE_PATH.get() else { + return Err(primary_error.into()); + }; + + let Ok(backup_bytes) = fs::read(backup_path_for(primary)) else { + return Err(primary_error.into()); + }; + + match serde_json::from_slice(&backup_bytes) { + Ok(cache) => Ok(cache), + Err(_) => Err(primary_error.into()), + } +} + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() .plugin(tauri_plugin_process::init()) .plugin(tauri_plugin_opener::init()) - .plugin(tauri_plugin_store::Builder::new().build()) + .plugin( + tauri_plugin_store::Builder::new() + .default_deserialize_fn(deserialize_with_recovery) + .build(), + ) .plugin(tauri_plugin_deep_link::init()) .plugin(tauri_plugin_notifications::init()) .setup(move |_app| { + // Resolve the store location before any store access, so the + // recovery hook can find the sidecar backup. + { + use tauri::Manager; + if let Ok(dir) = _app.path().app_data_dir() { + let _ = STORE_PATH.set(dir.join(STORE_FILE_NAME)); + } + } + #[cfg(mobile)] { _app.handle().plugin(tauri_plugin_biometric::init())?; @@ -112,7 +249,8 @@ pub fn run() { verify, get_device_id, get_platform, - log_to_terminal + log_to_terminal, + backup_store_file ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/infrastructure/eid-wallet/src/lib/global/state.ts b/infrastructure/eid-wallet/src/lib/global/state.ts index 27af96640..d1287b945 100644 --- a/infrastructure/eid-wallet/src/lib/global/state.ts +++ b/infrastructure/eid-wallet/src/lib/global/state.ts @@ -1,3 +1,4 @@ +import { invoke } from "@tauri-apps/api/core"; import { Store } from "@tauri-apps/plugin-store"; import type { CryptoAdapter } from "wallet-sdk"; import NotificationService from "../services/NotificationService"; @@ -158,6 +159,36 @@ export class GlobalState { }); } + /** + * Settle the store to disk and refresh its crash-safe backup copy. + * + * Call this when the app is backgrounded. The store plugin saves on a + * debounce, and Android kills backgrounded apps outright, so a pending + * save can still be in flight when the process dies. The plugin writes + * with a truncate-then-write, which leaves the file empty if it is + * interrupted midway, losing every persisted setting. + * + * Backgrounding is the last moment the app is reliably alive, so flushing + * here means there is no pending write left for a kill to interrupt. The + * backup taken afterwards covers the case where the kill lands before this + * finishes: it still holds the previous, complete state. + * + * Never throws. Failing to persist must not break the app going to sleep. + */ + async flushToDisk(): Promise { + try { + await this.#store.save(); + } catch (error) { + console.error("Failed to flush store on background:", error); + } + + try { + await invoke("backup_store_file"); + } catch (error) { + console.error("Failed to back up store on background:", error); + } + } + async reset() { try { await this.securityController.clear(); @@ -171,6 +202,16 @@ export class GlobalState { } catch (error) { console.error("Failed to reset global state:", error); } + + // Settle the cleared store and refresh the backup immediately. + // + // The backup is a copy of the previous contents, so until it is + // refreshed it still holds the user, vault and PIN hash of the session + // just ended. A kill that damages the primary before the next + // backgrounding would otherwise restore that ended session from the + // stale copy. Overwriting the backup here bounds that window to this + // call instead of leaving it open until the app is next backgrounded. + await this.flushToDisk(); const newGlobalState = await GlobalState.create(); return newGlobalState; } diff --git a/infrastructure/eid-wallet/src/routes/+layout.svelte b/infrastructure/eid-wallet/src/routes/+layout.svelte index 45f892de4..19a38877d 100644 --- a/infrastructure/eid-wallet/src/routes/+layout.svelte +++ b/infrastructure/eid-wallet/src/routes/+layout.svelte @@ -73,6 +73,12 @@ setContext("setGlobalState", (value: GlobalState | undefined) => { globalState = value; }); +function handleVisibilityChange() { + if (document.visibilityState === "hidden") { + globalState?.flushToDisk(); + } +} + onMount(async () => { // Bundle preload for the routes the splash CTAs reach — keeps the // first navigation snappy on cold start. @@ -96,6 +102,12 @@ onMount(async () => { // Consider adding fallback behavior or user notification } + // Settle the store to disk whenever the app is backgrounded. Android only + // kills apps once they are in the background, so this is the last moment + // the app is reliably alive, and flushing here leaves no pending write for + // a kill to interrupt and truncate. + document.addEventListener("visibilitychange", handleVisibilityChange); + // Handle deep links try { const { onOpenUrl, getCurrent } = await import( @@ -281,6 +293,7 @@ onMount(async () => { // Cleanup global event listeners onDestroy(() => { + document.removeEventListener("visibilitychange", handleVisibilityChange); if (typeof globalDeepLinkHandler !== "undefined") { window.removeEventListener("deepLinkReceived", globalDeepLinkHandler); }