From d782e05ac342c57402fd312a3718a57b78cbb0f0 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 23 Jul 2026 17:43:23 +0000 Subject: [PATCH] =?UTF-8?q?gui:=20Danger=20Zone=20in=20the=20Updates=20tab?= =?UTF-8?q?=20=E2=80=94=20reset=20levels=20for=20a=20wedged=20node?= 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 stuck install doesn't mean hand-deleting files under ~/.myownmesh. A "Danger Zone" card at the bottom of Settings → Updates (desktop only) offers three graduated, two-click-armed levels: - **Leave the fleet** — drop this device from its fleet (ownership, fleet key, signed fleet roster); keep other meshes and settings. Reuses the existing `fleet_leave`. - **Reset networking** — leave the fleet AND forget every mesh (rosters + signed state), keeping the device identity. - **Factory reset** — wipe everything (identity, config, all meshes, fleet ownership); the device becomes brand-new to every peer. Each reboots the whole stack when it fires. The myownmesh daemon is the real datastore, so a reset that only deletes files gets undone by an in-memory cache re-persisting on the next write. So each command clears state, the daemon exits, and `restart_app` relaunches the app — the supervised node and a fresh daemon come back on clean disk (identity regenerated on a factory reset). - node (mesh.rs): `reset_networking` (fleet_leave + daemon ForgetAllNetworks) and `factory_reset` (clear ownership, then daemon FactoryReset wipes the shared state dir); dispatched from node_control.rs. - allmystuff-protocol: `ForgetAllNetworks` / `FactoryReset` on the daemon Request mirror (needs a daemon that speaks them — see note). - GUI: `reset_networking` / `factory_reset` Tauri commands (restart_app already existed), `resetNetworking` / `factoryReset` bindings, the `dangerLeaveFleet` / `dangerResetNetworking` / `dangerFactoryReset` store methods, and the Danger Zone UI. Depends on the myownmesh daemon that adds ForgetAllNetworks/FactoryReset; the .myownmesh-rev pin will be bumped as that release cuts. Reset networking still leaves the fleet against an older daemon (the forget-all just no-ops with a parse error). Note: wipe + backend compile/clippy/type-check clean, but the reboot lifecycle (node/daemon exit → app relaunch → fresh stack) can't run 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/allmystuff-protocol/src/control.rs | 12 ++ gui/src-tauri/src/main.rs | 29 +++ gui/src/store.svelte.ts | 30 ++++ gui/src/tauri.ts | 18 ++ gui/src/ui/settings/UpdatesSection.svelte | 204 ++++++++++++++++++++++ node/src/mesh.rs | 42 +++++ node/src/node_control.rs | 2 + 7 files changed, 337 insertions(+) diff --git a/crates/allmystuff-protocol/src/control.rs b/crates/allmystuff-protocol/src/control.rs index d0f9633f..ff433a05 100644 --- a/crates/allmystuff-protocol/src/control.rs +++ b/crates/allmystuff-protocol/src/control.rs @@ -97,6 +97,18 @@ pub enum Request { #[serde(default)] purge: bool, }, + /// Forget **every** joined network on the daemon at once — a bulk + /// `NetworkRemove{purge:true}` that tears each down and deletes its roster + + /// signed governance state, keeping the device identity. The daemon exits + /// afterward so a fresh one reloads clean (matched by an app restart), rather + /// than a stale in-memory cache re-persisting what was just removed. Needs a + /// daemon that speaks this op; an older one replies with a parse error. + ForgetAllNetworks, + /// Factory reset — the daemon wipes its **entire** state directory + /// (`~/.myownmesh`: identity, config, every network, and the co-located + /// AllMyStuff ownership record) and exits so a fresh daemon mints a new + /// identity on empty state. The device becomes brand-new to every peer. + FactoryReset, NetworkUpdate { config: Value, }, diff --git a/gui/src-tauri/src/main.rs b/gui/src-tauri/src/main.rs index 383da7a4..77211a14 100644 --- a/gui/src-tauri/src/main.rs +++ b/gui/src-tauri/src/main.rs @@ -1223,6 +1223,33 @@ async fn fleet_leave(state: State<'_, AppState>) -> Result<(), String> { Ok(()) } +/// Danger Zone: leave the fleet and forget every network the daemon holds +/// (keeps this device's identity). The daemon exits afterward; the caller +/// follows with `restart_app` so the whole stack reloads clean. +#[tauri::command] +async fn reset_networking(state: State<'_, AppState>) -> Result<(), String> { + state + .node + .request("reset_networking", json!({})) + .await + .map_err(|e| e.to_string())?; + Ok(()) +} + +/// Danger Zone: factory reset — wipe this device's entire state (identity, +/// config, all networks, fleet ownership) so it's brand-new to every peer. The +/// node clears its ownership and the daemon wipes `~/.myownmesh` and exits; the +/// caller follows with `restart_app`. +#[tauri::command] +async fn factory_reset(state: State<'_, AppState>) -> Result<(), String> { + state + .node + .request("factory_reset", json!({})) + .await + .map_err(|e| e.to_string())?; + Ok(()) +} + /// Evict a device from the fleet (owner-only; the daemon enforces it). `code` /// is the owner's custody second factor when fleet MFA is enrolled. #[tauri::command] @@ -2505,6 +2532,8 @@ fn main() { open_room_window, owned_roster, fleet_leave, + reset_networking, + factory_reset, fleet_kick, fleet_set_name, fleet_grant_role, diff --git a/gui/src/store.svelte.ts b/gui/src/store.svelte.ts index cc42254c..5770c438 100644 --- a/gui/src/store.svelte.ts +++ b/gui/src/store.svelte.ts @@ -64,6 +64,8 @@ import { fileSend, fleetKick, fleetLeave, + resetNetworking, + factoryReset, fleetSetName, fleetGrantRole, fleetRevokeRole, @@ -7722,6 +7724,34 @@ class AppStore { // No toast — the Fleet pane drops to its "No fleet yet" state. } + // ---- Danger Zone (Settings → Updates) ---- + // Each level reboots the whole stack after it clears state. The mesh daemon + // is the real datastore, so a reset that only deletes state on one layer can + // be undone by another layer's in-memory cache re-persisting it — the reboot + // is what makes the wipe actually stick. `restartApp()` relaunches the app + // (and the supervised node + daemon come back on clean state), so these calls + // normally end by relaunching rather than returning. + + /** Leave the fleet, then reboot. */ + async dangerLeaveFleet() { + await this.leaveFleet(); + await restartApp(); + } + + /** Leave the fleet and forget every network (keeps the device identity), + * then reboot. */ + async dangerResetNetworking() { + await resetNetworking(); + await restartApp(); + } + + /** Factory reset — wipe identity, config, all networks and fleet ownership, + * then reboot into a brand-new device. */ + async dangerFactoryReset() { + await factoryReset(); + await restartApp(); + } + /** Evict a member from the fleet (owner-only). Routes through the governance * helper, so it prompts for the custody code when fleet MFA is enrolled. */ async kickFleetMember(device: string) { diff --git a/gui/src/tauri.ts b/gui/src/tauri.ts index 85c887d8..0c7065d8 100644 --- a/gui/src/tauri.ts +++ b/gui/src/tauri.ts @@ -1144,6 +1144,24 @@ export async function fleetLeave(): Promise { await invoke("fleet_leave"); } +/** Danger Zone: leave the fleet and forget every network the daemon holds, + * keeping the device identity. The daemon exits so it reloads clean — pair + * with `restartApp()`. No-op in web mode. */ +export async function resetNetworking(): Promise { + if (!isTauri()) return; + const { invoke } = await import("@tauri-apps/api/core"); + await invoke("reset_networking"); +} + +/** Danger Zone: factory reset — wipe this device's entire state (identity, + * config, all networks, fleet ownership) for a brand-new start. The daemon + * wipes `~/.myownmesh` and exits — pair with `restartApp()`. No-op in web. */ +export async function factoryReset(): Promise { + if (!isTauri()) return; + const { invoke } = await import("@tauri-apps/api/core"); + await invoke("factory_reset"); +} + /** Evict a device from the fleet (owner-only; the backend enforces it). * `code` is the owner's custody second factor when fleet MFA is enrolled. * Throws with the reason when refused. */ diff --git a/gui/src/ui/settings/UpdatesSection.svelte b/gui/src/ui/settings/UpdatesSection.svelte index cbc9b8c8..6c750a70 100644 --- a/gui/src/ui/settings/UpdatesSection.svelte +++ b/gui/src/ui/settings/UpdatesSection.svelte @@ -29,6 +29,35 @@ const d = new Date(at * 1000); return d.toLocaleString(); } + + // ---- Danger Zone ---- + // Two-click arming so a reset can't fire on a stray click. Each level reboots + // the whole stack (node + daemon + app) after clearing state: the daemon is + // the real datastore, so without the reboot an in-memory cache can re-persist + // ("resurrect") what was wiped. Desktop only — it needs the local daemon and + // a relaunch. + let armed = $state(null); + let resetting = $state(null); + let resetError = $state(null); + + async function runReset(kind: "leave" | "network" | "factory") { + if (armed !== kind) { + armed = kind; // first click arms; a second confirms + return; + } + armed = null; + resetting = kind; + resetError = null; + try { + if (kind === "leave") await app.dangerLeaveFleet(); + else if (kind === "network") await app.dangerResetNetworking(); + else await app.dangerFactoryReset(); + // The app relaunches on success, so control doesn't normally return here. + } catch (e) { + resetError = String(e); + resetting = null; + } + }
@@ -161,6 +190,93 @@ {/if} {/if} + + + {#if !web && !mobile} +
+
⚠ 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. +

+ +
+
+
Leave the fleet
+
+ Remove this device from its fleet — drops ownership, the fleet key, + and the fleet's signed roster. Keeps your other meshes and settings. +
+
+ +
+ +
+
+
Reset networking
+
+ Leave the fleet and forget every mesh — all rosters and signed + state — keeping this device's identity. A clean networking slate. +
+
+ +
+ +
+
+
Factory reset
+
+ Erase everything — identity, config, every mesh, and fleet + ownership. This device becomes brand-new to all peers. No undo. +
+
+ +
+ + {#if armed} + + {/if} + {#if resetError} +

Reset failed: {resetError}

+ {/if} +
+ {/if}
diff --git a/node/src/mesh.rs b/node/src/mesh.rs index e2ce80fd..b6734ab0 100644 --- a/node/src/mesh.rs +++ b/node/src/mesh.rs @@ -7779,6 +7779,48 @@ impl Mesh { Ok(()) } + /// Danger Zone: leave the fleet **and** forget every network on the daemon — + /// clears this device's fleet membership and purges each mesh's roster + + /// signed governance state, keeping the device identity. A clean networking + /// slate for a wedged node. Leaves the fleet first (clears our ownership + + /// purges the fleet's closed network), then tells the daemon to forget the + /// rest; the daemon exits so a fresh one reloads clean, and the GUI restarts + /// the app around it. Best-effort per step — we're resetting regardless. + pub async fn reset_networking(self: &Arc) -> Result<(), String> { + let _ = self.fleet_leave().await; + if let Err(e) = self.client.request(&Request::ForgetAllNetworks).await { + // A pre-reset-op daemon can't parse it; the fleet leave above still + // did the important part. Surface it but don't fail the reset. + tracing::warn!("reset networking: daemon forget-all errored: {e}"); + } + Ok(()) + } + + /// Danger Zone: factory reset — wipe this device back to brand-new. Clears + /// our local ownership record first (so the node can't re-persist + /// `allmystuff-ownership.json` after the daemon deletes it), then tells the + /// daemon to wipe its **entire** state directory (`~/.myownmesh`: identity, + /// config, every network, and our co-located ownership file) and exit. The + /// GUI restarts the app; a fresh node + daemon come up on empty state with a + /// new identity. The daemon's response may race its own exit, so a transport + /// error after the request is treated as "reset underway", not a failure. + pub async fn factory_reset(self: &Arc) -> Result<(), String> { + // Quiesce our ownership writer so it can't rewrite the file the daemon is + // about to delete. Best-effort — the daemon wipe is the authority, and + // we're restarting the whole stack regardless. + let _ = self.ownership.leave_fleet(); + self.emit_owned().await; + match self.client.request(&Request::FactoryReset).await { + Ok(_) => Ok(()), + Err(e) => { + tracing::warn!( + "factory reset: daemon request errored (it is likely already exiting): {e}" + ); + Ok(()) + } + } + } + /// Front-end command: kick `device` out of the fleet. Only the fleet /// **owner** can — eviction is an owner-authority governance act on the /// closed network. The signed `Evict` propagates the removal to every diff --git a/node/src/node_control.rs b/node/src/node_control.rs index 0cd5b54d..7f1cc928 100644 --- a/node/src/node_control.rs +++ b/node/src/node_control.rs @@ -1166,6 +1166,8 @@ pub async fn dispatch( } "owned_roster" => DispatchOut::Json(mesh.fleet_roster_value().await), "fleet_leave" => json_result(mesh.fleet_leave().await), + "reset_networking" => json_result(mesh.reset_networking().await), + "factory_reset" => json_result(mesh.factory_reset().await), "fleet_kick" => { let device: String = try_arg!(arg(a, "device")); let code: Option = try_arg!(opt(a, "code"));