Files
openhuman/app/src-tauri/src/local_data_reset.rs
T

404 lines
19 KiB
Rust

use crate::core_process;
#[cfg(target_os = "windows")]
use crate::reset_reboot_schedule;
/// Reset the user's local OpenHuman data and bounce the embedded core.
///
/// Replaces the prior two-step UI flow that called the core JSON-RPC
/// `openhuman.config_reset_local_data` (in-process removal) followed by
/// `restart_core_process`. The in-process removal failed on Windows with
/// `ERROR_SHARING_VIOLATION` (os error 32) because the running core held
/// open handles to SQLite databases, log files, the Sentry session store,
/// etc. inside the directory it was being asked to delete — see
/// OPENHUMAN-TAURI-AF.
///
/// New order:
///
/// 1. Query the core for the **paths** it would remove (`config_get_data_paths`)
/// while the core is still up — these are derived from the loaded config
/// and the active workspace marker, so the core is authoritative.
/// 2. Acquire the restart lock so a concurrent `restart_core_process` cannot
/// interleave with the remove.
/// 3. Shut down the embedded core. `CoreProcessHandle::shutdown` cancels
/// the cancellation token and awaits the tokio task, which drops the
/// SQLite pool, log writer, etc. — releasing every Windows file handle.
/// 4. Remove the **active user's** slice from this process: the current data
/// dir plus the two shared root markers (`active_workspace.toml` and
/// `active_user.toml`). The shared root `~/.openhuman` is left in place so
/// other users' `users/<id>` subtrees survive. Missing entries are
/// non-fatal.
/// 5. Restart the embedded core via `ensure_running`.
///
/// Returns `Ok(())` only when the core is back up and the active user's data
/// is gone (or was already absent). Any step's `Err` short-circuits and
/// surfaces to the UI, which already renders the message as a toast.
#[tauri::command]
pub async fn reset_local_data(
state: tauri::State<'_, core_process::CoreProcessHandle>,
user_id: Option<String>,
) -> Result<(), String> {
log::info!(
"[core] reset_local_data: command invoked from frontend (explicit_user_id={})",
user_id.is_some()
);
// ── 1. Ask the core for the paths it would remove ────────────────────
//
// The core is authoritative for path resolution (it owns config
// loading, the workspace marker, and the staging-vs-prod default-dir
// suffix). Resolve while the core is still up so we don't duplicate
// that logic here.
//
// `user_id` is forwarded so the core resolves the signed-in user's
// `users/<id>` slice directly. The GUI clear flow signs the user out
// (clearing `active_user.toml`) *before* invoking us, so a marker-based
// resolution would fall back to the pre-login `users/local` dir and delete
// an empty directory — leaving the real user's memory/history intact
// (issue #4950). When absent (pre-login recovery), the core falls back to
// the marker as before.
let paths = fetch_data_paths(user_id).await?;
log::info!(
"[core] reset_local_data: paths resolved current={} default={} workspace_marker={} user_marker={}",
paths.current_openhuman_dir.display(),
paths.default_openhuman_dir.display(),
paths.active_workspace_marker_path.display(),
paths.active_user_marker_path.display()
);
// ── 2. Acquire the restart lock ─────────────────────────────────────
//
// Prevents a concurrent `restart_core_process` from re-spawning the
// embedded server in the middle of the remove step.
let _guard = state.inner().restart_lock().await;
log::debug!("[core] reset_local_data: acquired restart lock");
// ── 3. Shut down the embedded core ──────────────────────────────────
//
// Drops the tokio task, which drops the SQLite pool, log writer, and
// every other RAII owner of a file handle inside the data directory.
// On Windows this is the load-bearing step for OPENHUMAN-TAURI-AF.
state.inner().shutdown().await;
log::info!("[core] reset_local_data: embedded core stopped");
// ── 3b. Release the host-process log file handle (issue #1615) ──────
//
// The daily-rotating log appender at `<data_dir>/logs/openhuman-*.log`
// is owned by *this* Tauri host process, not by the embedded core
// tokio task — so `shutdown()` above does not release it. On Windows
// that lingering OS file handle causes `remove_dir_all(.openhuman)`
// below to fail with `ERROR_SHARING_VIOLATION` (os error 32). Drop
// the writer guard now so the background flushing thread exits and
// the file handle is closed before the removal walks the tree.
let log_guard_dropped = openhuman_core::core::logging::shutdown_file_guard();
log::info!("[core] reset_local_data: shutdown_file_guard dropped guard = {log_guard_dropped}");
// ── 4. Remove the paths ─────────────────────────────────────────────
//
// Missing entries are non-fatal: the user may already have manually
// cleared the dir, or the marker may not exist for fresh installs.
//
// Capture the first delete error (if any) instead of propagating with
// `?` — we must still restart the embedded core in step 5 so the app
// doesn't end up with the sidecar dead. The original delete error is
// surfaced after the restart attempt.
//
// Scoping (issue: "Clear App Data" wiped every user, not just the active
// one): all user data lives under `~/.openhuman/users/<id>` and the shared
// root `~/.openhuman` holds every user's subtree. So we remove ONLY:
// * the two shared root-level marker files — `active_workspace.toml`
// (workspace pointer) and `active_user.toml` (sign-out), and
// * the active user's own directory (`current_openhuman_dir`).
// We must NOT `remove_dir_all` the shared root `default_openhuman_dir`:
// that destroyed sibling accounts' `users/<other>` data. Removing the
// active-user marker is what now signs the user out (previously this only
// happened as a side effect of nuking the root).
let delete_result: Result<(), String> = async {
remove_path_if_exists(
&paths.active_workspace_marker_path,
"active workspace marker",
)
.await?;
remove_path_if_exists(&paths.active_user_marker_path, "active user marker").await?;
remove_dir_if_exists(&paths.current_openhuman_dir, "current openhuman dir").await?;
Ok(())
}
.await;
if let Err(ref e) = delete_result {
log::warn!("[core] reset_local_data: delete step failed: {e}; will still restart core");
}
// ── 5. Restart the embedded core ────────────────────────────────────
//
// Always attempt restart, even if delete failed — otherwise the user
// is left with a dead sidecar. If restart itself fails, prefer the
// original delete error (more actionable) over the restart error.
let restart_result = state.inner().ensure_running().await;
match (&delete_result, &restart_result) {
(Ok(()), Ok(())) => log::info!("[core] reset_local_data: embedded core back up"),
(Err(_), Ok(())) => log::warn!(
"[core] reset_local_data: core restarted but delete step failed; surfacing delete error"
),
(Ok(()), Err(e)) => log::error!("[core] reset_local_data: core restart failed: {e}"),
(Err(_), Err(e)) => log::error!(
"[core] reset_local_data: both delete and restart failed; restart error: {e}"
),
}
delete_result?;
restart_result?;
Ok(())
}
/// Resolved data paths returned by `config_get_data_paths`.
struct ResolvedDataPaths {
current_openhuman_dir: std::path::PathBuf,
/// The shared root `~/.openhuman`. Retained for logging/diagnostics only —
/// the reset must NOT delete it, because it holds every user's
/// `users/<id>` subtree (deleting it wiped sibling accounts' data).
default_openhuman_dir: std::path::PathBuf,
active_workspace_marker_path: std::path::PathBuf,
/// `~/.openhuman/active_user.toml` — the shared active-user marker. Removed
/// to sign the current user out so the next launch boots pre-login.
active_user_marker_path: std::path::PathBuf,
}
fn is_windows_file_lock_raw_os_error(raw_os_error: Option<i32>) -> bool {
matches!(raw_os_error, Some(32 | 33))
}
fn is_windows_file_lock_error(error: &std::io::Error) -> bool {
cfg!(windows) && is_windows_file_lock_raw_os_error(error.raw_os_error())
}
/// Returns:
/// * `Ok(())` — the underlying remove failure should be swallowed (e.g.
/// the path disappeared between the failed `remove_*` call and the
/// reboot-fallback walk, so there is nothing left to clean up).
/// * `Err(msg)` — a user-facing failure message the caller should surface
/// to the UI / propagate up the reset flow.
fn reset_local_data_delete_error(
label: &str,
path: &std::path::Path,
error: &std::io::Error,
) -> Result<(), String> {
if is_windows_file_lock_error(error) {
log::warn!(
"[core] reset_local_data: Windows file lock blocked removal of {label} at {}: {error}",
path.display()
);
// Fallback: queue the still-locked sub-tree for deletion on the
// next Windows boot via MoveFileExW + MOVEFILE_DELAY_UNTIL_REBOOT.
// By this point in `reset_local_data` we have already:
// * shut down the embedded core (drops every SQLite/log handle
// the core task held), and
// * released the host-process log appender via
// `shutdown_file_guard()` (drops the rolling log file handle).
// So any remaining lock now comes from *outside* this process —
// anti-virus / file indexer / sibling app / Explorer — and cannot
// be released by closing more OpenHuman windows. See issue #1615.
#[cfg(target_os = "windows")]
{
return schedule_reboot_delete_or_describe(label, path, error);
}
// `is_windows_file_lock_error` is gated on `cfg!(windows)`, so on
// Linux/macOS this branch is unreachable at runtime — but cargo
// still type-checks the file for those targets and needs a value
// of type `String`.
#[cfg(not(target_os = "windows"))]
{
return Err(format!(
"Failed to remove {label} at {} because it is locked by another OpenHuman window or process. Close all OpenHuman windows and try again. ({error})",
path.display()
));
}
}
Err(format!(
"Failed to remove {label} at {}: {error}",
path.display()
))
}
/// Windows-only: ask the session manager to delete `path` (and its
/// children if it is a directory) on the next reboot, and return either a
/// user-facing message describing the outcome or `Ok(())` when the
/// underlying failure should be treated as already-cleaned-up.
#[cfg(target_os = "windows")]
fn schedule_reboot_delete_or_describe(
label: &str,
path: &std::path::Path,
original_error: &std::io::Error,
) -> Result<(), String> {
match reset_reboot_schedule::schedule_path_for_reboot_deletion(path) {
Ok(summary) => {
log::info!(
"[core] reset_local_data: scheduled {label} at {} for reboot deletion (files={}, dirs={})",
path.display(),
summary.files,
summary.dirs
);
Err(format!(
"Couldn't remove {label} at {} right now because another process is holding it open ({original_error}). {} files and {} folders have been queued for deletion the next time you restart Windows — restart soon to finish the reset.",
path.display(),
summary.files,
summary.dirs,
))
}
// Race condition: the still-locked path disappeared between the
// `remove_*` call that failed with `ERROR_SHARING_VIOLATION` and
// the metadata read inside the reboot-schedule walk. Whoever else
// held the handle has already finished cleaning up, so the reset
// goal is achieved — swallow the original lock error and treat
// this as success. The empty partial schedule (no entries queued
// yet) is what distinguishes "vanished cleanly" from "started
// walking, then hit a real error."
Err(failure)
if failure.error.kind() == std::io::ErrorKind::NotFound
&& failure.partial.total() == 0 =>
{
log::info!(
"[core] reset_local_data: {label} at {} disappeared between lock failure and reboot fallback; treating as removed",
path.display(),
);
Ok(())
}
Err(failure) => {
let partial_total = failure.partial.total();
log::error!(
"[core] reset_local_data: reboot delete fallback failed for {label} at {}: {} (partial schedule: files={}, dirs={})",
path.display(),
failure.error,
failure.partial.files,
failure.partial.dirs,
);
if partial_total == 0 {
Err(format!(
"Failed to remove {label} at {} because it is locked by another OpenHuman window or process, and scheduling deletion on next reboot also failed ({}). Close all OpenHuman windows and try again. ({original_error})",
path.display(),
failure.error,
))
} else {
Err(format!(
"Failed to remove {label} at {} because it is locked by another OpenHuman window or process. {} files and {} folders were queued for the next reboot before scheduling failed ({}); the rest still needs manual cleanup. Close all OpenHuman windows and try again. ({original_error})",
path.display(),
failure.partial.files,
failure.partial.dirs,
failure.error,
))
}
}
}
}
/// Call the core's `config_get_data_paths` RPC and parse the response.
///
/// When `user_id` is `Some`, the core resolves the target dir from that id
/// (`users/<id>`) rather than the active-user marker — see the note on
/// `reset_local_data` and issue #4950. Passing `null` preserves the legacy
/// marker-based resolution for pre-login recovery.
async fn fetch_data_paths(user_id: Option<String>) -> Result<ResolvedDataPaths, String> {
let url = crate::core_rpc::core_rpc_url_value();
// Only include `user_id` when present so the pre-login recovery path sends
// the exact same empty `params: {}` it always has (marker-based resolution).
let mut params = serde_json::Map::new();
if let Some(id) = user_id {
params.insert("user_id".to_string(), serde_json::Value::String(id));
}
let body = serde_json::json!({
"jsonrpc": "2.0",
"id": 1,
"method": "openhuman.config_get_data_paths",
"params": serde_json::Value::Object(params),
});
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(10))
.build()
.map_err(|e| format!("config_get_data_paths client build failed: {e}"))?;
let req = crate::core_rpc::apply_auth(client.post(&url))?;
let res = req
.json(&body)
.send()
.await
.map_err(|e| format!("config_get_data_paths request failed: {e}"))?;
if !res.status().is_success() {
return Err(format!("config_get_data_paths http {}", res.status()));
}
let envelope: serde_json::Value = res
.json()
.await
.map_err(|e| format!("config_get_data_paths decode failed: {e}"))?;
// JSON-RPC envelope wraps the `RpcOutcome` result twice:
// `{ "result": { "result": { ...paths... }, "logs": [...] } }`.
let inner = envelope
.pointer("/result/result")
.ok_or_else(|| "config_get_data_paths missing /result/result".to_string())?;
let current = inner
.get("current_openhuman_dir")
.and_then(|v| v.as_str())
.ok_or_else(|| "config_get_data_paths missing current_openhuman_dir".to_string())?;
let default = inner
.get("default_openhuman_dir")
.and_then(|v| v.as_str())
.ok_or_else(|| "config_get_data_paths missing default_openhuman_dir".to_string())?;
let marker = inner
.get("active_workspace_marker_path")
.and_then(|v| v.as_str())
.ok_or_else(|| "config_get_data_paths missing active_workspace_marker_path".to_string())?;
let user_marker = inner
.get("active_user_marker_path")
.and_then(|v| v.as_str())
.ok_or_else(|| "config_get_data_paths missing active_user_marker_path".to_string())?;
Ok(ResolvedDataPaths {
current_openhuman_dir: std::path::PathBuf::from(current),
default_openhuman_dir: std::path::PathBuf::from(default),
active_workspace_marker_path: std::path::PathBuf::from(marker),
active_user_marker_path: std::path::PathBuf::from(user_marker),
})
}
/// Remove a regular file if present. Missing → debug log + Ok.
async fn remove_path_if_exists(path: &std::path::Path, label: &str) -> Result<(), String> {
match tokio::fs::remove_file(path).await {
Ok(()) => {
log::info!(
"[core] reset_local_data: removed {label} at {}",
path.display()
);
Ok(())
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
log::debug!(
"[core] reset_local_data: {label} already absent at {}",
path.display()
);
Ok(())
}
Err(e) => reset_local_data_delete_error(label, path, &e),
}
}
/// Remove a directory tree if present. Missing → debug log + Ok.
async fn remove_dir_if_exists(path: &std::path::Path, label: &str) -> Result<(), String> {
match tokio::fs::remove_dir_all(path).await {
Ok(()) => {
log::info!(
"[core] reset_local_data: removed {label} at {}",
path.display()
);
Ok(())
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
log::debug!(
"[core] reset_local_data: {label} already absent at {}",
path.display()
);
Ok(())
}
Err(e) => reset_local_data_delete_error(label, path, &e),
}
}
#[cfg(test)]
#[path = "local_data_reset_tests.rs"]
mod tests;