mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
feat(desktop): add Sign Out to Settings to reset and relaunch (#1842)
Signed-off-by: Will Pfleger <pfleger.will@gmail.com> Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
co-authored by
npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7
parent
2d7ed3267a
commit
cf97237965
@@ -90,6 +90,7 @@ export default defineConfig({
|
||||
"**/project-pr-review.spec.ts",
|
||||
"**/persona-model-combobox-screenshots.spec.ts",
|
||||
"**/drafts-screenshots.spec.ts",
|
||||
"**/signout-screenshots.spec.ts",
|
||||
"**/buzz-theme-screenshots.spec.ts",
|
||||
"**/channel-sort.spec.ts",
|
||||
"**/identity-lost.spec.ts",
|
||||
|
||||
@@ -207,7 +207,8 @@ const overrides = new Map([
|
||||
// mcp-readonly-view rebase: PR2 MCP config surface FE-type fields force +1 over the grandfathered ceiling.
|
||||
// Git Bash prerequisite payload adds four fields to the shared Tauri API
|
||||
// contract. This is the canonical type location; split remains queued.
|
||||
["src/shared/api/types.ts", 1038],
|
||||
// signout-wipe: resetFailed field added to Identity type (+6 lines).
|
||||
["src/shared/api/types.ts", 1044],
|
||||
// readiness-gate: PersonaDialog.tsx threads computeLocalModeGate +
|
||||
// requiredCredentialEnvKeys + RequiredFieldLabel so the "New agent" dialog
|
||||
// shows required markers and credential amber rows (parity with
|
||||
@@ -307,7 +308,7 @@ const overrides = new Map([
|
||||
// am review fix: also clear stale V1 model field on provider rewrite +
|
||||
// new model-clear test. Load-bearing chimera fix.
|
||||
// keyring-dev-isolation: run_boot_migrations wires agent-key migration.
|
||||
["src-tauri/src/migration.rs", 1415],
|
||||
["src-tauri/src/migration.rs", 1436],
|
||||
// onMarkRead + isUnread prop threading (mirrors the onMarkUnread prop
|
||||
// already here) for the single-toggle mark-read/unread menu item — a small
|
||||
// overage from load-bearing per-message plumbing, not generic debt growth.
|
||||
@@ -331,7 +332,22 @@ const overrides = new Map([
|
||||
// security fix for the lost-update race that stranded agent keys.
|
||||
// identity-import-keyring: KeyringLockedScreen, RecoveryScreen,
|
||||
// load_readonly + load_all_readonly + store_all for safe cross-service reads.
|
||||
["src-tauri/src/secret_store.rs", 1140],
|
||||
// sign-out wipe: delete_all() method removes the entire keychain blob under
|
||||
// the interprocess advisory lock; +8 lines. Load-bearing; queued to split.
|
||||
// signout-wipe phase 2: delete_all_with_legacy_cleanup replaces delete_all;
|
||||
// reads blob keys + deletes per-key legacy entries to prevent resurrection.
|
||||
// + regression test for per-key resurrection via real OS keychain.
|
||||
// Net growth ~36+32 lines over prior cap. Load-bearing correctness fix.
|
||||
// signout-wipe pass-2 (F2): delete_all_with_legacy_cleanup DPK deletes now
|
||||
// observable (propagate real errors); verify_fully_wiped checks all three
|
||||
// keychain shapes (main blob, DPK blob, per-key "identity"). +73 lines.
|
||||
["src-tauri/src/secret_store.rs", 1307],
|
||||
// sign-out wipe: Sign Out section (AlertDialog + controlled state) added
|
||||
// at the bottom of the Profile settings page. Load-bearing UX feature;
|
||||
// queued to split when ProfileSettingsCard is broken into sub-components.
|
||||
// +20 lines: scroll-position save/restore across avatar editor open/close
|
||||
// to prevent layout shift from the Sign Out section causing a viewport jump.
|
||||
["src/features/settings/ui/ProfileSettingsCard.tsx", 1033],
|
||||
// keyring-dev-isolation: keyring_service() fn (7 lines) replaces the const
|
||||
// to return "buzz-desktop-dev" in debug builds. Load-bearing isolation fix.
|
||||
["src-tauri/src/app_state.rs", 1042],
|
||||
|
||||
@@ -23,7 +23,7 @@ const rules = [
|
||||
// glyphs are fixed display sizes sized to their avatar box (not readable
|
||||
// message text), so they are exempted from the readable-text px rule.
|
||||
const overrides = new Set([
|
||||
"src/features/settings/ui/ProfileSettingsCard.tsx:672",
|
||||
"src/features/settings/ui/ProfileSettingsCard.tsx:712",
|
||||
"src/features/onboarding/ui/AvatarStep.tsx:95",
|
||||
"src/features/agents/ui/AgentCreationPreview.tsx:666",
|
||||
"src/features/agents/ui/AgentCreationPreview.tsx:747",
|
||||
|
||||
@@ -77,6 +77,14 @@ pub struct AppState {
|
||||
/// `keys` so readers (signing, get_identity, etc.) are not blocked during
|
||||
/// keyring I/O.
|
||||
pub identity_mutation: Mutex<()>,
|
||||
/// Set when the boot-time Phase 2 reset attempted a wipe but verification
|
||||
/// failed. The sentinel is preserved so the next relaunch retries. All
|
||||
/// identity-dependent setup is skipped; the frontend shows a reset-failed
|
||||
/// recovery screen via `get_identity`.
|
||||
///
|
||||
/// Ordering: written once in `setup()` with `Ordering::Release`; read in
|
||||
/// `get_identity` with `Ordering::Acquire`.
|
||||
pub reset_failed: AtomicBool,
|
||||
/// Cached ACP session config from running agents, keyed by agent pubkey.
|
||||
/// Populated when the harness emits `session_config_captured` observer events.
|
||||
pub session_config_cache: Mutex<HashMap<String, SessionConfigCache>>,
|
||||
@@ -169,6 +177,7 @@ pub fn build_app_state() -> AppState {
|
||||
)),
|
||||
keyring_locked: AtomicBool::new(false),
|
||||
identity_lost: AtomicBool::new(false),
|
||||
reset_failed: AtomicBool::new(false),
|
||||
#[cfg(feature = "mesh-llm")]
|
||||
mesh_llm_runtime: AsyncMutex::new(None),
|
||||
#[cfg(feature = "mesh-llm")]
|
||||
|
||||
@@ -36,12 +36,16 @@ pub fn get_identity(state: State<'_, AppState>) -> Result<IdentityInfo, String>
|
||||
let locked = state
|
||||
.keyring_locked
|
||||
.load(std::sync::atomic::Ordering::Acquire);
|
||||
let reset_failed = state
|
||||
.reset_failed
|
||||
.load(std::sync::atomic::Ordering::Acquire);
|
||||
|
||||
Ok(IdentityInfo {
|
||||
pubkey: pubkey_hex,
|
||||
display_name,
|
||||
lost,
|
||||
locked,
|
||||
reset_failed,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -220,6 +224,7 @@ pub async fn import_identity(
|
||||
display_name,
|
||||
lost: false,
|
||||
locked: false,
|
||||
reset_failed: false,
|
||||
})
|
||||
})
|
||||
.await
|
||||
@@ -287,12 +292,53 @@ pub async fn persist_current_identity(
|
||||
display_name,
|
||||
lost: false,
|
||||
locked: false,
|
||||
reset_failed: false,
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("spawn_blocking failed: {e}"))?
|
||||
}
|
||||
|
||||
/// Write a reset-intent sentinel and request a graceful restart into Phase 2
|
||||
/// (boot-time wipe).
|
||||
///
|
||||
/// The actual data destruction is deferred to the next boot: `setup()` in
|
||||
/// `lib.rs` checks for the sentinel and performs the wipe before any migration
|
||||
/// or identity resolution. This two-phase design means a crash before the
|
||||
/// restart is safe — the sentinel persists and the wipe completes on the next
|
||||
/// open.
|
||||
///
|
||||
/// Not available in shared-identity mode (`BUZZ_SHARE_IDENTITY=1`): the key
|
||||
/// comes from an env var, not the keychain, so wiping would have no effect and
|
||||
/// would be confusing.
|
||||
#[tauri::command]
|
||||
pub async fn sign_out(app: tauri::AppHandle) -> Result<(), String> {
|
||||
if is_shared_identity() {
|
||||
return Err(
|
||||
"Sign out isn't available while BUZZ_SHARE_IDENTITY provides your identity. Unset BUZZ_SHARE_IDENTITY and BUZZ_PRIVATE_KEY, then relaunch to sign out."
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
// Stop all managed agents before restart so they don't race the wipe.
|
||||
if let Err(e) = crate::shutdown::shutdown_managed_agents(&app) {
|
||||
eprintln!("buzz-desktop sign-out: agent shutdown: {e}");
|
||||
}
|
||||
|
||||
// Write the reset sentinel — destruction happens on next boot.
|
||||
let data_dir = app
|
||||
.path()
|
||||
.app_data_dir()
|
||||
.map_err(|e| format!("app data dir: {e}"))?;
|
||||
crate::reset::write_sentinel(&data_dir)?;
|
||||
|
||||
// Tauri restarts only after normal shutdown, avoiding a single-instance
|
||||
// race. If restarting does not complete, the sentinel makes a manual open
|
||||
// finish the reset.
|
||||
app.request_restart();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn nostr_bind_tag(name: &str, value: &str) -> Result<Tag, String> {
|
||||
Tag::parse(vec![name, value]).map_err(|error| format!("{name} tag failed: {error}"))
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ pub mod nostr_convert;
|
||||
mod prevent_sleep;
|
||||
mod ptt_shortcut;
|
||||
mod relay;
|
||||
mod reset;
|
||||
mod secret_store;
|
||||
mod shutdown;
|
||||
mod templates;
|
||||
@@ -415,8 +416,41 @@ pub fn run() {
|
||||
.setup(move |app| {
|
||||
let app_handle = app.handle().clone();
|
||||
|
||||
// ── Phase 2: boot-time sentinel wipe ──────────────────────────────
|
||||
// Must run before migrations and identity resolution so the wipe
|
||||
// completes atomically on crash recovery.
|
||||
//
|
||||
// init_nest_dir is called early here (normally it runs inside
|
||||
// run_boot_migrations) so reset::run_boot_reset can call nest_dir().
|
||||
let reset_outcome = if let Ok(data_dir) = app_handle.path().app_data_dir() {
|
||||
let is_dev_for_reset = data_dir
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.map(crate::migration::is_dev_data_dir_name)
|
||||
.unwrap_or(false);
|
||||
crate::managed_agents::init_nest_dir(is_dev_for_reset);
|
||||
crate::reset::run_boot_reset(&data_dir)
|
||||
} else {
|
||||
crate::reset::ResetOutcome::default()
|
||||
};
|
||||
|
||||
if reset_outcome.failed {
|
||||
// Surface reset-failed state — skip identity resolution and
|
||||
// all side-effecting setup. The webview still loads so the
|
||||
// frontend can show the recovery screen via get_identity.
|
||||
let state = app_handle.state::<AppState>();
|
||||
state
|
||||
.reset_failed
|
||||
.store(true, std::sync::atomic::Ordering::Release);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Run all pre-identity data migrations before state loads from disk.
|
||||
migration::run_boot_migrations(&app_handle);
|
||||
if reset_outcome.completed {
|
||||
migration::run_boot_migrations_after_reset(&app_handle);
|
||||
} else {
|
||||
migration::run_boot_migrations(&app_handle);
|
||||
}
|
||||
|
||||
// Resolve persisted identity key (env var → file → generate+save).
|
||||
// This is fatal — the app should not start with an ephemeral identity
|
||||
@@ -517,7 +551,9 @@ pub fn run() {
|
||||
// destination is present. Non-fatal.
|
||||
// On a real migration, emit a one-time hint so the user can delete
|
||||
// the now-inert ~/.sprout; the frontend dedupes the toast.
|
||||
if migration::migrate_legacy_nest() {
|
||||
// Suppressed when a reset completed this boot: the nest was wiped and
|
||||
// a fresh ~/.sprout-less state is exactly what we want.
|
||||
if !reset_outcome.completed && migration::migrate_legacy_nest() {
|
||||
let _ = app_handle.emit("legacy-nest-migrated", ());
|
||||
}
|
||||
|
||||
@@ -525,10 +561,12 @@ pub fn run() {
|
||||
// from the shared ~/.buzz nest into the new dedicated ~/.buzz-dev
|
||||
// nest so no work is lost when the nest is first namespaced.
|
||||
// Runs only when nest_dir() resolved to ~/.buzz-dev (dev instance).
|
||||
// Suppressed after a reset so re-importing ~/.buzz into ~/.buzz-dev
|
||||
// doesn't re-populate what was just wiped.
|
||||
let is_dev_nest = managed_agents::nest_dir()
|
||||
.and_then(|p| p.file_name().map(|n| n.to_os_string()))
|
||||
.is_some_and(|n| n == ".buzz-dev");
|
||||
if is_dev_nest {
|
||||
if !reset_outcome.completed && is_dev_nest {
|
||||
migration::migrate_dev_nest();
|
||||
}
|
||||
|
||||
@@ -691,6 +729,7 @@ pub fn run() {
|
||||
discover_managed_agent_prereqs,
|
||||
sign_event,
|
||||
sign_nostr_identity_binding,
|
||||
sign_out,
|
||||
decrypt_observer_event,
|
||||
build_observer_control_event,
|
||||
create_auth_event,
|
||||
|
||||
@@ -44,7 +44,7 @@ const SHARED_AGENT_DIRS: &[&str] = &["agents/teams"];
|
||||
/// `xyz.block.buzz.app.developer`. This is the authoritative dev/prod
|
||||
/// discriminator shared by `run_boot_migrations`, `sync_shared_agent_data`,
|
||||
/// and `reconcile_target_dir`.
|
||||
fn is_dev_data_dir_name(name: &str) -> bool {
|
||||
pub(crate) fn is_dev_data_dir_name(name: &str) -> bool {
|
||||
name == CANONICAL_DEV_IDENTIFIER
|
||||
|| name
|
||||
.strip_prefix(CANONICAL_DEV_IDENTIFIER)
|
||||
@@ -55,7 +55,7 @@ fn canonical_dev_data_dir(current: &Path) -> Option<PathBuf> {
|
||||
current.parent().map(|p| p.join(CANONICAL_DEV_IDENTIFIER))
|
||||
}
|
||||
|
||||
fn legacy_app_data_dir(current: &Path) -> Option<PathBuf> {
|
||||
pub(crate) fn legacy_app_data_dir(current: &Path) -> Option<PathBuf> {
|
||||
let name = current.file_name()?.to_str()?;
|
||||
let legacy_name = if name.starts_with(CANONICAL_DEV_IDENTIFIER) {
|
||||
name.replacen(CANONICAL_DEV_IDENTIFIER, LEGACY_CANONICAL_DEV_IDENTIFIER, 1)
|
||||
@@ -119,6 +119,15 @@ fn copy_dir_all(src: &Path, dst: &Path) -> std::io::Result<()> {
|
||||
/// readers — reader-first loses a launch (stale harness/`mcp_command` until
|
||||
/// the next boot).
|
||||
pub fn run_boot_migrations(app: &tauri::AppHandle) {
|
||||
run_boot_migrations_inner(app, false);
|
||||
}
|
||||
|
||||
/// Entry point when a completed reset must suppress dev-nest re-import.
|
||||
pub fn run_boot_migrations_after_reset(app: &tauri::AppHandle) {
|
||||
run_boot_migrations_inner(app, true);
|
||||
}
|
||||
|
||||
fn run_boot_migrations_inner(app: &tauri::AppHandle, reset_completed: bool) {
|
||||
// Initialize the process-lifetime nest directory before any filesystem
|
||||
// operation that calls nest_dir(). The discriminator matches the existing
|
||||
// pattern used by reconcile_target_dir: dev instances have an app-data-dir
|
||||
@@ -139,8 +148,10 @@ pub fn run_boot_migrations(app: &tauri::AppHandle) {
|
||||
// ensures the dev nest boots with the correct workspace on its first launch,
|
||||
// matching what the prod nest had configured. Skip-if-dest-exists so it is
|
||||
// idempotent and never clobbers a value the dev nest already set explicitly.
|
||||
if is_dev {
|
||||
migrate_dev_repos_dir();
|
||||
// Uses the composed helper so the gate + migration run through the same
|
||||
// code path that the behavioral test exercises.
|
||||
if let (Some(home), Some(dev_nest)) = (dirs::home_dir(), crate::managed_agents::nest_dir()) {
|
||||
maybe_migrate_dev_repos_dir(is_dev, reset_completed, &home, &dev_nest);
|
||||
}
|
||||
|
||||
migrate_legacy_app_data_dir(app);
|
||||
@@ -318,23 +329,21 @@ fn migrate_legacy_nest_at(legacy: &Path, current: &Path) -> bool {
|
||||
/// also copies into `~/.buzz-dev` and could otherwise set the sentinel early.
|
||||
const DEV_NEST_MIGRATED_SENTINEL: &str = ".dev-nest-migrated";
|
||||
|
||||
/// Copy the `.repos-dir` dotfile from `~/.buzz` → `~/.buzz-dev`, non-destructively.
|
||||
///
|
||||
/// Must be called on dev builds BEFORE `resolve_repos_at_boot()` reads the
|
||||
/// dotfile, so the dev nest boots with the correct workspace configuration on
|
||||
/// its first launch. Skip-if-dest-exists so it is idempotent and never
|
||||
/// overwrites a value set directly by the dev nest.
|
||||
fn migrate_dev_repos_dir() {
|
||||
let Some(home) = dirs::home_dir() else {
|
||||
return;
|
||||
};
|
||||
/// Returns true when `migrate_dev_repos_dir` should run: dev build AND no
|
||||
/// completed reset this boot (a completed reset means the dev nest was just
|
||||
/// wiped — re-importing from prod would undo the sign-out).
|
||||
pub(crate) fn should_migrate_dev_repos_dir(is_dev: bool, reset_completed: bool) -> bool {
|
||||
is_dev && !reset_completed
|
||||
}
|
||||
|
||||
/// Injectable core: copy `.repos-dir` from `<home>/.buzz/` into `dev_nest`,
|
||||
/// non-destructively. Extracted so tests can inject temp paths without
|
||||
/// touching `dirs::home_dir()` or the global `nest_dir()` OnceLock.
|
||||
pub(crate) fn migrate_dev_repos_dir_at(home: &Path, dev_nest: &Path) {
|
||||
let src = home.join(".buzz").join(".repos-dir");
|
||||
if !src.exists() {
|
||||
return;
|
||||
}
|
||||
let Some(dev_nest) = crate::managed_agents::nest_dir() else {
|
||||
return;
|
||||
};
|
||||
let dst = dev_nest.join(".repos-dir");
|
||||
// Skip if the dev nest already has its own .repos-dir.
|
||||
if dst.exists() {
|
||||
@@ -342,7 +351,7 @@ fn migrate_dev_repos_dir() {
|
||||
}
|
||||
// Ensure the dev nest directory itself exists — this migration runs before
|
||||
// ensure_nest() in the boot sequence, so the directory may not yet exist.
|
||||
if let Err(e) = std::fs::create_dir_all(&dev_nest) {
|
||||
if let Err(e) = std::fs::create_dir_all(dev_nest) {
|
||||
eprintln!(
|
||||
"buzz-desktop: dev-nest-migration: failed to create dev nest {}: {e}",
|
||||
dev_nest.display()
|
||||
@@ -358,6 +367,21 @@ fn migrate_dev_repos_dir() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Composed gate + migration: applies `should_migrate_dev_repos_dir` and, if
|
||||
/// the gate passes, runs the injectable migration core. This is the seam that
|
||||
/// `run_boot_migrations_inner` calls with real paths — a future mis-wired flag
|
||||
/// or inverted gate breaks tests at this level, not just the predicate.
|
||||
pub(crate) fn maybe_migrate_dev_repos_dir(
|
||||
is_dev: bool,
|
||||
reset_completed: bool,
|
||||
home: &Path,
|
||||
dev_nest: &Path,
|
||||
) {
|
||||
if should_migrate_dev_repos_dir(is_dev, reset_completed) {
|
||||
migrate_dev_repos_dir_at(home, dev_nest);
|
||||
}
|
||||
}
|
||||
|
||||
/// One-time migration of dev-build nest contents from `~/.buzz` → `~/.buzz-dev`.
|
||||
///
|
||||
/// When a dev build first boots after this change ships, it switches from the
|
||||
|
||||
@@ -17,6 +17,11 @@ pub struct IdentityInfo {
|
||||
/// shows a "unlock the keyring and relaunch" screen. Mutually exclusive
|
||||
/// with `lost`.
|
||||
pub locked: bool,
|
||||
/// True when the boot-time Phase 2 reset attempted a wipe but verification
|
||||
/// failed. Identity resolution was skipped; the frontend shows a
|
||||
/// reset-failed recovery screen. The sentinel is preserved so the next
|
||||
/// relaunch retries the wipe automatically.
|
||||
pub reset_failed: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
|
||||
@@ -0,0 +1,847 @@
|
||||
//! Two-phase boot-time sentinel wipe.
|
||||
//!
|
||||
//! **Phase 1** (`write_sentinel`) — called by `sign_out`: writes a durable
|
||||
//! reset-intent file outside every path that will be wiped.
|
||||
//!
|
||||
//! **Phase 2** (`run_boot_reset`) — called at the very top of `setup()` in
|
||||
//! `lib.rs`, before migrations and identity resolution: if the sentinel is
|
||||
//! present the wipe runs atomically and the app falls through into clean
|
||||
//! onboarding.
|
||||
//!
|
||||
//! The sentinel lives at `<app_data_dir's parent>/.<bundle_id>.reset-pending`
|
||||
//! and survives the app-data wipe because the wipe targets the exact
|
||||
//! app-data dir, not its parent.
|
||||
//!
|
||||
//! Idempotency: if the process crashes mid-wipe the sentinel is still present
|
||||
//! on next boot and the wipe retries from the top.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
// ── Sentinel helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
/// Sentinel path: `<app_data_dir.parent>/.<bundle_id>.reset-pending`
|
||||
/// where `bundle_id` is the file-name component of `app_data_dir`
|
||||
/// (e.g. `xyz.block.buzz.app` or `xyz.block.buzz.app.dev`).
|
||||
pub(crate) fn sentinel_path(app_data_dir: &Path) -> PathBuf {
|
||||
let bundle_id = app_data_dir
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("buzz");
|
||||
let name = format!(".{bundle_id}.reset-pending");
|
||||
match app_data_dir.parent() {
|
||||
Some(parent) => parent.join(name),
|
||||
None => PathBuf::from(&name),
|
||||
}
|
||||
}
|
||||
|
||||
/// Atomically write the sentinel file. Content is intentionally empty —
|
||||
/// existence is the signal.
|
||||
pub(crate) fn write_sentinel(app_data_dir: &Path) -> Result<(), String> {
|
||||
let path = sentinel_path(app_data_dir);
|
||||
std::fs::write(&path, b"").map_err(|e| format!("write sentinel {}: {e}", path.display()))
|
||||
}
|
||||
|
||||
/// Return `true` when the sentinel file exists.
|
||||
pub(crate) fn check_sentinel(app_data_dir: &Path) -> bool {
|
||||
sentinel_path(app_data_dir).exists()
|
||||
}
|
||||
|
||||
/// Remove the sentinel file. A missing file is not an error.
|
||||
pub(crate) fn delete_sentinel(app_data_dir: &Path) -> Result<(), String> {
|
||||
let path = sentinel_path(app_data_dir);
|
||||
match std::fs::remove_file(&path) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
||||
Err(e) => Err(format!("delete sentinel {}: {e}", path.display())),
|
||||
}
|
||||
}
|
||||
|
||||
// ── Keychain abstraction (enables unit testing) ───────────────────────────────
|
||||
|
||||
/// Keychain operations needed by the boot-time reset.
|
||||
/// Implemented for `SecretStore`; a fake is used in tests.
|
||||
pub(crate) trait ResetKeychain {
|
||||
/// Delete the blob + all per-key legacy entries.
|
||||
fn delete_all_with_legacy(&self) -> Result<(), String>;
|
||||
/// Return `true` when all keychain shapes (main blob, DPK blob, per-key
|
||||
/// entries) that `migrate_legacy_key` can consume are absent.
|
||||
fn verify_fully_wiped(&self) -> bool;
|
||||
}
|
||||
|
||||
impl ResetKeychain for crate::secret_store::SecretStore {
|
||||
fn delete_all_with_legacy(&self) -> Result<(), String> {
|
||||
self.delete_all_with_legacy_cleanup()
|
||||
}
|
||||
|
||||
fn verify_fully_wiped(&self) -> bool {
|
||||
self.verify_fully_wiped()
|
||||
}
|
||||
}
|
||||
|
||||
// ── Result type ───────────────────────────────────────────────────────────────
|
||||
|
||||
/// Outcome of the boot-time reset check.
|
||||
#[derive(Debug, Default)]
|
||||
pub(crate) struct ResetOutcome {
|
||||
/// Wipe completed successfully this boot — suppress nest migrations.
|
||||
pub completed: bool,
|
||||
/// Wipe was attempted but verification failed — surface error state.
|
||||
pub failed: bool,
|
||||
}
|
||||
|
||||
// ── Boot-time reset ───────────────────────────────────────────────────────────
|
||||
|
||||
/// Wipe parameters assembled by `lib.rs` and passed into `run_boot_reset_with_keychain`.
|
||||
pub(crate) struct ResetContext<'a> {
|
||||
pub app_data_dir: &'a Path,
|
||||
/// Legacy App Support dir for this build (Sprout import source). When
|
||||
/// present and non-empty, wiped alongside `app_data_dir` to prevent
|
||||
/// `migrate_legacy_app_data_dir` from restoring the old identity.
|
||||
pub legacy_app_data_dir: Option<PathBuf>,
|
||||
/// Nest dir (`~/.buzz` or `~/.buzz-dev`) scoped to this build's variant,
|
||||
/// injected so unit tests can override without touching the global OnceLock.
|
||||
pub nest_dir: Option<PathBuf>,
|
||||
pub keychain: &'a dyn ResetKeychain,
|
||||
pub home_dir: Option<PathBuf>,
|
||||
pub is_dev: bool,
|
||||
}
|
||||
|
||||
/// Entry point called from `lib.rs` setup (before migrations).
|
||||
///
|
||||
/// Constructs a `SecretStore` for the running build's keyring service and
|
||||
/// delegates to `run_boot_reset_with_keychain` for testable wipe logic.
|
||||
pub(crate) fn run_boot_reset(app_data_dir: &Path) -> ResetOutcome {
|
||||
if !check_sentinel(app_data_dir) {
|
||||
return ResetOutcome::default();
|
||||
}
|
||||
|
||||
let is_dev = app_data_dir
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.map(crate::migration::is_dev_data_dir_name)
|
||||
.unwrap_or(false);
|
||||
|
||||
let store = crate::secret_store::SecretStore::keyring(crate::app_state::keyring_service());
|
||||
let home_dir = dirs::home_dir();
|
||||
let legacy_dir = crate::migration::legacy_app_data_dir(app_data_dir);
|
||||
let nest_dir = crate::managed_agents::nest_dir();
|
||||
|
||||
let ctx = ResetContext {
|
||||
app_data_dir,
|
||||
legacy_app_data_dir: legacy_dir,
|
||||
nest_dir,
|
||||
keychain: &store,
|
||||
home_dir,
|
||||
is_dev,
|
||||
};
|
||||
|
||||
run_boot_reset_with_keychain(ctx)
|
||||
}
|
||||
|
||||
/// Deterministic trash path: `<original>.reset-trash`. Unlike PID-based names,
|
||||
/// any boot can discover and clean trash from a prior crashed attempt.
|
||||
fn trash_path(original: &Path) -> PathBuf {
|
||||
original.with_file_name(format!(
|
||||
"{}.reset-trash",
|
||||
original
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("buzz")
|
||||
))
|
||||
}
|
||||
|
||||
/// Remove an existing reset-trash directory if present (from a prior crashed
|
||||
/// attempt), then rename `src` into the deterministic trash path. Returns
|
||||
/// `Ok(trash_path)` on success.
|
||||
fn rename_to_trash(src: &Path) -> Result<PathBuf, String> {
|
||||
let dst = trash_path(src);
|
||||
// Clear prior trash before renaming so a collision doesn't fail the rename.
|
||||
if dst.exists() {
|
||||
let _ = std::fs::remove_dir_all(&dst);
|
||||
}
|
||||
std::fs::rename(src, &dst)
|
||||
.map_err(|e| format!("rename {} → {}: {e}", src.display(), dst.display()))?;
|
||||
Ok(dst)
|
||||
}
|
||||
|
||||
/// Core wipe logic — separated for testing.
|
||||
pub(crate) fn run_boot_reset_with_keychain(ctx: ResetContext<'_>) -> ResetOutcome {
|
||||
let app_data_dir = ctx.app_data_dir;
|
||||
|
||||
// ── Step 1: rename app-data dir (atomic — sentinel survives the parent) ──
|
||||
let trash_app = trash_path(app_data_dir);
|
||||
|
||||
if app_data_dir.exists() {
|
||||
if let Err(e) = rename_to_trash(app_data_dir) {
|
||||
eprintln!("buzz-desktop reset: {e}");
|
||||
return ResetOutcome {
|
||||
completed: false,
|
||||
failed: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ── Step 1b: rename legacy App Support dir (sprout import source) ────────
|
||||
let trash_legacy: Option<PathBuf> = ctx.legacy_app_data_dir.as_ref().map(|l| trash_path(l));
|
||||
if let Some(ref legacy) = ctx.legacy_app_data_dir {
|
||||
if legacy.exists() {
|
||||
if let Err(e) = rename_to_trash(legacy) {
|
||||
eprintln!("buzz-desktop reset: {e}");
|
||||
// Non-fatal for legacy dir — continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Step 2: rename WebKit dir for this build ──────────────────────────────
|
||||
let trash_webkit: Option<PathBuf> = if let Some(ref home) = ctx.home_dir {
|
||||
let bundle_id = app_data_dir
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("buzz");
|
||||
let webkit_dir = home.join("Library").join("WebKit").join(bundle_id);
|
||||
let tw = trash_path(&webkit_dir);
|
||||
if webkit_dir.exists() {
|
||||
if let Err(e) = rename_to_trash(&webkit_dir) {
|
||||
eprintln!("buzz-desktop reset: {e}");
|
||||
// Non-fatal — continue
|
||||
}
|
||||
}
|
||||
Some(tw)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// ── Step 3: remove nest, ~/.sprout, ~/.config/buzz-agent, CLI symlink ────
|
||||
if let Some(ref nest) = ctx.nest_dir {
|
||||
let _ = std::fs::remove_dir_all(nest);
|
||||
}
|
||||
if let Some(ref home) = ctx.home_dir {
|
||||
let _ = std::fs::remove_dir_all(home.join(".sprout"));
|
||||
let _ = std::fs::remove_dir_all(home.join(".config").join("buzz-agent"));
|
||||
let link_name = crate::managed_agents::cli_link_name(ctx.is_dev);
|
||||
let _ = std::fs::remove_file(home.join(".local").join("bin").join(link_name));
|
||||
}
|
||||
|
||||
// ── Step 4: keychain — LAST so we can read keys before deleting ──────────
|
||||
if let Err(e) = ctx.keychain.delete_all_with_legacy() {
|
||||
eprintln!("buzz-desktop reset: keychain delete: {e}");
|
||||
// Keychain failure is fatal: keep sentinel, signal failure.
|
||||
// Restore all three dirs so the app returns to a coherent pre-reset state.
|
||||
if trash_app.exists() {
|
||||
let _ = std::fs::rename(&trash_app, app_data_dir);
|
||||
}
|
||||
if let Some(ref legacy) = ctx.legacy_app_data_dir {
|
||||
if let Some(ref tl) = trash_legacy {
|
||||
if tl.exists() {
|
||||
let _ = std::fs::rename(tl, legacy);
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(ref home) = ctx.home_dir {
|
||||
let bundle_id = app_data_dir
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("buzz");
|
||||
let webkit_dir = home.join("Library").join("WebKit").join(bundle_id);
|
||||
if let Some(ref tw) = trash_webkit {
|
||||
if tw.exists() {
|
||||
let _ = std::fs::rename(tw, &webkit_dir);
|
||||
}
|
||||
}
|
||||
}
|
||||
return ResetOutcome {
|
||||
completed: false,
|
||||
failed: true,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Step 5: sweep ALL reset trash (including from prior crashed boots) ───
|
||||
let _ = std::fs::remove_dir_all(&trash_app);
|
||||
if let Some(ref tl) = trash_legacy {
|
||||
let _ = std::fs::remove_dir_all(tl);
|
||||
}
|
||||
if let Some(ref tw) = trash_webkit {
|
||||
let _ = std::fs::remove_dir_all(tw);
|
||||
}
|
||||
|
||||
// ── Step 6: verify ────────────────────────────────────────────────────────
|
||||
let keychain_ok = ctx.keychain.verify_fully_wiped();
|
||||
let app_data_gone = !app_data_dir.exists();
|
||||
let legacy_gone = ctx
|
||||
.legacy_app_data_dir
|
||||
.as_ref()
|
||||
.map(|p| !p.exists())
|
||||
.unwrap_or(true);
|
||||
let nest_gone = ctx.nest_dir.as_ref().map(|n| !n.exists()).unwrap_or(true);
|
||||
let trash_app_gone = !trash_app.exists();
|
||||
let trash_legacy_gone = trash_legacy.as_ref().map(|p| !p.exists()).unwrap_or(true);
|
||||
let trash_webkit_gone = trash_webkit.as_ref().map(|p| !p.exists()).unwrap_or(true);
|
||||
|
||||
if !keychain_ok
|
||||
|| !app_data_gone
|
||||
|| !legacy_gone
|
||||
|| !nest_gone
|
||||
|| !trash_app_gone
|
||||
|| !trash_legacy_gone
|
||||
|| !trash_webkit_gone
|
||||
{
|
||||
eprintln!(
|
||||
"buzz-desktop reset: verification failed (keychain_wiped={keychain_ok}, \
|
||||
app_data_gone={app_data_gone}, legacy_gone={legacy_gone}, nest_gone={nest_gone}, \
|
||||
trash_app_gone={trash_app_gone}, trash_legacy_gone={trash_legacy_gone}, \
|
||||
trash_webkit_gone={trash_webkit_gone})"
|
||||
);
|
||||
return ResetOutcome {
|
||||
completed: false,
|
||||
failed: true,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Step 7: delete sentinel → success ────────────────────────────────────
|
||||
if let Err(e) = delete_sentinel(app_data_dir) {
|
||||
eprintln!("buzz-desktop reset: delete sentinel: {e}");
|
||||
// Sentinel not deleted — keep failed=false so the app boots into
|
||||
// onboarding, but on next boot the reset will retry (idempotent).
|
||||
}
|
||||
|
||||
ResetOutcome {
|
||||
completed: true,
|
||||
failed: false,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::cell::Cell;
|
||||
use tempfile::TempDir;
|
||||
|
||||
// ── Fake keychain ─────────────────────────────────────────────────────────
|
||||
|
||||
struct FakeKeychain {
|
||||
delete_result: Result<(), String>,
|
||||
/// Tracks number of times delete was called.
|
||||
delete_calls: Cell<u32>,
|
||||
/// Whether `verify_fully_wiped` returns true after a successful delete.
|
||||
wiped_after_delete: bool,
|
||||
/// When true, verify_fully_wiped always returns false regardless of
|
||||
/// delete outcome — simulates a transient/unknown keychain error
|
||||
/// during verification that cannot confirm absence.
|
||||
verify_always_fails: bool,
|
||||
}
|
||||
|
||||
impl FakeKeychain {
|
||||
fn ok() -> Self {
|
||||
FakeKeychain {
|
||||
delete_result: Ok(()),
|
||||
delete_calls: Cell::new(0),
|
||||
wiped_after_delete: true,
|
||||
verify_always_fails: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn fail(msg: &str) -> Self {
|
||||
FakeKeychain {
|
||||
delete_result: Err(msg.to_string()),
|
||||
delete_calls: Cell::new(0),
|
||||
wiped_after_delete: false,
|
||||
verify_always_fails: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn ok_but_not_wiped() -> Self {
|
||||
FakeKeychain {
|
||||
delete_result: Ok(()),
|
||||
delete_calls: Cell::new(0),
|
||||
wiped_after_delete: false,
|
||||
verify_always_fails: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete succeeds but verify returns false — simulates a transient
|
||||
/// unknown error during verification (e.g. keyring constructor failure
|
||||
/// or unclassified read error that cannot confirm absence).
|
||||
fn ok_but_verify_fails() -> Self {
|
||||
FakeKeychain {
|
||||
delete_result: Ok(()),
|
||||
delete_calls: Cell::new(0),
|
||||
wiped_after_delete: true,
|
||||
verify_always_fails: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ResetKeychain for FakeKeychain {
|
||||
fn delete_all_with_legacy(&self) -> Result<(), String> {
|
||||
self.delete_calls.set(self.delete_calls.get() + 1);
|
||||
self.delete_result.clone()
|
||||
}
|
||||
|
||||
fn verify_fully_wiped(&self) -> bool {
|
||||
if self.verify_always_fails {
|
||||
return false;
|
||||
}
|
||||
self.wiped_after_delete && self.delete_calls.get() > 0
|
||||
}
|
||||
}
|
||||
|
||||
fn make_app_data(tmp: &TempDir) -> PathBuf {
|
||||
let dir = tmp
|
||||
.path()
|
||||
.join("Application Support")
|
||||
.join("xyz.block.buzz.app");
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
fn make_ctx<'a>(
|
||||
app_data_dir: &'a Path,
|
||||
keychain: &'a dyn ResetKeychain,
|
||||
is_dev: bool,
|
||||
) -> ResetContext<'a> {
|
||||
ResetContext {
|
||||
app_data_dir,
|
||||
legacy_app_data_dir: None,
|
||||
nest_dir: None,
|
||||
keychain,
|
||||
home_dir: None, // skip nest/sprout/CLI ops in unit tests
|
||||
is_dev,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Test 1: no sentinel ───────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_no_sentinel_returns_no_op() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let app_data = make_app_data(&tmp);
|
||||
let kc = FakeKeychain::ok();
|
||||
|
||||
let outcome = run_boot_reset(app_data.as_path());
|
||||
assert!(!outcome.completed, "no sentinel → not completed");
|
||||
assert!(!outcome.failed, "no sentinel → not failed");
|
||||
assert_eq!(kc.delete_calls.get(), 0, "keychain not touched");
|
||||
assert!(app_data.exists(), "app-data dir untouched");
|
||||
}
|
||||
|
||||
// ── Test 2: full wipe succeeds ────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_sentinel_present_full_wipe_succeeds() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let app_data = make_app_data(&tmp);
|
||||
|
||||
// Also create a legacy App Support dir (sprout import source).
|
||||
let legacy_dir = tmp
|
||||
.path()
|
||||
.join("Application Support")
|
||||
.join("xyz.block.sprout.app");
|
||||
std::fs::create_dir_all(&legacy_dir).unwrap();
|
||||
std::fs::write(legacy_dir.join("identity.key"), b"old-identity").unwrap();
|
||||
|
||||
write_sentinel(&app_data).unwrap();
|
||||
let kc = FakeKeychain::ok();
|
||||
|
||||
let ctx = ResetContext {
|
||||
app_data_dir: &app_data,
|
||||
legacy_app_data_dir: Some(legacy_dir.clone()),
|
||||
nest_dir: None,
|
||||
keychain: &kc,
|
||||
home_dir: None,
|
||||
is_dev: false,
|
||||
};
|
||||
|
||||
let outcome = run_boot_reset_with_keychain(ctx);
|
||||
|
||||
assert!(outcome.completed, "should complete");
|
||||
assert!(!outcome.failed, "should not fail");
|
||||
assert!(!app_data.exists(), "app-data must be gone");
|
||||
assert!(!legacy_dir.exists(), "legacy app-data must be gone");
|
||||
assert!(!sentinel_path(&app_data).exists(), "sentinel must be gone");
|
||||
assert_eq!(kc.delete_calls.get(), 1, "keychain deleted once");
|
||||
}
|
||||
|
||||
// ── Test 3: keychain failure keeps sentinel ────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_sentinel_present_keychain_failure_keeps_sentinel() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let app_data = make_app_data(&tmp);
|
||||
write_sentinel(&app_data).unwrap();
|
||||
let kc = FakeKeychain::fail("keychain unavailable");
|
||||
let ctx = make_ctx(&app_data, &kc, false);
|
||||
|
||||
let outcome = run_boot_reset_with_keychain(ctx);
|
||||
|
||||
assert!(!outcome.completed);
|
||||
assert!(outcome.failed);
|
||||
assert!(
|
||||
sentinel_path(&app_data).exists(),
|
||||
"sentinel must be preserved on failure"
|
||||
);
|
||||
}
|
||||
|
||||
// ── Test 4: app-data rename works but verify fails ────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_sentinel_present_verify_failure_keeps_sentinel() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let app_data = make_app_data(&tmp);
|
||||
write_sentinel(&app_data).unwrap();
|
||||
// Keychain delete "succeeds" but verify_fully_wiped still returns false.
|
||||
let kc = FakeKeychain::ok_but_not_wiped();
|
||||
let ctx = make_ctx(&app_data, &kc, false);
|
||||
|
||||
let outcome = run_boot_reset_with_keychain(ctx);
|
||||
|
||||
assert!(!outcome.completed);
|
||||
assert!(outcome.failed);
|
||||
assert!(sentinel_path(&app_data).exists(), "sentinel preserved");
|
||||
}
|
||||
|
||||
// ── Test 5: crash-then-retry completes ───────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_crash_then_retry_completes() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let app_data = make_app_data(&tmp);
|
||||
write_sentinel(&app_data).unwrap();
|
||||
|
||||
// First run — keychain fails (simulates a crash mid-wipe).
|
||||
let kc1 = FakeKeychain::fail("transient error");
|
||||
let ctx1 = make_ctx(&app_data, &kc1, false);
|
||||
let first = run_boot_reset_with_keychain(ctx1);
|
||||
assert!(first.failed);
|
||||
assert!(
|
||||
sentinel_path(&app_data).exists(),
|
||||
"sentinel must survive first attempt"
|
||||
);
|
||||
|
||||
// Second run — keychain succeeds. App-data dir was renamed but we need
|
||||
// to recreate it for the test (the wipe tried to rename it).
|
||||
// In production the dir would already be gone; here it was renamed to
|
||||
// .trash-<pid> and then not cleaned up (keychain failed before cleanup).
|
||||
// Create a fresh app-data dir to simulate a reboot where app recreated it.
|
||||
std::fs::create_dir_all(&app_data).unwrap();
|
||||
|
||||
let kc2 = FakeKeychain::ok();
|
||||
let ctx2 = make_ctx(&app_data, &kc2, false);
|
||||
let second = run_boot_reset_with_keychain(ctx2);
|
||||
assert!(second.completed, "second attempt must complete");
|
||||
assert!(!second.failed);
|
||||
assert!(
|
||||
!sentinel_path(&app_data).exists(),
|
||||
"sentinel cleared on success"
|
||||
);
|
||||
}
|
||||
|
||||
// ── Test 6: dev build wipes dev nest, leaves prod nest intact ────────────
|
||||
|
||||
#[test]
|
||||
fn test_dev_build_wipes_dev_nest_not_prod() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
|
||||
// Create both nests.
|
||||
let dev_nest = tmp.path().join(".buzz-dev");
|
||||
let prod_nest = tmp.path().join(".buzz");
|
||||
std::fs::create_dir_all(&dev_nest).unwrap();
|
||||
std::fs::create_dir_all(&prod_nest).unwrap();
|
||||
|
||||
let app_data = tmp
|
||||
.path()
|
||||
.join("Application Support")
|
||||
.join("xyz.block.buzz.app.dev");
|
||||
std::fs::create_dir_all(&app_data).unwrap();
|
||||
write_sentinel(&app_data).unwrap();
|
||||
|
||||
let kc = FakeKeychain::ok();
|
||||
let ctx = ResetContext {
|
||||
app_data_dir: &app_data,
|
||||
legacy_app_data_dir: None,
|
||||
nest_dir: Some(dev_nest.clone()),
|
||||
keychain: &kc,
|
||||
home_dir: None,
|
||||
is_dev: true,
|
||||
};
|
||||
|
||||
let outcome = run_boot_reset_with_keychain(ctx);
|
||||
|
||||
assert!(outcome.completed, "wipe must complete");
|
||||
assert!(!dev_nest.exists(), "dev nest must be wiped");
|
||||
assert!(prod_nest.exists(), "prod nest must survive");
|
||||
}
|
||||
|
||||
// ── Test 7: prod build wipes prod nest, leaves dev nest intact ───────────
|
||||
|
||||
#[test]
|
||||
fn test_prod_build_wipes_prod_nest_not_dev() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
|
||||
// Create both nests.
|
||||
let dev_nest = tmp.path().join(".buzz-dev");
|
||||
let prod_nest = tmp.path().join(".buzz");
|
||||
std::fs::create_dir_all(&dev_nest).unwrap();
|
||||
std::fs::create_dir_all(&prod_nest).unwrap();
|
||||
|
||||
let app_data = tmp
|
||||
.path()
|
||||
.join("Application Support")
|
||||
.join("xyz.block.buzz.app");
|
||||
std::fs::create_dir_all(&app_data).unwrap();
|
||||
write_sentinel(&app_data).unwrap();
|
||||
|
||||
let kc = FakeKeychain::ok();
|
||||
let ctx = ResetContext {
|
||||
app_data_dir: &app_data,
|
||||
legacy_app_data_dir: None,
|
||||
nest_dir: Some(prod_nest.clone()),
|
||||
keychain: &kc,
|
||||
home_dir: None,
|
||||
is_dev: false,
|
||||
};
|
||||
|
||||
let outcome = run_boot_reset_with_keychain(ctx);
|
||||
|
||||
assert!(outcome.completed, "wipe must complete");
|
||||
assert!(!prod_nest.exists(), "prod nest must be wiped");
|
||||
assert!(dev_nest.exists(), "dev nest must survive");
|
||||
}
|
||||
|
||||
// ── Test 8: legacy app-data removed on reset ──────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_legacy_app_data_removed_on_reset() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let app_data = make_app_data(&tmp);
|
||||
|
||||
// Seed a legacy sprout dir with data that would be re-imported.
|
||||
let legacy_dir = tmp
|
||||
.path()
|
||||
.join("Application Support")
|
||||
.join("xyz.block.sprout.app");
|
||||
std::fs::create_dir_all(legacy_dir.join("agents")).unwrap();
|
||||
std::fs::write(legacy_dir.join("identity.key"), b"sprout-nsec").unwrap();
|
||||
|
||||
write_sentinel(&app_data).unwrap();
|
||||
let kc = FakeKeychain::ok();
|
||||
let ctx = ResetContext {
|
||||
app_data_dir: &app_data,
|
||||
legacy_app_data_dir: Some(legacy_dir.clone()),
|
||||
nest_dir: None,
|
||||
keychain: &kc,
|
||||
home_dir: None,
|
||||
is_dev: false,
|
||||
};
|
||||
|
||||
let outcome = run_boot_reset_with_keychain(ctx);
|
||||
|
||||
assert!(outcome.completed, "reset must complete");
|
||||
assert!(!legacy_dir.exists(), "legacy app-data dir must be removed");
|
||||
}
|
||||
|
||||
// ── Test 9: unknown error during delete → failed, sentinel kept ────────
|
||||
|
||||
#[test]
|
||||
fn test_unknown_delete_error_keeps_sentinel() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let app_data = make_app_data(&tmp);
|
||||
write_sentinel(&app_data).unwrap();
|
||||
// Simulates an unclassified/transient keychain error during delete.
|
||||
let kc = FakeKeychain::fail("unknown transient keychain error");
|
||||
let ctx = make_ctx(&app_data, &kc, false);
|
||||
|
||||
let outcome = run_boot_reset_with_keychain(ctx);
|
||||
|
||||
assert!(outcome.failed, "unknown delete error must fail");
|
||||
assert!(!outcome.completed, "must not complete on delete error");
|
||||
assert!(
|
||||
sentinel_path(&app_data).exists(),
|
||||
"sentinel must survive unknown delete error"
|
||||
);
|
||||
}
|
||||
|
||||
// ── Test 10: unknown error during verify → failed, sentinel kept ─────
|
||||
|
||||
#[test]
|
||||
fn test_unknown_verify_error_keeps_sentinel() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let app_data = make_app_data(&tmp);
|
||||
write_sentinel(&app_data).unwrap();
|
||||
// Delete succeeds but verification fails — simulates a transient
|
||||
// keychain error (e.g. constructor failure, unclassified read error)
|
||||
// that cannot confirm absence. The sentinel must survive.
|
||||
let kc = FakeKeychain::ok_but_verify_fails();
|
||||
let ctx = make_ctx(&app_data, &kc, false);
|
||||
|
||||
let outcome = run_boot_reset_with_keychain(ctx);
|
||||
|
||||
assert!(outcome.failed, "unknown verify error must fail");
|
||||
assert!(!outcome.completed, "must not complete on verify error");
|
||||
assert!(
|
||||
sentinel_path(&app_data).exists(),
|
||||
"sentinel must survive unknown verify error"
|
||||
);
|
||||
}
|
||||
|
||||
// ── Test 11: completed dev reset suppresses dev repos-dir migration ────
|
||||
// Behavioral test at the composed seam: exercises real filesystem effects
|
||||
// through maybe_migrate_dev_repos_dir, not just the boolean predicate.
|
||||
|
||||
#[test]
|
||||
fn test_completed_dev_reset_suppresses_dev_repos_dir_migration() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let app_data = tmp
|
||||
.path()
|
||||
.join("Application Support")
|
||||
.join("xyz.block.buzz.app.dev");
|
||||
std::fs::create_dir_all(&app_data).unwrap();
|
||||
write_sentinel(&app_data).unwrap();
|
||||
|
||||
// Seed prod ~/.buzz/.repos-dir so the migration has something to copy.
|
||||
let home = tmp.path().join("home");
|
||||
let prod_nest = home.join(".buzz");
|
||||
std::fs::create_dir_all(&prod_nest).unwrap();
|
||||
std::fs::write(prod_nest.join(".repos-dir"), "/some/workspace").unwrap();
|
||||
|
||||
let dev_nest = tmp.path().join(".buzz-dev");
|
||||
|
||||
// Run a real reset and take the REAL outcome.completed.
|
||||
let kc = FakeKeychain::ok();
|
||||
let ctx = ResetContext {
|
||||
app_data_dir: &app_data,
|
||||
legacy_app_data_dir: None,
|
||||
nest_dir: Some(dev_nest.clone()),
|
||||
keychain: &kc,
|
||||
home_dir: None,
|
||||
is_dev: true,
|
||||
};
|
||||
let outcome = run_boot_reset_with_keychain(ctx);
|
||||
assert!(outcome.completed, "reset must complete");
|
||||
|
||||
// Arm 1: completed dev reset → dev nest must NOT get .repos-dir.
|
||||
crate::migration::maybe_migrate_dev_repos_dir(true, outcome.completed, &home, &dev_nest);
|
||||
assert!(
|
||||
!dev_nest.join(".repos-dir").exists(),
|
||||
"completed dev reset must suppress .repos-dir import into dev nest"
|
||||
);
|
||||
|
||||
// Arm 2 (positive control): non-reset dev boot → dev nest IS created
|
||||
// with .repos-dir copied. This proves the test would have caught the
|
||||
// pass-3 resurrection live.
|
||||
let dev_nest_2 = tmp.path().join(".buzz-dev-control");
|
||||
crate::migration::maybe_migrate_dev_repos_dir(true, false, &home, &dev_nest_2);
|
||||
assert!(
|
||||
dev_nest_2.join(".repos-dir").exists(),
|
||||
"non-reset dev boot must import .repos-dir into dev nest"
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(dev_nest_2.join(".repos-dir")).unwrap(),
|
||||
"/some/workspace",
|
||||
".repos-dir content must match prod source"
|
||||
);
|
||||
|
||||
// Arm 3: prod build (is_dev=false) → nothing created regardless.
|
||||
let dev_nest_3 = tmp.path().join(".buzz-dev-prod");
|
||||
crate::migration::maybe_migrate_dev_repos_dir(false, false, &home, &dev_nest_3);
|
||||
assert!(
|
||||
!dev_nest_3.join(".repos-dir").exists(),
|
||||
"prod builds must never run dev repos-dir migration"
|
||||
);
|
||||
}
|
||||
|
||||
// ── Test 12: crash-after-rename retry cleans prior trash ─────────────
|
||||
|
||||
#[test]
|
||||
fn test_crash_retry_cleans_prior_deterministic_trash() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let app_support = tmp.path().join("Application Support");
|
||||
let app_data = app_support.join("xyz.block.buzz.app");
|
||||
std::fs::create_dir_all(&app_data).unwrap();
|
||||
write_sentinel(&app_data).unwrap();
|
||||
|
||||
// Simulate a prior crashed boot: originals absent, deterministic trash
|
||||
// present from the crash (as if the process renamed then died).
|
||||
let trash_app_dir = app_support.join("xyz.block.buzz.app.reset-trash");
|
||||
std::fs::create_dir_all(&trash_app_dir).unwrap();
|
||||
std::fs::write(trash_app_dir.join("identity.key"), b"old-key").unwrap();
|
||||
|
||||
// Original is gone (crashed mid-wipe), so remove it for the retry.
|
||||
std::fs::remove_dir_all(&app_data).unwrap();
|
||||
|
||||
let kc = FakeKeychain::ok();
|
||||
let ctx = make_ctx(&app_data, &kc, false);
|
||||
|
||||
let outcome = run_boot_reset_with_keychain(ctx);
|
||||
assert!(outcome.completed, "retry must complete");
|
||||
assert!(
|
||||
!trash_app_dir.exists(),
|
||||
"prior crash trash must be cleaned by retry"
|
||||
);
|
||||
assert!(
|
||||
!sentinel_path(&app_data).exists(),
|
||||
"sentinel must be cleared"
|
||||
);
|
||||
}
|
||||
|
||||
// ── Test 13: keychain-fail restores all dirs, retry cleans trash ──────
|
||||
|
||||
#[test]
|
||||
fn test_keychain_fail_restores_all_then_retry_cleans() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let app_support = tmp.path().join("Application Support");
|
||||
let app_data = app_support.join("xyz.block.buzz.app");
|
||||
std::fs::create_dir_all(&app_data).unwrap();
|
||||
std::fs::write(app_data.join("config.json"), b"{}").unwrap();
|
||||
|
||||
let legacy = app_support.join("xyz.block.sprout.app");
|
||||
std::fs::create_dir_all(&legacy).unwrap();
|
||||
std::fs::write(legacy.join("identity.key"), b"sprout-key").unwrap();
|
||||
|
||||
write_sentinel(&app_data).unwrap();
|
||||
|
||||
// First attempt: keychain fails → dirs restored.
|
||||
let kc1 = FakeKeychain::fail("keychain locked");
|
||||
let ctx1 = ResetContext {
|
||||
app_data_dir: &app_data,
|
||||
legacy_app_data_dir: Some(legacy.clone()),
|
||||
nest_dir: None,
|
||||
keychain: &kc1,
|
||||
home_dir: Some(tmp.path().to_path_buf()),
|
||||
is_dev: false,
|
||||
};
|
||||
let first = run_boot_reset_with_keychain(ctx1);
|
||||
assert!(first.failed, "first attempt must fail");
|
||||
assert!(
|
||||
app_data.exists(),
|
||||
"app-data must be restored after keychain fail"
|
||||
);
|
||||
assert!(
|
||||
legacy.exists(),
|
||||
"legacy dir must be restored after keychain fail"
|
||||
);
|
||||
assert!(sentinel_path(&app_data).exists(), "sentinel survives");
|
||||
|
||||
// Second attempt: keychain succeeds → everything cleaned including
|
||||
// any residual trash from prior attempts.
|
||||
let kc2 = FakeKeychain::ok();
|
||||
let ctx2 = ResetContext {
|
||||
app_data_dir: &app_data,
|
||||
legacy_app_data_dir: Some(legacy.clone()),
|
||||
nest_dir: None,
|
||||
keychain: &kc2,
|
||||
home_dir: Some(tmp.path().to_path_buf()),
|
||||
is_dev: false,
|
||||
};
|
||||
let second = run_boot_reset_with_keychain(ctx2);
|
||||
assert!(second.completed, "second attempt must complete");
|
||||
assert!(!app_data.exists(), "app-data must be gone");
|
||||
assert!(!legacy.exists(), "legacy must be gone");
|
||||
// No trash directories should remain.
|
||||
let trash_app = app_support.join("xyz.block.buzz.app.reset-trash");
|
||||
let trash_legacy = app_support.join("xyz.block.sprout.app.reset-trash");
|
||||
assert!(!trash_app.exists(), "app trash must be cleaned");
|
||||
assert!(!trash_legacy.exists(), "legacy trash must be cleaned");
|
||||
}
|
||||
}
|
||||
@@ -740,6 +740,163 @@ impl SecretStore {
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete the entire keychain blob for this service, plus all legacy per-key
|
||||
/// entries that could resurrect an identity on next boot.
|
||||
///
|
||||
/// Order of operations:
|
||||
/// 1. Read the blob to collect every key name (e.g. `identity`, agent keys).
|
||||
/// 2. Delete legacy per-key DPK entries for every key + the DPK blob itself.
|
||||
/// 3. Delete legacy per-key keyring entries for every key.
|
||||
/// 4. Delete the blob entry.
|
||||
/// 5. Clear the in-memory cache.
|
||||
///
|
||||
/// This is the correct wipe path for sign-out: the old `delete_all` skipped
|
||||
/// step 1–3 so stale per-key entries could be re-imported on the next launch
|
||||
/// via `migrate_legacy_key`. This method prevents that resurrection.
|
||||
pub fn delete_all_with_legacy_cleanup(&self) -> Result<(), String> {
|
||||
#[cfg(feature = "system-keyring")]
|
||||
{
|
||||
let _lock = acquire_blob_lock(&self.service)?;
|
||||
|
||||
// Step 1: read current blob keys (best-effort; no entry = empty set).
|
||||
let blob_keys: Vec<String> = match self.read_blob_raw() {
|
||||
Ok(Some(bytes)) => {
|
||||
let json = String::from_utf8(bytes).unwrap_or_default();
|
||||
serde_json::from_str::<std::collections::HashMap<String, String>>(&json)
|
||||
.map(|m| m.into_keys().collect())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
_ => vec![],
|
||||
};
|
||||
|
||||
// Always include "identity" even if the blob is empty or absent —
|
||||
// it may exist only as a legacy per-key entry.
|
||||
let mut all_keys = blob_keys;
|
||||
if !all_keys.contains(&"identity".to_string()) {
|
||||
all_keys.push("identity".to_string());
|
||||
}
|
||||
|
||||
// Steps 2 & 3: delete legacy per-key entries for every key.
|
||||
for key in &all_keys {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
match delete_generic_password_options(dpk_opts(&self.service, key)) {
|
||||
Ok(()) => {}
|
||||
Err(ref e) if is_not_found(e) => {}
|
||||
Err(ref e) if is_dpk_unavailable(e) => {}
|
||||
Err(e) => return Err(format!("dpk per-key delete {key}: {e}")),
|
||||
}
|
||||
}
|
||||
{
|
||||
let entry = keyring_entry(&self.service, key)
|
||||
.map_err(|e| format!("keyring entry constructor {key}: {e}"))?;
|
||||
match entry.delete_credential() {
|
||||
Ok(()) | Err(keyring::Error::NoEntry) => {}
|
||||
Err(e) if is_keyring_availability_error(&e.to_string()) => {
|
||||
return Err(format!("keyring unavailable deleting {key}: {e}"));
|
||||
}
|
||||
Err(e) => {
|
||||
return Err(format!("keyring per-key delete {key}: {e}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Step 2 (cont.): also delete the legacy DPK blob written by #1267.
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
match delete_generic_password_options(dpk_opts(&self.service, BLOB_KEY)) {
|
||||
Ok(()) => {}
|
||||
Err(ref e) if is_not_found(e) => {}
|
||||
Err(ref e) if is_dpk_unavailable(e) => {}
|
||||
Err(e) => return Err(format!("dpk blob delete: {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
// Step 4: delete the main blob entry.
|
||||
{
|
||||
let entry = keyring_entry(&self.service, BLOB_KEY)
|
||||
.map_err(|e| format!("keyring entry constructor blob: {e}"))?;
|
||||
match entry.delete_credential() {
|
||||
Ok(()) | Err(keyring::Error::NoEntry) => {}
|
||||
Err(e) if is_keyring_availability_error(&e.to_string()) => {
|
||||
return Err(format!("keyring unavailable: {e}"));
|
||||
}
|
||||
Err(e) => {
|
||||
return Err(format!("keyring blob delete: {e}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Step 5: clear the in-memory cache.
|
||||
let mut guard = self.cache.lock().unwrap_or_else(|e| e.into_inner());
|
||||
*guard = None;
|
||||
Ok(())
|
||||
}
|
||||
#[cfg(not(feature = "system-keyring"))]
|
||||
{
|
||||
Ok(()) // No-op: no keyring, nothing to delete.
|
||||
}
|
||||
}
|
||||
|
||||
/// Verify no identity-bearing keychain entry survives in any shape
|
||||
/// that `load("identity")` → `migrate_legacy_key` can consume:
|
||||
/// main blob, DPK blob (`BLOB_KEY`), and per-key `"identity"`.
|
||||
///
|
||||
/// Returns `true` when all three shapes are absent (or inaccessible in an
|
||||
/// expected way), `false` when any entry is found or the keychain is
|
||||
/// unavailable (fail-closed).
|
||||
pub fn verify_fully_wiped(&self) -> bool {
|
||||
#[cfg(feature = "system-keyring")]
|
||||
{
|
||||
// 1. Main blob must be absent.
|
||||
match self.read_blob_raw() {
|
||||
Ok(None) => {}
|
||||
Ok(Some(_)) => return false,
|
||||
Err(_) => return false,
|
||||
}
|
||||
// 2. Per-key "identity" via legacy keyring must be absent.
|
||||
match keyring_entry(&self.service, "identity") {
|
||||
Ok(entry) => match entry.get_password() {
|
||||
Err(keyring::Error::NoEntry) => {}
|
||||
Ok(_) => return false,
|
||||
// Any other error (availability, unknown, transient) → fail closed.
|
||||
// Only explicit NoEntry is proof of absence.
|
||||
Err(_) => return false,
|
||||
},
|
||||
// Constructor failure → cannot verify → fail closed.
|
||||
Err(_) => return false,
|
||||
}
|
||||
// 3. DPK blob (macOS only).
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
match generic_password(dpk_opts(&self.service, BLOB_KEY)) {
|
||||
Err(ref e) if is_not_found(e) => {}
|
||||
// dpk-unavailable is symmetric with load(): if load() can't
|
||||
// consume DPK in this state, a surviving entry is harmless.
|
||||
Err(ref e) if is_dpk_unavailable(e) => {}
|
||||
Ok(_) => return false,
|
||||
// Any other error → fail closed (not proof of absence).
|
||||
Err(_) => return false,
|
||||
}
|
||||
// 4. Per-key DPK "identity" (macOS only).
|
||||
match generic_password(dpk_opts(&self.service, "identity")) {
|
||||
Err(ref e) if is_not_found(e) => {}
|
||||
// dpk-unavailable: symmetric with load() — if load() can't
|
||||
// read DPK, a surviving entry can't resurrect identity.
|
||||
Err(ref e) if is_dpk_unavailable(e) => {}
|
||||
Ok(_) => return false,
|
||||
// Any other error → fail closed.
|
||||
Err(_) => return false,
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
#[cfg(not(feature = "system-keyring"))]
|
||||
{
|
||||
true // No keyring = nothing to verify.
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete the secret for `key`. A missing entry is not an error.
|
||||
pub fn delete(&self, key: &str) -> Result<(), String> {
|
||||
#[cfg(feature = "system-keyring")]
|
||||
@@ -1108,4 +1265,42 @@ mod tests {
|
||||
// Cleanup.
|
||||
let _ = store2.delete(key);
|
||||
}
|
||||
|
||||
#[ignore = "requires real OS keychain (run locally)"]
|
||||
#[test]
|
||||
fn delete_all_with_legacy_cleanup_removes_per_key_identity() {
|
||||
let svc = "buzz-test-delete-all-legacy";
|
||||
let key = "identity";
|
||||
let value = "nsec1legacytest";
|
||||
|
||||
// Seed a legacy per-key entry (old format, pre-blob migration).
|
||||
let entry = keyring_entry(svc, key).unwrap();
|
||||
entry.set_password(value).unwrap();
|
||||
|
||||
// Also seed a blob with a different key to exercise the full path.
|
||||
let store = SecretStore::keyring(svc);
|
||||
store.store("agent:abc123", "nsec1agent").unwrap();
|
||||
|
||||
// Legacy per-key identity should be discoverable via probe.
|
||||
let store2 = SecretStore::keyring(svc);
|
||||
assert_eq!(store2.probe(key), KeyringProbe::Present);
|
||||
|
||||
// Wipe everything via the sign-out path.
|
||||
store2.delete_all_with_legacy_cleanup().unwrap();
|
||||
|
||||
// Fresh store — neither the blob nor the per-key entry should remain.
|
||||
let store3 = SecretStore::keyring(svc);
|
||||
assert_eq!(
|
||||
store3.probe(key),
|
||||
KeyringProbe::ReachableButEmpty,
|
||||
"per-key identity must not survive delete_all_with_legacy_cleanup"
|
||||
);
|
||||
assert_eq!(
|
||||
store3.load(key).unwrap(),
|
||||
None,
|
||||
"load must not resurrect the legacy per-key identity"
|
||||
);
|
||||
// Agent key should also be gone.
|
||||
assert_eq!(store3.load("agent:abc123").unwrap(), None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import { OnboardingSlideTransition } from "@/features/onboarding/ui/OnboardingSl
|
||||
import { OnboardingFlow } from "@/features/onboarding/ui/OnboardingFlow";
|
||||
import { KeyringLockedScreen } from "@/features/onboarding/ui/KeyringLockedScreen";
|
||||
import { RelaunchRequiredScreen } from "@/features/onboarding/ui/RelaunchRequiredScreen";
|
||||
import { ResetFailedScreen } from "@/features/onboarding/ui/ResetFailedScreen";
|
||||
import type { Community } from "@/features/communities/types";
|
||||
import { useCommunityInit } from "@/features/communities/useCommunityInit";
|
||||
import { useNestNotifications } from "@/features/communities/useNestNotifications";
|
||||
@@ -299,6 +300,10 @@ function AppReady({
|
||||
onFirstRunCommunitySettled,
|
||||
]);
|
||||
|
||||
if (onboarding.stage === "reset-failed") {
|
||||
return <ResetFailedScreen />;
|
||||
}
|
||||
|
||||
if (onboarding.stage === "keyring-locked") {
|
||||
return <KeyringLockedScreen />;
|
||||
}
|
||||
|
||||
@@ -47,7 +47,6 @@ export function useIndependentThreadPanel(args: {
|
||||
args.personaLookup,
|
||||
args.respondToLookup,
|
||||
),
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: fields listed explicitly — see comment above
|
||||
[
|
||||
args.channelEvents,
|
||||
args.threadReplyEvents,
|
||||
|
||||
@@ -413,6 +413,9 @@ export function useAppOnboardingState(isSharedIdentity: boolean) {
|
||||
// the session cannot access it. No in-app recovery is possible; the user
|
||||
// must unlock the keyring externally and relaunch. Mutually exclusive with lost.
|
||||
const identityLocked = identity?.locked === true;
|
||||
// Boot-time Phase 2 reset failed — wipe was attempted but verification failed.
|
||||
// The sentinel is preserved so the next relaunch retries automatically.
|
||||
const identityResetFailed = identity?.resetFailed === true;
|
||||
|
||||
// Sticky boot fact: once identity was lost at boot, this remains true for the
|
||||
// entire session. Per-component state in OnboardingFlow cannot carry this
|
||||
@@ -516,15 +519,18 @@ export function useAppOnboardingState(isSharedIdentity: boolean) {
|
||||
currentPubkey,
|
||||
flow,
|
||||
identityLost,
|
||||
// keyring-locked is the highest-precedence stage: nothing in-session can
|
||||
// clear a locked keyring, so this fully blocks the UI until relaunch.
|
||||
// reset-failed is the highest-precedence stage: a failed boot-time reset
|
||||
// means identity resolution was skipped entirely. Nothing can proceed until
|
||||
// the user relaunches and the wipe retries.
|
||||
stage:
|
||||
identityLocked && identityQuery.status === "success"
|
||||
? ("keyring-locked" as const)
|
||||
: relaunchRequired
|
||||
? ("relaunch-required" as const)
|
||||
: isCompletingWelcomeSetup
|
||||
? ("blocking" as const)
|
||||
: onboardingGate.stage,
|
||||
identityResetFailed && identityQuery.status === "success"
|
||||
? ("reset-failed" as const)
|
||||
: identityLocked && identityQuery.status === "success"
|
||||
? ("keyring-locked" as const)
|
||||
: relaunchRequired
|
||||
? ("relaunch-required" as const)
|
||||
: isCompletingWelcomeSetup
|
||||
? ("blocking" as const)
|
||||
: onboardingGate.stage,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { RecoveryScreen } from "./RecoveryScreen";
|
||||
|
||||
export function ResetFailedScreen() {
|
||||
return (
|
||||
<RecoveryScreen
|
||||
testId="reset-failed"
|
||||
title="Sign out could not complete"
|
||||
body="Buzz was unable to fully clear your local data. Try relaunching — the reset will resume automatically. If this persists, contact support."
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -13,7 +13,17 @@ import {
|
||||
useUpdateProfileMutation,
|
||||
} from "@/features/profile/hooks";
|
||||
import { NsecMaskedDisplay } from "@/features/onboarding/ui/NsecMaskedDisplay";
|
||||
import { getNsec } from "@/shared/api/tauriIdentity";
|
||||
import { getNsec, signOut } from "@/shared/api/tauriIdentity";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/shared/ui/alert-dialog";
|
||||
import { MaskedAvatarBadgeFrame } from "@/features/profile/ui/MaskedAvatarBadgeFrame";
|
||||
import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar";
|
||||
import {
|
||||
@@ -250,11 +260,15 @@ export function ProfileSettingsCard({
|
||||
const [shouldRenderAvatarEditor, setShouldRenderAvatarEditor] =
|
||||
React.useState(false);
|
||||
const [avatarSquishKey, setAvatarSquishKey] = React.useState(0);
|
||||
const [isSignOutOpen, setIsSignOutOpen] = React.useState(false);
|
||||
const [isSignOutPending, setIsSignOutPending] = React.useState(false);
|
||||
const displayNameInputRef = React.useRef<HTMLInputElement>(null);
|
||||
const aboutTextareaRef = React.useRef<HTMLTextAreaElement>(null);
|
||||
const sectionRef = React.useRef<HTMLElement>(null);
|
||||
const isEditingProfileMetadataRef = React.useRef(false);
|
||||
const avatarEditorOpenFrameRef = React.useRef<number | null>(null);
|
||||
const avatarEditorFinishTimeoutRef = React.useRef<number | null>(null);
|
||||
const savedScrollTopRef = React.useRef<number | null>(null);
|
||||
isEditingProfileMetadataRef.current = isEditingProfileMetadata;
|
||||
|
||||
React.useEffect(() => {
|
||||
@@ -411,14 +425,31 @@ export function ProfileSettingsCard({
|
||||
window.clearTimeout(avatarEditorFinishTimeoutRef.current);
|
||||
avatarEditorFinishTimeoutRef.current = null;
|
||||
}, []);
|
||||
const saveScrollPosition = React.useCallback(() => {
|
||||
const el = sectionRef.current;
|
||||
if (!el) return;
|
||||
const scroller = el.closest<HTMLElement>("[class*='overflow-y']");
|
||||
if (scroller) savedScrollTopRef.current = scroller.scrollTop;
|
||||
}, []);
|
||||
const restoreScrollPosition = React.useCallback(() => {
|
||||
const saved = savedScrollTopRef.current;
|
||||
if (saved == null) return;
|
||||
savedScrollTopRef.current = null;
|
||||
const el = sectionRef.current;
|
||||
if (!el) return;
|
||||
const scroller = el.closest<HTMLElement>("[class*='overflow-y']");
|
||||
if (scroller) scroller.scrollTop = saved;
|
||||
}, []);
|
||||
const closeAvatarEditor = React.useCallback(() => {
|
||||
clearAvatarEditorFinishTimeout();
|
||||
setIsAvatarEditorOpen(false);
|
||||
setIsAvatarEditorFinishing(false);
|
||||
}, [clearAvatarEditorFinishTimeout]);
|
||||
restoreScrollPosition();
|
||||
}, [clearAvatarEditorFinishTimeout, restoreScrollPosition]);
|
||||
const completeAvatarEditorClose = React.useCallback(() => {
|
||||
setIsAvatarEditorOpen(false);
|
||||
clearAvatarEditorFinishTimeout();
|
||||
restoreScrollPosition();
|
||||
avatarEditorFinishTimeoutRef.current = window.setTimeout(
|
||||
() => {
|
||||
avatarEditorFinishTimeoutRef.current = null;
|
||||
@@ -426,7 +457,11 @@ export function ProfileSettingsCard({
|
||||
},
|
||||
shouldReduceMotion ? 0 : AVATAR_EDITOR_TRANSITION_MS,
|
||||
);
|
||||
}, [clearAvatarEditorFinishTimeout, shouldReduceMotion]);
|
||||
}, [
|
||||
clearAvatarEditorFinishTimeout,
|
||||
restoreScrollPosition,
|
||||
shouldReduceMotion,
|
||||
]);
|
||||
const reopenAvatarEditorAfterClose = React.useCallback(() => {
|
||||
clearAvatarEditorFinishTimeout();
|
||||
setShouldRenderAvatarEditor(true);
|
||||
@@ -435,6 +470,7 @@ export function ProfileSettingsCard({
|
||||
}, [clearAvatarEditorFinishTimeout]);
|
||||
|
||||
const openAvatarEditor = React.useCallback(() => {
|
||||
saveScrollPosition();
|
||||
setShouldRenderAvatarEditor(true);
|
||||
setIsAvatarEditorFinishing(false);
|
||||
clearAvatarEditorFinishTimeout();
|
||||
@@ -447,7 +483,7 @@ export function ProfileSettingsCard({
|
||||
avatarEditorOpenFrameRef.current = null;
|
||||
setIsAvatarEditorOpen(true);
|
||||
});
|
||||
}, [clearAvatarEditorFinishTimeout]);
|
||||
}, [clearAvatarEditorFinishTimeout, saveScrollPosition]);
|
||||
|
||||
const saveProfile = React.useCallback(async () => {
|
||||
if (!canSave) {
|
||||
@@ -535,7 +571,11 @@ export function ProfileSettingsCard({
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<section className="min-w-0" data-testid="settings-profile">
|
||||
<section
|
||||
className="min-w-0"
|
||||
data-testid="settings-profile"
|
||||
ref={sectionRef}
|
||||
>
|
||||
<div>
|
||||
<SettingsSectionHeader
|
||||
title="Profile"
|
||||
@@ -913,6 +953,80 @@ export function ProfileSettingsCard({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-8" data-testid="settings-signout">
|
||||
<SettingsSectionHeader
|
||||
title="Sign out"
|
||||
description="Removes your identity key and all local app data from this device. Back up your private key (nsec) before proceeding — this cannot be undone."
|
||||
/>
|
||||
<div className="overflow-hidden rounded-xl border border-destructive/20 bg-background/70 shadow-xs">
|
||||
<div className="flex items-center justify-between gap-4 px-4 py-3">
|
||||
<div className="min-w-0 space-y-1">
|
||||
<p className="text-sm font-medium">Clear data and sign out</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Wipes the keychain, agent settings, and app cache, then
|
||||
relaunches Buzz into first-run setup.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
className="inline-flex shrink-0 items-center gap-1.5 rounded-full border border-destructive/30 bg-destructive/5 px-3 py-1.5 text-sm font-medium text-destructive transition-colors hover:bg-destructive/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-destructive focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
data-testid="signout-open-dialog"
|
||||
disabled={isSignOutPending}
|
||||
onClick={() => setIsSignOutOpen(true)}
|
||||
type="button"
|
||||
>
|
||||
{isSignOutPending ? (
|
||||
<Spinner
|
||||
aria-label="Signing out"
|
||||
className="h-4 w-4 border-2"
|
||||
/>
|
||||
) : null}
|
||||
{isSignOutPending ? "Signing out…" : "Sign Out"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<AlertDialog
|
||||
onOpenChange={(open) => {
|
||||
if (!open && !isSignOutPending) setIsSignOutOpen(false);
|
||||
}}
|
||||
open={isSignOutOpen}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Sign out and wipe all data?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will delete your identity key, all agent settings, and
|
||||
cached data from this device, then relaunch Buzz into first-run
|
||||
setup. Make sure you have your private key (nsec) backed up
|
||||
before continuing — this cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isSignOutPending}>
|
||||
Cancel
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-destructive text-destructive-foreground shadow-xs hover:bg-destructive/90"
|
||||
data-testid="signout-confirm"
|
||||
disabled={isSignOutPending}
|
||||
onClick={() => {
|
||||
setIsSignOutPending(true);
|
||||
// Keep the pending state if signOut() resolves before restart.
|
||||
signOut().catch((err: unknown) => {
|
||||
setIsSignOutPending(false);
|
||||
setIsSignOutOpen(false);
|
||||
toast.error(
|
||||
err instanceof Error ? err.message : "Sign out failed.",
|
||||
);
|
||||
});
|
||||
}}
|
||||
>
|
||||
{isSignOutPending ? "Signing out…" : "Sign Out"}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ type RawIdentity = {
|
||||
display_name: string;
|
||||
lost?: boolean;
|
||||
locked?: boolean;
|
||||
reset_failed?: boolean;
|
||||
};
|
||||
|
||||
function fromRawIdentity(raw: RawIdentity): Identity {
|
||||
@@ -14,6 +15,7 @@ function fromRawIdentity(raw: RawIdentity): Identity {
|
||||
displayName: raw.display_name,
|
||||
lost: raw.lost === true,
|
||||
locked: raw.locked === true,
|
||||
resetFailed: raw.reset_failed === true,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -36,3 +38,14 @@ export async function persistCurrentIdentity(): Promise<Identity> {
|
||||
await invokeTauri<RawIdentity>("persist_current_identity"),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wipe all local Buzz state (keychain, App Support, WebKit, nest, OAuth cache,
|
||||
* CLI symlinks) and relaunch into first-run onboarding.
|
||||
*
|
||||
* The app restarts after this call completes. Callers should keep the pending
|
||||
* state until the process exits and only handle errors (e.g. display a toast).
|
||||
*/
|
||||
export async function signOut(): Promise<void> {
|
||||
await invokeTauri("sign_out");
|
||||
}
|
||||
|
||||
@@ -117,6 +117,10 @@ export type Identity = {
|
||||
* the user must unlock the keyring externally and relaunch.
|
||||
* Mutually exclusive with `lost`. */
|
||||
locked?: boolean;
|
||||
/** True when the boot-time Phase 2 reset attempted a wipe but verification
|
||||
* failed. Identity resolution was skipped; the sentinel is preserved so
|
||||
* the next relaunch retries the wipe automatically. */
|
||||
resetFailed?: boolean;
|
||||
};
|
||||
|
||||
export type Profile = {
|
||||
|
||||
@@ -9526,7 +9526,7 @@ export function maybeInstallE2eTauriMocks() {
|
||||
case "get_relay_self":
|
||||
if ((activeConfig?.mock?.relaySelfDelayMs ?? 0) > 0) {
|
||||
await new Promise((resolve) =>
|
||||
window.setTimeout(resolve, activeConfig!.mock!.relaySelfDelayMs),
|
||||
window.setTimeout(resolve, activeConfig?.mock?.relaySelfDelayMs),
|
||||
);
|
||||
}
|
||||
return activeConfig?.mock?.relaySelf ?? null;
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
import { installMockBridge } from "../helpers/bridge";
|
||||
import { openSettings } from "../helpers/settings";
|
||||
|
||||
const SHOTS = "test-results/signout";
|
||||
|
||||
test.describe("signout screenshots", () => {
|
||||
test.use({ viewport: { width: 1280, height: 900 } });
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
page.on("pageerror", (err) => {
|
||||
console.error(
|
||||
"PAGE ERROR:",
|
||||
err.message,
|
||||
err.stack?.split("\n").slice(0, 5).join("\n"),
|
||||
);
|
||||
});
|
||||
page.on("console", (msg) => {
|
||||
if (msg.type() === "error") {
|
||||
console.error("CONSOLE ERROR:", msg.text().slice(0, 500));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test("signout-section — Sign Out card in Settings › Profile", async ({
|
||||
page,
|
||||
}) => {
|
||||
await installMockBridge(page);
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
await openSettings(page, "profile");
|
||||
|
||||
const section = page.getByTestId("settings-signout");
|
||||
await section.scrollIntoViewIfNeeded();
|
||||
|
||||
// Settle animations before capture.
|
||||
await page.evaluate(() =>
|
||||
Promise.all(document.getAnimations().map((a) => a.finished)),
|
||||
);
|
||||
await page.waitForTimeout(200);
|
||||
|
||||
await section.screenshot({ path: `${SHOTS}/signout-section.png` });
|
||||
});
|
||||
|
||||
test("signout-dialog — AlertDialog shown before the wipe runs", async ({
|
||||
page,
|
||||
}) => {
|
||||
await installMockBridge(page);
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
await openSettings(page, "profile");
|
||||
|
||||
const section = page.getByTestId("settings-signout");
|
||||
await section.scrollIntoViewIfNeeded();
|
||||
|
||||
// Open the confirmation dialog.
|
||||
await page.getByTestId("signout-open-dialog").click();
|
||||
|
||||
const dialog = page.getByRole("alertdialog");
|
||||
await expect(dialog).toBeVisible({ timeout: 5_000 });
|
||||
await expect(dialog.getByText("Sign out and wipe all data?")).toBeVisible();
|
||||
|
||||
// Settle animations before capture.
|
||||
await page.evaluate(() =>
|
||||
Promise.all(document.getAnimations().map((a) => a.finished)),
|
||||
);
|
||||
await page.waitForTimeout(200);
|
||||
|
||||
await page.screenshot({ path: `${SHOTS}/signout-dialog.png` });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user