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).
This commit is contained in:
SaladDay
2026-06-23 17:43:57 +00:00
parent 55abd1822c
commit 100a14d2ed
10 changed files with 485 additions and 3 deletions
+41 -2
View File
@@ -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<u64>,
}
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<bool, String>
};
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<bool, String>
}
}
/// 检查是否有可用的应用更新,返回可用的新版本号(无更新时返回 None)。
///
/// 数据库版本过新的恢复界面用它判断:升级应用能否解决问题。若返回 None,说明
/// 已是最新版本,但数据库仍不兼容(通常由第三方客户端或更高版本创建),应提示用户
/// 升级无法解决,而不是让其反复尝试。
#[tauri::command]
pub async fn check_app_update_available(app: AppHandle) -> Result<Option<String>, 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<Option<String>, String> {
+16
View File
@@ -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<Option<i32>, 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<Self, AppError> {
let conn = Connection::open_in_memory().map_err(|e| AppError::Database(e.to_string()))?;
+14 -1
View File
@@ -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<String>,
/// 磁盘上数据库的 user_version(数据库版本过新时填充)
#[serde(skip_serializing_if = "Option::is_none")]
pub db_version: Option<i32>,
/// 当前应用支持的 SCHEMA_VERSION(数据库版本过新时填充)。
/// 当升级到最新版后 db_version 仍 > supported_version,说明可能由第三方客户端创建。
#[serde(skip_serializing_if = "Option::is_none")]
pub supported_version: Option<i32>,
}
static INIT_ERROR: OnceLock<RwLock<Option<InitErrorPayload>>> = OnceLock::new();
@@ -13,7 +24,6 @@ fn cell() -> &'static RwLock<Option<InitErrorPayload>> {
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");
+28
View File
@@ -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,
+303
View File
@@ -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<Phase>("checking");
const [availableVersion, setAvailableVersion] = useState<string | null>(null);
const [progress, setProgress] = useState<DownloadProgress | null>(null);
const [errorMsg, setErrorMsg] = useState<string | null>(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<string | null>(
"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<DownloadProgress>(
"update-download-progress",
(e) => setProgress(e.payload),
);
// 成功时后端会下载+安装+重启,不会返回;返回 false 表示无可用更新。
const updating = await invoke<boolean>("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 (
<div className="flex min-h-screen items-center justify-center bg-background p-6 text-foreground">
<div className="w-full max-w-lg space-y-5 rounded-2xl border border-border/60 bg-card/80 p-7 shadow-xl">
<div className="flex items-start gap-4">
<div
className={`flex h-12 w-12 shrink-0 items-center justify-center rounded-xl ${accent.chip}`}
>
<AccentIcon className="h-6 w-6" />
</div>
<div className="space-y-1">
<h1 className="text-lg font-semibold">
{t("dbUpgrade.title", "数据库版本过新")}
</h1>
<p className="text-sm text-muted-foreground">
{t(
"dbUpgrade.description",
"当前数据库由更新版本的 CC Switch 创建,需要升级应用后才能继续使用。升级不会删除你的数据。",
)}
</p>
{dbVersion != null && supportedVersion != null && (
<p className="pt-0.5 text-xs text-muted-foreground tabular-nums">
{t("dbUpgrade.versionInfo", {
db: dbVersion,
supported: supportedVersion,
defaultValue: "数据库版本 v{{db}} · 应用支持 v{{supported}}",
})}
</p>
)}
</div>
</div>
{/* 错误详情 / 数据库路径 */}
<div className="space-y-1 rounded-lg border border-border/50 bg-muted/40 p-3 text-xs text-muted-foreground">
{payload.error && (
<p className="break-words font-mono">{payload.error}</p>
)}
{payload.path && (
<p className="break-all">
{t("dbUpgrade.dbPath", "数据库文件")}{payload.path}
</p>
)}
</div>
{phase === "checking" && (
<p className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
{t("dbUpgrade.checking", "正在检查可用更新…")}
</p>
)}
{phase === "upgradable" && availableVersion && (
<p className="text-sm text-muted-foreground">
{t("dbUpgrade.updateAvailable", {
version: availableVersion,
defaultValue: "发现新版本 v{{version}},升级后即可继续使用。",
})}
</p>
)}
{phase === "incompatible" && (
<div className="space-y-2 rounded-lg border border-red-300/60 bg-red-50 p-3 text-sm text-red-700 dark:border-red-500/40 dark:bg-red-950/40 dark:text-red-300">
<p className="font-medium">
{t("dbUpgrade.incompatibleTitle", "升级也无法解决")}
</p>
<p className="leading-relaxed">
{t("dbUpgrade.incompatibleDescription", {
db: dbVersion,
supported: supportedVersion,
defaultValue:
"你已是最新版本,但数据库版本(v{{db}})仍高于本应用支持的版本(v{{supported}})。该数据库可能由第三方客户端或更高版本创建,升级当前官方应用也无法兼容。",
})}
</p>
</div>
)}
{phase === "updating" && (
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
<span className="flex items-center gap-2 text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
{percent === null
? t("dbUpgrade.preparing", "正在准备更新…")
: t("dbUpgrade.downloading", "正在下载更新…")}
</span>
{percent !== null && (
<span className="tabular-nums text-muted-foreground">
{percent}%
</span>
)}
</div>
<div className="h-2 w-full overflow-hidden rounded-full bg-muted">
<div
className={`h-full rounded-full bg-amber-500 transition-all duration-200 ${
percent === null ? "w-1/3 animate-pulse" : ""
}`}
style={percent === null ? undefined : { width: `${percent}%` }}
/>
</div>
{progress && (
<p className="text-right text-xs tabular-nums text-muted-foreground">
{fmtMB(progress.downloaded)} MB
{progress.total ? ` / ${fmtMB(progress.total)} MB` : ""}
</p>
)}
</div>
)}
{phase === "error" && errorMsg && (
<p className="rounded-lg border border-red-300/60 bg-red-50 p-3 text-sm text-red-700 dark:border-red-500/40 dark:bg-red-950/40 dark:text-red-300">
{errorMsg}
</p>
)}
<div className="flex flex-wrap items-center gap-2">
{(phase === "upgradable" || phase === "error") && (
<Button
onClick={startUpgrade}
className="gap-2 bg-amber-500 text-white hover:bg-amber-600"
>
{phase === "error" ? (
<RefreshCw className="h-4 w-4" />
) : (
<Download className="h-4 w-4" />
)}
{phase === "error"
? t("dbUpgrade.retry", "重试升级")
: t("dbUpgrade.upgradeNow", "升级应用")}
</Button>
)}
{(phase === "incompatible" || phase === "error") && (
<Button
variant="outline"
className="gap-2"
onClick={() =>
void invoke("open_external", { url: RELEASES_URL })
}
>
<ExternalLink className="h-4 w-4" />
{t("dbUpgrade.openReleases", "打开发布页")}
</Button>
)}
<Button
variant="outline"
className="gap-2"
onClick={() => void invoke("open_app_config_folder")}
disabled={phase === "updating"}
>
<FolderOpen className="h-4 w-4" />
{t("dbUpgrade.openConfigDir", "打开配置目录")}
</Button>
<Button
variant="ghost"
className="ml-auto text-muted-foreground"
onClick={() => void exit(0)}
disabled={phase === "updating"}
>
{t("dbUpgrade.quit", "退出")}
</Button>
</div>
</div>
</div>
);
}
export default DatabaseUpgrade;
+17
View File
@@ -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",
+17
View File
@@ -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": "設定の読み込みに失敗しました",
+17
View File
@@ -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": "設定載入失敗",
+17
View File
@@ -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": "配置加载失败",
+15
View File
@@ -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(
<React.StrictMode>
<ThemeProvider defaultTheme="system" storageKey="cc-switch-theme">
<DatabaseUpgrade payload={initError} />
<Toaster />
</ThemeProvider>
</React.StrictMode>,
);
return;
}
if (initError && (initError.path || initError.error)) {
await handleConfigLoadError(initError);
// 注意:不会执行到这里,因为 exit(1) 会终止进程