tauri-plugin-deeprespond-im 1.0.0-alpha

深应智能 IM 的 Tauri 插件:本地存储、安全存储、窗口间的锁与消息、系统事件、通知、角标、文件
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
//! 深应智能 IM 的 Tauri 插件(《sdk-tauri/4.实现与测试》第 3 节)。
//!
//! 替换 Web SDK 的平台适配层:本地存储(SQLite)、安全存储(主密钥在系统钥匙串中)、窗口间的锁与频道、
//! 引擎窗口(不显示的 WebView,固定为“主标签页”)、系统事件、系统通知与角标、文件的读写与传输、日志文件。
//! 只支持桌面(Windows、macOS、Linux),移动端编译时直接报错。

#[cfg(mobile)]
compile_error!("tauri-plugin-deeprespond-im 只支持桌面:手机 App 用 Flutter SDK(《sdk-tauri/2.功能概述文档》第 1 节)");

use std::path::PathBuf;
use std::sync::{Arc, Mutex};

use serde::Serialize;
use tauri::plugin::{Builder as PluginBuilder, TauriPlugin};
use tauri::{AppHandle, Manager, RunEvent, Runtime};

mod badge;
mod bus;
mod commands;
mod device;
mod engine;
mod error;
mod files;
mod hub;
mod instance;
mod logs;
mod media;
mod notify;
mod paths;
mod secure;
mod store;
mod system;

pub use error::{Error, Result};
pub use hub::ENGINE_LABEL;
pub use secure::Fallback;

/// 与 JavaScript 包之间的协议版本:窗口与插件的协议不同时以 unsupported(version_mismatch)拒绝。
pub const PROTOCOL_VERSION: u32 = 1;
/// crate 的版本,必须与 npm 包 @deeprespond/im-tauri 相同(《2.功能概述文档》第 13 节)。
pub const PLUGIN_VERSION: &str = env!("CARGO_PKG_VERSION");

/// 通知或通知上的按钮被点击,300 毫秒内没有窗口认领时交给 App(《3.接口设计》第 9 节)。
#[derive(Clone, Debug, Serialize)]
pub struct NotificationClick {
    pub id: String,
    pub kind: String,
    pub conversation_id: Option<String>,
    pub seq: Option<i64>,
    pub action: Option<String>,
    pub data: Option<serde_json::Map<String, serde_json::Value>>,
    /// 应用是被这次点击启动的(应用退出后点击通知中心里的通知)。
    pub launched: bool,
}

/// 来电通知上的按钮:accept 或 decline。
#[derive(Clone, Debug, Serialize)]
pub struct CallAction {
    pub action: String,
    pub call_id: String,
    pub data: Option<serde_json::Map<String, serde_json::Value>>,
}

/// 当前的未读总数和角标数(按 badgeIncludesMuted 计算)。
#[derive(Clone, Copy, Debug, Default, Serialize, PartialEq, Eq)]
pub struct Unread {
    pub total: u64,
    pub badge: u64,
}

/// 引擎窗口的状态(《3.接口设计》第 8 节的 engine.stateChanged)。
#[derive(Clone, Copy, Debug, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum EngineState {
    Starting,
    Ready,
    Restarting,
    Failed,
}

type AppCallback<R, T> = Box<dyn Fn(&AppHandle<R>, T) + Send + Sync>;
type AppHook<R> = Box<dyn Fn(&AppHandle<R>) + Send + Sync>;

/// App 在 Rust 中注册的处理函数(按 Runtime 泛型,单独作为 Tauri 的状态保存)。
pub(crate) struct Callbacks<R: Runtime> {
    pub on_notification_click: Option<AppCallback<R, NotificationClick>>,
    pub on_call_action: Option<AppCallback<R, CallAction>>,
    pub on_unread_changed: Option<AppCallback<R, Unread>>,
    pub on_main_window_missing: Option<AppHook<R>>,
}

