tauri-plugin-deeprespond-im 1.0.0-alpha

深应智能 IM 的 Tauri 插件:本地存储、安全存储、窗口间的锁与消息、系统事件、通知、角标、文件
//! Linux 的系统事件:logind(D-Bus 系统总线)的 PrepareForSleep、PrepareForShutdown 和延迟型抑制锁,
//! 当前会话的 Lock、Unlock 与 LockedHint,以及屏幕保护程序(会话总线)的 ActiveChanged。
//! 没有 logind 或会话总线的环境(容器、最小化安装)只靠通用的时钟和网卡检测。

use std::collections::HashMap;
use std::net::IpAddr;
use std::sync::{Arc, OnceLock};
use std::time::Duration;

use futures_util::StreamExt;
use tauri::{AppHandle, Runtime};
use zbus::message::Type as MessageType;
use zbus::zvariant::{OwnedFd, OwnedObjectPath, OwnedValue};
use zbus::{Connection, MatchRule, MessageStream};

use crate::Shared;

const LOGIND: &str = "org.freedesktop.login1";
const LOGIND_PATH: &str = "/org/freedesktop/login1";
const MANAGER: &str = "org.freedesktop.login1.Manager";
const SESSION: &str = "org.freedesktop.login1.Session";
const PROPERTIES: &str = "org.freedesktop.DBus.Properties";
/// 即将休眠时持有抑制锁的时间:页面收到 sleep 事件就够了,引擎没有要等的收尾。
const SLEEP_HOLD: Duration = Duration::from_secs(1);
/// 关机时持有抑制锁的时间:引擎收尾最多 2 秒,多留一点余量;进程退出时锁自动释放。
const SHUTDOWN_HOLD: Duration = Duration::from_millis(2500);

/// 会话总线的连接(通知、启动器角标、屏幕保护程序共用),第一次使用时建立;连不上的记为 None,不再重试。
pub(crate) async fn session_bus() -> Option<Connection> {
    static SESSION_BUS: tokio::sync::OnceCell<Option<Connection>> = tokio::sync::OnceCell::const_new();
    SESSION_BUS
        .get_or_init(|| async {
            match Connection::session().await {
                Ok(conn) => Some(conn),
                Err(err) => {
                    log::warn!("连接 D-Bus 会话总线失败,系统通知、启动器角标不可用:{err}");
                    None
                }
            }
        })
        .await
        .clone()
}

/// 应用的 .desktop 文件名(不含扩展名):通知的 desktop-entry 提示和 Unity 启动器角标都要与它一致。
/// 依次查找以 identifier、productName、可执行文件名命名的 .desktop 文件,都没有的用 identifier
/// (接入说明要求打包时按 identifier 命名,或保证与其中之一相同)。
pub(crate) fn desktop_entry<R: Runtime>(app: &AppHandle<R>) -> String {
    static ENTRY: OnceLock<String> = OnceLock::new();
    ENTRY
        .get_or_init(|| {
            let identifier = app.config().identifier.clone();
            let mut candidates = vec![identifier.clone(), app.package_info().name.clone()];
            if let Some(stem) = std::env::current_exe().ok().and_then(|p| p.file_stem().map(|s| s.to_string_lossy().into_owned())) {
                candidates.push(stem);
            }
            let dirs = application_dirs();
            pick_desktop_entry(&identifier, &candidates, |name| dirs.iter().any(|d| d.join(format!("{name}.desktop")).is_file()))
        })
        .clone()
}

pub(crate) fn pick_desktop_entry(identifier: &str, candidates: &[String], exists: impl Fn(&str) -> bool) -> String {
    candidates.iter().find(|c| !c.is_empty() && exists(c)).cloned().unwrap_or_else(|| identifier.to_string())
}

/// XDG 的 applications 目录(含 Flatpak 导出的)。
fn application_dirs() -> Vec<std::path::PathBuf> {
    let home = std::env::var_os("HOME").map(std::path::PathBuf::from);
    let data_home = std::env::var_os("XDG_DATA_HOME").map(std::path::PathBuf::from).or_else(|| home.as_ref().map(|h| h.join(".local/share")));
    let data_dirs = std::env::var("XDG_DATA_DIRS").unwrap_or_else(|_| "/usr/local/share:/usr/share".into());
    let mut dirs: Vec<_> = data_home.into_iter().collect();
    dirs.extend(data_dirs.split(':').filter(|d| !d.is_empty()).map(std::path::PathBuf::from));
    dirs.push("/var/lib/flatpak/exports/share".into());
    if let Some(h) = &home {
        dirs.push(h.join(".local/share/flatpak/exports/share"));
    }
    dirs.into_iter().map(|d| d.join("applications")).collect()
}

