Skip to main content

game_manager_lib/database/
core.rs

1//! Módulo de gerenciamento do banco de dados da aplicação.
2//!
3//! Gerencia a criação e inicialização do banco SQLite para a biblioteca de jogos e wishlist,
4//! além do armazenamento seguro de secrets (API keys, tokens) com criptografia.
5//!
6//! **Bancos de Dados:**
7//! - games.db: armazena jogos, wishlist, dados técnicos (pcgw_data) e subscriptions.
8//! - secrets.db: armazena secrets encriptados com AES-256-GCM.
9//! - cache.db: cache para respostas de APIs externas (RAWG, Steam).
10
11use crate::constants::{
12    DB_FILENAME_CACHE, DB_FILENAME_GAMES, DB_FILENAME_SECRETS, DB_JOURNAL_MODE,
13};
14use crate::errors::AppError;
15use crate::security;
16use crate::services::integration::pcgamingwiki::db::initialize_pcgamingwiki_tables;
17use rusqlite::{params, Connection};
18use std::sync::Mutex;
19use tauri::State;
20use tauri::{AppHandle, Manager};
21
22/// Define o estado global da aplicação com ambas as conexões
23pub struct AppState {
24    pub games_db: Mutex<Connection>,
25    pub secrets_db: Mutex<Connection>,
26    pub cache_db: Mutex<Connection>,
27}
28
29/// Retorna a versão atual do schema armazenada no banco
30pub fn current_schema_version(conn: &Connection) -> Result<u32, AppError> {
31    let version: i32 = conn
32        .query_row("PRAGMA user_version", [], |row| row.get(0))
33        .unwrap_or(0);
34
35    Ok(version.max(0) as u32)
36}
37
38/// Retorna a versão do schema esperada para esta versão do app
39pub fn expected_schema_version(app: &AppHandle) -> u32 {
40    // Usa o MAJOR da versão do app
41    let version = app.package_info().version.clone();
42    version.major as u32
43}
44
45// === INICIALIZAÇÃO CENTRALIZADA ===
46
47/// Inicializa ambos os bancos de dados e retorna o estado da aplicação
48///
49/// **Erros:**
50/// - Se não conseguir criar os diretórios
51/// - Se não conseguir abrir as conexões
52/// - Se falhar ao configurar WAL mode
53pub fn initialize_databases(app: &AppHandle) -> Result<AppState, String> {
54    let app_data_dir = app
55        .path()
56        .app_data_dir()
57        .map_err(|e| format!("Falha ao obter app_data_dir: {}", e))?;
58
59    std::fs::create_dir_all(&app_data_dir)
60        .map_err(|e| format!("Falha ao criar diretório: {}", e))?;
61
62    // Conexão para games.db
63    let games_path = app_data_dir.join(DB_FILENAME_GAMES);
64    let games_conn = Connection::open(&games_path)
65        .map_err(|e| format!("Erro ao abrir {}: {}", DB_FILENAME_GAMES, e))?;
66
67    games_conn
68        .pragma_update(None, "journal_mode", DB_JOURNAL_MODE)
69        .map_err(|e| format!("Erro ao configurar WAL no library.db: {}", e))?;
70
71    // Conexão para cache.db
72    let cache_path = app_data_dir.join(DB_FILENAME_CACHE);
73    let cache_conn = Connection::open(&cache_path)
74        .map_err(|e| format!("Erro ao abrir {}: {}", DB_FILENAME_CACHE, e))?;
75
76    cache_conn
77        .pragma_update(None, "journal_mode", DB_JOURNAL_MODE)
78        .map_err(|e| {
79            AppError::DatabaseWalConfigError("cache.db".to_string(), e.to_string()).to_string()
80        })?;
81
82    // Inicializa schema do cache
83    crate::services::cache::initialize_cache_db(&cache_conn)?;
84
85    // Conexão para secrets.db
86    let secrets_path = app_data_dir.join(DB_FILENAME_SECRETS);
87    let secrets_conn = Connection::open(&secrets_path)
88        .map_err(|e| format!("Erro ao abrir {}: {}", DB_FILENAME_SECRETS, e))?;
89
90    secrets_conn
91        .pragma_update(None, "journal_mode", DB_JOURNAL_MODE)
92        .map_err(|e| format!("Erro ao configurar WAL no secrets.db: {}", e))?;
93
94    // Executa migrations
95    crate::database::migrations::run_migrations(app, &games_conn)?;
96
97    // Cria schema completo
98    let schema_version = app.package_info().version.major as u32;
99    create_schema(&games_conn, schema_version)?;
100
101    Ok(AppState {
102        games_db: Mutex::new(games_conn),
103        secrets_db: Mutex::new(secrets_conn),
104        cache_db: Mutex::new(cache_conn),
105    })
106}
107
108// === BANCO DE DADOS DE GERENCIAMENTO DE BIBLIOTECAS E WISHLIST  ===
109
110/// Cria o schema completo do banco de dados (versão v4)
111///
112/// **Schema v4:**
113/// - Campos HLTB removidos
114/// - URLs legadas removidas (agora em external_links JSON)
115/// - users_score removido (substituído por steam_review_*)
116/// - Adicionadas as tabelas:
117///     - subscriptions
118///     - game_extras (detalhes técnicos)
119///     - game_data_paths
120///     - system_requirements
121fn create_schema(conn: &Connection, schema_version: u32) -> Result<(), String> {
122    conn.execute(
123        "CREATE TABLE IF NOT EXISTS games (
124            id TEXT PRIMARY KEY,
125            name TEXT NOT NULL,
126            cover_url TEXT,
127            platform TEXT NOT NULL,
128            platform_game_id TEXT NOT NULL,
129            installed BOOLEAN DEFAULT 0,
130            import_confidence TEXT,
131            install_path TEXT,
132            executable_path TEXT,
133            launch_args TEXT,
134            user_rating INTEGER,
135            favorite BOOLEAN DEFAULT 0,
136            status TEXT,
137            playtime INTEGER,
138            last_played TEXT,
139            added_at TEXT NOT NULL
140        )",
141        [],
142    )
143    .map_err(|e| e.to_string())?;
144
145    conn.execute(
146        "CREATE TABLE IF NOT EXISTS game_details (
147            game_id TEXT PRIMARY KEY,
148            steam_app_id TEXT,
149            developer TEXT,
150            publisher TEXT,
151            release_date TEXT,
152            genres TEXT,
153            tags TEXT,
154            series TEXT,
155            description_raw TEXT,
156            description_ptbr TEXT,
157            background_image TEXT,
158            critic_score INTEGER,
159            steam_review_label TEXT,
160            steam_review_count INTEGER,
161            steam_review_score REAL,
162            steam_review_updated_at TEXT,
163            esrb_rating TEXT,
164            is_adult BOOLEAN DEFAULT 0,
165            adult_tags TEXT,
166            external_links TEXT,
167            median_playtime INTEGER,
168            estimated_playtime REAL,
169            FOREIGN KEY(game_id) REFERENCES games(id) ON DELETE CASCADE
170        )",
171        [],
172    )
173    .map_err(|e| e.to_string())?;
174
175    conn.execute(
176        "CREATE TABLE IF NOT EXISTS wishlist (
177            id TEXT PRIMARY KEY,
178            name TEXT NOT NULL,
179            cover_url TEXT,
180            store_url TEXT,
181            store_platform TEXT,
182            current_price REAL,
183            normal_price REAL,
184            lowest_price REAL,
185            currency TEXT,
186            on_sale BOOLEAN DEFAULT 0,
187            voucher TEXT,
188            added_at TEXT,
189            itad_id TEXT
190        )",
191        [],
192    )
193    .map_err(|e| e.to_string())?;
194
195    conn.execute(
196        "CREATE TABLE IF NOT EXISTS subscriptions (
197        service TEXT PRIMARY KEY,   -- 'prime_gaming', 'game_pass', etc.
198        enabled BOOLEAN DEFAULT 0,
199        last_synced TEXT            -- ISO timestamp do último fetch
200    )",
201        [],
202    )
203    .map_err(|e| e.to_string())?;
204
205    // Índices
206    conn.execute(
207        "CREATE INDEX IF NOT EXISTS idx_name ON games(name COLLATE NOCASE)",
208        [],
209    )
210    .map_err(|e| e.to_string())?;
211
212    conn.execute(
213        "CREATE INDEX IF NOT EXISTS idx_platform ON games(platform)",
214        [],
215    )
216    .map_err(|e| e.to_string())?;
217
218    conn.execute(
219        "CREATE INDEX IF NOT EXISTS idx_favorite ON games(favorite)",
220        [],
221    )
222    .map_err(|e| e.to_string())?;
223
224    conn.execute("CREATE INDEX IF NOT EXISTS idx_status ON games(status)", [])
225        .map_err(|e| e.to_string())?;
226
227    // Tabelas extras - PCGamingWiki e scrapers relacionados
228    initialize_pcgamingwiki_tables(conn).map_err(|e| e.to_string())?;
229
230    // Marca versão do schema
231    conn.pragma_update(None, "user_version", schema_version)
232        .map_err(|e| format!("Erro ao definir versão do schema: {}", e))?;
233
234    Ok(())
235}
236
237/// Inicializa o banco de dados e verifica a versão do schema.
238///
239/// Se o banco estiver desatualizado, retorna erro com instruções para o usuário.
240#[tauri::command]
241pub fn init_db(app: AppHandle, state: State<AppState>) -> Result<String, String> {
242    let conn = state
243        .games_db
244        .lock()
245        .map_err(|_| "Falha ao bloquear mutex do games_db")?;
246
247    let current_version = current_schema_version(&conn).unwrap_or(0) as i32;
248    let expected_version = expected_schema_version(&app) as i32;
249
250    if current_version == 0 {
251        let schema_version = expected_schema_version(&app);
252        return Ok(format!("Banco de dados novo criado (v{})", schema_version));
253    }
254
255    if current_version != expected_version {
256        let app_data_dir = app
257            .path()
258            .app_data_dir()
259            .map_err(|e| format!("Falha ao obter app_data_dir: {}", e))?;
260
261        return Err(format!(
262            "Banco desatualizado: Schema atual: v{}, esperado: v{}. Faça backup, exclua o diretório da aplicação em: {:?} e reinicie para recriar o banco.",
263            current_version, expected_version, app_data_dir
264        ));
265    }
266
267    Ok(format!("Banco de dados OK (v{})", current_version))
268}
269
270/// Serializa tags para salvar no banco
271pub fn serialize_tags(tags: &[crate::models::GameTag]) -> Result<String, String> {
272    serde_json::to_string(tags).map_err(|e| e.to_string())
273}
274
275/// Deserializa tags do banco (com fallback para formato antigo)
276pub fn deserialize_tags(tags_json: &str) -> Vec<crate::models::GameTag> {
277    use crate::utils::tag_utils::{TagCategory, TagRole};
278
279    // Tenta deserializar como novo formato
280    if let Ok(tags) = serde_json::from_str::<Vec<crate::models::GameTag>>(tags_json) {
281        return tags;
282    }
283
284    // Fallback: formato antigo (string separada por vírgulas)
285    tags_json
286        .split(',')
287        .map(|s| s.trim())
288        .filter(|s| !s.is_empty())
289        .map(|slug| crate::models::GameTag {
290            slug: slug.to_string(),
291            name: slug.to_string(),
292            category: TagCategory::Meta,
293            role: TagRole::Context,
294            relevance: 5.0,
295        })
296        .collect()
297}
298
299// === BANCO DE DADOS PARA GERENCIAMENTO DE API KEYS (secrets.db) ===
300
301/// Obtém conexão com o banco de secrets a partir do AppState.
302/// Cria automaticamente a tabela `encrypted_keys` se não existir.
303fn get_secrets_connection<'a>(
304    state: &'a State<AppState>,
305) -> Result<std::sync::MutexGuard<'a, Connection>, String> {
306    let conn = state
307        .secrets_db
308        .lock()
309        .map_err(|_| "Falha ao bloquear mutex do secrets_db".to_string())?;
310
311    conn.execute(
312        r#"
313        CREATE TABLE IF NOT EXISTS encrypted_keys (
314            key TEXT PRIMARY KEY,
315            value TEXT NOT NULL
316        )
317        "#,
318        [],
319    )
320    .map_err(|e: rusqlite::Error| e.to_string())?;
321
322    Ok(conn)
323}
324
325/// Salva um secret encriptado no banco. Se a chave já existir, o valor é substituído (upsert).
326pub fn set_secret(app: &AppHandle, key_name: &str, value: &str) -> Result<(), AppError> {
327    let state: tauri::State<AppState> = app.state();
328    let conn = get_secrets_connection(&state)?;
329
330    let encrypted = security::encrypt(app, value)?;
331
332    conn.execute(
333        "INSERT OR REPLACE INTO encrypted_keys (key, value) VALUES (?1, ?2)",
334        params![key_name, encrypted],
335    )?;
336
337    Ok(())
338}
339
340/// Recupera e decripta um secret do banco. Se a chave não existir, retorna string vazia ao invés de erro.
341pub fn get_secret(app: &AppHandle, key_name: &str) -> Result<String, AppError> {
342    let state: tauri::State<AppState> = app.state();
343    let conn = get_secrets_connection(&state)?;
344
345    let result: Result<String, rusqlite::Error> = conn.query_row(
346        "SELECT value FROM encrypted_keys WHERE key = ?1",
347        params![key_name],
348        |row| row.get::<_, String>(0),
349    );
350
351    match result {
352        Ok(encrypted) => {
353            let decrypted = security::decrypt(app, &encrypted)?;
354            Ok(decrypted)
355        }
356        Err(rusqlite::Error::QueryReturnedNoRows) => Ok(String::new()),
357        Err(e) => Err(AppError::DatabaseError(e.to_string())),
358    }
359}
360
361/// Remove um secret do banco permanentemente.
362pub fn delete_secret(app: &AppHandle, key_name: &str) -> Result<(), AppError> {
363    let state: tauri::State<AppState> = app.state();
364    let conn = get_secrets_connection(&state)?;
365
366    conn.execute(
367        "DELETE FROM encrypted_keys WHERE key = ?1",
368        params![key_name],
369    )?;
370
371    Ok(())
372}
373
374/// Retorna lista de chaves de secrets suportadas pela aplicação.
375pub fn list_supported_keys() -> Vec<&'static str> {
376    vec![
377        "steam_id",
378        "steam_api_key",
379        "rawg_api_key",
380        "gemini_api_key",
381        "gamebrain_api_key",
382    ]
383}