From d6090efcac1481c2af34815d3dbd3f03edbe9abe Mon Sep 17 00:00:00 2001 From: Sahil Garg Date: Wed, 23 Sep 2026 23:10:32 +0530 Subject: [PATCH 1/2] fix(eid-wallet): settle the settings store before the app is killed Closing the app from the background intermittently signed the user out, dropping them back to the create-or-restore splash with their vault, user, PIN and document gone. The settings store is saved by tauri-plugin-store with fs::write, which opens the file with O_TRUNC, so the previous contents are discarded before the new bytes are written. Auto-save is debounced and Android kills backgrounded apps with SIGKILL, so a save is frequently still in flight when the kill lands. Interrupting that write leaves global-state.json at zero bytes, and the next launch reads an empty store, concludes this is a first run and persists a fresh onboarding state over the top, which makes the loss permanent. An app is only killed after it has been backgrounded, and the webview still receives visibilitychange at that point. Use it as the settling point: flush the store so no debounced write remains outstanding, then copy the settled file into a sidecar backup written atomically via a temp file, fsync and rename. Once that completes there is no write left for a kill to interrupt. Loading falls back to the sidecar when the primary file cannot be parsed, which covers a kill that arrives before the flush finishes; the backup then still holds the previous complete state. Recovery keys off unparseable bytes rather than an absent or empty cache, so a session the user ended deliberately is never resurrected: clearing the store on logout serialises to "{}", which is valid JSON and is passed through untouched. Backing up refuses to copy a primary that does not parse, so a damaged file cannot overwrite a good backup. With no readable backup the original parse error is returned and a genuine first launch still runs normal setup instead of receiving invented state. All of this runs at backgrounding only. The startup and steady-state save paths are untouched, so launch performs no extra filesystem work. Fixes #1143 --- .../eid-wallet/src-tauri/src/lib.rs | 142 +++++++++++++++++- .../eid-wallet/src/lib/global/state.ts | 31 ++++ .../eid-wallet/src/routes/+layout.svelte | 13 ++ 3 files changed, 184 insertions(+), 2 deletions(-) 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..c70ebd8ec 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(); 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); } From b8c609584bdf9d67ffbf40869ba6e6eca5106410 Mon Sep 17 00:00:00 2001 From: Sahil Garg Date: Thu, 24 Sep 2026 10:05:18 +0530 Subject: [PATCH 2/2] fix(eid-wallet): refresh the store backup when the session is reset The backup is a copy of the store as it was at the last backgrounding, so between a reset and the next backgrounding it still held the user, vault and PIN hash of the session that just ended. Recovery keys off unparseable bytes, so a kill that truncated the primary file in that window restored the backup and brought the ended session back with its credentials intact. Settle the cleared store and refresh the backup as part of the reset, which bounds that window to the reset itself. The refresh sits outside the catch so a controller that throws part-way through clearing cannot leave the stale copy behind. --- infrastructure/eid-wallet/src/lib/global/state.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/infrastructure/eid-wallet/src/lib/global/state.ts b/infrastructure/eid-wallet/src/lib/global/state.ts index c70ebd8ec..d1287b945 100644 --- a/infrastructure/eid-wallet/src/lib/global/state.ts +++ b/infrastructure/eid-wallet/src/lib/global/state.ts @@ -202,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; }