v0.24.9 — Deploy _api/, fix WebView2 Program Files, DL robustness
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) <noreply@anthropic.com>
This commit is contained in:
@@ -11,7 +11,7 @@
|
|||||||
|
|
||||||
#define MyAppName "PROSERVE Launcher"
|
#define MyAppName "PROSERVE Launcher"
|
||||||
#define MyAppShortName "PS_Launcher"
|
#define MyAppShortName "PS_Launcher"
|
||||||
#define MyAppVersion "0.24.5"
|
#define MyAppVersion "0.24.9"
|
||||||
#define MyAppPublisher "ASTERION VR"
|
#define MyAppPublisher "ASTERION VR"
|
||||||
#define MyAppURL "https://asterionvr.com"
|
#define MyAppURL "https://asterionvr.com"
|
||||||
#define MyAppExeName "PS_Launcher.exe"
|
#define MyAppExeName "PS_Launcher.exe"
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ using PSLauncher.Core.Integrity;
|
|||||||
using PSLauncher.Core.Licensing;
|
using PSLauncher.Core.Licensing;
|
||||||
using PSLauncher.Core.Localization;
|
using PSLauncher.Core.Localization;
|
||||||
using PSLauncher.Core.Manifests;
|
using PSLauncher.Core.Manifests;
|
||||||
|
using PSLauncher.Core.ApiTool;
|
||||||
using PSLauncher.Core.DocTool;
|
using PSLauncher.Core.DocTool;
|
||||||
using PSLauncher.Core.Health;
|
using PSLauncher.Core.Health;
|
||||||
using PSLauncher.Core.Health.OpenVRInterop;
|
using PSLauncher.Core.Health.OpenVRInterop;
|
||||||
@@ -69,6 +70,19 @@ public partial class App : Application
|
|||||||
"PSLauncher", "logs");
|
"PSLauncher", "logs");
|
||||||
Directory.CreateDirectory(LogsDirectory);
|
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
|
// Bootstrap i18n : on lit d'abord la config (sans services DI) pour fixer la
|
||||||
// langue avant que le moindre élément XAML soit instancié.
|
// langue avant que le moindre élément XAML soit instancié.
|
||||||
try
|
try
|
||||||
@@ -204,6 +218,13 @@ public partial class App : Application
|
|||||||
configProvider: () => sp.GetRequiredService<LocalConfig>().DocTool,
|
configProvider: () => sp.GetRequiredService<LocalConfig>().DocTool,
|
||||||
sp.GetRequiredService<ILogger<DocToolDeployer>>()));
|
sp.GetRequiredService<ILogger<DocToolDeployer>>()));
|
||||||
|
|
||||||
|
// Déploiement de l'API PHP de stats (parallèle à Report/Doc) :
|
||||||
|
// copie atomique de _api/ → C:\xampp\htdocs\ProserveApi\
|
||||||
|
services.AddSingleton<IApiToolDeployer>(sp =>
|
||||||
|
new ApiToolDeployer(
|
||||||
|
configProvider: () => sp.GetRequiredService<LocalConfig>().ApiTool,
|
||||||
|
sp.GetRequiredService<ILogger<ApiToolDeployer>>()));
|
||||||
|
|
||||||
services.AddSingleton<IOpenVrService, OpenVrService>();
|
services.AddSingleton<IOpenVrService, OpenVrService>();
|
||||||
services.AddSingleton<ISystemHealthService, SystemHealthService>();
|
services.AddSingleton<ISystemHealthService, SystemHealthService>();
|
||||||
|
|
||||||
|
|||||||
@@ -18,9 +18,9 @@
|
|||||||
<Product>PROSERVE Launcher</Product>
|
<Product>PROSERVE Launcher</Product>
|
||||||
<Copyright>© 2026 ASTERION VR — All rights reserved</Copyright>
|
<Copyright>© 2026 ASTERION VR — All rights reserved</Copyright>
|
||||||
<RootNamespace>PSLauncher.App</RootNamespace>
|
<RootNamespace>PSLauncher.App</RootNamespace>
|
||||||
<Version>0.24.5</Version>
|
<Version>0.24.9</Version>
|
||||||
<AssemblyVersion>0.24.5.0</AssemblyVersion>
|
<AssemblyVersion>0.24.9.0</AssemblyVersion>
|
||||||
<FileVersion>0.24.5.0</FileVersion>
|
<FileVersion>0.24.9.0</FileVersion>
|
||||||
|
|
||||||
<!-- Single-file self-contained publish profile (used by `dotnet publish`) -->
|
<!-- Single-file self-contained publish profile (used by `dotnet publish`) -->
|
||||||
<PublishSingleFile>true</PublishSingleFile>
|
<PublishSingleFile>true</PublishSingleFile>
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ using PSLauncher.Core.Installations;
|
|||||||
using PSLauncher.Core.Licensing;
|
using PSLauncher.Core.Licensing;
|
||||||
using PSLauncher.Core.Localization;
|
using PSLauncher.Core.Localization;
|
||||||
using PSLauncher.Core.Manifests;
|
using PSLauncher.Core.Manifests;
|
||||||
|
using PSLauncher.Core.ApiTool;
|
||||||
using PSLauncher.Core.DocTool;
|
using PSLauncher.Core.DocTool;
|
||||||
using PSLauncher.Core.Health;
|
using PSLauncher.Core.Health;
|
||||||
using PSLauncher.Core.Migrations;
|
using PSLauncher.Core.Migrations;
|
||||||
@@ -49,6 +50,7 @@ public sealed partial class MainViewModel : ObservableObject
|
|||||||
private readonly IDatabaseMigrationService _migrationService;
|
private readonly IDatabaseMigrationService _migrationService;
|
||||||
private readonly IReportToolDeployer _reportDeployer;
|
private readonly IReportToolDeployer _reportDeployer;
|
||||||
private readonly IDocToolDeployer _docDeployer;
|
private readonly IDocToolDeployer _docDeployer;
|
||||||
|
private readonly IApiToolDeployer _apiDeployer;
|
||||||
private readonly ISystemHealthService _healthService;
|
private readonly ISystemHealthService _healthService;
|
||||||
private readonly IServiceProvider _serviceProvider;
|
private readonly IServiceProvider _serviceProvider;
|
||||||
private readonly ILogger<MainViewModel> _logger;
|
private readonly ILogger<MainViewModel> _logger;
|
||||||
@@ -112,6 +114,15 @@ public sealed partial class MainViewModel : ObservableObject
|
|||||||
[NotifyCanExecuteChangedFor(nameof(CheckForUpdatesCommand))]
|
[NotifyCanExecuteChangedFor(nameof(CheckForUpdatesCommand))]
|
||||||
private bool _isBusy;
|
private bool _isBusy;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Flag dédié au téléchargement en cours, distinct de <see cref="IsBusy"/> 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.
|
||||||
|
/// </summary>
|
||||||
|
[ObservableProperty]
|
||||||
|
private bool _isDownloadActive;
|
||||||
|
|
||||||
[ObservableProperty] private double _progressPercent;
|
[ObservableProperty] private double _progressPercent;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -311,6 +322,7 @@ public sealed partial class MainViewModel : ObservableObject
|
|||||||
IDatabaseMigrationService migrationService,
|
IDatabaseMigrationService migrationService,
|
||||||
IReportToolDeployer reportDeployer,
|
IReportToolDeployer reportDeployer,
|
||||||
IDocToolDeployer docDeployer,
|
IDocToolDeployer docDeployer,
|
||||||
|
IApiToolDeployer apiDeployer,
|
||||||
ISystemHealthService healthService,
|
ISystemHealthService healthService,
|
||||||
IServiceProvider serviceProvider,
|
IServiceProvider serviceProvider,
|
||||||
ILogger<MainViewModel> logger)
|
ILogger<MainViewModel> logger)
|
||||||
@@ -329,6 +341,7 @@ public sealed partial class MainViewModel : ObservableObject
|
|||||||
_migrationService = migrationService;
|
_migrationService = migrationService;
|
||||||
_reportDeployer = reportDeployer;
|
_reportDeployer = reportDeployer;
|
||||||
_docDeployer = docDeployer;
|
_docDeployer = docDeployer;
|
||||||
|
_apiDeployer = apiDeployer;
|
||||||
_healthService = healthService;
|
_healthService = healthService;
|
||||||
_serviceProvider = serviceProvider;
|
_serviceProvider = serviceProvider;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
@@ -516,13 +529,43 @@ public sealed partial class MainViewModel : ObservableObject
|
|||||||
private void WireRowHandlers(VersionRowViewModel row)
|
private void WireRowHandlers(VersionRowViewModel row)
|
||||||
{
|
{
|
||||||
row.LaunchHandler = LaunchVersion;
|
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.UninstallHandler = r => _ = UninstallVersionAsync(r);
|
||||||
row.ShowReleaseNotesHandler = r => _ = ShowReleaseNotesAsync(r);
|
row.ShowReleaseNotesHandler = r => _ = ShowReleaseNotesAsync(r);
|
||||||
row.OpenFolderHandler = OpenVersionFolder;
|
row.OpenFolderHandler = OpenVersionFolder;
|
||||||
row.RestartFromZeroHandler = r => _ = RestartFromZeroAsync(r);
|
row.RestartFromZeroHandler = r => _ = RestartFromZeroAsync(r);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Error boundary autour de <see cref="InstallVersionAsync"/>. 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).
|
||||||
|
/// </summary>
|
||||||
|
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))]
|
[RelayCommand(CanExecute = nameof(CanCheckUpdates))]
|
||||||
private async Task CheckForUpdatesAsync()
|
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; }
|
if (IsBusy) { ThemedMessageBox.Show(Strings.MsgBusy, Strings.MsgBoxPatience, MessageBoxButton.OK, MessageBoxImage.Information); return; }
|
||||||
|
|
||||||
IsBusy = true;
|
IsBusy = true;
|
||||||
|
IsDownloadActive = true;
|
||||||
_activeRow = row;
|
_activeRow = row;
|
||||||
_activeDownloadCts = new CancellationTokenSource();
|
_activeDownloadCts = new CancellationTokenSource();
|
||||||
var ct = _activeDownloadCts.Token;
|
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<DeployProgress>(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);
|
StatusMessage = Strings.StatusInstalledSuccess(row.Version);
|
||||||
ProgressDetail = null;
|
ProgressDetail = null;
|
||||||
ProgressPercent = 0;
|
ProgressPercent = 0;
|
||||||
@@ -1100,6 +1187,7 @@ public sealed partial class MainViewModel : ObservableObject
|
|||||||
_activeRow = null;
|
_activeRow = null;
|
||||||
_activeInstallTask = null;
|
_activeInstallTask = null;
|
||||||
IsBusy = false;
|
IsBusy = false;
|
||||||
|
IsDownloadActive = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ using CommunityToolkit.Mvvm.Input;
|
|||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using PSLauncher.App.Views;
|
using PSLauncher.App.Views;
|
||||||
using PSLauncher.Core.Configuration;
|
using PSLauncher.Core.Configuration;
|
||||||
|
using PSLauncher.Core.ApiTool;
|
||||||
using PSLauncher.Core.DocTool;
|
using PSLauncher.Core.DocTool;
|
||||||
using PSLauncher.Core.Downloads;
|
using PSLauncher.Core.Downloads;
|
||||||
using PSLauncher.Core.Installations;
|
using PSLauncher.Core.Installations;
|
||||||
@@ -35,6 +36,7 @@ public sealed partial class SettingsViewModel : ObservableObject
|
|||||||
private readonly IDatabaseMigrationService _migrationService;
|
private readonly IDatabaseMigrationService _migrationService;
|
||||||
private readonly IReportToolDeployer _reportDeployer;
|
private readonly IReportToolDeployer _reportDeployer;
|
||||||
private readonly IDocToolDeployer _docDeployer;
|
private readonly IDocToolDeployer _docDeployer;
|
||||||
|
private readonly IApiToolDeployer _apiDeployer;
|
||||||
private readonly IInstallationRegistry _registry;
|
private readonly IInstallationRegistry _registry;
|
||||||
private readonly HttpClient _http;
|
private readonly HttpClient _http;
|
||||||
private readonly ILogger<SettingsViewModel> _logger;
|
private readonly ILogger<SettingsViewModel> _logger;
|
||||||
@@ -85,6 +87,17 @@ public sealed partial class SettingsViewModel : ObservableObject
|
|||||||
= new();
|
= new();
|
||||||
public bool HasDocBackups => DocBackups.Count > 0;
|
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<ApiBackupViewModel> ApiBackups { get; }
|
||||||
|
= new();
|
||||||
|
public bool HasApiBackups => ApiBackups.Count > 0;
|
||||||
|
|
||||||
// ----- Health checks (bandeau de santé système) -----
|
// ----- Health checks (bandeau de santé système) -----
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Liste éditable des checks affichés dans le bandeau de santé. Chaque ligne
|
/// 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,
|
IDatabaseMigrationService migrationService,
|
||||||
IReportToolDeployer reportDeployer,
|
IReportToolDeployer reportDeployer,
|
||||||
IDocToolDeployer docDeployer,
|
IDocToolDeployer docDeployer,
|
||||||
|
IApiToolDeployer apiDeployer,
|
||||||
IInstallationRegistry registry,
|
IInstallationRegistry registry,
|
||||||
HttpClient http,
|
HttpClient http,
|
||||||
ILogger<SettingsViewModel> logger)
|
ILogger<SettingsViewModel> logger)
|
||||||
@@ -176,6 +190,7 @@ public sealed partial class SettingsViewModel : ObservableObject
|
|||||||
_migrationService = migrationService;
|
_migrationService = migrationService;
|
||||||
_reportDeployer = reportDeployer;
|
_reportDeployer = reportDeployer;
|
||||||
_docDeployer = docDeployer;
|
_docDeployer = docDeployer;
|
||||||
|
_apiDeployer = apiDeployer;
|
||||||
_registry = registry;
|
_registry = registry;
|
||||||
_http = http;
|
_http = http;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
@@ -204,6 +219,11 @@ public sealed partial class SettingsViewModel : ObservableObject
|
|||||||
_docAutoDeploy = config.DocTool.AutoDeploy;
|
_docAutoDeploy = config.DocTool.AutoDeploy;
|
||||||
_docMaxBackups = config.DocTool.MaxBackups;
|
_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
|
// 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.
|
// muter _config.HealthChecks.Checks tant que l'utilisateur n'a pas cliqué Save.
|
||||||
foreach (var entry in config.HealthChecks.Checks)
|
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)
|
// Charge les listes de backups en arrière-plan (UI immédiate puis remplies quand prêtes)
|
||||||
_ = LoadReportBackupsAsync();
|
_ = LoadReportBackupsAsync();
|
||||||
_ = LoadDocBackupsAsync();
|
_ = LoadDocBackupsAsync();
|
||||||
|
_ = LoadApiBackupsAsync();
|
||||||
|
|
||||||
LauncherVersion = Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? "0.0.0";
|
LauncherVersion = Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? "0.0.0";
|
||||||
MachineId = licenseService.GetMachineId();
|
MachineId = licenseService.GetMachineId();
|
||||||
@@ -343,6 +364,11 @@ public sealed partial class SettingsViewModel : ObservableObject
|
|||||||
_config.DocTool.AutoDeploy = DocAutoDeploy;
|
_config.DocTool.AutoDeploy = DocAutoDeploy;
|
||||||
_config.DocTool.MaxBackups = Math.Max(0, DocMaxBackups);
|
_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
|
// 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 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.
|
// (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;
|
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<DeployProgress>(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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -899,3 +1015,22 @@ public sealed partial class DocBackupViewModel : ObservableObject
|
|||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private Task Revert() => _revertHandler(Info);
|
private Task Revert() => _revertHandler(Info);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Variante api — même structure que <see cref="ReportBackupViewModel"/>.</summary>
|
||||||
|
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<BackupInfo, Task> _revertHandler;
|
||||||
|
public ApiBackupViewModel(BackupInfo info, Func<BackupInfo, Task> revertHandler)
|
||||||
|
{
|
||||||
|
Info = info;
|
||||||
|
_revertHandler = revertHandler;
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private Task Revert() => _revertHandler(Info);
|
||||||
|
}
|
||||||
|
|||||||
@@ -767,7 +767,7 @@
|
|||||||
Content="{x:Static loc:Strings.ActionCancel}"
|
Content="{x:Static loc:Strings.ActionCancel}"
|
||||||
VerticalAlignment="Center" Margin="12,0,0,0"
|
VerticalAlignment="Center" Margin="12,0,0,0"
|
||||||
Command="{Binding CancelDownloadCommand}"
|
Command="{Binding CancelDownloadCommand}"
|
||||||
Visibility="{Binding IsBusy, Converter={StaticResource BoolToVisibility}}" />
|
Visibility="{Binding IsDownloadActive, Converter={StaticResource BoolToVisibility}}" />
|
||||||
</Grid>
|
</Grid>
|
||||||
</Border>
|
</Border>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ using System.Windows;
|
|||||||
using System.Windows.Controls;
|
using System.Windows.Controls;
|
||||||
using System.Windows.Controls.Primitives;
|
using System.Windows.Controls.Primitives;
|
||||||
using System.Windows.Interop;
|
using System.Windows.Interop;
|
||||||
|
using Microsoft.Web.WebView2.Wpf;
|
||||||
using PSLauncher.App.ViewModels;
|
using PSLauncher.App.ViewModels;
|
||||||
using PSLauncher.Core.Localization;
|
using PSLauncher.Core.Localization;
|
||||||
|
|
||||||
@@ -90,6 +91,24 @@ public partial class MainWindow : Window
|
|||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
SourceInitialized += MainWindow_SourceInitialized;
|
SourceInitialized += MainWindow_SourceInitialized;
|
||||||
Closing += MainWindow_Closing;
|
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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -538,6 +538,116 @@
|
|||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
|
<!-- API PHP de stats (XAMPP htdocs deploy, mirror of Report/Doc) -->
|
||||||
|
<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.SettingsApiTool}"
|
||||||
|
FontSize="11" FontWeight="Bold"
|
||||||
|
Foreground="{StaticResource Brush.Text.Secondary}"
|
||||||
|
Margin="0,0,0,8" />
|
||||||
|
|
||||||
|
<TextBlock Text="{x:Static loc:Strings.SettingsReportHtdocs}"
|
||||||
|
Foreground="{StaticResource Brush.Text.Secondary}"
|
||||||
|
FontSize="12" Margin="0,0,0,4" />
|
||||||
|
<TextBox Text="{Binding ApiHtdocsRoot, UpdateSourceTrigger=PropertyChanged}"
|
||||||
|
Background="#1A1A20" Foreground="{StaticResource Brush.Text.Primary}"
|
||||||
|
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1" Padding="8"
|
||||||
|
FontFamily="Consolas" />
|
||||||
|
|
||||||
|
<TextBlock Text="{x:Static loc:Strings.SettingsReportFolder}"
|
||||||
|
Foreground="{StaticResource Brush.Text.Secondary}"
|
||||||
|
FontSize="12" Margin="0,12,0,4" />
|
||||||
|
<TextBox Text="{Binding ApiFolderName, UpdateSourceTrigger=PropertyChanged}"
|
||||||
|
Background="#1A1A20" Foreground="{StaticResource Brush.Text.Primary}"
|
||||||
|
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1" Padding="8"
|
||||||
|
FontFamily="Consolas" />
|
||||||
|
|
||||||
|
<CheckBox IsChecked="{Binding ApiAutoDeploy}"
|
||||||
|
Margin="0,12,0,0"
|
||||||
|
Foreground="{StaticResource Brush.Text.Primary}"
|
||||||
|
Content="{x:Static loc:Strings.SettingsApiAutoDeploy}" />
|
||||||
|
|
||||||
|
<Grid Margin="0,12,0,0">
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="*" />
|
||||||
|
<ColumnDefinition Width="80" />
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<TextBlock Grid.Column="0"
|
||||||
|
Text="{x:Static loc:Strings.SettingsApiMaxBackups}"
|
||||||
|
Foreground="{StaticResource Brush.Text.Secondary}"
|
||||||
|
FontSize="12" VerticalAlignment="Center" />
|
||||||
|
<TextBox Grid.Column="1"
|
||||||
|
Text="{Binding ApiMaxBackups, UpdateSourceTrigger=PropertyChanged}"
|
||||||
|
Background="#1A1A20" Foreground="{StaticResource Brush.Text.Primary}"
|
||||||
|
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1" Padding="8" />
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid Margin="0,12,0,0">
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="*" />
|
||||||
|
<ColumnDefinition Width="Auto" />
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<TextBlock Grid.Column="0"
|
||||||
|
Text="{Binding ApiDeployStatus}"
|
||||||
|
Foreground="{StaticResource Brush.Text.Secondary}"
|
||||||
|
FontSize="12" VerticalAlignment="Center"
|
||||||
|
TextWrapping="Wrap" />
|
||||||
|
<Button Grid.Column="1"
|
||||||
|
Style="{StaticResource SecondaryButton}"
|
||||||
|
Content="{x:Static loc:Strings.SettingsApiRedeploy}"
|
||||||
|
Command="{Binding RedeployApiCommand}"
|
||||||
|
IsEnabled="{Binding IsDeployingApi, Converter={StaticResource InverseBoolToBool}}" />
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<TextBlock Text="{x:Static loc:Strings.SettingsApiBackups}"
|
||||||
|
FontWeight="Bold" FontSize="11"
|
||||||
|
Foreground="{StaticResource Brush.Text.Secondary}"
|
||||||
|
Margin="0,16,0,6" />
|
||||||
|
<TextBlock Text="{x:Static loc:Strings.SettingsReportNoBackups}"
|
||||||
|
Foreground="{StaticResource Brush.Text.Secondary}"
|
||||||
|
FontSize="12" FontStyle="Italic"
|
||||||
|
Visibility="{Binding HasApiBackups, Converter={StaticResource InverseBoolToVisibility}}" />
|
||||||
|
<ItemsControl ItemsSource="{Binding ApiBackups}"
|
||||||
|
Visibility="{Binding HasApiBackups, Converter={StaticResource BoolToVisibility}}">
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate>
|
||||||
|
<Border Background="#1A1A20"
|
||||||
|
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1"
|
||||||
|
CornerRadius="4" Padding="10,8" Margin="0,0,0,4">
|
||||||
|
<Grid>
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="*" />
|
||||||
|
<ColumnDefinition Width="Auto" />
|
||||||
|
<ColumnDefinition Width="Auto" />
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<StackPanel Grid.Column="0" VerticalAlignment="Center">
|
||||||
|
<TextBlock Text="{Binding DateDisplay}"
|
||||||
|
Foreground="{StaticResource Brush.Text.Primary}"
|
||||||
|
FontSize="13" FontWeight="SemiBold" />
|
||||||
|
<TextBlock Text="{Binding SizeDisplay}"
|
||||||
|
Foreground="{StaticResource Brush.Text.Secondary}"
|
||||||
|
FontSize="11" />
|
||||||
|
</StackPanel>
|
||||||
|
<TextBlock Grid.Column="1"
|
||||||
|
Text="{Binding FolderName}"
|
||||||
|
FontFamily="Consolas" FontSize="10"
|
||||||
|
Foreground="{StaticResource Brush.Text.Secondary}"
|
||||||
|
VerticalAlignment="Center" Margin="0,0,12,0" />
|
||||||
|
<Button Grid.Column="2"
|
||||||
|
Style="{StaticResource SecondaryButton}"
|
||||||
|
Content="{x:Static loc:Strings.SettingsReportRevert}"
|
||||||
|
Command="{Binding RevertCommand}"
|
||||||
|
Padding="12,6" />
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
<!-- Outil Doc (XAMPP htdocs deploy, mirror of Report) -->
|
<!-- Outil Doc (XAMPP htdocs deploy, mirror of Report) -->
|
||||||
<Border Background="{StaticResource Brush.Bg.Card}"
|
<Border Background="{StaticResource Brush.Bg.Card}"
|
||||||
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1"
|
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1"
|
||||||
|
|||||||
211
src/PSLauncher.Core/ApiTool/ApiToolDeployer.cs
Normal file
211
src/PSLauncher.Core/ApiTool/ApiToolDeployer.cs
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using PSLauncher.Core.ReportTool;
|
||||||
|
using PSLauncher.Models;
|
||||||
|
|
||||||
|
namespace PSLauncher.Core.ApiTool;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Implémentation : copy récursif + atomic rename + backups horodatés.
|
||||||
|
/// Strictement parallèle à <see cref="ReportToolDeployer"/> mais avec
|
||||||
|
/// <c>_api/</c> comme source au lieu de <c>_report/</c>. Voir le commentaire
|
||||||
|
/// de cette dernière classe pour les détails du mécanisme.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ApiToolDeployer : IApiToolDeployer
|
||||||
|
{
|
||||||
|
private const string SourceSubdir = "_api";
|
||||||
|
private const string BackupSuffix = ".backup-";
|
||||||
|
private const string TimestampFormat = "yyyyMMdd-HHmmss";
|
||||||
|
|
||||||
|
private readonly Func<ApiToolConfig> _configProvider;
|
||||||
|
private readonly ILogger<ApiToolDeployer> _logger;
|
||||||
|
|
||||||
|
public ApiToolDeployer(
|
||||||
|
Func<ApiToolConfig> configProvider,
|
||||||
|
ILogger<ApiToolDeployer> logger)
|
||||||
|
{
|
||||||
|
_configProvider = configProvider;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<DeployResult> DeployAsync(
|
||||||
|
string installFolder,
|
||||||
|
IProgress<DeployProgress>? 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<IReadOnlyList<BackupInfo>> ListBackupsAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
var cfg = _configProvider();
|
||||||
|
var prefix = cfg.FolderName + BackupSuffix;
|
||||||
|
|
||||||
|
if (!Directory.Exists(cfg.HtdocsRoot))
|
||||||
|
return Task.FromResult<IReadOnlyList<BackupInfo>>(Array.Empty<BackupInfo>());
|
||||||
|
|
||||||
|
var list = new List<BackupInfo>();
|
||||||
|
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<IReadOnlyList<BackupInfo>>(list);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<DeployResult> 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
25
src/PSLauncher.Core/ApiTool/IApiToolDeployer.cs
Normal file
25
src/PSLauncher.Core/ApiTool/IApiToolDeployer.cs
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
using PSLauncher.Core.ReportTool;
|
||||||
|
|
||||||
|
namespace PSLauncher.Core.ApiTool;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Déploie l'API PHP de stats PROSERVE depuis le sous-dossier <c>_api/</c> bundled
|
||||||
|
/// dans chaque ZIP PROSERVE vers le htdocs local. Mêmes garanties que
|
||||||
|
/// <see cref="IReportToolDeployer"/> : copy + atomic rename + backups horodatés
|
||||||
|
/// + revert manuel.
|
||||||
|
///
|
||||||
|
/// On réutilise les types <see cref="DeployResult"/>, <see cref="DeployStatus"/>,
|
||||||
|
/// <see cref="DeployProgress"/> et <see cref="BackupInfo"/> du namespace ReportTool
|
||||||
|
/// pour ne pas dupliquer la modélisation — le mécanisme est strictement le même.
|
||||||
|
/// </summary>
|
||||||
|
public interface IApiToolDeployer
|
||||||
|
{
|
||||||
|
Task<DeployResult> DeployAsync(
|
||||||
|
string installFolder,
|
||||||
|
IProgress<DeployProgress>? progress,
|
||||||
|
CancellationToken ct);
|
||||||
|
|
||||||
|
Task<IReadOnlyList<BackupInfo>> ListBackupsAsync(CancellationToken ct);
|
||||||
|
|
||||||
|
Task<DeployResult> RevertAsync(string backupFolderName, CancellationToken ct);
|
||||||
|
}
|
||||||
@@ -62,7 +62,11 @@ public sealed class DownloadManager : IDownloadManager
|
|||||||
_retryPipeline = new ResiliencePipelineBuilder()
|
_retryPipeline = new ResiliencePipelineBuilder()
|
||||||
.AddRetry(new RetryStrategyOptions
|
.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,
|
BackoffType = DelayBackoffType.Exponential,
|
||||||
Delay = TimeSpan.FromSeconds(1),
|
Delay = TimeSpan.FromSeconds(1),
|
||||||
MaxDelay = TimeSpan.FromSeconds(32),
|
MaxDelay = TimeSpan.FromSeconds(32),
|
||||||
@@ -369,7 +373,22 @@ public sealed class DownloadManager : IDownloadManager
|
|||||||
_stateStore.Save(state);
|
_stateStore.Save(state);
|
||||||
progress?.Report(new DownloadProgress(state.DownloadedBytes, total, 0, TimeSpan.Zero));
|
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;
|
var actualSize = new FileInfo(partialPath).Length;
|
||||||
if (actualSize != total)
|
if (actualSize != total)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -567,6 +567,42 @@ public static class Strings
|
|||||||
public static string SettingsDocBackups => SettingsReportBackups;
|
public static string SettingsDocBackups => SettingsReportBackups;
|
||||||
public static string SettingsDocMaxBackups => SettingsReportMaxBackups;
|
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 ----
|
// ---- Backups Report tool ----
|
||||||
public static string SettingsReportBackups => T("Backups disponibles", "Available backups", "可用备份", "สำรองข้อมูลที่มี", "النسخ الاحتياطية المتاحة");
|
public static string SettingsReportBackups => T("Backups disponibles", "Available backups", "可用备份", "สำรองข้อมูลที่มี", "النسخ الاحتياطية المتاحة");
|
||||||
public static string SettingsReportMaxBackups => T("Conserver N backups", "Keep N backups", "保留 N 个备份", "เก็บสำรอง N ชุด", "الاحتفاظ بـ N نسخة احتياطية");
|
public static string SettingsReportMaxBackups => T("Conserver N backups", "Keep N backups", "保留 N 个备份", "เก็บสำรอง N ชุด", "الاحتفاظ بـ N نسخة احتياطية");
|
||||||
|
|||||||
@@ -64,6 +64,13 @@ public sealed class LocalConfig
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public DocToolConfig DocTool { get; set; } = new();
|
public DocToolConfig DocTool { get; set; } = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Configuration du déploiement de l'API PHP de stats PROSERVE. Même mécanisme
|
||||||
|
/// que ReportTool / DocTool : copie de <c>_api/</c> bundled dans le ZIP PROSERVE
|
||||||
|
/// vers <c>{HtdocsRoot}\{FolderName}</c> avec backups horodatés et revert.
|
||||||
|
/// </summary>
|
||||||
|
public ApiToolConfig ApiTool { get; set; } = new();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Surveillance d'état système affichée en bandeau sous la top bar
|
/// Surveillance d'état système affichée en bandeau sous la top bar
|
||||||
/// (pings d'IP, processus requis comme SteamVR / Vive Business Streaming…).
|
/// (pings d'IP, processus requis comme SteamVR / Vive Business Streaming…).
|
||||||
@@ -150,6 +157,20 @@ public sealed class DocToolConfig
|
|||||||
public int MaxBackups { get; set; } = 3;
|
public int MaxBackups { get; set; } = 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Mêmes paramètres que <see cref="ReportToolConfig"/> mais pour l'API PHP de
|
||||||
|
/// stats (sous-dossier <c>_api/</c> du ZIP, déployé vers
|
||||||
|
/// <c>{HtdocsRoot}\proserve</c> par défaut — c'est l'emplacement standard où
|
||||||
|
/// PROSERVE s'attend à trouver son API côté client, en minuscule).
|
||||||
|
/// </summary>
|
||||||
|
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
|
public sealed class HealthChecksConfig
|
||||||
{
|
{
|
||||||
/// <summary>Période de re-vérification, en secondes. 0 = désactivé.</summary>
|
/// <summary>Période de re-vérification, en secondes. 0 = désactivé.</summary>
|
||||||
|
|||||||
Reference in New Issue
Block a user