mirror of
https://github.com/block/buzz.git
synced 2026-10-30 22:11:36 +00:00
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>
936 lines
40 KiB
Rust
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();
|
|
}
|
|
_ => {}
|
|
});
|
|
}
|