tauri-plugin-deeprespond-im 1.0.0-alpha

深应智能 IM 的 Tauri 插件:本地存储、安全存储、窗口间的锁与消息、系统事件、通知、角标、文件
//! 命令的注册与按窗口角色的检查(《4.实现与测试》第 3 节)。各领域的命令在各自模块的 commands 中,
//! 这里是会话、引擎、安全存储、锁与频道、日志的命令。插件的命令只供 SDK 的 JavaScript 部分调用,不作为对 App 的接口。

use std::sync::Arc;

use serde::Deserialize;
use serde_json::{json, Value};
use tauri::ipc::{Channel, Invoke};
use tauri::{AppHandle, Runtime, State, Webview};

use crate::bus::LockGrant;
use crate::error::{Error, Result};
use crate::hub::{Instance, ENGINE_LABEL};
use crate::{engine, Callbacks, Shared, Unread, PLUGIN_VERSION, PROTOCOL_VERSION};

pub fn handler<R: Runtime>() -> impl Fn(Invoke<R>) -> bool + Send + Sync + 'static {
    tauri::generate_handler![
        hello,
        session_hint,
        window_state,
        engine_ready,
        engine_alive,
        engine_exit_done,
        secure_load,
        secure_set,
        unread_report,
        lock_request,
        lock_release,
        lock_cancel,
        bus_join,
        bus_leave,
        bus_post,
        bus_will,
        log_write,
        diagnostics_export,
        crate::store::commands::db_open,
        crate::store::commands::db_close,
        crate::store::commands::db_delete,
        crate::store::commands::tx_begin,
        crate::store::commands::tx_read,
        crate::store::commands::tx_write,
        crate::store::commands::tx_commit,
        crate::store::commands::tx_abort,
        crate::notify::commands::notify_show,
        crate::notify::commands::notify_clear,
        crate::notify::commands::notify_permission,
        crate::notify::commands::notify_request_permission,
        crate::notify::commands::notify_claim,
        crate::badge::commands::badge_set,
        crate::badge::commands::attention_request,
        crate::badge::commands::tray_blink,
        crate::files::commands::files_pick,
        crate::files::commands::files_pick_save,
        crate::files::commands::files_stat,
        crate::files::commands::files_read,
        crate::files::commands::files_write,
        crate::files::commands::files_persist,
        crate::files::commands::files_release,
        crate::files::commands::files_upload_part,
        crate::files::commands::files_download,
        crate::files::commands::files_cancel,
        crate::files::commands::files_open,
        crate::files::commands::files_reveal,
        crate::files::commands::files_prepare_image,
        crate::files::commands::files_prepare_video,
    ]
}

/// 引擎专用的命令:只接受标签为 deeprespond-im-engine 的窗口(即使 App 误把 deeprespond-im:engine 授予了其他窗口)。
pub fn require_engine<R: Runtime>(webview: &Webview<R>) -> Result<()> {
    if webview.label() == ENGINE_LABEL {
        Ok(())
    } else {
        Err(Error::not_engine())
    }
}

/// 调用方的页面实例(必须先 hello)。
pub fn caller<R: Runtime>(shared: &Shared, webview: &Webview<R>) -> Result<Arc<Instance>> {
    shared.hub.get(webview.label()).ok_or_else(|| Error::invalid_state("not_attached"))
}

#[tauri::command(rename_all = "snake_case")]
async fn hello<R: Runtime>(app: AppHandle<R>, webview: Webview<R>, state: State<'_, Arc<Shared>>, js_version: String, protocol: u32, channel: Channel<Value>) -> Result<Value> {
    let shared = state.inner().clone();
    let label = webview.label().to_string();
    let (instance, old) = shared.hub.attach(&label, channel);
    if let Some(old) = old {
        crate::release_instance(&shared, &old);
    }
    if js_version != PLUGIN_VERSION || protocol != PROTOCOL_VERSION {
        shared.logs.plugin("error", &format!("JavaScript 包({js_version},协议 {protocol})与插件({PLUGIN_VERSION},协议 {PROTOCOL_VERSION})的版本不同"));
    }
    let info = crate::system::info();
    let launch_click = if instance.engine { None } else { shared.notify.launch_click() };
    Ok(json!({
        "instance": instance.id,
        "label": label,
        "engine": instance.engine,
        "plugin_version": PLUGIN_VERSION,
        "protocol": PROTOCOL_VERSION,
        "os": info.os,
        "os_version": info.os_version,
        "arch": std::env::consts::ARCH,
        "computer_name": info.computer_name,
        "webview_version": tauri::webview_version().unwrap_or_default(),
        "device_id": shared.device.id,
        "data_dir": shared.paths.data,
        "temp_dir": shared.paths.temp,
        "another_instance": shared.another_instance,
        "engine_state": shared.engine.state(),
        "app_keys": shared.engine.app_keys(),
        "launch_click": launch_click,
        "main_window": shared.config.main_window,
        "log_file": shared.config.log_file,
        "window": if instance.engine { Value::Null } else { engine::window_state(&app, &shared, &label) },
    }))
}

