From 100a14d2ed7ea42ba134a6880ff1458772c2bdf0 Mon Sep 17 00:00:00 2001 From: SaladDay Date: Tue, 23 Jun 2026 17:43:57 +0000 Subject: [PATCH] feat(db): in-app recovery screen with upgrade button when DB version is too new When the SQLite user_version is newer than the app supports (SCHEMA_VERSION), Database::init() fails and previously dead-ended in a native Retry/Exit dialog (Retry just fails again). The app now boots a dedicated recovery screen instead. - Detect the recoverable "version too new" case and surface it via init_status (kind="db_version_too_new" + db_version/supported_version); force-show the main window and skip normal AppState boot (recovery commands need only AppHandle). - The recovery screen first checks for an available update: - update available -> "Upgrade app" runs the updater (download + install + restart) with a download progress bar. - no update (already latest) -> warns that the DB is too new even for the latest build (likely a third-party client), so upgrading cannot help. - install_update_and_restart now emits `update-download-progress` events; new `check_app_update_available` command. - i18n: en / ja / zh / zh-TW. Verified: pnpm typecheck + format:check + test:unit (351) green; cross-model review of Rust correctness and recovery-mode safety (no AppState panic). --- src-tauri/src/commands/settings.rs | 43 +++- src-tauri/src/database/mod.rs | 16 ++ src-tauri/src/init_status.rs | 15 +- src-tauri/src/lib.rs | 28 +++ src/components/DatabaseUpgrade.tsx | 303 +++++++++++++++++++++++++++++ src/i18n/locales/en.json | 17 ++ src/i18n/locales/ja.json | 17 ++ src/i18n/locales/zh-TW.json | 17 ++ src/i18n/locales/zh.json | 17 ++ src/main.tsx | 15 ++ 10 files changed, 485 insertions(+), 3 deletions(-) create mode 100644 src/components/DatabaseUpgrade.tsx diff --git a/src-tauri/src/commands/settings.rs b/src-tauri/src/commands/settings.rs index 8cceccb4f..8e4c08964 100644 --- a/src-tauri/src/commands/settings.rs +++ b/src-tauri/src/commands/settings.rs @@ -1,8 +1,15 @@ #![allow(non_snake_case)] -use tauri::AppHandle; +use tauri::{AppHandle, Emitter}; use tauri_plugin_updater::UpdaterExt; +/// 应用更新下载进度(通过 `update-download-progress` 事件发给前端)。 +#[derive(Clone, serde::Serialize)] +struct UpdateDownloadProgress { + downloaded: u64, + total: Option, +} + fn merge_settings_for_save( mut incoming: crate::settings::AppSettings, existing: &crate::settings::AppSettings, @@ -203,8 +210,22 @@ pub async fn install_update_and_restart(app: AppHandle) -> Result }; log::info!("开始下载应用更新: {}", update.version); + let progress_handle = app.clone(); + let mut downloaded: u64 = 0; let bytes = update - .download(|_, _| {}, || {}) + .download( + move |chunk_len, content_len| { + downloaded = downloaded.saturating_add(chunk_len as u64); + let _ = progress_handle.emit( + "update-download-progress", + UpdateDownloadProgress { + downloaded, + total: content_len, + }, + ); + }, + || {}, + ) .await .map_err(|e| format!("下载更新失败: {e}"))?; @@ -245,6 +266,24 @@ pub async fn install_update_and_restart(app: AppHandle) -> Result } } +/// 检查是否有可用的应用更新,返回可用的新版本号(无更新时返回 None)。 +/// +/// 数据库版本过新的恢复界面用它判断:升级应用能否解决问题。若返回 None,说明 +/// 已是最新版本,但数据库仍不兼容(通常由第三方客户端或更高版本创建),应提示用户 +/// 升级无法解决,而不是让其反复尝试。 +#[tauri::command] +pub async fn check_app_update_available(app: AppHandle) -> Result, String> { + let updater = app + .updater_builder() + .build() + .map_err(|e| format!("初始化更新器失败: {e}"))?; + let update = updater + .check() + .await + .map_err(|e| format!("检查更新失败: {e}"))?; + Ok(update.map(|u| u.version)) +} + /// 获取 app_config_dir 覆盖配置 (从 Store) #[tauri::command] pub async fn get_app_config_dir_override(app: AppHandle) -> Result, String> { diff --git a/src-tauri/src/database/mod.rs b/src-tauri/src/database/mod.rs index 888b86768..a92713210 100644 --- a/src-tauri/src/database/mod.rs +++ b/src-tauri/src/database/mod.rs @@ -159,6 +159,22 @@ impl Database { Ok(db) } + /// 读取磁盘上数据库的 `user_version`;仅当它比应用支持的 [`SCHEMA_VERSION`] + /// 更新时返回 `Some(version)`。 + /// + /// 用于初始化失败后判断是否为「数据库版本过新(应用过旧,需升级应用)」的可恢复 + /// 场景——此时不应反复弹出无效的重试对话框,而应引导用户在应用内升级。 + pub fn stored_user_version_exceeds_supported( + db_path: &std::path::Path, + ) -> Result, AppError> { + if !db_path.exists() { + return Ok(None); + } + let conn = Connection::open(db_path).map_err(|e| AppError::Database(e.to_string()))?; + let version = Self::get_user_version(&conn)?; + Ok((version > SCHEMA_VERSION).then_some(version)) + } + /// 创建内存数据库(用于测试) pub fn memory() -> Result { let conn = Connection::open_in_memory().map_err(|e| AppError::Database(e.to_string()))?; diff --git a/src-tauri/src/init_status.rs b/src-tauri/src/init_status.rs index 042dd620a..8133f1fff 100644 --- a/src-tauri/src/init_status.rs +++ b/src-tauri/src/init_status.rs @@ -5,6 +5,17 @@ use std::sync::{OnceLock, RwLock}; pub struct InitErrorPayload { pub path: String, pub error: String, + /// 错误类别。`Some("db_version_too_new")` 表示数据库版本过新(应用过旧), + /// 前端据此展示「升级应用」恢复界面而非直接退出。 + #[serde(skip_serializing_if = "Option::is_none")] + pub kind: Option, + /// 磁盘上数据库的 user_version(数据库版本过新时填充) + #[serde(skip_serializing_if = "Option::is_none")] + pub db_version: Option, + /// 当前应用支持的 SCHEMA_VERSION(数据库版本过新时填充)。 + /// 当升级到最新版后 db_version 仍 > supported_version,说明可能由第三方客户端创建。 + #[serde(skip_serializing_if = "Option::is_none")] + pub supported_version: Option, } static INIT_ERROR: OnceLock>> = OnceLock::new(); @@ -13,7 +24,6 @@ fn cell() -> &'static RwLock> { INIT_ERROR.get_or_init(|| RwLock::new(None)) } -#[allow(dead_code)] pub fn set_init_error(payload: InitErrorPayload) { #[allow(clippy::unwrap_used)] if let Ok(mut guard) = cell().write() { @@ -102,6 +112,9 @@ mod tests { let payload = InitErrorPayload { path: "/tmp/config.json".into(), error: "broken json".into(), + kind: None, + db_version: None, + supported_version: None, }; set_init_error(payload.clone()); let got = get_init_error().expect("should get payload back"); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 863bb21a0..2ead0294d 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -409,6 +409,33 @@ pub fn run() { Err(e) => { log::error!("Failed to init database: {e}"); + // 数据库版本过新(应用过旧):无法向下迁移,反复重试也无效。 + // 记录可恢复错误并进入应用内「升级应用」恢复界面,而不是死循环弹窗。 + if let Ok(Some(version)) = + crate::database::Database::stored_user_version_exceeds_supported( + &db_path, + ) + { + log::warn!("数据库版本过新(v{version}),引导用户在应用内升级应用"); + crate::init_status::set_init_error( + crate::init_status::InitErrorPayload { + path: db_path.display().to_string(), + error: e.to_string(), + kind: Some("db_version_too_new".to_string()), + db_version: Some(version), + supported_version: Some( + crate::database::SCHEMA_VERSION, + ), + }, + ); + // 主窗口默认 visible:false,恢复界面必须强制显示 + if let Some(window) = app.get_webview_window("main") { + let _ = window.show(); + let _ = window.set_focus(); + } + return Ok(()); + } + if !show_database_init_error_dialog(app.handle(), &db_path, &e.to_string()) { log::info!("用户选择退出程序"); @@ -1187,6 +1214,7 @@ pub fn run() { commands::set_log_config, commands::restart_app, commands::install_update_and_restart, + commands::check_app_update_available, commands::check_for_updates, commands::is_portable_mode, commands::copy_text_to_clipboard, diff --git a/src/components/DatabaseUpgrade.tsx b/src/components/DatabaseUpgrade.tsx new file mode 100644 index 000000000..09da4e13b --- /dev/null +++ b/src/components/DatabaseUpgrade.tsx @@ -0,0 +1,303 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { invoke } from "@tauri-apps/api/core"; +import { listen } from "@tauri-apps/api/event"; +import { exit } from "@tauri-apps/plugin-process"; +import { + Database, + Download, + RefreshCw, + ExternalLink, + FolderOpen, + Loader2, + AlertTriangle, +} from "lucide-react"; +import { Button } from "@/components/ui/button"; + +const RELEASES_URL = "https://github.com/farion1231/cc-switch/releases"; + +interface DatabaseUpgradeProps { + payload: { + path?: string; + error?: string; + kind?: string; + db_version?: number; + supported_version?: number; + }; +} + +// checking: 启动时检查是否有可用更新 +// upgradable: 有可用更新,升级应用即可解决 +// incompatible: 已是最新版本但数据库仍过新(可能来自第三方客户端),升级无法解决 +// updating: 正在下载/安装更新 +// error: 升级过程出错 +type Phase = "checking" | "upgradable" | "incompatible" | "updating" | "error"; + +interface DownloadProgress { + downloaded: number; + total: number | null; +} + +/** + * 数据库版本过新(应用过旧)时的应用内恢复界面。 + * + * 启动时先检查是否有可用更新: + * - 有 → 提供「升级应用」一键下载+安装+重启,并展示下载进度条。 + * - 无 → 说明当前已是最新版本但数据库仍不兼容(通常由第三方客户端或更高版本创建), + * 升级无法解决,及时提醒用户备份后改用兼容客户端或等待官方支持。 + */ +export function DatabaseUpgrade({ payload }: DatabaseUpgradeProps) { + const { t } = useTranslation(); + const [phase, setPhase] = useState("checking"); + const [availableVersion, setAvailableVersion] = useState(null); + const [progress, setProgress] = useState(null); + const [errorMsg, setErrorMsg] = useState(null); + const unlistenRef = useRef<(() => void) | null>(null); + + const dbVersion = payload.db_version; + const supportedVersion = payload.supported_version; + + // 启动时检查可用更新,决定 upgradable / incompatible + useEffect(() => { + let cancelled = false; + (async () => { + try { + const version = await invoke( + "check_app_update_available", + ); + if (cancelled) return; + if (version) { + setAvailableVersion(version); + setPhase("upgradable"); + } else { + setPhase("incompatible"); + } + } catch { + // 检查失败(如离线):仍允许尝试升级,避免完全卡死 + if (!cancelled) setPhase("upgradable"); + } + })(); + return () => { + cancelled = true; + }; + }, []); + + useEffect(() => { + return () => { + unlistenRef.current?.(); + }; + }, []); + + const startUpgrade = useCallback(async () => { + setPhase("updating"); + setProgress(null); + setErrorMsg(null); + try { + unlistenRef.current?.(); + unlistenRef.current = await listen( + "update-download-progress", + (e) => setProgress(e.payload), + ); + // 成功时后端会下载+安装+重启,不会返回;返回 false 表示无可用更新。 + const updating = await invoke("install_update_and_restart"); + unlistenRef.current?.(); + unlistenRef.current = null; + if (!updating) { + // 竞态:检查时有更新、安装时已无 → 按不兼容处理 + setPhase("incompatible"); + } + // updating === true:应用即将重启,保持 updating 态直到进程退出。 + } catch (e) { + unlistenRef.current?.(); + unlistenRef.current = null; + setErrorMsg(e instanceof Error ? e.message : String(e)); + setPhase("error"); + } + }, []); + + const percent = + progress && progress.total && progress.total > 0 + ? Math.min(100, Math.round((progress.downloaded / progress.total) * 100)) + : null; + const fmtMB = (n: number) => (n / 1024 / 1024).toFixed(1); + + const isIncompatible = phase === "incompatible"; + const accent = isIncompatible + ? { + chip: "bg-red-100 text-red-600 dark:bg-red-950/50 dark:text-red-400", + Icon: AlertTriangle, + } + : { + chip: "bg-amber-100 text-amber-600 dark:bg-amber-950/50 dark:text-amber-400", + Icon: Database, + }; + const AccentIcon = accent.Icon; + + return ( +
+
+
+
+ +
+
+

