Settings: Database section with connection params, test, and replay button

New « BASE DE DONNÉES (XAMPP / MySQL) » section in Paramètres covering:

- Host / Port / User / Password / Database — defaults match a fresh XAMPP
  install (localhost:3306, root, empty password, "proserve"). Password is
  DPAPI-encrypted in config.json (same scheme as the license key).
- « Tester » button — opens a fresh MySqlConnection and runs SELECT 1.
  Shows ✓ OK or the connection error inline.
- « Appliquer automatiquement les migrations à l'install » checkbox — opt-in
  for the post-install migration step. Default true.
- « 🔁 Rejouer les migrations » button — manually re-runs ApplyMigrationsAsync
  on the latest installed version. Useful when the post-install run failed
  (XAMPP was off) or after a dev added a new SQL file. Live status « 3/5 :
  0042_add_index.sql » + final « ✓ N applied, M skipped » or « ✗ failure ».
- Hint paragraph below explaining the _migrations/ convention.

SettingsViewModel
- Pulls IDatabaseMigrationService and IInstallationRegistry via DI.
- Save() now also persists the DB block, encrypting the password before write.
- TestDbAsync temporarily swaps the in-memory config so the service sees the
  values being typed (without persisting until Save).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-03 08:25:32 +02:00
parent 6f8a320d69
commit 750bb8cfc9
3 changed files with 263 additions and 0 deletions

View File

