Files
buzz/desktop/src-tauri/src/lib.rs
6eb65919f1 feat(identity): recover desktop identity from a signed-in phone (#4845)
**Category:** new-feature
**User Impact:** People who lose a desktop identity can securely restore
it from a signed-in Buzz phone without creating a replacement identity.

**Problem:** A fresh or identity-lost desktop could not recover its
existing full Buzz identity from an already-authorized phone.

**Solution:** Add a SAS-confirmed reverse NIP-AB transfer, durable
desktop import, a dedicated mobile recovery entry point, and clearer
desktop recovery dialogs with tested loading, drag-and-drop, and failure
states.


https://github.com/user-attachments/assets/e9215c9c-80d0-462f-9161-0fa184ca2f74

<details>
<summary>File changes</summary>

**crates/buzz-core/src/pairing/session.rs**
Adds the reverse encrypted payload and source-completion state
transitions used for phone-to-desktop recovery.

**desktop/src-tauri/src/commands/identity.rs**
Exposes the existing guarded identity commit path for recovery imports.

**desktop/src-tauri/src/commands/pairing.rs**
Adds recovery-mode pairing, durable nsec import, start serialization,
stale-task protection, and explicit rejection of unsupported recovery
payloads.

**desktop/src-tauri/src/lib.rs**
Registers the recovery pairing command.

**desktop/src/app/App.tsx**
Refreshes the recovered identity before continuing onboarding.

**desktop/src/features/onboarding/machineOnboarding.ts**
Adds recovery transitions to the onboarding state machine.

**desktop/src/features/onboarding/ui/BackupPasswordTimeline.tsx**
Adds the visual backup-to-password-to-unlock progression.

**desktop/src/features/onboarding/ui/IdentityRecoveryPairing.tsx**
Implements QR generation, copy fallback, SAS confirmation, cancellation,
expiry, and completion UI.

**desktop/src/features/onboarding/ui/MachineOnboardingFlow.tsx**
Connects private-key, phone, and backup recovery paths to the onboarding
flow.

**desktop/src/features/onboarding/ui/NostrKeyImportForm.tsx**
Polishes recovery dialogs, backup drag-and-drop, loading stability, and
security copy.

**desktop/src/shared/api/tauri.ts**
Keeps the existing pairing API surface focused on standard
desktop-to-mobile pairing.

**desktop/src/shared/api/tauriPairing.ts**
Adds the recovery pairing invoke without growing the ratcheted shared
API file.

**desktop/src/testing/e2eBridge.ts**
Mocks recovery pairing commands and lifecycle events for browser tests.

**desktop/tests/e2e/identity-lost.spec.ts**
Covers lost-identity entry, QR/copy recovery, SAS, cancellation, expiry,
success, errors, backup import, drag-and-drop, and screenshots.

**desktop/tests/e2e/onboarding.spec.ts**
Verifies recovered identities continue through harness setup without
replacement-key side effects.

**mobile/lib/features/pairing/pairing_page.dart**
Adds recovery-only scanning and explicit identity-handoff warnings.

**mobile/lib/features/pairing/pairing_provider.dart**
Recognizes recovery codes, returns the signed-in nsec after mutual SAS
approval, and waits for desktop completion.

**mobile/lib/features/settings/settings_page.dart**
Accepts the recovery route builder at the app composition boundary to
preserve feature isolation.

**mobile/lib/features/settings/settings_page/connection_section.dart**
Adds the signed-in “Send identity to desktop” settings action.

**mobile/test/features/pairing/pairing_page_test.dart**
Covers recovery-only validation and handoff messaging.

**mobile/test/features/pairing/pairing_provider_test.dart**
Covers reverse payload encryption, confirmation ordering, success,
failure, timeout, and cleanup.

</details>

## Reproduction steps

1. Launch Buzz Desktop with identity-lost state and choose **Recover
from your phone**.
2. Confirm the QR and persistent **Copy pairing code** fallback appear
without layout shift.
3. On a signed-in phone, open **Settings → Send identity to desktop**,
scan or paste the recovery code, and compare the six-digit SAS on both
devices.
4. Confirm on both sides and verify Desktop restores the identity and
continues to harness setup.
5. Repeat from identity-lost state with **Recover from a backup file**;
verify picker and drag-and-drop both advance to password entry and
restore the encrypted backup.
6. Exercise cancellation, mismatched/unsupported codes, expired
sessions, and an invalid backup; verify each returns actionable,
non-stuck UI.

## Screenshots

### Desktop phone recovery — complete flow

| Recovery entry | Pairing QR | Code match | Receiving identity |
|---|---|---|---|
| ![Desktop recovery
entry](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/4845/desktop-phone-01-recovery-entry.png)
| ![Desktop phone recovery
QR](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/4845/desktop-phone-02-qr.png)
| ![Desktop security-code
match](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/4845/desktop-phone-03-sas.png)
| ![Desktop receiving
identity](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/4845/desktop-phone-04-receiving.png)
|

### iOS Simulator — complete handoff flow

| Settings entry | Recovery scanner | Manual recovery code | Code
confirmation |
|---|---|---|---|
| ![iOS Settings entry for Send identity to
desktop](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/4845/ios-01-settings-entry.png)
| ![iOS recovery scanner
entry](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/4845/ios-02-recovery-entry.png)
| ![iOS manual recovery code
entry](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/4845/ios-03-manual-code.png)
| ![iOS security-code
confirmation](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/4845/ios-04-sas-verification.png)
|

### Encrypted backup recovery — adjusted file flow

| File picker | Drag-and-drop target | Password step |
|---|---|---|
| ![Desktop encrypted-backup file
picker](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/4845/desktop-backup-01-file-picker-settled.png)
| ![Desktop encrypted-backup drag-and-drop
target](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/4845/desktop-backup-02-drag-drop.png)
| ![Desktop backup password
step](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/4845/desktop-backup-03-enter-password.png)
|

## Verification

- `cargo test -p buzz-core pairing` — 71 passed
- `just mobile-test` — 1,169 passed
- `pnpm build:e2e && pnpm exec playwright test identity-lost.spec.ts
--project=smoke` — 15 passed
- Full pre-push gates — desktop checks, desktop unit tests, Rust tests,
Tauri checks, and mobile tests passed

---------

Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
Co-authored-by: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@buzz.block.builderlab.xyz>
Co-authored-by: Carl <acda9e433d19dcd0e6b6840f7f4b98f3a56f1fab98049d444c087019e6d36560@buzz.block.builderlab.xyz>
2026-08-06 11:47:18 -07:00

993 lines
42 KiB
Rust

#![recursion_limit = "256"] // Deep Tauri command futures exceed the default layout query depth.
mod app_menu;
mod app_state;
mod archive;
mod builderlab;
mod commands;
mod deep_link;
mod egress_guard;
mod event_sync;
mod events;
mod huddle;
mod identity_storage;
mod initial_window;
mod key_backup;
mod linux_media;
#[cfg(target_os = "macos")]
mod macos_notifications;
mod managed_agents;
mod media_proxy;
#[cfg(feature = "mesh-llm")]
mod mesh_llm;
#[cfg(not(feature = "mesh-llm"))]
mod mesh_llm_stubs;
mod migration;
#[cfg(test)]
mod model_tests;
mod models;
mod native_websocket;
mod nostr_bind;
pub mod nostr_convert;
mod prevent_sleep;
mod ptt_shortcut;
mod relay;
mod relay_admission;
mod reset;
mod secret_store;
mod shutdown;
mod templates;
mod terminal_runtime;
#[cfg_attr(not(test), allow(dead_code))]
mod terminal_transport;
#[cfg(target_os = "macos")]
mod tray_menu;
mod util;
#[cfg(target_os = "linux")]
pub mod webkit_rendering;
use app_state::{build_app_state, resolve_persisted_identity, AppState};
use builderlab::*;
use commands::*;
use deep_link::{
acknowledge_pending_community_deep_link, handle_deep_link_url,
take_pending_community_deep_link, PendingCommunityDeepLinks,
};
use huddle::audio_output::{
get_audio_output_device, list_audio_output_devices, set_audio_output_device,
};
use huddle::reconnect::reconnect_huddle_audio;
use huddle::{
add_agent_to_huddle, check_pipeline_hotstart, close_huddle_companion, confirm_huddle_active,
download_voice_models, end_huddle, get_huddle_agent_pubkeys, get_huddle_state,
get_model_status, get_voice_input_mode, interrupt_huddle_speech, join_huddle, leave_huddle,
open_huddle_window, push_audio_pcm, remove_agent_from_huddle, set_huddle_manual_mic_unmuted,
set_huddle_transcription_enabled, set_tts_enabled, set_voice_input_mode, speak_agent_message,
start_huddle, start_stt_pipeline, HuddlePhase,
};
use initial_window::*;
use managed_agents::{
backfill_persona_snapshots, ensure_nest, list_managed_agent_runtimes,
put_managed_agent_runtime_lifecycle, reconcile_managed_agent_runtimes,
restart_managed_agent_runtime, start_managed_agent_runtime, stop_managed_agent_runtime,
try_regenerate_nest,
};
#[cfg(not(feature = "mesh-llm"))]
use mesh_llm_stubs::*;
#[cfg(all(feature = "mesh-llm", target_os = "macos"))]
use shutdown::{hard_exit_after_mesh_shutdown, relaunch_after_mesh_shutdown};
use shutdown::{is_restart_request, shut_down_app};
use std::sync::{atomic::AtomicBool, atomic::Ordering, Arc};
#[cfg(target_os = "macos")]
use tauri::Listener;
use tauri::{Emitter, Manager, RunEvent, WindowEvent};
use tauri_plugin_window_state::StateFlags;
#[cfg(target_os = "macos")]
use tray_menu::show_main_window;
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
// mesh-llm's async chains (model download, node start/join) overflow
// tokio's default 2 MiB worker stacks — a stack-guard SIGABRT, not a
// panic. Upstream mesh-llm and mesh-console both run on 8 MiB worker
// stacks for this reason; give Tauri's command runtime the same headroom
// before anything else touches tauri::async_runtime.
#[cfg(feature = "mesh-llm")]
match tokio::runtime::Builder::new_multi_thread()
.enable_all()
.thread_stack_size(crate::mesh_llm::MESH_WORKER_STACK_SIZE)
.build()
{
Ok(runtime) => {
tauri::async_runtime::set(runtime.handle().clone());
// Keep the runtime alive for the process lifetime; dropping it
// would shut down the workers Tauri now depends on.
std::mem::forget(runtime);
eprintln!(
"buzz-mesh: installed tokio runtime with {} MiB worker stacks",
crate::mesh_llm::MESH_WORKER_STACK_SIZE / (1024 * 1024)
);
}
Err(error) => {
// Fall back to Tauri's default runtime: the app still works,
// only deep mesh-llm futures are at risk of stack overflow.
eprintln!("buzz-mesh: failed to build big-stack tokio runtime, using default: {error}");
}
}
let builder = tauri::Builder::default()
.plugin(tauri_plugin_single_instance::init(|app, argv, _cwd| {
// Focus the existing window when a duplicate instance launches.
if let Some(w) = app.get_webview_window("main") {
let _ = w.set_focus();
}
// Forward any deep link URLs from the duplicate launch.
for arg in &argv {
if arg.starts_with("buzz://") {
handle_deep_link_url(app, arg);
}
}
}))
.plugin(tauri_plugin_deep_link::init())
.plugin(tauri_plugin_notification::init())
.plugin(tauri_plugin_opener::init())
.plugin(
tauri_plugin_window_state::Builder::default()
// Visibility is excluded: the native reveal plugin below
// shows the window after saved geometry has been restored.
.with_state_flags(StateFlags::all() & !StateFlags::VISIBLE)
.build(),
)
.plugin(
tauri::plugin::Builder::<_, ()>::new("initial-window-reveal")
.on_webview_ready(|webview| {
if webview.label() != "main" {
return;
}
// Linux/WebKitGTK needs media-stream settings and a
// permission-request handler for getUserMedia; no-op
// on macOS/Windows.
linux_media::enable_media_capture(&webview);
// macOS applies the restored geometry asynchronously. Wait
// for several identical outer bounds and for React to
// commit the startup surface before revealing it.
let window = webview.window();
#[cfg(target_os = "macos")]
{
set_initial_window_backing(&window);
let (initial_render_tx, initial_render_rx) = tokio::sync::oneshot::channel();
window
.app_handle()
.once(INITIAL_RENDER_READY_EVENT, move |_| {
let _ = initial_render_tx.send(());
});
tauri::async_runtime::spawn(async move {
wait_for_stable_initial_window_geometry(&window).await;
if tokio::time::timeout(
std::time::Duration::from_secs(5),
initial_render_rx,
)
.await
.is_err()
{
eprintln!(
"buzz-desktop: initial render did not commit before reveal timeout"
);
}
reveal_initial_window(&window);
clear_initial_window_backing(&window).await;
});
}
#[cfg(not(target_os = "macos"))]
{
reveal_initial_window(&window);
}
})
.build(),
)
.plugin(native_websocket::init())
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_process::init());
// The global-shortcut plugin is omitted from test builds: linking it into
// the lib-test binary makes it fail to load on Windows
// (STATUS_ENTRYPOINT_NOT_FOUND) before any test runs.
#[cfg(not(test))]
let builder = builder.plugin({
use tauri_plugin_global_shortcut::ShortcutState;
// Generation counter for the release delay task. Incremented on
// every press — a delayed release only fires if the generation
// hasn't changed (i.e. no new press happened during the delay).
// This prevents press→release→press within 200 ms from having
// the first release clobber the second press.
let ptt_press_gen = Arc::new(std::sync::atomic::AtomicU64::new(0));
tauri_plugin_global_shortcut::Builder::new()
.with_handler(move |app, _shortcut, event| {
let state = match app.try_state::<AppState>() {
Some(s) => s,
None => return,
};
// Only act if a huddle is active and mode is PTT.
let (is_ptt_mode, is_active) = match state.huddle_state.lock() {
Ok(hs) => (
hs.voice_input_mode == huddle::VoiceInputMode::PushToTalk,
matches!(
hs.phase,
huddle::HuddlePhase::Connected | huddle::HuddlePhase::Active
),
),
Err(_) => return,
};
if !is_ptt_mode || !is_active {
return;
}
match event.state {
ShortcutState::Pressed => {
// Bump generation — invalidates any pending release delay.
ptt_press_gen.fetch_add(1, std::sync::atomic::Ordering::Release);
if let Ok(hs) = state.huddle_state.lock() {
hs.ptt_active
.store(true, std::sync::atomic::Ordering::Release);
// Only cancel TTS if it's actually playing — avoids
// a stale cancel flag that drops the next queued message.
if hs.tts_active.load(std::sync::atomic::Ordering::Acquire) {
hs.tts_cancel
.store(true, std::sync::atomic::Ordering::Release);
}
}
// Emit ptt-state=true to the frontend.
// The React side plays the press audio cue on this event
// (Web Audio API via HuddleContext). Rust-side rodio audio
// was considered but rejected: the rodio OutputStream must
// outlive the handler and sharing it across the shortcut
// closure adds lifecycle complexity for marginal gain.
// The React implementation is sufficient and simpler.
let _ = app.emit("ptt-state", true);
}
ShortcutState::Released => {
// Capture generation at release time.
let gen_at_release =
ptt_press_gen.load(std::sync::atomic::Ordering::Acquire);
let gen_arc = Arc::clone(&ptt_press_gen);
let app_handle = app.clone();
// 200 ms release delay — captures the tail of the utterance.
// Only applies if no new press happened during the delay.
tauri::async_runtime::spawn(async move {
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
// Check generation — if it changed, a new press arrived.
if gen_arc.load(std::sync::atomic::Ordering::Acquire) != gen_at_release
{
return; // Superseded by a new press.
}
if let Some(state) = app_handle.try_state::<AppState>() {
if let Ok(hs) = state.huddle_state.lock() {
hs.ptt_active
.store(false, std::sync::atomic::Ordering::Release);
}
}
// Emit ptt-state=false — React plays the release audio cue.
let _ = app_handle.emit("ptt-state", false);
});
}
}
})
.build()
});
// Register the updater only in configured release builds; omit it locally.
#[cfg(buzz_updater_enabled)]
let builder = if cfg!(debug_assertions) {
builder
} else {
builder.plugin(tauri_plugin_updater::Builder::new().build())
};
let app = app_menu::install(builder)
.register_asynchronous_uri_scheme_protocol("buzz-media", |ctx, request, responder| {
let app = ctx.app_handle().clone();
tauri::async_runtime::spawn(async move {
let response = media_proxy::handle_buzz_media(&app, &request).await;
responder.respond(response);
});
})
.manage(build_app_state())
.manage(ClipboardState::new())
.manage(PendingCommunityDeepLinks::default())
.manage(BuilderlabSession::default())
.manage(BuilderlabLogin::default())
.manage(commands::pairing::PairingHandle::new())
.manage(terminal_runtime::TerminalSessions::default())
.setup(move |app| {
let app_handle = app.handle().clone();
#[cfg(target_os = "macos")]
{
tray_menu::init(&app_handle)?;
macos_notifications::init(&app_handle)?;
}
// ── Phase 2: boot-time sentinel wipe ──────────────────────────────
// Must run before migrations and identity resolution so the wipe
// completes atomically on crash recovery.
//
// init_nest_dir is called early here (normally it runs inside
// run_boot_migrations) so reset::run_boot_reset can call nest_dir().
let reset_outcome = if let Ok(data_dir) = app_handle.path().app_data_dir() {
let is_dev_for_reset = data_dir
.file_name()
.and_then(|n| n.to_str())
.map(crate::migration::is_dev_data_dir_name)
.unwrap_or(false);
crate::managed_agents::init_nest_dir(is_dev_for_reset);
crate::reset::run_boot_reset(&data_dir)
} else {
crate::reset::ResetOutcome::default()
};
if reset_outcome.failed {
// Surface reset-failed state — skip identity resolution and
// all side-effecting setup. The webview still loads so the
// frontend can show the recovery screen via get_identity.
let state = app_handle.state::<AppState>();
state
.reset_failed
.store(true, std::sync::atomic::Ordering::Release);
return Ok(());
}
// Run all pre-identity data migrations before state loads from disk.
if reset_outcome.completed {
migration::run_boot_migrations_after_reset(&app_handle);
} else {
migration::run_boot_migrations(&app_handle);
}
// Resolve persisted identity key (env var → file → generate+save).
// This is fatal — the app should not start with an ephemeral identity
// that will be lost on restart, as that silently breaks channel
// memberships, DMs, and relay identity.
let state = app_handle.state::<AppState>();
if let Err(e) = resolve_persisted_identity(&app_handle, &state) {
eprintln!("buzz-desktop: fatal: identity resolution failed: {e}");
std::process::exit(1);
}
// 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;
// Backfill the pinned persona snapshot for any pre-existing agent
// that predates the record-authoritative-spawn cutover (persona_id
// set but no source_version). Must run before
// restore_managed_agents_on_launch so no agent spawns from an empty
// snapshot. Synchronous and best-effort — a failure here must not
// block launch, but a missing persona is logged loudly inside.
if let Err(e) = backfill_persona_snapshots(&app_handle) {
eprintln!("buzz-desktop: persona-snapshot backfill failed: {e}");
}
// Warm the loaded-harness registry BEFORE restore so cold-launch
// agent spawns can resolve custom/preset runtime ids without
// waiting for the frontend's discover_acp_providers call. This is
// a pure directory scan — no PATH probing, no async work.
{
let custom_dir = app_handle
.path()
.app_data_dir()
.ok()
.map(|d| d.join("custom_harnesses"));
managed_agents::custom_harnesses::warm_harness_registry_from_dir(
custom_dir.as_deref(),
);
}
// Store the AppHandle so huddle commands can emit `huddle-state-changed`
// events via `huddle::emit_huddle_state` without threading the handle
// through every call site.
if let Ok(mut guard) = state.app_handle.lock() {
*guard = Some(app_handle.clone());
}
let (tts_settings, tts_settings_load_error) =
huddle::tts_settings::load_for_app(&app_handle);
if let Ok(mut guard) = state.huddle_audio.tts.lock() {
*guard = tts_settings.clone();
}
if let Ok(mut guard) = state.huddle_audio.tts_load_error.lock() {
*guard = tts_settings_load_error;
}
if let Ok(mut huddle) = state.huddle_state.lock() {
huddle.tts_enabled = tts_settings.agent_text_to_speech;
}
// Bring up the runtime-owned shared-compute coordinator before
// saved agents are restored. Its lifetime is tied to the app, not
// a UI mount; it publishes discovery and reconciles membership for
// MeshLLM's native admission and transport.
#[cfg(feature = "mesh-llm")]
{
// Route mesh-llm's download progress (model weights, runtime)
// onto Tauri events so the UI can render real progress.
crate::mesh_llm::install_progress_sink(&app_handle);
let mesh_app = app_handle.clone();
tauri::async_runtime::spawn(async move {
crate::mesh_llm::start_coordinator(mesh_app).await;
});
}
// Start the localhost media streaming proxy. Uses the shared HTTP
// client so VPN tunnelling applies. The port is stored in AppState
// and exposed to the frontend via the `get_media_proxy_port` command.
let proxy_client = state.http_client.clone();
let proxy_handle = app_handle.clone();
tauri::async_runtime::spawn(async move {
let port = media_proxy::spawn_media_proxy(proxy_client, proxy_handle.clone()).await;
let state = proxy_handle.state::<AppState>();
state
.media_proxy_port
.store(port, std::sync::atomic::Ordering::Relaxed);
});
// Create the Buzz nest (~/.buzz or ~/.buzz-dev for dev builds) before
// agents are restored, so default_agent_workdir() resolves to the
// nest directory. Non-fatal: agents fall back to $HOME if nest
// creation fails.
if let Err(error) = ensure_nest() {
eprintln!("buzz-desktop: failed to create nest: {error}");
}
// Resolve the REPOS symlink from the persisted repos_dir BEFORE
// agents are restored below, and decide whether restore is safe.
// The frontend's apply_workspace runs only after React mounts —
// later than the async agent restore — so without this an agent
// could clone into the empty real REPOS dir, and once REPOS is
// non-empty ensure_repos_symlink refuses forever. resolve_repos_at_boot
// fails closed: if a repos_dir was configured but its symlink could
// not be resolved (transiently unavailable external volume), it
// returns false so we skip restore this launch rather than let an
// agent clone into the wrong REPOS. See managed_agents::repos.
let restore_agents = match managed_agents::nest_dir() {
Some(nest) => managed_agents::resolve_repos_at_boot(&nest),
None => true,
};
// Carry the agent's knowledge from the legacy nest (~/.sprout) into
// the live nest after it exists. Must run after ensure_nest() so the
// destination is present. Non-fatal.
// On a real migration, emit a one-time hint so the user can delete
// the now-inert ~/.sprout; the frontend dedupes the toast.
// Suppressed when a reset completed this boot: the nest was wiped and
// a fresh ~/.sprout-less state is exactly what we want.
if !reset_outcome.completed && migration::migrate_legacy_nest() {
let _ = app_handle.emit("legacy-nest-migrated", ());
}
// One-time migration for dev builds: copy accumulated knowledge
// from the shared ~/.buzz nest into the new dedicated ~/.buzz-dev
// nest so no work is lost when the nest is first namespaced.
// Runs only when nest_dir() resolved to ~/.buzz-dev (dev instance).
// Suppressed after a reset so re-importing ~/.buzz into ~/.buzz-dev
// doesn't re-populate what was just wiped.
let is_dev_nest = managed_agents::nest_dir()
.and_then(|p| p.file_name().map(|n| n.to_os_string()))
.is_some_and(|n| n == ".buzz-dev");
if !reset_outcome.completed && is_dev_nest {
migration::migrate_dev_nest();
}
// Create/update the local CLI symlink pointing to the
// bundled CLI binary. Non-fatal: agents find CLI via PATH.
if let Ok(exe) = std::env::current_exe() {
if let Some(parent) = exe.parent() {
if let Err(error) = managed_agents::ensure_cli_symlink(parent, is_dev_nest) {
eprintln!("buzz-desktop: failed to create CLI symlink: {error}");
}
}
}
try_regenerate_nest(&app_handle);
if let Some(mgr) = huddle::models::global_model_manager() {
mgr.start_stt_download(state.http_client.clone());
mgr.start_tts_download(state.http_client.clone());
}
// Handle deep link URLs received while the app is running (macOS)
// and on cold start. The single-instance plugin handles forwarding
// from duplicate launches on Windows/Linux.
#[cfg(desktop)]
{
use tauri_plugin_deep_link::DeepLinkExt;
let dl_handle = app.handle().clone();
app.deep_link().on_open_url(move |event| {
for url in event.urls() {
handle_deep_link_url(&dl_handle, url.as_str());
}
});
}
// Defer launch-time agent restoration until `apply_workspace` has
// installed the active workspace relay and identity. Starting here
// would race React initialization and send agents whose saved record
// has no relay override to the localhost fallback. Preserve the
// boot-time repos and identity recovery safety gates by only marking
// restoration pending when both allow it.
if restore_agents && !recovery_mode {
state
.managed_agent_restore_pending
.store(true, Ordering::Release);
}
// Periodic sweep: reap orphaned agents from dead instances every 60s.
// Catches agents that escaped both the Justfile trap and boot-time
// reaping (e.g. a `just staging` Ctrl+C leak that only gets collected
// by a different instance's periodic sweep).
let sweep_handle = app.handle().clone();
tauri::async_runtime::spawn(async move {
use std::collections::HashSet;
use std::time::Duration;
use tauri::Manager;
let instance_id = managed_agents::current_instance_id(&sweep_handle);
let state = sweep_handle.state::<AppState>();
// Two-tick grace: only reap same-instance orphans seen on two
// consecutive sweeps. Prevents killing a legitimately-starting
// agent that spawned between the skip-list snapshot and the scan.
let mut prev_orphans: HashSet<u32> = HashSet::new();
loop {
tokio::time::sleep(Duration::from_secs(60)).await;
// Collect PIDs of our own live agents to avoid killing them.
let skip_pids: Vec<u32> = state
.managed_agent_processes
.lock()
.map(|runtimes| runtimes.values().map(|rt| rt.child.id()).collect())
.unwrap_or_default();
let prev = prev_orphans.clone();
let inst = instance_id.clone();
// Run the blocking syscall work off the async executor.
let new_orphans = tauri::async_runtime::spawn_blocking(move || {
let orphans = managed_agents::sweep_system_agent_processes_with_grace(
&inst, &skip_pids, &prev,
);
managed_agents::reap_dead_instance_agents(&inst, &skip_pids);
orphans
})
.await
.unwrap_or_default();
prev_orphans = new_orphans;
}
});
// Drain events the retention store flagged `pending_sync` (UI
// create/edit, delete tombstones, launch reconcile) to the relay.
// One loop is the sole publisher for persona, team, and managed-
// agent writers; a relay-unreachable tick leaves rows pending for
// the next sweep.
// 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;
loop {
let state = flush_handle.state::<AppState>();
if let Err(e) = managed_agents::persona_events::flush_active_pending_events(
&flush_handle,
&state,
)
.await
{
eprintln!("buzz-desktop: event-flush: {e}");
}
tokio::time::sleep(Duration::from_secs(30)).await;
}
});
}
Ok(())
})
.invoke_handler(tauri::generate_handler![
terminal_runtime::terminal_attach,
terminal_runtime::terminal_detach,
terminal_runtime::terminal_close,
terminal_runtime::terminal_input,
terminal_runtime::terminal_resize,
terminal_runtime::terminal_scroll,
terminal_runtime::terminal_ack,
terminal_runtime::terminal_viewport_ready,
terminal_runtime::terminal_focus,
take_pending_community_deep_link,
acknowledge_pending_community_deep_link,
start_builderlab_login,
cancel_builderlab_login,
get_builderlab_auth,
clear_builderlab_auth,
get_builderlab_nostr_identity,
bind_builderlab_nostr_identity,
delete_builderlab_nostr_identity,
list_builderlab_communities,
check_builderlab_community_name,
create_builderlab_community,
archive_builderlab_community,
unarchive_builderlab_community,
transfer_builderlab_community,
title_bar_double_click,
get_identity,
get_nsec,
generate_backup_passphrase,
create_ncryptsec_backup,
verify_ncryptsec_backup,
save_ncryptsec_copy,
import_identity,
persist_current_identity,
get_profile,
update_profile,
update_profile_at_relay,
get_user_profile,
get_users_batch,
get_user_notes,
get_git_identity,
get_project_repo_snapshot,
get_project_repo_diff,
get_project_local_repo_diff,
get_project_local_repo_snapshot,
get_project_repo_sync_status,
list_project_local_repositories,
clone_project_repository,
create_project_remote_branch,
delete_project_remote_branch,
push_project_local_repository,
pull_project_local_repository,
sign_project_pull_request_status,
sign_project_pull_request_review_request,
publish_project_pull_request_merged_status,
merge_project_pull_request,
open_project_terminal,
open_project_merge_recovery_terminal,
search_users,
get_presence,
get_os_idle_seconds,
get_default_relay_url,
auto_connect_default_relay_enabled,
get_legacy_workspace_storage,
is_shared_identity,
get_relay_ws_url,
get_relay_http_url,
get_media_proxy_port,
fetch_link_preview_title,
discover_acp_auth_methods,
discover_acp_providers,
discover_git_bash_prerequisite,
install_acp_runtime,
save_custom_harness,
delete_custom_harness,
connect_acp_runtime,
discover_managed_agent_prereqs,
sign_event,
sign_nostr_identity_binding,
sign_out,
decrypt_observer_event,
build_observer_control_event,
create_auth_event,
nip44_encrypt_to_self,
nip44_decrypt_from_self,
get_channels,
create_channel,
ensure_starter_channels,
open_dm,
hide_dm,
get_channel_details,
get_channel_members,
update_channel,
set_channel_topic,
set_channel_purpose,
archive_channel,
unarchive_channel,
delete_channel,
add_channel_members,
remove_channel_member,
change_channel_member_role,
join_channel,
leave_channel,
get_canvas,
set_canvas,
get_feed,
search_messages,
send_channel_message,
send_managed_agent_channel_message,
has_managed_agent_channel_message_marker,
get_forum_posts,
get_forum_thread,
get_thread_replies,
get_channel_window,
get_channel_messages_before,
edit_message,
delete_message,
add_reaction,
remove_reaction,
get_event,
show_native_notification,
#[cfg(target_os = "macos")]
macos_notifications::take_pending_activations,
#[cfg(target_os = "macos")]
macos_notifications::notification_permission_state,
#[cfg(target_os = "macos")]
macos_notifications::request_notification_access,
upload_media,
pick_and_upload_media,
pick_and_upload_image,
upload_media_bytes,
upload_media_bytes_raw,
cancel_media_upload,
download_image,
save_png_data_url,
download_file,
fetch_media_bytes,
copy_image_to_clipboard,
copy_text_to_clipboard,
read_clipboard_text,
fetch_snapshot_bytes,
relay_requires_membership,
list_relay_members,
get_my_relay_membership,
add_relay_member,
remove_relay_member,
change_relay_member_role,
archive_identity,
unarchive_identity,
list_archived_identities,
get_relay_self,
resolve_oa_owner,
list_relay_agents,
list_managed_agents,
list_managed_agent_runtimes,
start_managed_agent_runtime,
stop_managed_agent_runtime,
restart_managed_agent_runtime,
reconcile_managed_agent_runtimes,
put_managed_agent_runtime_lifecycle,
create_managed_agent,
start_managed_agent,
stop_managed_agent,
set_agent_managed_profiles,
set_managed_agent_start_on_app_launch,
set_managed_agent_auto_restart,
delete_managed_agent,
get_managed_agent_log,
get_agent_models,
discover_agent_models,
agent_access_owner_only,
get_agent_config_surface,
get_runtime_file_config,
get_baked_build_env_keys,
get_baked_build_env,
put_agent_session_config,
get_global_agent_config,
set_global_agent_config,
mesh_start_node,
mesh_stop_node,
mesh_node_status,
mesh_serving_usage,
mesh_installed_models,
mesh_model_catalog,
update_managed_agent,
discover_backend_providers,
probe_backend_provider,
list_personas,
create_persona,
update_persona,
update_persona_and_publish,
delete_persona,
set_persona_active,
set_persona_shared,
reconcile_inbound_persona_event,
list_channel_templates,
create_channel_template,
update_channel_template,
delete_channel_template,
duplicate_channel_template,
list_teams,
create_team,
update_team,
delete_team,
export_agent_snapshot,
card_mint_key_status,
card_mint_save_openai_key,
mint_agent_card,
save_agent_card,
list_agent_cards,
load_agent_card,
preview_agent_snapshot_import,
confirm_agent_snapshot_import,
encode_agent_snapshot_for_send,
export_team_snapshot,
encode_team_snapshot_for_send,
preview_team_snapshot_import,
confirm_team_snapshot_import,
get_channel_workflows,
get_channels_workflows,
get_workflow,
create_workflow,
update_workflow,
delete_workflow,
get_workflow_runs,
get_run_approvals,
trigger_workflow,
grant_approval,
deny_approval,
publish_note,
get_contact_list,
set_contact_list,
get_notes_timeline,
get_global_notes,
get_note,
get_note_reactions,
get_liked_notes,
start_huddle,
join_huddle,
leave_huddle,
end_huddle,
get_huddle_state,
close_huddle_companion,
open_huddle_window,
push_audio_pcm,
reconnect_huddle_audio,
start_stt_pipeline,
set_huddle_transcription_enabled,
download_voice_models,
get_model_status,
set_tts_enabled,
huddle::tts_settings::get_tts_settings,
huddle::tts_settings::list_voice_registry,
huddle::tts_settings::set_pocket_voice,
huddle::tts_settings::preview_pocket_voice,
huddle::tts_settings::import_pocket_voice,
huddle::tts_settings::delete_pocket_voice,
huddle::agent_voice::ensure_huddle_agent_voice_settings,
huddle::agent_voice::set_huddle_agent_tts_enabled,
huddle::agent_voice::set_huddle_agent_voice,
speak_agent_message,
interrupt_huddle_speech,
add_agent_to_huddle,
remove_agent_from_huddle,
huddle::agents::sync_agents_to_active_huddle,
check_pipeline_hotstart,
confirm_huddle_active,
perform_sidebar_default_haptic,
get_huddle_agent_pubkeys,
set_voice_input_mode,
get_voice_input_mode,
set_huddle_manual_mic_unmuted,
list_audio_output_devices,
set_audio_output_device,
get_audio_output_device,
start_pairing,
start_identity_recovery_pairing,
confirm_pairing_sas,
cancel_pairing,
apply_workspace,
validate_repos_dir,
get_active_workspace,
fetch_workspace_icon,
fetch_join_policy,
set_prevent_sleep_active,
get_agent_memory,
relay_reconnect_hook,
relay_reconnect_hook_configured,
observer_archive_default_enabled,
agent_metric_archive_default_enabled,
archive::archive_events,
archive::create_save_subscription,
archive::merge_save_subscription_kinds,
archive::remove_save_subscription_kind,
archive::list_save_subscriptions,
archive::delete_save_subscription,
archive::read_archived_events,
archive::read_archived_observer_events_for_channel,
archive::index_observer_channel_id,
archive::read_unindexed_observer_rows,
is_auto_update_supported,
set_window_vibrancy,
#[cfg(target_os = "macos")]
tray_menu::clear_tray_agent_activity,
#[cfg(target_os = "macos")]
tray_menu::requeue_tray_actions,
#[cfg(target_os = "macos")]
tray_menu::take_tray_actions,
#[cfg(target_os = "macos")]
tray_menu::update_tray_agent_activity,
])
.build(tauri::generate_context!())
.expect("error while building tauri application");
let shutdown_done = Arc::new(AtomicBool::new(false));
#[cfg(unix)]
shutdown::install_signal_handler(app.handle().clone(), Arc::clone(&shutdown_done));
let run_shutdown_done = Arc::clone(&shutdown_done);
let restart_requested = Arc::new(AtomicBool::new(false));
app.run(move |app_handle, event| match event {
#[cfg(target_os = "macos")]
RunEvent::Reopen { .. } => show_main_window(app_handle),
#[cfg(target_os = "macos")]
RunEvent::WindowEvent {
label,
event: WindowEvent::CloseRequested { api, .. },
..
} if label == "main" => {
// Keep the webview alive so Buzz can be reopened from its tray menu.
api.prevent_close();
if let Some(window) = app_handle.get_webview_window("main") {
if let Err(error) = window.hide() {
eprintln!("buzz-desktop: failed to hide main window: {error}");
}
}
}
RunEvent::WindowEvent {
label,
event: WindowEvent::CloseRequested { .. },
..
} if label.starts_with("huddle-") => {
let is_active_huddle_window =
app_handle
.state::<AppState>()
.huddle()
.ok()
.is_some_and(|huddle| {
!matches!(huddle.phase, HuddlePhase::Idle | HuddlePhase::Leaving)
&& huddle
.ephemeral_channel_id
.as_deref()
.is_some_and(|channel_id| label == format!("huddle-{channel_id}"))
});
if is_active_huddle_window {
if let Err(error) = app_handle.emit("huddle-companion-returned", ()) {
eprintln!("buzz-desktop: failed to restore huddle drawer: {error}");
}
}
}
RunEvent::ExitRequested { code, .. } => {
if is_restart_request(code) {
restart_requested.store(true, Ordering::SeqCst);
}
shut_down_app(app_handle, &run_shutdown_done);
}
RunEvent::Exit => {
shut_down_app(app_handle, &run_shutdown_done);
app_handle.state::<ClipboardState>().release();
#[cfg(all(feature = "mesh-llm", target_os = "macos"))]
if restart_requested.load(Ordering::SeqCst) {
relaunch_after_mesh_shutdown(app_handle);
}
// AppKit terminates through libc exit(), which runs C++ static
// destructors. The embedded ggml/Metal runtime currently aborts in
// that destructor phase even after its node has stopped cleanly.
// End the process only after Buzz and Mesh shutdown above, while
// deliberately skipping those native global destructors.
#[cfg(all(feature = "mesh-llm", target_os = "macos"))]
hard_exit_after_mesh_shutdown();
}
_ => {}
});
}