/// 界面窗口只拿到会话的用户名和会话 ID(《1.本地存储》第 2.2 节)。
#[tauri::command(rename_all = "snake_case")]
fn session_hint<R: Runtime>(webview: Webview<R>, state: State<'_, Arc<Shared>>, app_key: String) -> Result<Value> {
    caller(&state, &webview)?;
    Ok(hint_of(&state, &app_key))
}

fn hint_of(shared: &Shared, app_key: &str) -> Value {
    let values = shared.secure.load(app_key).unwrap_or_default();
    let Some(text) = values.get("session") else { return Value::Null };
    let session: Value = serde_json::from_str(text).unwrap_or(Value::Null);
    match (session.get("username").and_then(Value::as_str), session.get("session_id").and_then(Value::as_str)) {
        (Some(username), Some(session_id)) => json!({ "username": username, "session_id": session_id }),
        _ => Value::Null,
    }
}

#[tauri::command(rename_all = "snake_case")]
fn window_state<R: Runtime>(app: AppHandle<R>, webview: Webview<R>, state: State<'_, Arc<Shared>>) -> Result<Value> {
    Ok(engine::window_state(&app, &state, webview.label()))
}

#[tauri::command(rename_all = "snake_case")]
fn engine_ready<R: Runtime>(webview: Webview<R>, state: State<'_, Arc<Shared>>, app_key: String, signed_in: bool) -> Result<()> {
    require_engine(&webview)?;
    caller(&state, &webview)?;
    let changed = state.engine.ready(&app_key, signed_in);
    crate::system::app_nap(state.config.disable_app_nap && state.engine.any_signed_in());
    if changed {
        state.logs.plugin("info", "引擎就绪");
    }
    engine::broadcast_state(&state);
    Ok(())
}

#[tauri::command(rename_all = "snake_case")]
fn engine_alive<R: Runtime>(webview: Webview<R>, state: State<'_, Arc<Shared>>) -> Result<()> {
    require_engine(&webview)?;
    state.engine.alive();
    Ok(())
}

#[tauri::command(rename_all = "snake_case")]
fn engine_exit_done<R: Runtime>(webview: Webview<R>, state: State<'_, Arc<Shared>>) -> Result<()> {
    require_engine(&webview)?;
    state.engine.exit_done();
    Ok(())
}

/// 引擎读出一个 AppKey 的安全存储(先按设备标识核对会话,第 2.3 节)和 users.json。
#[tauri::command(rename_all = "snake_case")]
fn secure_load<R: Runtime>(webview: Webview<R>, state: State<'_, Arc<Shared>>, app_key: String) -> Result<Value> {
    require_engine(&webview)?;
    let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_millis() as u64).unwrap_or(0);
    if state.secure.check_session_device(&app_key, &state.device.id, now)? {
        broadcast_hint(&state, &app_key);
    }
    let values = state.secure.load(&app_key)?;
    let users = std::fs::read_to_string(state.paths.users_json(&app_key)).ok();
    Ok(json!({
        "session": values.get("session"),
        "pending_logout": values.get("pending_logout"),
        "db_keys": Value::Null,
        "users": users,
        "device_id": state.device.id,
    }))
}