/// Builder 的设置。
#[derive(Clone, Debug)]
pub(crate) struct Config {
    pub engine_url: String,
    pub main_window: String,
    pub exit_when_last_window_closed: bool,
    pub fallback: Fallback,
    pub log_file: bool,
    pub log_dir: Option<PathBuf>,
    pub proxy: Option<String>,
    pub allow_read_dirs: Vec<PathBuf>,
    pub allow_write_dirs: Vec<PathBuf>,
    pub disable_app_nap: bool,
}

/// 插件的共享状态。
pub(crate) struct Shared {
    pub config: Config,
    pub identifier: String,
    pub paths: paths::Paths,
    pub hub: hub::Hub,
    pub bus: bus::Bus,
    pub secure: Arc<secure::Secure>,
    pub device: device::Device,
    pub store: store::StoreManager,
    pub files: files::FilesState,
    pub notify: notify::Notifier,
    pub badge: badge::BadgeState,
    pub system: system::SystemState,
    pub engine: engine::EngineCtl,
    pub logs: logs::LogFiles,
    pub unread: Mutex<Unread>,
    /// 另一个进程正在使用同一份数据(单实例的文件锁取不到)。
    pub another_instance: bool,
    pub _instance_lock: Option<instance::InstanceLock>,
}

/// 插件的构建器(《3.接口设计》第 2 节)。
pub struct Builder<R: Runtime> {
    config: Config,
    callbacks: Callbacks<R>,
}

impl<R: Runtime> Default for Builder<R> {
    fn default() -> Self {
        Self::new()
    }
}

impl<R: Runtime> Builder<R> {
    pub fn new() -> Self {
        Self {
            config: Config {
                engine_url: "engine.html".into(),
                main_window: "main".into(),
                exit_when_last_window_closed: true,
                fallback: Fallback::File,
                log_file: false,
                log_dir: None,
                proxy: None,
                allow_read_dirs: Vec::new(),
                allow_write_dirs: Vec::new(),
                disable_app_nap: true,
            },
            callbacks: Callbacks { on_notification_click: None, on_call_action: None, on_unread_changed: None, on_main_window_missing: None },
        }
    }

    /// 引擎页在前端资源中的路径,必须是打包在内的页面;`http(s)://` 地址在初始化时报错(开发模式下允许本机的开发服务器)。
    pub fn engine_url(mut self, path: impl Into<String>) -> Self {
        self.config.engine_url = path.into();
        self
    }

    /// 通知点击、来电接听没有窗口认领时显示并聚焦的窗口。
    pub fn main_window(mut self, label: impl Into<String>) -> Self {
        self.config.main_window = label.into();
        self
    }

    /// 主窗口不存在时由 App 重新创建。
    pub fn on_main_window_missing(mut self, f: impl Fn(&AppHandle<R>) + Send + Sync + 'static) -> Self {
        self.callbacks.on_main_window_missing = Some(Box::new(f));
        self
    }

    /// 最后一个界面窗口销毁时退出进程(默认 true);缩到托盘的应用设为 false。
    pub fn exit_when_last_window_closed(mut self, exit: bool) -> Self {
        self.config.exit_when_last_window_closed = exit;
        self
    }

    /// 钥匙串不可用时的处理(默认 File)。
    pub fn secure_storage_fallback(mut self, fallback: Fallback) -> Self {
        self.config.fallback = fallback;
        self
    }

    /// 写日志文件(默认 false)。
    pub fn log_file(mut self, enabled: bool) -> Self {
        self.config.log_file = enabled;
        self
    }

    /// 日志文件的目录(默认应用日志目录下的 deeprespond_im)。
    pub fn log_dir(mut self, dir: impl Into<PathBuf>) -> Self {
        self.config.log_dir = Some(dir.into());
        self
    }

    /// Rust 侧上传和下载使用的代理(默认系统代理)。
    pub fn proxy(mut self, url: impl Into<String>) -> Self {
        self.config.proxy = Some(url.into());
        self
    }

    /// 额外允许读取的目录。
    pub fn allow_read_dir(mut self, dir: impl Into<PathBuf>) -> Self {
        self.config.allow_read_dirs.push(dir.into());
        self
    }

    /// 额外允许写入的目录。
    pub fn allow_write_dir(mut self, dir: impl Into<PathBuf>) -> Self {
        self.config.allow_write_dirs.push(dir.into());
        self
    }