+ {t("dbUpgrade.title", "数据库版本过新")} +

+

+ {t( + "dbUpgrade.description", + "当前数据库由更新版本的 CC Switch 创建,需要升级应用后才能继续使用。升级不会删除你的数据。", + )} +

+ {dbVersion != null && supportedVersion != null && ( +

+ {t("dbUpgrade.versionInfo", { + db: dbVersion, + supported: supportedVersion, + defaultValue: "数据库版本 v{{db}} · 应用支持 v{{supported}}", + })} +

+ )} +
+
+ + {/* 错误详情 / 数据库路径 */} +
+ {payload.error && ( +

{payload.error}

+ )} + {payload.path && ( +

+ {t("dbUpgrade.dbPath", "数据库文件")}:{payload.path} +

+ )} +
+ + {phase === "checking" && ( +

+ + {t("dbUpgrade.checking", "正在检查可用更新…")} +

+ )} + + {phase === "upgradable" && availableVersion && ( +

+ {t("dbUpgrade.updateAvailable", { + version: availableVersion, + defaultValue: "发现新版本 v{{version}},升级后即可继续使用。", + })} +

+ )} + + {phase === "incompatible" && ( +
+

+ {t("dbUpgrade.incompatibleTitle", "升级也无法解决")} +

+

+ {t("dbUpgrade.incompatibleDescription", { + db: dbVersion, + supported: supportedVersion, + defaultValue: + "你已是最新版本,但数据库版本(v{{db}})仍高于本应用支持的版本(v{{supported}})。该数据库可能由第三方客户端或更高版本创建,升级当前官方应用也无法兼容。", + })} +

+
+ )} + + {phase === "updating" && ( +
+
+ + + {percent === null + ? t("dbUpgrade.preparing", "正在准备更新…") + : t("dbUpgrade.downloading", "正在下载更新…")} + + {percent !== null && ( + + {percent}% + + )} +
+
+
+
+ {progress && ( +

+ {fmtMB(progress.downloaded)} MB + {progress.total ? ` / ${fmtMB(progress.total)} MB` : ""} +

+ )} +
+ )} + + {phase === "error" && errorMsg && ( +

+ {errorMsg} +

+ )} + +
+ {(phase === "upgradable" || phase === "error") && ( + + )} + + {(phase === "incompatible" || phase === "error") && ( + + )} + + + + +
+
+
+ ); +} + +export default DatabaseUpgrade; diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index b04f381a0..6b0ef3677 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -1610,6 +1610,23 @@ "tip2": "• Extractor function runs in sandbox environment, supports ES2020+ syntax", "tip3": "• Entire config must be wrapped in () to form object literal expression" }, + "dbUpgrade": { + "title": "Database version is too new", + "description": "This database was created by a newer version of CC Switch. Upgrade the app to continue. Upgrading will not delete your data.", + "versionInfo": "Database v{{db}} · App supports v{{supported}}", + "dbPath": "Database file", + "checking": "Checking for available updates…", + "updateAvailable": "Version v{{version}} is available; upgrading will let you continue.", + "incompatibleTitle": "Upgrading won't fix this", + "incompatibleDescription": "You are already on the latest version, but the database (v{{db}}) is still newer than this app supports (v{{supported}}). It was likely created by a third-party client or a newer build, so upgrading the official app cannot make it compatible.", + "preparing": "Preparing update…", + "downloading": "Downloading update…", + "upgradeNow": "Upgrade app", + "retry": "Retry upgrade", + "openReleases": "Open releases page", + "openConfigDir": "Open config folder", + "quit": "Quit" + }, "errors": { "usage_query_failed": "Usage query failed", "configLoadFailedTitle": "Configuration Load Failed", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 89965078f..bf75da98b 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -1610,6 +1610,23 @@ "tip2": "• 抽出関数はサンドボックスで実行され、ES2020+ の構文を使えます", "tip3": "• 全体を () で囲み、オブジェクトリテラル式にしてください" }, + "dbUpgrade": { + "title": "データベースのバージョンが新しすぎます", + "description": "このデータベースは新しいバージョンの CC Switch で作成されています。続行するにはアプリを更新してください。更新してもデータは削除されません。", + "versionInfo": "データベース v{{db}} · アプリ対応 v{{supported}}", + "dbPath": "データベースファイル", + "checking": "利用可能な更新を確認中…", + "updateAvailable": "新しいバージョン v{{version}} が利用可能です。更新すると続行できます。", + "incompatibleTitle": "更新しても解決できません", + "incompatibleDescription": "すでに最新バージョンですが、データベース(v{{db}})はこのアプリの対応バージョン(v{{supported}})より新しいままです。サードパーティ製クライアントまたはより新しいビルドで作成された可能性があり、公式アプリを更新しても互換性は得られません。", + "preparing": "更新を準備中…", + "downloading": "更新をダウンロード中…", + "upgradeNow": "アプリを更新", + "retry": "更新を再試行", + "openReleases": "リリースページを開く", + "openConfigDir": "設定フォルダを開く", + "quit": "終了" + }, "errors": { "usage_query_failed": "利用状況の取得に失敗しました", "configLoadFailedTitle": "設定の読み込みに失敗しました", diff --git a/src/i18n/locales/zh-TW.json b/src/i18n/locales/zh-TW.json index d004c9d57..5ed2708f6 100644 --- a/src/i18n/locales/zh-TW.json +++ b/src/i18n/locales/zh-TW.json @@ -1582,6 +1582,23 @@ "tip2": "• extractor 函式在沙盒環境中執行,支援 ES2020+ 語法", "tip3": "• 整個設定必須用 () 包覆,形成物件實字運算式" }, + "dbUpgrade": { + "title": "資料庫版本過新", + "description": "目前資料庫由較新版本的 CC Switch 建立,需要升級應用後才能繼續使用。升級不會刪除你的資料。", + "versionInfo": "資料庫版本 v{{db}} · 應用支援 v{{supported}}", + "dbPath": "資料庫檔案", + "checking": "正在檢查可用更新…", + "updateAvailable": "發現新版本 v{{version}},升級後即可繼續使用。", + "incompatibleTitle": "升級也無法解決", + "incompatibleDescription": "你已是最新版本,但資料庫版本(v{{db}})仍高於本應用支援的版本(v{{supported}})。該資料庫可能由第三方用戶端或更高版本建立,升級目前官方應用也無法相容。", + "preparing": "正在準備更新…", + "downloading": "正在下載更新…", + "upgradeNow": "升級應用", + "retry": "重試升級", + "openReleases": "開啟發佈頁", + "openConfigDir": "開啟設定目錄", + "quit": "結束" + }, "errors": { "usage_query_failed": "用量查詢失敗", "configLoadFailedTitle": "設定載入失敗", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index dfd401108..6e4f980e4 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -1610,6 +1610,23 @@ "tip2": "• extractor 函数在沙箱环境中执行,支持 ES2020+ 语法", "tip3": "• 整个配置必须用 () 包裹,形成对象字面量表达式" }, + "dbUpgrade": { + "title": "数据库版本过新", + "description": "当前数据库由更新版本的 CC Switch 创建,需要升级应用后才能继续使用。升级不会删除你的数据。", + "versionInfo": "数据库版本 v{{db}} · 应用支持 v{{supported}}", + "dbPath": "数据库文件", + "checking": "正在检查可用更新…", + "updateAvailable": "发现新版本 v{{version}},升级后即可继续使用。", + "incompatibleTitle": "升级也无法解决", + "incompatibleDescription": "你已是最新版本,但数据库版本(v{{db}})仍高于本应用支持的版本(v{{supported}})。该数据库可能由第三方客户端或更高版本创建,升级当前官方应用也无法兼容。", + "preparing": "正在准备更新…", + "downloading": "正在下载更新…", + "upgradeNow": "升级应用", + "retry": "重试升级", + "openReleases": "打开发布页", + "openConfigDir": "打开配置目录", + "quit": "退出" + }, "errors": { "usage_query_failed": "用量查询失败", "configLoadFailedTitle": "配置加载失败", diff --git a/src/main.tsx b/src/main.tsx index d3fb63e2e..923f0ab41 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,6 +1,7 @@ import React from "react"; import ReactDOM from "react-dom/client"; import App from "./App"; +import { DatabaseUpgrade } from "./components/DatabaseUpgrade"; import { UpdateProvider } from "./contexts/UpdateContext"; import "./index.css"; // 导入国际化配置 @@ -30,6 +31,8 @@ try { interface ConfigLoadErrorPayload { path?: string; error?: string; + /** "db_version_too_new" 表示数据库版本过新,渲染应用内升级恢复界面 */ + kind?: string; } /** @@ -76,6 +79,18 @@ async function bootstrap() { const initError = (await invoke( "get_init_error", )) as ConfigLoadErrorPayload | null; + if (initError && initError.kind === "db_version_too_new") { + // 数据库版本过新:渲染应用内「升级应用」恢复界面,不进入正常 App + ReactDOM.createRoot(document.getElementById("root")!).render( + + + + + + , + ); + return; + } if (initError && (initError.path || initError.error)) { await handleConfigLoadError(initError); // 注意:不会执行到这里,因为 exit(1) 会终止进程