//! Application state holding database pools and repository handles. use goingson_core::{ AttachmentRepository, BackupSettingsRepository, ContactRepository, DailyNoteRepository, EmailAccountRepository, EmailRepository, EventRepository, MilestoneRepository, MonthlyReviewRepository, ProjectRepository, SavedViewRepository, SearchRepository, StatsRepository, SyncAccountRepository, TaskRepository, WeeklyReviewRepository, }; use goingson_db_sqlite::{ SqliteAttachmentRepository, SqliteBackupSettingsRepository, SqliteContactRepository, SqliteDailyNoteRepository, SqliteEmailAccountRepository, SqliteEmailRepository, SqliteEventRepository, SqliteMilestoneRepository, SqliteMonthlyReviewRepository, SqliteProjectRepository, SqliteSavedViewRepository, SqliteSearchRepository, SqliteStatsRepository, SqliteSyncAccountRepository, SqliteTaskRepository, SqliteWeeklyReviewRepository, }; use sqlx::SqlitePool; use std::path::PathBuf; use std::sync::{Arc, Mutex, RwLock}; use tokio::sync::Mutex as TokioMutex; use synckit_client::{SyncKitClient, SyncKitConfig}; use tauri::{AppHandle, Manager}; use tracing::{debug, info, instrument, warn}; /// Default SyncKit server URL. pub const SYNC_SERVER_URL: &str = "https://makenot.work"; /// Load the SyncKit config from synckit.toml in the project root (compile-time embed). /// The API key is a public client identifier, not a secret. const SYNCKIT_TOML: &str = include_str!("../../synckit.toml"); /// Application state holding database connections and repositories pub struct AppState { pub pool: SqlitePool, pub projects: Arc, pub tasks: Arc, pub events: Arc, pub emails: Arc, pub email_accounts: Arc, pub contacts: Arc, pub daily_notes: Arc, pub attachments: Arc, pub stats: Arc, pub search: Arc, pub milestones: Arc, pub saved_views: Arc, pub weekly_reviews: Arc, pub monthly_reviews: Arc, pub backup_settings: Arc, pub sync_accounts: Arc, pub sync_client: RwLock>>, pub sync_lock: Arc>, /// Per-account email sync locks to prevent concurrent syncs on the same account. pub email_sync_locks: Arc>>, /// Per-account token refresh locks to prevent concurrent refreshes. pub token_refresh_locks: Arc>>>>, /// Pending OAuth flows keyed by state token (CSRF + PKCE verifier stored server-side). pub pending_oauth_flows: Arc>>, pub data_dir: PathBuf, } /// Server-side storage for a pending OAuth flow. /// Keeps the PKCE code_verifier out of the frontend and enables state validation. #[derive(Debug)] pub struct PendingOAuthFlow { pub code_verifier: String, pub provider_id: String, pub port: u16, } impl AppState { #[instrument(skip(app), name = "AppState::new")] pub async fn new(app: &AppHandle) -> Result { // Get app data directory let app_data_dir = app .path() .app_data_dir() .map_err(|e| format!("Failed to get app data dir: {}", e))?; info!(?app_data_dir, "Initializing application state"); // Create directory if it doesn't exist std::fs::create_dir_all(&app_data_dir) .map_err(|e| format!("Failed to create app data dir: {}", e))?; let db_path = app_data_dir.join("goingson.db"); debug!(?db_path, "Connecting to database"); // Create database connection pool (WAL mode, FK enforcement, pool limits) let pool = goingson_db_sqlite::init_pool(Some(db_path.to_str().unwrap_or("goingson.db"))) .await .map_err(|e| format!("Failed to connect to database: {}", e))?; info!("Database connection established"); // Run migrations debug!("Running database migrations"); sqlx::migrate!("../migrations/sqlite") .run(&pool) .await .map_err(|e| format!("Failed to run migrations: {}", e))?; info!("Database migrations completed"); // Reset applying_remote flag in case app crashed during pull if let Err(e) = sqlx::query("UPDATE sync_state SET value = '0' WHERE key = 'applying_remote'") .execute(&pool) .await { warn!("Failed to reset applying_remote flag: {e}"); } // Migrate email IDs from random v4 to deterministic v5 goingson_db_sqlite::migrations::migrate_deterministic_email_ids(&pool) .await .map_err(|e| format!("Email ID migration failed: {e}"))?; // Ensure desktop user exists (single-user mode) ensure_desktop_user_exists(&pool).await?; // Create repositories let projects = Arc::new(SqliteProjectRepository::new(pool.clone())); let tasks = Arc::new(SqliteTaskRepository::new(pool.clone())); let events = Arc::new(SqliteEventRepository::new(pool.clone())); let emails = Arc::new(SqliteEmailRepository::new(pool.clone())); let email_accounts = Arc::new(SqliteEmailAccountRepository::new(pool.clone())); let contacts = Arc::new(SqliteContactRepository::new(pool.clone())); let daily_notes = Arc::new(SqliteDailyNoteRepository::new(pool.clone())); let attachments = Arc::new(SqliteAttachmentRepository::new(pool.clone())); let stats = Arc::new(SqliteStatsRepository::new(pool.clone())); let search = Arc::new(SqliteSearchRepository::new(pool.clone())); let milestones = Arc::new(SqliteMilestoneRepository::new(pool.clone())); let saved_views = Arc::new(SqliteSavedViewRepository::new(pool.clone())); let weekly_reviews = Arc::new(SqliteWeeklyReviewRepository::new(pool.clone())); let monthly_reviews = Arc::new(SqliteMonthlyReviewRepository::new(pool.clone())); let backup_settings = Arc::new(SqliteBackupSettingsRepository::new(pool.clone())); let sync_accounts = Arc::new(SqliteSyncAccountRepository::new(pool.clone())); // Initialize SyncKit client from saved key or env vars (optional) let sync_client = load_sync_client(&app_data_dir); Ok(Self { pool, projects, tasks, events, emails, email_accounts, contacts, daily_notes, attachments, stats, search, milestones, saved_views, weekly_reviews, monthly_reviews, backup_settings, sync_accounts, sync_client: RwLock::new(sync_client.map(Arc::new)), sync_lock: Arc::new(TokioMutex::new(())), email_sync_locks: Arc::new(Mutex::new(std::collections::HashSet::new())), token_refresh_locks: Arc::new(Mutex::new(std::collections::HashMap::new())), pending_oauth_flows: Arc::new(Mutex::new(std::collections::HashMap::new())), data_dir: app_data_dir, }) } /// Gets or creates a per-account token refresh lock. pub fn token_refresh_lock(&self, account_id: uuid::Uuid) -> Arc> { let mut locks = self.token_refresh_locks.lock().unwrap_or_else(|e| e.into_inner()); locks.entry(account_id) .or_insert_with(|| Arc::new(TokioMutex::new(()))) .clone() } } /// Load a SyncKit API key from the keychain, migrating from plaintext file if needed. fn load_api_key(data_dir: &std::path::Path) -> Option { // Migrate plaintext file to keychain (one-time) let key_path = data_dir.join("sync_api_key"); if key_path.exists() { if let Ok(file_key) = std::fs::read_to_string(&key_path) { let file_key = file_key.trim().to_string(); if !file_key.is_empty() { if crate::oauth::CredentialStore::get_sync_api_key().is_none() { match crate::oauth::CredentialStore::store_sync_api_key(&file_key) { Ok(()) => info!("Migrated sync API key from file to keychain"), Err(e) => warn!("Failed to migrate sync API key to keychain: {}", e), } } // Delete plaintext file regardless (keychain now has it or already had it) if let Err(e) = std::fs::remove_file(&key_path) { warn!("Failed to remove plaintext sync API key file: {}", e); } } } } // Load from keychain if let Some(key) = crate::oauth::CredentialStore::get_sync_api_key() { return Some(key); } if let Ok(key) = std::env::var("GOINGSON_SYNC_API_KEY") { return Some(key); } // Fall back to bundled synckit.toml parse_synckit_toml_key().map(String::from) } /// Extract the api_key value from the bundled synckit.toml. fn parse_synckit_toml_key() -> Option<&'static str> { for line in SYNCKIT_TOML.lines() { let line = line.trim(); if let Some(rest) = line.strip_prefix("api_key") { let rest = rest.trim_start(); if let Some(rest) = rest.strip_prefix('=') { let rest = rest.trim(); let rest = rest.trim_matches('"'); if !rest.is_empty() { return Some(rest); } } } } None } /// Create a SyncKitClient from a saved or env-provided API key. fn load_sync_client(data_dir: &std::path::Path) -> Option { let api_key = load_api_key(data_dir)?; let server_url = std::env::var("GOINGSON_SYNC_SERVER_URL") .unwrap_or_else(|_| SYNC_SERVER_URL.to_string()); info!(%server_url, "SyncKit client configured"); let client = SyncKitClient::new(SyncKitConfig { server_url, api_key }); // Try to restore session from keychain match crate::oauth::CredentialStore::get_sync_token() { Some(creds) => { info!("Restoring sync session from keychain (user={})", creds.user_id); client.restore_session(&creds.token, creds.user_id, creds.app_id); if client.is_token_expired() { warn!("Stored sync token is expired, clearing session"); client.clear_session(); } else { info!("Sync session restored successfully"); match client.try_load_key_from_keychain() { Ok(true) => info!("Sync encryption key loaded from keychain"), Ok(false) => debug!("No sync encryption key in keychain"), Err(e) => warn!("Failed to load sync encryption key: {}", e), } } } None => { info!("No sync token found in keychain — user will need to authenticate"); } } Some(client) } /// Save an API key to the OS keychain. pub fn save_api_key(_data_dir: &std::path::Path, api_key: &str) { if let Err(e) = crate::oauth::CredentialStore::store_sync_api_key(api_key) { tracing::error!("Failed to save API key to keychain: {e}"); } } /// Fixed user ID for single-user desktop app pub const DESKTOP_USER_ID: goingson_core::UserId = goingson_core::UserId::from_uuid(uuid::Uuid::from_u128(1)); /// Ensure the desktop user exists in the database #[instrument(skip(pool))] async fn ensure_desktop_user_exists(pool: &SqlitePool) -> Result<(), String> { let user_id = DESKTOP_USER_ID.to_string(); // Check if user already exists let exists: Option<(String,)> = sqlx::query_as("SELECT id FROM users WHERE id = ?") .bind(&user_id) .fetch_optional(pool) .await .map_err(|e| format!("Failed to check for desktop user: {}", e))?; if exists.is_none() { info!("Creating desktop user"); // Create desktop user with a placeholder password (not used for auth in desktop mode) let now = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string(); sqlx::query( "INSERT INTO users (id, email, password_hash, display_name, created_at) VALUES (?, ?, ?, ?, ?)" ) .bind(&user_id) .bind("desktop@localhost") .bind("desktop-mode-no-password") .bind("Desktop User") .bind(&now) .execute(pool) .await .map_err(|e| format!("Failed to create desktop user: {}", e))?; } else { debug!("Desktop user already exists"); } Ok(()) }