tauri-plugin-deeprespond-im 1.0.0-alpha

深应智能 IM 的 Tauri 插件:本地存储、安全存储、窗口间的锁与消息、系统事件、通知、角标、文件
//! 文件(《4.实现与测试》第 3.6 节):允许读写的路径、对话框、drim-file 协议、信息与分片读取、Blob 落盘、
//! 图片与视频的上传前处理、上传与下载、发送队列的附件、打开与定位。
//!
//! 文件内容不进入 JavaScript 的内存:信息、类型、上传的每一片都由 Rust 按路径读取(《2.功能概述文档》第 9 节)。

use std::collections::{HashMap, HashSet, VecDeque};
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex, OnceLock};

use serde::Serialize;
use tauri::plugin::Builder as PluginBuilder;
use tauri::Runtime;

use crate::error::{Error, Result};
use crate::paths::{outbox_dir, Paths};
use crate::Shared;

mod allow;
pub mod commands;
mod dialog;
mod image;
mod open;
pub(crate) mod protocol;
mod read;
mod transfer;
mod video;

#[cfg(test)]
mod tests;

pub use transfer::Cancel;

/// 进行中的上传、下载(files_cancel 按 task 取消)。
struct Task {
    label: String,
    instance: u64,
    upload: bool,
    cancel: Arc<Cancel>,
}

/// 附件进入发送队列后的位置(files_persist 的结果)。
#[derive(Debug, Serialize, PartialEq)]
pub struct Persisted {
    pub path: String,
    /// 是否为 SDK 自己的副本(发送完成后删除);false 表示只记了原文件的路径。
    pub owned: bool,
    pub size: u64,
    pub mtime: u64,
}

pub struct FilesState {
    paths: Paths,
    pub(crate) allow: allow::Allow,
    writes: read::BlobWrites,
    tasks: Mutex<HashMap<String, Task>>,
    /// 先于任务开始到达的取消(JavaScript 发起后立即取消时,两个命令的执行顺序不确定)。
    cancelled_early: Mutex<VecDeque<String>>,
    /// 正在下载的目标:同名的两个下载不写同一个 .part。
    downloading: Mutex<HashSet<PathBuf>>,
    parts: transfer::Parts,
    proxy: Option<String>,
    client: OnceLock<std::result::Result<reqwest::Client, String>>,
}

impl FilesState {
    pub fn new(paths: Paths, allow_read_dirs: Vec<PathBuf>, allow_write_dirs: Vec<PathBuf>, proxy: Option<String>) -> Self {
        let downloads = download_dir();
        let allow = allow::Allow::new(paths.data.clone(), paths.temp.clone(), downloads, allow_read_dirs, allow_write_dirs);
        Self {
            paths,
            allow,
            writes: read::BlobWrites::default(),
            tasks: Mutex::new(HashMap::new()),
            cancelled_early: Mutex::new(VecDeque::new()),
            downloading: Mutex::new(HashSet::new()),
            parts: transfer::Parts::default(),
            proxy,
            client: OnceLock::new(),
        }
    }

    /// 写入前的检查(下载、导出诊断包):返回真实路径。
    pub fn check_write(&self, path: &Path) -> Result<PathBuf> {
        self.allow.check_write(path)
    }

    /// 读取前的检查:返回真实路径。
    pub fn check_read(&self, path: &Path) -> Result<PathBuf> {
        self.allow.check_read(path)
    }

    /// 页面实例不在了:它发起的上传取消(新的引擎会续传),没写完的 Blob 删除;下载不中断(《4.实现与测试》第 8 节 TA-62)。
    pub fn release_instance(&self, label: &str, instance: u64) {
        for task in self.tasks.lock().unwrap().values() {
            if task.upload && task.label == label && task.instance == instance {
                task.cancel.cancel();
            }
        }
        self.writes.release_instance(label, instance);
    }

    fn client(&self) -> Result<reqwest::Client> {
        let built = self.client.get_or_init(|| transfer::build_client(self.proxy.as_deref(), true));
        built.clone().map_err(Error::network)
    }

    fn start_task(&self, task: &str, label: &str, instance: u64, upload: bool) -> Result<Arc<Cancel>> {
        let cancel = Arc::new(Cancel::default());
        {
            let mut early = self.cancelled_early.lock().unwrap();
            if let Some(i) = early.iter().position(|t| t == task) {
                early.remove(i);
                return Err(Error::aborted());
            }
        }
        let mut tasks = self.tasks.lock().unwrap();
        if tasks.contains_key(task) {
            return Err(Error::invalid_state("task_running"));
        }
        tasks.insert(task.to_string(), Task { label: label.to_string(), instance, upload, cancel: cancel.clone() });
        Ok(cancel)
    }