    /// 未读数变化时调用(供 App 更新托盘的提示文字或菜单)。
    pub fn on_unread_changed(mut self, f: impl Fn(&AppHandle<R>, Unread) + Send + Sync + 'static) -> Self {
        self.callbacks.on_unread_changed = Some(Box::new(f));
        self
    }

    /// 通知点击在 300 毫秒内没有窗口认领时调用。
    pub fn on_notification_click(mut self, f: impl Fn(&AppHandle<R>, NotificationClick) + Send + Sync + 'static) -> Self {
        self.callbacks.on_notification_click = Some(Box::new(f));
        self
    }

    /// 来电通知上“接听”“拒绝”的处理。
    pub fn on_call_action(mut self, f: impl Fn(&AppHandle<R>, CallAction) + Send + Sync + 'static) -> Self {
        self.callbacks.on_call_action = Some(Box::new(f));
        self
    }

    /// macOS 登录期间阻止 App Nap(默认 true)。
    pub fn disable_app_nap(mut self, disable: bool) -> Self {
        self.config.disable_app_nap = disable;
        self
    }

    pub fn build(self) -> TauriPlugin<R> {
        let Builder { config, callbacks } = self;
        let callbacks = Mutex::new(Some(callbacks));
        let setup_config = config.clone();
        let mut builder = PluginBuilder::<R>::new("deeprespond-im")
            .invoke_handler(commands::handler())
            .setup(move |app, _api| {
                if let Some(cb) = callbacks.lock().unwrap().take() {
                    app.manage(cb);
                }
                let shared = setup(app, setup_config.clone())?;
                app.manage(shared.clone());
                engine::start(app, &shared)?;
                system::start(app, &shared);
                Ok(())
            })
            .on_event(|app, event| on_event(app, event))
            .on_webview_ready(|webview| {
                files::protocol::enable_cors(&webview);
                media::allow_own_media(&webview);
            })
            .on_page_load(|webview, payload| engine::on_page_load(webview, payload));
        builder = files::register_protocol(builder);
        builder.build()
    }
}

/// 只用默认设置的插件。
pub fn init<R: Runtime>() -> TauriPlugin<R> {
    Builder::new().build()
}

fn setup<R: Runtime>(app: &AppHandle<R>, config: Config) -> std::result::Result<Arc<Shared>, Box<dyn std::error::Error>> {
    engine::check_engine_url(&config.engine_url)?;
    let identifier = app.config().identifier.clone();
    let path = app.path();
    let data = path.app_local_data_dir()?.join("deeprespond_im");
    let temp = path.app_cache_dir()?.join("deeprespond_im");
    let log_dir = match &config.log_dir {
        Some(dir) => dir.clone(),
        None => path.app_log_dir()?.join("deeprespond_im"),
    };
    paths::ensure_private_dir(&data)?;
    paths::ensure_private_dir(&temp)?;
    // 处理中的临时文件:启动时删除超过 1 天的(《1.本地存储》第 6 节)
    paths::clean_old_files(&temp, std::time::Duration::from_secs(86400));
    let paths = paths::Paths { data: data.clone(), temp: temp.clone(), logs: log_dir.clone() };
    let logs = logs::LogFiles::new(log_dir, config.log_file);
    let lock = match instance::acquire(&data) {
        Ok(lock) => lock,
        Err(err) => {
            log::warn!("单实例的文件锁不可用:{err}");
            None
        }
    };
    let another_instance = lock.is_none();
    if another_instance {
        logs.plugin("warn", "另一个进程正在使用同一份数据,本进程不创建引擎客户端");
    }
    let device = device::load_or_create(&data, &identifier, device::machine_id().as_deref())?;
    let secure = Arc::new(secure::Secure::new(data.clone(), config.fallback, Box::new(secure::SystemKeyStore::new(&identifier))));
    let handle = app.clone();
    let store = store::StoreManager::new(
        data.clone(),
        secure.clone(),
        Arc::new(move |app_key: String, freed: usize| {
            if let Some(shared) = handle.try_state::<Arc<Shared>>() {
                shared.hub.send_engine(serde_json::json!({ "t": "storage_full", "app_key": app_key, "freed_users": freed }));
            }
        }),
    );
    let files = files::FilesState::new(paths.clone(), config.allow_read_dirs.clone(), config.allow_write_dirs.clone(), config.proxy.clone());
    let notify = notify::Notifier::new(app, &identifier);
    let shared = Arc::new(Shared {
        identifier,
        paths,
        hub: hub::Hub::default(),
        bus: bus::Bus::default(),
        secure,
        device,
        store,
        files,
        notify,
        badge: badge::BadgeState::default(),
        system: system::SystemState::default(),
        engine: engine::EngineCtl::default(),
        logs,
        unread: Mutex::new(Unread::default()),
        another_instance,
        _instance_lock: lock,
        config,
    });
    Ok(shared)
}