pub(super) fn start<R: Runtime>(app: &AppHandle<R>, shared: &Arc<Shared>) {
    let (handle, state) = (app.clone(), shared.clone());
    tauri::async_runtime::spawn(async move {
        let conn = match Connection::system().await {
            Ok(conn) => conn,
            Err(err) => {
                state.logs.plugin("warn", &format!("连接 D-Bus 系统总线失败,休眠、关机、锁屏只靠通用办法:{err}"));
                return;
            }
        };
        tokio::join!(watch_manager(&handle, &state, &conn), watch_session(&handle, &state, &conn));
    });
    let (handle, state) = (app.clone(), shared.clone());
    tauri::async_runtime::spawn(async move {
        if let Some(conn) = session_bus().await {
            watch_screensaver(&handle, &state, &conn).await;
        }
    });
}

async fn signal_stream(conn: &Connection, sender: Option<&str>, path: Option<&str>, interface: Option<&str>, member: Option<&str>) -> zbus::Result<MessageStream> {
    let mut rule = MatchRule::builder().msg_type(MessageType::Signal);
    if let Some(sender) = sender {
        rule = rule.sender(sender)?;
    }
    if let Some(path) = path {
        rule = rule.path(path)?;
    }
    if let Some(interface) = interface {
        rule = rule.interface(interface)?;
    }
    if let Some(member) = member {
        rule = rule.member(member)?;
    }
    MessageStream::for_match_rule(rule.build(), conn, None).await
}

/// 延迟型抑制锁:系统休眠、关机前等我们释放(最多 InhibitDelayMaxSec,默认 5 秒)。
async fn inhibit(conn: &Connection, shared: &Shared) -> Option<OwnedFd> {
    let reply = conn
        .call_method(Some(LOGIND), LOGIND_PATH, Some(MANAGER), "Inhibit", &("shutdown:sleep", shared.identifier.as_str(), "退出前保存数据、关闭连接", "delay"))
        .await;
    match reply.and_then(|m| m.body().deserialize::<OwnedFd>()) {
        Ok(fd) => Some(fd),
        Err(err) => {
            shared.logs.plugin("warn", &format!("logind 的延迟型抑制锁取不到,关机时引擎可能来不及收尾:{err}"));
            None
        }
    }
}

/// 过一会儿再释放抑制锁(关闭文件描述符)。
fn release_later(fd: Option<OwnedFd>, after: Duration) {
    if let Some(fd) = fd {
        tauri::async_runtime::spawn(async move {
            tokio::time::sleep(after).await;
            drop(fd);
        });
    }
}

async fn watch_manager<R: Runtime>(app: &AppHandle<R>, shared: &Arc<Shared>, conn: &Connection) {
    let mut stream = match signal_stream(conn, Some(LOGIND), Some(LOGIND_PATH), Some(MANAGER), None).await {
        Ok(stream) => stream,
        Err(err) => {
            shared.logs.plugin("warn", &format!("订阅 logind 的信号失败:{err}"));
            return;
        }
    };
    let mut lock = inhibit(conn, shared).await;
    while let Some(msg) = stream.next().await {
        let Ok(msg) = msg else { continue };
        let header = msg.header();
        let Some(member) = header.member() else { continue };
        let Ok(start) = msg.body().deserialize::<bool>() else { continue };
        match (member.as_str(), start) {
            ("PrepareForSleep", true) => {
                super::on_sleep(shared, "logind");
                release_later(lock.take(), SLEEP_HOLD);
            }
            ("PrepareForSleep", false) => {
                super::on_resume(shared, None, "logind");
                // 抑制锁只管一次:为下一次休眠重新申请
                if lock.is_none() {
                    lock = inhibit(conn, shared).await;
                }
            }
            ("PrepareForShutdown", true) => {
                super::on_shutdown(app, shared, "logind");
                release_later(lock.take(), SHUTDOWN_HOLD);
            }
            // 关机被取消(较新的 systemd 会发出)
            ("PrepareForShutdown", false) if lock.is_none() => lock = inhibit(conn, shared).await,
            _ => {}
        }
    }
}

/// 本进程所在的会话:先按进程号找,找不到(如由 systemd 用户服务启动)用 XDG_SESSION_ID,再用 logind 的 auto。
async fn session_path(conn: &Connection) -> Option<OwnedObjectPath> {
    let by_pid = conn.call_method(Some(LOGIND), LOGIND_PATH, Some(MANAGER), "GetSessionByPID", &(std::process::id(),)).await;
    if let Ok(path) = by_pid.and_then(|m| m.body().deserialize::<OwnedObjectPath>()) {
        return Some(path);
    }
    let id = std::env::var("XDG_SESSION_ID").ok().filter(|s| !s.is_empty()).unwrap_or_else(|| "auto".into());
    let reply = conn.call_method(Some(LOGIND), LOGIND_PATH, Some(MANAGER), "GetSession", &(id.as_str(),)).await;
    reply.and_then(|m| m.body().deserialize::<OwnedObjectPath>()).ok()
}