#[tauri::command(rename_all = "snake_case")]
fn secure_set<R: Runtime>(webview: Webview<R>, state: State<'_, Arc<Shared>>, app_key: String, key: String, value: Option<String>) -> Result<()> {
    require_engine(&webview)?;
    match key.as_str() {
        "users" => {
            let path = state.paths.users_json(&app_key);
            match value {
                Some(text) => crate::paths::write_atomic(&path, keep_plugin_fields(&path, &text).as_bytes())?,
                None => {
                    let _ = std::fs::remove_file(&path);
                }
            }
        }
        "session" | "pending_logout" => {
            state.secure.set(&app_key, &key, value)?;
            if key == "session" {
                broadcast_hint(&state, &app_key);
            }
        }
        _ => return Err(Error::validation("invalid_key", format!("不认识的键 {key}"))),
    }
    Ok(())
}

/// users.json 由 Web SDK 写入(dbs 列表),插件在其中按用户记下 encrypted(《1.本地存储》第 7 节):
/// JavaScript 不认识这个字段,写入时保留原来的值,否则开启、关闭加密后无法判断为 encryption_changed。
fn keep_plugin_fields(path: &std::path::Path, text: &str) -> String {
    let Ok(mut next) = serde_json::from_str::<Value>(text) else { return text.to_string() };
    let old: Value = std::fs::read_to_string(path).ok().and_then(|t| serde_json::from_str(&t).ok()).unwrap_or(Value::Null);
    let encrypted: std::collections::HashMap<String, Value> = old
        .get("items")
        .and_then(Value::as_array)
        .map(|items| {
            items
                .iter()
                .filter_map(|i| Some((i.get("username")?.as_str()?.to_string(), i.get("encrypted")?.clone())))
                .collect()
        })
        .unwrap_or_default();
    if let Some(items) = next.get_mut("items").and_then(Value::as_array_mut) {
        for item in items {
            let name = item.get("username").and_then(Value::as_str).map(str::to_string);
            if let (Some(name), Some(obj)) = (name, item.as_object_mut()) {
                if !obj.contains_key("encrypted") {
                    if let Some(flag) = encrypted.get(&name) {
                        obj.insert("encrypted".into(), flag.clone());
                    }
                }
            }
        }
    }
    next.to_string()
}

fn broadcast_hint(shared: &Shared, app_key: &str) {
    shared.hub.broadcast_windows(json!({ "t": "kv", "app_key": app_key, "key": "session", "hint": hint_of(shared, app_key) }));
}

#[tauri::command(rename_all = "snake_case")]
fn unread_report<R: Runtime>(app: AppHandle<R>, webview: Webview<R>, state: State<'_, Arc<Shared>>, total: u64, badge: u64) -> Result<()> {
    require_engine(&webview)?;
    let next = Unread { total, badge };
    let changed = {
        let mut current = state.unread.lock().unwrap();
        let changed = *current != next;
        *current = next;
        changed
    };
    if changed {
        if let Some(callbacks) = tauri::Manager::try_state::<Callbacks<R>>(&app) {
            if let Some(f) = &callbacks.on_unread_changed {
                f(&app, next);
            }
        }
    }
    Ok(())
}

#[tauri::command(rename_all = "snake_case")]
async fn lock_request<R: Runtime>(webview: Webview<R>, state: State<'_, Arc<Shared>>, name: String, id: String, steal: bool, if_available: bool) -> Result<bool> {
    let instance = caller(&state, &webview)?;
    let grant = state.bus.request(&state.hub, &name, &instance, &id, steal, if_available);
    match grant {
        LockGrant::Granted => Ok(true),
        LockGrant::NotAvailable => Ok(false),
        LockGrant::Wait(rx) => rx.await.unwrap_or_else(|_| Err(Error::aborted())),
    }
}

/// 释放持有的锁,或取消排队中的请求(等待者以 aborted 结束)。
#[tauri::command(rename_all = "snake_case")]
fn lock_release<R: Runtime>(webview: Webview<R>, state: State<'_, Arc<Shared>>, id: String) -> Result<()> {
    let instance = caller(&state, &webview)?;
    state.bus.release(&instance.label, instance.id, &id);
    Ok(())
}

#[tauri::command(rename_all = "snake_case")]
fn lock_cancel<R: Runtime>(webview: Webview<R>, state: State<'_, Arc<Shared>>, id: String) -> Result<()> {
    let instance = caller(&state, &webview)?;
    state.bus.release(&instance.label, instance.id, &id);
    Ok(())
}

#[tauri::command(rename_all = "snake_case")]
fn bus_join<R: Runtime>(webview: Webview<R>, state: State<'_, Arc<Shared>>, name: String) -> Result<()> {
    let instance = caller(&state, &webview)?;
    state.bus.join(&name, &instance);
    Ok(())
}

