tauri-plugin-deeprespond-im 1.0.0-alpha

深应智能 IM 的 Tauri 插件:本地存储、安全存储、窗口间的锁与消息、系统事件、通知、角标、文件
//! 日志文件与诊断包(《2.功能概述文档》第 12 节):引擎页、界面窗口和插件的日志写入同一组文件,带来源标记;
//! 单个文件最大 10 MB、保留 5 个、最多 7 天。诊断包含最近的日志、版本、系统、引擎状态、代理设置(去掉用户名和密码)、数据库大小,
//! 不含令牌和消息内容(日志本身就不记录这些,规则同 Web SDK)。

use std::fs::{self, File, OpenOptions};
use std::io::Write;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use std::time::{Duration, SystemTime, UNIX_EPOCH};

use serde_json::{json, Value};

use crate::commands::LogEntryIn;
use crate::error::{Error, Result};

const MAX_FILE_BYTES: u64 = 10 * 1024 * 1024;
const KEEP_FILES: usize = 5;
const MAX_AGE: Duration = Duration::from_secs(7 * 86400);
const FILE_NAME: &str = "deeprespond_im.log";

pub struct LogFiles {
    dir: PathBuf,
    enabled: bool,
    file: Mutex<Option<(File, u64)>>,
}

impl LogFiles {
    pub fn new(dir: PathBuf, enabled: bool) -> Self {
        if enabled {
            let _ = fs::create_dir_all(&dir);
            remove_old(&dir);
        }
        Self { dir, enabled, file: Mutex::new(None) }
    }

    /// 插件自己的日志:同时交给 log crate(App 可以用 tauri-plugin-log 收集)。
    pub fn plugin(&self, level: &str, message: &str) {
        match level {
            "error" => log::error!("{message}"),
            "warn" => log::warn!("{message}"),
            "info" => log::info!("{message}"),
            _ => log::debug!("{message}"),
        }
        self.write(json!({ "time": now_ms(), "level": level, "source": "plugin", "message": message }));
    }

    /// 页面交来的日志(Web SDK 的 LogEntry)。
    pub fn js(&self, source: &str, entries: &[LogEntryIn]) {
        for e in entries {
            let mut line = json!({ "time": e.time.map(|t| t as u64).unwrap_or_else(now_ms), "level": e.level, "source": source, "module": e.module, "message": e.message });
            if let Some(code) = &e.code {
                line["code"] = json!(code);
            }
            if let Some(reason) = &e.reason {
                line["reason"] = json!(reason);
            }
            self.write(line);
        }
    }

    fn write(&self, line: Value) {
        if !self.enabled {
            return;
        }
        let mut guard = self.file.lock().unwrap();
        if guard.as_ref().is_some_and(|(_, size)| *size >= MAX_FILE_BYTES) {
            *guard = None;
            rotate(&self.dir);
        }
        if guard.is_none() {
            let path = self.dir.join(FILE_NAME);
            match OpenOptions::new().create(true).append(true).open(&path) {
                Ok(file) => {
                    let size = file.metadata().map(|m| m.len()).unwrap_or(0);
                    *guard = Some((file, size));
                }
                Err(_) => return,
            }
        }
        if let Some((file, size)) = guard.as_mut() {
            let mut text = line.to_string();
            text.push('\n');
            if file.write_all(text.as_bytes()).is_ok() {
                *size += text.len() as u64;
            }
        }
    }

    pub fn flush(&self) {
        if let Some((file, _)) = self.file.lock().unwrap().as_mut() {
            let _ = file.flush();
        }
    }

