app_state.rs 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439
  1. use std::fs;
  2. use std::path::{Path, PathBuf};
  3. use std::time::{SystemTime, UNIX_EPOCH};
  4. use serde_json::{Map, Value};
  5. use tauri::{AppHandle, Manager, Runtime};
  6. use tauri_plugin_store::StoreExt;
  7. use crate::atomic_file::write_bytes_atomically;
  8. pub const PRIMARY_FILE_NAME: &str = "app-state.json";
  9. pub const BAK_FILE_NAME: &str = "app-state.json.bak";
  10. const SUBSTANTIVE_KEYS: &[&str] = &[
  11. "llmConfig",
  12. "providerConfigs",
  13. "recentProjects",
  14. "lastProject",
  15. ];
  16. #[derive(Debug, Clone, Copy, PartialEq, Eq)]
  17. pub enum RecoverAction {
  18. Unchanged,
  19. RefreshedBak,
  20. RestoredFromBak,
  21. Quarantined,
  22. }
  23. #[derive(Debug, Clone, PartialEq, Eq)]
  24. pub struct RecoverReport {
  25. pub action: RecoverAction,
  26. pub message: String,
  27. }
  28. enum Classified {
  29. Missing,
  30. Unusable,
  31. Object { value: Value, has_substance: bool },
  32. }
  33. fn value_has_substance(value: &Value) -> bool {
  34. let Some(object) = value.as_object() else {
  35. return false;
  36. };
  37. SUBSTANTIVE_KEYS.iter().any(|key| match object.get(*key) {
  38. None | Some(Value::Null) => false,
  39. Some(Value::String(text)) => !text.is_empty(),
  40. Some(Value::Array(items)) => !items.is_empty(),
  41. Some(Value::Object(nested)) => !nested.is_empty(),
  42. Some(_) => true,
  43. })
  44. }
  45. fn classify(path: &Path) -> Classified {
  46. if !path.exists() {
  47. return Classified::Missing;
  48. }
  49. let Ok(bytes) = fs::read(path) else {
  50. return Classified::Unusable;
  51. };
  52. if bytes.iter().all(u8::is_ascii_whitespace) {
  53. return Classified::Unusable;
  54. }
  55. match serde_json::from_slice::<Value>(&bytes) {
  56. Ok(value) if value.is_object() => {
  57. let has_substance = value_has_substance(&value);
  58. Classified::Object {
  59. value,
  60. has_substance,
  61. }
  62. }
  63. _ => Classified::Unusable,
  64. }
  65. }
  66. fn object_bytes(value: &Value) -> Result<Vec<u8>, String> {
  67. serde_json::to_vec_pretty(value).map_err(|error| format!("序列化 app-state 失败: {error}"))
  68. }
  69. fn quarantine_primary(path: &Path) -> Result<(), String> {
  70. if !path.exists() {
  71. return Ok(());
  72. }
  73. let stamp = SystemTime::now()
  74. .duration_since(UNIX_EPOCH)
  75. .map(|duration| duration.as_millis())
  76. .unwrap_or(0);
  77. let dest = path.with_file_name(format!("app-state.json.corrupt-{stamp}"));
  78. fs::rename(path, &dest)
  79. .or_else(|_| {
  80. fs::copy(path, &dest)
  81. .and_then(|_| fs::remove_file(path))
  82. .map(|_| ())
  83. })
  84. .map_err(|error| {
  85. format!(
  86. "隔离损坏的 app-state 失败 '{}' -> '{}': {error}",
  87. path.display(),
  88. dest.display()
  89. )
  90. })
  91. }
  92. fn write_value(path: &Path, value: &Value) -> Result<(), String> {
  93. write_bytes_atomically(path, &object_bytes(value)?)
  94. }
  95. pub fn recover_app_state_file(dir: &Path) -> RecoverReport {
  96. let primary_path = dir.join(PRIMARY_FILE_NAME);
  97. let bak_path = dir.join(BAK_FILE_NAME);
  98. let primary = classify(&primary_path);
  99. let bak = classify(&bak_path);
  100. let primary_unusable = matches!(primary, Classified::Missing | Classified::Unusable);
  101. let primary_has_substance = matches!(
  102. primary,
  103. Classified::Object {
  104. has_substance: true,
  105. ..
  106. }
  107. );
  108. let bak_has_substance = matches!(
  109. bak,
  110. Classified::Object {
  111. has_substance: true,
  112. ..
  113. }
  114. );
  115. let bak_usable_value = match &bak {
  116. Classified::Object {
  117. has_substance: true,
  118. value,
  119. } => Some(value.clone()),
  120. _ => None,
  121. };
  122. if (!primary_has_substance && bak_has_substance) || (primary_unusable && bak_has_substance) {
  123. if let Some(value) = bak_usable_value {
  124. if let Err(error) = quarantine_primary(&primary_path) {
  125. return RecoverReport {
  126. action: RecoverAction::Unchanged,
  127. message: format!("无法隔离损坏的 app-state.json: {error}"),
  128. };
  129. }
  130. return match write_value(&primary_path, &value) {
  131. Ok(()) => RecoverReport {
  132. action: RecoverAction::RestoredFromBak,
  133. message: "已从 app-state.json.bak 恢复全局配置".to_string(),
  134. },
  135. Err(error) => RecoverReport {
  136. action: RecoverAction::Unchanged,
  137. message: format!("从 bak 恢复 app-state.json 失败: {error}"),
  138. },
  139. };
  140. }
  141. }
  142. if primary_has_substance {
  143. let bak_needs_refresh = !matches!(
  144. bak,
  145. Classified::Object {
  146. has_substance: true,
  147. ..
  148. }
  149. );
  150. if bak_needs_refresh {
  151. if let Classified::Object { value, .. } = primary {
  152. return match write_value(&bak_path, &value) {
  153. Ok(()) => RecoverReport {
  154. action: RecoverAction::RefreshedBak,
  155. message: "已用当前配置刷新 app-state.json.bak".to_string(),
  156. },
  157. Err(error) => RecoverReport {
  158. action: RecoverAction::Unchanged,
  159. message: format!("刷新 app-state.json.bak 失败: {error}"),
  160. },
  161. };
  162. }
  163. }
  164. return RecoverReport {
  165. action: RecoverAction::Unchanged,
  166. message: "app-state.json 完好".to_string(),
  167. };
  168. }
  169. if matches!(primary, Classified::Unusable) {
  170. return match quarantine_primary(&primary_path) {
  171. Ok(()) => RecoverReport {
  172. action: RecoverAction::Quarantined,
  173. message: "已隔离损坏的 app-state.json,未写入空配置".to_string(),
  174. },
  175. Err(error) => RecoverReport {
  176. action: RecoverAction::Unchanged,
  177. message: format!("隔离损坏的 app-state.json 失败: {error}"),
  178. },
  179. };
  180. }
  181. RecoverReport {
  182. action: RecoverAction::Unchanged,
  183. message: "app-state.json 不存在或尚无实质配置".to_string(),
  184. }
  185. }
  186. pub fn persist_app_state_object(dir: &Path, value: &Value) -> Result<(), String> {
  187. if !value.is_object() {
  188. return Err("app-state 必须是 JSON 对象".to_string());
  189. }
  190. let primary_path = dir.join(PRIMARY_FILE_NAME);
  191. let incoming_has_substance = value_has_substance(value);
  192. if let Classified::Object {
  193. has_substance: true,
  194. ..
  195. } = classify(&primary_path)
  196. {
  197. if !incoming_has_substance {
  198. eprintln!("[app-state] 拒绝用无实质配置的内容覆盖现有 app-state.json");
  199. return Err("拒绝用空配置覆盖现有应用配置".to_string());
  200. }
  201. }
  202. let bytes = object_bytes(value)?;
  203. write_bytes_atomically(&primary_path, &bytes)?;
  204. if incoming_has_substance {
  205. write_bytes_atomically(&dir.join(BAK_FILE_NAME), &bytes)?;
  206. }
  207. Ok(())
  208. }
  209. fn open_app_state_store<R: Runtime>(
  210. app: &AppHandle<R>,
  211. ) -> Result<std::sync::Arc<tauri_plugin_store::Store<R>>, String> {
  212. app.store_builder(PRIMARY_FILE_NAME)
  213. .disable_auto_save()
  214. .build()
  215. .map_err(|error| format!("无法打开应用状态存储: {error}"))
  216. }
  217. pub fn persist_plugin_store<R: Runtime>(app: &AppHandle<R>) -> Result<PathBuf, String> {
  218. let dir = app
  219. .path()
  220. .app_data_dir()
  221. .map_err(|error| format!("无法获取 app_data_dir: {error}"))?;
  222. let path = dir.join(PRIMARY_FILE_NAME);
  223. let store = open_app_state_store(app)?;
  224. let mut map = Map::new();
  225. for (key, value) in store.entries() {
  226. map.insert(key, value);
  227. }
  228. persist_app_state_object(&dir, &Value::Object(map))?;
  229. Ok(path)
  230. }
  231. /// Flush plugin-store memory to disk before the process dies.
  232. /// Debounced frontend writes can otherwise be lost on window destroy.
  233. pub fn persist_app_state_before_exit<R: Runtime>(app: &AppHandle<R>) {
  234. if let Err(error) = persist_plugin_store(app) {
  235. eprintln!("[app-state] 退出前持久化失败: {error}");
  236. }
  237. }
  238. pub fn prepare_app_state_store<R: Runtime>(app: &AppHandle<R>) {
  239. let Ok(dir) = app.path().app_data_dir() else {
  240. eprintln!("[app-state] could not resolve app_data_dir");
  241. return;
  242. };
  243. let report = recover_app_state_file(&dir);
  244. eprintln!("[app-state] {}", report.message);
  245. if let Err(error) = open_app_state_store(app) {
  246. eprintln!("[app-state] 打开存储失败: {error}");
  247. }
  248. }
  249. #[tauri::command]
  250. pub async fn write_app_state_atomic(
  251. app: AppHandle,
  252. entries: Value,
  253. ) -> Result<(), String> {
  254. let dir = app
  255. .path()
  256. .app_data_dir()
  257. .map_err(|error| format!("无法获取 app_data_dir: {error}"))?;
  258. tauri::async_runtime::spawn_blocking(move || persist_app_state_object(&dir, &entries))
  259. .await
  260. .map_err(|error| format!("write_app_state_atomic join error: {error}"))?
  261. }
  262. #[cfg(test)]
  263. mod tests {
  264. use super::*;
  265. use crate::atomic_file::write_bytes_atomically_with_replace;
  266. fn unique_test_dir(name: &str) -> PathBuf {
  267. let dir = std::env::temp_dir().join(format!(
  268. "qmai_app_state_{name}_{}",
  269. uuid::Uuid::new_v4()
  270. ));
  271. fs::create_dir_all(&dir).unwrap();
  272. dir
  273. }
  274. fn write_raw(dir: &Path, name: &str, contents: &str) {
  275. fs::write(dir.join(name), contents).unwrap();
  276. }
  277. fn read(dir: &Path, name: &str) -> String {
  278. fs::read_to_string(dir.join(name)).unwrap()
  279. }
  280. fn corrupt_names(dir: &Path) -> Vec<String> {
  281. fs::read_dir(dir)
  282. .unwrap()
  283. .filter_map(|entry| entry.ok())
  284. .map(|entry| entry.file_name().to_string_lossy().into_owned())
  285. .filter(|name| name.starts_with("app-state.json.corrupt-"))
  286. .collect()
  287. }
  288. #[test]
  289. fn restores_truncated_primary_from_bak() {
  290. let dir = unique_test_dir("truncated");
  291. write_raw(&dir, PRIMARY_FILE_NAME, r#"{"llmConfig":{"model":""#);
  292. write_raw(
  293. &dir,
  294. BAK_FILE_NAME,
  295. r#"{"llmConfig":{"model":"kept"},"recentProjects":[{"path":"/novel"}]}"#,
  296. );
  297. let report = recover_app_state_file(&dir);
  298. assert_eq!(report.action, RecoverAction::RestoredFromBak);
  299. let restored: Value = serde_json::from_str(&read(&dir, PRIMARY_FILE_NAME)).unwrap();
  300. assert_eq!(restored["llmConfig"]["model"], "kept");
  301. assert_eq!(corrupt_names(&dir).len(), 1);
  302. let _ = fs::remove_dir_all(&dir);
  303. }
  304. #[test]
  305. fn restores_when_primary_lost_substantive_keys() {
  306. let dir = unique_test_dir("shrink");
  307. write_raw(
  308. &dir,
  309. PRIMARY_FILE_NAME,
  310. r#"{"analytics_device_uuid":"abc"}"#,
  311. );
  312. write_raw(
  313. &dir,
  314. BAK_FILE_NAME,
  315. r#"{"llmConfig":{"provider":"openai","model":"gpt"},"providerConfigs":{"openai":{}}}"#,
  316. );
  317. let report = recover_app_state_file(&dir);
  318. assert_eq!(report.action, RecoverAction::RestoredFromBak);
  319. let restored: Value = serde_json::from_str(&read(&dir, PRIMARY_FILE_NAME)).unwrap();
  320. assert!(restored.get("llmConfig").is_some());
  321. assert!(restored.get("analytics_device_uuid").is_none());
  322. let _ = fs::remove_dir_all(&dir);
  323. }
  324. #[test]
  325. fn quarantines_unusable_primary_without_writing_empty_json() {
  326. let dir = unique_test_dir("quarantine");
  327. write_raw(&dir, PRIMARY_FILE_NAME, "{not-json");
  328. let report = recover_app_state_file(&dir);
  329. assert_eq!(report.action, RecoverAction::Quarantined);
  330. assert!(!dir.join(PRIMARY_FILE_NAME).exists());
  331. assert_eq!(corrupt_names(&dir).len(), 1);
  332. let _ = fs::remove_dir_all(&dir);
  333. }
  334. #[test]
  335. fn refreshes_missing_bak_from_healthy_primary() {
  336. let dir = unique_test_dir("bak");
  337. write_raw(
  338. &dir,
  339. PRIMARY_FILE_NAME,
  340. r#"{"lastProject":{"path":"/novel","name":"n"}}"#,
  341. );
  342. let report = recover_app_state_file(&dir);
  343. assert_eq!(report.action, RecoverAction::RefreshedBak);
  344. let bak: Value = serde_json::from_str(&read(&dir, BAK_FILE_NAME)).unwrap();
  345. assert_eq!(bak["lastProject"]["path"], "/novel");
  346. let _ = fs::remove_dir_all(&dir);
  347. }
  348. #[test]
  349. fn refuses_to_overwrite_substantive_state_with_empty_payload() {
  350. let dir = unique_test_dir("refuse");
  351. write_raw(
  352. &dir,
  353. PRIMARY_FILE_NAME,
  354. r#"{"llmConfig":{"model":"keep"},"recentProjects":[{"path":"/a"}]}"#,
  355. );
  356. let error = persist_app_state_object(
  357. &dir,
  358. &serde_json::json!({"analytics_device_uuid":"x"}),
  359. )
  360. .unwrap_err();
  361. assert!(error.contains("拒绝"));
  362. let kept: Value = serde_json::from_str(&read(&dir, PRIMARY_FILE_NAME)).unwrap();
  363. assert_eq!(kept["llmConfig"]["model"], "keep");
  364. let _ = fs::remove_dir_all(&dir);
  365. }
  366. #[test]
  367. fn persist_writes_primary_and_bak() {
  368. let dir = unique_test_dir("persist");
  369. persist_app_state_object(
  370. &dir,
  371. &serde_json::json!({"providerConfigs":{"openai":{"apiKey":"k"}}}),
  372. )
  373. .unwrap();
  374. let primary: Value = serde_json::from_str(&read(&dir, PRIMARY_FILE_NAME)).unwrap();
  375. let bak: Value = serde_json::from_str(&read(&dir, BAK_FILE_NAME)).unwrap();
  376. assert_eq!(primary, bak);
  377. assert_eq!(primary["providerConfigs"]["openai"]["apiKey"], "k");
  378. let _ = fs::remove_dir_all(&dir);
  379. }
  380. #[test]
  381. fn failed_atomic_replace_keeps_original_app_state() {
  382. let dir = unique_test_dir("persist_fail");
  383. let path = dir.join(PRIMARY_FILE_NAME);
  384. fs::write(&path, r#"{"llmConfig":{"model":"keep"}}"#).unwrap();
  385. let result = write_bytes_atomically_with_replace(
  386. &path,
  387. br#"{"llmConfig":{"model":"new"}}"#,
  388. |_temp, _dest| Err("injected".to_string()),
  389. );
  390. assert!(result.is_err());
  391. assert_eq!(
  392. fs::read_to_string(&path).unwrap(),
  393. r#"{"llmConfig":{"model":"keep"}}"#
  394. );
  395. let _ = fs::remove_dir_all(&dir);
  396. }
  397. }