#[tauri::command(rename_all = "snake_case")]
fn bus_leave<R: Runtime>(webview: Webview<R>, state: State<'_, Arc<Shared>>, name: String) -> Result<()> {
    let instance = caller(&state, &webview)?;
    state.bus.leave(&name, &instance);
    Ok(())
}

/// 频道消息:JavaScript 侧按顺序排队,一次交来一批(保证同一发送者的顺序)。
#[tauri::command(rename_all = "snake_case")]
fn bus_post<R: Runtime>(webview: Webview<R>, state: State<'_, Arc<Shared>>, name: String, msgs: Vec<Value>) -> Result<()> {
    let instance = caller(&state, &webview)?;
    for msg in &msgs {
        let size = serde_json::to_vec(msg).map(|v| v.len()).unwrap_or(0);
        state.bus.post(&state.hub, &name, &instance, msg, size)?;
    }
    Ok(())
}

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

    #[test]
    fn users_json_keeps_encrypted_flag() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("users.json");
        std::fs::write(&path, r#"{"v":1,"items":[{"username":"alice","encrypted":true},{"username":"bob","encrypted":false}]}"#).unwrap();
        let out: Value = serde_json::from_str(&keep_plugin_fields(&path, r#"{"v":1,"items":[{"username":"alice","last_used_at":5},{"username":"carol"}]}"#)).unwrap();
        assert_eq!(out["items"][0]["encrypted"], json!(true));
        assert_eq!(out["items"][0]["last_used_at"], json!(5));
        assert!(out["items"][1].get("encrypted").is_none());
    }
}

/// 页面不在了(窗口关闭、崩溃)时插件替它发到频道的消息(Web SDK 的 bye)。
#[tauri::command(rename_all = "snake_case")]
fn bus_will<R: Runtime>(webview: Webview<R>, state: State<'_, Arc<Shared>>, name: String, msg: Value) -> Result<()> {
    let instance = caller(&state, &webview)?;
    state.bus.set_will(&name, &instance, msg);
    Ok(())
}

#[derive(Deserialize)]
pub struct LogEntryIn {
    pub level: String,
    pub time: Option<f64>,
    pub module: String,
    pub message: String,
    pub code: Option<String>,
    pub reason: Option<String>,
}

#[tauri::command(rename_all = "snake_case")]
fn log_write<R: Runtime>(webview: Webview<R>, state: State<'_, Arc<Shared>>, entries: Vec<LogEntryIn>) -> Result<()> {
    let source = if webview.label() == ENGINE_LABEL { "engine".to_string() } else { format!("window:{}", webview.label()) };
    state.logs.js(&source, &entries);
    Ok(())
}

#[tauri::command(rename_all = "snake_case")]
async fn diagnostics_export<R: Runtime>(app: AppHandle<R>, webview: Webview<R>, state: State<'_, Arc<Shared>>, save_to: Option<String>, info: Value) -> Result<String> {
    caller(&state, &webview)?;
    let shared = state.inner().clone();
    let dest = match save_to {
        Some(path) => Some(shared.files.check_write(std::path::Path::new(&path))?),
        None => None,
    };
    let base = json!({
        "plugin_version": PLUGIN_VERSION,
        "identifier": shared.identifier,
        "device": { "regenerated_this_run": shared.device.regenerated, "machine_bound": !shared.device.unbound },
        "log_dir": shared.paths.logs,
        "system": crate::system::info(),
        "webview_version": tauri::webview_version().unwrap_or_default(),
        "engine_state": shared.engine.state(),
        "engine_app_keys": shared.engine.app_keys(),
        "another_instance": shared.another_instance,
        "key_source": shared.secure.key_source(),
        "proxy": crate::logs::redact_proxy(shared.config.proxy.as_deref()),
        "system_proxy": crate::logs::system_proxy(),
        "databases": shared.store.sizes(),
        "windows": tauri::Manager::webview_windows(&app).keys().cloned().collect::<Vec<_>>(),
        "js": info,
    });
    let path = tauri::async_runtime::spawn_blocking(move || shared.logs.export(&shared.paths.temp, dest.as_deref(), &base))
        .await
        .map_err(|e| Error::internal(e.to_string()))??;
    Ok(path.to_string_lossy().into_owned())
}