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
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
//! 数据库文件(《1.本地存储》第 4、6、7、8 节):打开(损坏、App 降级、加密设置变化时删除重建)、删除、
//! users.json(本应用在这台电脑上已有的数据库)、数据库密钥、磁盘已满时的清理、诊断用的大小。

use std::collections::{BTreeMap, HashSet};
use std::fs;
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use std::time::{Duration, Instant};

use rusqlite::{Connection, ErrorCode, OpenFlags};
use serde_json::{json, Map, Value};

use super::schema::{self, Schema};
use super::tx::Worker;
use super::{Core, DbFile, FULL_INTERVAL, READERS};
use crate::error::{Error, Result};
use crate::paths;
use crate::secure::Secure;

/// users.json 的格式版本(与 Web SDK、Flutter SDK 的 dbs 列表相同)。
const USERS_VERSION: u64 = 1;
/// 最近使用时间一天内只更新一次(与 Web SDK 相同)。
const DAY_MS: i64 = 86_400_000;

pub(crate) struct OpenParams {
    pub app_key: String,
    /// 小写的用户名。
    pub username: String,
    pub version: u32,
    pub schema: Arc<Schema>,
    pub encrypt: bool,
    /// 引擎打开:更新 users.json(界面窗口只读,不改)。
    pub touch: bool,
}

pub(crate) struct Physical {
    pub file: DbFile,
    pub old_version: u32,
    pub reset: Option<&'static str>,
}

/// 打开数据库文件(在阻塞线程中执行):
/// 1. users.json 记下的加密设置与这次不同:删除重建(encryption_changed);
/// 2. 打不开(NOTADB、CORRUPT:文件损坏、密钥丢失):删除重建(corrupted);
/// 3. 结构版本比请求的新(App 降级):删除重建(schema_downgrade);比请求的旧:升级;
/// 4. 打开 4 个只读连接(加密时同样先设密钥)。
pub(crate) fn open_file(core: &Core, p: OpenParams) -> Result<Physical> {
    let path = paths::db_path(&core.data_dir, &p.app_key, &p.username);
    core.wait_closing(&path);
    if let Some(dir) = path.parent() {
        fs::create_dir_all(dir)?;
    }
    let users_path = paths::app_dir(&core.data_dir, &p.app_key).join("users.json");
    let mut users = Users::read(users_path);
    let mut reset = None;
    if let Some(previous) = users.encrypted(&p.username) {
        if previous != p.encrypt && path.exists() {
            log::warn!("本地数据库的加密设置变了({previous} → {}),删除重建", p.encrypt);
            delete_db_files(&path)?;
            reset = Some("encryption_changed");
        }
    }
    let key = db_key(&core.secure, &p.app_key, &p.username, p.encrypt)?;
    let (mut conn, mut stored) = match probe(&path, key.as_deref()) {
        Ok(opened) => opened,
        Err(err) if is_corrupt(&err) => {
            // 要求加密而文件是明文的 SQLite:加密设置变了(users.json 被旧数据覆盖时靠这个判断)
            let reason = if p.encrypt && plain_header(&path) { "encryption_changed" } else { "corrupted" };
            log::warn!("本地数据库打不开({err}),删除重建");
            delete_db_files(&path)?;
            reset = Some(reset.unwrap_or(reason));
            probe(&path, key.as_deref())?
        }
        Err(err) => return Err(err.into()),
    };
    if stored > p.version {
        log::warn!("本地数据库的结构版本 {stored} 比本 SDK 的 {} 新(App 降级),删除后重新同步", p.version);
        drop(conn);
        delete_db_files(&path)?;
        (conn, stored) = probe(&path, key.as_deref())?;
        reset = Some("schema_downgrade");
    }
    let old_version = stored;
    if stored < p.version {
        schema::upgrade(&mut conn, &p.schema, p.version)?;
    }
    let readers = (0..READERS).map(|_| open_conn(&path, key.as_deref(), true)).collect::<rusqlite::Result<Vec<_>>>()?;
    let tag: String = path.file_stem().map(|s| s.to_string_lossy().chars().take(6).collect()).unwrap_or_default();
    let writer = Worker::spawn(format!("drim-db-w-{tag}"), conn)?;
    let readers = readers.into_iter().enumerate().map(|(i, c)| Worker::spawn(format!("drim-db-r{i}-{tag}"), c)).collect::<Result<Vec<_>>>()?;
    if p.touch && users.touch(&p.username, p.encrypt, now_ms()) {
        if let Err(err) = users.write() {
            log::warn!("更新 users.json 失败:{err}");
        }
    }
    let file = DbFile::new(p.app_key, p.username, path, p.encrypt, p.schema, p.version, writer, readers);
    Ok(Physical { file, old_version, reset })
}

