fix(desktop): stop losing imported identity on every launch (#1568)

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:
Will Pfleger
2026-07-09 17:58:36 -04:00
committed by GitHub
co-authored by npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7
parent 94ec2c2bc6
commit e15f068571
36 changed files with 2595 additions and 668 deletions
+1
View File
@@ -88,6 +88,7 @@ export default defineConfig({
"**/drafts-screenshots.spec.ts",
"**/buzz-theme-screenshots.spec.ts",
"**/channel-sort.spec.ts",
"**/identity-lost.spec.ts",
],
use: {
...devices["Desktop Chrome"],
+14 -2
View File
@@ -148,7 +148,11 @@ const overrides = new Map([
// baked-env-required-badge: getBakedBuildEnvKeys wrapper adds ~16 lines. Queued to split.
// restart-badge: started the queued split — start/stopManagedAgent moved to
// tauriManagedAgents.ts; limit ratcheted down 1388 → 1380 to bank the headroom.
["src/shared/api/tauri.ts", 1380],
// identity-import-keyring: identity wrappers (RawIdentity, getIdentity, getNsec,
// importIdentity, persistCurrentIdentity) moved to tauriIdentity.ts;
// limit ratcheted down 1380 → 1360 to bank the headroom (absorbs main-side
// growth landed between the split and the rebase).
["src/shared/api/tauri.ts", 1360],
// readiness-gate: PersonaDialog.tsx threads computeLocalModeGate +
// requiredCredentialEnvKeys + RequiredFieldLabel so the "New agent" dialog
// shows required markers and credential amber rows (parity with
@@ -175,6 +179,12 @@ const overrides = new Map([
// unified-agent-model 1A.1: inline test module moved to discovery/tests.rs,
// ratcheting 1259 -> 802 (under the 1000 default; entry kept as a ratchet).
["src-tauri/src/managed_agents/discovery.rs", 802],
// identity-import-keyring: the identity resolution state machine's behavioral
// matrix (46 tests over FakeIdentityStore — probe × marker × file cells,
// adoption / read-back-corruption / marker-failure arms, recovery-mode
// gating). Load-bearing regression coverage for silent identity rotation,
// not generic debt growth. Approved override; split if the matrix grows.
["src-tauri/src/app_state_tests.rs", 1420],
// migration_tests.rs carries the harness-sync migration coverage plus the
// patch_json_records owner-only writeback regression test (SECURITY.md:90
// crash-safe 0o600 fallback). Load-bearing security + feature coverage, not
@@ -217,7 +227,9 @@ const overrides = new Map([
// cross-process keychain race fix (D3): interprocess lock + BlobLockGuard +
// uid-keyed lockfile path + behavioral tests add ~303 lines. Load-bearing
// security fix for the lost-update race that stranded agent keys.
["src-tauri/src/secret_store.rs", 1110],
// identity-import-keyring: KeyringLockedScreen, RecoveryScreen,
// load_readonly + load_all_readonly + store_all for safe cross-service reads.
["src-tauri/src/secret_store.rs", 1140],
// 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],
+15
View File
@@ -102,5 +102,20 @@ fn main() {
println!("cargo:rustc-cfg=buzz_updater_enabled");
}
// Cargo test executables get no embedded Windows manifest (tauri_build
// attaches one to bin targets only), so the loader binds comctl32 v5, which
// lacks TaskDialogIndirect (statically imported via tauri-plugin-dialog/rfd)
// and debug test exes die at load with STATUS_ENTRYPOINT_NOT_FOUND. Declaring
// the Common Controls v6 dependency makes link.exe emit a side-by-side
// <exe>.manifest that the loader honors for manifest-less executables;
// binaries with an embedded manifest (the real app) ignore it.
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("windows")
&& std::env::var("CARGO_CFG_TARGET_ENV").as_deref() == Ok("msvc")
{
println!(
"cargo:rustc-link-arg=/MANIFESTDEPENDENCY:type='win32' name='Microsoft.Windows.Common-Controls' version='6.0.0.0' processorArchitecture='*' publicKeyToken='6595b64144ccf1df' language='*'"
);
}
tauri_build::build()
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -60,7 +60,7 @@ pub(super) fn retain_managed_agent_pending(
let content = serde_json::to_string(&agent_event_content(record))
.map_err(|e| format!("failed to serialize managed-agent content: {e}"))?;
let (owner_pubkey, event) = {
let keys = state.keys.lock().map_err(|e| e.to_string())?;
let keys = state.signing_keys()?;
let owner_pubkey = keys.public_key().to_hex();
let existing =
get_retained_event(&conn, KIND_MANAGED_AGENT, &owner_pubkey, &record.pubkey)?;
@@ -122,7 +122,7 @@ fn tombstone_managed_agent_pending(app: &AppHandle, state: &AppState, agent_pubk
let result = (|| -> Result<(), String> {
let (owner_pubkey, event) = {
let keys = state.keys.lock().map_err(|e| e.to_string())?;
let keys = state.signing_keys()?;
let owner_pubkey = keys.public_key().to_hex();
let event = build_agent_delete(agent_pubkey, &owner_pubkey)?
.sign_with_keys(&keys)
@@ -512,7 +512,7 @@ pub async fn create_managed_agent(
// Agents authenticate via the auth tag in their kind:0 profile event.
// No tokens are minted. Fail closed: bad auth tag → don't create agent.
let auth_tag = {
let owner_keys = state.keys.lock().map_err(|e| e.to_string())?;
let owner_keys = state.signing_keys()?;
// Bridge nostr 0.37 → 0.36 (buzz-sdk) via hex round-trip.
let compat_owner = nostr::Keys::parse(&owner_keys.secret_key().to_secret_hex())
.map_err(|e| format!("failed to bridge owner keys: {e}"))?;
+127 -48
View File
@@ -11,23 +11,37 @@ use crate::{
relay::{self, relay_api_base_url_with_override, relay_ws_url_with_override},
};
/// Encode `pubkey` as npub bech32 and truncate it for display: first 10 chars
/// + "…" + last 4 chars. Returns the full bech32 when it is 16 chars or fewer.
fn truncated_display_name(pubkey: &PublicKey) -> Result<String, String> {
let bech32 = pubkey
.to_bech32()
.map_err(|error| format!("bech32 encode failed: {error}"))?;
Ok(if bech32.len() > 16 {
format!("{}…{}", &bech32[..10], &bech32[bech32.len() - 4..])
} else {
bech32
})
}
#[tauri::command]
pub fn get_identity(state: State<'_, AppState>) -> Result<IdentityInfo, String> {
let keys = state.keys.lock().map_err(|error| error.to_string())?;
let pubkey = keys.public_key();
let pubkey_hex = pubkey.to_hex();
let bech32 = pubkey
.to_bech32()
.map_err(|error| format!("bech32 encode failed: {error}"))?;
let display_name = if bech32.len() > 16 {
format!("{}…{}", &bech32[..10], &bech32[bech32.len() - 4..])
} else {
bech32
};
let display_name = truncated_display_name(&pubkey)?;
let lost = state
.identity_lost
.load(std::sync::atomic::Ordering::Acquire);
let locked = state
.keyring_locked
.load(std::sync::atomic::Ordering::Acquire);
Ok(IdentityInfo {
pubkey: pubkey_hex,
display_name,
lost,
locked,
})
}
@@ -72,11 +86,7 @@ pub async fn sign_event(
tags: Vec<Vec<String>>,
state: State<'_, AppState>,
) -> Result<String, String> {
let keys = state
.keys
.lock()
.map_err(|error| error.to_string())?
.clone();
let keys = state.signing_keys()?;
tauri::async_runtime::spawn_blocking(move || {
let nostr_tags = tags
@@ -104,13 +114,7 @@ pub fn decrypt_observer_event(
event_json: String,
state: State<'_, AppState>,
) -> Result<serde_json::Value, String> {
let nsec = {
let keys = state.keys.lock().map_err(|error| error.to_string())?;
keys.secret_key()
.to_bech32()
.map_err(|error| format!("encode nsec: {error}"))?
};
let keys = Keys::parse(&nsec).map_err(|error| format!("parse nsec: {error}"))?;
let keys = state.signing_keys()?;
let event = Event::from_json(event_json).map_err(|error| format!("invalid event: {error}"))?;
// Defense-in-depth: verify event ID and signature before decrypting.
@@ -131,13 +135,7 @@ pub fn build_observer_control_event(
payload: serde_json::Value,
state: State<'_, AppState>,
) -> Result<String, String> {
let nsec = {
let keys = state.keys.lock().map_err(|error| error.to_string())?;
keys.secret_key()
.to_bech32()
.map_err(|error| format!("encode nsec: {error}"))?
};
let keys = Keys::parse(&nsec).map_err(|error| format!("parse nsec: {error}"))?;
let keys = state.signing_keys()?;
let agent_pubkey = PublicKey::from_hex(agent_pubkey.trim())
.map_err(|error| format!("invalid agent pubkey: {error}"))?;
let agent_pubkey_hex = agent_pubkey.to_hex();
@@ -159,7 +157,7 @@ pub fn build_observer_control_event(
#[tauri::command]
pub fn get_nsec(state: State<'_, AppState>) -> Result<String, String> {
let keys = state.keys.lock().map_err(|error| error.to_string())?;
let keys = state.signing_keys()?;
keys.secret_key()
.to_bech32()
.map_err(|error| format!("encode nsec: {error}"))
@@ -174,36 +172,121 @@ pub async fn import_identity(
let trimmed = nsec.trim();
let keys = Keys::parse(trimmed).map_err(|e| format!("Invalid private key: {e}"))?;
// Persist to identity.key before swapping in-memory state. If the disk
// write fails, the running app keeps the old identity.
// Serialize against persist_current_identity: hold this guard for the
// full function body so a concurrent stale persist can't overwrite
// this import.
let state = app_handle.state::<AppState>();
let _mutation_guard = state.identity_mutation.lock().map_err(|e| e.to_string())?;
let data_dir = app_handle
.path()
.app_data_dir()
.map_err(|e| format!("app data dir: {e}"))?;
std::fs::create_dir_all(&data_dir).map_err(|e| format!("create app data dir: {e}"))?;
let key_path = data_dir.join("identity.key");
crate::app_state::save_key_file(&key_path, &keys)?;
// Update in-memory keys only after persistence succeeds.
let state = app_handle.state::<AppState>();
// Persist into the OS keyring first (store → read-back verify → marker →
// delete file). Falls back to the 0o600 file when the keyring is
// unavailable; returns Err only when both backends fail.
let store = crate::secret_store::SecretStore::shared(crate::app_state::keyring_service());
crate::app_state::persist_imported_identity(store, &keys, &key_path, &data_dir)?;
// Update in-memory keys BEFORE clearing recovery flags. The Release
// stores below pair with Acquire loads in get_identity: a reader
// observing false is guaranteed to see the updated keys.
let pubkey = keys.public_key();
*state.keys.lock().map_err(|e| e.to_string())? = keys;
// Clear both recovery flags — an import is valid in either lost or
// keyring-locked state and resolves both. In the locked case the
// keyring is unreachable, so persist_imported_identity already fell
// back to identity.key; on the next Unreachable boot the file is
// loaded directly and when the keyring returns the adoption path
// picks it up.
state
.identity_lost
.store(false, std::sync::atomic::Ordering::Release);
state
.keyring_locked
.store(false, std::sync::atomic::Ordering::Release);
let pubkey_hex = pubkey.to_hex();
let bech32 = pubkey
.to_bech32()
.map_err(|error| format!("bech32 encode failed: {error}"))?;
let display_name = if bech32.len() > 16 {
format!("{}…{}", &bech32[..10], &bech32[bech32.len() - 4..])
} else {
bech32
};
let display_name = truncated_display_name(&pubkey)?;
eprintln!("buzz-desktop: imported identity pubkey {}", pubkey_hex);
Ok(IdentityInfo {
pubkey: pubkey_hex,
display_name,
lost: false,
locked: false,
})
})
.await
.map_err(|e| format!("spawn_blocking failed: {e}"))?
}
/// Make the current ephemeral identity durable by persisting it to the OS
/// keyring (or falling back to identity.key). This is called when the user
/// chooses to start a new identity instead of re-importing their previous one
/// — it converts the transient lost-state key into a permanent identity.
///
/// **LOST-ONLY**: returns `Err` when `identity_lost` is false, and deliberately
/// does NOT accept `keyring_locked`. In locked state the user's real identity
/// still exists in the unreachable keyring; persisting the ephemeral key to
/// `identity.key` would make it appear as a "different key" on next boot,
/// and the mismatched-file adoption path would then clobber the real keyring
/// key once the keyring becomes reachable again. The correct action in locked
/// state is to unlock the keyring and relaunch — not to adopt the ephemeral key.
#[tauri::command]
pub async fn persist_current_identity(
app_handle: tauri::AppHandle,
) -> Result<IdentityInfo, String> {
tokio::task::spawn_blocking(move || {
let state = app_handle.state::<AppState>();
// Acquire mutation lock before reading identity_lost so that a
// concurrent import_identity cannot complete between our check and
// our persist, which would let the stale ephemeral key overwrite the
// imported one.
let _mutation_guard = state.identity_mutation.lock().map_err(|e| e.to_string())?;
if !state
.identity_lost
.load(std::sync::atomic::Ordering::Acquire)
{
return Err("identity is not in a lost state".to_string());
}
// Clone current keys without holding the mutex across keyring I/O.
let keys = state.keys.lock().map_err(|e| e.to_string())?.clone();
let data_dir = app_handle
.path()
.app_data_dir()
.map_err(|e| format!("app data dir: {e}"))?;
std::fs::create_dir_all(&data_dir).map_err(|e| format!("create app data dir: {e}"))?;
let key_path = data_dir.join("identity.key");
let store = crate::secret_store::SecretStore::shared(crate::app_state::keyring_service());
crate::app_state::persist_imported_identity(store, &keys, &key_path, &data_dir)?;
// Keys are already the live identity — only clear identity_lost.
// Release pairs with Acquire in get_identity so readers see
// consistent state.
state
.identity_lost
.store(false, std::sync::atomic::Ordering::Release);
let pubkey = keys.public_key();
let pubkey_hex = pubkey.to_hex();
let display_name = truncated_display_name(&pubkey)?;
Ok(IdentityInfo {
pubkey: pubkey_hex,
display_name,
lost: false,
locked: false,
})
})
.await
@@ -293,11 +376,7 @@ pub async fn create_auth_event(
relay_url: String,
state: State<'_, AppState>,
) -> Result<String, String> {
let keys = state
.keys
.lock()
.map_err(|error| error.to_string())?
.clone();
let keys = state.signing_keys()?;
tauri::async_runtime::spawn_blocking(move || {
let tags = vec![
@@ -323,7 +402,7 @@ pub async fn nip44_encrypt_to_self(
plaintext: String,
state: State<'_, AppState>,
) -> Result<String, String> {
let keys = state.keys.lock().map_err(|e| e.to_string())?.clone();
let keys = state.signing_keys()?;
tauri::async_runtime::spawn_blocking(move || {
nip44::encrypt(
@@ -343,7 +422,7 @@ pub async fn nip44_decrypt_from_self(
ciphertext: String,
state: State<'_, AppState>,
) -> Result<String, String> {
let keys = state.keys.lock().map_err(|e| e.to_string())?.clone();
let keys = state.signing_keys()?;
tauri::async_runtime::spawn_blocking(move || {
nip44::decrypt(keys.secret_key(), &keys.public_key(), &ciphertext)
+1 -1
View File
@@ -208,7 +208,7 @@ async fn do_upload(
};
let base_url = relay_api_base_url_with_override(state);
let auth_event = {
let keys = state.keys.lock().map_err(|e| e.to_string())?;
let keys = state.signing_keys()?;
sign_blossom_upload_auth(&keys, &sha256, expiry_secs, &base_url)?
};
+6 -9
View File
@@ -76,15 +76,12 @@ pub async fn start_pairing(
}
pairing.clear();
let (nsec, pubkey_hex) = {
let keys = state.keys.lock().map_err(|e| e.to_string())?;
let nsec = keys
.secret_key()
.to_bech32()
.map_err(|e| format!("encode nsec: {e}"))?;
let pubkey = keys.public_key().to_hex();
(nsec, pubkey)
};
let keys = state.signing_keys()?;
let nsec = keys
.secret_key()
.to_bech32()
.map_err(|e| format!("encode nsec: {e}"))?;
let pubkey_hex = keys.public_key().to_hex();
let ws_url = relay_ws_url_with_override(&state);
let http_url = relay_api_base_url_with_override(&state);
+46 -22
View File
@@ -312,6 +312,19 @@ pub fn run() {
resolve_persisted_identity(&app_handle, &state)
.map_err(|e| -> Box<dyn std::error::Error> { e.into() })?;
// When the identity is in recovery mode (lost = keyring empty after
// migration, or keyring-locked = keyring unreachable but marker
// present), all owner-keyed side effects (event sync, agent restore,
// relay publish) are skipped. The frontend shows a recovery screen;
// the user must relaunch after restoring the identity.
let identity_lost = state
.identity_lost
.load(std::sync::atomic::Ordering::Acquire);
let keyring_locked = state
.keyring_locked
.load(std::sync::atomic::Ordering::Acquire);
let recovery_mode = identity_lost || keyring_locked;
// Snapshot owner keys after identity resolution; the best-effort
// event reconcile itself runs off the synchronous setup path below.
let owner_keys = state
@@ -420,8 +433,11 @@ pub fn run() {
// Sync team-dir edits and reconcile persona/team/agent events after
// setup can continue. It is best-effort retention backfill, unlike
// identity resolution above, so JSON/SQLite/signing work must not
// hold the boot path hostage.
event_sync::spawn_event_sync(app_handle.clone(), owner_keys);
// hold the boot path hostage. Skipped in recovery mode — the owner
// key is ephemeral.
if !recovery_mode {
event_sync::spawn_event_sync(app_handle.clone(), owner_keys);
}
if let Some(mgr) = huddle::models::global_model_manager() {
mgr.start_stt_download(state.http_client.clone());
@@ -447,7 +463,9 @@ pub fn run() {
// the boot-time repos symlink result (see restore_agents above):
// skip when a configured repos_dir could not be resolved, so no
// agent clones into a REPOS that isn't the user's target.
if restore_agents {
// Also skipped in recovery mode — agents must not be spawned
// under an ephemeral owner key.
if restore_agents && !recovery_mode {
tauri::async_runtime::spawn(async move {
if let Err(error) =
restore_managed_agents_on_launch(&app_handle, shutdown_started.as_ref())
@@ -502,26 +520,31 @@ pub fn run() {
// One loop is the sole publisher for persona, team, and managed-
// agent writers; a relay-unreachable tick leaves rows pending for
// the next sweep.
let flush_handle = app.handle().clone();
tauri::async_runtime::spawn(async move {
use std::time::Duration;
use tauri::Manager;
let Ok(db_path) = managed_agents::managed_agents_base_dir(&flush_handle)
.map(|d| d.join("retention.db"))
else {
eprintln!("buzz-desktop: event-flush: cannot resolve retention db path");
return;
};
loop {
let state = flush_handle.state::<AppState>();
if let Err(e) =
managed_agents::persona_events::flush_pending_events(&db_path, &state).await
{
eprintln!("buzz-desktop: event-flush: {e}");
// Skipped in recovery mode — flushing under an ephemeral key would
// publish events attributed to an identity the user doesn't own.
if !recovery_mode {
let flush_handle = app.handle().clone();
tauri::async_runtime::spawn(async move {
use std::time::Duration;
use tauri::Manager;
let Ok(db_path) = managed_agents::managed_agents_base_dir(&flush_handle)
.map(|d| d.join("retention.db"))
else {
eprintln!("buzz-desktop: event-flush: cannot resolve retention db path");
return;
};
loop {
let state = flush_handle.state::<AppState>();
if let Err(e) =
managed_agents::persona_events::flush_pending_events(&db_path, &state)
.await
{
eprintln!("buzz-desktop: event-flush: {e}");
}
tokio::time::sleep(Duration::from_secs(30)).await;
}
tokio::time::sleep(Duration::from_secs(30)).await;
}
});
});
}
Ok(())
})
@@ -530,6 +553,7 @@ pub fn run() {
get_identity,
get_nsec,
import_identity,
persist_current_identity,
get_profile,
update_profile,
get_user_profile,
+11
View File
@@ -6,6 +6,17 @@ use serde::{Deserialize, Deserializer, Serialize};
pub struct IdentityInfo {
pub pubkey: String,
pub display_name: String,
/// True when the app booted with an ephemeral key because the OS keyring
/// was empty despite a prior successful migration (key was externally
/// deleted). The frontend routes to the nsec re-import step when true.
/// Mutually exclusive with `locked`.
pub lost: bool,
/// True when the app booted with an ephemeral key because the OS keyring
/// holding the identity is unreachable this boot (keyring locked or
/// unavailable). The real key still exists in the keyring; the frontend
/// shows a "unlock the keyring and relaunch" screen. Mutually exclusive
/// with `lost`.
pub locked: bool,
}
#[derive(Serialize, Deserialize)]
+4 -4
View File
@@ -486,14 +486,14 @@ pub async fn submit_event(
// so the MutexGuard is dropped and the future remains Send.
let url = format!("{}/events", relay_api_base_url_with_override(state));
let (auth_header, body_bytes) = {
let keys = state.keys.lock().map_err(|e| e.to_string())?;
let keys = state.signing_keys()?;
let event = builder
.sign_with_keys(&keys)
.map_err(|e| format!("failed to sign event: {e}"))?;
let body = event.as_json().into_bytes();
let auth = build_nip98_auth_header_for_keys(&keys, &Method::POST, &url, &body)?;
(auth, body)
}; // keys lock dropped here
}; // keys dropped here
let response = state
.http_client
@@ -532,9 +532,9 @@ pub async fn submit_signed_event(
let url = format!("{}/events", relay_api_base_url_with_override(state));
let body_bytes = event.as_json().into_bytes();
let auth_header = {
let keys = state.keys.lock().map_err(|e| e.to_string())?;
let keys = state.signing_keys()?;
build_nip98_auth_header_for_keys(&keys, &Method::POST, &url, &body_bytes)?
}; // keys lock dropped here
}; // keys dropped here
let response = state
.http_client
+30
View File
@@ -718,6 +718,36 @@ impl SecretStore {
}
}
/// Verify that `key` holds `expected` by reading directly from the OS
/// backend, bypassing the in-process cache. This is the key innovation for
/// read-back verification: it proves the OS keyring round-trip, not just
/// that the in-process cache was updated.
///
/// Returns `Ok(true)` when the stored value matches `expected`, `Ok(false)`
/// when the entry is absent or holds a different value, and `Err` when the
/// backend is unavailable.
pub fn verify_stored_raw(&self, key: &str, expected: &str) -> Result<bool, String> {
#[cfg(feature = "system-keyring")]
{
let raw = self.read_blob_raw()?;
match raw {
None => Ok(false),
Some(bytes) => {
let json = String::from_utf8(bytes).map_err(|e| format!("blob utf8: {e}"))?;
let map =
serde_json::from_str::<std::collections::HashMap<String, String>>(&json)
.map_err(|e| format!("blob json: {e}"))?;
Ok(map.get(key).is_some_and(|v| v == expected))
}
}
}
#[cfg(not(feature = "system-keyring"))]
{
let _ = (key, expected);
Err("system-keyring feature disabled".to_string())
}
}
/// Store `value` for `key`. Reports `Err` on availability failures — callers
/// decide whether to fall back to file storage.
pub fn store(&self, key: &str, value: &str) -> Result<(), String> {
+11
View File
@@ -17,6 +17,8 @@ import { useReloadShortcut } from "@/app/useReloadShortcut";
import { useAppOnboardingState } from "@/features/onboarding/hooks";
import { OnboardingSlideTransition } from "@/features/onboarding/ui/OnboardingSlideTransition";
import { OnboardingFlow } from "@/features/onboarding/ui/OnboardingFlow";
import { KeyringLockedScreen } from "@/features/onboarding/ui/KeyringLockedScreen";
import { RelaunchRequiredScreen } from "@/features/onboarding/ui/RelaunchRequiredScreen";
import type { Workspace } from "@/features/workspaces/types";
import { useWorkspaceInit } from "@/features/workspaces/useWorkspaceInit";
import { useNestNotifications } from "@/features/workspaces/useNestNotifications";
@@ -285,11 +287,20 @@ function AppReady({
onFirstRunWorkspaceSettled,
]);
if (onboarding.stage === "keyring-locked") {
return <KeyringLockedScreen />;
}
if (onboarding.stage === "relaunch-required") {
return <RelaunchRequiredScreen />;
}
if (onboarding.stage === "onboarding") {
return (
<OnboardingFlow
actions={onboarding.flow.actions}
canBackToWorkspaceSetup={canBackToWorkspaceSetup}
identityLost={onboarding.identityLost}
initialProfile={onboarding.flow.initialProfile}
key={onboarding.currentPubkey ?? "anonymous"}
onBackToWorkspaceSetup={onBackToWorkspaceSetup}
@@ -101,8 +101,7 @@ export function useAutoRestartPolicy() {
const fresh = await listManagedAgents();
const current = fresh.find((a) => a.pubkey === agent.pubkey);
if (
!current ||
!current.needsRestart ||
!current?.needsRestart ||
!current.autoRestartOnConfigChange ||
current.status !== "running" ||
getAgentWorkingState(agent.pubkey).source !== "none"
@@ -3,7 +3,8 @@ import * as React from "react";
import { subscribeToAgentObserverFrames } from "@/shared/api/observerRelay";
import type { RelayEvent, ManagedAgent } from "@/shared/api/types";
import type { ControlResultFrame } from "@/shared/api/types";
import { getIdentity, putAgentSessionConfig } from "@/shared/api/tauri";
import { putAgentSessionConfig } from "@/shared/api/tauri";
import { getIdentity } from "@/shared/api/tauriIdentity";
import { decryptObserverEvent } from "@/shared/api/tauriObserver";
import { normalizePubkey } from "@/shared/lib/pubkey";
import { useQueryClient } from "@tanstack/react-query";
+55 -2
View File
@@ -122,6 +122,7 @@ type OnboardingGateStage = "blocking" | "onboarding" | "ready";
type UseFirstRunOnboardingGateOptions = {
currentPubkey: string | null;
identityIsFetching: boolean;
identityLost: boolean;
identityStatus: QueryStatus;
isSharedIdentity: boolean;
profileHasEvent: boolean | undefined;
@@ -217,6 +218,7 @@ function resolveOnboardingGateStage({
export function useFirstRunOnboardingGate({
currentPubkey,
identityIsFetching,
identityLost,
identityStatus,
isSharedIdentity,
profileHasEvent,
@@ -238,6 +240,23 @@ export function useFirstRunOnboardingGate({
);
}, [currentPubkey]);
// When the backend signals "identity lost" (keyring was cleared after a
// successful migration), force onboarding open immediately so the user can
// re-import their nsec. This runs once, after identity settles.
React.useEffect(() => {
if (!identityLost || !currentPubkey || identityStatus !== "success") {
return;
}
setGateState((current) =>
updateActiveGateState(current, currentPubkey, (activeGateState) => ({
...activeGateState,
hasCompletedCurrentPubkey: false,
hasSettledCurrentPubkey: true,
isOpen: true,
})),
);
}, [currentPubkey, identityLost, identityStatus]);
React.useEffect(() => {
// Fast-path: shared identity worktrees have already onboarded in the
// main checkout. Skip unconditionally without waiting for the relay
@@ -389,10 +408,27 @@ export function useAppOnboardingState(isSharedIdentity: boolean) {
);
const [isCompletingWelcomeSetup, setIsCompletingWelcomeSetup] =
React.useState(false);
const profileQuery = useProfileQuery();
const identityLost = identity?.lost === true;
// Keyring unreachable at boot — the real key is still in the OS keyring but
// 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;
// Sticky boot fact: once identity was lost at boot, this remains true for the
// entire session. Per-component state in OnboardingFlow cannot carry this
// because the flow remounts when pubkey changes after recovery.
const [bootedLost, setBootedLost] = React.useState(false);
React.useEffect(() => {
if (identityLost) setBootedLost(true);
}, [identityLost]);
const profileQuery = useProfileQuery(
!identityLost && !identityLocked && identityQuery.status === "success",
);
const onboardingGate = useFirstRunOnboardingGate({
currentPubkey,
identityIsFetching: identityQuery.fetchStatus === "fetching",
identityLost,
identityStatus: identityQuery.status,
isSharedIdentity,
profileHasEvent: profileQuery.data?.hasProfileEvent,
@@ -469,9 +505,26 @@ export function useAppOnboardingState(isSharedIdentity: boolean) {
},
};
// Recovery completed this boot: force a relaunch screen regardless of any
// other gate state. Backend startup routines (event sync, agent restore,
// pending-event flush) were skipped for the ephemeral key and cannot restart
// in-process, so nothing else can proceed until the app restarts.
const relaunchRequired =
bootedLost && !identityLost && identityQuery.status === "success";
return {
currentPubkey,
flow,
stage: isCompletingWelcomeSetup ? "blocking" : onboardingGate.stage,
identityLost,
// keyring-locked is the highest-precedence stage: nothing in-session can
// clear a locked keyring, so this fully blocks the UI until relaunch.
stage:
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 KeyringLockedScreen() {
return (
<RecoveryScreen
testId="keyring-locked"
title="Unlock your system keyring"
body="Your identity is safe in the OS keyring, but it's unreachable this session. Unlock your keyring or sign into your desktop session, then relaunch Buzz."
/>
);
}
@@ -10,6 +10,7 @@ import { Spinner } from "@/shared/ui/spinner";
const NOSTR_KEY_FILE_MAX_BYTES = 1024;
type NostrKeyImportFormProps = {
backLabel?: string;
disabled?: boolean;
errorMessage?: string | null;
onBack: () => void;
@@ -24,6 +25,7 @@ type NostrKeyImportFormProps = {
* existing key). The caller owns what happens after `onImport` resolves.
*/
export function NostrKeyImportForm({
backLabel = "Back",
disabled = false,
errorMessage: externalErrorMessage = null,
onBack,
@@ -265,7 +267,7 @@ export function NostrKeyImportForm({
type="button"
variant="ghost"
>
Back
{backLabel}
</Button>
</div>
</form>
@@ -7,7 +7,11 @@ import {
} from "@/features/profile/hooks";
import { relayClient } from "@/shared/api/relayClient";
import { getMyRelayMembershipLookup } from "@/shared/api/relayMembers";
import { getIdentity, importIdentity } from "@/shared/api/tauri";
import {
getIdentity,
importIdentity,
persistCurrentIdentity,
} from "@/shared/api/tauriIdentity";
import {
ACCENT_STORAGE_KEY,
NEUTRAL_ACCENT,
@@ -73,6 +77,7 @@ async function checkMembershipDenied(): Promise<boolean> {
type OnboardingFlowProps = {
actions: OnboardingActions;
canBackToWorkspaceSetup: boolean;
identityLost?: boolean;
initialProfile: OnboardingProfileSeed;
onBackToWorkspaceSetup: () => void;
};
@@ -142,6 +147,7 @@ function resolveProfileSaveRecovery(
export function OnboardingFlow({
actions,
canBackToWorkspaceSetup,
identityLost = false,
initialProfile,
onBackToWorkspaceSetup,
}: OnboardingFlowProps) {
@@ -151,11 +157,15 @@ export function OnboardingFlow({
const profileUpdateMutation = useUpdateProfileMutation();
const { error: profileSaveError, isPending: isSavingProfile } =
profileUpdateMutation;
const [currentPage, setCurrentPage] =
React.useState<OnboardingPage>("profile");
// When identity was lost (keyring cleared after migration), land the user
// directly on the import step with a recovery notice rather than profile setup.
const [currentPage, setCurrentPage] = React.useState<OnboardingPage>(
identityLost ? "key-import" : "profile",
);
const [profileDraft, setProfileDraft] =
React.useState<OnboardingProfileValues>(savedProfile);
const [deniedPubkey, setDeniedPubkey] = React.useState<string>("");
const [persistError, setPersistError] = React.useState<string | null>(null);
const [isUploadingAvatar, setIsUploadingAvatar] = React.useState(false);
const [isProfileAdvancePending, setIsProfileAdvancePending] =
React.useState(false);
@@ -403,6 +413,29 @@ export function OnboardingFlow({
[profileUpdateMutation, queryClient],
);
// Lost-mode "start new identity": confirm first (irreversible), then persist
// the ephemeral key so the new identity is durable, then let the stage
// machinery (bootedLost + !identityLost) replace this flow with
// RelaunchRequiredScreen. No navigation needed here.
const handleLostModeBack = React.useCallback(async () => {
const confirmed = window.confirm(
"This will create a new identity and abandon your previous key. This cannot be undone. Continue?",
);
if (!confirmed) {
return;
}
try {
const identity = await persistCurrentIdentity();
queryClient.setQueryData(["identity"], identity);
} catch (error) {
setPersistError(
error instanceof Error
? error.message
: "Failed to create a new identity. Please try again.",
);
}
}, [queryClient]);
if (currentPage === "membership-denied") {
return (
<MembershipDenied
@@ -495,18 +528,41 @@ export function OnboardingFlow({
transitionKey={`key-import-${transitionDirection}`}
>
<div className="w-full max-w-[440px]">
<h1 className="text-3xl font-semibold tracking-tight">
Use your existing key
</h1>
<p className="mt-3 text-sm leading-6 text-muted-foreground">
Import your Nostr private key to use that identity with Buzz. If
this key already has a profile on the relay, your name and
avatar are restored automatically.
</p>
{identityLost ? (
<>
<h1 className="text-3xl font-semibold tracking-tight">
Re-import your key
</h1>
<p className="mt-3 text-sm leading-6 text-muted-foreground">
Your identity is no longer in the system keyring. Re-import
your nsec to restore it — Buzz will restart to finish
recovery. Or go back to start a new identity with a fresh
key.
</p>
</>
) : (
<>
<h1 className="text-3xl font-semibold tracking-tight">
Use your existing key
</h1>
<p className="mt-3 text-sm leading-6 text-muted-foreground">
Import your Nostr private key to use that identity with
Buzz. If this key already has a profile on the relay, your
name and avatar are restored automatically.
</p>
</>
)}
</div>
{persistError ? (
<p className="mt-4 w-full max-w-[440px] text-sm text-destructive">
{persistError}
</p>
) : null}
<NostrKeyImportForm
onBack={showProfilePage}
backLabel={identityLost ? "Start new identity" : undefined}
onBack={identityLost ? handleLostModeBack : showProfilePage}
onImport={importExistingKey}
/>
</OnboardingSlideTransition>
@@ -0,0 +1,41 @@
import { relaunch } from "@tauri-apps/plugin-process";
import { useSystemColorScheme } from "@/shared/theme/useSystemColorScheme";
import { Button } from "@/shared/ui/button";
import { StartupWindowDragRegion } from "@/shared/ui/StartupWindowDragRegion";
export function RecoveryScreen({
testId,
title,
body,
}: {
testId: string;
title: string;
body: string;
}) {
const systemColorScheme = useSystemColorScheme();
return (
<div
className="buzz-onboarding-neutral-theme buzz-startup-shell flex items-center justify-center bg-background px-4 py-8 text-foreground"
data-system-color-scheme={systemColorScheme}
data-testid={testId}
>
<StartupWindowDragRegion />
<div className="relative flex w-full max-w-[500px] flex-col items-center text-center">
<h1 className="text-3xl font-semibold tracking-tight">{title}</h1>
<p className="mt-3 text-sm leading-6 text-muted-foreground">{body}</p>
<Button
className="mt-8 h-10 w-full max-w-[300px]"
data-testid="relaunch-app"
onClick={() => {
void relaunch();
}}
type="button"
>
Relaunch Buzz
</Button>
</div>
</div>
);
}
@@ -0,0 +1,11 @@
import { RecoveryScreen } from "./RecoveryScreen";
export function RelaunchRequiredScreen() {
return (
<RecoveryScreen
testId="relaunch-required"
title="Restart Buzz to finish recovery"
body="Your identity was updated. Buzz needs to restart so syncing and agents run under it."
/>
);
}
@@ -1,7 +1,7 @@
import * as React from "react";
import { toast } from "sonner";
import { getIdentity } from "@/shared/api/tauri";
import { getIdentity } from "@/shared/api/tauriIdentity";
import type { Identity } from "@/shared/api/types";
import type { NostrBindDeepLinkPayload } from "@/shared/deep-link";
import { listenForNostrBindDeepLinks } from "@/shared/deep-link";
+2 -1
View File
@@ -2,7 +2,8 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import * as React from "react";
import { relayClient } from "@/shared/api/relayClient";
import { getIdentity, signRelayEvent } from "@/shared/api/tauri";
import { signRelayEvent } from "@/shared/api/tauri";
import { getIdentity } from "@/shared/api/tauriIdentity";
import {
getProjectLocalRepoDiff,
getProjectRepoDiff,
@@ -4,7 +4,7 @@ import { flushSync } from "react-dom";
import {
getIdentity,
importIdentity as tauriImportIdentity,
} from "@/shared/api/tauri";
} from "@/shared/api/tauriIdentity";
import { NostrKeyImportForm } from "@/features/onboarding/ui/NostrKeyImportForm";
import {
type OnboardingTransitionDirection,
@@ -1,11 +1,8 @@
import { useEffect, useRef, useState } from "react";
import { relayClient } from "@/shared/api/relayClient";
import {
applyWorkspace,
getDefaultRelayUrl,
getIdentity,
} from "@/shared/api/tauri";
import { applyWorkspace, getDefaultRelayUrl } from "@/shared/api/tauri";
import { getIdentity } from "@/shared/api/tauriIdentity";
import { resetMediaCaches } from "@/shared/lib/mediaUrl";
import { clearSearchHitEventCache } from "@/app/navigation/searchHitEventCache";
import {
@@ -1,6 +1,6 @@
import * as React from "react";
import { getIdentity } from "@/shared/api/tauri";
import { getIdentity } from "@/shared/api/tauriIdentity";
import { markWorkspaceRead } from "@/features/workspaces/workspaceMarkRead";
import { pollWorkspaceUnread } from "@/features/workspaces/workspaceUnreadObserver";
+2 -1
View File
@@ -17,7 +17,8 @@
*/
import { relayClient } from "@/shared/api/relayClient";
import { getIdentity, signRelayEvent } from "@/shared/api/tauri";
import { signRelayEvent } from "@/shared/api/tauri";
import { getIdentity } from "@/shared/api/tauriIdentity";
import type { RelayEvent } from "@/shared/api/types";
import type { CustomEmoji } from "@/shared/lib/remarkCustomEmoji";
+1 -1
View File
@@ -1,6 +1,6 @@
import { useQuery } from "@tanstack/react-query";
import { getIdentity } from "@/shared/api/tauri";
import { getIdentity } from "@/shared/api/tauriIdentity";
export function useIdentityQuery() {
return useQuery({
+2 -1
View File
@@ -1,5 +1,6 @@
import { relayClient } from "@/shared/api/relayClient";
import { getIdentity, signRelayEvent } from "@/shared/api/tauri";
import { signRelayEvent } from "@/shared/api/tauri";
import { getIdentity } from "@/shared/api/tauriIdentity";
import type {
RelayEvent,
RelayMember,
-21
View File
@@ -14,7 +14,6 @@ import type {
CreateChannelInput,
GetHomeFeedInput,
HomeFeedResponse,
Identity,
ManagedAgent,
ManagedAgentBackend,
RelayAgent,
@@ -50,8 +49,6 @@ import type {
RuntimeConfigSurface,
} from "@/shared/api/types";
type RawIdentity = { pubkey: string; display_name: string };
type RawProfile = {
pubkey: string;
display_name: string | null;
@@ -458,24 +455,6 @@ function fromRawUserSearchResult(user: RawUserSearchResult): UserSearchResult {
};
}
export async function getIdentity(): Promise<Identity> {
const identity = await invokeTauri<RawIdentity>("get_identity");
return {
pubkey: identity.pubkey,
displayName: identity.display_name,
};
}
export async function getNsec(): Promise<string> {
return invokeTauri<string>("get_nsec");
}
export async function importIdentity(nsec: string): Promise<Identity> {
const raw = await invokeTauri<RawIdentity>("import_identity", { nsec });
return { pubkey: raw.pubkey, displayName: raw.display_name };
}
export async function getProfile(): Promise<Profile> {
const profile = await invokeTauri<RawProfile>("get_profile");
return fromRawProfile(profile);
+38
View File
@@ -0,0 +1,38 @@
import { invokeTauri } from "@/shared/api/tauri";
import type { Identity } from "@/shared/api/types";
type RawIdentity = {
pubkey: string;
display_name: string;
lost?: boolean;
locked?: boolean;
};
function fromRawIdentity(raw: RawIdentity): Identity {
return {
pubkey: raw.pubkey,
displayName: raw.display_name,
lost: raw.lost === true,
locked: raw.locked === true,
};
}
export async function getIdentity(): Promise<Identity> {
return fromRawIdentity(await invokeTauri<RawIdentity>("get_identity"));
}
export async function getNsec(): Promise<string> {
return invokeTauri<string>("get_nsec");
}
export async function importIdentity(nsec: string): Promise<Identity> {
return fromRawIdentity(
await invokeTauri<RawIdentity>("import_identity", { nsec }),
);
}
export async function persistCurrentIdentity(): Promise<Identity> {
return fromRawIdentity(
await invokeTauri<RawIdentity>("persist_current_identity"),
);
}
+11
View File
@@ -106,6 +106,17 @@ export type AddChannelMembersResult = {
export type Identity = {
pubkey: string;
displayName: string;
/** True when the app booted in "identity lost" recovery mode — the OS
* keyring was empty despite a prior successful migration. The frontend
* should route to nsec re-import instead of normal onboarding.
* Mutually exclusive with `locked`. */
lost?: boolean;
/** True when the app booted with an ephemeral key because the OS keyring
* holding the real identity is UNREACHABLE (e.g. GNOME Keyring / KWallet
* locked). The real key still exists; no in-app recovery is possible —
* the user must unlock the keyring externally and relaunch.
* Mutually exclusive with `lost`. */
locked?: boolean;
};
export type Profile = {
+40 -2
View File
@@ -179,6 +179,12 @@ type E2eConfig = {
// Event IDs that `get_event` should report as definitively not found.
// Causes `useDraftRootStatus` to classify as `deleted`.
deletedEventIds?: string[];
// When true, `get_identity` returns `lost: true` until `persist_current_identity`
// or `import_identity` is called. Drives the identity-lost recovery UX in tests.
identityLost?: boolean;
// When true, `get_identity` returns `locked: true` until `import_identity` is
// called. Drives the keyring-locked screen in tests.
identityLocked?: boolean;
};
relayHttpUrl?: string;
relayWsUrl?: string;
@@ -839,6 +845,13 @@ const OWNED_RELAY_AGENT_PUBKEY =
"a1b2c3d4e5f60718293a4b5c6d7e8f90112233445566778899aabbccddeeff00";
const MOCK_IDENTITY_PUBKEY = DEFAULT_MOCK_IDENTITY.pubkey;
// Tracks whether `persist_current_identity` or `import_identity` has cleared
// the lost flag set by `mock.identityLost`. Reset to false on each fresh page
// load (module re-evaluation), so tests start in a clean state.
let mockIdentityLostCleared = false;
// Same pattern for `mock.identityLocked`.
let mockIdentityLockedCleared = false;
const mockDisplayNames = new Map<string, string>([
[MOCK_IDENTITY_PUBKEY, DEFAULT_MOCK_IDENTITY.display_name],
[ALICE_PUBKEY, "alice"],
@@ -8189,18 +8202,43 @@ export function maybeInstallE2eTauriMocks() {
},
};
}
case "get_identity":
case "get_identity": {
const isLost =
!mockIdentityLostCleared && activeConfig?.mock?.identityLost === true;
const isLocked =
!mockIdentityLockedCleared &&
activeConfig?.mock?.identityLocked === true;
if (identity) {
return {
pubkey: identity.pubkey,
display_name: identity.username,
lost: false,
locked: false,
};
}
return DEFAULT_MOCK_IDENTITY;
return { ...DEFAULT_MOCK_IDENTITY, lost: isLost, locked: isLocked };
}
case "get_nsec":
return "nsec1mock000000000000000000000000000000000000000000000000000000";
case "persist_current_identity": {
// Persist the ephemeral key: clears only the lost flag. The locked flag
// is cleared only by import_identity; production rejects
// persist_current_identity when the identity is in the locked state.
mockIdentityLostCleared = true;
const currentPubkey = identity?.pubkey ?? DEFAULT_MOCK_IDENTITY.pubkey;
const currentDisplayName =
identity?.username ?? DEFAULT_MOCK_IDENTITY.display_name;
return {
pubkey: currentPubkey,
display_name: currentDisplayName,
lost: false,
locked: false,
};
}
case "import_identity":
mockIdentityLostCleared = true;
mockIdentityLockedCleared = true;
return importMockIdentity(
(payload as { nsec?: string } | null)?.nsec ?? "",
);
+147
View File
@@ -0,0 +1,147 @@
import { hexToBytes } from "@noble/hashes/utils.js";
import { expect, test } from "@playwright/test";
import { nsecEncode } from "nostr-tools/nip19";
import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge";
test("lost boot opens onboarding gate directly on the key-import page", async ({
page,
}) => {
await installMockBridge(
page,
{ identityLost: true },
{ skipOnboardingSeed: true },
);
await page.goto("/");
await expect(page.getByTestId("onboarding-gate")).toBeVisible();
await expect(
page.getByRole("heading", { name: "Re-import your key" }),
).toBeVisible();
});
test("importing a key from lost mode shows the relaunch-required screen", async ({
page,
}) => {
await installMockBridge(
page,
{ identityLost: true },
{ skipOnboardingSeed: true },
);
await page.goto("/");
await expect(
page.getByRole("heading", { name: "Re-import your key" }),
).toBeVisible();
const importedNsec = nsecEncode(hexToBytes(TEST_IDENTITIES.alice.privateKey));
await page.getByTestId("nostr-import-nsec-input").fill(importedNsec);
await expect(page.getByTestId("nostr-import-npub-preview")).toBeVisible();
await page.getByTestId("nostr-import-submit").click();
await expect(page.getByTestId("relaunch-required")).toBeVisible();
});
test("start-new-identity from lost mode persists the ephemeral key after confirmation", async ({
page,
}) => {
await installMockBridge(
page,
{ identityLost: true },
{ skipOnboardingSeed: true },
);
await page.goto("/");
await expect(
page.getByRole("heading", { name: "Re-import your key" }),
).toBeVisible();
page.on("dialog", (dialog) => dialog.accept());
await page.getByRole("button", { name: "Start new identity" }).click();
await expect(page.getByTestId("relaunch-required")).toBeVisible();
await expect
.poll(() =>
page.evaluate(
() =>
(
window as Window & {
__BUZZ_E2E_COMMAND_PAYLOADS__?: Array<{ command: string }>;
}
).__BUZZ_E2E_COMMAND_PAYLOADS__?.some(
(e) => e.command === "persist_current_identity",
) ?? false,
),
)
.toBe(true);
});
test("cancelling start-new-identity in lost mode stays on the import screen", async ({
page,
}) => {
await installMockBridge(
page,
{ identityLost: true },
{ skipOnboardingSeed: true },
);
await page.goto("/");
await expect(
page.getByRole("heading", { name: "Re-import your key" }),
).toBeVisible();
page.on("dialog", (dialog) => dialog.dismiss());
await page.getByRole("button", { name: "Start new identity" }).click();
// Still on the import screen — no navigation, no persist
await expect(
page.getByRole("heading", { name: "Re-import your key" }),
).toBeVisible();
await expect(page.getByTestId("relaunch-required")).toHaveCount(0);
});
test("locked boot shows the keyring-locked screen without the onboarding gate or key-import UI", async ({
page,
}) => {
await installMockBridge(
page,
{ identityLocked: true },
{ skipOnboardingSeed: true },
);
await page.goto("/");
await expect(page.getByTestId("keyring-locked")).toBeVisible();
await expect(page.getByTestId("onboarding-gate")).toHaveCount(0);
await expect(
page.getByRole("heading", { name: "Re-import your key" }),
).toHaveCount(0);
});
test("locked screen relaunch button records the process-restart invoke", async ({
page,
}) => {
await installMockBridge(
page,
{ identityLocked: true },
{ skipOnboardingSeed: true },
);
await page.goto("/");
await expect(page.getByTestId("keyring-locked")).toBeVisible();
await page.getByTestId("relaunch-app").click();
await expect
.poll(() =>
page.evaluate(
() =>
(
window as Window & {
__BUZZ_E2E_COMMAND_PAYLOADS__?: Array<{ command: string }>;
}
).__BUZZ_E2E_COMMAND_PAYLOADS__?.some(
(e) => e.command === "plugin:process|restart",
) ?? false,
),
)
.toBe(true);
});
+10
View File
@@ -207,6 +207,16 @@ type MockBridgeOptions = {
* can exercise the "Thread deleted" label / disabled-send path.
*/
deletedEventIds?: string[];
/**
* When true, `get_identity` returns `lost: true` until `persist_current_identity`
* or `import_identity` is invoked. Drives the identity-lost recovery UX in tests.
*/
identityLost?: boolean;
/**
* When true, `get_identity` returns `locked: true` until `import_identity` is
* invoked. Drives the keyring-locked screen in tests.
*/
identityLocked?: boolean;
};
type BridgeOptions = {