Files
buzz/desktop/src-tauri/src/shutdown.rs
Kalvin C 1fc6a71ef9 feat(desktop): add dismissible banner pointing to the new buzz app (#9379)
## 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>
2026-11-13 19:08:25 +11:00

290 lines
10 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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(&current_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)));
}
}