Files
buzz/desktop/src-tauri/src/lib.rs
Will Pfleger a7c7414a7f feat(desktop): implement 30178 team catalog backend (#5112)
Implements the Rust/backend half of 30178 team catalog sharing on the
community catalog. No TS callers yet — this is PR 1 of 2;
[#3995](https://github.com/block/buzz/pull/3995) (stacked here) adds the
parse layer, hooks, `CommunityCatalogDialog`, and e2e tests.

## What this adds

**Projection builder** (`team_catalog.rs`):
`build_team_catalog_event/content` produces a 30178 event from a team +
member definitions. Size contracts: 192 KiB total ceiling, per-field
bounds (name 256 B, text 4 KiB, system-prompt 16 KiB, avatar URL 32 KiB
for projection fields; https URLs additionally validated at 2,048
bytes). Avatar handling: oversized raster data URLs downscaled to fit;
oversized built-in avatars silently omitted; oversized https URLs
rejected with a named error.

**Share/unshare/tombstone** (`commands/teams/pending.rs`):
`prepare_team_publication_at` retains a signed 30178 head for the flush
loop; share state is relay-scoped (one community can share while another
does not). `refresh_or_retract_shared_head_at` rebuilds or tombstones
the head immediately on team/member edit — no stale publication until
the next boot. Both the 30178 catalog and 30176 team tombstones are
signed with a `created_at` that strictly dominates the retained head's
(read inside the delete transaction), so a future-dated head cannot
survive its own deletion under the relay's `created_at <=` soft-delete
gate.

**Serialized-publisher share command** (`commands/teams/sharing.rs`,
`managed_agents/persona_events.rs`): `set_team_shared` publishes the
retained head through `flush_pending_events_at` rather than submitting
the prepared event directly. A direct submit ran outside
`managed_agents_store_lock` and raced `delete_team`: the delete
atomically purges the head's retained row and enqueues a newer 30178
tombstone in one transaction, and a delayed direct submit could land the
old shared head *after* that tombstone. Because 30178 replacement has no
deletion watermark, the deleted team would go publicly live again.
Routing through the flush is necessary but not sufficient, because the
flush is not itself a single publisher: several call sites (the 30s
sweep, this share toggle, managed-policy updates) invoke it
concurrently, and each invocation has an await gap between its per-row
re-read and its relay POST. A second flush could publish the tombstone
in that gap while an earlier flush's delayed POST lands the just-purged
head after it. The flush now acquires a per-scope publisher mutex —
keyed by the canonical retention `db_path`, which already *is* the
durable scope identity (hashed normalized relay URL + owner pubkey) —
and holds it across its entire invocation: snapshot, per-row re-read,
POST, and `mark_synced`. Serialized-per-scope flush ⟹ within one scope
the only interleavings are head-before-tombstone (the head lands first,
then is dominated by the later tombstone) or purged-row-skip (the delete
committed first, so the re-read skips the head) — a purged head can
never publish after its tombstone. The lock is a
`LazyLock<Mutex<HashMap<PathBuf, Arc<tokio::sync::Mutex<()>>>>>` static
rather than an `AppState` field: it keeps the invariant at its
acquisition site and out of the size-ratcheted `app_state.rs`
(precedent: `agent_models_databricks.rs`'s `AUTH_GATE`), and the
std-mutex map guard is released before the async guard is awaited so it
never spans an await point. Keying per scope — rather than one
process-global lock — means a stalled or hostile relay in one community
can no longer block publication in every other community, and each
per-row submit is additionally wrapped in a
`tokio::time::timeout(PUBLISH_TIMEOUT = 60s)`:
`submit_signed_event_at_with_keys` first waits on the process-wide
admission gate (up to 300s on a 429) and then POSTs on the app-wide
`http_client`, which leaves reqwest's connect/read/total timeouts unset,
so a relay that accepts the connection and never finishes the response
would otherwise pin the lock forever. A timeout takes the same
`Err`/`continue` path as a relay rejection — the row stays pending for
the next 30s sweep and a timed-out tombstone keeps its replacement
deferred this pass — so a live admission gate now surfaces as
timeout-pending rather than a held lock, the correct durable behavior
since the sweep retries. `publicationStatus` (Published/Queued) is
derived by re-reading the retained row's pending flag after the flush.
The previously-unused `relayMessage` field is dropped from
`SetTeamSharedResult`: the flush loop swallows every per-event relay
rejection to its own log and only surfaces local DB faults (which the
status re-read already propagates), so the field was permanently `null`
on this path. #3995's `tauriTeams.ts` mapping and `useTeamActions.ts`
log branch drop with it.

**Domination-aware flush** (`managed_agents/persona_events.rs`): a
strictly-dominating tombstone can be signed past the relay's ingest
acceptance window (`MAX_TIMESTAMP_DRIFT_SECS`, ±900s from server time).
Republishing such a byte-frozen event verbatim from the pending queue
lets it age out of the window and be rejected forever, stranding the
head live. The flush loop is now domination-aware for every retained
kind:5 (covering both 30176 and 30178). For a pending tombstone with
floor `f` (= the retained row's own `created_at`):

- `f <= now` → re-date and re-sign at `now` (mirrors the existing
archive-request re-sign branch; `mark_synced` stays keyed to the
untouched retained row, so a re-date can't mask a concurrent edit),
- `now < f <= now + 900` → publish verbatim at `f`, inside the window,
- `f > now + 900` → skip the sweep; the event stays pending and its
replacement keeps deferring (via `failed_tombstones`) so out-of-order
retraction remains impossible, converging as the wall clock advances.

No path emits an event the gate rejects, and a boundary reject
self-heals through the submit-error requeue. Durable across offline gaps
of any length.

**Atomic adopt** (`commands/teams/adopt/`): `add_team_from_catalog`
re-fetches and signature-verifies the head from the relay, then plans
and commits a multi-entity add across two store writes with byte-exact
rollback on any failure (crash window between writes explicitly
retained). `plan_add` resolves full member provenance (owner, d-tag,
member-key, projection-hash), reuses a recipient's own local built-in
only when the published slug matches and the reuse hint's
`projection_hash` — recomputed from the member's own embedded fields at
the parse boundary and rejected on mismatch — equals
(case-insensitively, matching the boundary's hex tolerance) the
recipient's local built-in hash, and reactivates deactivated copies on
re-add. Because the boundary already proves the hint hash describes the
reviewed projection, a publisher cannot pair a real built-in's slug +
hash with arbitrary reviewed fields to make adoption install the
recipient's built-in in place of what was shown. `commit_stores`
snapshots both stores before writing and restores them on failure. The
commit and the retention enqueue are sequenced inside
`commit_and_enqueue`, the sole route to a durable adoption commit: once
the store write succeeds it enqueues a pending 30175 for every member
copy the add wrote or reactivated and a pending 30176 for the team, so a
crash before the next boot reconcile cannot lose the only adopted copy.
A provenance match on an already-active copy is retained too (not just a
reactivation): a recovery retry after a crash between the persona write
and post-commit retention finds the copy active with no 30175 row, and
`plan_add` short-circuits once the team row exists, so this reuse branch
is the only place that retry can re-enqueue the orphaned member head.
Retaining unconditionally is conservative, not exact — an active copy
still referenced by a standalone managed agent can already hold a live
head, and re-retaining only bumps it monotonically; reused built-ins are
handled separately and never reach this branch. A byte-identical reused
built-in and an idempotent replay write nothing and enqueue nothing; a
failed commit propagates and enqueues nothing. Enqueue is best-effort
per row (the boot reconcile is the backstop). The frontend refreshes via
the `useAddTeamFromCatalogMutation` query invalidation in #3995, so no
`agents-data-changed` emit is needed here.

**Startup reconcile** (`event_sync.rs`):
`reconcile_team_catalog_heads_at` walks all retained 30178 heads at
boot: republishes heads whose content changed, tombstones heads whose
team or member was deleted, skips unshared heads and unchanged content.
Multi-team continuation — all shared teams processed in one pass.

**Cross-device catalog retention** (`commands/personas/inbound.rs`):
both recovery paths above — the boot reconcile worklist and the
interactive `refresh_or_retract_shared_head_at` — key off a retained
30178 row and guard-return without one. A second device therefore never
retained the owner's own catalog head published from another device, so
its later edit or delete could never supersede or retract that
discoverable head. The inbound reconcile now retains an inbound 30178
head as this device's publication witness through
`retain_inbound_catalog_witness`, a self-gating dispatcher invoked
unconditionally on the production non-deletion path: newest-wins via
`retain_inbound_event`, arrival-scoped, no local JSON store, and
deliberately **no** refresh or republish on arrival — a 30178 arrival is
either this device's own echo or the other device's publication, and
rebuilding on either would make two devices ping-pong identical heads.
Retention advances the witness and stops. The tombstone router accepts a
kind:5 covering a 30178 coordinate, so an inbound deletion purges the
retained head on the receiving device (the covered-head purge already
happens inside `commit_inbound_tombstone_with_store`; a 30178 head has
no local record to remove). After a successful inbound persona/team
upsert, this device refreshes the affected shared heads so the community
catalog tracks the inbound edit (persona edit → every team whose
resolved members include it, resolving the local persona `id` by d-tag;
team edit → that team's head); after an inbound tombstone, a team
deletion retracts its 30178 coordinate and a persona deletion refreshes
the teams that listed it. The refresh is idempotent across devices:
`refresh_or_retract_shared_head_at` skips the publish when the rebuilt
projection is byte-identical to the retained head and still shared, so
the editing device's own published head triggers no churn republish on
the receiving device.

**Executable-text concealment gate** (`team_catalog.rs`,
`definition_validation.rs`): `validate_team_catalog_content` — the
single chokepoint both the publish builder
(`build_team_catalog_content`) and the adopt parser
(`team_catalog_content_from_event`) funnel through — now rejects
invisible, default-ignorable, and bidirectional-override characters
(e.g. U+200B, U+2066, U+202E) in every field delivered verbatim to the
ACP harness or rendered as reviewed identity in the catalog UI. This is
the same invariant the persona catalog already enforces at its own parse
boundary (`persona_catalog::parse_agent`); the 30178 boundary was the
outlier. Member `display_name` + `system_prompt` go through
`validate_agent_definition_text` per member (exact parity with
`parse_agent`: display-name rule with no layout controls, prompt rule
allowing `\n`/`\t`); `name_pool` entries take the display-name rule
since they are minted verbatim as instance display names; team
`instructions` take the visible-text rule with layout controls allowed,
since they reach `BUZZ_ACP_TEAM_INSTRUCTIONS` multiline. The team `name`
takes the display-name rule (no layout controls) and the `description`
takes the visible-text rule with layout controls allowed, since both are
rendered verbatim in the catalog UI as reviewed identity.
`validate_visible_text` is exposed `pub(crate)` from
`definition_validation.rs` and re-exported via `managed_agents`. A
signed, shared, current head can no longer smuggle concealed control
characters into executable configuration or reviewed catalog text
through either the publish or the adopt path; emoji (VS16/ZWJ) names and
multiline instructions still pass.

**Types**: `TeamRecord` and `AgentDefinition` extended with `shared`,
`catalog_source`, `team_catalog_source` fields. All commands registered
in `lib.rs`.

## Tests

- `team_catalog/tests.rs`: projection, size contracts, member-key
stability, tombstone rollback, fixture matrix
- `adopt/tests.rs`: head verification, store planning, provenance,
rollback
- `team_catalog/tests/concealment.rs`: the chokepoint rejects
default-ignorable (U+200B) and bidi controls (U+2066, U+202E) in member
`display_name`, `system_prompt`, `name_pool`, team `instructions`, and
the team `name`/`description`, on both the publish and adopt paths; an
emoji-bearing display name and multiline instructions still pass, so no
legitimate team becomes unshareable
- `team_catalog/tests/reuse_hint.rs`: a member pairing a real built-in's
slug with that built-in's genuine `projection_hash` but carrying
unrelated reviewed fields is rejected at the parse boundary, so adoption
can never substitute the recipient's built-in for the reviewed
projection; an honestly-stamped built-in reuse hint (including an
uppercase form of its true hash) still passes the boundary. Removing the
boundary recompute lets the tampered member validate, proving the test
discriminates the substitution class
- `adopt/tests/reuse.rs`: `reusable_builtin` reuses a local built-in for
an exact-match hint (one record, no copy), reuses it just the same when
the genuine hash is uppercased (case-insensitive, matching the boundary
— one record, not two), and falls through to an authoritative embedded
copy when the hash does not match. Comparing the hash case-sensitively
turns the uppercase case red (two records instead of one reused)
- `adopt/tests/concealment.rs`: a signed, shared, current head carrying
a bidi override drives the `add_verified_team` sequence (verify+parse →
`plan_add` → `commit_and_enqueue`) through real temp stores and a real
retention scope, asserting the head is rejected AND the personas store,
teams store, and retention rows are all left byte-unchanged. Stripping
the concealment call at the chokepoint turns it red — the parse then
succeeds and both stores are written, proving the test discriminates a
validate-after-write regression, not just an error return
- `adopt/tests/retention.rs`: adoption drives `commit_and_enqueue`
through a spy commit + a real temp-dir retention scope and asserts
persisted pending rows — commits-then-enqueues (30175 per minted member
+ 30176 team), a failed commit enqueues nothing, an idempotent replay
skips both the commit and the enqueue, a reused built-in retains only
the team, a reactivated copy is re-retained, and a partial-commit crash
recovery (active member copy with no retention row, team row absent)
re-enqueues the orphaned member's 30175. Deleting the enqueue inside the
seam turns these red — the wiring, not just the helper, is protected
- `sharing/tests.rs`: publish/queue lifecycle plus three concurrency
gate tests. (a)
`concurrent_flushes_never_land_the_head_after_its_tombstone` prepares a
share, runs a concurrent delete's purge+tombstone, flushes the tombstone
to a recording relay, then releases the delayed share and asserts the
purged 30178 head is never published after its tombstone and no pending
row survives (removing the lock turns it red — the relay sees the
resurrected head after the tombstone). (b)
`a_stalled_scope_does_not_block_publication_in_another_scope` pins one
scope's flush mid-POST on a stalled relay and runs a second scope's
flush to completion, asserting it publishes without waiting
(re-globalizing the key turns it red). (c)
`a_stalled_relay_releases_the_publisher_lock_within_the_bound` proves a
never-completing POST returns within `PUBLISH_TIMEOUT`, leaves the row
pending, and releases the lock so a subsequent same-scope flush proceeds
(removing the timeout turns it red).
`test_relay_rejection_stays_durably_queued` asserts queued +
still-pending rather than a relay-message string, matching the
flush-routed contract where the rejection text is no longer surfaced
- `pending/tests.rs`: share/unshare/tombstone lifecycle, edit
refresh/retract, tombstone timestamp domination, typed outcomes,
cross-device catalog convergence — device B retains device A's inbound
head then supersedes it on a member edit and tombstones the coordinate
on a delete, a byte-identical rebuild is a no-op (`Noop`, `created_at`
untouched), and an inbound retention alone queues no outbound publish
(the no-ping-pong guard). Neutralizing the inbound retention leg turns
the supersede/tombstone regressions red — B stays blind (`Noop`, no
dominating tombstone) — proving they discriminate the load-bearing leg
- `catalog_reconcile_tests.rs`: a signed kind:30178 head is driven
through the real production entrypoint
`reconcile_inbound_persona_event_blocking` over a `MockRuntime`
`AppHandle` (retention scope resolved from the handle's `app_data_dir`
under an overridden `$HOME`/`$XDG_DATA_HOME`), asserting the arrival
witness is retained at the owner coordinate with `pending_sync=false`,
stored verbatim, and no outbound publish is queued. An early return for
`KIND_TEAM_CATALOG` immediately before the production
`retain_inbound_catalog_witness` invocation turns it red, proving the
seam under test is the production dispatch path and not a test-only shim
- `pending/tests/gate.rs`: flush driven through a stub relay that logs
every `POST /events` with its accept/reject status and enforces the real
±900s ingest gate, for both 30176 and 30178 — within-window
publish+dominate; beyond-window stays-pending with **zero POSTs** (the
gate never receives a rejectable event); and the delayed/offline-retry
case where a tombstone signed strictly past a then-future head has aged
more than 900s into the past, so flush must re-date to `now` to publish.
The reversal check — restore the byte-frozen replay in
`persona_events.rs` and the delayed-retry and zero-POST assertions go
red — is what proves the suite discriminates the fix from the rejected
implementation
- `teams/tests.rs`: 30176 tombstone timestamp domination and no-head
fallback
- `event_sync_team_catalog_tests.rs`: reconcile scenarios including
multi-head continuation
- 26 shared JSON parity fixtures
(`tests/fixtures/team_catalog_content/`) consumed by both this PR's Rust
tests and #3995's TS tests

## Durable ordering, deletion reconcile, and relay-contract alignment

The catalog paths above sit on the shared inbound/deletion retention
seam. This PR makes that seam's ordering structural rather than
conventional, adds a negative-side (deletion) counterpart to the
existing positive-side boot backstop, and aligns inbound resolution with
the relay's actual soft-delete and NIP-33 winner rules.

**Preflight-then-commit (`commit_inbound_with_store`,
`commands/personas/inbound.rs` + `retention.rs`).** A named primitive
runs the fallible store mutation first and advances the durable
retention head only on success; an event that loses preflight returns
`Skipped` without touching the store. The persona/team upsert arms and
the inbound kind:5 removal path all route through it, so no inbound arm
can advance the head ahead of the store write it represents. The
managed-agent arm keeps its own preflight (its runtime transition must
not run for a skipped event) and still advances the head only after
`save_managed_agents`.

**Atomic + monotonic tombstone helpers (30175 / 30176 / 30177).** The
three ordinary tombstone helpers (`commands/personas/pending.rs`,
`commands/agents_pending.rs`, `commands/teams/mod.rs`) each read the
prior head, sign, delete, and retain inside one `BEGIN IMMEDIATE`
transaction, and sign the kind:5 with a `created_at` that strictly
dominates the prior head (`monotonic_created_at(prior_head)`). This
mirrors the 30178 `tombstone_team_catalog_coordinate` precedent. The
three siblings deliberately duplicate the `BEGIN IMMEDIATE` shape rather
than sharing a helper this round — the shared-helper consolidation is
deferred to the persona-tombstone follow-up PR where the flush-replay
class already lives.

**Deletion reconcile (`event_sync.rs`, negative-side counterpart of the
positive-side boot backstop).** The positive side already reconstructs
missing retained heads at boot from surviving disk records. Deletion is
the asymmetric gap: an atomic tombstone helper preserves the head on
failure, but boot reconcile enumerated disk records — deletion already
removed them — so an orphan retained head was never tombstoned. The
deletion reconcile enumerates retained 30175/30176 heads and tombstones
only genuine orphans (a retained head with no matching disk record),
routing through the now-atomic helpers. It reconciles only against a
successfully parsed store: a truncated, malformed, or wrong-shape JSON
store fails loudly (never triggers a tombstone); a missing file is
treated as empty. Managed agents (30177) are excluded by design: their
inbound sync retains a head *without* minting a local disk record
(agents carry device-local secrets that can't come from a relay event),
so a retained 30177 head with no matching record is the normal
cross-device state for every agent created on another device — not a
lost deletion. Sweeping it would tombstone and archive another device's
live agents at boot. Agent deletion-retry therefore stays a pre-existing
gap owned by the direct delete path (tracked in Follow-ups).

**Inbound relay-contract alignment (`retention.rs`,
`commands/personas/inbound.rs`).** Two inbound rules now match the
relay:
- *Equal-second tie-break.* `inbound_event_outcome` previously treated
every equal timestamp as stale; the relay resolves equal `created_at` by
lowest event id. Preflight now matches the relay tuple — strictly newer
timestamp wins, equal timestamp resolves by lower event id, an exact
echo skips — so two devices authoring different same-second successors
converge instead of one silently republishing an event the relay
refuses.
- *Covered-head resolution on inbound kind:5.*
`reconcile_inbound_tombstone` previously consulted only the retained
kind:5 row, so a historical tombstone replayed after a newer recreation
deleted the recreated record and never purged the covered head.
Preflight now resolves both the tombstone row and the covered
`(target_kind, owner, d_tag)` head: a target head strictly newer than
the tombstone preserves JSON and skips; an actually-covered head is
removed from disk first (fallible), then the tombstone row commit and
covered-head purge happen atomically. A JSON-save failure advances
neither, so the identical event stays retryable.

**Atomic agent-archive coupling (`commands/agents_pending.rs`).** The
30177 tombstone and the 9035 archive request now enqueue in one `BEGIN
IMMEDIATE` transaction, with the archive's `persona_id` derived from the
retained head (`persona_id_from_head`) rather than the deleted record,
where it survives the tombstone as owner-signed historical alias data.
The standalone `archive_managed_agent_pending` production callers are
removed — there is no double-enqueue path. Unlike personas/teams, this
coupling is not re-enqueued by the boot deletion reconcile (30177 is
excluded, above): a crash after the disk-authoritative record is removed
but before this transaction commits leaves agent deletion-retry a
pre-existing gap owned by this direct delete path (tracked in
Follow-ups).

## Follow-ups

`publish_prepared_persona` (`commands/personas/sharing.rs`) has the
identical direct-submit-outside-the-lock race for 30175 persona heads
that this PR fixes for 30178 team heads. It predates this work and is
tracked in the separate persona-tombstone follow-up PR, not folded here.

The app-wide `http_client` leaves reqwest's connect/read/total timeouts
unset (`app_state.rs` builder configures only pool options). This PR
bounds the team-publish call site with a `tokio::time::timeout`, but
every other consumer of the shared client remains exposed to a
non-responding endpoint. A client-wide default timeout is the broader
fix; it is pre-existing on main and affects all consumers, so it is out
of scope here.

Managed-agent deletion-retry: the atomic 30177 tombstone + 9035 archive
is best-effort, and unlike personas/teams it is deliberately excluded
from the boot deletion reconcile (a device-local-absent 30177 head is
the normal cross-device state, not a deletion). A crash between the
delete's store write and its retention enqueue therefore has no boot
backstop for agents. The durable fix is a local deletion intent written
before the record is removed; deferred rather than folded into this
round.

Stack: this PR → [#3995](https://github.com/block/buzz/pull/3995)

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
2026-08-27 17:44:58 -04:00

936 lines
40 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 channel_head_cache;
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 link_preview_tags;
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_relay_client;
mod native_websocket;
mod native_websocket_batch;
mod nostr_bind;
pub mod nostr_convert;
mod observed_unread;
mod persona_catalog;
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 unread_catch_up;
mod util;
#[cfg(target_os = "linux")]
pub mod webkit_rendering;
use app_state::{build_app_state, resolve_persisted_identity, AppState};
use builderlab::*;
#[doc(hidden)]
pub use commands::print_agent_access_owner_only_probe_if_requested;
use commands::*;
use deep_link::{
acknowledge_pending_community_deep_link, acknowledge_pending_entity_deep_link,
acknowledge_pending_navigation_deep_link, clear_pending_navigation_deep_links,
handle_deep_link_url, take_pending_community_deep_link, take_pending_entity_deep_link,
take_pending_navigation_deep_link, PendingCommunityDeepLinks, PendingEntityDeepLinks,
PendingNavigationDeepLinks,
};
use huddle::{
add_agent_to_huddle,
audio_output::{get_audio_output_device, list_audio_output_devices, set_audio_output_device},
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,
reconnect::reconnect_huddle_audio,
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 async chains overflow tokio's default 2 MiB stacks; run on 8 MiB like upstream.
#[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 push-to-talk global-shortcut plugin lives in `ptt_shortcut`, next to
// the registration lifecycle it drives. Installing it is a no-op in test
// builds; see that module for why.
let builder = ptt_shortcut::install(builder);
// 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(PendingNavigationDeepLinks::default())
.manage(PendingEntityDeepLinks::default())
.manage(BuilderlabSession::default())
.manage(BuilderlabLogin::default())
.manage(commands::pairing::PairingHandle::new())
.manage(terminal_runtime::TerminalSessions::default())
.manage(archive::sync::ArchiveSyncState::default())
.manage(native_relay_client::NativeRelayClient::default())
.manage(observed_unread::ObservedUnreadStore::default())
.manage(channel_head_cache::ChannelHeadCacheStore::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 recovery_mode = state
.identity_lost
.load(std::sync::atomic::Ordering::Acquire)
|| state
.keyring_locked
.load(std::sync::atomic::Ordering::Acquire);
// 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_harness_dir = app_handle
.path()
.app_data_dir()
.ok()
.map(|d| d.join("custom_harnesses"));
managed_agents::custom_harnesses::warm_harness_registry_from_dir(
custom_harness_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);
tauri::async_runtime::spawn(crate::mesh_llm::start_coordinator(app_handle.clone()));
}
// 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}");
}
archive::spawn_warm_init(app_handle.clone());
// 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)]
deep_link::install_deep_link_handlers(app);
// 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,
take_pending_navigation_deep_link,
acknowledge_pending_navigation_deep_link,
clear_pending_navigation_deep_links,
take_pending_entity_deep_link,
acknowledge_pending_entity_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_file_content,
get_project_repo_diff,
get_project_local_repo_diff,
get_project_local_repo_snapshot,
get_project_local_repo_file_content,
get_project_repo_sync_status,
list_project_local_repositories,
open_project_repository_folder,
clone_project_repository,
create_project_remote_branch,
delete_project_remote_branch,
push_project_local_repository,
pull_project_local_repository,
publish_project_owner_announcement,
sign_project_pull_request_status,
sign_project_pull_request_review_request,
sign_project_issue_assignment,
sign_project_issue_unassignment,
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_metadata,
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,
get_open_channel_directory,
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_reconnect_repair,
get_channel_window,
get_channel_messages_before,
edit_message,
delete_message,
add_reaction,
remove_reaction,
get_event,
get_events,
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,
release_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,
revalidate_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,
persist_agent_effort_level,
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,
persona_catalog::fetch_persona_catalog,
unread_catch_up::unread_catch_up,
observed_unread::observed_unread_open_scope,
observed_unread::observed_unread_ingest,
channel_head_cache::channel_head_cache_load,
channel_head_cache::channel_head_cache_store,
channel_head_cache::channel_head_cache_clear,
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,
set_team_shared,
add_team_from_catalog,
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,
archive::get_agent_usage_series,
archive::get_observer_retention_days,
archive::set_observer_retention_days,
archive::archive_size_stats,
archive::sync::announce_archive_sync_epoch,
archive::sync::start_archive_sync,
archive::sync::stop_archive_sync,
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();
}
_ => {}
});
}