From 4e5ac475d33d144660813f08b1975daadddac625 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 23 Jul 2026 17:24:34 +0000 Subject: [PATCH] =?UTF-8?q?gui:=20Danger=20Zone=20in=20the=20Updates=20tab?= =?UTF-8?q?=20=E2=80=94=20forget-all=20/=20factory=20reset?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an in-app way to reset a node, so recovering a wedged install doesn't mean hand-deleting files under ~/.myownmesh. A "Danger Zone" card at the bottom of Settings → Updates offers two graduated, two-click-armed actions: - **Forget all meshes** — leave and purge every network (rosters + signed governance state), keeping the device identity. - **Factory reset** — wipe the entire state directory (identity, config, all networks); the device becomes brand-new to every peer. Both reboot the whole stack when they fire. The daemon is the real datastore, so a reset that only deletes files would be undone by in-memory caches re-persisting on the next write — the classic "I deleted it but it came back". So each command wipes on disk, then the daemon exits; the GUI relaunches the app, and a fresh daemon comes up on clean state (via the existing ensure_daemon_running path, or the supervising service). Backend (daemon control protocol): - `Request::ForgetAllNetworks` — bulk `NetworkRemove{purge:true}` over every network in the registry. - `Request::FactoryReset` — quiesce each network, then `remove_dir_all` the state dir. - `schedule_daemon_exit()` — both exit shortly after the response flushes. GUI: `mesh_forget_all_networks` / `mesh_factory_reset` / `restart_app` Tauri commands (the last waits out the daemon's exit, then `app.restart()`), `meshClient.forgetAllNetworksAndRestart` / `factoryResetAndRestart`, and the Danger Zone UI. Note: the wipe + backend compile/clippy/type-check clean, but the reboot lifecycle (daemon self-exit → app relaunch → fresh daemon) can't be exercised in CI and wants a smoke test on a real desktop install. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_018x1RV6ppUdMDTHxScVo26L --- crates/myownmesh/src/control.rs | 89 +++++++++++ gui/src-tauri/src/control_client.rs | 7 + gui/src-tauri/src/main.rs | 43 +++++ gui/src/mesh-client.svelte.ts | 20 +++ gui/src/ui/settings/UpdatesSection.svelte | 183 ++++++++++++++++++++++ 5 files changed, 342 insertions(+) diff --git a/crates/myownmesh/src/control.rs b/crates/myownmesh/src/control.rs index 4f3bc40..5d04d47 100644 --- a/crates/myownmesh/src/control.rs +++ b/crates/myownmesh/src/control.rs @@ -109,6 +109,21 @@ pub enum Request { #[serde(default)] purge: bool, }, + /// Forget **every** joined network at once — a `NetworkRemove{purge:true}` + /// for all of them: tear each out of the registry, `leave()` its driver, and + /// delete its signed governance state + roster from disk. Keeps this device's + /// identity. The daemon then exits (see [`schedule_daemon_exit`]) so every + /// layer reloads from the now-clean disk instead of a stale in-memory cache + /// that would re-persist ("resurrect") what was just removed; the GUI/service + /// brings a fresh daemon back up. + ForgetAllNetworks, + /// Factory reset — wipe this device's **entire** state directory + /// (`~/.myownmesh`, honouring `MYOWNMESH_HOME`): identity, config, and every + /// network's roster + governance state. The device becomes a brand-new + /// identity to every peer. Quiesces each network first (so nothing + /// re-persists mid-wipe), then removes the tree and exits so a fresh daemon + /// mints a new identity on empty state. + FactoryReset, /// Update an already-joined network's config in place. Hot- /// reloadable changes (topology / label / auto_approve / roster /// path) are applied without dropping any peer; transport-level @@ -1014,6 +1029,14 @@ async fn dispatch(state: &Arc, req: Request) -> Response { info!(%network, purge, "control: network_remove"); network_remove(state, &network, purge).await } + Request::ForgetAllNetworks => { + info!("control: forget_all_networks"); + forget_all_networks(state).await + } + Request::FactoryReset => { + info!("control: factory_reset"); + factory_reset(state).await + } Request::NetworkUpdate { config } => { info!(network = %config.network_id, config_id = %config.id, "control: network_update"); network_update(state, config).await @@ -1814,6 +1837,72 @@ async fn network_remove(state: &Arc, key: &str, purge: bool) -> Re } } +/// Forget every joined network at once — the bulk `NetworkRemove{purge:true}`. +/// Each network is torn down live and its signed state + roster deleted from +/// disk; the device identity is kept. Snapshots the set first so removing as we +/// go can't skip an entry. Schedules a daemon exit ([`schedule_daemon_exit`]) so +/// every layer reloads clean around the wipe. +async fn forget_all_networks(state: &Arc) -> Response { + let mut forgotten = Vec::new(); + for n in state.registry.summaries() { + // `network_remove` resolves either alias; the config id is stable. + let _ = network_remove(state, &n.config_id, true).await; + forgotten.push(n.config_id); + } + schedule_daemon_exit(); + Response::ok(serde_json::json!({ "forgotten": forgotten, "restarting": true })) +} + +/// Factory reset — return this device to a brand-new state. First quiesce every +/// network (tear it down + purge its files) so nothing re-persists mid-wipe, +/// then remove the whole state directory (identity, config, and any leftovers), +/// and finally exit so a fresh daemon mints a new identity on empty state. The +/// live control socket + log file descriptors stay valid until exit; the +/// supervising service, or the GUI's `ensure_daemon_running` on relaunch, brings +/// the daemon back. Best-effort per step — we always schedule the exit so a +/// partial failure still ends in a clean restart rather than a half-wiped daemon +/// re-persisting stale caches. +async fn factory_reset(state: &Arc) -> Response { + // Quiesce writers first: tearing each network down stops its engine driver + // from writing a roster/state file back out while we're deleting the tree. + for n in state.registry.summaries() { + let _ = network_remove(state, &n.config_id, true).await; + } + let dir = match myownmesh_core::dirs::data_dir() { + Ok(d) => d, + Err(e) => { + // Can't find the dir to wipe — still restart so we don't leave the + // caller hanging on a half-done reset. + schedule_daemon_exit(); + return Response::err(format!("factory reset: resolve state dir: {e}")); + } + }; + if let Err(e) = std::fs::remove_dir_all(&dir) { + // A missing dir already reads as reset; anything else is worth logging, + // but we still exit so caches can't resurrect what did get deleted. + if e.kind() != std::io::ErrorKind::NotFound { + warn!(dir = %dir.display(), "factory reset: remove_dir_all: {e:#}"); + } + } + schedule_daemon_exit(); + Response::ok(serde_json::json!({ "reset": true, "restarting": true })) +} + +/// Exit the daemon shortly after the current response flushes, so a fresh +/// instance reloads from the now-clean disk. The reset commands use this: the +/// only reliable way to drop every in-memory cache — which would otherwise +/// re-persist and "resurrect" the state we just deleted — is a clean process +/// restart. The short delay lets the JSON response reach the client first; the +/// supervising service (Restart=always) or the GUI's `ensure_daemon_running` on +/// relaunch starts a fresh daemon. +fn schedule_daemon_exit() { + tokio::spawn(async { + tokio::time::sleep(std::time::Duration::from_millis(400)).await; + info!("reset complete — exiting so a fresh daemon reloads clean state"); + std::process::exit(0); + }); +} + /// Reconnect a joined network in place — the non-destructive twin of /// [`network_remove`] + [`network_add`]. Hands the live `JoinedNetwork` a /// reconnect request (redial signaling + renegotiate ICE) without leaving the diff --git a/gui/src-tauri/src/control_client.rs b/gui/src-tauri/src/control_client.rs index 4a4140f..240c3f5 100644 --- a/gui/src-tauri/src/control_client.rs +++ b/gui/src-tauri/src/control_client.rs @@ -77,6 +77,13 @@ pub enum Request { NetworkRemove { network: String, }, + /// Forget every joined network at once (purges each network's signed state + + /// roster; keeps the device identity). The daemon exits afterward so it + /// reloads clean — the GUI restarts the stack. + ForgetAllNetworks, + /// Wipe this device's entire state directory (identity, config, all + /// networks) and exit for a fresh start — a factory reset. + FactoryReset, /// Atomic in-place edit of an already-joined network. Hot-applies /// label / topology / auto-approve without dropping peers; restarts /// transport only for signaling/STUN/TURN edits. Preserves the roster diff --git a/gui/src-tauri/src/main.rs b/gui/src-tauri/src/main.rs index 0a45b74..22f3ff4 100644 --- a/gui/src-tauri/src/main.rs +++ b/gui/src-tauri/src/main.rs @@ -258,6 +258,46 @@ async fn mesh_network_remove( unwrap_response(resp) } +/// Danger Zone: forget every joined network at once (purges each network's +/// signed state + roster; keeps the device identity). The daemon exits after +/// responding so it reloads clean; the caller follows with `restart_app`. +#[tauri::command] +async fn mesh_forget_all_networks(state: State<'_, AppState>) -> Result { + let resp = state + .client + .request(&Request::ForgetAllNetworks) + .await + .map_err(|e| e.to_string())?; + unwrap_response(resp) +} + +/// Danger Zone: factory reset — wipe the entire state directory (identity, +/// config, all networks). The daemon exits so a fresh one mints a new identity; +/// the caller follows with `restart_app`. +#[tauri::command] +async fn mesh_factory_reset(state: State<'_, AppState>) -> Result { + let resp = state + .client + .request(&Request::FactoryReset) + .await + .map_err(|e| e.to_string())?; + unwrap_response(resp) +} + +/// Relaunch the whole app. The Danger Zone calls this right after a reset so +/// every layer restarts on the now-clean state — the daemon (which the reset +/// told to exit), the Tauri backend, and the webview — instead of any of them +/// serving a stale in-memory cache that would resurrect what was just wiped. +/// The short wait lets the daemon finish exiting so the relaunched app spawns a +/// fresh one via `ensure_daemon_running` rather than reconnecting to the old. +#[tauri::command] +async fn restart_app(app: AppHandle) -> Result<(), String> { + tokio::time::sleep(std::time::Duration::from_millis(1200)).await; + app.restart(); + #[allow(unreachable_code)] + Ok(()) +} + /// Atomic in-place network edit. The daemon hot-applies label / topology /// / auto-approve and only restarts transport for signaling/STUN/TURN /// changes — the roster survives either way. Replaces the GUI's previous @@ -712,6 +752,9 @@ fn main() { update_check, update_apply, update_set_prefs, + mesh_forget_all_networks, + mesh_factory_reset, + restart_app, ]) .setup(move |app| { let handle = app.handle().clone(); diff --git a/gui/src/mesh-client.svelte.ts b/gui/src/mesh-client.svelte.ts index cdbea55..77d3a29 100644 --- a/gui/src/mesh-client.svelte.ts +++ b/gui/src/mesh-client.svelte.ts @@ -222,6 +222,24 @@ function createMeshClient() { await refreshNetworks(); } + /** Danger Zone: forget every joined network at once, then reboot the whole + * stack. Purges each network's signed state + roster while keeping this + * device's identity. The daemon exits after the reset so it reloads clean, + * and `restart_app` relaunches the app on top of it so no layer keeps a + * stale cache that would resurrect what was wiped. `restart_app` never + * resolves (the app is replaced), so this call ends by relaunching. */ + async function forgetAllNetworksAndRestart() { + await invoke("mesh_forget_all_networks"); + await invoke("restart_app"); + } + + /** Danger Zone: factory reset — wipe this device's entire state (identity, + * config, every network) and reboot into a brand-new identity. */ + async function factoryResetAndRestart() { + await invoke("mesh_factory_reset"); + await invoke("restart_app"); + } + /** Atomic in-place edit of an already-joined network. The daemon * hot-applies label / topology / auto-approve and only restarts * transport for signaling/STUN/TURN edits — the roster is preserved @@ -717,6 +735,8 @@ function createMeshClient() { configShow, networkAdd, networkRemove, + forgetAllNetworksAndRestart, + factoryResetAndRestart, networkUpdate, exportNetworkFile, diff --git a/gui/src/ui/settings/UpdatesSection.svelte b/gui/src/ui/settings/UpdatesSection.svelte index 623fbf9..c243588 100644 --- a/gui/src/ui/settings/UpdatesSection.svelte +++ b/gui/src/ui/settings/UpdatesSection.svelte @@ -22,6 +22,33 @@ let urlDraft = $state(""); let intervalDraft = $state(6); + // ---- Danger Zone ---- + // Two-click arming so a reset can't fire on a stray click. When an action + // fires it reboots the whole stack (the mesh daemon and the app): the daemon + // is the real datastore, so every layer has to reload from the now-clean disk + // or an in-memory cache would just re-persist ("resurrect") what we deleted. + let armed = $state(null); + let resetting = $state(null); + let resetError = $state(null); + + async function runReset(kind: "forget" | "factory") { + if (armed !== kind) { + armed = kind; // first click arms; a second confirms + return; + } + armed = null; + resetting = kind; + resetError = null; + try { + if (kind === "forget") await meshClient.forgetAllNetworksAndRestart(); + else await meshClient.factoryResetAndRestart(); + // The app relaunches on success, so control doesn't normally return here. + } catch (e) { + resetError = String(e); + resetting = null; + } + } + async function load() { try { status = await meshClient.updateStatus(); @@ -282,6 +309,70 @@ {/if} + + +
+
⚠ Danger Zone
+

+ Each of these clears state and then restarts the app and the mesh + daemon, so every layer reloads from disk. Without the reboot, cached + state can quietly reappear. +

+ +
+
+
Forget all meshes
+
+ Leave and delete every network — rosters and signed governance state — + while keeping this device's identity. Use when memberships are stuck + or you want a clean networking slate. +
+
+ +
+ +
+
+
Factory reset
+
+ Erase everything — identity, config, and every network. This + device becomes brand-new to all peers. There is no undo. +
+
+ +
+ + {#if armed} + + {/if} + {#if resetError} +

Reset failed: {resetError}

+ {/if} +