mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
co-authored by
npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7
parent
94ec2c2bc6
commit
e15f068571
@@ -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"],
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
+459
-526
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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}"))?;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)?
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,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";
|
||||
|
||||
|
||||
@@ -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,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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"),
|
||||
);
|
||||
}
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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 ?? "",
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user