/// 打开写连接并读出结构版本:损坏、密钥不对在这一步报出。
fn probe(path: &Path, key: Option<&str>) -> rusqlite::Result<(Connection, u32)> {
    let conn = open_conn(path, key, false)?;
    let version = schema::read_version(&conn)?;
    Ok((conn, version))
}

/// 连接的设置与 Flutter 相同:先 PRAGMA key(SQLCipher,原始密钥,跳过密钥派生),WAL,synchronous = NORMAL。
fn open_conn(path: &Path, key: Option<&str>, readonly: bool) -> rusqlite::Result<Connection> {
    let flags = if readonly { OpenFlags::SQLITE_OPEN_READ_ONLY } else { OpenFlags::SQLITE_OPEN_READ_WRITE | OpenFlags::SQLITE_OPEN_CREATE };
    let conn = Connection::open_with_flags(path, flags | OpenFlags::SQLITE_OPEN_NO_MUTEX)?;
    if let Some(key) = key {
        conn.execute_batch(&format!("PRAGMA key = \"x'{key}'\""))?;
    }
    conn.busy_timeout(Duration::from_secs(5))?;
    conn.set_prepared_statement_cache_capacity(64);
    if !readonly {
        conn.pragma_update_and_check(None, "journal_mode", "WAL", |row| row.get::<_, String>(0))?;
        conn.execute_batch("PRAGMA synchronous = NORMAL")?;
    }
    Ok(conn)
}

fn is_corrupt(err: &rusqlite::Error) -> bool {
    matches!(err, rusqlite::Error::SqliteFailure(e, _) if matches!(e.code, ErrorCode::NotADatabase | ErrorCode::DatabaseCorrupt))
}

/// 文件是不是明文的 SQLite(SQLCipher 加密的文件开头是随机的盐)。
fn plain_header(path: &Path) -> bool {
    let mut header = [0u8; 16];
    fs::File::open(path).and_then(|mut f| f.read_exact(&mut header)).is_ok() && &header == b"SQLite format 3\0"
}

/// 删除数据库文件及 -wal、-shm、-journal。
pub(crate) fn delete_db_files(path: &Path) -> Result<()> {
    for suffix in ["", "-wal", "-shm", "-journal"] {
        let mut name = path.as_os_str().to_owned();
        name.push(suffix);
        match fs::remove_file(PathBuf::from(name)) {
            Ok(()) => {}
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
            Err(e) => return Err(Error::from_io(&e)),
        }
    }
    Ok(())
}

fn delete_user_files(data_dir: &Path, app_key: &str, username: &str) -> Result<()> {
    delete_db_files(&paths::db_path(data_dir, app_key, username))?;
    match fs::remove_dir_all(paths::user_dir(data_dir, app_key, username)) {
        Ok(()) => Ok(()),
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
        Err(e) => Err(Error::from_io(&e)),
    }
}

