Files
PS_Launcher/src/PSLauncher.App/App.xaml.cs
j.foucher 6f8a320d69 DB migrations: bundled SQL applied automatically after install
Each PROSERVE ZIP can now ship a _migrations/ subfolder with versioned SQL
scripts. The launcher applies them right after extraction, in the same
install session as the binary copy, so the user never lands on a PROSERVE
build whose schema doesn't match its code.

Service (PSLauncher.Core/Migrations)
- IDatabaseMigrationService + DatabaseMigrationService (MySqlConnector,
  BSD-licensed). Each .sql runs in a transaction; on failure the DB
  state is rolled back and the install completes (files only) but the
  user is warned to fix the connection / replay later.
- Tracking table _launcher_migrations (filename, applied_at, checksum,
  duration_ms) — same model as Flyway / Doctrine. Already-applied scripts
  are skipped on subsequent installs. Modified scripts trigger a warning
  log without blocking.
- Custom SQL splitter that respects strings/comments/backticks so a single
  .sql file can contain multiple statements separated by `;`.
- DatabasePasswordProtector: DPAPI CurrentUser scope for the MySQL password
  in config.json (same protection as the license key).

Config (PSLauncher.Models/LocalConfig.cs)
- New DatabaseConfig section: Host=localhost, Port=3306, User=root, empty
  password, Database=proserve, AutoApplyMigrations=true. Defaults match a
  fresh XAMPP install. Override via Settings (next commit).

Install pipeline (MainViewModel)
- After ZipInstaller.InstallAsync and before declaring the install complete,
  if AutoApplyMigrations and a _migrations/ folder exists, run
  ApplyMigrationsAsync with progress reporting (per-file %, filename in
  footer). Failure shows MsgMigrationFailed dialog explaining XAMPP must
  be running and pointing to Settings → Database for connection params.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 08:21:53 +02:00

200 lines
8.8 KiB
C#

