diff --git a/src/PSLauncher.App/App.xaml.cs b/src/PSLauncher.App/App.xaml.cs index 43c1093..5a8f3e6 100644 --- a/src/PSLauncher.App/App.xaml.cs +++ b/src/PSLauncher.App/App.xaml.cs @@ -16,6 +16,7 @@ using PSLauncher.Core.Integrity; using PSLauncher.Core.Licensing; using PSLauncher.Core.Localization; using PSLauncher.Core.Manifests; +using PSLauncher.Core.Migrations; using PSLauncher.Core.Process; using PSLauncher.Core.Updates; using PSLauncher.Models; @@ -162,6 +163,17 @@ public partial class App : Application services.AddSingleton(); + // Migration DB MySQL : appliquée juste après extraction d'un build PROSERVE + // si le ZIP contient un sous-répertoire _migrations/. La config DB et le + // password (DPAPI) sont lus à la demande pour refléter les changements + // utilisateur dans Settings sans recréer le service. + services.AddSingleton(sp => + new DatabaseMigrationService( + configProvider: () => sp.GetRequiredService().Database, + passwordProvider: () => DatabasePasswordProtector.Unprotect( + sp.GetRequiredService().Database.EncryptedPassword), + sp.GetRequiredService>())); + services.AddTransient(); services.AddSingleton(); services.AddSingleton(); diff --git a/src/PSLauncher.App/ViewModels/MainViewModel.cs b/src/PSLauncher.App/ViewModels/MainViewModel.cs index ea4ca33..0c66812 100644 --- a/src/PSLauncher.App/ViewModels/MainViewModel.cs +++ b/src/PSLauncher.App/ViewModels/MainViewModel.cs @@ -14,6 +14,7 @@ using PSLauncher.Core.Installations; using PSLauncher.Core.Licensing; using PSLauncher.Core.Localization; using PSLauncher.Core.Manifests; +using PSLauncher.Core.Migrations; using PSLauncher.Core.Process; using PSLauncher.Core.Updates; using PSLauncher.Models; @@ -33,6 +34,7 @@ public sealed partial class MainViewModel : ObservableObject private readonly ILicenseService _licenseService; private readonly ILauncherSelfUpdater _selfUpdater; private readonly IToastService _toastService; + private readonly IDatabaseMigrationService _migrationService; private readonly IServiceProvider _serviceProvider; private readonly ILogger _logger; @@ -173,6 +175,7 @@ public sealed partial class MainViewModel : ObservableObject ILicenseService licenseService, ILauncherSelfUpdater selfUpdater, IToastService toastService, + IDatabaseMigrationService migrationService, IServiceProvider serviceProvider, ILogger logger) { @@ -187,6 +190,7 @@ public sealed partial class MainViewModel : ObservableObject _licenseService = licenseService; _selfUpdater = selfUpdater; _toastService = toastService; + _migrationService = migrationService; _serviceProvider = serviceProvider; _logger = logger; @@ -655,6 +659,45 @@ public sealed partial class MainViewModel : ObservableObject try { File.Delete(zipPath); } catch { /* non critique */ } + // 5) Migrations DB MySQL bundled dans _migrations/. Skip si la config + // a désactivé l'auto-apply ou si le ZIP ne contient pas de répertoire. + if (_config.Database.AutoApplyMigrations) + { + StatusMessage = Strings.StatusApplyingMigrations(row.Version); + ProgressDetail = null; + ProgressPercent = 0; + var migProgress = new Progress(mp => + { + if (mp.Total > 0) + { + var pct = (double)mp.Done / mp.Total * 100.0; + ProgressPercent = pct; + row.ProgressPercent = pct; + } + var label = string.IsNullOrEmpty(mp.CurrentFilename) + ? Strings.StatusApplyingMigrations(row.Version) + : Strings.ProgressMigration(mp.Done + 1, mp.Total, mp.CurrentFilename); + ProgressDetail = label; + row.ProgressDetail = label; + }); + var migResult = await _migrationService.ApplyMigrationsAsync(target, migProgress, ct); + if (!migResult.AllSucceeded) + { + // L'install a réussi côté fichiers ; on garde la version installée mais on + // notifie l'utilisateur que la DB n'a pas été migrée — la prochaine relance + // de l'install (ou un Settings → "Rejouer migrations") tentera à nouveau. + _logger.LogError("DB migration failed for v{Version}: {Detail}", row.Version, migResult.FailureMessage); + MessageBox.Show( + Strings.MsgMigrationFailed(migResult.FailureMessage ?? "?"), + Strings.MsgBoxMigrationFailed, + MessageBoxButton.OK, MessageBoxImage.Warning); + } + else if (migResult.AppliedCount > 0) + { + _logger.LogInformation("Applied {N} DB migration(s) for v{Version}", migResult.AppliedCount, row.Version); + } + } + StatusMessage = Strings.StatusInstalledSuccess(row.Version); ProgressDetail = null; ProgressPercent = 0; diff --git a/src/PSLauncher.Core/Localization/Strings.cs b/src/PSLauncher.Core/Localization/Strings.cs index 0771cf3..195e538 100644 --- a/src/PSLauncher.Core/Localization/Strings.cs +++ b/src/PSLauncher.Core/Localization/Strings.cs @@ -401,6 +401,30 @@ public static class Strings $"🔍 التحقق من SHA-256 v{version}: {percent}%" ); + // ==================== DB MIGRATIONS ==================== + public static string StatusApplyingMigrations(string version) => T( + $"Application des migrations base de données v{version}…", + $"Applying database migrations for v{version}…", + $"正在应用 v{version} 的数据库迁移…", + $"กำลังปรับใช้การโยกย้ายฐานข้อมูล v{version}…", + $"جارٍ تطبيق ترقيات قاعدة البيانات v{version}…" + ); + public static string ProgressMigration(int done, int total, string filename) => T( + $"🗄 Migration {done}/{total} : {filename}", + $"🗄 Migration {done}/{total}: {filename}", + $"🗄 迁移 {done}/{total}:{filename}", + $"🗄 การโยกย้าย {done}/{total}: {filename}", + $"🗄 الترقية {done}/{total}: {filename}" + ); + public static string MsgBoxMigrationFailed => T("Migration DB échouée", "DB migration failed", "数据库迁移失败", "การโยกย้าย DB ล้มเหลว", "فشل ترقية قاعدة البيانات"); + public static string MsgMigrationFailed(string detail) => T( + $"Une migration de la base de données a échoué :\n\n{detail}\n\nLa version a été extraite mais la base n'a PAS été modifiée (rollback automatique).\nVérifie que XAMPP est démarré et que les paramètres de connexion sont corrects (Settings → Base de données), puis clique « Rejouer les migrations » dans Settings.", + $"A database migration failed:\n\n{detail}\n\nThe version was extracted but the database was NOT modified (automatic rollback).\nMake sure XAMPP is running and the connection settings are correct (Settings → Database), then click « Replay migrations » in Settings.", + $"数据库迁移失败:\n\n{detail}\n\n版本已解压,但数据库未被修改(自动回滚)。\n请确保 XAMPP 正在运行并且连接设置正确(设置 → 数据库),然后点击设置中的「重新执行迁移」。", + $"การโยกย้ายฐานข้อมูลล้มเหลว:\n\n{detail}\n\nเวอร์ชันถูกแตกแล้วแต่ฐานข้อมูลไม่ถูกแก้ไข (rollback อัตโนมัติ)\nตรวจสอบว่า XAMPP กำลังทำงานและการตั้งค่าการเชื่อมต่อถูกต้อง (ตั้งค่า → ฐานข้อมูล) จากนั้นคลิก « เรียกใช้การโยกย้ายอีกครั้ง » ในการตั้งค่า", + $"فشلت ترقية قاعدة البيانات:\n\n{detail}\n\nتم استخراج النسخة لكن قاعدة البيانات لم تُعدَّل (rollback تلقائي).\nتأكد من تشغيل XAMPP وأن إعدادات الاتصال صحيحة (الإعدادات → قاعدة البيانات)، ثم انقر « إعادة تشغيل الترقيات » في الإعدادات." + ); + public static string StatusPreparingDownload(string version) => T( $"Préparation du téléchargement v{version}…", $"Preparing download for v{version}…", diff --git a/src/PSLauncher.Core/Migrations/DatabaseMigrationService.cs b/src/PSLauncher.Core/Migrations/DatabaseMigrationService.cs new file mode 100644 index 0000000..f6caa27 --- /dev/null +++ b/src/PSLauncher.Core/Migrations/DatabaseMigrationService.cs @@ -0,0 +1,313 @@ +using System.Diagnostics; +using System.Security.Cryptography; +using System.Text; +using Microsoft.Extensions.Logging; +using MySqlConnector; +using PSLauncher.Models; + +namespace PSLauncher.Core.Migrations; + +/// +/// Implémentation MySQL/MariaDB du service de migration. +/// Workflow : +/// +/// 1. CREATE TABLE IF NOT EXISTS _launcher_migrations (filename, applied_at, checksum, duration_ms) +/// 2. SELECT déjà-appliquées +/// 3. Liste {installFolder}/_migrations/*.sql triées alphabétiquement +/// 4. Pour chaque non-appliquée : +/// START TRANSACTION +/// Exécute le script (peut contenir plusieurs statements séparés par ;) +/// INSERT INTO _launcher_migrations +/// COMMIT +/// Si exception → ROLLBACK + abort + reporte l'erreur +/// 5. Pour chaque appliquée dont le checksum a changé : log warning (script modifié après publication) +/// +/// +public sealed class DatabaseMigrationService : IDatabaseMigrationService +{ + private const string TrackingTable = "_launcher_migrations"; + + private readonly Func _configProvider; + private readonly Func _passwordProvider; + private readonly ILogger _logger; + + public DatabaseMigrationService( + Func configProvider, + Func passwordProvider, + ILogger logger) + { + _configProvider = configProvider; + _passwordProvider = passwordProvider; + _logger = logger; + } + + public async Task TestConnectionAsync(CancellationToken ct) + { + try + { + await using var conn = await OpenAsync(ct).ConfigureAwait(false); + await using var cmd = new MySqlCommand("SELECT 1", conn); + await cmd.ExecuteScalarAsync(ct).ConfigureAwait(false); + return null; + } + catch (Exception ex) + { + return ex.Message; + } + } + + public async Task ApplyMigrationsAsync( + string installFolder, + IProgress? progress, + CancellationToken ct) + { + var migrationsDir = Path.Combine(installFolder, "_migrations"); + if (!Directory.Exists(migrationsDir)) + { + _logger.LogInformation("No _migrations/ directory in {Folder}, skipping DB migration step", installFolder); + return new MigrationResult(Array.Empty(), AllSucceeded: true, FailureMessage: null); + } + + // Liste les .sql, ignore les autres extensions (README, etc.) + var files = Directory.EnumerateFiles(migrationsDir, "*.sql", SearchOption.TopDirectoryOnly) + .OrderBy(f => Path.GetFileName(f), StringComparer.Ordinal) + .ToList(); + + if (files.Count == 0) + { + _logger.LogInformation("_migrations/ exists but empty in {Folder}", installFolder); + return new MigrationResult(Array.Empty(), AllSucceeded: true, FailureMessage: null); + } + + await using var conn = await OpenAsync(ct).ConfigureAwait(false); + await EnsureTrackingTableAsync(conn, ct).ConfigureAwait(false); + + var applied = await LoadAppliedAsync(conn, ct).ConfigureAwait(false); + var outcomes = new List(); + + for (int i = 0; i < files.Count; i++) + { + ct.ThrowIfCancellationRequested(); + var filepath = files[i]; + var filename = Path.GetFileName(filepath); + progress?.Report(new MigrationProgress(i, files.Count, filename)); + + var sql = await File.ReadAllTextAsync(filepath, Encoding.UTF8, ct).ConfigureAwait(false); + var checksum = ComputeSha256(sql); + + if (applied.TryGetValue(filename, out var prevChecksum)) + { + if (!string.Equals(prevChecksum, checksum, StringComparison.OrdinalIgnoreCase)) + { + // Détection d'une modif après-publication : on skip mais on log fort. + _logger.LogWarning( + "Migration {File} was modified after being applied (stored sha256={Stored}, current={Now}). " + + "Ignored to preserve historical truth — write a NEW migration file instead.", + filename, prevChecksum[..12], checksum[..12]); + } + outcomes.Add(new MigrationOutcome(filename, MigrationStatus.Skipped, 0, null)); + continue; + } + + var sw = Stopwatch.StartNew(); + await using var tx = await conn.BeginTransactionAsync(ct).ConfigureAwait(false); + try + { + await ExecuteScriptAsync(conn, tx, sql, ct).ConfigureAwait(false); + await using var insertCmd = new MySqlCommand( + $"INSERT INTO {TrackingTable} (filename, applied_at, checksum, duration_ms) VALUES (@f, NOW(), @c, @d)", + conn, tx); + insertCmd.Parameters.AddWithValue("@f", filename); + insertCmd.Parameters.AddWithValue("@c", checksum); + insertCmd.Parameters.AddWithValue("@d", sw.ElapsedMilliseconds); + await insertCmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false); + await tx.CommitAsync(ct).ConfigureAwait(false); + sw.Stop(); + outcomes.Add(new MigrationOutcome(filename, MigrationStatus.Applied, sw.ElapsedMilliseconds, null)); + _logger.LogInformation("Migration {File} applied in {Ms} ms", filename, sw.ElapsedMilliseconds); + } + catch (Exception ex) + { + sw.Stop(); + try { await tx.RollbackAsync(ct).ConfigureAwait(false); } catch { /* swallow */ } + outcomes.Add(new MigrationOutcome(filename, MigrationStatus.Failed, sw.ElapsedMilliseconds, ex.Message)); + _logger.LogError(ex, "Migration {File} FAILED after {Ms} ms — DB state rolled back", filename, sw.ElapsedMilliseconds); + progress?.Report(new MigrationProgress(i + 1, files.Count, filename)); + return new MigrationResult(outcomes, AllSucceeded: false, FailureMessage: $"{filename} : {ex.Message}"); + } + } + + progress?.Report(new MigrationProgress(files.Count, files.Count, string.Empty)); + return new MigrationResult(outcomes, AllSucceeded: true, FailureMessage: null); + } + + public async Task> ListAppliedAsync(CancellationToken ct) + { + await using var conn = await OpenAsync(ct).ConfigureAwait(false); + await EnsureTrackingTableAsync(conn, ct).ConfigureAwait(false); + + var list = new List(); + await using var cmd = new MySqlCommand( + $"SELECT filename, applied_at, checksum, duration_ms FROM {TrackingTable} ORDER BY id", + conn); + await using var reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false); + while (await reader.ReadAsync(ct).ConfigureAwait(false)) + { + list.Add(new AppliedMigration( + reader.GetString(0), + DateTime.SpecifyKind(reader.GetDateTime(1), DateTimeKind.Utc), + reader.GetString(2), + reader.GetInt64(3))); + } + return list; + } + + // ----- helpers ----- + + private async Task OpenAsync(CancellationToken ct) + { + var cfg = _configProvider(); + var pwd = _passwordProvider() ?? string.Empty; + var csb = new MySqlConnectionStringBuilder + { + Server = cfg.Host, + Port = cfg.Port, + UserID = cfg.User, + Password = pwd, + Database = cfg.Database, + // Désactive le pooling : on ouvre une connexion par opération courte, + // pas besoin de garder un pool ouvert sur la machine du user. + Pooling = false, + ConnectionTimeout = 10, + DefaultCommandTimeout = 600, // 10 min : laisse de la marge pour les grosses migrations + AllowUserVariables = true, + }; + var conn = new MySqlConnection(csb.ConnectionString); + await conn.OpenAsync(ct).ConfigureAwait(false); + return conn; + } + + private static async Task EnsureTrackingTableAsync(MySqlConnection conn, CancellationToken ct) + { + const string ddl = $""" + CREATE TABLE IF NOT EXISTS {TrackingTable} ( + id INT PRIMARY KEY AUTO_INCREMENT, + filename VARCHAR(255) NOT NULL UNIQUE, + applied_at DATETIME NOT NULL, + checksum CHAR(64) NOT NULL, + duration_ms BIGINT NOT NULL DEFAULT 0 + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + """; + await using var cmd = new MySqlCommand(ddl, conn); + await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false); + } + + private static async Task> LoadAppliedAsync(MySqlConnection conn, CancellationToken ct) + { + var map = new Dictionary(StringComparer.OrdinalIgnoreCase); + await using var cmd = new MySqlCommand($"SELECT filename, checksum FROM {TrackingTable}", conn); + await using var reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false); + while (await reader.ReadAsync(ct).ConfigureAwait(false)) + map[reader.GetString(0)] = reader.GetString(1); + return map; + } + + /// + /// MySqlCommand exécute UNE commande à la fois. Pour un fichier .sql contenant + /// plusieurs statements séparés par ;, on parse manuellement (en respectant + /// les chaînes / commentaires) et on exécute statement-par-statement dans la + /// même transaction. Évite la dépendance à MySqlScript et permet de + /// supporter proprement. + /// + private static async Task ExecuteScriptAsync(MySqlConnection conn, MySqlTransaction tx, string sql, CancellationToken ct) + { + foreach (var stmt in SplitStatements(sql)) + { + ct.ThrowIfCancellationRequested(); + var trimmed = stmt.Trim(); + if (string.IsNullOrEmpty(trimmed)) continue; + // Skip si juste un commentaire ou une ligne blanche + if (IsAllCommentsOrWhitespace(trimmed)) continue; + await using var cmd = new MySqlCommand(trimmed, conn, tx); + await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false); + } + } + + private static bool IsAllCommentsOrWhitespace(string s) + { + // Heuristique simple : si après strip des commentaires il reste des trucs non-blancs. + var stripped = System.Text.RegularExpressions.Regex.Replace(s, @"(--[^\n]*)|(/\*[\s\S]*?\*/)", ""); + return string.IsNullOrWhiteSpace(stripped); + } + + /// + /// Splitter SQL minimaliste : sépare par ; en respectant les chaînes + /// (' et "), les identifiants (`...`) et les commentaires (-- ... et /* ... */). + /// Suffisant pour la plupart des fichiers de migration courants ; pour des + /// triggers/procs avec DELIMITER, écrire chaque statement dans un fichier séparé. + /// + private static IEnumerable SplitStatements(string sql) + { + var sb = new StringBuilder(); + int i = 0; + char? quote = null; + bool inLineComment = false; + bool inBlockComment = false; + + while (i < sql.Length) + { + char c = sql[i]; + char next = i + 1 < sql.Length ? sql[i + 1] : '\0'; + + if (inLineComment) + { + sb.Append(c); + if (c == '\n') inLineComment = false; + i++; + continue; + } + if (inBlockComment) + { + sb.Append(c); + if (c == '*' && next == '/') { sb.Append(next); i += 2; inBlockComment = false; continue; } + i++; + continue; + } + if (quote is not null) + { + sb.Append(c); + if (c == '\\' && i + 1 < sql.Length) { sb.Append(sql[i + 1]); i += 2; continue; } + if (c == quote) quote = null; + i++; + continue; + } + + // hors-quote, hors-commentaire + if (c == '-' && next == '-') { sb.Append(c).Append(next); i += 2; inLineComment = true; continue; } + if (c == '/' && next == '*') { sb.Append(c).Append(next); i += 2; inBlockComment = true; continue; } + if (c == '\'' || c == '"' || c == '`') { quote = c; sb.Append(c); i++; continue; } + + if (c == ';') + { + yield return sb.ToString(); + sb.Clear(); + i++; + continue; + } + sb.Append(c); + i++; + } + if (sb.Length > 0) + { + var tail = sb.ToString().Trim(); + if (tail.Length > 0) yield return tail; + } + } + + private static string ComputeSha256(string text) + { + var bytes = Encoding.UTF8.GetBytes(text); + var hash = SHA256.HashData(bytes); + return Convert.ToHexString(hash).ToLowerInvariant(); + } +} diff --git a/src/PSLauncher.Core/Migrations/DatabasePasswordProtector.cs b/src/PSLauncher.Core/Migrations/DatabasePasswordProtector.cs new file mode 100644 index 0000000..b1c5fcb --- /dev/null +++ b/src/PSLauncher.Core/Migrations/DatabasePasswordProtector.cs @@ -0,0 +1,36 @@ +using System.Security.Cryptography; +using System.Text; + +namespace PSLauncher.Core.Migrations; + +/// +/// Chiffre / déchiffre le mot de passe MySQL stocké dans +/// via DPAPI scope CurrentUser. Même mécanisme que pour la clé de license : un user +/// qui copierait le config.json sur une autre machine ne pourrait pas déchiffrer. +/// +public static class DatabasePasswordProtector +{ + public static string? Protect(string? clearPassword) + { + if (string.IsNullOrEmpty(clearPassword)) return null; + var data = Encoding.UTF8.GetBytes(clearPassword); + var protectedBytes = ProtectedData.Protect(data, optionalEntropy: null, scope: DataProtectionScope.CurrentUser); + return Convert.ToBase64String(protectedBytes); + } + + public static string? Unprotect(string? base64) + { + if (string.IsNullOrEmpty(base64)) return null; + try + { + var protectedBytes = Convert.FromBase64String(base64); + var data = ProtectedData.Unprotect(protectedBytes, optionalEntropy: null, scope: DataProtectionScope.CurrentUser); + return Encoding.UTF8.GetString(data); + } + catch + { + // Cache déplacé sur autre machine / autre user → non déchiffrable + return null; + } + } +} diff --git a/src/PSLauncher.Core/Migrations/IDatabaseMigrationService.cs b/src/PSLauncher.Core/Migrations/IDatabaseMigrationService.cs new file mode 100644 index 0000000..42e3052 --- /dev/null +++ b/src/PSLauncher.Core/Migrations/IDatabaseMigrationService.cs @@ -0,0 +1,57 @@ +namespace PSLauncher.Core.Migrations; + +/// +/// Applique les scripts SQL livrés dans le sous-répertoire _migrations/ +/// d'un build PROSERVE installé. Chaque script tournant en transaction, le +/// résultat est tracké dans une table _launcher_migrations pour qu'un +/// même script ne soit pas rejoué deux fois (mêmes garanties que Flyway/Doctrine). +/// +public interface IDatabaseMigrationService +{ + /// + /// Cherche {installFolder}/_migrations/*.sql, applique en ordre alphabétique + /// les scripts pas encore présents dans _launcher_migrations, et reporte la + /// progression. Retourne la liste détaillée des opérations (appliquées + skippées). + /// + Task ApplyMigrationsAsync( + string installFolder, + IProgress? progress, + CancellationToken ct); + + /// + /// Liste les migrations déjà appliquées en base. Utile pour la vue Settings. + /// + Task> ListAppliedAsync(CancellationToken ct); + + /// + /// Test de connexion. Retourne null si OK, sinon le message d'erreur formaté. + /// + Task TestConnectionAsync(CancellationToken ct); +} + +public sealed record MigrationProgress(int Done, int Total, string CurrentFilename); + +public sealed record MigrationResult( + IReadOnlyList Outcomes, + bool AllSucceeded, + string? FailureMessage) +{ + public int AppliedCount => Outcomes.Count(o => o.Status == MigrationStatus.Applied); + public int SkippedCount => Outcomes.Count(o => o.Status == MigrationStatus.Skipped); + public int FailedCount => Outcomes.Count(o => o.Status == MigrationStatus.Failed); + public bool HasNoMigrationsFolder => !Outcomes.Any() && AllSucceeded; +} + +public sealed record MigrationOutcome( + string Filename, + MigrationStatus Status, + long DurationMs, + string? Error); + +public enum MigrationStatus { Skipped, Applied, Failed } + +public sealed record AppliedMigration( + string Filename, + DateTime AppliedAt, + string Checksum, + long DurationMs); diff --git a/src/PSLauncher.Core/PSLauncher.Core.csproj b/src/PSLauncher.Core/PSLauncher.Core.csproj index 81cfb39..6c4f5b1 100644 --- a/src/PSLauncher.Core/PSLauncher.Core.csproj +++ b/src/PSLauncher.Core/PSLauncher.Core.csproj @@ -12,6 +12,8 @@ + + diff --git a/src/PSLauncher.Models/LocalConfig.cs b/src/PSLauncher.Models/LocalConfig.cs index c2cbde2..671401f 100644 --- a/src/PSLauncher.Models/LocalConfig.cs +++ b/src/PSLauncher.Models/LocalConfig.cs @@ -29,6 +29,45 @@ public sealed class LocalConfig public bool VerifyDownloadHash { get; set; } = true; public LicenseConfig License { get; set; } = new(); + + /// + /// Connexion à la base MySQL locale (XAMPP) utilisée par PROSERVE pour ses + /// statistiques. Le launcher s'en sert pour appliquer les migrations bundled + /// dans _migrations/ au moment de l'install d'une nouvelle version. + /// + public DatabaseConfig Database { get; set; } = new(); +} + +/// +/// Paramètres de connexion à la base MySQL locale. Les valeurs par défaut +/// correspondent à un XAMPP standard fraîchement installé. Override possible +/// via Settings → Base de données. +/// +public sealed class DatabaseConfig +{ + public string Host { get; set; } = "localhost"; + public uint Port { get; set; } = 3306; + public string User { get; set; } = "root"; + + /// + /// Mot de passe MySQL stocké chiffré DPAPI (CurrentUser scope) en base64. + /// Vide = pas de password (config XAMPP par défaut). NE PAS écrire en clair. + /// + public string? EncryptedPassword { get; set; } + + /// + /// Nom de la base de données qui contient les tables de PROSERVE. + /// Default "proserve" — change si ton install crée la DB sous un autre nom. + /// + public string Database { get; set; } = "proserve"; + + /// + /// Si true, le launcher applique automatiquement les migrations SQL bundled + /// dans _migrations/ de chaque ZIP, juste après l'extraction. + /// Sécurité : on n'applique JAMAIS sans cette opt-in (au cas où certains users + /// auraient une config DB exotique non gérée par le defaut). + /// + public bool AutoApplyMigrations { get; set; } = true; } public sealed class LicenseConfig