feat:移除了弹窗,服务器添加sls

This commit is contained in:
AxTps committed 2026-09-08 22:39:45 +08:00
commit 6a295f9a7a
4082 files changed
+1322534

No files matched your search

+774
View File
@@ -0,0 +1,774 @@
use sqlx::sqlite::{SqliteConnectOptions, SqliteConnection};
use sqlx::{ConnectOptions, Connection};
use std::ffi::OsString;
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
const CURRENT_APP_VERSION: &str = env!("CARGO_PKG_VERSION");
const REQUIRED_APP_DB_TABLES: &[&str] =
&["_sqlx_migrations", "instances", "settings"];
enum IntegrityStatus {
Healthy,
Corrupt(String),
}
pub(crate) async fn restore_corrupt_app_db_if_needed(
db_path: &Path,
) -> crate::Result<()> {
if !db_path.try_exists()? {
return Ok(());
}
let backup_dir = app_db_backup_dir_for(db_path)?;
restore_corrupt_app_db_from(db_path, &backup_dir).await
}
async fn restore_corrupt_app_db_from(
db_path: &Path,
backup_dir: &Path,
) -> crate::Result<()> {
let corruption = match check_database_integrity(db_path).await? {
IntegrityStatus::Healthy => return Ok(()),
IntegrityStatus::Corrupt(corruption) => corruption,
};
tracing::error!(
database = %db_path.display(),
corruption,
"App database integrity check failed"
);
let Some(backup_path) = latest_healthy_app_db_backup(backup_dir).await?
else {
return Err(crate::ErrorKind::FSError(format!(
"App database {} is corrupted, and no healthy backup is available in {}",
db_path.display(),
backup_dir.display()
))
.into());
};
crate::util::io::create_dir_all(backup_dir).await?;
let corrupt_path = next_corrupt_database_path(backup_dir).await?;
let restore_staging_path = next_restore_staging_path(db_path).await?;
tokio::fs::copy(&backup_path, &restore_staging_path).await?;
if !matches!(
check_database_integrity(&restore_staging_path).await?,
IntegrityStatus::Healthy
) {
cleanup_staged_database(&restore_staging_path).await;
return Err(crate::ErrorKind::FSError(format!(
"App database backup {} became invalid while preparing recovery",
backup_path.display()
))
.into());
}
if let Err(error) = remove_database_sidecars(&restore_staging_path).await {
cleanup_staged_database(&restore_staging_path).await;
return Err(error);
}
if let Err(error) = archive_database_files(db_path, &corrupt_path).await {
cleanup_staged_database(&restore_staging_path).await;
return Err(error);
}
if let Err(error) = tokio::fs::rename(&restore_staging_path, db_path).await
{
let rollback_result =
restore_archived_database_files(&corrupt_path, db_path).await;
cleanup_staged_database(&restore_staging_path).await;
if let Err(rollback_error) = rollback_result {
return Err(crate::ErrorKind::FSError(format!(
"Failed to activate recovered app database: {error}; failed to restore original database: {rollback_error}"
))
.into());
}
return Err(error.into());
}
tracing::warn!(
database = %db_path.display(),
backup = %backup_path.display(),
corrupt_archive = %corrupt_path.display(),
"Recovered corrupted app database from latest healthy backup"
);
Ok(())
}
async fn check_database_integrity(
db_path: &Path,
) -> crate::Result<IntegrityStatus> {
let options = SqliteConnectOptions::new()
.filename(db_path)
.busy_timeout(Duration::from_secs(30))
.read_only(true)
.create_if_missing(false);
let mut conn = match options.connect().await {
Ok(conn) => conn,
Err(error) if is_sqlite_corruption(&error) => {
return Ok(IntegrityStatus::Corrupt(error.to_string()));
}
Err(error) => return Err(error.into()),
};
let result = sqlx::query_scalar::<_, String>("PRAGMA quick_check(1)")
.fetch_all(&mut conn)
.await;
let status = match result {
Ok(rows) if rows.len() == 1 && rows[0] == "ok" => {
IntegrityStatus::Healthy
}
Ok(rows) => IntegrityStatus::Corrupt(rows.join("; ")),
Err(error) if is_sqlite_corruption(&error) => {
IntegrityStatus::Corrupt(error.to_string())
}
Err(error) => return Err(error.into()),
};
conn.close().await?;
Ok(status)
}
fn is_sqlite_corruption(error: &sqlx::Error) -> bool {
let sqlx::Error::Database(error) = error else {
return false;
};
error
.code()
.and_then(|code| code.parse::<i32>().ok())
.is_some_and(|code| matches!(code & 0xff, 11 | 26))
}
async fn latest_healthy_app_db_backup(
backup_dir: &Path,
) -> crate::Result<Option<PathBuf>> {
let mut candidates = Vec::new();
let mut entries = match tokio::fs::read_dir(backup_dir).await {
Ok(entries) => entries,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
return Ok(None);
}
Err(error) => return Err(error.into()),
};
while let Some(entry) = entries.next_entry().await? {
let path = entry.path();
let Some(file_name) = path.file_name().and_then(|name| name.to_str())
else {
continue;
};
if !file_name.starts_with("app-db-before-")
|| path.extension().and_then(|extension| extension.to_str())
!= Some("db")
{
continue;
}
let modified = entry.metadata().await?.modified().unwrap_or(UNIX_EPOCH);
candidates.push((modified, path));
}
candidates.sort_by(|left, right| right.0.cmp(&left.0));
for (_, candidate) in candidates {
if matches!(
check_database_integrity(&candidate).await,
Ok(IntegrityStatus::Healthy)
) && is_app_database(&candidate).await?
{
return Ok(Some(candidate));
}
}
Ok(None)
}
async fn is_app_database(db_path: &Path) -> crate::Result<bool> {
let mut conn = open_read_only_db(db_path).await?;
let required_tables = serde_json::to_string(REQUIRED_APP_DB_TABLES)?;
let table_count: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM sqlite_master
WHERE type = 'table'
AND name IN (SELECT value FROM json_each(?))",
)
.bind(required_tables)
.fetch_one(&mut conn)
.await?;
conn.close().await?;
Ok(table_count == REQUIRED_APP_DB_TABLES.len() as i64)
}
async fn archive_database_files(
db_path: &Path,
archive_path: &Path,
) -> crate::Result<()> {
tokio::fs::rename(db_path, archive_path).await?;
let mut archived_sidecars = Vec::new();
for suffix in ["-wal", "-shm"] {
let source = sqlite_sidecar_path(db_path, suffix);
if !source.try_exists()? {
continue;
}
let destination = sqlite_sidecar_path(archive_path, suffix);
if let Err(error) = tokio::fs::rename(&source, &destination).await {
for (archived, original) in archived_sidecars.into_iter().rev() {
let _ = tokio::fs::rename(archived, original).await;
}
let _ = tokio::fs::rename(archive_path, db_path).await;
return Err(error.into());
}
archived_sidecars.push((destination, source));
}
Ok(())
}
async fn restore_archived_database_files(
archive_path: &Path,
db_path: &Path,
) -> crate::Result<()> {
tokio::fs::rename(archive_path, db_path).await?;
for suffix in ["-wal", "-shm"] {
let archived = sqlite_sidecar_path(archive_path, suffix);
if archived.try_exists()? {
tokio::fs::rename(archived, sqlite_sidecar_path(db_path, suffix))
.await?;
}
}
Ok(())
}
async fn next_corrupt_database_path(
backup_dir: &Path,
) -> crate::Result<PathBuf> {
let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
for suffix in 1.. {
let suffix = (suffix > 1).then(|| format!("-{suffix}"));
let path = backup_dir.join(format!(
"app-db-corrupt-{timestamp}{}.db",
suffix.as_deref().unwrap_or_default()
));
if !path.try_exists()? {
return Ok(path);
}
}
unreachable!()
}
async fn next_restore_staging_path(db_path: &Path) -> crate::Result<PathBuf> {
let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_nanos();
for suffix in 1.. {
let suffix = (suffix > 1).then(|| format!("-{suffix}"));
let path = db_path.with_file_name(format!(
"app.db.restore-{timestamp}{}.tmp",
suffix.as_deref().unwrap_or_default()
));
if !path.try_exists()? {
return Ok(path);
}
}
unreachable!()
}
fn sqlite_sidecar_path(db_path: &Path, suffix: &str) -> PathBuf {
let mut path = OsString::from(db_path.as_os_str());
path.push(suffix);
PathBuf::from(path)
}
async fn remove_database_sidecars(db_path: &Path) -> crate::Result<()> {
for suffix in ["-wal", "-shm"] {
let path = sqlite_sidecar_path(db_path, suffix);
match tokio::fs::remove_file(path).await {
Ok(()) => {}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => return Err(error.into()),
}
}
Ok(())
}
async fn cleanup_staged_database(db_path: &Path) {
let _ = tokio::fs::remove_file(db_path).await;
let _ = remove_database_sidecars(db_path).await;
}
pub(crate) async fn maybe_backup_existing_app_db(
db_path: &Path,
) -> crate::Result<()> {
if !db_path.try_exists()? {
tracing::debug!(
"Skipping pre-migration app database backup because {} does not exist",
db_path.display()
);
return Ok(());
}
tracing::debug!(
"Inspecting {} for a pre-migration app database backup",
db_path.display()
);
let mut conn = match open_read_only_db(db_path).await {
Ok(conn) => conn,
Err(err) => {
tracing::error!(
"Failed to open {} read-only before migrations: {err}",
db_path.display()
);
return Err(err);
}
};
let has_user_tables = match has_user_tables(&mut conn).await {
Ok(has_user_tables) => has_user_tables,
Err(err) => {
tracing::error!(
"Failed to inspect app database tables before migrations: {err}"
);
return Err(err);
}
};
if !has_user_tables {
tracing::debug!(
"Skipping pre-migration app database backup because {} has no app data tables",
db_path.display()
);
return Ok(());
}
let stored_version = match read_stored_app_version(&mut conn).await {
Ok(version) => version,
Err(err) => {
tracing::error!(
"Failed to read stored app database version before migrations: {err}"
);
return Err(err);
}
};
if stored_version.as_deref() == Some(CURRENT_APP_VERSION) {
tracing::debug!(
"Skipping pre-migration app database backup because app version is already recorded as {CURRENT_APP_VERSION}"
);
return Ok(());
}
let stored_version = stored_version.as_deref().unwrap_or("unknown");
let backup_dir = match app_db_backup_dir_for(db_path) {
Ok(path) => path,
Err(err) => {
tracing::error!(
"Failed to resolve app database backup directory before migrations: {err}"
);
return Err(err);
}
};
let backup_path = match next_backup_path(
&backup_dir,
stored_version,
CURRENT_APP_VERSION,
)
.await
{
Ok(path) => path,
Err(err) => {
tracing::error!(
"Failed to choose app database backup path in {} before migrations: {err}",
backup_dir.display()
);
return Err(err);
}
};
tracing::info!(
"Creating pre-migration app database backup from version {stored_version} before opening with version {CURRENT_APP_VERSION} at {}",
backup_path.display()
);
if let Err(err) = create_sqlite_snapshot(&mut conn, &backup_path).await {
tracing::error!(
"Failed to create pre-migration app database backup at {}: {err}",
backup_path.display()
);
return Err(err);
}
tracing::info!(
"Created pre-migration app database backup at {}",
backup_path.display()
);
Ok(())
}
pub(crate) async fn backup_app_db_for_update(
db_path: &Path,
target_version: &str,
) -> crate::Result<PathBuf> {
if !db_path.try_exists()? {
return Err(crate::ErrorKind::FSError(format!(
"Cannot back up missing app database {}",
db_path.display()
))
.into());
}
let channel = db_path
.parent()
.and_then(Path::file_name)
.and_then(|name| name.to_str())
.unwrap_or("release");
let settings_dir =
db_path.parent().and_then(Path::parent).ok_or_else(|| {
crate::ErrorKind::FSError(format!(
"App database path {} has no settings directory",
db_path.display()
))
})?;
let backup_dir = settings_dir.join("Backups").join("app-db").join(channel);
crate::util::io::create_dir_all(&backup_dir).await?;
let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let backup_path = backup_dir.join(format!(
"app-db-before-update-{}-{}-{timestamp}.db",
sanitize_version_for_filename(channel),
sanitize_version_for_filename(target_version),
));
let mut conn = open_read_only_db(db_path).await?;
create_sqlite_snapshot(&mut conn, &backup_path).await?;
conn.close().await?;
if !matches!(
check_database_integrity(&backup_path).await?,
IntegrityStatus::Healthy
) {
let _ = tokio::fs::remove_file(&backup_path).await;
return Err(crate::ErrorKind::FSError(format!(
"Update database backup {} failed its integrity check",
backup_path.display()
))
.into());
}
tracing::info!(
database = %db_path.display(),
backup = %backup_path.display(),
target_version,
"Created app database backup before update"
);
Ok(backup_path)
}
async fn open_read_only_db(db_path: &Path) -> crate::Result<SqliteConnection> {
let conn_options = SqliteConnectOptions::new()
.filename(db_path)
.busy_timeout(Duration::from_secs(30))
.read_only(true)
.create_if_missing(false);
Ok(conn_options.connect().await?)
}
pub fn app_db_backup_dir() -> crate::Result<PathBuf> {
if let Some(path) = std::env::var_os("THESEUS_DB_BACKUP_DIR") {
return Ok(PathBuf::from(path));
}
let app_identifier = if let Some(dir_info) =
crate::state::DirectoryInfo::global_handle_if_ready()
{
dir_info.app_identifier.clone()
} else {
crate::brand::BUNDLE_IDENTIFIER.to_string()
};
let base =
crate::state::DirectoryInfo::initial_settings_dir_path(&app_identifier)
.ok_or(crate::ErrorKind::FSError(
"Could not find valid config dir for app database backups"
.to_string(),
))?;
Ok(base.join("Backups").join("app-db"))
}
fn app_db_backup_dir_for(db_path: &Path) -> crate::Result<PathBuf> {
if let Some(path) = std::env::var_os("THESEUS_DB_BACKUP_DIR") {
return Ok(PathBuf::from(path));
}
let base = db_path.parent().ok_or_else(|| {
crate::ErrorKind::FSError(format!(
"App database path {} has no parent directory",
db_path.display()
))
})?;
let backup_dir = base.join("Backups").join("app-db");
match db_path
.parent()
.and_then(Path::file_name)
.and_then(|name| name.to_str())
{
Some("beta") | Some("release") => Ok(backup_dir.join(
db_path
.parent()
.and_then(Path::file_name)
.expect("database channel directory has a name"),
)),
_ => Ok(backup_dir),
}
}
async fn has_user_tables(conn: &mut SqliteConnection) -> crate::Result<bool> {
let count = sqlx::query_scalar!(
"
SELECT COUNT(*)
FROM sqlite_master
WHERE type = 'table'
AND name NOT LIKE 'sqlite_%'
AND name NOT IN ('_sqlx_migrations', 'app_metadata')
",
)
.fetch_one(&mut *conn)
.await?;
Ok(count > 0)
}
async fn read_stored_app_version(
conn: &mut SqliteConnection,
) -> crate::Result<Option<String>> {
if !has_table(conn, "app_metadata").await? {
return Ok(None);
}
Ok(sqlx::query_scalar!(
"SELECT value FROM app_metadata WHERE key = 'app_version'"
)
.fetch_optional(&mut *conn)
.await?)
}
async fn has_table(
conn: &mut SqliteConnection,
table_name: &str,
) -> crate::Result<bool> {
let count = sqlx::query_scalar!(
"
SELECT COUNT(*)
FROM sqlite_master
WHERE type = 'table' AND name = ?
",
table_name,
)
.fetch_one(&mut *conn)
.await?;
Ok(count > 0)
}
async fn next_backup_path(
backup_dir: &Path,
stored_version: &str,
current_version: &str,
) -> crate::Result<PathBuf> {
crate::util::io::create_dir_all(backup_dir).await?;
let stored_version = sanitize_version_for_filename(stored_version);
let current_version = sanitize_version_for_filename(current_version);
let backup_path = backup_dir.join(format!(
"app-db-before-{current_version}-from-{stored_version}.db"
));
if !backup_path.try_exists()? {
return Ok(backup_path);
}
for suffix in 2.. {
let backup_path = backup_dir.join(format!(
"app-db-before-{current_version}-from-{stored_version}-{suffix}.db"
));
if !backup_path.try_exists()? {
return Ok(backup_path);
}
}
unreachable!()
}
fn sanitize_version_for_filename(version: &str) -> String {
let mut sanitized = String::new();
let mut replaced_last_char = false;
for character in version.chars() {
if character.is_ascii_alphanumeric()
|| character == '.'
|| character == '-'
|| character == '_'
{
sanitized.push(character);
replaced_last_char = false;
} else if !replaced_last_char {
sanitized.push('-');
replaced_last_char = true;
}
}
let sanitized = sanitized.trim_matches(&['.', '-', '_'][..]);
if sanitized.is_empty() {
"unknown".to_string()
} else {
sanitized.to_string()
}
}
async fn create_sqlite_snapshot(
conn: &mut SqliteConnection,
backup_path: &Path,
) -> crate::Result<()> {
let backup_path = backup_path
.to_str()
.ok_or_else(|| crate::ErrorKind::UTFError(backup_path.to_path_buf()))?;
sqlx::query!("VACUUM INTO ?", backup_path)
.execute(&mut *conn)
.await?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
async fn create_test_app_db(path: &Path, marker: &str) {
let options = SqliteConnectOptions::new()
.filename(path)
.create_if_missing(true);
let mut conn = options.connect().await.unwrap();
sqlx::raw_sql(
"CREATE TABLE _sqlx_migrations (version INTEGER PRIMARY KEY);
CREATE TABLE instances (id TEXT PRIMARY KEY);
CREATE TABLE settings (id INTEGER PRIMARY KEY);
CREATE TABLE recovery_marker (value TEXT NOT NULL);",
)
.execute(&mut conn)
.await
.unwrap();
sqlx::query("INSERT INTO recovery_marker (value) VALUES (?)")
.bind(marker)
.execute(&mut conn)
.await
.unwrap();
conn.close().await.unwrap();
}
async fn read_marker(path: &Path) -> String {
let mut conn = open_read_only_db(path).await.unwrap();
let marker = sqlx::query_scalar("SELECT value FROM recovery_marker")
.fetch_one(&mut conn)
.await
.unwrap();
conn.close().await.unwrap();
marker
}
#[tokio::test]
async fn healthy_database_is_not_replaced() {
let temp = tempfile::tempdir().unwrap();
let db_path = temp.path().join("app.db");
let backup_dir = temp.path().join("Backups").join("app-db");
tokio::fs::create_dir_all(&backup_dir).await.unwrap();
create_test_app_db(&db_path, "current").await;
create_test_app_db(
&backup_dir.join("app-db-before-test-from-old.db"),
"backup",
)
.await;
restore_corrupt_app_db_from(&db_path, &backup_dir)
.await
.unwrap();
assert_eq!(read_marker(&db_path).await, "current");
}
#[tokio::test]
async fn corrupted_database_restores_latest_healthy_backup() {
let temp = tempfile::tempdir().unwrap();
let db_path = temp.path().join("app.db");
let backup_dir = temp.path().join("Backups").join("app-db");
tokio::fs::create_dir_all(&backup_dir).await.unwrap();
tokio::fs::write(&db_path, b"not a sqlite database")
.await
.unwrap();
create_test_app_db(
&backup_dir.join("app-db-before-test-from-old.db"),
"backup",
)
.await;
restore_corrupt_app_db_from(&db_path, &backup_dir)
.await
.unwrap();
assert_eq!(read_marker(&db_path).await, "backup");
let mut entries = tokio::fs::read_dir(&backup_dir).await.unwrap();
let mut found_corrupt_archive = false;
while let Some(entry) = entries.next_entry().await.unwrap() {
found_corrupt_archive |= entry
.file_name()
.to_str()
.is_some_and(|name| name.starts_with("app-db-corrupt-"));
}
assert!(found_corrupt_archive);
}
#[tokio::test]
async fn corrupted_database_without_backup_is_left_untouched() {
let temp = tempfile::tempdir().unwrap();
let db_path = temp.path().join("app.db");
let backup_dir = temp.path().join("Backups").join("app-db");
let corrupt_bytes = b"not a sqlite database";
tokio::fs::write(&db_path, corrupt_bytes).await.unwrap();
let error = restore_corrupt_app_db_from(&db_path, &backup_dir)
.await
.expect_err("recovery must require a healthy backup");
assert!(error.to_string().contains("no healthy backup"));
assert_eq!(tokio::fs::read(&db_path).await.unwrap(), corrupt_bytes);
}
#[tokio::test]
async fn update_backup_creates_a_healthy_channel_snapshot() {
let temp = tempfile::tempdir().unwrap();
let db_path = temp.path().join("release").join("app.db");
tokio::fs::create_dir_all(db_path.parent().unwrap())
.await
.unwrap();
create_test_app_db(&db_path, "release").await;
let backup_path =
backup_app_db_for_update(&db_path, "1.10.0").await.unwrap();
assert!(backup_path.exists());
assert_eq!(read_marker(&backup_path).await, "release");
assert!(matches!(
check_database_integrity(&backup_path).await.unwrap(),
IntegrityStatus::Healthy
));
}
}