use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::path::PathBuf; use tauri::State; use crate::crypto::key_manager; use crate::db::connection::DbManager; use crate::db::schema; use crate::error::{AppError, AppResult}; use crate::shared::session::SessionState; #[derive(Deserialize)] #[serde(rename_all = "camelCase")] pub struct ImportMigrationData { pub migration_file_path: String, } #[derive(Serialize)] #[serde(rename_all = "camelCase")] pub struct MigrationResult { pub success: bool, pub user_id: Option, pub error: Option, } #[derive(Serialize)] #[serde(rename_all = "camelCase")] pub struct MigrationCheckResult { pub found: bool, pub user_id: Option, pub has_db: bool, pub migration_path: Option, } #[derive(Serialize)] #[serde(rename_all = "camelCase")] pub struct AutoMigrationResult { pub migrated: bool, pub user_id: Option, pub token_migrated: bool, pub key_migrated: bool, pub pin_migrated: bool, pub db_migrated: bool, pub error: Option, } #[derive(Deserialize)] struct ElectronMigrationFile { version: u32, user_id: String, encryption_key: String, pin_hash: Option, db_source: String, } // ─── Electron safeStorage vault format ─────────────────────────────────────── #[derive(Deserialize, Default)] struct ElectronVault { #[serde(default)] token: Option, #[serde(flatten)] extra: HashMap, } // ─── Auto-migration from Electron ──────────────────────────────────────────── fn electron_data_paths() -> Vec { let home = dirs_next::home_dir().unwrap_or_else(|| PathBuf::from(".")); let mut paths = Vec::new(); #[cfg(target_os = "macos")] { let app_support = home.join("Library").join("Application Support"); paths.push(app_support.join("ERitors Scribe")); paths.push(app_support.join("Electron")); } #[cfg(target_os = "windows")] { if let Some(appdata) = dirs_next::data_dir() { paths.push(appdata.join("ERitors Scribe")); paths.push(appdata.join("Electron")); } } paths } fn find_electron_vault(data_path: &PathBuf) -> Option { let candidate = data_path.join("secure-config.json"); if candidate.exists() { return Some(candidate); } None } fn find_electron_dbs(data_path: &PathBuf) -> Vec<(String, PathBuf)> { let mut result = Vec::new(); if let Ok(entries) = std::fs::read_dir(data_path) { for entry in entries.flatten() { let name = entry.file_name().to_string_lossy().to_string(); if name.starts_with("eritors-local-") && name.ends_with(".db") { let user_id = name .trim_start_matches("eritors-local-") .trim_end_matches(".db") .to_string(); result.push((user_id, entry.path())); } } } result } #[cfg(target_os = "macos")] fn decrypt_electron_value(encrypted_b64: &str) -> Option { use aes::Aes128; use cbc::cipher::{block_padding::Pkcs7, BlockDecryptMut as _, KeyIvInit}; use pbkdf2::pbkdf2_hmac; use sha1::Sha1; type Aes128CbcDec = cbc::Decryptor; let b64 = if encrypted_b64.starts_with("encrypted:") { &encrypted_b64[10..] } else { encrypted_b64 }; let data = base64::Engine::decode( &base64::engine::general_purpose::STANDARD, b64, ).ok()?; if !data.starts_with(b"v10") { return None; } let ciphertext = &data[3..]; let app_names = ["ERitors Scribe", "Electron"]; for app_name in &app_names { let service = format!("{} Safe Storage", app_name); if let Ok(entry) = keyring::Entry::new(&service, app_name) { if let Ok(password) = entry.get_password() { let mut key = [0u8; 16]; pbkdf2_hmac::( password.as_bytes(), b"saltysalt", 1003, &mut key, ); let iv = [0x20u8; 16]; let mut buf = ciphertext.to_vec(); if let Ok(decryptor) = Aes128CbcDec::new_from_slices(&key, &iv) { if let Ok(decrypted) = decryptor.decrypt_padded_mut::(&mut buf) { if let Ok(s) = String::from_utf8(decrypted.to_vec()) { return Some(s); } } } } } } None } #[cfg(not(target_os = "macos"))] fn decrypt_electron_value(_encrypted_b64: &str) -> Option { None } fn read_electron_vault(vault_path: &PathBuf) -> HashMap { let mut result = HashMap::new(); let Ok(content) = std::fs::read_to_string(vault_path) else { return result; }; let Ok(parsed) = serde_json::from_str::>(&content) else { return result; }; for (key, value) in &parsed { if let Some(s) = value.as_str() { if let Some(decrypted) = decrypt_electron_value(s) { result.insert(key.clone(), decrypted); } else if !s.starts_with("encrypted:") { result.insert(key.clone(), s.to_string()); } } } result } /// Called automatically at startup in production — detects and migrates Electron data. #[tauri::command] pub fn auto_migrate_electron( db: State, session: State, ) -> AppResult { let already_migrated = key_manager::get_last_user_id().ok().flatten(); if already_migrated.is_some() { return Ok(AutoMigrationResult { migrated: false, user_id: None, token_migrated: false, key_migrated: false, pin_migrated: false, db_migrated: false, error: None, }); } for data_path in electron_data_paths() { if !data_path.exists() { continue; } let dbs = find_electron_dbs(&data_path); if dbs.is_empty() { continue; } let vault_data = if let Some(vault_path) = find_electron_vault(&data_path) { read_electron_vault(&vault_path) } else { HashMap::new() }; let token = vault_data.get("token").cloned(); let last_user_id = vault_data.get("lastUserId").cloned() .or_else(|| dbs.first().map(|(uid, _)| uid.clone())); let Some(user_id) = last_user_id else { continue; }; let encryption_key = vault_data.get(&format!("encryptionKey-{}", user_id)).cloned(); let pin_hash = vault_data.get(&format!("pinHash-{}", user_id)).cloned(); // Copy DB files let mut db_migrated = false; for (uid, db_path) in &dbs { if uid != &user_id { continue; } { let mut db_manager = db.lock() .map_err(|e| AppError::Internal(format!("DB lock: {}", e)))?; db_manager.initialize(uid)?; let dest = db_manager.get_db_path(uid); if let Some(parent) = dest.parent() { let _ = std::fs::create_dir_all(parent); } if std::fs::copy(db_path, &dest).is_ok() { for ext in &["db-wal", "db-shm"] { let src_extra = db_path.with_extension(ext); if src_extra.exists() { let dst_extra = dest.with_extension(ext); let _ = std::fs::copy(&src_extra, &dst_extra); } } db_migrated = true; } let conn = db_manager.get_connection(uid)?; schema::initialize_schema(conn)?; if !cfg!(debug_assertions) { schema::run_migrations(conn)?; } } } // Store secrets let token_migrated = if let Some(ref t) = token { key_manager::set_token(t).is_ok() } else { false }; let key_migrated = if let Some(ref k) = encryption_key { key_manager::set_user_encryption_key(&user_id, k).is_ok() } else { false }; let pin_migrated = if let Some(ref p) = pin_hash { key_manager::set_pin_hash(&user_id, p).is_ok() } else { false }; key_manager::set_last_user_id(&user_id)?; let mut session_guard = session.lock() .map_err(|e| AppError::Internal(format!("Session lock: {}", e)))?; session_guard.user_id = Some(user_id.clone()); return Ok(AutoMigrationResult { migrated: true, user_id: Some(user_id), token_migrated, key_migrated, pin_migrated, db_migrated, error: None, }); } Ok(AutoMigrationResult { migrated: false, user_id: None, token_migrated: false, key_migrated: false, pin_migrated: false, db_migrated: false, error: None, }) } /// Check if an Electron migration file + DB exist at the given path (legacy). #[tauri::command] pub fn check_electron_migration(migration_file_path: String) -> AppResult { let path = PathBuf::from(&migration_file_path); if !path.exists() { return Ok(MigrationCheckResult { found: false, user_id: None, has_db: false, migration_path: None }); } let content = std::fs::read_to_string(&path).map_err(AppError::Io)?; let migration: ElectronMigrationFile = serde_json::from_str(&content)?; let db_name = format!("eritors-local-{}.db", migration.user_id); let db_path = path.parent().unwrap_or(&path).join(&db_name); Ok(MigrationCheckResult { found: true, user_id: Some(migration.user_id), has_db: db_path.exists(), migration_path: Some(migration_file_path), }) } /// Import from Electron export file (legacy). #[tauri::command] pub fn import_from_electron( data: ImportMigrationData, db: State, session: State, ) -> AppResult { let migration_path = PathBuf::from(&data.migration_file_path); if !migration_path.exists() { return Ok(MigrationResult { success: false, user_id: None, error: Some("Migration file not found".into()) }); } let content = std::fs::read_to_string(&migration_path)?; let migration: ElectronMigrationFile = serde_json::from_str(&content)?; if migration.version != 1 { return Ok(MigrationResult { success: false, user_id: None, error: Some(format!("Unsupported version: {}", migration.version)) }); } let source_db = migration_path.parent() .unwrap_or_else(|| std::path::Path::new(".")) .join(format!("eritors-local-{}.db", migration.user_id)); if !source_db.exists() { let electron_db = PathBuf::from(&migration.db_source); if !electron_db.exists() { return Ok(MigrationResult { success: false, user_id: None, error: Some("Database file not found.".into()) }); } copy_db_files(&electron_db, &migration.user_id, &db)?; } else { copy_db_files(&source_db, &migration.user_id, &db)?; } key_manager::set_user_encryption_key(&migration.user_id, &migration.encryption_key)?; if let Some(ref pin_hash) = migration.pin_hash { key_manager::set_pin_hash(&migration.user_id, pin_hash)?; } key_manager::set_last_user_id(&migration.user_id)?; { let mut db_manager = db.lock().map_err(|e| AppError::Internal(format!("DB lock: {}", e)))?; db_manager.initialize(&migration.user_id)?; let conn = db_manager.get_connection(&migration.user_id)?; schema::initialize_schema(conn)?; if !cfg!(debug_assertions) { schema::run_migrations(conn)?; } } let mut session_guard = session.lock().map_err(|e| AppError::Internal(format!("Session lock: {}", e)))?; session_guard.user_id = Some(migration.user_id.clone()); cleanup_migration_files(&migration_path, &migration.user_id); Ok(MigrationResult { success: true, user_id: Some(migration.user_id), error: None }) } fn cleanup_migration_files(migration_path: &PathBuf, user_id: &str) { let _ = std::fs::remove_file(migration_path); if let Some(parent) = migration_path.parent() { let db_copy = parent.join(format!("eritors-local-{}.db", user_id)); let _ = std::fs::remove_file(&db_copy); let _ = std::fs::remove_file(db_copy.with_extension("db-wal")); let _ = std::fs::remove_file(db_copy.with_extension("db-shm")); } } fn copy_db_files(source_db: &PathBuf, user_id: &str, db: &State) -> AppResult<()> { let db_manager = db.lock().map_err(|e| AppError::Internal(format!("DB lock: {}", e)))?; let dest_db = db_manager.get_db_path(user_id); if let Some(parent) = dest_db.parent() { std::fs::create_dir_all(parent)?; } std::fs::copy(source_db, &dest_db)?; for ext in &["db-wal", "db-shm"] { let src = source_db.with_extension(ext); if src.exists() { let dst = dest_db.with_file_name(format!("eritors-local-{}.{}", user_id, ext)); let _ = std::fs::copy(&src, &dst); } } Ok(()) }