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:
@@ -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<MainViewModel> _logger;
|
||||
@@ -112,6 +114,15 @@ public sealed partial class MainViewModel : ObservableObject
|
||||
[NotifyCanExecuteChangedFor(nameof(CheckForUpdatesCommand))]
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
@@ -311,6 +322,7 @@ public sealed partial class MainViewModel : ObservableObject
|
||||
IDatabaseMigrationService migrationService,
|
||||
IReportToolDeployer reportDeployer,
|
||||
IDocToolDeployer docDeployer,
|
||||
IApiToolDeployer apiDeployer,
|
||||
ISystemHealthService healthService,
|
||||
IServiceProvider serviceProvider,
|
||||
ILogger<MainViewModel> 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);
|
||||
}
|
||||
|
||||
/// <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))]
|
||||
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<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);
|
||||
ProgressDetail = null;
|
||||
ProgressPercent = 0;
|
||||
@@ -1100,6 +1187,7 @@ public sealed partial class MainViewModel : ObservableObject
|
||||
_activeRow = null;
|
||||
_activeInstallTask = null;
|
||||
IsBusy = false;
|
||||
IsDownloadActive = false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<SettingsViewModel> _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<ApiBackupViewModel> ApiBackups { get; }
|
||||
= new();
|
||||
public bool HasApiBackups => ApiBackups.Count > 0;
|
||||
|
||||
// ----- Health checks (bandeau de santé système) -----
|
||||
/// <summary>
|
||||
/// 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<SettingsViewModel> 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<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>
|
||||
@@ -899,3 +1015,22 @@ public sealed partial class DocBackupViewModel : ObservableObject
|
||||
[RelayCommand]
|
||||
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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user