/// db_delete:删除文件和用户目录(附件),从 users.json 中去掉,删掉它的数据库密钥。
pub(crate) fn delete_user(core: &Core, app_key: &str, username: &str) -> Result<()> {
    delete_user_files(&core.data_dir, app_key, username)?;
    let mut users = Users::read(paths::app_dir(&core.data_dir, app_key).join("users.json"));
    if users.remove(&[username.to_string()]) {
        users.write()?;
    }
    forget_db_key(&core.secure, app_key, username);
    Ok(())
}

impl Core {
    /// 写入时磁盘已满(SQLITE_FULL):10 分钟内最多一次,在后台删除 users.json 中其他用户的数据库,然后通知引擎。
    pub(crate) fn storage_full(&self, app_key: &str, current: &str) {
        {
            let mut map = self.full_at.lock().unwrap();
            if map.get(app_key).is_some_and(|t| t.elapsed() < FULL_INTERVAL) {
                return;
            }
            map.insert(app_key.to_string(), Instant::now());
        }
        let Some(core) = self.me.upgrade() else { return };
        let app_key = app_key.to_string();
        let current = current.to_string();
        let spawned = std::thread::Builder::new().name("drim-db-full".into()).spawn(move || {
            let freed = core.free_space(&app_key, &current);
            (core.on_full)(app_key, freed);
        });
        if let Err(err) = spawned {
            log::error!("创建磁盘清理线程失败:{err}");
        }
    }

    /// 删除当前用户以外、没有打开着的用户的数据库(与 Flutter 的 onStorageFull 相同,按最久没用过的先删)。
    fn free_space(&self, app_key: &str, current: &str) -> usize {
        // 与打开、删除互斥:不会删掉正在打开的文件
        let _guard = self.open_lock.blocking_lock();
        let users_path = paths::app_dir(&self.data_dir, app_key).join("users.json");
        let open: HashSet<String> = self.state.lock().unwrap().files.values().filter(|f| f.app_key == app_key).map(|f| f.username.clone()).collect();
        let mut freed = Vec::new();
        for name in Users::read(users_path.clone()).by_age_except(current) {
            if open.contains(&name) {
                continue;
            }
            self.wait_closing(&paths::db_path(&self.data_dir, app_key, &name));
            match delete_user_files(&self.data_dir, app_key, &name) {
                Ok(()) => freed.push(name),
                Err(err) => log::warn!("磁盘已满时删除其他用户的数据库失败:{err}"),
            }
        }
        if !freed.is_empty() {
            let mut users = Users::read(users_path);
            if users.remove(&freed) {
                if let Err(err) = users.write() {
                    log::warn!("更新 users.json 失败:{err}");
                }
            }
            for name in &freed {
                forget_db_key(&self.secure, app_key, name);
            }
        }
        log::warn!("本地数据库写入时磁盘已满:删除了 {} 个其他用户的本地数据库", freed.len());
        freed.len()
    }
}

/// users.json:`{ v: 1, items: [{ username, created_at, last_used_at, pending_delete, encrypted? }] }`,
/// 由引擎经 secure_set 写入;插件在打开时补上 encrypted 和最近使用时间,删除时去掉对应的项。其他字段原样保留。
struct Users {
    path: PathBuf,
    items: Vec<Map<String, Value>>,
    /// 比本 SDK 新的格式:不认识,不改写(与 secure.bin 的规则相同)。
    newer: bool,
}

impl Users {
    fn read(path: PathBuf) -> Users {
        let doc: Option<Value> = fs::read_to_string(&path).ok().and_then(|t| serde_json::from_str(&t).ok());
        let Some(Value::Object(mut doc)) = doc else { return Users { path, items: Vec::new(), newer: false } };
        if doc.get("v").and_then(Value::as_u64).unwrap_or(USERS_VERSION) > USERS_VERSION {
            return Users { path, items: Vec::new(), newer: true };
        }
        let items = match doc.remove("items") {
            Some(Value::Array(items)) => items
                .into_iter()
                .filter_map(|i| match i {
                    Value::Object(m) if m.get("username").is_some_and(Value::is_string) => Some(m),
                    _ => None,
                })
                .collect(),
            _ => Vec::new(),
        };
        Users { path, items, newer: false }
    }