async fn watch_session<R: Runtime>(app: &AppHandle<R>, shared: &Arc<Shared>, conn: &Connection) {
    let Some(path) = session_path(conn).await else {
        shared.logs.plugin("info", "找不到本进程的 logind 会话,锁屏只看屏幕保护程序");
        return;
    };
    let mut stream = match signal_stream(conn, Some(LOGIND), Some(path.as_str()), None, None).await {
        Ok(stream) => stream,
        Err(err) => {
            shared.logs.plugin("warn", &format!("订阅 logind 会话的信号失败:{err}"));
            return;
        }
    };
    // 启动时已经锁屏(例如开机自启后还没解锁)
    let initial = conn.call_method(Some(LOGIND), path.as_str(), Some(PROPERTIES), "Get", &(SESSION, "LockedHint")).await;
    if let Ok(Ok(true)) = initial.and_then(|m| m.body().deserialize::<OwnedValue>()).map(|v| v.downcast_ref::<bool>()) {
        super::on_lock(app, shared, true, "logind");
    }
    while let Some(msg) = stream.next().await {
        let Ok(msg) = msg else { continue };
        let header = msg.header();
        let Some(member) = header.member() else { continue };
        match member.as_str() {
            // Lock、Unlock 是 loginctl lock-session 等发出的“请锁屏/解锁”,桌面随之锁屏
            "Lock" => super::on_lock(app, shared, true, "logind"),
            "Unlock" => super::on_lock(app, shared, false, "logind"),
            // 用户输入密码解锁不发 Unlock,只改 LockedHint
            "PropertiesChanged" => {
                let Ok((interface, changed, _)) = msg.body().deserialize::<(String, HashMap<String, OwnedValue>, Vec<String>)>() else { continue };
                if interface != SESSION {
                    continue;
                }
                if let Some(Ok(locked)) = changed.get("LockedHint").map(|v| v.downcast_ref::<bool>()) {
                    super::on_lock(app, shared, locked, "logind");
                }
            }
            _ => {}
        }
    }
}

/// 屏幕保护程序:GNOME 的 org.gnome.ScreenSaver、KDE 等的 org.freedesktop.ScreenSaver。
async fn watch_screensaver<R: Runtime>(app: &AppHandle<R>, shared: &Arc<Shared>, conn: &Connection) {
    let gnome = signal_stream(conn, None, None, Some("org.gnome.ScreenSaver"), Some("ActiveChanged")).await;
    let freedesktop = signal_stream(conn, None, None, Some("org.freedesktop.ScreenSaver"), Some("ActiveChanged")).await;
    let (Ok(gnome), Ok(freedesktop)) = (gnome, freedesktop) else {
        shared.logs.plugin("warn", "订阅屏幕保护程序的信号失败");
        return;
    };
    let mut merged = futures_util::stream::select(gnome, freedesktop);
    while let Some(msg) = merged.next().await {
        let Ok(msg) = msg else { continue };
        if let Ok(active) = msg.body().deserialize::<bool>() {
            super::on_lock(app, shared, active, "屏幕保护程序");
        }
    }
}

pub(super) fn interface_addrs() -> Vec<(String, IpAddr)> {
    super::unix_interface_addrs()
}

/// 默认路由(IPv4 /proc/net/route、IPv6 /proc/net/ipv6_route):换网关、连上 VPN 时会变。
pub(super) fn default_routes() -> Vec<String> {
    let v4 = std::fs::read_to_string("/proc/net/route").unwrap_or_default();
    let v6 = std::fs::read_to_string("/proc/net/ipv6_route").unwrap_or_default();
    parse_default_routes(&v4, &v6)
}

pub(crate) fn parse_default_routes(v4: &str, v6: &str) -> Vec<String> {
    let mut out = Vec::new();
    for line in v4.lines().skip(1) {
        let cols: Vec<&str> = line.split_whitespace().collect();
        // Iface Destination Gateway Flags RefCnt Use Metric Mask ...
        if cols.len() >= 8 && cols[1] == "00000000" && cols[7] == "00000000" {
            out.push(format!("route4|{}|{}", cols[0], cols[2]));
        }
    }
    for line in v6.lines() {
        let cols: Vec<&str> = line.split_whitespace().collect();
        // 目的地址 前缀长度 源地址 源前缀 下一跳 度量 引用 使用 标志 网卡
        if cols.len() >= 10 && cols[1] == "00" && cols[0].chars().all(|c| c == '0') && cols[9] != "lo" {
            out.push(format!("route6|{}|{}", cols[9], cols[4]));
        }
    }
    out
}