    /// 导出诊断包:dest 为空时放在临时目录。返回压缩包的路径。
    pub fn export(&self, temp: &Path, dest: Option<&Path>, info: &Value) -> Result<PathBuf> {
        self.flush();
        let name = format!("deeprespond_im-diagnostics-{}.zip", now_ms());
        let path = match dest {
            Some(d) if d.is_dir() => d.join(&name),
            Some(d) => d.to_path_buf(),
            None => {
                fs::create_dir_all(temp).map_err(|e| Error::from_io(&e))?;
                temp.join(&name)
            }
        };
        let file = File::create(&path).map_err(|e| Error::from_io(&e))?;
        let mut zip = zip::ZipWriter::new(file);
        let options = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated);
        zip.start_file("info.json", options).map_err(zip_err)?;
        zip.write_all(serde_json::to_string_pretty(info).unwrap_or_default().as_bytes()).map_err(|e| Error::from_io(&e))?;
        for (i, log) in log_files(&self.dir).into_iter().enumerate() {
            if let Ok(bytes) = fs::read(&log) {
                let entry = log.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| format!("log{i}"));
                zip.start_file(format!("logs/{entry}"), options).map_err(zip_err)?;
                zip.write_all(&bytes).map_err(|e| Error::from_io(&e))?;
            }
        }
        zip.finish().map_err(zip_err)?;
        Ok(path)
    }
}

fn zip_err(err: zip::result::ZipError) -> Error {
    Error::storage("io", err.to_string())
}

fn now_ms() -> u64 {
    SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_millis() as u64).unwrap_or(0)
}

/// 现有的日志文件,新的在前。
fn log_files(dir: &Path) -> Vec<PathBuf> {
    let mut out = vec![dir.join(FILE_NAME)];
    for i in 1..=KEEP_FILES {
        out.push(dir.join(format!("{FILE_NAME}.{i}")));
    }
    out.into_iter().filter(|p| p.exists()).collect()
}

fn rotate(dir: &Path) {
    let _ = fs::remove_file(dir.join(format!("{FILE_NAME}.{KEEP_FILES}")));
    for i in (1..KEEP_FILES).rev() {
        let _ = fs::rename(dir.join(format!("{FILE_NAME}.{i}")), dir.join(format!("{FILE_NAME}.{}", i + 1)));
    }
    let _ = fs::rename(dir.join(FILE_NAME), dir.join(format!("{FILE_NAME}.1")));
}

fn remove_old(dir: &Path) {
    let now = SystemTime::now();
    for path in log_files(dir) {
        let old = fs::metadata(&path).and_then(|m| m.modified()).ok().and_then(|m| now.duration_since(m).ok()).is_some_and(|age| age > MAX_AGE);
        if old {
            let _ = fs::remove_file(path);
        }
    }
}

/// 代理地址去掉用户名和密码。
pub fn redact_proxy(proxy: Option<&str>) -> Option<String> {
    let proxy = proxy?;
    match proxy.find("://").map(|i| i + 3) {
        Some(start) => match proxy[start..].find('@') {
            Some(at) => Some(format!("{}***@{}", &proxy[..start], &proxy[start + at + 1..])),
            None => Some(proxy.to_string()),
        },
        None => Some(proxy.to_string()),
    }
}

/// 环境变量中的系统代理设置(WebView 与 Rust 的代理不一致时排查,《2.功能概述文档》第 7 节)。
pub fn system_proxy() -> Value {
    let mut out = serde_json::Map::new();
    for key in ["HTTPS_PROXY", "https_proxy", "HTTP_PROXY", "http_proxy", "ALL_PROXY", "all_proxy", "NO_PROXY", "no_proxy"] {
        if let Ok(value) = std::env::var(key) {
            out.insert(key.into(), json!(redact_proxy(Some(&value))));
        }
    }
    Value::Object(out)
}

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

    #[test]
    fn proxy_credentials_are_removed() {
        assert_eq!(redact_proxy(Some("http://user:pw@proxy.local:8080")).unwrap(), "http://***@proxy.local:8080");
        assert_eq!(redact_proxy(Some("socks5://proxy:1080")).unwrap(), "socks5://proxy:1080");
    }

    #[test]
    fn rotates_and_exports() {
        let dir = tempfile::tempdir().unwrap();
        let logs = LogFiles::new(dir.path().join("logs"), true);
        logs.plugin("warn", "测试");
        logs.flush();
        let text = fs::read_to_string(dir.path().join("logs").join(FILE_NAME)).unwrap();
        assert!(text.contains("\"source\":\"plugin\""));
        rotate(&dir.path().join("logs"));
        assert!(dir.path().join("logs").join(format!("{FILE_NAME}.1")).exists());
        let zip = logs.export(dir.path(), None, &json!({ "a": 1 })).unwrap();
        assert!(zip.exists());
    }
}