@@ -8,8 +8,10 @@ using CommunityToolkit.Mvvm.Input;
using Microsoft.Extensions.Logging;
using PSLauncher.Core.Configuration;
using PSLauncher.Core.Downloads;
using PSLauncher.Core.Installations;
using PSLauncher.Core.Licensing;
using PSLauncher.Core.Localization;
using PSLauncher.Core.Migrations;
using PSLauncher.Models;
namespace PSLauncher.App.ViewModels;
@@ -27,6 +29,8 @@ public sealed partial class SettingsViewModel : ObservableObject
private readonly LocalConfig _config;
private readonly ILicenseService _licenseService;
private readonly IDownloadStateStore _downloadStore;
private readonly IDatabaseMigrationService _migrationService;
private readonly IInstallationRegistry _registry;
private readonly HttpClient _http;
private readonly ILogger<SettingsViewModel> _logger;
@@ -39,6 +43,18 @@ public sealed partial class SettingsViewModel : ObservableObject
[ObservableProperty] private string _installRoot;
[ObservableProperty] private string _language;
// ----- DB Migration settings -----
[ObservableProperty] private string _dbHost;
[ObservableProperty] private uint _dbPort;
[ObservableProperty] private string _dbUser;
[ObservableProperty] private string _dbPassword;
[ObservableProperty] private string _dbName;
[ObservableProperty] private bool _dbAutoApplyMigrations;
[ObservableProperty] private string? _dbConnectionStatus;
[ObservableProperty] private bool _isTestingDb;
[ObservableProperty] private string? _migrationsReplayStatus;
[ObservableProperty] private bool _isReplayingMigrations;
public IReadOnlyList<LanguageOption> AvailableLanguages { get; } =
PSLauncher.Core.Localization.Strings.Available
.Select(t => new LanguageOption(t.Code, t.Name))
@@ -105,6 +121,8 @@ public sealed partial class SettingsViewModel : ObservableObject
LocalConfig config,
ILicenseService licenseService,
IDownloadStateStore downloadStore,
IDatabaseMigrationService migrationService,
IInstallationRegistry registry,
HttpClient http,
ILogger<SettingsViewModel> logger)
{
@@ -112,6 +130,8 @@ public sealed partial class SettingsViewModel : ObservableObject
_config = config;
_licenseService = licenseService;
_downloadStore = downloadStore;
_migrationService = migrationService;
_registry = registry;
_http = http;
_logger = logger;
@@ -119,6 +139,13 @@ public sealed partial class SettingsViewModel : ObservableObject
_installRoot = config.InstallRoot;
_language = string.IsNullOrWhiteSpace(config.Language) ? "auto" : config.Language;
_dbHost = config.Database.Host;
_dbPort = config.Database.Port;
_dbUser = config.Database.User;
_dbPassword = DatabasePasswordProtector.Unprotect(config.Database.EncryptedPassword) ?? string.Empty;
_dbName = config.Database.Database;
_dbAutoApplyMigrations = config.Database.AutoApplyMigrations;
LauncherVersion = Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? "0.0.0";
MachineId = licenseService.GetMachineId();
LogsDirectory = App.LogsDirectory;
@@ -225,6 +252,17 @@ public sealed partial class SettingsViewModel : ObservableObject
_config.ServerBaseUrl = ServerBaseUrl.TrimEnd('/');
_config.InstallRoot = InstallRoot;
_config.Language = Language ?? "auto";
// DB settings
_config.Database.Host = DbHost.Trim();
_config.Database.Port = DbPort > 0 ? DbPort : 3306;
_config.Database.User = DbUser.Trim();
_config.Database.EncryptedPassword = string.IsNullOrEmpty(DbPassword)
? null
: DatabasePasswordProtector.Protect(DbPassword);
_config.Database.Database = DbName.Trim();
_config.Database.AutoApplyMigrations = DbAutoApplyMigrations;
_configStore.Save(_config);
_logger.LogInformation("Settings saved (language={Lang}, changed={Changed})", _config.Language, languageChanged);
@@ -289,4 +327,99 @@ public sealed partial class SettingsViewModel : ObservableObject
}
private static string FormatSize(long bytes) => Strings.FormatSize(bytes);
// ===================== DB MIGRATIONS =====================
/// <summary>
/// Test la connexion à la DB MySQL avec les paramètres en cours de saisie.
/// Persiste temporairement les valeurs sur _config.Database avant le test
/// pour que le service les voie via son configProvider, puis remet (et le
/// Save() final écrasera quand l'utilisateur cliquera Enregistrer).
/// </summary>
[RelayCommand]
private async Task TestDbAsync()
{
DbConnectionStatus = Strings.SettingsTest + "…";
IsTestingDb = true;
// On applique temporairement les valeurs saisies
var oldHost = _config.Database.Host;
var oldPort = _config.Database.Port;
var oldUser = _config.Database.User;
var oldPwd = _config.Database.EncryptedPassword;
var oldDb = _config.Database.Database;
try
{
_config.Database.Host = DbHost.Trim();
_config.Database.Port = DbPort > 0 ? DbPort : 3306;
_config.Database.User = DbUser.Trim();
_config.Database.EncryptedPassword = string.IsNullOrEmpty(DbPassword)
? null
: DatabasePasswordProtector.Protect(DbPassword);
_config.Database.Database = DbName.Trim();
var err = await _migrationService.TestConnectionAsync(CancellationToken.None);
DbConnectionStatus = err is null ? "✓ OK" : $"✗ {err}";
}
catch (Exception ex) { DbConnectionStatus = $"✗ {ex.Message}"; }
finally
{
// Restaure : seul Save() doit écrire pour de bon
_config.Database.Host = oldHost;
_config.Database.Port = oldPort;
_config.Database.User = oldUser;
_config.Database.EncryptedPassword = oldPwd;
_config.Database.Database = oldDb;
IsTestingDb = false;
}
}
/// <summary>
/// Re-joue les migrations bundled dans le _migrations/ de la version actuellement
/// installée la plus récente. Utile si l'auto-apply post-install avait échoué (ex.
/// XAMPP éteint à ce moment-là), ou pour rejouer manuellement après ajout d'une
/// nouvelle migration côté dev.
/// </summary>
[RelayCommand]
private async Task ReplayMigrationsAsync()
{
var installed = _registry.Scan().OrderByDescending(v => v.Version).FirstOrDefault();
if (installed is null)
{
MigrationsReplayStatus = "Aucune version installée — installe d'abord une version avec _migrations/.";
return;
}
IsReplayingMigrations = true;
MigrationsReplayStatus = $"Réapplication des migrations pour v{installed.Version}…";
try
{
var progress = new Progress<MigrationProgress>(mp =>
{
if (mp.Total > 0)
MigrationsReplayStatus = $"{mp.Done + 1}/{mp.Total} : {mp.CurrentFilename}";
});
// Il faut persister les éventuelles modifs DB avant le test (le service lit la config courante)
Save();
var result = await _migrationService.ApplyMigrationsAsync(
installed.FolderPath, progress, CancellationToken.None);
if (result.AllSucceeded)
{
MigrationsReplayStatus = result.AppliedCount > 0
? $"✓ {result.AppliedCount} migration(s) appliquée(s), {result.SkippedCount} déjà en place."
: $"✓ Tout est à jour ({result.SkippedCount} déjà appliquée(s))";
}
else
{
MigrationsReplayStatus = $"✗ Échec : {result.FailureMessage}";
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Replay migrations failed");
MigrationsReplayStatus = $"✗ Erreur : {ex.Message}";
}
finally
{
IsReplayingMigrations = false;
}
}
}

View File