    fn find(&mut self, username: &str) -> Option<&mut Map<String, Value>> {
        self.items.iter_mut().find(|i| i.get("username").and_then(Value::as_str) == Some(username))
    }

    fn encrypted(&mut self, username: &str) -> Option<bool> {
        self.find(username)?.get("encrypted")?.as_bool()
    }

    /// 记下加密设置;一天以上没更新的最近使用时间改为现在;待删除的取消(又用上了)。返回是否有变化。
    fn touch(&mut self, username: &str, encrypted: bool, now: i64) -> bool {
        let Some(item) = self.find(username) else {
            let item = json!({ "username": username, "created_at": now, "last_used_at": now, "pending_delete": false, "encrypted": encrypted });
            if let Value::Object(map) = item {
                self.items.push(map);
            }
            return true;
        };
        let mut changed = false;
        if item.get("encrypted").and_then(Value::as_bool) != Some(encrypted) {
            item.insert("encrypted".into(), encrypted.into());
            changed = true;
        }
        if item.get("pending_delete").and_then(Value::as_bool) == Some(true) {
            item.insert("pending_delete".into(), false.into());
            changed = true;
        }
        if now - item.get("last_used_at").and_then(Value::as_i64).unwrap_or(0) >= DAY_MS {
            item.insert("last_used_at".into(), now.into());
            changed = true;
        }
        changed
    }

    fn remove(&mut self, names: &[String]) -> bool {
        let before = self.items.len();
        self.items.retain(|i| !i.get("username").and_then(Value::as_str).is_some_and(|u| names.iter().any(|n| n == u)));
        self.items.len() != before
    }

    /// 除 current 以外的用户,最久没用过的在前。
    fn by_age_except(&self, current: &str) -> Vec<String> {
        let mut list: Vec<(i64, String)> = self
            .items
            .iter()
            .filter_map(|i| Some((i.get("last_used_at").and_then(Value::as_i64).unwrap_or(0), i.get("username")?.as_str()?.to_string())))
            .filter(|(_, u)| u != current)
            .collect();
        list.sort();
        list.into_iter().map(|(_, u)| u).collect()
    }

    fn write(&self) -> Result<()> {
        if self.newer {
            return Ok(());
        }
        if self.items.is_empty() {
            return match fs::remove_file(&self.path) {
                Ok(()) => Ok(()),
                Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
                Err(e) => Err(Error::from_io(&e)),
            };
        }
        let text = json!({ "v": USERS_VERSION, "items": self.items }).to_string();
        write_unique_atomic(&self.path, text.as_bytes())
    }
}

/// 写临时文件再改名。临时文件名每次不同:引擎的 secure_set 可能同时写同一个 users.json,两边不能共用一个临时文件。
fn write_unique_atomic(path: &Path, bytes: &[u8]) -> Result<()> {
    static SEQ: AtomicU64 = AtomicU64::new(0);
    let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("file");
    let tmp = path.with_file_name(format!(".{name}.{}.{}.tmp", std::process::id(), SEQ.fetch_add(1, Ordering::Relaxed)));
    let result = (|| {
        let mut file = fs::File::create(&tmp)?;
        file.write_all(bytes)?;
        file.sync_all()?;
        fs::rename(&tmp, path)
    })();
    if let Err(e) = result {
        let _ = fs::remove_file(&tmp);
        return Err(Error::from_io(&e));
    }
    Ok(())
}

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

