mirror of
https://github.com/block/buzz.git
synced 2026-11-13 21:01:53 +00:00
## Summary The Buzz 1.0 desktop app now lives in [block/buzz-app](https://github.com/block/buzz-app). This adds a banner to the 0.X desktop app that tells users, and lets them download the new app's installer without leaving the app. - A full-width banner sits under the top chrome: "A new Buzz app is here! This version is being retired, so grab the new one whenever you get a chance." - **Upgrade** downloads the installer for this machine inside the app. A dialog shows the progress, then the saved file's name and folder with **Show in Finder** (worded per platform) and **Done**. The file goes to the Downloads folder and is revealed in the file manager. It is never opened, mounted or run. - **Cancel**, Escape or the dialog's close button stops a running download and removes the partial file. - When no installer can be resolved for this machine (unsupported platform, or the manifest fetch fails or is slow), **Upgrade** opens `https://buzz.xyz` in the browser instead. - **More info** opens the [upgrade guide](https://github.com/block/buzz-app/blob/main/docs/upgrading.md) in the browser. - Dismissing (×, labelled "Remind me later") stores a timestamp in `localStorage`. The banner is read once per mount, so it comes back on the next launch (or community switch) once 24h have passed. - It is hidden in the huddle room window. ## Demo https://github.com/user-attachments/assets/1f1916e8-f32d-4ec0-8e60-fe7c85dd4a57 Recorded on macOS (Apple Silicon) from a native debug build with the real Rust backend: a full download, **Done**, then a second download cancelled part-way. - The network is throttled through a local proxy so the progress stays visible. Unthrottled, the installer arrives in about two seconds. - The app was launched with a scratch home folder, so the path in the dialog is a scratch path, and the Finder window that the reveal opens is left out of the recording. - Presses are accessibility presses, marked with an orange ring. The OS cursor is not shown. ## How the download works - **Which installer:** a Tauri command reports this install's key (`macos-arm64`, `macos-x64`, `windows-x64`, `linux-appimage` or `linux-deb`). The webview reads the matching entry from the [buzz-app download manifest](https://raw.githubusercontent.com/block/buzz-app/download-feed/downloads.json) once per banner mount. - **Validation:** Rust re-validates the URL on every call. It must be `https://github.com/block/buzz-app/releases/download/<tag>/<file>`, with a restricted tag and file-name character set and the extension this machine installs. The webview names a URL and nothing else. - **Redirects:** a dedicated HTTPS-only client follows at most 5 redirects, and only to `github.com`, `objects.githubusercontent.com` or `release-assets.githubusercontent.com`. - **Limits:** 512 MiB, 15 s to connect, 30 s for headers, 30 s without a byte, 60 min in total. One download at a time. - **Saving:** the body is written to `<name>.<random>.part` in the Downloads folder, then renamed to `<name>` (or `<name> (1)` and so on) without ever replacing an existing file. The partial file is removed on failure, on cancel and at app exit. A partial left by a crash or force-quit is removed on the next launch. - **Downloaded-from-the-internet marking:** macOS gets the `com.apple.quarantine` attribute a browser would write, and Windows gets the Mark of the Web (`Zone.Identifier`). If the mark cannot be written, the download fails and the file is not kept. - **Failures:** the dialog says what went wrong in plain words. Transient failures lead with **Try again**. Failures a retry cannot fix lead with **Download in browser**, which opens the validated installer URL. ## Testing - Unit: `deprecationBannerVisibility.test.mjs`, `newAppInstaller.test.mjs` (manifest and URL rules), `newAppInstallerDownload.test.mjs` (dialog state), and a jsdom test of the download hook. - Rust: `new_app_installer_tests.rs` covers URL validation, the redirect policy, limits, naming without overwrite, cancel and shutdown cleanup, launch cleanup, and the quarantine and Mark of the Web values. CI runs this crate's tests on Linux and on `windows-latest`, so the Mark of the Web test writes a real alternate data stream there. The quarantine test runs on macOS only, which CI builds but does not test. - Smoke: `tests/e2e/deprecation-banner.spec.ts` drives the banner, the download dialog, each failure's actions, cancel and the keyboard paths against the mock bridge. - Native, macOS arm64 debug build: the download was saved with `com.apple.quarantine` set and revealed in Finder; cancel removed the partial; the download kept running while the window was hidden; SIGTERM and a menu quit mid-download removed the partial; after a force kill the next launch removed it. Not verified by hand: - The Windows and Linux flows in a running app, including SmartScreen on Windows. - The Gatekeeper prompt when opening the downloaded DMG, and the macOS Downloads-folder consent prompt, which need a signed build. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Kalvin Chau <kalvin@block.xyz> Signed-off-by: Matt Toohey <contact@matttoohey.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Matt Toohey <contact@matttoohey.com>
290 lines
10 KiB
Rust
290 lines
10 KiB
Rust
use tauri::Manager;
|
||
|
||
use crate::app_state::AppState;
|
||
use crate::managed_agents::{
|
||
self, kill_stale_tracked_processes, load_managed_agents, save_managed_agents,
|
||
sync_managed_agent_processes, BackendKind,
|
||
};
|
||
use crate::{prevent_sleep, util};
|
||
|
||
pub(crate) fn is_restart_request(code: Option<i32>) -> bool {
|
||
code == Some(tauri::RESTART_EXIT_CODE)
|
||
}
|
||
|
||
pub(crate) fn shut_down_app(app: &tauri::AppHandle, shutdown_done: &std::sync::atomic::AtomicBool) {
|
||
use std::sync::atomic::Ordering;
|
||
|
||
app.state::<AppState>()
|
||
.shutdown_started
|
||
.store(true, Ordering::SeqCst);
|
||
if !shutdown_done.swap(true, Ordering::SeqCst) {
|
||
prevent_sleep::release(&app.state::<AppState>().prevent_sleep);
|
||
crate::observed_unread::flush(app);
|
||
crate::channel_head_cache::flush(app);
|
||
if let Some(downloads) = app.try_state::<crate::commands::InstallerDownloads>() {
|
||
downloads.abort_for_shutdown();
|
||
}
|
||
app.state::<crate::terminal_runtime::TerminalSessions>()
|
||
.shutdown_all();
|
||
if let Err(error) = shutdown_managed_agents(app) {
|
||
eprintln!("buzz-desktop: failed to stop managed agents: {error}");
|
||
}
|
||
#[cfg(feature = "mesh-llm")]
|
||
shutdown_mesh_runtime(app);
|
||
}
|
||
}
|
||
|
||
/// Install SIGINT/SIGTERM/SIGHUP cleanup on ctrlc's dedicated handler thread.
|
||
#[cfg(unix)]
|
||
pub(crate) fn install_signal_handler(
|
||
app: tauri::AppHandle,
|
||
shutdown_done: std::sync::Arc<std::sync::atomic::AtomicBool>,
|
||
) {
|
||
use std::sync::atomic::Ordering;
|
||
|
||
if let Err(error) = ctrlc::set_handler(move || {
|
||
app.state::<AppState>()
|
||
.shutdown_started
|
||
.store(true, Ordering::SeqCst);
|
||
if !shutdown_done.swap(true, Ordering::SeqCst) {
|
||
// This path ends in `exit`, so the download never cleans up itself.
|
||
if let Some(downloads) = app.try_state::<crate::commands::InstallerDownloads>() {
|
||
downloads.abort_for_shutdown();
|
||
}
|
||
app.state::<crate::terminal_runtime::TerminalSessions>()
|
||
.shutdown_all();
|
||
let _ = shutdown_managed_agents(&app);
|
||
#[cfg(feature = "mesh-llm")]
|
||
shutdown_mesh_runtime(&app);
|
||
}
|
||
#[cfg(all(feature = "mesh-llm", target_os = "macos"))]
|
||
hard_exit_after_mesh_shutdown();
|
||
#[cfg(not(all(feature = "mesh-llm", target_os = "macos")))]
|
||
std::process::exit(0);
|
||
}) {
|
||
eprintln!("buzz-desktop: failed to register signal handler: {error}");
|
||
}
|
||
}
|
||
|
||
#[cfg(all(feature = "mesh-llm", target_os = "macos"))]
|
||
fn updated_macos_binary(current_binary: &std::path::Path) -> Option<std::path::PathBuf> {
|
||
let macos_directory = current_binary.parent()?;
|
||
if macos_directory.file_name()? != "MacOS" {
|
||
return None;
|
||
}
|
||
let contents_directory = macos_directory.parent()?;
|
||
if contents_directory.file_name()? != "Contents" {
|
||
return None;
|
||
}
|
||
let info_plist =
|
||
plist::from_file::<_, plist::Dictionary>(contents_directory.join("Info.plist")).ok()?;
|
||
let binary_name = info_plist.get("CFBundleExecutable")?.as_string()?;
|
||
Some(macos_directory.join(binary_name))
|
||
}
|
||
|
||
#[cfg(all(feature = "mesh-llm", target_os = "macos"))]
|
||
pub(crate) fn relaunch_after_mesh_shutdown(app: &tauri::AppHandle) -> ! {
|
||
use std::process::Command;
|
||
|
||
tauri_plugin_single_instance::destroy(app);
|
||
let env = app.env();
|
||
match tauri::process::current_binary(&env) {
|
||
Ok(current_binary) => {
|
||
let binary = updated_macos_binary(¤t_binary).unwrap_or(current_binary);
|
||
if let Err(error) = Command::new(binary)
|
||
.args(env.args_os.iter().skip(1))
|
||
.spawn()
|
||
{
|
||
eprintln!("buzz-desktop: failed to relaunch app: {error}");
|
||
}
|
||
}
|
||
Err(error) => eprintln!("buzz-desktop: failed to locate app for relaunch: {error}"),
|
||
}
|
||
hard_exit_after_mesh_shutdown();
|
||
}
|
||
|
||
#[cfg(all(feature = "mesh-llm", target_os = "macos"))]
|
||
pub(crate) fn hard_exit_after_mesh_shutdown() -> ! {
|
||
// SAFETY: all Buzz-managed subprocesses and the embedded Mesh runtime have
|
||
// been stopped. `_exit` intentionally skips only process-global C++
|
||
// destructors and buffered stdio; no application state remains observable.
|
||
unsafe { libc::_exit(0) }
|
||
}
|
||
|
||
#[cfg(feature = "mesh-llm")]
|
||
pub(crate) fn shutdown_mesh_runtime(app: &tauri::AppHandle) {
|
||
let app = app.clone();
|
||
let (tx, rx) = std::sync::mpsc::channel();
|
||
tauri::async_runtime::spawn(async move {
|
||
let state = app.state::<AppState>();
|
||
let runtime = state.mesh_llm_runtime.lock().await.take();
|
||
let result = match runtime {
|
||
Some(runtime) => runtime.stop().await,
|
||
None => Ok(()),
|
||
};
|
||
let _ = tx.send(result);
|
||
});
|
||
match rx.recv_timeout(std::time::Duration::from_secs(5)) {
|
||
Ok(Ok(())) => {}
|
||
Ok(Err(error)) => eprintln!("buzz-desktop: failed to stop Mesh runtime: {error}"),
|
||
Err(error) => eprintln!("buzz-desktop: timed out stopping Mesh runtime: {error}"),
|
||
}
|
||
}
|
||
|
||
pub(crate) fn shutdown_managed_agents(app: &tauri::AppHandle) -> Result<(), String> {
|
||
let state = app.state::<AppState>();
|
||
let _restore_transition = state
|
||
.managed_agent_runtime_transition
|
||
.lock()
|
||
.map_err(|error| error.to_string())?;
|
||
let _store_guard = state
|
||
.managed_agents_store_lock
|
||
.lock()
|
||
.map_err(|error| error.to_string())?;
|
||
let mut records = load_managed_agents(app)?;
|
||
let mut runtimes = state
|
||
.managed_agent_processes
|
||
.lock()
|
||
.map_err(|error| error.to_string())?;
|
||
let (mut changed, _exited) = sync_managed_agent_processes(
|
||
&mut records,
|
||
&mut runtimes,
|
||
&managed_agents::current_instance_id(app),
|
||
);
|
||
changed |= kill_stale_tracked_processes(
|
||
&mut records,
|
||
&runtimes,
|
||
&managed_agents::current_instance_id(app),
|
||
);
|
||
|
||
// Stop all tracked agents. Send SIGTERM to all process
|
||
// groups first, then wait for exits in parallel to avoid serial 1s waits.
|
||
struct AgentToStop {
|
||
idx: usize,
|
||
pid: u32,
|
||
runtime: Option<managed_agents::ManagedAgentPairRuntime>,
|
||
}
|
||
|
||
let mut to_stop: Vec<AgentToStop> = Vec::new();
|
||
for (idx, record) in records.iter().enumerate() {
|
||
if record.backend != BackendKind::Local {
|
||
continue;
|
||
}
|
||
// Drain every tracked pair for this record, not just the first — an
|
||
// agent can run one harness per community, and each pair gets the
|
||
// graceful SIGTERM → 2s wait → SIGKILL fan-out with a stop log
|
||
// marker, instead of falling through to the orphan sweep's 200ms
|
||
// grace below.
|
||
for key in managed_agents::managed_agent_runtime_keys(&runtimes, &record.pubkey) {
|
||
let runtime = runtimes.remove(&key);
|
||
let Some(pid) = runtime
|
||
.as_ref()
|
||
.map(|rt| rt.child.id())
|
||
.or(record.runtime_pid)
|
||
else {
|
||
continue;
|
||
};
|
||
to_stop.push(AgentToStop { idx, pid, runtime });
|
||
}
|
||
}
|
||
|
||
if !to_stop.is_empty() {
|
||
changed = true;
|
||
|
||
// Fan-out: send SIGTERM to all process groups at once.
|
||
#[cfg(unix)]
|
||
for agent in &to_stop {
|
||
let pgid = -(agent.pid as i32);
|
||
unsafe {
|
||
libc::kill(pgid, libc::SIGTERM);
|
||
}
|
||
}
|
||
|
||
// Wait up to 2s for all to exit, checking in a polling loop.
|
||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
|
||
loop {
|
||
if to_stop
|
||
.iter()
|
||
.all(|a| !managed_agents::process_is_running(a.pid))
|
||
{
|
||
break;
|
||
}
|
||
if std::time::Instant::now() >= deadline {
|
||
break;
|
||
}
|
||
std::thread::sleep(std::time::Duration::from_millis(50));
|
||
}
|
||
|
||
// Fan-out: SIGKILL any survivors.
|
||
#[cfg(unix)]
|
||
for agent in &to_stop {
|
||
if managed_agents::process_is_running(agent.pid) {
|
||
let pgid = -(agent.pid as i32);
|
||
unsafe {
|
||
libc::kill(pgid, libc::SIGKILL);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Reap children and update records.
|
||
for mut agent in to_stop {
|
||
if let Some(ref mut rt) = agent.runtime {
|
||
// Best-effort reap — don’t block shutdown if the child is stuck
|
||
// in uninterruptible sleep. The zombie will be cleaned up when
|
||
// our process exits and launchd reaps it.
|
||
let _ = rt.child.try_wait();
|
||
// Write log marker (best-effort).
|
||
let record = &records[agent.idx];
|
||
let _ = managed_agents::append_log_marker(
|
||
&rt.log_path,
|
||
&format!(
|
||
"=== stopped {} ({}) at {} ===",
|
||
record.name,
|
||
record.pubkey,
|
||
util::now_iso()
|
||
),
|
||
);
|
||
}
|
||
let record = &mut records[agent.idx];
|
||
record.runtime_pid = None;
|
||
record.last_stopped_at = Some(util::now_iso());
|
||
record.updated_at = util::now_iso();
|
||
record.last_exit_code = None;
|
||
record.last_error = None;
|
||
}
|
||
}
|
||
|
||
// Final sweep: kill any orphaned agent processes we have PID file receipts
|
||
// for that escaped process-group kills or weren't tracked in records.
|
||
// All tracked PIDs have already been killed above, so pass an empty skip list.
|
||
managed_agents::sweep_orphaned_agent_processes(app, &[]);
|
||
|
||
// System-wide sweep: agent workers (goose, buzz-agent, etc.) are spawned
|
||
// in their own process groups by buzz-acp, so group-kills above only
|
||
// reach the harness, not the workers. Scan all user processes and kill any
|
||
// known agent binaries that are still running.
|
||
managed_agents::sweep_system_agent_processes(&managed_agents::current_instance_id(app), &[]);
|
||
|
||
// Dead-instance reaping: find agents belonging to Buzz instances
|
||
// whose desktop process is no longer running and reap them.
|
||
managed_agents::reap_dead_instance_agents(&managed_agents::current_instance_id(app), &[]);
|
||
|
||
if changed {
|
||
save_managed_agents(app, &records)?;
|
||
}
|
||
|
||
Ok(())
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::is_restart_request;
|
||
|
||
#[test]
|
||
fn only_tauri_restart_exit_code_requests_a_relaunch() {
|
||
assert!(is_restart_request(Some(tauri::RESTART_EXIT_CODE)));
|
||
assert!(!is_restart_request(None));
|
||
assert!(!is_restart_request(Some(0)));
|
||
}
|
||
}
|