/// “Linux 6.8.0 (Ubuntu 24.04)”。
pub(super) fn os_version() -> String {
    let kernel = std::fs::read_to_string("/proc/sys/kernel/osrelease").unwrap_or_default();
    let release = std::fs::read_to_string("/etc/os-release").or_else(|_| std::fs::read_to_string("/usr/lib/os-release")).unwrap_or_default();
    format_os_version(&kernel, &release)
}

pub(crate) fn format_os_version(kernel: &str, os_release: &str) -> String {
    let number: String = kernel.trim().chars().take_while(|c| c.is_ascii_digit() || *c == '.').collect();
    let number = number.trim_end_matches('.');
    let field = |key: &str| {
        os_release.lines().find_map(|line| line.strip_prefix(key).and_then(|v| v.strip_prefix('='))).map(|v| v.trim().trim_matches('"').trim_matches('\'').to_string())
    };
    let distro = match (field("NAME"), field("VERSION_ID")) {
        (Some(name), Some(version)) if !version.is_empty() => format!("{name} {version}"),
        (Some(name), _) => name,
        _ => String::new(),
    };
    let mut out = String::from("Linux");
    if !number.is_empty() {
        out.push(' ');
        out.push_str(number);
    }
    if !distro.is_empty() {
        out.push_str(&format!(" ({distro})"));
    }
    out
}

/// 计算机名:hostnamectl 设置的“好看的名字”,没有的用主机名。
pub(super) fn computer_name() -> String {
    let pretty = std::fs::read_to_string("/etc/machine-info").ok().and_then(|text| {
        text.lines().find_map(|l| l.strip_prefix("PRETTY_HOSTNAME=")).map(|v| v.trim().trim_matches('"').to_string()).filter(|v| !v.is_empty())
    });
    pretty
        .or_else(|| first_line("/etc/hostname"))
        .or_else(|| first_line("/proc/sys/kernel/hostname"))
        .unwrap_or_default()
}

fn first_line(path: &str) -> Option<String> {
    std::fs::read_to_string(path).ok().and_then(|t| t.lines().next().map(|l| l.trim().to_string())).filter(|s| !s.is_empty())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn os_version_string() {
        let release = "NAME=\"Ubuntu\"\nVERSION_ID=\"24.04\"\nID=ubuntu\n";
        assert_eq!(format_os_version("6.8.0-45-generic\n", release), "Linux 6.8.0 (Ubuntu 24.04)");
        assert_eq!(format_os_version("6.10.3-arch1-1", "NAME=\"Arch Linux\"\nID=arch\n"), "Linux 6.10.3 (Arch Linux)");
        assert_eq!(format_os_version("5.15.0", ""), "Linux 5.15.0");
        // 只看 NAME=,不被 PRETTY_NAME= 干扰
        assert_eq!(format_os_version("6.1.0", "PRETTY_NAME=\"Debian GNU/Linux 12\"\nNAME=\"Debian GNU/Linux\"\nVERSION_ID=\"12\""), "Linux 6.1.0 (Debian GNU/Linux 12)");
    }

    #[test]
    fn default_routes_parsed() {
        let v4 = "Iface\tDestination\tGateway \tFlags\tRefCnt\tUse\tMetric\tMask\t\tMTU\tWindow\tIRTT\n\
                  eth0\t00000000\t0101A8C0\t0003\t0\t0\t100\t00000000\t0\t0\t0\n\
                  eth0\t0001A8C0\t00000000\t0001\t0\t0\t100\t00FFFFFF\t0\t0\t0\n";
        let v6 = "00000000000000000000000000000000 00 00000000000000000000000000000000 00 fe800000000000000000000000000001 00000400 00000001 00000000 00000003 wlan0\n\
                  00000000000000000000000000000000 00 00000000000000000000000000000000 00 00000000000000000000000000000000 ffffffff 00000001 00000000 00200200 lo\n";
        assert_eq!(parse_default_routes(v4, v6), vec!["route4|eth0|0101A8C0".to_string(), "route6|wlan0|fe800000000000000000000000000001".to_string()]);
    }

    #[test]
    fn desktop_entry_choice() {
        let candidates = vec!["com.example.im".to_string(), "Example IM".to_string(), "example-im".to_string()];
        assert_eq!(pick_desktop_entry("com.example.im", &candidates, |n| n == "example-im"), "example-im");
        assert_eq!(pick_desktop_entry("com.example.im", &candidates, |_| false), "com.example.im");
    }
}