    fn finish_task(&self, task: &str) {
        self.tasks.lock().unwrap().remove(task);
    }

    /// files_cancel:任务还没开始的,记下来,开始时直接以 aborted 结束。
    pub fn cancel(&self, task: &str) {
        if let Some(t) = self.tasks.lock().unwrap().get(task) {
            t.cancel.cancel();
            return;
        }
        let mut early = self.cancelled_early.lock().unwrap();
        early.push_back(task.to_string());
        while early.len() > 64 {
            early.pop_front();
        }
    }

    /// 上传一片(引擎调用)。
    #[allow(clippy::too_many_arguments)]
    pub async fn upload_part(&self, task: &str, label: &str, instance: u64, path: &str, part: transfer::UploadPart, progress: Arc<dyn Fn(u64) + Send + Sync>) -> Result<u16> {
        let real = self.allow.check_read(Path::new(path))?;
        let client = self.client()?;
        let cancel = self.start_task(task, label, instance, true)?;
        let result = transfer::upload_part(&client, transfer::UploadPart { path: real, ..part }, cancel, progress).await;
        self.finish_task(task);
        result
    }

    /// 下载(窗口和引擎都可以调用)。dest 为目录或完整路径,默认系统的“下载”目录。
    #[allow(clippy::too_many_arguments)]
    pub async fn download(&self, task: &str, label: &str, instance: u64, url: &str, dest: Option<&str>, rename: bool, name: Option<&str>, progress: &(dyn Fn(u64, Option<u64>) + Send + Sync)) -> Result<(PathBuf, u64)> {
        let fallback_name = || transfer::sanitize_file_name(name.map(str::to_string).or_else(|| transfer::name_from_url(url)).as_deref().unwrap_or(""), "download");
        let (dir, file_name, picked) = match dest.filter(|d| !d.is_empty()) {
            None => {
                let dir = self.allow.downloads().ok_or_else(|| Error::unsupported("downloads_dir_unavailable"))?.to_path_buf();
                fs::create_dir_all(&dir).map_err(|e| Error::from_io(&e))?;
                let dir = fs::canonicalize(&dir).map_err(|e| Error::from_io(&e))?;
                (dir, fallback_name(), false)
            }
            Some(dest) => {
                let real = self.allow.check_write(Path::new(dest))?;
                if real.is_dir() || dest.ends_with('/') || dest.ends_with(std::path::MAIN_SEPARATOR) {
                    fs::create_dir_all(&real).map_err(|e| Error::from_io(&e))?;
                    (real, fallback_name(), false)
                } else {
                    let dir = real.parent().ok_or_else(|| Error::permission_denied("path_not_allowed"))?.to_path_buf();
                    fs::create_dir_all(&dir).map_err(|e| Error::from_io(&e))?;
                    let given = real.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
                    (dir, transfer::sanitize_file_name(&given, "download"), self.allow.is_picked_save(&real))
                }
            }
        };
        // pickSavePath 得到的路径:保存对话框已经问过是否覆盖
        let rename = rename && !picked;
        let target = {
            let mut busy = self.downloading.lock().unwrap();
            let target = transfer::choose_target(&dir, &file_name, rename, |p| busy.contains(p));
            busy.insert(target.clone());
            target
        };
        let client = self.client();
        let result = match client {
            Ok(client) => match self.start_task(task, label, instance, false) {
                Ok(cancel) => {
                    let r = transfer::download(&client, url, &target, rename, &self.parts, &cancel, progress).await;
                    self.finish_task(task);
                    r
                }
                Err(err) => Err(err),
            },
            Err(err) => Err(err),
        };
        self.downloading.lock().unwrap().remove(&target);
        let (path, size) = result?;
        open::mark_origin(&path, Some(url));
        // SDK 下载的文件可以打开、定位、预览
        self.allow.register_read(&path);
        Ok((path, size))
    }

