mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-24 12:44:18 +08:00
edeee25fae
* 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).
125 lines
3.8 KiB
Rust
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);
|
|
}
|
|
}
|