/// 数据库密钥:secure.bin 中的 db_keys,`{ "v": 1, "keys": { "<用户名的哈希>": "<64 位十六进制>" } }`。
fn load_keys(secure: &Secure, app_key: &str) -> Result<BTreeMap<String, String>> {
    let values = secure.load(app_key)?;
    let Some(text) = values.get("db_keys") else { return Ok(BTreeMap::new()) };
    let doc: Value = serde_json::from_str(text).unwrap_or(Value::Null);
    if doc.get("v").and_then(Value::as_u64) != Some(1) {
        log::warn!("db_keys 的格式不认识,按没有处理");
        return Ok(BTreeMap::new());
    }
    let keys = doc.get("keys").and_then(Value::as_object).map(|m| m.iter().filter_map(|(k, v)| Some((k.clone(), v.as_str()?.to_string()))).collect());
    Ok(keys.unwrap_or_default())
}

fn save_keys(secure: &Secure, app_key: &str, keys: &BTreeMap<String, String>) -> Result<()> {
    let value = if keys.is_empty() { None } else { Some(json!({ "v": 1, "keys": keys }).to_string()) };
    secure.set(app_key, "db_keys", value)
}

/// 用户的数据库密钥:第一次打开时生成 32 字节随机密钥(《1.本地存储》第 7 节)。
#[cfg(feature = "sqlcipher")]
fn db_key(secure: &Secure, app_key: &str, username: &str, encrypt: bool) -> Result<Option<String>> {
    use rand::RngCore;
    if !encrypt {
        return Ok(None);
    }
    let hash = paths::short_hash(username);
    let mut keys = load_keys(secure, app_key)?;
    if let Some(key) = keys.get(&hash) {
        if key.len() == 64 && key.bytes().all(|b| b.is_ascii_hexdigit()) {
            return Ok(Some(key.to_ascii_lowercase()));
        }
    }
    let mut bytes = [0u8; 32];
    rand::rng().fill_bytes(&mut bytes);
    let key = hex::encode(bytes);
    keys.insert(hash, key.clone());
    save_keys(secure, app_key, &keys)?;
    Ok(Some(key))
}

#[cfg(not(feature = "sqlcipher"))]
fn db_key(_secure: &Secure, _app_key: &str, _username: &str, encrypt: bool) -> Result<Option<String>> {
    if encrypt {
        return Err(Error::unsupported("sqlcipher_required"));
    }
    Ok(None)
}

/// 删除数据库时一并删掉它的密钥。
fn forget_db_key(secure: &Secure, app_key: &str, username: &str) {
    let hash = paths::short_hash(username);
    let result = load_keys(secure, app_key).and_then(|mut keys| if keys.remove(&hash).is_some() { save_keys(secure, app_key, &keys) } else { Ok(()) });
    if let Err(err) = result {
        log::warn!("删除数据库密钥失败:{err}");
    }
}

/// 诊断:数据目录下各数据库文件(`{appKey 的哈希}/{用户名的哈希}.db`)的大小。
pub(crate) fn sizes(data_dir: &Path, open: &HashSet<PathBuf>) -> Value {
    let size = |p: &Path| fs::metadata(p).map(|m| m.len()).unwrap_or(0);
    let mut out = Vec::new();
    let Ok(apps) = fs::read_dir(data_dir) else { return Value::Array(out) };
    for app in apps.flatten() {
        if !app.file_type().is_ok_and(|t| t.is_dir()) {
            continue;
        }
        let Ok(files) = fs::read_dir(app.path()) else { continue };
        for f in files.flatten() {
            let path = f.path();
            if path.extension().and_then(|e| e.to_str()) != Some("db") {
                continue;
            }
            let mut wal = path.as_os_str().to_owned();
            wal.push("-wal");
            out.push(json!({
                "file": format!("{}/{}", app.file_name().to_string_lossy(), f.file_name().to_string_lossy()),
                "bytes": size(&path),
                "wal_bytes": size(Path::new(&wal)),
                "open": open.contains(&path),
            }));
        }
    }
    out.sort_by(|a, b| a["file"].as_str().cmp(&b["file"].as_str()));
    Value::Array(out)
}