    /// 附件进入发送队列(引擎调用):temp 的移入附件目录,不超过 copy_max_bytes 的复制,更大的只记路径(跨重启仍允许读取)。
    pub fn persist(&self, path: &str, temp: bool, app_key: &str, username: &str, copy_max_bytes: u64) -> Result<Persisted> {
        let real = self.allow.check_read(Path::new(path))?;
        let meta = fs::metadata(&real).map_err(|e| Error::from_io_read(&e))?;
        if !meta.is_file() {
            return Err(Error::validation("file_unreadable", "不是普通文件"));
        }
        let outbox = outbox_dir(&self.paths.data, app_key, username);
        crate::paths::ensure_private_dir(&outbox)?;
        let outbox = fs::canonicalize(&outbox).map_err(|e| Error::from_io(&e))?;
        if real.starts_with(&outbox) {
            return Ok(Persisted { path: path.to_string(), owned: true, size: meta.len(), mtime: read::mtime_ms(&meta) });
        }
        let name = real.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
        let dest = outbox.join(format!("{}-{}", read::random_hex(), strip_random_prefix(&name)));
        // 只移动 SDK 自己的临时文件:别处的文件即使标为 temp 也不能被移走;临时目录中的不论大小都移走,
        // 否则 1 天后被清理,附件就丢了(《2.功能概述文档》第 9.1 节)
        if self.allow.in_temp(&real) {
            move_file(&real, &dest)?;
        } else if temp {
            log::warn!("标为临时文件的附件不在临时目录中,按普通文件处理");
            return self.persist(path, false, app_key, username, copy_max_bytes);
        } else if meta.len() <= copy_max_bytes {
            if let Err(err) = fs::copy(&real, &dest) {
                let _ = fs::remove_file(&dest);
                return Err(Error::from_io(&err));
            }
        } else {
            self.allow.persist_add(&real)?;
            return Ok(Persisted { path: path.to_string(), owned: false, size: meta.len(), mtime: read::mtime_ms(&meta) });
        }
        let saved = fs::metadata(&dest).map_err(|e| Error::from_io(&e))?;
        Ok(Persisted { path: dest.to_string_lossy().into_owned(), owned: true, size: saved.len(), mtime: read::mtime_ms(&saved) })
    }

    /// 附件发送完成或被删除(引擎调用):owned 的删除文件(只限附件目录和临时目录),其他的从跨重启的列表中去掉。
    pub fn release(&self, path: &str, owned: bool) -> Result<()> {
        let path = Path::new(path);
        if !owned {
            return self.allow.persist_remove(path);
        }
        let Some((real, exists)) = allow::resolve(path) else { return Err(Error::permission_denied("path_not_allowed")) };
        if !(self.allow.in_user_dir(&real) || self.allow.in_temp(&real)) {
            return Err(Error::permission_denied("path_not_allowed"));
        }
        if exists {
            fs::remove_file(&real).map_err(|e| Error::from_io(&e))?;
        }
        Ok(())
    }
}

/// 临时文件名开头的“16 位十六进制-”(Blob 落盘、处理后的图片)在移入附件目录时去掉,不叠加。
fn strip_random_prefix(name: &str) -> &str {
    match name.split_once('-') {
        Some((prefix, rest)) if prefix.len() == 16 && prefix.bytes().all(|b| b.is_ascii_hexdigit()) && !rest.is_empty() => rest,
        _ => name,
    }
}

/// 改名;跨文件系统(临时目录与数据目录不在同一个分区)时复制后删除。
fn move_file(from: &Path, to: &Path) -> Result<()> {
    if fs::rename(from, to).is_ok() {
        return Ok(());
    }
    if let Err(err) = fs::copy(from, to) {
        let _ = fs::remove_file(to);
        return Err(Error::from_io(&err));
    }
    let _ = fs::remove_file(from);
    Ok(())
}

/// 系统的“下载”目录:XDG 设置、Windows 的已知文件夹、macOS 的 ~/Downloads;取不到的用主目录下的 Downloads。
fn download_dir() -> Option<PathBuf> {
    dirs::download_dir().or_else(|| dirs::home_dir().map(|h| h.join("Downloads")))
}

/// 拖放到窗口上的文件:登记可读的普通文件(忽略文件夹),交给那个窗口。
pub fn on_drag_drop(shared: &Shared, label: &str, paths: &[PathBuf]) {
    let files = shared.files.register_dropped(paths);
    if !files.is_empty() {
        shared.hub.send(label, serde_json::json!({ "t": "files_dropped", "paths": files }));
    }
}

impl FilesState {
    fn register_dropped(&self, paths: &[PathBuf]) -> Vec<String> {
        paths
            .iter()
            .filter(|p| fs::File::open(p).is_ok())
            .filter_map(|p| self.allow.register_read(p).map(|_| p.to_string_lossy().into_owned()))
            .collect()
    }
}

/// 注册 drim-file 协议(插件的 Builder 上)。
pub fn register_protocol<R: Runtime>(builder: PluginBuilder<R>) -> PluginBuilder<R> {
    protocol::register(builder)
}