/// 页面实例不在了(窗口销毁、页面重新加载、引擎崩溃):释放它的锁、频道、事务和数据库句柄。
pub(crate) fn release_instance(shared: &Shared, instance: &hub::Instance) {
    shared.bus.release_instance(&shared.hub, &instance.label, instance.id);
    shared.store.release_instance(&instance.label, instance.id);
    shared.files.release_instance(&instance.label, instance.id);
}

fn on_event<R: Runtime>(app: &AppHandle<R>, event: &RunEvent) {
    let Some(shared) = app.try_state::<Arc<Shared>>() else { return };
    let shared = shared.inner().clone();
    match event {
        RunEvent::Ready => engine::on_ready(app, &shared),
        RunEvent::ExitRequested { code, api, .. } => engine::on_exit_requested(app, &shared, *code, api),
        RunEvent::WindowEvent { label, event, .. } => {
            engine::on_window_event(app, &shared, label, event);
            if let tauri::WindowEvent::DragDrop(tauri::DragDropEvent::Drop { paths, .. }) = event {
                files::on_drag_drop(&shared, label, paths);
            }
        }
        RunEvent::Exit => {
            shared.store.close_all();
            shared.logs.flush();
        }
        _ => {}
    }
}

/// App 自己发起的退出(如托盘菜单的“退出”):插件先让引擎收尾(最多 2 秒)再退出(《3.接口设计》第 9 节)。
pub fn exit<R: Runtime>(app: &AppHandle<R>, code: i32) {
    match app.try_state::<Arc<Shared>>() {
        Some(shared) => engine::begin_exit(app, &shared, code),
        None => app.exit(code),
    }
}

/// 单实例插件转来的第二次启动的命令行:其中有通知点击的启动参数(`--drim-notification=...`,Windows 应用退出后点击通知、
/// 或经 App 的 URI 协议转来)时照常交出点击(《2.功能概述文档》第 8.3 节)。在 tauri_plugin_single_instance 的回调中调用。
pub fn handle_args<R: Runtime>(app: &AppHandle<R>, argv: &[String]) {
    notify::handle_args(app, argv);
}

/// Rust 侧的接口:`app.deeprespond_im().unread()`、`engine_state()`。
pub struct DeeprespondIm<R: Runtime> {
    app: AppHandle<R>,
}

impl<R: Runtime> DeeprespondIm<R> {
    /// 当前未读总数和角标数。
    pub fn unread(&self) -> Unread {
        self.app.try_state::<Arc<Shared>>().map(|s| *s.unread.lock().unwrap()).unwrap_or_default()
    }

    /// 引擎状态。
    pub fn engine_state(&self) -> EngineState {
        self.app.try_state::<Arc<Shared>>().map(|s| s.engine.state()).unwrap_or(EngineState::Starting)
    }

    /// 退出(同 [`exit`])。
    pub fn exit(&self, code: i32) {
        exit(&self.app, code)
    }
}

pub trait DeeprespondImExt<R: Runtime> {
    fn deeprespond_im(&self) -> DeeprespondIm<R>;
}

impl<R: Runtime, T: Manager<R>> DeeprespondImExt<R> for T {
    fn deeprespond_im(&self) -> DeeprespondIm<R> {
        DeeprespondIm { app: self.app_handle().clone() }
    }
}