Download
173 lines 5.9 KiBRustModified 2026-08-01 raw
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173
//! System tray host via the StatusNotifierItem D-Bus protocol. divebar
//! registers as the StatusNotifierWatcher/Host; apps publish their tray
//! items over D-Bus and we forward them to the frontend as
//! `divebar://tray` events (icons as data URIs). Clicks come back through
//! the tray_activate command. Legacy XEmbed-only tray apps are not
//! supported (they can't be rendered inside a webview).

use base64::Engine;
use serde_json::{json, Value};
use std::sync::{Mutex, OnceLock};
use system_tray::client::{ActivateRequest, Client};
use system_tray::data::BaseMap;
use system_tray::item::StatusNotifierItem;
use system_tray::menu::{MenuItem, MenuType, ToggleState, ToggleType, TrayMenu};
use tauri::AppHandle;

static CLIENT: OnceLock<Client> = OnceLock::new();

pub fn spawn_tray_host(app: AppHandle) {
    tauri::async_runtime::spawn(async move {
        let client = match Client::new().await {
            Ok(client) => client,
            Err(err) => {
                eprintln!("divebar: tray host failed to start: {err}");
                return;
            }
        };
        let mut rx = client.subscribe();
        let items = client.items();
        let _ = CLIENT.set(client);

        emit(&app, &items);
        loop {
            use tokio::sync::broadcast::error::RecvError;
            match rx.recv().await {
                // Any event (add/update/remove) → rebuild the whole payload;
                // item counts are tiny so diffing isn't worth it.
                Ok(_) | Err(RecvError::Lagged(_)) => emit(&app, &items),
                Err(RecvError::Closed) => break,
            }
        }
    });
}

pub async fn activate(address: String, secondary: bool, x: i32, y: i32) -> Result<(), String> {
    let client = CLIENT.get().ok_or("tray host not running")?;
    let request = if secondary {
        ActivateRequest::Secondary { address, x, y }
    } else {
        ActivateRequest::Default { address, x, y }
    };
    client.activate(request).await.map_err(|e| e.to_string())
}

pub async fn menu_click(address: String, menu_path: String, id: i32) -> Result<(), String> {
    let client = CLIENT.get().ok_or("tray host not running")?;
    client
        .activate(ActivateRequest::MenuItem {
            address,
            menu_path,
            submenu_id: id,
        })
        .await
        .map_err(|e| e.to_string())
}

fn emit(app: &AppHandle, items: &std::sync::Arc<Mutex<BaseMap>>) {
    let payload = build_payload(items);
    crate::events::emit(app, "tray", payload);
}

fn build_payload(items: &std::sync::Arc<Mutex<BaseMap>>) -> Value {
    let map = items.lock().unwrap();
    let mut entries: Vec<(String, Value)> = map
        .iter()
        .map(|(address, (item, menu))| {
            (
                item.id.clone(),
                json!({
                    "address": address,
                    "id": item.id,
                    "title": item.title,
                    "icon": icon_data_uri(item),
                    "status": format!("{:?}", item.status),
                    "menuPath": item.menu,
                    "menu": serialize_menu(menu.as_ref()),
                }),
            )
        })
        .collect();
    // Sort by app id for a stable display order across updates.
    entries.sort_by(|a, b| a.0.cmp(&b.0));
    Value::Array(entries.into_iter().map(|(_, v)| v).collect())
}

fn serialize_menu(menu: Option<&TrayMenu>) -> Value {
    match menu {
        Some(m) => Value::Array(m.submenus.iter().filter_map(serialize_menu_item).collect()),
        None => Value::Null,
    }
}

fn serialize_menu_item(item: &MenuItem) -> Option<Value> {
    if !item.visible {
        return None;
    }
    if matches!(item.menu_type, MenuType::Separator) {
        return Some(json!({ "type": "separator" }));
    }
    let checked = if matches!(item.toggle_type, ToggleType::CannotBeToggled) {
        Value::Null
    } else {
        match item.toggle_state {
            ToggleState::On => Value::Bool(true),
            ToggleState::Off => Value::Bool(false),
            ToggleState::Indeterminate => Value::Null,
        }
    };
    Some(json!({
        "type": "item",
        "id": item.id,
        "label": item.label,
        "enabled": item.enabled,
        "checked": checked,
        "children": item.submenu.iter().filter_map(serialize_menu_item).collect::<Vec<_>>(),
    }))
}

fn icon_data_uri(item: &StatusNotifierItem) -> Option<String> {
    // Prefer pixmaps sent over the bus; fall back to icon theme lookup.
    if let Some(pixmaps) = &item.icon_pixmap {
        if let Some(best) = pixmaps
            .iter()
            .filter(|p| p.width > 0 && p.height > 0)
            .min_by_key(|p| (p.width.max(p.height) - 32).abs())
        {
            // SNI pixmaps are ARGB32 in network byte order.
            let mut rgba = Vec::with_capacity(best.pixels.len());
            for px in best.pixels.chunks_exact(4) {
                rgba.extend_from_slice(&[px[1], px[2], px[3], px[0]]);
            }
            return crate::xicon::encode_png_data_uri(best.width as u32, best.height as u32, &rgba);
        }
    }

    let name = item.icon_name.as_deref().filter(|n| !n.is_empty())?;

    // The item may ship its own icon directory.
    if let Some(dir) = item.icon_theme_path.as_deref().filter(|p| !p.is_empty()) {
        for ext in ["png", "svg"] {
            let path = std::path::Path::new(dir).join(format!("{name}.{ext}"));
            if let Some(uri) = file_data_uri(&path) {
                return Some(uri);
            }
        }
    }

    let path = crate::icons::lookup(name, 48)?;
    file_data_uri(&path)
}

fn file_data_uri(path: &std::path::Path) -> Option<String> {
    let mime = match path.extension()?.to_str()? {
        "png" => "image/png",
        "svg" => "image/svg+xml",
        _ => return None,
    };
    let bytes = std::fs::read(path).ok()?;
    Some(format!(
        "data:{mime};base64,{}",
        base64::engine::general_purpose::STANDARD.encode(&bytes)
    ))
}