using System.IO;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Runtime.InteropServices;
using System.Windows;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using PSLauncher.App.Services;
using PSLauncher.App.ViewModels;
using PSLauncher.App.Views;
using PSLauncher.Core.Configuration;
using PSLauncher.Core.Downloads;
using PSLauncher.Core.Installations;
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;
using Serilog;
namespace PSLauncher.App;
public partial class App : Application
{
private IHost? _host;
private static System.Threading.Mutex? _singleInstanceMutex;
private const string SingleInstanceMutexName = "Global\\PSLauncher-3F8E2C1A-9B47-4D5E-A0F8-2E9D1B6C7A3F";
private const string BringToFrontMessage = "PSLAUNCHER_BRING_TO_FRONT";
private static readonly int WM_PSLAUNCHER_BRING = RegisterWindowMessage(BringToFrontMessage);
[DllImport("user32.dll")]
private static extern int RegisterWindowMessage(string lpString);
[DllImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool PostMessage(IntPtr hWnd, int Msg, IntPtr wParam, IntPtr lParam);
private const int HWND_BROADCAST = 0xFFFF;
public static string LogsDirectory { get; private set; } = string.Empty;
protected override void OnStartup(StartupEventArgs e)
{
base.OnStartup(e);
// Single-instance : si une autre instance tourne déjà, on lui demande de
// se mettre au premier plan via un broadcast Windows et on quitte tout de suite.
_singleInstanceMutex = new System.Threading.Mutex(initiallyOwned: true,
name: SingleInstanceMutexName, out var createdNew);
if (!createdNew)
{
PostMessage((IntPtr)HWND_BROADCAST, WM_PSLAUNCHER_BRING, IntPtr.Zero, IntPtr.Zero);
Shutdown();
return;
}
// Logs avant tout — pour ne rien perdre des erreurs au boot
LogsDirectory = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"PSLauncher", "logs");
Directory.CreateDirectory(LogsDirectory);
// Bootstrap i18n : on lit d'abord la config (sans services DI) pour fixer la
// langue avant que le moindre élément XAML soit instancié.
try
{
var bootstrapStore = new ConfigStore(
Microsoft.Extensions.Logging.Abstractions.NullLogger<ConfigStore>.Instance);
var bootstrapCfg = bootstrapStore.Load();
Strings.Init(bootstrapCfg.Language);
}
catch
{
Strings.Init("auto");
}
Log.Logger = new LoggerConfiguration()
.MinimumLevel.Debug()
.Enrich.FromLogContext()
.WriteTo.Debug()
.WriteTo.File(
path: Path.Combine(LogsDirectory, "app-.log"),
rollingInterval: RollingInterval.Day,
retainedFileCountLimit: 10,
outputTemplate: "{Timestamp:yyyy-MM-dd HH:mm:ss.fff} [{Level:u3}] {SourceContext} {Message:lj}{NewLine}{Exception}")
.CreateLogger();
Log.Information("PSLauncher starting (logs in {Path})", LogsDirectory);
AppDomain.CurrentDomain.UnhandledException += (_, args) =>
Log.Fatal((Exception)args.ExceptionObject, "Unhandled exception (AppDomain)");
DispatcherUnhandledException += (_, args) =>
{
Log.Error(args.Exception, "Unhandled UI exception");
args.Handled = false;
};
_host = Host.CreateDefaultBuilder()
.UseSerilog()
.ConfigureServices((_, services) =>
{
services.AddSingleton<IConfigStore, ConfigStore>();
services.AddSingleton<LocalConfig>(sp => sp.GetRequiredService<IConfigStore>().Load());
services.AddSingleton(sp =>
{
// Handler tuné pour les gros téléchargements parallèles :
// - MaxConnectionsPerServer=16 pour autoriser le multi-segment (8 par défaut).
// - AutomaticDecompression sur l'API JSON uniquement (None ici car les builds
// sont des ZIPs déjà compressés ; éviter la CPU+RAM gaspillée par un éventuel
// wrapping gzip côté serveur).
// - PooledConnectionLifetime court pour éviter qu'OVH ferme nos sockets sous nous.
var handler = new SocketsHttpHandler
{
PooledConnectionLifetime = TimeSpan.FromMinutes(5),
PooledConnectionIdleTimeout = TimeSpan.FromSeconds(60),
MaxConnectionsPerServer = 16,
EnableMultipleHttp2Connections = true,
AutomaticDecompression = System.Net.DecompressionMethods.None,
};
var http = new HttpClient(handler)
{
Timeout = Timeout.InfiniteTimeSpan,
DefaultRequestVersion = System.Net.HttpVersion.Version20,
DefaultVersionPolicy = HttpVersionPolicy.RequestVersionOrLower,
};
http.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("PSLauncher", "0.5"));
return http;
});
services.AddSingleton<IInstallationRegistry>(sp =>
new InstallationRegistry(
sp.GetRequiredService<ILogger<InstallationRegistry>>(),
() => sp.GetRequiredService<LocalConfig>().InstallRoot));
services.AddSingleton<IProcessLauncher, ProcessLauncher>();
services.AddSingleton<IIntegrityService, IntegrityService>();
services.AddSingleton<IZipInstaller, ZipInstaller>();
services.AddSingleton<IDownloadStateStore, DownloadStateStore>();
services.AddSingleton<IManifestService>(sp =>
new ManifestService(
sp.GetRequiredService<HttpClient>(),
() => sp.GetRequiredService<LocalConfig>().ServerBaseUrl,
sp.GetRequiredService<ILogger<ManifestService>>()));
services.AddSingleton<IDownloadManager, DownloadManager>();
services.AddSingleton<IUpdateChecker, UpdateChecker>();
services.AddSingleton<ILauncherSelfUpdater, LauncherSelfUpdater>();
services.AddSingleton<ILicenseService>(sp =>
new LicenseService(
sp.GetRequiredService<HttpClient>(),
() => sp.GetRequiredService<LocalConfig>().ServerBaseUrl,
sp.GetRequiredService<IConfigStore>(),
sp.GetRequiredService<LocalConfig>(),
sp.GetRequiredService<ILogger<LicenseService>>()));
services.AddSingleton<IToastService, ToastService>();
// 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<IDatabaseMigrationService>(sp =>
new DatabaseMigrationService(
configProvider: () => sp.GetRequiredService<LocalConfig>().Database,
passwordProvider: () => DatabasePasswordProtector.Unprotect(
sp.GetRequiredService<LocalConfig>().Database.EncryptedPassword),
sp.GetRequiredService<ILogger<DatabaseMigrationService>>()));
services.AddTransient<SettingsViewModel>();
services.AddSingleton<MainViewModel>();
services.AddSingleton<MainWindow>();
})
.Build();
var window = _host.Services.GetRequiredService<MainWindow>();
window.DataContext = _host.Services.GetRequiredService<MainViewModel>();
// Mise en page droite-à-gauche pour les langues RTL (arabe).
if (Strings.IsRightToLeft)
window.FlowDirection = FlowDirection.RightToLeft;
MainWindow = window;
window.Show();
}
protected override void OnExit(ExitEventArgs e)
{
Log.Information("PSLauncher shutting down");
Log.CloseAndFlush();
_host?.Dispose();
base.OnExit(e);
}
}