复查发现(audit 脚本 + 修正后的单测扫描器):
- 27 处多行 tf("中文") 调用点未被键迁移的单行替换覆盖,英文模式下
回退显示中文;正则补丁全部换为语义键
- DirFilter/DetailFormat::label() 仍返回中文常量,t() 动态查键必然
miss(英文模式方向过滤/格式按钮显示中文);label() 改返回
common.all/dir.*、common.auto/text(Hex/Base64 语言中性字面量)
- 单测扫描器与 audit 脚本的共同盲区:t(/tf( 与引号之间的空白换行
不被匹配;扫描器改为跳过空白后取字面量,多行调用纳入强制覆盖
- 6 处 UI 直写补翻:连接测试成功通知、复制连接"(copy)"后缀、
rfd 导入/导出对话框标题、设置对话框标题、订阅标识符字段标签
- store 导入/导出错误与 PayloadFormat 编码错误(均经 Notification
展示)key 化,共 14 条新词条
- 删除 10 个真死键;mqtt/tls/update 层诊断错误维持中文(技术诊断
流,不进 UI 渲染路径),边界记录于模块文档
语言包现状:354 键、en/zh-CN 双语并排、零死键、零 UI 中文直写;
42 测试全绿;英文模式实机截图复核(含回归点)通过。
402 lines
15 KiB
Rust
402 lines
15 KiB
Rust
//! JSON 文件持久化。数据存放在系统应用数据目录的 `MQTTX-GPUI/` 下:
|
||
//! Windows: %APPDATA%\MQTTX-GPUI,macOS: ~/Library/Application Support/MQTTX-GPUI,
|
||
//! Linux: ~/.local/share/MQTTX-GPUI。
|
||
//!
|
||
//! 刻意与官方 Electron MQTTX 的 `MQTTX/` 目录区分,避免互相覆盖数据。
|
||
|
||
/// 存储层诊断输出到 stderr(避免仅为文件持久化引入日志框架),
|
||
/// 并同步上报 Sentry(未初始化时为空操作)。
|
||
macro_rules! elog {
|
||
($($arg:tt)*) => {{
|
||
let msg = format!($($arg)*);
|
||
eprintln!("[store][{}] {}",
|
||
chrono::Local::now().format("%H:%M:%S"),
|
||
msg);
|
||
sentry::capture_message(&msg, sentry::Level::Error);
|
||
}};
|
||
}
|
||
|
||
use std::collections::HashSet;
|
||
use std::path::{Path, PathBuf};
|
||
use std::sync::Mutex;
|
||
|
||
use anyhow::{Context, Result};
|
||
|
||
use crate::{
|
||
aliyun::AliyunPreset,
|
||
model::{AppSettings, ConnectionConfig, GlobalVariable, PublishPreset, Subscription},
|
||
};
|
||
|
||
#[derive(Debug, Clone)]
|
||
pub struct Storage {
|
||
dir: PathBuf,
|
||
/// 本次会话中加载失败的文件名。下次保存前先把原文件备份为
|
||
/// `<name>.corrupt-<时间戳>`,避免损坏文件被空数据直接覆盖。
|
||
corrupt: std::sync::Arc<Mutex<HashSet<String>>>,
|
||
}
|
||
|
||
impl Storage {
|
||
pub fn new() -> Self {
|
||
let dir = dirs::data_dir()
|
||
.unwrap_or_else(std::env::temp_dir)
|
||
.join("MQTTX-GPUI");
|
||
if let Err(e) = std::fs::create_dir_all(&dir) {
|
||
elog!("创建数据目录 {} 失败: {e}", dir.display());
|
||
}
|
||
Self {
|
||
dir,
|
||
corrupt: std::sync::Arc::new(Mutex::new(HashSet::new())),
|
||
}
|
||
}
|
||
|
||
fn path(&self, name: &str) -> PathBuf {
|
||
self.dir.join(name)
|
||
}
|
||
|
||
fn read_json<T: for<'de> serde::Deserialize<'de>>(&self, name: &str) -> Result<Option<T>> {
|
||
let p = self.path(name);
|
||
if !p.exists() {
|
||
return Ok(None);
|
||
}
|
||
let result = self.read_json_inner(&p);
|
||
if let Err(e) = &result {
|
||
// 读取/解析失败不能静默:否则「连接全部消失」无任何线索,
|
||
// 且下一次保存会用空数据覆盖本可抢救的原文件。
|
||
elog!("加载 {} 失败,本次会话将以空数据继续: {e}", p.display());
|
||
if let Ok(mut set) = self.corrupt.lock() {
|
||
set.insert(name.to_string());
|
||
}
|
||
}
|
||
result
|
||
}
|
||
|
||
fn read_json_inner<T: for<'de> serde::Deserialize<'de>>(&self, p: &Path) -> Result<Option<T>> {
|
||
let mut text = std::fs::read_to_string(p)
|
||
.with_context(|| crate::ui::i18n::tf("io.err_read", &[("p", &p.display().to_string())]))?;
|
||
// 容忍 Windows 编辑器/PowerShell 写入的 UTF-8 BOM(BOM 只有 1 个字符,逐字符剥离)
|
||
if let Some(rest) = text.strip_prefix('\u{feff}') {
|
||
text = rest.to_string();
|
||
}
|
||
if text.trim().is_empty() {
|
||
return Ok(None);
|
||
}
|
||
let value =
|
||
serde_json::from_str(&text).with_context(|| crate::ui::i18n::tf("io.err_parse_json_path", &[("p", &p.display().to_string())]))?;
|
||
Ok(Some(value))
|
||
}
|
||
|
||
fn write_json<T: serde::Serialize + ?Sized>(&self, name: &str, value: &T) -> Result<()> {
|
||
// 曾加载失败过的文件:覆盖前先备份原文件,把数据丢失降为可恢复
|
||
let was_corrupt = self.corrupt.lock().map(|mut s| s.remove(name)).unwrap_or(false);
|
||
let target = self.path(name);
|
||
if was_corrupt && target.exists() {
|
||
let ts = chrono::Local::now().format("%Y%m%d-%H%M%S");
|
||
let backup = self.path(&format!("{name}.corrupt-{ts}"));
|
||
match std::fs::rename(&target, &backup) {
|
||
Ok(()) => elog!(
|
||
"{} 加载失败过,原文件已备份为 {} 后重新写入",
|
||
name,
|
||
backup.display()
|
||
),
|
||
Err(e) => elog!("备份损坏文件 {} 失败: {e}", target.display()),
|
||
}
|
||
}
|
||
|
||
// tmp 名拼入 pid,避免并发调用同名互踩;写完 fsync 再 rename,
|
||
// 保证掉电后 rename 提交的目标内容完整。
|
||
let tmp = self.path(&format!("{name}.{}.tmp", std::process::id()));
|
||
let result = (|| -> Result<()> {
|
||
let text = serde_json::to_string_pretty(value)?;
|
||
let mut f = std::fs::File::create(&tmp)
|
||
.with_context(|| format!("写入 {} 失败", tmp.display()))?;
|
||
std::io::Write::write_all(&mut f, text.as_bytes())?;
|
||
f.sync_all().with_context(|| format!("刷盘 {} 失败", tmp.display()))?;
|
||
drop(f);
|
||
std::fs::rename(&tmp, &target)
|
||
.with_context(|| format!("替换 {} 失败", target.display()))?;
|
||
Ok(())
|
||
})();
|
||
if result.is_err() {
|
||
let _ = std::fs::remove_file(&tmp);
|
||
}
|
||
result
|
||
}
|
||
|
||
pub fn dir(&self) -> &Path {
|
||
&self.dir
|
||
}
|
||
|
||
pub fn log_dir(&self) -> PathBuf {
|
||
self.dir.join("logs")
|
||
}
|
||
|
||
// ── connections ──
|
||
pub fn load_connections(&self) -> Vec<ConnectionConfig> {
|
||
self.read_json("connections.json")
|
||
.ok()
|
||
.flatten()
|
||
.unwrap_or_default()
|
||
}
|
||
pub fn save_connections(&self, conns: &[ConnectionConfig]) {
|
||
if let Err(e) = self.write_json("connections.json", conns) {
|
||
elog!("保存连接失败: {e}");
|
||
}
|
||
}
|
||
|
||
// ── subscriptions ──
|
||
pub fn load_subscriptions(&self) -> Vec<Subscription> {
|
||
self.read_json("subscriptions.json")
|
||
.ok()
|
||
.flatten()
|
||
.unwrap_or_default()
|
||
}
|
||
pub fn save_subscriptions(&self, subs: &[Subscription]) {
|
||
if let Err(e) = self.write_json("subscriptions.json", subs) {
|
||
elog!("保存订阅失败: {e}");
|
||
}
|
||
}
|
||
|
||
// ── presets ──
|
||
pub fn load_presets(&self) -> Vec<PublishPreset> {
|
||
self.read_json("presets.json")
|
||
.ok()
|
||
.flatten()
|
||
.unwrap_or_default()
|
||
}
|
||
pub fn save_presets(&self, presets: &[PublishPreset]) {
|
||
if let Err(e) = self.write_json("presets.json", presets) {
|
||
elog!("保存预设失败: {e}");
|
||
}
|
||
}
|
||
|
||
// ── variables ──
|
||
pub fn load_variables(&self) -> Vec<GlobalVariable> {
|
||
self.read_json("variables.json")
|
||
.ok()
|
||
.flatten()
|
||
.unwrap_or_default()
|
||
}
|
||
pub fn save_variables(&self, vars: &[GlobalVariable]) {
|
||
if let Err(e) = self.write_json("variables.json", vars) {
|
||
elog!("保存全局变量失败: {e}");
|
||
}
|
||
}
|
||
|
||
// ── aliyun presets ──
|
||
pub fn load_aliyun(&self) -> Vec<AliyunPreset> {
|
||
self.read_json("aliyun.json")
|
||
.ok()
|
||
.flatten()
|
||
.unwrap_or_default()
|
||
}
|
||
pub fn save_aliyun(&self, presets: &[AliyunPreset]) {
|
||
if let Err(e) = self.write_json("aliyun.json", presets) {
|
||
elog!("保存阿里云预设失败: {e}");
|
||
}
|
||
}
|
||
|
||
// ── settings ──
|
||
pub fn load_settings(&self) -> AppSettings {
|
||
self.read_json("settings.json")
|
||
.ok()
|
||
.flatten()
|
||
.unwrap_or_default()
|
||
}
|
||
pub fn save_settings(&self, settings: &AppSettings) {
|
||
if let Err(e) = self.write_json("settings.json", settings) {
|
||
elog!("保存设置失败: {e}");
|
||
}
|
||
}
|
||
|
||
// ── ui state(标签页会话恢复) ──
|
||
pub fn load_ui_state(&self) -> UiState {
|
||
self.read_json("ui-state.json")
|
||
.ok()
|
||
.flatten()
|
||
.unwrap_or_default()
|
||
}
|
||
pub fn save_ui_state(&self, state: &UiState) {
|
||
if let Err(e) = self.write_json("ui-state.json", state) {
|
||
elog!("保存界面状态失败: {e}");
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 会话 UI 状态:打开的标签页与活动标签,用于重启后恢复工作区。
|
||
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
|
||
pub struct UiState {
|
||
#[serde(default)]
|
||
pub open_tabs: Vec<String>,
|
||
#[serde(default)]
|
||
pub active_tab: Option<String>,
|
||
/// 连接侧栏抽屉是否收起(抽屉式折叠,Ctrl+B 切换)
|
||
#[serde(default)]
|
||
pub sidebar_collapsed: bool,
|
||
}
|
||
|
||
impl Default for Storage {
|
||
fn default() -> Self {
|
||
Self::new()
|
||
}
|
||
}
|
||
|
||
// ── 连接导入 / 导出(Phase 2 UI 调用) ──────────────────────────────────────
|
||
|
||
/// 导出连接为 JSON 数组(与 connections.json 同构:缩进格式化、UTF-8 无 BOM)。
|
||
pub fn export_connections_to(path: &Path, conns: &[ConnectionConfig]) -> Result<(), String> {
|
||
let text = serde_json::to_string_pretty(conns)
|
||
.map_err(|e| crate::ui::i18n::tf("io.err_serialize", &[("e", &e.to_string())]))?;
|
||
std::fs::write(path, text).map_err(|e| crate::ui::i18n::tf("io.err_write", &[("p", &path.display().to_string()), ("e", &e.to_string())]))
|
||
}
|
||
|
||
/// 从 JSON 文件导入连接。
|
||
///
|
||
/// 容错规则:容忍 UTF-8 BOM;顶层允许是数组,也可能是 `{ "connections": [...] }` 对象;
|
||
/// 任一项不是合法连接配置、或 `id` 为空白时,返回带序号的错误信息。
|
||
pub fn import_connections_from(path: &Path) -> Result<Vec<ConnectionConfig>, String> {
|
||
let bytes = std::fs::read(path).map_err(|e| crate::ui::i18n::tf("io.err_read", &[("p", &path.display().to_string()), ("e", &e.to_string())]))?;
|
||
let mut text =
|
||
String::from_utf8(bytes).map_err(|_| crate::ui::i18n::tf("io.err_utf8", &[("p", &path.display().to_string())]))?;
|
||
if let Some(rest) = text.strip_prefix('\u{feff}') {
|
||
text = rest.to_string();
|
||
}
|
||
let value: serde_json::Value =
|
||
serde_json::from_str(text.trim()).map_err(|e| crate::ui::i18n::tf("io.err_parse_json", &[("e", &e.to_string())]))?;
|
||
let items = match value {
|
||
serde_json::Value::Array(items) => items,
|
||
serde_json::Value::Object(mut map) => match map.remove("connections") {
|
||
Some(serde_json::Value::Array(items)) => items,
|
||
_ => {
|
||
return Err(
|
||
crate::ui::i18n::t("io.err_missing_field").to_string(),
|
||
);
|
||
}
|
||
},
|
||
_ => return Err("JSON 顶层应为连接数组".to_string()),
|
||
};
|
||
let mut conns = Vec::with_capacity(items.len());
|
||
for (i, item) in items.into_iter().enumerate() {
|
||
match serde_json::from_value::<ConnectionConfig>(item) {
|
||
Ok(c) => {
|
||
// 空 id 会在去重/订阅关联时产生悬空引用,导入层直接拒绝
|
||
if c.id.trim().is_empty() {
|
||
return Err(crate::ui::i18n::tf("io.err_item_id", &[("n", &(i + 1))]));
|
||
}
|
||
conns.push(c);
|
||
}
|
||
Err(e) => return Err(crate::ui::i18n::tf("io.err_item_invalid", &[("n", &(i + 1)), ("e", &e.to_string())])),
|
||
}
|
||
}
|
||
Ok(conns)
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
fn temp_path(name: &str) -> PathBuf {
|
||
std::env::temp_dir().join(format!(
|
||
"mqttx-store-test-{}-{name}",
|
||
uuid::Uuid::new_v4().simple()
|
||
))
|
||
}
|
||
|
||
#[test]
|
||
fn export_import_roundtrip() {
|
||
let path = temp_path("roundtrip.json");
|
||
let mut c = ConnectionConfig::new();
|
||
c.group = Some("测试组".into());
|
||
c.connection_timeout_secs = 25;
|
||
c.max_reconnect_times = 5;
|
||
export_connections_to(&path, std::slice::from_ref(&c)).expect("导出应成功");
|
||
let back = import_connections_from(&path).expect("导入应成功");
|
||
assert_eq!(back.len(), 1);
|
||
assert_eq!(back[0].id, c.id);
|
||
assert_eq!(back[0].group.as_deref(), Some("测试组"));
|
||
assert_eq!(back[0].connection_timeout_secs, 25);
|
||
assert_eq!(back[0].max_reconnect_times, 5);
|
||
let _ = std::fs::remove_file(&path);
|
||
}
|
||
|
||
#[test]
|
||
fn import_tolerates_bom_and_object_form() {
|
||
let path = temp_path("bom-object.json");
|
||
let c = ConnectionConfig::new();
|
||
let body = serde_json::to_string(&serde_json::json!({
|
||
"connections": [serde_json::to_value(&c).unwrap()]
|
||
}))
|
||
.unwrap();
|
||
// 写入 BOM 前缀,验证容错
|
||
std::fs::write(&path, format!("\u{feff}{body}")).expect("写入测试文件");
|
||
let back = import_connections_from(&path).expect("BOM + 对象形式应可导入");
|
||
assert_eq!(back.len(), 1);
|
||
let _ = std::fs::remove_file(&path);
|
||
}
|
||
|
||
#[test]
|
||
fn import_reports_invalid_item_index() {
|
||
let path = temp_path("invalid.json");
|
||
let good = serde_json::to_value(ConnectionConfig::new()).unwrap();
|
||
let body = serde_json::json!([good, {"id": "bad"}]).to_string();
|
||
std::fs::write(&path, body).expect("写入测试文件");
|
||
let err = import_connections_from(&path).expect_err("非法项应报错");
|
||
assert!(err.contains("第 2 项"), "错误应定位到序号: {err}");
|
||
let _ = std::fs::remove_file(&path);
|
||
}
|
||
|
||
#[test]
|
||
fn import_rejects_empty_id() {
|
||
let path = temp_path("empty-id.json");
|
||
let mut c = ConnectionConfig::new();
|
||
c.id = " ".into();
|
||
let good = serde_json::to_value(ConnectionConfig::new()).unwrap();
|
||
let body = serde_json::json!([good, serde_json::to_value(&c).unwrap()]).to_string();
|
||
std::fs::write(&path, body).expect("写入测试文件");
|
||
let err = import_connections_from(&path).expect_err("空 id 应报错");
|
||
assert!(err.contains("第 2 项"), "错误应定位到序号: {err}");
|
||
assert!(err.contains("id"), "错误应说明 id 为空: {err}");
|
||
let _ = std::fs::remove_file(&path);
|
||
}
|
||
|
||
// 损坏文件:加载失败要被感知,且重新保存前原文件必须被备份,
|
||
// 防止「连接全部消失后被空数据覆盖」的不可恢复丢失
|
||
#[test]
|
||
fn corrupted_file_is_logged_and_backed_up_before_overwrite() {
|
||
let dir = std::env::temp_dir().join(format!(
|
||
"mqttx-store-test-{}",
|
||
uuid::Uuid::new_v4().simple()
|
||
));
|
||
std::fs::create_dir_all(&dir).expect("创建测试目录");
|
||
let storage = Storage {
|
||
dir: dir.clone(),
|
||
corrupt: std::sync::Arc::new(Mutex::new(HashSet::new())),
|
||
};
|
||
std::fs::write(dir.join("connections.json"), "{ 这不是 JSON").expect("写入损坏文件");
|
||
|
||
assert!(storage.load_connections().is_empty(), "损坏文件按空数据处理");
|
||
|
||
storage.save_connections(&[ConnectionConfig::new()]);
|
||
|
||
let names: Vec<String> = std::fs::read_dir(&dir)
|
||
.expect("列出测试目录")
|
||
.flatten()
|
||
.map(|e| e.file_name().to_string_lossy().to_string())
|
||
.collect();
|
||
assert!(
|
||
names.iter().any(|n| n.starts_with("connections.json.corrupt-")),
|
||
"覆盖前应备份损坏文件,实际文件: {names:?}"
|
||
);
|
||
assert!(names.contains(&"connections.json".to_string()), "新文件应已写入");
|
||
// 备份内容仍是损坏原文,可人工抢救
|
||
let backup = names
|
||
.iter()
|
||
.find(|n| n.starts_with("connections.json.corrupt-"))
|
||
.expect("备份存在");
|
||
assert_eq!(
|
||
std::fs::read_to_string(dir.join(backup)).expect("读备份"),
|
||
"{ 这不是 JSON"
|
||
);
|
||
let _ = std::fs::remove_dir_all(&dir);
|
||
}
|
||
}
|