tauri-plugin-deeprespond-im 1.0.0-alpha

深应智能 IM 的 Tauri 插件:本地存储、安全存储、窗口间的锁与消息、系统事件、通知、角标、文件
//! 角标、引起注意、托盘闪烁(《2.功能概述文档》第 8.4 节,《4.实现与测试》第 3.5 节)。
//!
//! - 角标:macOS 程序坞图标的数字(Tauri 的 set_badge_count,属于整个应用);Windows 任务栏按钮的叠加图标
//!   (插件生成数字图标,作用于每个界面窗口,新窗口获得焦点时补上);Linux 的 Unity 启动器接口(D-Bus 信号)。
//! - 引起注意:Tauri 的 request_user_attention,对象为指定的窗口、主窗口或任意可见的界面窗口。
//! - 托盘闪烁:在 App 的托盘图标上轮换两个图标,任一界面窗口获得焦点时停止并恢复第一个图标。

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 {
    /// 当前的角标数(None 表示不显示):Windows 上新获得焦点的窗口据此补上叠加图标。
    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>,
}

/// tray_blink 的参数。
#[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>,
}

/// 设置角标;0 和 None 都表示不显示。
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) {}

/// 界面窗口获得焦点:停止托盘闪烁;Windows 上给这个窗口补上叠加图标(新建的窗口没有)。
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;
}

/// 引起注意。macOS 的程序坞图标属于整个应用,没有界面窗口时借用引擎窗口;Windows 的闪烁要依附在有任务栏按钮的窗口上。
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!("没有可以引起注意的窗口"),
    }
}

/// 开始(或换成新的)托盘闪烁;None 停止。
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);
    }
}