Files
buzz/desktop/src-tauri/src/tray_menu.rs
T
4c665aeac3 fix(desktop): serialize tray channel actions for frontend (#4762)
## Summary
- serialize native `openChannel` tray actions with the camelCase field
names consumed by the TypeScript frontend
- prevent a valid tray channel ID from becoming `/channels/undefined`
- add a Rust serialization contract test covering the complete frontend
payload shape


### Root cause
`TrayAction` renamed the enum variant to `openChannel`, but its struct
fields still serialized as `channel_id` and `community_generation`. The
frontend reads `action.channelId`, so tray navigation called
`goChannel(undefined)`.


### Testing
- manually verified the corrected runtime payload and tray navigation
before removing temporary logging
- `just desktop-ci`
- pre-push hooks (desktop checks/tests, Tauri checks, and Rust tests)

---------

Signed-off-by: Kalvin Chau <kalvin@block.xyz>
Signed-off-by: npub1dccv64krpcpse5cmkzfeh998cftungyatw3djt8jwdw6g43f7fyqzzmrf7 <6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@buzz.block.builderlab.xyz>
Co-authored-by: npub122y0pqkertljmedu303rl0aqrj3w8pvu43t6jxm6875lzg6f2pwqegc3xc <5288f082d91aff2de5bc8be23fbfa01ca2e3859cac57a91b7a3fa9f12349505c@buzz.block.builderlab.xyz>
Co-authored-by: npub1dccv64krpcpse5cmkzfeh998cftungyatw3djt8jwdw6g43f7fyqzzmrf7 <6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@buzz.block.builderlab.xyz>
2026-08-04 21:43:21 +00:00

666 lines
21 KiB
Rust

//! Native system-tray menu for the desktop app.
//!
//! The webview owns the live agent-turn state. It sends the small display
//! projection here so the native menu can remain useful while Buzz is hidden.
// Mouse back/forward (X1/X2 buttons and swipe) is also macOS-only native I/O;
// group it here so both platform-layer init paths share one call site in lib.rs.
#[path = "mouse_nav.rs"]
pub(crate) mod mouse_nav;
use std::{
sync::{Mutex, OnceLock},
time::{Duration, Instant},
};
#[cfg(target_os = "macos")]
use objc2::MainThreadMarker;
#[cfg(target_os = "macos")]
use objc2_foundation::{NSProcessInfo, NSString};
use serde::{Deserialize, Serialize};
use tauri::{
image::Image,
menu::{Menu, MenuItem, PredefinedMenuItem},
tray::{TrayIcon, TrayIconBuilder},
AppHandle, Emitter, Manager, Runtime,
};
const TRAY_ID: &str = "buzz-tray";
const OPEN_BUZZ_ID: &str = "tray-open-buzz";
const NEW_CHANNEL_ID: &str = "tray-new-channel";
const QUIT_ID: &str = "tray-quit";
const OPEN_CHANNEL_PREFIX: &str = "tray-open-channel:";
const OPEN_CHANNEL_ACTIVITY_SEPARATOR: char = '|';
#[cfg(target_os = "macos")]
const TRAY_MENU_MINIMUM_WIDTH: f64 = 320.0;
static PREVIEW_STARTED_AT: OnceLock<Instant> = OnceLock::new();
/// A local-only menu preview for demonstrating the working-agent section
/// without connecting to a relay. It is deliberately unavailable in release
/// builds and must be explicitly enabled when launching the debug app.
fn preview_activities() -> Option<Vec<TrayAgentActivity>> {
if !cfg!(debug_assertions) || std::env::var("BUZZ_TRAY_MENU_DEMO").ok().as_deref() != Some("1")
{
return None;
}
let preview_elapsed = PREVIEW_STARTED_AT.get_or_init(Instant::now).elapsed();
Some(vec![
TrayAgentActivity {
activity_id: "tray-preview-planning-scout".into(),
agent_name: "Scout".into(),
channel_id: "tray-preview-planning".into(),
channel_name: "planning".into(),
elapsed: format_elapsed(Duration::from_secs(192) + preview_elapsed),
},
TrayAgentActivity {
activity_id: "tray-preview-planning-builder".into(),
agent_name: "Builder".into(),
channel_id: "tray-preview-planning".into(),
channel_name: "planning".into(),
elapsed: format_elapsed(Duration::from_secs(68) + preview_elapsed),
},
TrayAgentActivity {
activity_id: "tray-preview-mobile-reviewer".into(),
agent_name: "Reviewer".into(),
channel_id: "tray-preview-mobile".into(),
channel_name: "mobile".into(),
elapsed: format_elapsed(Duration::from_secs(31) + preview_elapsed),
},
])
}
fn preview_recent_activities() -> Option<Vec<TrayAgentActivity>> {
if !cfg!(debug_assertions) || std::env::var("BUZZ_TRAY_MENU_DEMO").ok().as_deref() != Some("1")
{
return None;
}
Some(vec![TrayAgentActivity {
activity_id: "recent:tray-preview-design-architect".into(),
agent_name: "Architect".into(),
channel_id: "tray-preview-design".into(),
channel_name: "design".into(),
elapsed: "4m 25s".into(),
}])
}
fn format_elapsed(elapsed: Duration) -> String {
let total_seconds = elapsed.as_secs();
if total_seconds < 60 {
return format!("{total_seconds}s");
}
let seconds = total_seconds % 60;
let total_minutes = total_seconds / 60;
if total_minutes < 60 {
return format!("{total_minutes}m {seconds}s");
}
let minutes = total_minutes % 60;
let hours = total_minutes / 60;
format!("{hours}h {minutes}m {seconds}s")
}
/// Builds the standalone Buzz bee as a transparent, macOS template image.
///
/// The app icon includes a rounded square, which is useful for the Dock but
/// looks out of place beside the monochrome menu-bar icons. Keeping this
/// vector-derived mask here also lets macOS tint it correctly in light and
/// dark menu bars without a separate bitmap asset.
fn tray_bee_icon() -> Image<'static> {
const WIDTH: u32 = 64;
const HEIGHT: u32 = 43;
const SAMPLES_PER_AXIS: u32 = 4;
const BEE_WIDTH: f32 = 466.0;
const BEE_HEIGHT: f32 = 309.0;
fn circle_contains(x: f32, y: f32, center_x: f32, center_y: f32, radius: f32) -> bool {
let delta_x = x - center_x;
let delta_y = y - center_y;
delta_x * delta_x + delta_y * delta_y <= radius * radius
}
fn rounded_rect_contains(
x: f32,
y: f32,
left: f32,
top: f32,
width: f32,
height: f32,
radius: f32,
) -> bool {
let right = left + width;
let bottom = top + height;
let closest_x = x.clamp(left + radius, right - radius);
let closest_y = y.clamp(top + radius, bottom - radius);
let delta_x = x - closest_x;
let delta_y = y - closest_y;
delta_x * delta_x + delta_y * delta_y <= radius * radius
}
fn bee_contains(x: f32, y: f32) -> bool {
let silhouette = circle_contains(x, y, 91.7, 154.5, 91.7)
|| circle_contains(x, y, 374.3, 154.5, 91.7)
|| rounded_rect_contains(x, y, 128.0, 0.0, 210.0, 309.0, 34.0);
let cutout = circle_contains(x, y, 193.3, 84.4, 27.0)
|| circle_contains(x, y, 276.0, 84.4, 27.0)
|| rounded_rect_contains(x, y, 166.3, 157.2, 136.9, 38.3, 5.0)
|| rounded_rect_contains(x, y, 166.9, 235.1, 136.2, 37.6, 5.0);
silhouette && !cutout
}
let mut rgba = vec![0; (WIDTH * HEIGHT * 4) as usize];
let samples = SAMPLES_PER_AXIS * SAMPLES_PER_AXIS;
for pixel_y in 0..HEIGHT {
for pixel_x in 0..WIDTH {
let mut covered_samples = 0;
for sample_y in 0..SAMPLES_PER_AXIS {
for sample_x in 0..SAMPLES_PER_AXIS {
let x = (pixel_x as f32 + (sample_x as f32 + 0.5) / SAMPLES_PER_AXIS as f32)
/ WIDTH as f32
* BEE_WIDTH;
let y = (pixel_y as f32 + (sample_y as f32 + 0.5) / SAMPLES_PER_AXIS as f32)
/ HEIGHT as f32
* BEE_HEIGHT;
if bee_contains(x, y) {
covered_samples += 1;
}
}
}
let index = ((pixel_y * WIDTH + pixel_x) * 4) as usize;
rgba[index + 3] = (covered_samples * u8::MAX as u32 / samples) as u8;
}
}
Image::new_owned(rgba, WIDTH, HEIGHT)
}
/// A running agent and its current channel.
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TrayAgentActivity {
activity_id: String,
agent_name: String,
channel_id: String,
channel_name: String,
elapsed: String,
}
struct TrayActivityMenuItem<R: Runtime> {
activity_id: String,
channel_id: String,
agent_item: MenuItem<R>,
}
struct TrayActionQueue {
community_generation: u64,
pending_actions: Vec<TrayAction>,
}
struct TrayMenuState<R: Runtime> {
activity_items: Mutex<Vec<TrayActivityMenuItem<R>>>,
action_queue: Mutex<TrayActionQueue>,
}
#[derive(Debug, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase", tag = "kind")]
pub enum TrayAction {
NewChannel,
OpenChannel {
#[serde(rename = "channelId")]
channel_id: String,
#[serde(rename = "communityGeneration")]
community_generation: u64,
},
}
pub(crate) fn show_main_window<R: Runtime>(app: &AppHandle<R>) {
let Some(window) = app.get_webview_window("main") else {
return;
};
if let Err(error) = window.unminimize() {
eprintln!("buzz-desktop: failed to restore main window from tray: {error}");
return;
}
if let Err(error) = window.show() {
eprintln!("buzz-desktop: failed to show main window from tray: {error}");
return;
}
if let Err(error) = window.set_focus() {
eprintln!("buzz-desktop: failed to focus main window from tray: {error}");
}
}
fn queue_tray_action<R: Runtime>(app: &AppHandle<R>, mut action: TrayAction) {
let state = app.state::<TrayMenuState<R>>();
let Ok(mut queue) = state.action_queue.lock() else {
eprintln!("buzz-desktop: tray action queue is unavailable");
return;
};
if let TrayAction::OpenChannel {
community_generation,
..
} = &mut action
{
*community_generation = queue.community_generation;
}
queue.pending_actions.push(action);
drop(queue);
if let Err(error) = app.emit("tray-action-available", ()) {
eprintln!("buzz-desktop: failed to notify frontend of tray action: {error}");
}
}
fn append_separator<R: Runtime>(app: &AppHandle<R>, menu: &Menu<R>) -> tauri::Result<()> {
menu.append(&PredefinedMenuItem::separator(app)?)
}
fn agent_item_label(activity: &TrayAgentActivity) -> String {
let primary = format!("{} · {}", activity.agent_name, activity.elapsed);
#[cfg(target_os = "macos")]
{
if supports_menu_item_subtitles() {
primary
} else {
format!("{primary} — #{}", activity.channel_name)
}
}
#[cfg(not(target_os = "macos"))]
{
format!("{primary} — #{}", activity.channel_name)
}
}
#[cfg(target_os = "macos")]
fn supports_menu_item_subtitles() -> bool {
static SUPPORTS_SUBTITLES: OnceLock<bool> = OnceLock::new();
*SUPPORTS_SUBTITLES.get_or_init(|| {
NSProcessInfo::processInfo()
.operatingSystemVersion()
.majorVersion
>= 14
})
}
fn channel_item_id(activity: &TrayAgentActivity) -> String {
format!(
"{OPEN_CHANNEL_PREFIX}{}{OPEN_CHANNEL_ACTIVITY_SEPARATOR}{}",
activity.channel_id, activity.activity_id
)
}
fn build_menu<R: Runtime>(
app: &AppHandle<R>,
activities: &[TrayAgentActivity],
recent_activities: &[TrayAgentActivity],
) -> tauri::Result<(Menu<R>, Vec<TrayActivityMenuItem<R>>)> {
let menu = Menu::new(app)?;
let mut activity_items =
Vec::with_capacity(activities.len().saturating_add(recent_activities.len()));
let running = MenuItem::new(app, "Running", false, None::<&str>)?;
menu.append(&running)?;
if activities.is_empty() {
let empty = MenuItem::new(app, "No agents are running", false, None::<&str>)?;
menu.append(&empty)?;
} else {
append_activity_items(app, &menu, activities, &mut activity_items)?;
}
if !recent_activities.is_empty() {
append_separator(app, &menu)?;
let recent = MenuItem::new(app, "Recent", false, None::<&str>)?;
menu.append(&recent)?;
append_activity_items(app, &menu, recent_activities, &mut activity_items)?;
}
append_separator(app, &menu)?;
menu.append(&MenuItem::with_id(
app,
NEW_CHANNEL_ID,
"New Channel",
true,
None::<&str>,
)?)?;
append_separator(app, &menu)?;
menu.append(&MenuItem::with_id(
app,
OPEN_BUZZ_ID,
"Open Buzz",
true,
None::<&str>,
)?)?;
append_separator(app, &menu)?;
menu.append(&MenuItem::with_id(
app,
QUIT_ID,
"Quit Buzz",
true,
None::<&str>,
)?)?;
Ok((menu, activity_items))
}
fn append_activity_items<R: Runtime>(
app: &AppHandle<R>,
menu: &Menu<R>,
activities: &[TrayAgentActivity],
activity_items: &mut Vec<TrayActivityMenuItem<R>>,
) -> tauri::Result<()> {
for activity in activities {
let agent_item = MenuItem::with_id(
app,
channel_item_id(activity),
agent_item_label(activity),
true,
None::<&str>,
)?;
menu.append(&agent_item)?;
activity_items.push(TrayActivityMenuItem {
activity_id: activity.activity_id.clone(),
channel_id: activity.channel_id.clone(),
agent_item,
});
}
Ok(())
}
#[cfg(target_os = "macos")]
fn apply_activity_presentation<R: Runtime>(
tray: &TrayIcon<R>,
activities: &[TrayAgentActivity],
recent_activities: &[TrayAgentActivity],
) -> Result<(), String> {
if !supports_menu_item_subtitles() {
return Ok(());
}
let subtitles = activities
.iter()
.chain(recent_activities)
.map(|activity| format!("#{}", activity.channel_name))
.collect::<Vec<_>>();
let running_count = activities.len();
let recent_count = recent_activities.len();
tray.with_inner_tray_icon(move |inner| {
let Some(status_item) = inner.ns_status_item() else {
return;
};
let Some(main_thread) = MainThreadMarker::new() else {
return;
};
let Some(menu) = status_item.menu(main_thread) else {
return;
};
menu.setMinimumWidth(TRAY_MENU_MINIMUM_WIDTH);
let mut item_index = 1;
for subtitle in subtitles.iter().take(running_count) {
if let Some(item) = menu.itemAtIndex(item_index) {
let subtitle = NSString::from_str(subtitle);
item.setSubtitle(Some(&subtitle));
}
item_index += 1;
}
if running_count == 0 {
item_index += 1;
}
if recent_count > 0 {
// The separator and Recent heading precede the completed rows.
item_index += 2;
for subtitle in subtitles.iter().skip(running_count) {
if let Some(item) = menu.itemAtIndex(item_index) {
let subtitle = NSString::from_str(subtitle);
item.setSubtitle(Some(&subtitle));
}
item_index += 1;
}
}
})
.map_err(|error| error.to_string())
}
#[cfg(not(target_os = "macos"))]
fn apply_activity_presentation<R: Runtime>(
_tray: &TrayIcon<R>,
_activities: &[TrayAgentActivity],
_recent_activities: &[TrayAgentActivity],
) -> Result<(), String> {
Ok(())
}
fn handle_menu_event<R: Runtime>(app: &AppHandle<R>, id: &str) {
match id {
OPEN_BUZZ_ID => show_main_window(app),
NEW_CHANNEL_ID => {
show_main_window(app);
queue_tray_action(app, TrayAction::NewChannel);
}
QUIT_ID => app.exit(0),
_ => {
let Some(channel_id) = id.strip_prefix(OPEN_CHANNEL_PREFIX) else {
return;
};
show_main_window(app);
let channel_id = channel_id
.split_once(OPEN_CHANNEL_ACTIVITY_SEPARATOR)
.map(|(channel_id, _)| channel_id)
.unwrap_or(channel_id);
queue_tray_action(
app,
TrayAction::OpenChannel {
channel_id: channel_id.into(),
community_generation: 0,
},
);
}
}
}
/// Installs the persistent Buzz tray icon with the initial empty activity menu.
pub fn init<R: Runtime>(app: &AppHandle<R>) -> tauri::Result<()> {
let preview_activities = preview_activities();
let preview_recent_activities = preview_recent_activities();
let activities = preview_activities.as_deref().unwrap_or(&[]);
let recent_activities = preview_recent_activities.as_deref().unwrap_or(&[]);
let (menu, activity_items) = build_menu(app, activities, recent_activities)?;
app.manage(TrayMenuState {
activity_items: Mutex::new(activity_items),
action_queue: Mutex::new(TrayActionQueue {
community_generation: 0,
pending_actions: Vec::new(),
}),
});
let tray = TrayIconBuilder::with_id(TRAY_ID)
.menu(&menu)
.icon(tray_bee_icon())
.icon_as_template(true)
.on_menu_event(|app, event| handle_menu_event(app, event.id.as_ref()))
.build(app)?;
if let Err(error) = apply_activity_presentation(&tray, activities, recent_activities) {
eprintln!("buzz-desktop: failed to apply tray menu presentation: {error}");
}
mouse_nav::init(app);
Ok(())
}
/// Drains actions selected from the tray while the frontend was unavailable.
#[tauri::command]
pub fn take_tray_actions<R: Runtime>(app: AppHandle<R>) -> Result<Vec<TrayAction>, String> {
let state = app.state::<TrayMenuState<R>>();
let mut queue = state
.action_queue
.lock()
.map_err(|_| "Buzz tray action queue is unavailable".to_string())?;
Ok(std::mem::take(&mut queue.pending_actions))
}
fn requeue_actions(queue: &mut TrayActionQueue, mut actions: Vec<TrayAction>) {
actions.retain(|action| match action {
TrayAction::NewChannel => true,
TrayAction::OpenChannel {
community_generation,
..
} => *community_generation == queue.community_generation,
});
actions.append(&mut queue.pending_actions);
queue.pending_actions = actions;
}
/// Restores actions that were drained as the frontend unmounted. Channel
/// actions from a previous community generation are discarded.
#[tauri::command]
pub fn requeue_tray_actions<R: Runtime>(
app: AppHandle<R>,
actions: Vec<TrayAction>,
) -> Result<(), String> {
let state = app.state::<TrayMenuState<R>>();
let mut queue = state
.action_queue
.lock()
.map_err(|_| "Buzz tray action queue is unavailable".to_string())?;
requeue_actions(&mut queue, actions);
drop(queue);
app.emit("tray-action-available", ())
.map_err(|error| error.to_string())
}
/// Clears community-scoped agent activity and queued channel navigation from
/// the native tray menu.
#[tauri::command]
pub fn clear_tray_agent_activity<R: Runtime>(app: AppHandle<R>) -> Result<(), String> {
let state = app.state::<TrayMenuState<R>>();
let mut queue = state
.action_queue
.lock()
.map_err(|_| "Buzz tray action queue is unavailable".to_string())?;
queue.community_generation = queue.community_generation.wrapping_add(1);
queue
.pending_actions
.retain(|action| matches!(action, TrayAction::NewChannel));
drop(queue);
update_tray_agent_activity(app, Vec::new(), Vec::new())
}
/// Replaces the native menu's activity section with the current live work.
#[tauri::command]
pub fn update_tray_agent_activity<R: Runtime>(
app: AppHandle<R>,
activities: Vec<TrayAgentActivity>,
recent_activities: Vec<TrayAgentActivity>,
) -> Result<(), String> {
let preview_activities = preview_activities();
let preview_recent_activities = preview_recent_activities();
let activities = preview_activities.as_deref().unwrap_or(&activities);
let recent_activities = preview_recent_activities
.as_deref()
.unwrap_or(&recent_activities);
let state = app.state::<TrayMenuState<R>>();
let mut activity_items = state
.activity_items
.lock()
.map_err(|_| "Buzz tray menu state is unavailable".to_string())?;
if activity_items.len() == activities.len().saturating_add(recent_activities.len())
&& activity_items
.iter()
.zip(activities.iter().chain(recent_activities))
.all(|(item, activity)| {
item.activity_id == activity.activity_id && item.channel_id == activity.channel_id
})
{
for (item, activity) in activity_items
.iter()
.zip(activities.iter().chain(recent_activities))
{
item.agent_item
.set_text(agent_item_label(activity))
.map_err(|error| error.to_string())?;
}
let tray = app
.tray_by_id(TRAY_ID)
.ok_or_else(|| "Buzz tray icon is not available".to_string())?;
apply_activity_presentation(&tray, activities, recent_activities)?;
return Ok(());
}
let (menu, next_activity_items) =
build_menu(&app, activities, recent_activities).map_err(|error| error.to_string())?;
let tray = app
.tray_by_id(TRAY_ID)
.ok_or_else(|| "Buzz tray icon is not available".to_string())?;
tray.set_menu(Some(menu))
.map_err(|error| error.to_string())?;
apply_activity_presentation(&tray, activities, recent_activities)?;
*activity_items = next_activity_items;
Ok(())
}
#[cfg(test)]
mod tests {
use super::{requeue_actions, TrayAction, TrayActionQueue};
#[test]
fn open_channel_action_serializes_with_frontend_field_names() {
let action = TrayAction::OpenChannel {
channel_id: "channel-123".into(),
community_generation: 7,
};
assert_eq!(
serde_json::to_value(action).expect("tray action should serialize"),
serde_json::json!({
"kind": "openChannel",
"channelId": "channel-123",
"communityGeneration": 7,
})
);
}
#[test]
fn stale_channel_actions_are_not_requeued_after_community_change() {
let mut queue = TrayActionQueue {
community_generation: 2,
pending_actions: Vec::new(),
};
requeue_actions(
&mut queue,
vec![TrayAction::OpenChannel {
channel_id: "old-channel".into(),
community_generation: 1,
}],
);
assert!(queue.pending_actions.is_empty());
}
#[test]
fn new_channel_actions_survive_community_change() {
let mut queue = TrayActionQueue {
community_generation: 2,
pending_actions: Vec::new(),
};
requeue_actions(&mut queue, vec![TrayAction::NewChannel]);
assert_eq!(queue.pending_actions, vec![TrayAction::NewChannel]);
}
}