From 7de0c01b97e0262d4826de43185eec0acd7a49a0 Mon Sep 17 00:00:00 2001 From: "j.foucher" Date: Mon, 4 May 2026 13:53:13 +0200 Subject: [PATCH] =?UTF-8?q?v0.24.9=20=E2=80=94=20Deploy=20=5Fapi/,=20fix?= =?UTF-8?q?=20WebView2=20Program=20Files,=20DL=20robustness?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deploy auto de l'API PHP de stats (parallèle Report/Doc) : - ApiToolDeployer : copy _api/ → C:\xampp\htdocs\proserve\ avec atomic rename + backups horodatés + revert + pruning. Strict clone du pattern DocToolDeployer. - ApiToolConfig dans LocalConfig (default folder "proserve" en minuscule pour matcher l'install standard PROSERVE côté client). - Settings → Avancés : section dédiée (HtdocsRoot, FolderName, AutoDeploy, MaxBackups, Re-déployer, liste backups + revert) + ApiBackupViewModel. - DI registration dans App.xaml.cs, injection MainViewModel, appel post-install après le bloc Doc. - 8 nouvelles strings i18n (5 langues). Fix WebView2 vide en Program Files : - Set WEBVIEW2_USER_DATA_FOLDER vers %LocalAppData%\PSLauncher\WebView2\ dans App.OnStartup. Sans ça, WebView2 essaie d'écrire son cache à côté du .exe (read-only en Program Files) → init silencieusement KO → onglets Report/Doc vides. Refresh auto WebView2 sur navigation d'onglet : - Hook IsVisibleChanged sur ReportWebView et DocsWebView. À chaque ré-affichage, CoreWebView2.Reload() pour voir les données serveur à jour. Le binding Source garde la nav initiale au cold start. Fix UI : Cancel décalant le footer pendant Check updates : - Nouveau flag IsDownloadActive distinct de IsBusy. Visibility du bouton Cancel passe de IsBusy → IsDownloadActive : couvre uniquement les DL, plus aucune apparition pendant Check updates / Install / Uninstall / Self-update qui décalait tout le layout 1 s. DL robustness (multi-PC) : - SafeInstallAsync : error boundary sur l'install handler. Plus aucune exception silencieuse — log + popup d'erreur visible (résout les cas "le 2e PC ne démarre pas, sans message"). - DownloadManager : vérif explicite que CHAQUE segment a Completed=true && DownloadedBytes >= Length AVANT le SHA-256. Le check actualSize était inutile (fichier sparse-préalloué). - MaxRetryAttempts Polly 6 → 10 pour absorber les outages PHP-FPM request_terminate_timeout sous charge multi-PC. Co-Authored-By: Claude Opus 4.7 (1M context) --- installer/PSLauncher.iss | 2 +- src/PSLauncher.App/App.xaml.cs | 21 ++ src/PSLauncher.App/PSLauncher.App.csproj | 6 +- .../ViewModels/MainViewModel.cs | 90 +++++++- .../ViewModels/SettingsViewModel.cs | 135 +++++++++++ src/PSLauncher.App/Views/MainWindow.xaml | 2 +- src/PSLauncher.App/Views/MainWindow.xaml.cs | 19 ++ src/PSLauncher.App/Views/SettingsDialog.xaml | 110 +++++++++ .../ApiTool/ApiToolDeployer.cs | 211 ++++++++++++++++++ .../ApiTool/IApiToolDeployer.cs | 25 +++ .../Downloads/DownloadManager.cs | 23 +- src/PSLauncher.Core/Localization/Strings.cs | 36 +++ src/PSLauncher.Models/LocalConfig.cs | 21 ++ 13 files changed, 693 insertions(+), 8 deletions(-) create mode 100644 src/PSLauncher.Core/ApiTool/ApiToolDeployer.cs create mode 100644 src/PSLauncher.Core/ApiTool/IApiToolDeployer.cs diff --git a/installer/PSLauncher.iss b/installer/PSLauncher.iss index 9d6ae00..d0e88ab 100644 --- a/installer/PSLauncher.iss +++ b/installer/PSLauncher.iss @@ -11,7 +11,7 @@ #define MyAppName "PROSERVE Launcher" #define MyAppShortName "PS_Launcher" -#define MyAppVersion "0.24.5" +#define MyAppVersion "0.24.9" #define MyAppPublisher "ASTERION VR" #define MyAppURL "https://asterionvr.com" #define MyAppExeName "PS_Launcher.exe" diff --git a/src/PSLauncher.App/App.xaml.cs b/src/PSLauncher.App/App.xaml.cs index cef361f..aebabc9 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.ApiTool; using PSLauncher.Core.DocTool; using PSLauncher.Core.Health; using PSLauncher.Core.Health.OpenVRInterop; @@ -69,6 +70,19 @@ public partial class App : Application "PSLauncher", "logs"); Directory.CreateDirectory(LogsDirectory); + // WebView2 a besoin d'écrire son UserDataFolder (cache, cookies, GPU shader cache, + // etc.). Par défaut il essaie à côté du .exe — ce qui échoue silencieusement quand + // on est installé en Program Files (read-only sans admin) → onglets Report/Doc + // vides. On force le user-data-folder dans %LocalAppData%\PSLauncher\WebView2 via + // la var d'environnement officielle, lue par WebView2 à la première initialisation. + // À setter AVANT que tout contrôle WebView2 ne soit instancié → ici, dans OnStartup, + // est largement assez tôt (la MainWindow n'est créée que plus bas). + var webViewDataDir = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "PSLauncher", "WebView2"); + Directory.CreateDirectory(webViewDataDir); + Environment.SetEnvironmentVariable("WEBVIEW2_USER_DATA_FOLDER", webViewDataDir, EnvironmentVariableTarget.Process); + // 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 @@ -204,6 +218,13 @@ public partial class App : Application configProvider: () => sp.GetRequiredService().DocTool, sp.GetRequiredService>())); + // Déploiement de l'API PHP de stats (parallèle à Report/Doc) : + // copie atomique de _api/ → C:\xampp\htdocs\ProserveApi\ + services.AddSingleton(sp => + new ApiToolDeployer( + configProvider: () => sp.GetRequiredService().ApiTool, + sp.GetRequiredService>())); + services.AddSingleton(); services.AddSingleton(); diff --git a/src/PSLauncher.App/PSLauncher.App.csproj b/src/PSLauncher.App/PSLauncher.App.csproj index 962aa88..b13a877 100644 --- a/src/PSLauncher.App/PSLauncher.App.csproj +++ b/src/PSLauncher.App/PSLauncher.App.csproj @@ -18,9 +18,9 @@ PROSERVE Launcher © 2026 ASTERION VR — All rights reserved PSLauncher.App - 0.24.5 - 0.24.5.0 - 0.24.5.0 + 0.24.9 + 0.24.9.0 + 0.24.9.0 true diff --git a/src/PSLauncher.App/ViewModels/MainViewModel.cs b/src/PSLauncher.App/ViewModels/MainViewModel.cs index 2d111f9..b256667 100644 --- a/src/PSLauncher.App/ViewModels/MainViewModel.cs +++ b/src/PSLauncher.App/ViewModels/MainViewModel.cs @@ -17,6 +17,7 @@ using PSLauncher.Core.Installations; using PSLauncher.Core.Licensing; using PSLauncher.Core.Localization; using PSLauncher.Core.Manifests; +using PSLauncher.Core.ApiTool; using PSLauncher.Core.DocTool; using PSLauncher.Core.Health; using PSLauncher.Core.Migrations; @@ -49,6 +50,7 @@ public sealed partial class MainViewModel : ObservableObject private readonly IDatabaseMigrationService _migrationService; private readonly IReportToolDeployer _reportDeployer; private readonly IDocToolDeployer _docDeployer; + private readonly IApiToolDeployer _apiDeployer; private readonly ISystemHealthService _healthService; private readonly IServiceProvider _serviceProvider; private readonly ILogger _logger; @@ -112,6 +114,15 @@ public sealed partial class MainViewModel : ObservableObject [NotifyCanExecuteChangedFor(nameof(CheckForUpdatesCommand))] private bool _isBusy; + /// + /// Flag dédié au téléchargement en cours, distinct de qui + /// couvre AUSSI Check updates / Install / Uninstall / Self-update. Sert à scoper + /// la visibilité du bouton "Annuler" du footer aux seuls DL — sinon il apparaît + /// 1 s pendant un Check updates et décale tout le layout. + /// + [ObservableProperty] + private bool _isDownloadActive; + [ObservableProperty] private double _progressPercent; /// @@ -311,6 +322,7 @@ public sealed partial class MainViewModel : ObservableObject IDatabaseMigrationService migrationService, IReportToolDeployer reportDeployer, IDocToolDeployer docDeployer, + IApiToolDeployer apiDeployer, ISystemHealthService healthService, IServiceProvider serviceProvider, ILogger logger) @@ -329,6 +341,7 @@ public sealed partial class MainViewModel : ObservableObject _migrationService = migrationService; _reportDeployer = reportDeployer; _docDeployer = docDeployer; + _apiDeployer = apiDeployer; _healthService = healthService; _serviceProvider = serviceProvider; _logger = logger; @@ -516,13 +529,43 @@ public sealed partial class MainViewModel : ObservableObject private void WireRowHandlers(VersionRowViewModel row) { row.LaunchHandler = LaunchVersion; - row.InstallHandler = r => _activeInstallTask = InstallVersionAsync(r); + // Wrap InstallVersionAsync via SafeInstallAsync : sans ça, une exception + // levée AVANT le premier await (ex. ThemedMessageBox.Show dans le check + // "IsBusy") tomberait dans le vide (handler sync void) et la UI resterait + // figée sans message — exactement le symptôme "le 2e PC ne démarre pas". + row.InstallHandler = r => _activeInstallTask = SafeInstallAsync(r); row.UninstallHandler = r => _ = UninstallVersionAsync(r); row.ShowReleaseNotesHandler = r => _ = ShowReleaseNotesAsync(r); row.OpenFolderHandler = OpenVersionFolder; row.RestartFromZeroHandler = r => _ = RestartFromZeroAsync(r); } + /// + /// Error boundary autour de . Garantit qu'aucune + /// exception ne disparaît silencieusement (cas du fire-and-forget pré-0.24.6 où + /// le launcher pouvait rester figé sur un click sans aucun feedback). + /// + private async Task SafeInstallAsync(VersionRowViewModel row) + { + try + { + await InstallVersionAsync(row); + } + catch (Exception ex) + { + _logger.LogError(ex, "InstallVersionAsync unhandled exception (row={Version})", row.Version); + StatusMessage = Strings.StatusError(ex.Message); + try + { + ThemedMessageBox.Show( + Strings.MsgInstallFailed(ex.Message), + Strings.MsgBoxError, + MessageBoxButton.OK, MessageBoxImage.Error); + } + catch { /* dialog peut échouer si on est en train de quitter */ } + } + } + [RelayCommand(CanExecute = nameof(CanCheckUpdates))] private async Task CheckForUpdatesAsync() { @@ -826,6 +869,7 @@ public sealed partial class MainViewModel : ObservableObject if (IsBusy) { ThemedMessageBox.Show(Strings.MsgBusy, Strings.MsgBoxPatience, MessageBoxButton.OK, MessageBoxImage.Information); return; } IsBusy = true; + IsDownloadActive = true; _activeRow = row; _activeDownloadCts = new CancellationTokenSource(); var ct = _activeDownloadCts.Token; @@ -1059,6 +1103,49 @@ public sealed partial class MainViewModel : ObservableObject } } + // 8) Déploiement de l'API PHP de stats (parallèle à Report/Doc). + // Cherche _api/ dans le ZIP extrait et copie vers htdocs en + // atomic rename. Skip silencieux si _api/ absent du ZIP. + if (_config.ApiTool.AutoDeploy) + { + StatusMessage = Strings.StatusDeployingApi(row.Version); + ProgressDetail = null; + ProgressPercent = 0; + var apProgress = new Progress(dp => + { + if (dp.FilesTotal > 0) + { + var pct = (double)dp.FilesDone / dp.FilesTotal * 100.0; + ProgressPercent = pct; + row.ProgressPercent = pct; + } + var label = Strings.ProgressApiDeploy(dp.FilesDone, dp.FilesTotal, dp.CurrentFilename); + ProgressDetail = label; + row.ProgressDetail = label; + }); + var apResult = await _apiDeployer.DeployAsync(target, apProgress, ct); + switch (apResult.Status) + { + case DeployStatus.Deployed: + _logger.LogInformation("Api tool deployed ({N} files) for v{Version}", apResult.FilesDeployed, row.Version); + break; + case DeployStatus.SkippedNoSourceFolder: + _logger.LogInformation("No _api/ in v{Version} ZIP, api tool unchanged", row.Version); + break; + case DeployStatus.XamppNotFound: + // Idem Doc : warning XAMPP déjà éventuellement affiché par Report, + // on log silencieusement pour ne pas multiplier les popups identiques. + _logger.LogWarning("Api tool deploy : XAMPP htdocs not found at {Root}", _config.ApiTool.HtdocsRoot); + break; + case DeployStatus.Failed: + ThemedMessageBox.Show( + Strings.MsgApiDeployFailed(apResult.ErrorMessage ?? "?"), + Strings.MsgBoxApiDeployFailed, + MessageBoxButton.OK, MessageBoxImage.Warning); + break; + } + } + StatusMessage = Strings.StatusInstalledSuccess(row.Version); ProgressDetail = null; ProgressPercent = 0; @@ -1100,6 +1187,7 @@ public sealed partial class MainViewModel : ObservableObject _activeRow = null; _activeInstallTask = null; IsBusy = false; + IsDownloadActive = false; } } diff --git a/src/PSLauncher.App/ViewModels/SettingsViewModel.cs b/src/PSLauncher.App/ViewModels/SettingsViewModel.cs index 35fa987..673e85a 100644 --- a/src/PSLauncher.App/ViewModels/SettingsViewModel.cs +++ b/src/PSLauncher.App/ViewModels/SettingsViewModel.cs @@ -8,6 +8,7 @@ using CommunityToolkit.Mvvm.Input; using Microsoft.Extensions.Logging; using PSLauncher.App.Views; using PSLauncher.Core.Configuration; +using PSLauncher.Core.ApiTool; using PSLauncher.Core.DocTool; using PSLauncher.Core.Downloads; using PSLauncher.Core.Installations; @@ -35,6 +36,7 @@ public sealed partial class SettingsViewModel : ObservableObject private readonly IDatabaseMigrationService _migrationService; private readonly IReportToolDeployer _reportDeployer; private readonly IDocToolDeployer _docDeployer; + private readonly IApiToolDeployer _apiDeployer; private readonly IInstallationRegistry _registry; private readonly HttpClient _http; private readonly ILogger _logger; @@ -85,6 +87,17 @@ public sealed partial class SettingsViewModel : ObservableObject = new(); public bool HasDocBackups => DocBackups.Count > 0; + // ----- API Tool deploy settings (mirror of Report/Doc) ----- + [ObservableProperty] private string _apiHtdocsRoot; + [ObservableProperty] private string _apiFolderName; + [ObservableProperty] private bool _apiAutoDeploy; + [ObservableProperty] private int _apiMaxBackups; + [ObservableProperty] private string? _apiDeployStatus; + [ObservableProperty] private bool _isDeployingApi; + public System.Collections.ObjectModel.ObservableCollection ApiBackups { get; } + = new(); + public bool HasApiBackups => ApiBackups.Count > 0; + // ----- Health checks (bandeau de santé système) ----- /// /// Liste éditable des checks affichés dans le bandeau de santé. Chaque ligne @@ -165,6 +178,7 @@ public sealed partial class SettingsViewModel : ObservableObject IDatabaseMigrationService migrationService, IReportToolDeployer reportDeployer, IDocToolDeployer docDeployer, + IApiToolDeployer apiDeployer, IInstallationRegistry registry, HttpClient http, ILogger logger) @@ -176,6 +190,7 @@ public sealed partial class SettingsViewModel : ObservableObject _migrationService = migrationService; _reportDeployer = reportDeployer; _docDeployer = docDeployer; + _apiDeployer = apiDeployer; _registry = registry; _http = http; _logger = logger; @@ -204,6 +219,11 @@ public sealed partial class SettingsViewModel : ObservableObject _docAutoDeploy = config.DocTool.AutoDeploy; _docMaxBackups = config.DocTool.MaxBackups; + _apiHtdocsRoot = config.ApiTool.HtdocsRoot; + _apiFolderName = config.ApiTool.FolderName; + _apiAutoDeploy = config.ApiTool.AutoDeploy; + _apiMaxBackups = config.ApiTool.MaxBackups; + // Snapshot éditable des health checks. On clone chaque entry pour ne pas // muter _config.HealthChecks.Checks tant que l'utilisateur n'a pas cliqué Save. foreach (var entry in config.HealthChecks.Checks) @@ -212,6 +232,7 @@ public sealed partial class SettingsViewModel : ObservableObject // Charge les listes de backups en arrière-plan (UI immédiate puis remplies quand prêtes) _ = LoadReportBackupsAsync(); _ = LoadDocBackupsAsync(); + _ = LoadApiBackupsAsync(); LauncherVersion = Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? "0.0.0"; MachineId = licenseService.GetMachineId(); @@ -343,6 +364,11 @@ public sealed partial class SettingsViewModel : ObservableObject _config.DocTool.AutoDeploy = DocAutoDeploy; _config.DocTool.MaxBackups = Math.Max(0, DocMaxBackups); + _config.ApiTool.HtdocsRoot = ApiHtdocsRoot.Trim(); + _config.ApiTool.FolderName = ApiFolderName.Trim(); + _config.ApiTool.AutoDeploy = ApiAutoDeploy; + _config.ApiTool.MaxBackups = Math.Max(0, ApiMaxBackups); + // Health checks : on remplace intégralement la liste persistée par // les copies éditées. Les originaux restent en mémoire de toute façon // (les VM les ont clonés), donc Cancel a déjà préservé l'état initial. @@ -781,6 +807,96 @@ public sealed partial class SettingsViewModel : ObservableObject IsDeployingDoc = false; } } + + // ===================== API TOOL BACKUPS (mirror of Report) ===================== + + private async Task LoadApiBackupsAsync() + { + try + { + var list = await _apiDeployer.ListBackupsAsync(CancellationToken.None); + ApiBackups.Clear(); + foreach (var b in list) + ApiBackups.Add(new ApiBackupViewModel(b, RevertApiToBackupAsync)); + OnPropertyChanged(nameof(HasApiBackups)); + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Could not list api backups"); + } + } + + private async Task RevertApiToBackupAsync(BackupInfo backup) + { + var localDate = backup.TimestampUtc.ToLocalTime(); + var dateLabel = Strings.FormatLongDate(localDate); + var confirm = ThemedMessageBox.Show( + Strings.MsgRevertReportConfirm(dateLabel), + Strings.MsgBoxRevertReport, + MessageBoxButton.YesNo, MessageBoxImage.Question, MessageBoxResult.No); + if (confirm != MessageBoxResult.Yes) return; + + IsDeployingApi = true; + ApiDeployStatus = $"Revert vers {dateLabel}…"; + try + { + var result = await _apiDeployer.RevertAsync(backup.FolderName, CancellationToken.None); + ApiDeployStatus = result.Status == DeployStatus.Deployed + ? Strings.MsgRevertReportSuccess(dateLabel) + : $"✗ {result.ErrorMessage}"; + await LoadApiBackupsAsync(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Revert api failed"); + ApiDeployStatus = $"✗ {ex.Message}"; + } + finally + { + IsDeployingApi = false; + } + } + + [RelayCommand] + private async Task RedeployApiAsync() + { + var installed = _registry.Scan().OrderByDescending(v => v.Version).FirstOrDefault(); + if (installed is null) + { + ApiDeployStatus = "Aucune version installée — installe d'abord une version qui contient _api/."; + return; + } + IsDeployingApi = true; + ApiDeployStatus = $"Déploiement depuis v{installed.Version}…"; + try + { + Save(); + var progress = new Progress(dp => + { + if (dp.FilesTotal > 0) + ApiDeployStatus = $"{dp.FilesDone}/{dp.FilesTotal} : {dp.CurrentFilename}"; + }); + var result = await _apiDeployer.DeployAsync(installed.FolderPath, progress, CancellationToken.None); + ApiDeployStatus = result.Status switch + { + DeployStatus.Deployed => $"✓ {result.FilesDeployed} fichiers déployés vers {result.TargetPath}", + DeployStatus.SkippedNoSourceFolder => $"⚠ La version v{installed.Version} ne contient pas de _api/ — rien à déployer", + DeployStatus.XamppNotFound => $"✗ {result.ErrorMessage}", + DeployStatus.Failed => $"✗ {result.ErrorMessage}", + _ => "?", + }; + await LoadApiBackupsAsync(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Redeploy api failed"); + ApiDeployStatus = $"✗ {ex.Message}"; + } + finally + { + IsDeployingApi = false; + } + } } /// @@ -899,3 +1015,22 @@ public sealed partial class DocBackupViewModel : ObservableObject [RelayCommand] private Task Revert() => _revertHandler(Info); } + +/// Variante api — même structure que . +public sealed partial class ApiBackupViewModel : ObservableObject +{ + public BackupInfo Info { get; } + public string DateDisplay => Strings.FormatLongDate(Info.TimestampUtc.ToLocalTime()); + public string SizeDisplay => Strings.SettingsReportBackupSize(Strings.FormatSize(Info.SizeBytes)); + public string FolderName => Info.FolderName; + + private readonly Func _revertHandler; + public ApiBackupViewModel(BackupInfo info, Func revertHandler) + { + Info = info; + _revertHandler = revertHandler; + } + + [RelayCommand] + private Task Revert() => _revertHandler(Info); +} diff --git a/src/PSLauncher.App/Views/MainWindow.xaml b/src/PSLauncher.App/Views/MainWindow.xaml index 49ec239..59e2f98 100644 --- a/src/PSLauncher.App/Views/MainWindow.xaml +++ b/src/PSLauncher.App/Views/MainWindow.xaml @@ -767,7 +767,7 @@ Content="{x:Static loc:Strings.ActionCancel}" VerticalAlignment="Center" Margin="12,0,0,0" Command="{Binding CancelDownloadCommand}" - Visibility="{Binding IsBusy, Converter={StaticResource BoolToVisibility}}" /> + Visibility="{Binding IsDownloadActive, Converter={StaticResource BoolToVisibility}}" /> diff --git a/src/PSLauncher.App/Views/MainWindow.xaml.cs b/src/PSLauncher.App/Views/MainWindow.xaml.cs index 73594fa..17fe66b 100644 --- a/src/PSLauncher.App/Views/MainWindow.xaml.cs +++ b/src/PSLauncher.App/Views/MainWindow.xaml.cs @@ -3,6 +3,7 @@ using System.Windows; using System.Windows.Controls; using System.Windows.Controls.Primitives; using System.Windows.Interop; +using Microsoft.Web.WebView2.Wpf; using PSLauncher.App.ViewModels; using PSLauncher.Core.Localization; @@ -90,6 +91,24 @@ public partial class MainWindow : Window InitializeComponent(); SourceInitialized += MainWindow_SourceInitialized; Closing += MainWindow_Closing; + + // Force un reload de la WebView quand son onglet redevient visible + // (Library → Report ou Library → Doc), pour que l'utilisateur voie + // toujours les données les plus à jour côté serveur PHP. Sans ça, + // WebView2 garde la page en cache de la première navigation et ne + // refresh jamais. Au tout premier affichage CoreWebView2 n'est pas + // encore initialisée — le binding Source fait alors la nav initiale, + // les reloads suivants kicker uniquement sur les changements d'onglet. + ReportWebView.IsVisibleChanged += OnWebViewBecameVisible; + DocsWebView.IsVisibleChanged += OnWebViewBecameVisible; + } + + private static void OnWebViewBecameVisible(object sender, DependencyPropertyChangedEventArgs e) + { + if (e.NewValue is true && sender is WebView2 wv && wv.CoreWebView2 is not null) + { + wv.CoreWebView2.Reload(); + } } /// diff --git a/src/PSLauncher.App/Views/SettingsDialog.xaml b/src/PSLauncher.App/Views/SettingsDialog.xaml index 1b861c0..6fb1ea3 100644 --- a/src/PSLauncher.App/Views/SettingsDialog.xaml +++ b/src/PSLauncher.App/Views/SettingsDialog.xaml @@ -538,6 +538,116 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +public sealed class ApiToolDeployer : IApiToolDeployer +{ + private const string SourceSubdir = "_api"; + private const string BackupSuffix = ".backup-"; + private const string TimestampFormat = "yyyyMMdd-HHmmss"; + + private readonly Func _configProvider; + private readonly ILogger _logger; + + public ApiToolDeployer( + Func configProvider, + ILogger logger) + { + _configProvider = configProvider; + _logger = logger; + } + + public async Task DeployAsync( + string installFolder, + IProgress? progress, + CancellationToken ct) + { + var cfg = _configProvider(); + var source = Path.Combine(installFolder, SourceSubdir); + var target = Path.Combine(cfg.HtdocsRoot, cfg.FolderName); + + if (!Directory.Exists(source)) + { + _logger.LogInformation("No {Sub}/ directory in {Folder}, skipping api deploy", SourceSubdir, installFolder); + return new DeployResult(DeployStatus.SkippedNoSourceFolder, 0, 0, target); + } + + if (!Directory.Exists(cfg.HtdocsRoot)) + { + _logger.LogWarning("HtdocsRoot {Root} not found — XAMPP not installed at expected location", cfg.HtdocsRoot); + return new DeployResult(DeployStatus.XamppNotFound, 0, 0, target, + $"Le dossier {cfg.HtdocsRoot} est introuvable. XAMPP est-il installé à cet emplacement ? (Settings → Avancés)"); + } + + var stagingTarget = target + ".new"; + var backupTarget = target + BackupSuffix + DateTime.UtcNow.ToString(TimestampFormat, CultureInfo.InvariantCulture); + + try + { + if (Directory.Exists(stagingTarget)) Directory.Delete(stagingTarget, recursive: true); + + var allFiles = Directory.EnumerateFiles(source, "*", SearchOption.AllDirectories).ToList(); + int total = allFiles.Count; + long bytesTotal = 0; + + await Task.Run(() => + { + Directory.CreateDirectory(stagingTarget); + int done = 0; + foreach (var srcFile in allFiles) + { + ct.ThrowIfCancellationRequested(); + var rel = Path.GetRelativePath(source, srcFile); + var dstFile = Path.Combine(stagingTarget, rel); + var dstDir = Path.GetDirectoryName(dstFile); + if (!string.IsNullOrEmpty(dstDir) && !Directory.Exists(dstDir)) + Directory.CreateDirectory(dstDir); + File.Copy(srcFile, dstFile, overwrite: true); + bytesTotal += new FileInfo(srcFile).Length; + done++; + progress?.Report(new DeployProgress(done, total, rel)); + } + }, ct).ConfigureAwait(false); + + if (Directory.Exists(target)) + Directory.Move(target, backupTarget); + Directory.Move(stagingTarget, target); + + PruneOldBackups(cfg); + + _logger.LogInformation("Api tool deployed to {Target} ({Files} files, {Bytes} bytes), backup at {Backup}", + target, total, bytesTotal, backupTarget); + return new DeployResult(DeployStatus.Deployed, total, bytesTotal, target); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to deploy api tool to {Target}", target); + try { if (Directory.Exists(stagingTarget)) Directory.Delete(stagingTarget, recursive: true); } + catch { /* ignore */ } + return new DeployResult(DeployStatus.Failed, 0, 0, target, ex.Message); + } + } + + public Task> ListBackupsAsync(CancellationToken ct) + { + var cfg = _configProvider(); + var prefix = cfg.FolderName + BackupSuffix; + + if (!Directory.Exists(cfg.HtdocsRoot)) + return Task.FromResult>(Array.Empty()); + + var list = new List(); + foreach (var dir in Directory.EnumerateDirectories(cfg.HtdocsRoot, prefix + "*", SearchOption.TopDirectoryOnly)) + { + ct.ThrowIfCancellationRequested(); + var name = Path.GetFileName(dir); + var tsPart = name.Substring(prefix.Length); + if (!DateTime.TryParseExact(tsPart, TimestampFormat, CultureInfo.InvariantCulture, + DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var ts)) + { + continue; + } + long size = 0; + try + { + foreach (var f in Directory.EnumerateFiles(dir, "*", SearchOption.AllDirectories)) + size += new FileInfo(f).Length; + } + catch { /* size best-effort */ } + list.Add(new BackupInfo(name, ts, size)); + } + list.Sort((a, b) => b.TimestampUtc.CompareTo(a.TimestampUtc)); + return Task.FromResult>(list); + } + + public async Task RevertAsync(string backupFolderName, CancellationToken ct) + { + var cfg = _configProvider(); + var backupPath = Path.Combine(cfg.HtdocsRoot, backupFolderName); + var target = Path.Combine(cfg.HtdocsRoot, cfg.FolderName); + + if (!Directory.Exists(backupPath)) + { + return new DeployResult(DeployStatus.BackupNotFound, 0, 0, target, + $"Le backup {backupFolderName} n'existe plus."); + } + + var newBackupForCurrent = target + BackupSuffix + DateTime.UtcNow.ToString(TimestampFormat, CultureInfo.InvariantCulture); + + try + { + await Task.Run(() => + { + ct.ThrowIfCancellationRequested(); + if (Directory.Exists(target)) + Directory.Move(target, newBackupForCurrent); + Directory.Move(backupPath, target); + }, ct).ConfigureAwait(false); + + int files = 0; + long bytes = 0; + try + { + foreach (var f in Directory.EnumerateFiles(target, "*", SearchOption.AllDirectories)) + { + files++; + bytes += new FileInfo(f).Length; + } + } + catch { /* best-effort */ } + + PruneOldBackups(cfg); + _logger.LogInformation("Reverted api to backup {Backup} ({Files} files), previous current saved as {New}", + backupFolderName, files, newBackupForCurrent); + return new DeployResult(DeployStatus.Deployed, files, bytes, target); + } + catch (Exception ex) + { + _logger.LogError(ex, "Api revert to {Backup} failed", backupFolderName); + return new DeployResult(DeployStatus.Failed, 0, 0, target, ex.Message); + } + } + + private void PruneOldBackups(ApiToolConfig cfg) + { + try + { + var prefix = cfg.FolderName + BackupSuffix; + var dirs = Directory.EnumerateDirectories(cfg.HtdocsRoot, prefix + "*", SearchOption.TopDirectoryOnly) + .Select(d => new { Path = d, Name = Path.GetFileName(d) }) + .OrderByDescending(d => d.Name, StringComparer.Ordinal) + .ToList(); + + int keep = Math.Max(0, cfg.MaxBackups); + for (int i = keep; i < dirs.Count; i++) + { + try + { + Directory.Delete(dirs[i].Path, recursive: true); + _logger.LogDebug("Pruned old api backup {Dir}", dirs[i].Name); + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Could not prune api backup {Dir} (will retry next deploy)", dirs[i].Name); + } + } + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Api backup pruning skipped"); + } + } +} diff --git a/src/PSLauncher.Core/ApiTool/IApiToolDeployer.cs b/src/PSLauncher.Core/ApiTool/IApiToolDeployer.cs new file mode 100644 index 0000000..ba3508c --- /dev/null +++ b/src/PSLauncher.Core/ApiTool/IApiToolDeployer.cs @@ -0,0 +1,25 @@ +using PSLauncher.Core.ReportTool; + +namespace PSLauncher.Core.ApiTool; + +/// +/// Déploie l'API PHP de stats PROSERVE depuis le sous-dossier _api/ bundled +/// dans chaque ZIP PROSERVE vers le htdocs local. Mêmes garanties que +/// : copy + atomic rename + backups horodatés +/// + revert manuel. +/// +/// On réutilise les types , , +/// et du namespace ReportTool +/// pour ne pas dupliquer la modélisation — le mécanisme est strictement le même. +/// +public interface IApiToolDeployer +{ + Task DeployAsync( + string installFolder, + IProgress? progress, + CancellationToken ct); + + Task> ListBackupsAsync(CancellationToken ct); + + Task RevertAsync(string backupFolderName, CancellationToken ct); +} diff --git a/src/PSLauncher.Core/Downloads/DownloadManager.cs b/src/PSLauncher.Core/Downloads/DownloadManager.cs index 1e7fc28..a5696a7 100644 --- a/src/PSLauncher.Core/Downloads/DownloadManager.cs +++ b/src/PSLauncher.Core/Downloads/DownloadManager.cs @@ -62,7 +62,11 @@ public sealed class DownloadManager : IDownloadManager _retryPipeline = new ResiliencePipelineBuilder() .AddRetry(new RetryStrategyOptions { - MaxRetryAttempts = 6, + // 10 tentatives × backoff 1→32s avec jitter ≈ ~3 min de retry budget + // par segment. Couvre les outages PHP-FPM courts en multi-PC (où des + // segments peuvent être tués mid-stream par request_terminate_timeout) + // sans donner l'impression d'une boucle infinie en cas de panne réelle. + MaxRetryAttempts = 10, BackoffType = DelayBackoffType.Exponential, Delay = TimeSpan.FromSeconds(1), MaxDelay = TimeSpan.FromSeconds(32), @@ -369,7 +373,22 @@ public sealed class DownloadManager : IDownloadManager _stateStore.Save(state); progress?.Report(new DownloadProgress(state.DownloadedBytes, total, 0, TimeSpan.Zero)); - // 5. Vérif taille + // 5. Vérif complétion des segments + // Le check `actualSize != total` ci-dessous est INSUFFISANT car le fichier est + // pré-alloué en sparse à `total` octets — sa taille filesystem matchera toujours + // peu importe ce qui a été écrit. Si un segment a échoué silencieusement (cas + // pré-0.24.5 ou bug futur), on aurait des trous (zéros NTFS) → SHA KO en sortie + // sans diagnostic clair. On vérifie explicitement que CHAQUE segment a atteint + // sa cible AVANT de lancer le SHA-256 (qui prend 30s-3min sur un 14 Go). + var incomplete = state.Segments.FirstOrDefault(s => !s.Completed || s.DownloadedBytes < s.Length); + if (incomplete is not null) + { + _stateStore.Save(state); // garde l'état pour permettre un resume manuel + throw new InvalidDataException( + $"Segment {incomplete.Index} incomplet : {incomplete.DownloadedBytes:N0}/{incomplete.Length:N0} octets " + + $"(Completed={incomplete.Completed}). Relance le téléchargement pour reprendre."); + } + var actualSize = new FileInfo(partialPath).Length; if (actualSize != total) { diff --git a/src/PSLauncher.Core/Localization/Strings.cs b/src/PSLauncher.Core/Localization/Strings.cs index 9b4c4e0..d77b576 100644 --- a/src/PSLauncher.Core/Localization/Strings.cs +++ b/src/PSLauncher.Core/Localization/Strings.cs @@ -567,6 +567,42 @@ public static class Strings public static string SettingsDocBackups => SettingsReportBackups; public static string SettingsDocMaxBackups => SettingsReportMaxBackups; + // ---- API tool (mêmes mécanique que Report/Doc, autres libellés) ---- + public static string StatusDeployingApi(string version) => T( + $"Déploiement de l'API v{version}…", + $"Deploying API for v{version}…", + $"正在部署 v{version} 的 API…", + $"กำลังปรับใช้ API v{version}…", + $"جارٍ نشر واجهة برمجة التطبيقات v{version}…" + ); + public static string ProgressApiDeploy(int done, int total, string filename) => T( + $"🔌 Déploiement API : {done}/{total} — {filename}", + $"🔌 Deploying API: {done}/{total} — {filename}", + $"🔌 部署 API:{done}/{total} — {filename}", + $"🔌 ปรับใช้ API: {done}/{total} — {filename}", + $"🔌 نشر API: {done}/{total} — {filename}" + ); + public static string MsgBoxApiDeployFailed => T("Déploiement API échoué", "API deploy failed", "API 部署失败", "การปรับใช้ API ล้มเหลว", "فشل نشر API"); + public static string MsgApiDeployFailed(string detail) => T( + $"Le déploiement de l'API a échoué :\n\n{detail}\n\nLa version PROSERVE est installée mais l'API de stats pourrait être désynchronisée. Tu peux retenter via Paramètres → Avancés → Re-déployer l'API.", + $"API deploy failed:\n\n{detail}\n\nThe PROSERVE version is installed but the stats API might be out of sync. You can retry via Settings → Advanced → Re-deploy API.", + $"API 部署失败:\n\n{detail}\n\nPROSERVE 版本已安装,但统计 API 可能不同步。您可以通过 设置 → 高级 → 重新部署 API 重试。", + $"การปรับใช้ API ล้มเหลว:\n\n{detail}\n\nเวอร์ชัน PROSERVE ติดตั้งแล้วแต่ API สถิติอาจไม่ซิงค์ คุณสามารถลองใหม่ผ่าน ตั้งค่า → ขั้นสูง → ปรับใช้ API ใหม่", + $"فشل نشر API:\n\n{detail}\n\nنسخة PROSERVE مثبتة لكن API الإحصائيات قد لا يكون متزامناً. يمكنك إعادة المحاولة عبر الإعدادات → متقدم → إعادة نشر API." + ); + + public static string SettingsApiTool => T("API PHP DE STATS (XAMPP htdocs)", "STATS PHP API (XAMPP htdocs)", "统计 PHP API (XAMPP htdocs)", "API PHP สถิติ (XAMPP htdocs)", "واجهة API PHP للإحصائيات (XAMPP htdocs)"); + public static string SettingsApiAutoDeploy => T( + "Déployer automatiquement l'API à l'install d'une nouvelle version", + "Automatically deploy the API when installing a new version", + "安装新版本时自动部署 API", + "ปรับใช้ API โดยอัตโนมัติเมื่อติดตั้งเวอร์ชันใหม่", + "نشر API تلقائياً عند تثبيت نسخة جديدة" + ); + public static string SettingsApiRedeploy => T("🔌 Re-déployer l'API maintenant", "🔌 Re-deploy API now", "🔌 立即重新部署 API", "🔌 ปรับใช้ API ใหม่เดี๋ยวนี้", "🔌 إعادة نشر API الآن"); + public static string SettingsApiBackups => SettingsReportBackups; + public static string SettingsApiMaxBackups => SettingsReportMaxBackups; + // ---- Backups Report tool ---- public static string SettingsReportBackups => T("Backups disponibles", "Available backups", "可用备份", "สำรองข้อมูลที่มี", "النسخ الاحتياطية المتاحة"); public static string SettingsReportMaxBackups => T("Conserver N backups", "Keep N backups", "保留 N 个备份", "เก็บสำรอง N ชุด", "الاحتفاظ بـ N نسخة احتياطية"); diff --git a/src/PSLauncher.Models/LocalConfig.cs b/src/PSLauncher.Models/LocalConfig.cs index 8f9e486..75ede97 100644 --- a/src/PSLauncher.Models/LocalConfig.cs +++ b/src/PSLauncher.Models/LocalConfig.cs @@ -64,6 +64,13 @@ public sealed class LocalConfig /// public DocToolConfig DocTool { get; set; } = new(); + /// + /// Configuration du déploiement de l'API PHP de stats PROSERVE. Même mécanisme + /// que ReportTool / DocTool : copie de _api/ bundled dans le ZIP PROSERVE + /// vers {HtdocsRoot}\{FolderName} avec backups horodatés et revert. + /// + public ApiToolConfig ApiTool { get; set; } = new(); + /// /// Surveillance d'état système affichée en bandeau sous la top bar /// (pings d'IP, processus requis comme SteamVR / Vive Business Streaming…). @@ -150,6 +157,20 @@ public sealed class DocToolConfig public int MaxBackups { get; set; } = 3; } +/// +/// Mêmes paramètres que mais pour l'API PHP de +/// stats (sous-dossier _api/ du ZIP, déployé vers +/// {HtdocsRoot}\proserve par défaut — c'est l'emplacement standard où +/// PROSERVE s'attend à trouver son API côté client, en minuscule). +/// +public sealed class ApiToolConfig +{ + public string HtdocsRoot { get; set; } = @"C:\xampp\htdocs"; + public string FolderName { get; set; } = "proserve"; + public bool AutoDeploy { get; set; } = true; + public int MaxBackups { get; set; } = 3; +} + public sealed class HealthChecksConfig { /// Période de re-vérification, en secondes. 0 = désactivé.