use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use serde::Deserialize;
use tauri::{AppHandle, Manager, Runtime, UserAttentionType};
use crate::error::{Error, Result};
use crate::hub::ENGINE_LABEL;
use crate::Shared;
pub mod commands;
#[cfg(any(target_os = "windows", test))]
mod icon;
#[cfg(target_os = "linux")]
mod linux;
#[cfg(target_os = "macos")]
mod macos;
#[cfg(target_os = "windows")]
mod windows;
#[derive(Default)]
pub struct BadgeState {
count: Mutex<Option<u64>>,
generation: AtomicU64,
#[cfg(feature = "tray")]
blink: Mutex<Option<Blink>>,
}
#[cfg(feature = "tray")]
struct Blink {
tray_id: String,
restore: tauri::image::Image<'static>,
}
#[derive(Clone, Debug, Deserialize)]
#[cfg_attr(not(feature = "tray"), allow(dead_code))]
pub struct TrayBlink {
pub tray_id: String,
pub icons: [String; 2],
pub interval_ms: Option<u64>,
}
pub(crate) async fn set<R: Runtime>(app: &AppHandle<R>, shared: &Shared, count: Option<u64>) {
let count = count.filter(|c| *c > 0);
*shared.badge.count.lock().unwrap() = count;
#[cfg(target_os = "macos")]
macos::apply(app, count);
#[cfg(target_os = "windows")]
windows::apply(app, count, None);
#[cfg(target_os = "linux")]
linux::apply(&crate::system::desktop_entry(app), count).await;
}
pub fn window_gone<R: Runtime>(_app: &AppHandle<R>, _shared: &Shared, _label: &str) {}
pub fn window_focused<R: Runtime>(app: &AppHandle<R>, shared: &Shared, label: &str) {
stop_blink(app, shared);
#[cfg(target_os = "windows")]
{
let count = *shared.badge.count.lock().unwrap();
if count.is_some() {
windows::apply(app, count, Some(label));
}
}
#[cfg(not(target_os = "windows"))]
let _ = label;
}
pub(crate) fn attention<R: Runtime>(app: &AppHandle<R>, shared: &Shared, critical: bool, label: Option<&str>) {
let kind = if critical { UserAttentionType::Critical } else { UserAttentionType::Informational };
let windows = app.webview_windows();
let target = label
.filter(|l| *l != ENGINE_LABEL)
.and_then(|l| windows.get(l))
.or_else(|| windows.get(&shared.config.main_window))
.or_else(|| windows.iter().find(|(l, w)| l.as_str() != ENGINE_LABEL && w.is_visible().unwrap_or(false)).map(|(_, w)| w));
#[cfg(target_os = "macos")]
let target = target.or_else(|| windows.values().next());
match target {
Some(window) => {
if let Err(err) = window.request_user_attention(Some(kind)) {
log::debug!("请求引起注意失败:{err}");
}
}
None => log::debug!("没有可以引起注意的窗口"),
}
}
pub(crate) fn tray_blink<R: Runtime>(app: &AppHandle<R>, shared: &Arc<Shared>, blink: Option<TrayBlink>) -> Result<()> {
stop_blink(app, shared);
let Some(blink) = blink else { return Ok(()) };
start_blink(app, shared, blink)
}
#[cfg(feature = "tray")]
fn start_blink<R: Runtime>(app: &AppHandle<R>, shared: &Arc<Shared>, blink: TrayBlink) -> Result<()> {
use std::time::Duration;
let dir = app.path().resource_dir()?;
let first = load_icon(&dir, &blink.icons[0])?;
let second = load_icon(&dir, &blink.icons[1])?;
let tray = app.tray_by_id(blink.tray_id.as_str()).ok_or_else(|| Error::validation("tray_not_found", format!("没有 ID 为 {} 的托盘图标", blink.tray_id)))?;
let interval = Duration::from_millis(blink.interval_ms.unwrap_or(500).clamp(200, 5000));
let generation = {
let mut slot = shared.badge.blink.lock().unwrap();
*slot = Some(Blink { tray_id: blink.tray_id.clone(), restore: first.clone() });
shared.badge.generation.fetch_add(1, Ordering::SeqCst) + 1
};
let shared = shared.clone();
tauri::async_runtime::spawn(async move {
let mut lit = false;
loop {
tokio::time::sleep(interval).await;
if shared.badge.generation.load(Ordering::SeqCst) != generation {
break;
}
lit = !lit;
let _ = tray.set_icon(Some(if lit { second.clone() } else { first.clone() }));
if shared.badge.generation.load(Ordering::SeqCst) != generation {
let _ = tray.set_icon(Some(first.clone()));
break;
}
}
});
Ok(())
}
#[cfg(not(feature = "tray"))]
fn start_blink<R: Runtime>(_app: &AppHandle<R>, _shared: &Arc<Shared>, _blink: TrayBlink) -> Result<()> {
Err(Error::unsupported("tray_feature_disabled"))
}
fn stop_blink<R: Runtime>(app: &AppHandle<R>, shared: &Shared) {
shared.badge.generation.fetch_add(1, Ordering::SeqCst);
#[cfg(feature = "tray")]
{
let blink = shared.badge.blink.lock().unwrap().take();
if let Some(blink) = blink {
if let Some(tray) = app.tray_by_id(blink.tray_id.as_str()) {
let _ = tray.set_icon(Some(blink.restore));
}
}
}
#[cfg(not(feature = "tray"))]
let _ = app;
}
#[cfg(feature = "tray")]
fn load_icon(dir: &std::path::Path, relative: &str) -> Result<tauri::image::Image<'static>> {
if !is_resource_path(relative) {
return Err(Error::validation("invalid_path", format!("托盘图标必须是应用资源中的相对路径:{relative}")));
}
let path = dir.join(relative);
if !path.is_file() {
return Err(Error::validation("file_not_found", format!("找不到托盘图标 {}", path.display())));
}
tauri::image::Image::from_path(&path).map_err(|e| Error::validation("file_unreadable", format!("托盘图标 {} 读取失败:{e}", path.display())))
}
#[cfg_attr(not(feature = "tray"), allow(dead_code))]
pub(crate) fn is_resource_path(relative: &str) -> bool {
use std::path::Component;
let path = std::path::Path::new(relative);
!relative.is_empty() && path.components().all(|c| matches!(c, Component::Normal(_) | Component::CurDir))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn resource_paths() {
assert!(is_resource_path("icons/tray.png"));
assert!(is_resource_path("./icons/tray-unread.png"));
assert!(!is_resource_path("../secret.png"));
assert!(!is_resource_path("icons/../../x.png"));
assert!(!is_resource_path("/etc/passwd"));
assert!(!is_resource_path(""));
}
#[test]
fn tray_blink_args() {
let blink: TrayBlink = serde_json::from_value(serde_json::json!({ "tray_id": "main", "icons": ["a.png", "b.png"], "interval_ms": 600 })).unwrap();
assert_eq!(blink.icons[1], "b.png");
assert_eq!(blink.interval_ms, Some(600));
let blink: TrayBlink = serde_json::from_value(serde_json::json!({ "tray_id": "main", "icons": ["a.png", "b.png"], "interval_ms": null })).unwrap();
assert_eq!(blink.interval_ms, None);
}
}