@@ -248,6 +248,115 @@
</StackPanel>
</Border>
<!-- Base de données (XAMPP / MySQL) — config + test + replay -->
<Border Background="{StaticResource Brush.Bg.Card}"
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1"
CornerRadius="6" Padding="20" Margin="0,0,0,16">
<StackPanel>
<TextBlock Text="{x:Static loc:Strings.SettingsDatabase}"
FontSize="11" FontWeight="Bold"
Foreground="{StaticResource Brush.Text.Secondary}"
Margin="0,0,0,8" />
<Grid Margin="0,0,0,8">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="100" />
</Grid.ColumnDefinitions>
<StackPanel Grid.Column="0" Margin="0,0,8,0">
<TextBlock Text="{x:Static loc:Strings.SettingsDbHost}"
Foreground="{StaticResource Brush.Text.Secondary}"
FontSize="12" Margin="0,0,0,4" />
<TextBox Text="{Binding DbHost, UpdateSourceTrigger=PropertyChanged}"
Background="#1A1A20" Foreground="{StaticResource Brush.Text.Primary}"
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1" Padding="8" />
</StackPanel>
<StackPanel Grid.Column="1">
<TextBlock Text="{x:Static loc:Strings.SettingsDbPort}"
Foreground="{StaticResource Brush.Text.Secondary}"
FontSize="12" Margin="0,0,0,4" />
<TextBox Text="{Binding DbPort, UpdateSourceTrigger=PropertyChanged}"
Background="#1A1A20" Foreground="{StaticResource Brush.Text.Primary}"
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1" Padding="8" />
</StackPanel>
</Grid>
<Grid Margin="0,0,0,8">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<StackPanel Grid.Column="0" Margin="0,0,8,0">
<TextBlock Text="{x:Static loc:Strings.SettingsDbUser}"
Foreground="{StaticResource Brush.Text.Secondary}"
FontSize="12" Margin="0,0,0,4" />
<TextBox Text="{Binding DbUser, UpdateSourceTrigger=PropertyChanged}"
Background="#1A1A20" Foreground="{StaticResource Brush.Text.Primary}"
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1" Padding="8" />
</StackPanel>
<StackPanel Grid.Column="1">
<TextBlock Text="{x:Static loc:Strings.SettingsDbPassword}"
Foreground="{StaticResource Brush.Text.Secondary}"
FontSize="12" Margin="0,0,0,4" />
<TextBox Text="{Binding DbPassword, UpdateSourceTrigger=PropertyChanged}"
Background="#1A1A20" Foreground="{StaticResource Brush.Text.Primary}"
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1" Padding="8"
FontFamily="Consolas" />
</StackPanel>
</Grid>
<TextBlock Text="{x:Static loc:Strings.SettingsDbName}"
Foreground="{StaticResource Brush.Text.Secondary}"
FontSize="12" Margin="0,0,0,4" />
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<TextBox Grid.Column="0"
Text="{Binding DbName, UpdateSourceTrigger=PropertyChanged}"
Background="#1A1A20" Foreground="{StaticResource Brush.Text.Primary}"
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1" Padding="8"
FontFamily="Consolas" />
<Button Grid.Column="1" Margin="8,0,0,0"
Style="{StaticResource SecondaryButton}"
Content="{x:Static loc:Strings.SettingsTest}"
Command="{Binding TestDbCommand}"
IsEnabled="{Binding IsTestingDb, Converter={StaticResource InverseBoolToBool}}" />
</Grid>
<TextBlock Text="{Binding DbConnectionStatus}"
Foreground="{StaticResource Brush.Text.Secondary}"
FontSize="12" Margin="0,8,0,0" TextWrapping="Wrap" />
<CheckBox IsChecked="{Binding DbAutoApplyMigrations}"
Margin="0,12,0,0"
Foreground="{StaticResource Brush.Text.Primary}"
Content="{x:Static loc:Strings.SettingsDbAutoMigrate}" />
<Grid Margin="0,12,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="0"
Text="{Binding MigrationsReplayStatus}"
Foreground="{StaticResource Brush.Text.Secondary}"
FontSize="12" VerticalAlignment="Center"
TextWrapping="Wrap" />
<Button Grid.Column="1"
Style="{StaticResource SecondaryButton}"
Content="{x:Static loc:Strings.SettingsDbReplay}"
Command="{Binding ReplayMigrationsCommand}"
IsEnabled="{Binding IsReplayingMigrations, Converter={StaticResource InverseBoolToBool}}" />
</Grid>
<TextBlock Text="{x:Static loc:Strings.SettingsDbHint}"
Foreground="{StaticResource Brush.Text.Secondary}"
FontStyle="Italic" FontSize="11" Margin="0,8,0,0"
TextWrapping="Wrap" />
</StackPanel>
</Border>
<!-- Logs / About -->
<Border Background="{StaticResource Brush.Bg.Card}"
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1"