Files
CC-Switch/src-tauri/src/init_status.rs
T
SaladDay edeee25fae 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).
2026-06-24 15:07:42 +08:00

125 lines
3.8 KiB
Rust

use serde::Serialize;
use std::sync::{OnceLock, RwLock};
#[derive(Debug, Clone, Serialize)]
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();
fn cell() -> &'static RwLock<Option<InitErrorPayload>> {
INIT_ERROR.get_or_init(|| RwLock::new(None))
}
pub fn set_init_error(payload: InitErrorPayload) {
#[allow(clippy::unwrap_used)]
if let Ok(mut guard) = cell().write() {
*guard = Some(payload);
}
}
pub fn get_init_error() -> Option<InitErrorPayload> {
cell().read().ok()?.clone()
}
// ============================================================
// 迁移结果状态
// ============================================================
static MIGRATION_SUCCESS: OnceLock<RwLock<bool>> = OnceLock::new();
fn migration_cell() -> &'static RwLock<bool> {
MIGRATION_SUCCESS.get_or_init(|| RwLock::new(false))
}
pub fn set_migration_success() {
if let Ok(mut guard) = migration_cell().write() {
*guard = true;
}
}
/// 获取并消费迁移成功状态(只返回一次 true,之后返回 false)
pub fn take_migration_success() -> bool {
if let Ok(mut guard) = migration_cell().write() {
let val = *guard;
*guard = false;
val
} else {
false
}
}
// ============================================================
// Skills SSOT 迁移结果状态
// ============================================================
#[derive(Debug, Clone, Serialize)]
pub struct SkillsMigrationPayload {
pub count: usize,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
}
static SKILLS_MIGRATION_RESULT: OnceLock<RwLock<Option<SkillsMigrationPayload>>> = OnceLock::new();
fn skills_migration_cell() -> &'static RwLock<Option<SkillsMigrationPayload>> {
SKILLS_MIGRATION_RESULT.get_or_init(|| RwLock::new(None))
}
pub fn set_skills_migration_result(count: usize) {
if let Ok(mut guard) = skills_migration_cell().write() {
*guard = Some(SkillsMigrationPayload { count, error: None });
}
}
pub fn set_skills_migration_error(error: String) {
if let Ok(mut guard) = skills_migration_cell().write() {
*guard = Some(SkillsMigrationPayload {
count: 0,
error: Some(error),
});
}
}
/// 获取并消费 Skills 迁移结果(只返回一次 Some,之后返回 None)
pub fn take_skills_migration_result() -> Option<SkillsMigrationPayload> {
if let Ok(mut guard) = skills_migration_cell().write() {
guard.take()
} else {
None
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn init_error_roundtrip() {
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");
assert_eq!(got.path, payload.path);
assert_eq!(got.error, payload.error);
}
}