mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-24 12:44:18 +08:00
feat(db): in-app recovery screen with upgrade button when DB version is too new (#4575)
* 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).
* fix(db): pre-check too-new DB before schema writes; exit on close in recovery mode
Addresses review feedback on the too-new-DB recovery flow.
- P1: stored_user_version_exceeds_supported() is now checked BEFORE
Database::init(), so create_tables()'s DDL (incl. the unconditional
DROP INDEX/DROP TABLE IF EXISTS failover_queue) never runs against a
database whose user_version we cannot understand. The earlier
post-init-failure recovery branch is removed; the pre-check is the
single authoritative guard, so "don't write to a DB we can't read"
now holds from the very first DB access.
- P2: the window CloseRequested handler now detects recovery mode
(init_error kind = db_version_too_new) and exits the app instead of
honoring minimize_to_tray_on_close. Recovery mode returns before the
tray is created, so hiding the window would leave the app running with
no tray to bring it back; native-close now quits cleanly.
Verified: cargo fmt --check clean; cross-model Rust review of both fixes
(compile-correctness + no normal-startup/close regression).
This commit is contained in:
@@ -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> {
|
||||
|
||||
@@ -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()))?;
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -270,6 +270,16 @@ pub fn run() {
|
||||
// 拦截窗口关闭:根据设置决定是否最小化到托盘
|
||||
.on_window_event(|window, event| {
|
||||
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
|
||||
// 数据库版本过新的恢复模式下没有托盘可唤回,关闭即退出,避免应用隐身后台
|
||||
let in_db_recovery = crate::init_status::get_init_error()
|
||||
.map(|p| p.kind.as_deref() == Some("db_version_too_new"))
|
||||
.unwrap_or(false);
|
||||
if in_db_recovery {
|
||||
api.prevent_close();
|
||||
window.app_handle().exit(0);
|
||||
return;
|
||||
}
|
||||
|
||||
let settings = crate::settings::get_settings();
|
||||
|
||||
if settings.minimize_to_tray_on_close {
|
||||
@@ -403,6 +413,35 @@ pub fn run() {
|
||||
// 说明:从 v3.8.* 升级的用户通常会走到这里的 SQLite schema 迁移,
|
||||
// 若迁移失败(数据库损坏/权限不足/user_version 过新等),需要给用户明确提示,
|
||||
// 否则表现可能只是“应用打不开/闪退”。
|
||||
//
|
||||
// 预检:数据库版本过新时,必须先于任何 schema 写操作(create_tables 内含
|
||||
// DROP/ALTER 等 DDL)进入恢复界面,避免旧应用对读不懂的更新版 DB 落写。
|
||||
match crate::database::Database::stored_user_version_exceeds_supported(&db_path) {
|
||||
Ok(Some(version)) => {
|
||||
log::warn!("数据库版本过新(v{version}),引导用户在应用内升级应用");
|
||||
crate::init_status::set_init_error(crate::init_status::InitErrorPayload {
|
||||
path: db_path.display().to_string(),
|
||||
error: format!(
|
||||
"数据库版本过新({version}),当前应用仅支持 {},请升级应用后再尝试。",
|
||||
crate::database::SCHEMA_VERSION
|
||||
),
|
||||
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(());
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(e) => {
|
||||
log::warn!("预检数据库版本失败,继续正常初始化流程: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
let db = loop {
|
||||
match crate::database::Database::init() {
|
||||
Ok(db) => break Arc::new(db),
|
||||
@@ -1187,6 +1226,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,
|
||||
|
||||
Reference in New Issue
Block a user