Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
142 changes: 140 additions & 2 deletions infrastructure/eid-wallet/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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/
Expand Down Expand Up @@ -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<PathBuf> = 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::<serde_json::Value>(&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(
Comment thread
Sahil2004 marked this conversation as resolved.
bytes: &[u8],
) -> std::result::Result<
std::collections::HashMap<String, serde_json::Value>,
Box<dyn std::error::Error + Send + Sync>,
> {
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()),
}
Comment thread
Sahil2004 marked this conversation as resolved.
}

#[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())?;
Expand All @@ -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");
Expand Down
41 changes: 41 additions & 0 deletions infrastructure/eid-wallet/src/lib/global/state.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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<void> {
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() {
Comment thread
Sahil2004 marked this conversation as resolved.
try {
await this.securityController.clear();
Expand All @@ -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;
}
Expand Down
13 changes: 13 additions & 0 deletions infrastructure/eid-wallet/src/routes/+layout.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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(
Expand Down Expand Up @@ -281,6 +293,7 @@ onMount(async () => {

// Cleanup global event listeners
onDestroy(() => {
document.removeEventListener("visibilitychange", handleVisibilityChange);
if (typeof globalDeepLinkHandler !== "undefined") {
window.removeEventListener("deepLinkReceived", globalDeepLinkHandler);
}
Expand Down
Loading