Nouvelle option dans le popup « Mise à jour disponible » (case cochée par défaut, décochable) : « Conserver les sauvegardes et replays de la version précédente ». Après extraction du ZIP de la nouvelle version, le launcher copie : • PROSERVE_UE_*/Saved/SaveGames/*.sav (profils + progression Unreal) • PROSERVE_UE_*/Saved/Demos/*.replay (replays de session pour debrief) Source = version installée au plus haut SemVer autre que celle qu'on vient d'installer (couvre upgrade ET ré-install). Le dossier projet UE est déduit du nom de l'exe (PROSERVE_UE_5_7.exe → PROSERVE_UE_5_7/) — supporte le passage UE_5_5 → UE_5_7 (renommage transparent : on copie du dossier source vers le dossier cible, peu importe leur numéro UE). Sémantique non-destructive : si la nouvelle install contient déjà un fichier au même chemin (profil/replay bundlé dans le ZIP), il n'est PAS écrasé. La version la plus à jour côté installer prime pour ce slot précis ; les autres fichiers créés par l'opérateur en cours d'utilisation sont copiés normalement. Liste des sous-dossiers + globs en table statique (PreservedSavedSubdirs) pour faciliter l'ajout futur (Logs/Config user…). Best-effort : exceptions IO loggées en warn mais l'install n'échoue pas pour une copie qui foire (handle verrouillé, accès refusé). Si l'option est décochée OU si aucune version précédente n'est installée, no-op silencieux. Côté UI : checkbox au-dessus des boutons Plus tard / Télécharger, avec tooltip détaillant les deux chemins. État remonté via dialog.PreserveSaveGames et lu par MainViewModel APRÈS l'écriture du .proserve-meta.json (donc avant les redists & SteamVR merge). Pour les resumes de DL interrompus, le défaut est TRUE (l'utilisateur a déjà confirmé la première fois). i18n complète : FR/EN/CN/TH/AR/ES/DE pour le libellé de la case, le tooltip et le StatusMessage « Copie des sauvegardes et replays depuis vX.Y.Z (N fichiers)… ». Bump : 1.0.1 → 1.0.2 (feature patch). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2614 lines
124 KiB
C#
2614 lines
124 KiB
C#
using System.Collections.ObjectModel;
|
||
using System.Diagnostics;
|
||
using System.IO;
|
||
using System.Net.NetworkInformation;
|
||
using System.Net.Sockets;
|
||
using System.Reflection;
|
||
using System.Windows;
|
||
using CommunityToolkit.Mvvm.ComponentModel;
|
||
using CommunityToolkit.Mvvm.Input;
|
||
using Microsoft.Extensions.Logging;
|
||
using PSLauncher.App.Views;
|
||
using Microsoft.Extensions.DependencyInjection;
|
||
using PSLauncher.App.Services;
|
||
using PSLauncher.Core.Configuration;
|
||
using PSLauncher.Core.Downloads;
|
||
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.Lan;
|
||
using PSLauncher.Core.Health;
|
||
using PSLauncher.Core.Migrations;
|
||
using PSLauncher.Core.Process;
|
||
using PSLauncher.Core.ReportTool;
|
||
using PSLauncher.Core.SteamVr;
|
||
using PSLauncher.Core.Updates;
|
||
using PSLauncher.Models;
|
||
|
||
namespace PSLauncher.App.ViewModels;
|
||
|
||
/// <summary>
|
||
/// Onglets affichés dans la sidebar droite du launcher. Chacun correspond à un
|
||
/// bloc de contenu visible/caché dans la fenêtre principale.
|
||
/// </summary>
|
||
public enum LauncherPage { Library, Report, Documentation }
|
||
|
||
public sealed partial class MainViewModel : ObservableObject
|
||
{
|
||
private readonly IInstallationRegistry _registry;
|
||
private readonly IProcessLauncher _processLauncher;
|
||
private readonly IConfigStore _configStore;
|
||
private readonly LocalConfig _config;
|
||
private readonly IManifestService _manifestService;
|
||
private readonly IUpdateChecker _updateChecker;
|
||
private readonly IDownloadManager _downloadManager;
|
||
private readonly IZipInstaller _zipInstaller;
|
||
private readonly ILicenseService _licenseService;
|
||
private readonly ILauncherSelfUpdater _selfUpdater;
|
||
private readonly IToastService _toastService;
|
||
private readonly IDatabaseMigrationService _migrationService;
|
||
private readonly IReportToolDeployer _reportDeployer;
|
||
private readonly IDocToolDeployer _docDeployer;
|
||
private readonly IApiToolDeployer _apiDeployer;
|
||
private readonly ISteamVrSettingsDeployer _steamVrDeployer;
|
||
private readonly IPeerSourceResolver _peerResolver;
|
||
private readonly IZipCacheStore _zipCacheStore;
|
||
private readonly ILanCacheServer _lanCacheServer;
|
||
private readonly ILanDiscoveryService _lanDiscovery;
|
||
private readonly ISystemHealthService _healthService;
|
||
private readonly IServiceProvider _serviceProvider;
|
||
private readonly ILogger<MainViewModel> _logger;
|
||
|
||
private LicenseValidationResponse? _license;
|
||
|
||
/// <summary>
|
||
/// Host du peer LAN utilisé pour le DL en cours. Null = DL depuis OVH.
|
||
/// Sert à différencier visuellement la source dans le footer (📡 vs ⬇)
|
||
/// pendant TOUT le DL, pas juste 1 s avant que le progress prenne le relais.
|
||
/// </summary>
|
||
private string? _currentPeerHost;
|
||
|
||
private RemoteManifest? _lastManifest;
|
||
private CancellationTokenSource? _activeDownloadCts;
|
||
private VersionRowViewModel? _activeRow;
|
||
/// <summary>
|
||
/// Référence au Task de l'install/DL en cours, exposée aux commandes externes
|
||
/// (ex. RestartFromZero) qui ont besoin d'attendre la fin réelle des écritures
|
||
/// disque avant de supprimer le partial. Sinon les segments continuent à écrire
|
||
/// pendant 1-2 sec après le Cancel() et recréent le fichier qu'on vient de delete.
|
||
/// </summary>
|
||
private Task? _activeInstallTask;
|
||
|
||
/// <summary>
|
||
/// Référence au process PROSERVE lancé par le launcher (la PLUS RÉCENTE).
|
||
/// Sert de garde-fou contre les double-lancements : avant chaque
|
||
/// <see cref="LaunchVersion"/>, on vérifie que ce champ est null ou que le
|
||
/// process a terminé. Le hook Exited remet le champ à null pour autoriser
|
||
/// le prochain lancement (manuel ou auto). Couvre uniquement les instances
|
||
/// LANCÉES PAR NOUS — pour détecter une instance lancée hors launcher, on
|
||
/// délègue à <see cref="IProcessLauncher.IsRunning(InstalledVersion)"/>.
|
||
/// </summary>
|
||
private System.Diagnostics.Process? _runningProserve;
|
||
|
||
/// <summary>
|
||
/// True quand un install vient d'auto-retry suite à un 404 + manifest refresh.
|
||
/// Évite la boucle infinie : si l'install retry échoue ENCORE avec 404,
|
||
/// on bascule sur le message "manifest serveur aussi obsolète" au lieu de
|
||
/// retry indéfiniment. Reseté à false en début d'install et après le retry.
|
||
/// </summary>
|
||
private bool _install404RetryConsumed;
|
||
|
||
public ObservableCollection<VersionRowViewModel> OtherVersions { get; } = new();
|
||
|
||
/// <summary>
|
||
/// Indicateurs affichés dans le bandeau de santé sous la top bar : pings,
|
||
/// processus requis (SteamVR, Vive Streaming, etc.). Liste construite depuis
|
||
/// <see cref="HealthChecksConfig.Checks"/> au démarrage. Refresh périodique
|
||
/// piloté par <see cref="StartHealthLoop"/>.
|
||
/// </summary>
|
||
public ObservableCollection<HealthIndicatorViewModel> HealthIndicators { get; } = new();
|
||
public bool HasHealthIndicators => HealthIndicators.Count > 0;
|
||
|
||
/// <summary>
|
||
/// Cancelle les boucles de polling per-check actuellement en vol. Recréée à
|
||
/// chaque appel à <see cref="StartHealthLoop"/> pour permettre un redémarrage
|
||
/// propre après édition des checks dans Settings (hot-reload sans relancer
|
||
/// l'app).
|
||
/// </summary>
|
||
private CancellationTokenSource? _healthLoopCts;
|
||
|
||
/// <summary>
|
||
/// Le bandeau santé n'a de sens que sur la page Library — Reports et
|
||
/// Documentation sont des WebView pleine-page où afficher des pills
|
||
/// de monitoring système n'apporte rien et coupe visuellement.
|
||
/// </summary>
|
||
public bool ShowHealthBanner => HasHealthIndicators && IsLibrary;
|
||
|
||
[ObservableProperty]
|
||
[NotifyPropertyChangedFor(nameof(HasFeaturedVersion))]
|
||
[NotifyPropertyChangedFor(nameof(HasOtherVersions))]
|
||
[NotifyPropertyChangedFor(nameof(EmptyHintVisibility))]
|
||
private VersionRowViewModel? _featuredVersion;
|
||
|
||
public bool HasFeaturedVersion => FeaturedVersion is not null;
|
||
public bool HasOtherVersions => OtherVersions.Count > 0;
|
||
|
||
[ObservableProperty]
|
||
[NotifyPropertyChangedFor(nameof(FooterText))]
|
||
[NotifyPropertyChangedFor(nameof(FooterVisibility))]
|
||
private string? _statusMessage;
|
||
|
||
[ObservableProperty]
|
||
[NotifyPropertyChangedFor(nameof(FooterText))]
|
||
[NotifyPropertyChangedFor(nameof(FooterVisibility))]
|
||
[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>
|
||
/// Onglet actuellement actif dans la sidebar gauche. La UI montre le bon bloc
|
||
/// via les converters IsLibrary / IsReport / IsDocumentation. Default = Library
|
||
/// pour que le launcher s'ouvre directement sur l'écran principal.
|
||
/// On notifie aussi FooterVisibility/FooterText : le footer (barre de DL,
|
||
/// status d'install) appartient à la page Library uniquement — il n'a aucun
|
||
/// sens visuel sur Report/Documentation qui sont des WebView pleine page.
|
||
/// </summary>
|
||
[ObservableProperty]
|
||
[NotifyPropertyChangedFor(nameof(IsLibrary))]
|
||
[NotifyPropertyChangedFor(nameof(IsReport))]
|
||
[NotifyPropertyChangedFor(nameof(IsDocumentation))]
|
||
[NotifyPropertyChangedFor(nameof(FooterVisibility))]
|
||
[NotifyPropertyChangedFor(nameof(FooterText))]
|
||
[NotifyPropertyChangedFor(nameof(ShowHealthBanner))]
|
||
private LauncherPage _currentPage = LauncherPage.Library;
|
||
|
||
public bool IsLibrary => CurrentPage == LauncherPage.Library;
|
||
public bool IsReport => CurrentPage == LauncherPage.Report;
|
||
public bool IsDocumentation => CurrentPage == LauncherPage.Documentation;
|
||
|
||
/// <summary>URL résolue pour le panneau Reports. WebView2.Source attend un Uri.</summary>
|
||
public Uri? ReportUri => TryParseUri(_config.ReportUrl);
|
||
/// <summary>URL résolue pour le panneau Documentation. Null si non configurée.</summary>
|
||
public Uri? DocumentationUri => TryParseUri(_config.DocumentationUrl);
|
||
public bool HasDocumentationUrl => DocumentationUri is not null;
|
||
|
||
private static Uri? TryParseUri(string? s)
|
||
=> !string.IsNullOrWhiteSpace(s) && Uri.TryCreate(s, UriKind.Absolute, out var u) ? u : null;
|
||
|
||
[RelayCommand] private void NavigateLibrary() => CurrentPage = LauncherPage.Library;
|
||
[RelayCommand] private void NavigateReport() => CurrentPage = LauncherPage.Report;
|
||
[RelayCommand] private void NavigateDocumentation() => CurrentPage = LauncherPage.Documentation;
|
||
|
||
[ObservableProperty]
|
||
[NotifyPropertyChangedFor(nameof(FooterText))]
|
||
[NotifyPropertyChangedFor(nameof(FooterVisibility))]
|
||
private string? _progressDetail;
|
||
|
||
/// <summary>
|
||
/// True quand un téléchargement est actif (à utiliser pour le prompt « quitter ?» de
|
||
/// la fenêtre principale). Distinct de <see cref="IsBusy"/> qui couvre aussi
|
||
/// l'extraction et la vérification.
|
||
/// </summary>
|
||
public bool HasActiveDownload => _activeDownloadCts is not null
|
||
&& !_activeDownloadCts.IsCancellationRequested
|
||
&& _activeRow is not null
|
||
&& _activeRow.State == VersionRowState.Downloading;
|
||
|
||
/// <summary>Version actuellement en cours de DL, ou null.</summary>
|
||
public string? ActiveDownloadVersion => _activeRow?.Version;
|
||
|
||
public string LicenseSummary
|
||
{
|
||
get
|
||
{
|
||
if (_license is null) return Strings.LicenseSummaryNone;
|
||
if (_license.Status == "valid")
|
||
{
|
||
return Strings.LicenseSummaryValid(_license.OwnerName ?? "—",
|
||
Strings.FormatDate(_license.DownloadEntitlementUntil));
|
||
}
|
||
if (_license.Status == "expired")
|
||
return Strings.LicenseSummaryExpired(Strings.FormatDate(_license.DownloadEntitlementUntil));
|
||
if (_license.Status == "revoked")
|
||
return Strings.LicenseSummaryRevoked;
|
||
return _license.Status;
|
||
}
|
||
}
|
||
|
||
public string LicenseIcon
|
||
{
|
||
get
|
||
{
|
||
if (_license is null) return "🔒";
|
||
return _license.Status switch
|
||
{
|
||
"valid" when IsLicenseExpiringSoon() => "⏰",
|
||
"valid" => "✓",
|
||
"expired" => "⚠",
|
||
"revoked" => "🚫",
|
||
_ => "❓",
|
||
};
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Catégorie utilisée par la UI pour piloter la couleur du badge license.
|
||
/// </summary>
|
||
public string LicenseSeverity
|
||
{
|
||
get
|
||
{
|
||
if (_license is null) return "Error";
|
||
return _license.Status switch
|
||
{
|
||
"valid" when IsLicenseExpiringSoon() => "Warning",
|
||
"valid" => "Ok",
|
||
_ => "Error",
|
||
};
|
||
}
|
||
}
|
||
|
||
private bool IsLicenseExpiringSoon()
|
||
{
|
||
if (_license?.DownloadEntitlementUntil is not { } until) return false;
|
||
return (until - DateTime.UtcNow).TotalDays < 30;
|
||
}
|
||
|
||
private void NotifyLicenseChanged()
|
||
{
|
||
OnPropertyChanged(nameof(LicenseSummary));
|
||
OnPropertyChanged(nameof(LicenseIcon));
|
||
OnPropertyChanged(nameof(LicenseSeverity));
|
||
}
|
||
|
||
public string EmptyHint => Strings.EmptyHint(_config.InstallRoot);
|
||
|
||
public Visibility EmptyHintVisibility =>
|
||
FeaturedVersion is null && OtherVersions.Count == 0
|
||
? Visibility.Visible : Visibility.Collapsed;
|
||
|
||
/// <summary>
|
||
/// Footer visible UNIQUEMENT sur la page Library : c'est elle qui héberge
|
||
/// les téléchargements et installs, donc le seul contexte où la barre
|
||
/// de progression a du sens. Sur Report/Documentation (WebView pleine page),
|
||
/// le footer disparaît même si IsBusy ou StatusMessage non vide — l'opération
|
||
/// continue en arrière-plan, le user retrouve son état en revenant sur Library.
|
||
/// </summary>
|
||
public Visibility FooterVisibility =>
|
||
IsLibrary && (IsBusy || !string.IsNullOrEmpty(StatusMessage))
|
||
? Visibility.Visible : Visibility.Collapsed;
|
||
|
||
public string FooterText =>
|
||
!string.IsNullOrEmpty(ProgressDetail) ? ProgressDetail :
|
||
StatusMessage ?? string.Empty;
|
||
|
||
/// <summary>
|
||
/// Version courte du launcher au format "v0.24.0", affichée dans la sidebar.
|
||
/// Lit l'AssemblyVersion (Major.Minor.Build) — le 4e champ (Revision) toujours .0
|
||
/// est tronqué pour rester lisible.
|
||
/// </summary>
|
||
public string AppVersion
|
||
{
|
||
get
|
||
{
|
||
var v = Assembly.GetExecutingAssembly().GetName().Version;
|
||
return v is null ? "v?" : $"v{v.Major}.{v.Minor}.{v.Build}";
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// IPv4 locale du PC affichée dans la sidebar pour le diagnostic terrain
|
||
/// (le support peut demander "quelle IP a ce poste ?" sans naviguer dans
|
||
/// les paramètres Windows). On filtre sur l'interface qui a une default
|
||
/// gateway IPv4 — élimine loopback, Hyper-V virtual switch, VPN inactifs,
|
||
/// et autres interfaces sans route. "—" si aucune connectivité réseau.
|
||
/// </summary>
|
||
public string LocalIpAddress
|
||
{
|
||
get
|
||
{
|
||
try
|
||
{
|
||
var ip = NetworkInterface.GetAllNetworkInterfaces()
|
||
.Where(n => n.OperationalStatus == OperationalStatus.Up
|
||
&& n.NetworkInterfaceType != NetworkInterfaceType.Loopback
|
||
&& n.GetIPProperties().GatewayAddresses
|
||
.Any(g => g.Address.AddressFamily == AddressFamily.InterNetwork))
|
||
.SelectMany(n => n.GetIPProperties().UnicastAddresses)
|
||
.Where(a => a.Address.AddressFamily == AddressFamily.InterNetwork)
|
||
.Select(a => a.Address.ToString())
|
||
.FirstOrDefault();
|
||
return ip ?? "—";
|
||
}
|
||
catch
|
||
{
|
||
return "—";
|
||
}
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Statut du serveur LAN affiché dans la sidebar info :
|
||
/// "ON" si activé+actif, "KO" si activé mais le bind a échoué (port pris,
|
||
/// pas d'IP LAN…), "—" si désactivé. Refresh toutes les 10 s par timer.
|
||
/// </summary>
|
||
public string LanServerStatus
|
||
{
|
||
get
|
||
{
|
||
if (!_config.LanCache.ServerEnabled) return "—";
|
||
return _lanCacheServer.IsRunning ? "ON" : "KO";
|
||
}
|
||
}
|
||
|
||
/// <summary>"ON" / "—" selon le flag client. Pas de notion d'erreur côté client (probe à la demande).</summary>
|
||
public string LanClientStatus => _config.LanCache.ClientEnabled ? "ON" : "—";
|
||
|
||
/// <summary>Nombre de peers découverts auto via UDP (TTL 60 s côté LanDiscoveryService).</summary>
|
||
public int LanPeerCount => _lanDiscovery.ListDiscovered().Count;
|
||
|
||
private System.Windows.Threading.DispatcherTimer? _lanInfoRefreshTimer;
|
||
|
||
public MainViewModel(
|
||
IInstallationRegistry registry,
|
||
IProcessLauncher processLauncher,
|
||
IConfigStore configStore,
|
||
LocalConfig config,
|
||
IManifestService manifestService,
|
||
IUpdateChecker updateChecker,
|
||
IDownloadManager downloadManager,
|
||
IZipInstaller zipInstaller,
|
||
ILicenseService licenseService,
|
||
ILauncherSelfUpdater selfUpdater,
|
||
IToastService toastService,
|
||
IDatabaseMigrationService migrationService,
|
||
IReportToolDeployer reportDeployer,
|
||
IDocToolDeployer docDeployer,
|
||
IApiToolDeployer apiDeployer,
|
||
ISteamVrSettingsDeployer steamVrDeployer,
|
||
IPeerSourceResolver peerResolver,
|
||
IZipCacheStore zipCacheStore,
|
||
ILanCacheServer lanCacheServer,
|
||
ILanDiscoveryService lanDiscovery,
|
||
ISystemHealthService healthService,
|
||
IServiceProvider serviceProvider,
|
||
ILogger<MainViewModel> logger)
|
||
{
|
||
_registry = registry;
|
||
_processLauncher = processLauncher;
|
||
_configStore = configStore;
|
||
_config = config;
|
||
_manifestService = manifestService;
|
||
_updateChecker = updateChecker;
|
||
_downloadManager = downloadManager;
|
||
_zipInstaller = zipInstaller;
|
||
_licenseService = licenseService;
|
||
_selfUpdater = selfUpdater;
|
||
_toastService = toastService;
|
||
_migrationService = migrationService;
|
||
_reportDeployer = reportDeployer;
|
||
_docDeployer = docDeployer;
|
||
_apiDeployer = apiDeployer;
|
||
_steamVrDeployer = steamVrDeployer;
|
||
_peerResolver = peerResolver;
|
||
_zipCacheStore = zipCacheStore;
|
||
_lanCacheServer = lanCacheServer;
|
||
_lanDiscovery = lanDiscovery;
|
||
_healthService = healthService;
|
||
_serviceProvider = serviceProvider;
|
||
_logger = logger;
|
||
|
||
// Charge la license depuis le cache (pas d'appel réseau au démarrage,
|
||
// ça reste rapide ; un refresh proactif arrive dès qu'on clique « Vérifier les MAJ »)
|
||
_license = _licenseService.GetCached();
|
||
|
||
RebuildList();
|
||
InitHealthIndicators();
|
||
|
||
// Vérification automatique des MAJ au démarrage (silencieuse et non bloquante)
|
||
_ = Task.Run(async () =>
|
||
{
|
||
try
|
||
{
|
||
await Task.Delay(500); // laisse l'UI se stabiliser
|
||
await Application.Current.Dispatcher.InvokeAsync(async () =>
|
||
{
|
||
await CheckForUpdatesAsync();
|
||
});
|
||
}
|
||
catch (Exception ex) { _logger.LogWarning(ex, "Auto check at startup failed"); }
|
||
});
|
||
|
||
// Boucle périodique des health checks. Ne tourne que si la config en
|
||
// contient au moins un. Premier passage immédiat puis re-check tous les
|
||
// RefreshIntervalSeconds.
|
||
StartHealthLoop();
|
||
|
||
// Rafraîchit le bloc info LAN de la sidebar toutes les 10 s. Le serveur
|
||
// peut transitionner ON↔KO sans interaction user (port pris…), et le
|
||
// peer count change au rythme des beacons UDP (15 s × TTL 60 s).
|
||
// DispatcherTimer.Tick s'exécute sur le UI thread → OnPropertyChanged
|
||
// peut updater les bindings WPF directement.
|
||
_lanInfoRefreshTimer = new System.Windows.Threading.DispatcherTimer(
|
||
TimeSpan.FromSeconds(10),
|
||
System.Windows.Threading.DispatcherPriority.Background,
|
||
(_, _) =>
|
||
{
|
||
OnPropertyChanged(nameof(LanServerStatus));
|
||
OnPropertyChanged(nameof(LanClientStatus));
|
||
OnPropertyChanged(nameof(LanPeerCount));
|
||
},
|
||
Application.Current.Dispatcher);
|
||
_lanInfoRefreshTimer.Start();
|
||
|
||
// Mode auto : si une version est désignée + feature activée, on la lance
|
||
// automatiquement après que la UI soit prête (Dispatcher.BeginInvoke différé
|
||
// pour que MainWindow ait fini son OnLoaded). Le grace period via popup modal
|
||
// sera affiché par-dessus la fenêtre normalement.
|
||
Application.Current.Dispatcher.BeginInvoke(
|
||
new Action(TryAutoLaunchAtStartup),
|
||
System.Windows.Threading.DispatcherPriority.Background);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Construit la collection observable des indicateurs depuis la config et
|
||
/// notifie HasHealthIndicators pour que la UI cache le bandeau si la liste
|
||
/// est vide. Idempotent — appelée au startup uniquement, pas à chaque refresh.
|
||
/// </summary>
|
||
private void InitHealthIndicators()
|
||
{
|
||
HealthIndicators.Clear();
|
||
foreach (var entry in _config.HealthChecks.Checks)
|
||
HealthIndicators.Add(new HealthIndicatorViewModel(entry));
|
||
OnPropertyChanged(nameof(HasHealthIndicators));
|
||
OnPropertyChanged(nameof(ShowHealthBanner));
|
||
}
|
||
|
||
/// <summary>
|
||
/// Lance une boucle indépendante par check, chacune cadencée sur son propre
|
||
/// <see cref="HealthCheckEntry.RefreshIntervalMs"/>. Un Process check rapide
|
||
/// (~1 ms) peut tourner toutes les 1-2 s sans impact CPU, alors qu'un Ping
|
||
/// (qui coûte un round-trip réseau) tourne typiquement à 5-10 s. Premier
|
||
/// passage immédiat puis re-check selon l'intervalle.
|
||
///
|
||
/// Cancelle la boucle précédente si elle existe (cas du hot-reload depuis
|
||
/// Settings → édition des checks).
|
||
/// </summary>
|
||
private void StartHealthLoop()
|
||
{
|
||
// Stop l'ancienne boucle si on est en hot-reload (édition Settings)
|
||
try { _healthLoopCts?.Cancel(); } catch { /* déjà disposée */ }
|
||
_healthLoopCts?.Dispose();
|
||
_healthLoopCts = null;
|
||
|
||
if (HealthIndicators.Count == 0) return;
|
||
|
||
_healthLoopCts = new CancellationTokenSource();
|
||
var ct = _healthLoopCts.Token;
|
||
|
||
foreach (var vm in HealthIndicators.ToList())
|
||
{
|
||
var localVm = vm;
|
||
// Floor de 500 ms pour éviter qu'un user mette 0/1 ms par erreur dans le json
|
||
// et sature un coeur CPU à pleins gaz.
|
||
var intervalMs = Math.Max(500, localVm.Entry.RefreshIntervalMs);
|
||
_ = Task.Run(async () =>
|
||
{
|
||
while (!ct.IsCancellationRequested)
|
||
{
|
||
try
|
||
{
|
||
var result = await _healthService.RunCheckAsync(localVm.Entry, ct).ConfigureAwait(false);
|
||
if (ct.IsCancellationRequested) break;
|
||
await Application.Current.Dispatcher.InvokeAsync(() => localVm.Apply(result));
|
||
}
|
||
catch (OperationCanceledException) { break; }
|
||
catch (Exception ex)
|
||
{
|
||
_logger.LogDebug(ex, "Health check {Name} threw", localVm.Entry.Name);
|
||
}
|
||
try { await Task.Delay(intervalMs, ct).ConfigureAwait(false); }
|
||
catch { break; }
|
||
}
|
||
}, ct);
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Recompose la liste affichée à partir du scan local + du dernier manifest distant.
|
||
/// La version « courante » (FeaturedVersion) est la plus haute installée ; à défaut,
|
||
/// la plus haute disponible côté serveur. Toutes les autres vont dans OtherVersions.
|
||
/// </summary>
|
||
private void RebuildList()
|
||
{
|
||
// Si un DL/verify est en cours, on capture la row active pour transférer
|
||
// son état (Downloading/Verifying + progression) sur la nouvelle instance.
|
||
// Sans ça, l'UI repasse sur le nouveau row qui est en AvailableIdle avec
|
||
// ResumableBytes lu depuis state.json → boutons "Reprendre" + "Annuler"
|
||
// affichés alors que le DL continue derrière (footer qui avance).
|
||
var oldActive = _activeRow;
|
||
var preserveActive = oldActive is not null
|
||
&& oldActive.State is VersionRowState.Downloading or VersionRowState.Verifying;
|
||
|
||
var installed = _registry.Scan().ToDictionary(v => v.Version);
|
||
|
||
// Filtrage BÊTA : si la license n'a pas le flag canSeeBetas, on cache
|
||
// les versions taggées isBeta=true. Les installations locales déjà
|
||
// présentes ne sont pas filtrées (un client qui avait installé une
|
||
// bêta puis perd l'accès continue de la voir et de pouvoir la lancer
|
||
// — on n'efface pas son disque).
|
||
var canSeeBetas = _license?.CanSeeBetas ?? false;
|
||
var rawRemote = _lastManifest?.Versions ?? new List<VersionManifest>();
|
||
var remote = canSeeBetas
|
||
? rawRemote
|
||
: rawRemote.Where(v => !v.IsBeta).ToList();
|
||
var remoteByVer = remote.ToDictionary(v => v.Version);
|
||
|
||
var allVersions = installed.Keys.Union(remoteByVer.Keys)
|
||
.OrderByDescending(v => SemVer.Parse(v))
|
||
.ToList();
|
||
|
||
var rows = new List<VersionRowViewModel>();
|
||
foreach (var ver in allVersions)
|
||
{
|
||
VersionRowViewModel row;
|
||
if (installed.TryGetValue(ver, out var inst))
|
||
{
|
||
row = VersionRowViewModel.ForInstalled(inst, remoteByVer.GetValueOrDefault(ver));
|
||
}
|
||
else
|
||
{
|
||
row = VersionRowViewModel.ForRemote(remoteByVer[ver]);
|
||
}
|
||
WireRowHandlers(row);
|
||
rows.Add(row);
|
||
}
|
||
|
||
// Marque les rows distantes qui ont un DL en pause (partial + state.json présents)
|
||
// et applique le filtre license
|
||
foreach (var r in rows.Where(r => r.IsRemoteOnly))
|
||
{
|
||
var st = _downloadManager.GetResumableState(r.Version);
|
||
if (st is not null) r.ResumableBytes = st.DownloadedBytes;
|
||
|
||
// License : la version est-elle téléchargeable selon notre entitlement ?
|
||
r.LicenseAllowsDownload = r.Remote is not null
|
||
&& _licenseService.CanDownloadVersion(_license, r.Remote);
|
||
}
|
||
|
||
// Si un DL était en vol, propage son état sur la nouvelle instance avant
|
||
// que les bindings UI ne s'attachent : State Downloading/Verifying +
|
||
// progression copiés, ResumableBytes forcé à 0 pour cacher les boutons
|
||
// "Reprendre" / "Annuler" tant que le DL tourne. _activeRow est repointé
|
||
// sur la nouvelle instance pour que les progress callbacks (résolus à
|
||
// l'exécution via _activeRow, pas via une capture closure) ciblent le
|
||
// bon objet et que la barre de progress de la row avance live.
|
||
if (preserveActive && oldActive is not null)
|
||
{
|
||
var matching = rows.FirstOrDefault(r => r.Version == oldActive.Version);
|
||
if (matching is not null)
|
||
{
|
||
matching.State = oldActive.State;
|
||
matching.ProgressPercent = oldActive.ProgressPercent;
|
||
matching.ProgressDetail = oldActive.ProgressDetail;
|
||
matching.ResumableBytes = 0;
|
||
_activeRow = matching;
|
||
}
|
||
}
|
||
|
||
// Featured : plus haute installée, sinon plus haute distante
|
||
var featured = rows.FirstOrDefault(r => r.IsInstalled) ?? rows.FirstOrDefault();
|
||
FeaturedVersion = featured;
|
||
|
||
OtherVersions.Clear();
|
||
foreach (var r in rows)
|
||
{
|
||
if (!ReferenceEquals(r, featured))
|
||
OtherVersions.Add(r);
|
||
}
|
||
|
||
OnPropertyChanged(nameof(EmptyHint));
|
||
OnPropertyChanged(nameof(EmptyHintVisibility));
|
||
OnPropertyChanged(nameof(HasFeaturedVersion));
|
||
OnPropertyChanged(nameof(HasOtherVersions));
|
||
}
|
||
|
||
private void WireRowHandlers(VersionRowViewModel row)
|
||
{
|
||
row.LaunchHandler = LaunchVersion;
|
||
// 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);
|
||
row.ForceFreshDownloadHandler = r => _ = ForceFreshDownloadAsync(r);
|
||
row.ToggleAutoModeHandler = ToggleAutoModeForRow;
|
||
|
||
// Hydrate l'état mode auto depuis la config persistée (à chaque RebuildList)
|
||
row.ShowAutoButton = _config.AutoMode.FeatureEnabled && row.IsInstalled;
|
||
row.IsAutoModeSelected = string.Equals(
|
||
_config.AutoMode.SelectedVersion, row.Version, StringComparison.Ordinal);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Click sur le bouton AUTO d'une row. Toggle l'état sur cette row uniquement,
|
||
/// désélectionne toutes les autres (unicité), persiste la config. Si on toggle
|
||
/// OFF la version actuellement sélectionnée, SelectedVersion devient null
|
||
/// (= mode auto inactif mais feature toujours activée pour permettre une nouvelle
|
||
/// sélection plus tard).
|
||
/// </summary>
|
||
private void ToggleAutoModeForRow(VersionRowViewModel row)
|
||
{
|
||
// Trace fichier (en plus du Serilog principal) pour pouvoir diagnostiquer le
|
||
// path d'invocation depuis le bouton AUTO sans dépendre du logger DI.
|
||
try
|
||
{
|
||
var dir = System.IO.Path.Combine(
|
||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||
"PSLauncher", "logs");
|
||
System.IO.Directory.CreateDirectory(dir);
|
||
System.IO.File.AppendAllText(
|
||
System.IO.Path.Combine(dir, "auto-mode.log"),
|
||
$"{DateTime.UtcNow:yyyy-MM-dd HH:mm:ss.fff}Z ToggleAutoModeForRow v{row.Version} (FeatureEnabled={_config.AutoMode.FeatureEnabled}, IsInstalled={row.IsInstalled}, current SelectedVersion={_config.AutoMode.SelectedVersion}){Environment.NewLine}");
|
||
}
|
||
catch { }
|
||
|
||
if (!_config.AutoMode.FeatureEnabled)
|
||
{
|
||
_logger.LogInformation("Auto-mode toggle ignored : FeatureEnabled=false");
|
||
return;
|
||
}
|
||
if (!row.IsInstalled)
|
||
{
|
||
_logger.LogInformation("Auto-mode toggle ignored : v{Version} pas installée", row.Version);
|
||
return;
|
||
}
|
||
|
||
var newSelected = !row.IsAutoModeSelected;
|
||
|
||
// Désélectionne toutes les autres
|
||
if (FeaturedVersion is not null)
|
||
FeaturedVersion.IsAutoModeSelected = false;
|
||
foreach (var r in OtherVersions)
|
||
r.IsAutoModeSelected = false;
|
||
|
||
// Sélectionne celle-ci (ou laisse désélectionnée si on a toggle OFF)
|
||
row.IsAutoModeSelected = newSelected;
|
||
_config.AutoMode.SelectedVersion = newSelected ? row.Version : null;
|
||
_configStore.Save(_config);
|
||
_logger.LogInformation("Auto-mode selection: {Version}",
|
||
newSelected ? row.Version : "(none)");
|
||
|
||
// Si on vient d'activer le mode auto sur cette version, déclenche immédiatement
|
||
// le countdown + lancement. Différé via Dispatcher.BeginInvoke pour que le
|
||
// bouton ait le temps de repeindre en orange AVANT que la modal apparaisse.
|
||
if (newSelected)
|
||
{
|
||
Application.Current.Dispatcher.BeginInvoke(
|
||
new Action(() => StartAutoLaunchCountdown(row)),
|
||
System.Windows.Threading.DispatcherPriority.Background);
|
||
}
|
||
}
|
||
|
||
/// <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()
|
||
{
|
||
IsBusy = true;
|
||
StatusMessage = Strings.StatusCheckingUpdates;
|
||
try
|
||
{
|
||
// Re-validation de la license en parallèle du fetch manifest. Permet
|
||
// au client de refléter sans délai les changements admin (modification
|
||
// de date, révocation, etc.) sans attendre l'expiration du cache offline
|
||
// de 100 jours. Si l'utilisateur est offline ou si l'API ne répond pas,
|
||
// on garde le cache silencieusement (pas de crash).
|
||
await RefreshLicenseFromServerAsync(CancellationToken.None);
|
||
|
||
var result = await _updateChecker.CheckAsync(CancellationToken.None);
|
||
if (result.Error is not null)
|
||
{
|
||
StatusMessage = Strings.StatusError(result.Error);
|
||
return;
|
||
}
|
||
|
||
_lastManifest = result.Manifest;
|
||
RebuildList();
|
||
|
||
// Détection MAJ du launcher lui-même (présent en `manifest.launcher`).
|
||
// Lancée en arrière-plan pour ne pas bloquer l'affichage des résultats produit.
|
||
if (result.Manifest?.Launcher is { } launcher && _selfUpdater.IsNewerThanCurrent(launcher))
|
||
{
|
||
_ = Application.Current.Dispatcher.BeginInvoke(() => PromptLauncherUpdate(launcher));
|
||
}
|
||
|
||
// Suffixe d'origine du manifest pour que l'utilisateur sache si la
|
||
// liste des versions vient d'OVH (canonique, à jour) ou d'un peer/cache
|
||
// local (potentiellement plus ancien).
|
||
var sourceSuffix = _manifestService.LastSource switch
|
||
{
|
||
ManifestSource.Ovh => " · " + Strings.ManifestSourceOvh,
|
||
ManifestSource.Peer => " · " + Strings.ManifestSourcePeer,
|
||
ManifestSource.DiskCache => " · " + Strings.ManifestSourceDiskCache,
|
||
_ => string.Empty,
|
||
};
|
||
|
||
if (result.IsLatestNewerThanInstalled && result.LatestRemote is not null)
|
||
StatusMessage = Strings.StatusNewAvailable(result.LatestRemote.Version) + sourceSuffix;
|
||
else if (result.LatestRemote is not null)
|
||
StatusMessage = Strings.StatusUpToDate(result.LatestRemote.Version) + sourceSuffix;
|
||
else
|
||
StatusMessage = Strings.StatusNoRemote + sourceSuffix;
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_logger.LogError(ex, "Update check failed");
|
||
StatusMessage = Strings.StatusError(ex.Message);
|
||
}
|
||
finally
|
||
{
|
||
IsBusy = false;
|
||
}
|
||
}
|
||
private bool CanCheckUpdates() => !IsBusy;
|
||
|
||
/// <summary>
|
||
/// Tente une re-validation online de la license. Met à jour le cache local
|
||
/// avec les valeurs fraîches du serveur (date d'expiration, statut révoqué, etc.).
|
||
/// Silencieux en cas d'échec réseau pour ne pas casser le check des MAJ ; le
|
||
/// cache offline reste utilisable.
|
||
/// </summary>
|
||
private async Task RefreshLicenseFromServerAsync(CancellationToken ct)
|
||
{
|
||
var key = _licenseService.GetDecryptedKey();
|
||
if (string.IsNullOrEmpty(key)) return;
|
||
try
|
||
{
|
||
var resp = await _licenseService.ValidateAsync(key, ct);
|
||
_licenseService.SaveCached(key, resp);
|
||
_license = _licenseService.GetCached();
|
||
NotifyLicenseChanged();
|
||
_logger.LogInformation("License revalidated from server (status={Status}, until={Until})",
|
||
resp.Status, resp.DownloadEntitlementUntil);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_logger.LogInformation(ex, "License revalidation failed (offline or server error). Keeping cached state.");
|
||
}
|
||
}
|
||
|
||
private async void PromptLauncherUpdate(LauncherInfo launcher)
|
||
{
|
||
try
|
||
{
|
||
var dialog = new Views.LauncherUpdateDialog(launcher, _selfUpdater.GetCurrentVersion())
|
||
{
|
||
Owner = Application.Current.MainWindow
|
||
};
|
||
dialog.ShowDialog();
|
||
if (!dialog.UpdateRequested) return;
|
||
|
||
IsBusy = true;
|
||
StatusMessage = Strings.StatusDownloadingLauncher;
|
||
var progress = new Progress<DownloadProgress>(p =>
|
||
{
|
||
if (p.TotalBytes > 0) ProgressPercent = (double)p.BytesDownloaded / p.TotalBytes * 100.0;
|
||
ProgressDetail = Strings.ProgressLauncherDl(launcher.Version, FormatSize(p.BytesDownloaded), FormatSize(p.TotalBytes));
|
||
});
|
||
|
||
await _selfUpdater.DownloadAndApplyAsync(launcher, progress, CancellationToken.None);
|
||
StatusMessage = Strings.StatusLauncherUpdating;
|
||
await Task.Delay(800); // laisse le temps au toast / message de s'afficher
|
||
Application.Current.Shutdown();
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_logger.LogError(ex, "Self-update failed");
|
||
ThemedMessageBox.Show(Strings.MsgSelfUpdateFailed(ex.Message),
|
||
Strings.MsgBoxError, MessageBoxButton.OK, MessageBoxImage.Error);
|
||
StatusMessage = Strings.StatusSelfUpdateError(ex.Message);
|
||
IsBusy = false;
|
||
}
|
||
}
|
||
|
||
[RelayCommand]
|
||
private void OpenSettings()
|
||
{
|
||
var vm = _serviceProvider.GetRequiredService<SettingsViewModel>();
|
||
var dialog = new Views.SettingsDialog(vm) { Owner = Application.Current.MainWindow };
|
||
if (dialog.ShowDialog() == true)
|
||
{
|
||
// Au retour, l'URL serveur ou l'installRoot ont pu changer → refresh
|
||
_license = _licenseService.GetCached();
|
||
NotifyLicenseChanged();
|
||
RebuildList();
|
||
|
||
// Hot-reload du bandeau de santé : la liste des checks (et leurs intervals)
|
||
// a pu être éditée. On reconstruit les indicateurs et on relance la boucle
|
||
// per-check sans avoir à fermer/rouvrir le launcher.
|
||
InitHealthIndicators();
|
||
StartHealthLoop();
|
||
OnPropertyChanged(nameof(ReportUri));
|
||
OnPropertyChanged(nameof(DocumentationUri));
|
||
OnPropertyChanged(nameof(HasDocumentationUrl));
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Clic sur le badge license dans la top bar.
|
||
/// - License active (valid/expired/revoked) → ouvre LicenseDetailsDialog (détails + désactiver)
|
||
/// - Sinon → ouvre OnboardingDialog (saisie de clé)
|
||
/// Si l'utilisateur désactive depuis les détails, on enchaîne sur l'onboarding pour
|
||
/// faciliter le re-keying éventuel — sinon on revient simplement à l'état "Aucune license".
|
||
/// </summary>
|
||
[RelayCommand]
|
||
private async Task OpenLicenseAsync()
|
||
{
|
||
var current = _licenseService.GetCached();
|
||
|
||
if (current is not null)
|
||
{
|
||
var details = new Views.LicenseDetailsDialog(_licenseService, current)
|
||
{
|
||
Owner = Application.Current.MainWindow
|
||
};
|
||
details.ShowDialog();
|
||
if (!details.DeactivateRequested)
|
||
{
|
||
// Pas de changement
|
||
await Task.CompletedTask;
|
||
return;
|
||
}
|
||
// Désactivation : on a déjà vidé le cache, on rafraîchit la UI puis on reboucle
|
||
_license = null;
|
||
NotifyLicenseChanged();
|
||
RebuildList();
|
||
}
|
||
|
||
// Aucune license → propose l'onboarding
|
||
var onboard = new Views.OnboardingDialog(_licenseService) { Owner = Application.Current.MainWindow };
|
||
onboard.ShowDialog();
|
||
if (onboard.LicenseActivated)
|
||
{
|
||
_license = _licenseService.GetCached();
|
||
NotifyLicenseChanged();
|
||
RebuildList();
|
||
}
|
||
await Task.CompletedTask;
|
||
}
|
||
|
||
[RelayCommand]
|
||
private void OpenInstallRoot()
|
||
{
|
||
if (!Directory.Exists(_config.InstallRoot)) return;
|
||
Process.Start(new ProcessStartInfo
|
||
{
|
||
FileName = _config.InstallRoot,
|
||
UseShellExecute = true,
|
||
Verb = "open"
|
||
});
|
||
}
|
||
|
||
[RelayCommand]
|
||
private void CancelDownload()
|
||
{
|
||
if (_activeDownloadCts is null || _activeRow is null) return;
|
||
var confirm = ThemedMessageBox.Show(
|
||
Strings.MsgCancelDownloadConfirm(_activeRow.Version),
|
||
Strings.MsgBoxCancelDl,
|
||
MessageBoxButton.YesNo, MessageBoxImage.Question, MessageBoxResult.No);
|
||
if (confirm != MessageBoxResult.Yes) return;
|
||
_activeDownloadCts.Cancel();
|
||
}
|
||
|
||
/// <summary>
|
||
/// Discard total : annule + supprime le partial + state.json.
|
||
/// Permet de repartir de zéro depuis le bouton « ↻ Reprendre » contextuel.
|
||
/// </summary>
|
||
private async Task RestartFromZeroAsync(VersionRowViewModel row)
|
||
{
|
||
// Si un DL est actif sur cette même row, on doit d'abord l'annuler proprement.
|
||
var st = _downloadManager.GetResumableState(row.Version);
|
||
var sizeStr = FormatSize(st?.DownloadedBytes ?? 0);
|
||
var confirm = ThemedMessageBox.Show(
|
||
Strings.MsgRestartDownloadConfirm(row.Version, sizeStr),
|
||
Strings.MsgBoxRestartDl,
|
||
MessageBoxButton.YesNo, MessageBoxImage.Warning, MessageBoxResult.No);
|
||
if (confirm != MessageBoxResult.Yes) return;
|
||
|
||
// Cancel le DL actif si c'est cette row, puis attend la propagation complète
|
||
// (les 16 segments doivent chacun stopper leurs écritures et fermer leur
|
||
// FileStream sinon DiscardResumableState échouera/sera réécrasé).
|
||
if (_activeRow is not null && _activeRow == row && _activeDownloadCts is not null)
|
||
{
|
||
_activeDownloadCts.Cancel();
|
||
// Attend que InstallVersionAsync termine son catch OperationCanceledException
|
||
// — y compris le flush + state save + libération des FileStream segment.
|
||
// Timeout de sécurité 10s au cas où une connexion HTTPS bloque sur close.
|
||
if (_activeInstallTask is { } running && !running.IsCompleted)
|
||
{
|
||
StatusMessage = "Annulation en cours…";
|
||
try
|
||
{
|
||
var awaitTask = await Task.WhenAny(running, Task.Delay(TimeSpan.FromSeconds(10)));
|
||
if (!ReferenceEquals(awaitTask, running))
|
||
_logger.LogWarning("Install task did not finish cancelling within 10s, proceeding anyway");
|
||
}
|
||
catch (Exception ex) { _logger.LogDebug(ex, "Awaiting install task during restart"); }
|
||
}
|
||
}
|
||
|
||
_downloadManager.DiscardResumableState(row.Version);
|
||
row.ResumableBytes = 0;
|
||
row.State = VersionRowState.AvailableIdle;
|
||
row.ProgressDetail = null;
|
||
row.ProgressPercent = 0;
|
||
StatusMessage = null;
|
||
ProgressDetail = null;
|
||
ProgressPercent = 0;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Purge TOUT pour cette version (state.json + .partial + .zip cache + sidecar
|
||
/// SHA), afin de forcer un re-DL complet au prochain Install. Utile quand le
|
||
/// SHA-256 a changé côté serveur et que la source précédente (OVH CDN, peer
|
||
/// LAN) était obsolète — le cache local du launcher pourrait perpétuer le
|
||
/// problème en servant aux peers du LAN un ZIP avec sidecar incohérent.
|
||
///
|
||
/// Cas d'usage typique : opérateur backoffice qui a rebuild un release (ex.
|
||
/// v1.5.4) avec nouveau contenu + nouveau SHA, et un PC client qui n'arrive
|
||
/// pas à re-DL parce que sa source est en cache stale.
|
||
/// </summary>
|
||
private async Task ForceFreshDownloadAsync(VersionRowViewModel row)
|
||
{
|
||
var confirm = ThemedMessageBox.Show(
|
||
Strings.MsgForceFreshConfirm(row.Version),
|
||
Strings.MsgBoxForceFresh,
|
||
MessageBoxButton.YesNo, MessageBoxImage.Question, MessageBoxResult.No);
|
||
if (confirm != MessageBoxResult.Yes) return;
|
||
|
||
// Si un DL est actif sur cette même row, on le cancel proprement avant
|
||
// de purger (même pattern que RestartFromZeroAsync).
|
||
if (_activeRow is not null && _activeRow == row && _activeDownloadCts is not null)
|
||
{
|
||
_activeDownloadCts.Cancel();
|
||
if (_activeInstallTask is { } running && !running.IsCompleted)
|
||
{
|
||
StatusMessage = "Annulation en cours…";
|
||
try
|
||
{
|
||
var awaitTask = await Task.WhenAny(running, Task.Delay(TimeSpan.FromSeconds(10)));
|
||
if (!ReferenceEquals(awaitTask, running))
|
||
_logger.LogWarning("Install task did not finish cancelling within 10s, proceeding with purge anyway");
|
||
}
|
||
catch (Exception ex) { _logger.LogDebug(ex, "Awaiting install task during force-fresh"); }
|
||
}
|
||
}
|
||
|
||
// 1. Purge state.json + .partial (ces deux sont DownloadStateStore-only)
|
||
_downloadManager.DiscardResumableState(row.Version);
|
||
|
||
// 2. Purge le cache LAN (.zip + .sha256 sidecar). Best-effort : si un
|
||
// autre process garde un handle (rare : LanCacheServer en cours de
|
||
// serve), on log warning et on continue.
|
||
try
|
||
{
|
||
await _zipCacheStore.InvalidateAsync(row.Version, CancellationToken.None);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_logger.LogWarning(ex, "Could not fully invalidate cache for v{Version} during force-fresh", row.Version);
|
||
}
|
||
|
||
// 3. Reset UI state pour que la row reprenne "Installer" propre.
|
||
row.ResumableBytes = 0;
|
||
row.State = VersionRowState.AvailableIdle;
|
||
row.ProgressDetail = null;
|
||
row.ProgressPercent = 0;
|
||
StatusMessage = null;
|
||
ProgressDetail = null;
|
||
ProgressPercent = 0;
|
||
_logger.LogInformation("Force-fresh download purge completed for v{Version}", row.Version);
|
||
}
|
||
|
||
// ------------ Per-row actions ------------
|
||
|
||
private void LaunchVersion(VersionRowViewModel row)
|
||
{
|
||
if (row.Installed is null) return;
|
||
|
||
// ---- Garde-fou anti double-lancement ----
|
||
// Deux niveaux de vérif pour couvrir tous les cas :
|
||
// 1. _runningProserve : process LANCÉ PAR NOUS encore vivant ⇒ silently
|
||
// refuse (cas typique : exit event glitché, ou double-clic du bouton
|
||
// LANCER, ou auto-mode qui re-déclenche pendant qu'on tourne déjà).
|
||
// 2. _processLauncher.IsRunning(row.Installed) : scan système pour
|
||
// détecter une instance lancée HORS launcher (ex. opérateur a
|
||
// double-cliqué le .exe dans l'explorateur).
|
||
// En cas de match, on log + popup. La popup n'apparaît PAS pour les
|
||
// déclenchements auto-mode (silently skip) pour ne pas spammer.
|
||
if (IsAnyProserveAlreadyRunning(row.Installed, out var detail))
|
||
{
|
||
_logger.LogWarning(
|
||
"Refusing to launch PROSERVE v{Version} : an instance is already running ({Detail})",
|
||
row.Version, detail);
|
||
// Distinction manuel vs auto-mode : pour un clic LANCER manuel,
|
||
// l'opérateur doit savoir pourquoi rien ne se passe ⇒ popup.
|
||
// Pour un auto-trigger (countdown finish, exit retrigger…), silently
|
||
// skip suffit — pas d'interaction utilisateur en cours.
|
||
if (!_isAutoLaunchInFlight)
|
||
{
|
||
ThemedMessageBox.Show(
|
||
Strings.MsgAlreadyRunningBody,
|
||
Strings.MsgAlreadyRunningTitle,
|
||
MessageBoxButton.OK, MessageBoxImage.Warning);
|
||
}
|
||
return;
|
||
}
|
||
|
||
try
|
||
{
|
||
// Si on lance la version désignée comme "auto", on passe les CLI args
|
||
// configurés (ex: -autoconnect -login=Jerome2 -password=… -ipserver=10.0.4.100).
|
||
// Pour les lancements manuels d'autres versions, args = null (comportement standard).
|
||
var isAutoVersion = _config.AutoMode.FeatureEnabled
|
||
&& !string.IsNullOrEmpty(_config.AutoMode.SelectedVersion)
|
||
&& string.Equals(_config.AutoMode.SelectedVersion, row.Version, StringComparison.Ordinal);
|
||
string[]? cliArgs = isAutoVersion ? BuildAutoModeCliArgs(_config.AutoMode.Args) : null;
|
||
|
||
var proc = _processLauncher.Launch(row.Installed, cliArgs);
|
||
_runningProserve = proc;
|
||
_config.LastLaunchedVersion = row.Version;
|
||
_configStore.Save(_config);
|
||
|
||
// Réduit la fenêtre du launcher pour ne pas gêner Proserve qui démarre
|
||
if (Application.Current.MainWindow is { } mw)
|
||
mw.WindowState = WindowState.Minimized;
|
||
|
||
// Quand Proserve se termine, on remet la fenêtre du launcher au premier plan.
|
||
// EnableRaisingEvents est requis pour recevoir l'event Exited.
|
||
try
|
||
{
|
||
proc.EnableRaisingEvents = true;
|
||
proc.Exited += (_, _) =>
|
||
{
|
||
_logger.LogInformation("PROSERVE v{Version} a quitté — restauration du launcher", row.Version);
|
||
Application.Current?.Dispatcher.Invoke(() =>
|
||
{
|
||
// Libère le slot AVANT de potentiellement relancer en mode auto
|
||
// (sinon le garde-fou ci-dessus refuserait le nouveau launch).
|
||
// Compare la référence pour éviter de nuller un process plus récent
|
||
// dans un scénario rare où le user a relancé manuellement entre temps.
|
||
if (ReferenceEquals(_runningProserve, proc))
|
||
_runningProserve = null;
|
||
|
||
if (Application.Current.MainWindow is { } main)
|
||
{
|
||
if (main.WindowState == WindowState.Minimized)
|
||
main.WindowState = WindowState.Normal;
|
||
main.Activate();
|
||
// Pop temporaire pour amener la fenêtre au premier plan
|
||
main.Topmost = true;
|
||
main.Topmost = false;
|
||
}
|
||
// Si la version qui vient de quitter est la version auto, on
|
||
// déclenche le countdown de relance. L'opérateur peut annuler
|
||
// (désactive le mode auto sur cette session) ou laisser le timer
|
||
// expirer pour relancer.
|
||
if (isAutoVersion)
|
||
StartAutoLaunchCountdown(row);
|
||
});
|
||
};
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
// ShellExecute peut renvoyer un Process sans accès aux events — non bloquant.
|
||
// Conséquence : _runningProserve ne sera jamais nullé via Exited et resterait
|
||
// bloquant. On clear immédiatement pour ne pas verrouiller les futurs launches
|
||
// (au pire on perd la protection sur cette instance, mais IsRunning() scanne
|
||
// le système et catchera quand même les doublons).
|
||
_logger.LogDebug(ex, "Could not hook Exited on launched process");
|
||
_runningProserve = null;
|
||
}
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_logger.LogError(ex, "Launch failed");
|
||
_runningProserve = null;
|
||
ThemedMessageBox.Show(Strings.MsgLaunchFailed(ex.Message),
|
||
Strings.MsgBoxLaunchError, MessageBoxButton.OK, MessageBoxImage.Error);
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// True quand un lancement auto-mode est en cours de déclenchement (countdown
|
||
/// en cours OU appel <see cref="LaunchVersion"/> issu du dialog). Sert à
|
||
/// rendre le garde-fou silencieux pour les triggers auto (sinon une popup
|
||
/// "déjà lancé" apparaîtrait à chaque tick d'un exit-event glitché en mode
|
||
/// auto). Toggled par <see cref="StartAutoLaunchCountdown"/>.
|
||
/// </summary>
|
||
private bool _isAutoLaunchInFlight;
|
||
|
||
/// <summary>
|
||
/// True si une instance PROSERVE tourne déjà — soit lancée par nous
|
||
/// (<see cref="_runningProserve"/> encore vivant), soit lancée hors launcher
|
||
/// et détectée via <see cref="IProcessLauncher.IsRunning"/>. Le paramètre
|
||
/// <paramref name="detail"/> est rempli avec le motif de blocage pour les logs.
|
||
/// </summary>
|
||
private bool IsAnyProserveAlreadyRunning(InstalledVersion target, out string detail)
|
||
{
|
||
// 1) Notre process tracking
|
||
if (_runningProserve is { } p)
|
||
{
|
||
try
|
||
{
|
||
if (!p.HasExited)
|
||
{
|
||
detail = $"_runningProserve PID={p.Id} still alive";
|
||
return true;
|
||
}
|
||
// Process a terminé mais Exited n'a pas (encore) fired ⇒ on
|
||
// libère le slot et continue à vérifier le système.
|
||
_runningProserve = null;
|
||
}
|
||
catch (InvalidOperationException)
|
||
{
|
||
// Process déjà disposé → considère terminé
|
||
_runningProserve = null;
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
// Sandboxing ou perte d'accès au process : on ne peut pas savoir.
|
||
// Mieux vaut être permissif (laisser passer) que bloquer à tort.
|
||
_logger.LogDebug(ex, "Could not poll _runningProserve.HasExited, treating as exited");
|
||
_runningProserve = null;
|
||
}
|
||
}
|
||
|
||
// 2) Scan système pour les instances externes (ou nôtres dont on a perdu
|
||
// le handle, ex. après un crash launcher partiel).
|
||
try
|
||
{
|
||
if (_processLauncher.IsRunning(target))
|
||
{
|
||
detail = $"system scan detected running {Path.GetFileName(target.ExecutablePath)}";
|
||
return true;
|
||
}
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_logger.LogDebug(ex, "IsRunning() scan failed, treating as not running");
|
||
}
|
||
|
||
detail = string.Empty;
|
||
return false;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Convertit la liste <see cref="AutoModeConfig.Args"/> en string[] format CLI
|
||
/// compatible Unreal Engine (FParse::Param et FParse::Value).
|
||
///
|
||
/// L'opérateur saisit la liste de paires {Key, Value} sans avoir à se soucier
|
||
/// de la syntaxe : il tape <c>autoconnect</c> (sans tiret) et <c>login</c> /
|
||
/// <c>Jerome</c>. Le launcher se charge d'ajouter les <c>-</c>, <c>=</c> et
|
||
/// le quotage des valeurs avec espaces.
|
||
///
|
||
/// Règles de formatage produites :
|
||
/// • Value vide → <c>-{key}</c> (flag : FParse::Param)
|
||
/// • Value sans espace → <c>-{key}={value}</c> (FParse::Value classique)
|
||
/// • Value avec espace → <c>-{key}="value spaced"</c> (quotage Win32)
|
||
///
|
||
/// Défensif : on strip un éventuel <c>-</c> en tête de Key et un <c>=</c> en
|
||
/// tête de Value au cas où l'opérateur les a saisis dans l'UI ("au cas où" =
|
||
/// retour utilisateur : certains tapent <c>-login</c> ou <c>=Jerome</c> par
|
||
/// réflexe et obtenaient <c>--login</c> ou <c>-login==Jerome</c> à l'exécution).
|
||
/// Les clés vides sont skippées silencieusement.
|
||
/// </summary>
|
||
/// <summary>
|
||
/// Construit la liste finale des processus à terminer avant le merge SteamVR.
|
||
/// Source principale : les health checks de type "Process" (= les programmes
|
||
/// que l'opérateur surveille sur le bandeau de santé = ceux qu'il sait
|
||
/// présents sur sa machine VR). On y ajoute systématiquement les processus
|
||
/// "guardian" (VIVE Business Streaming, RR*, etc.) qui relancent SteamVR
|
||
/// même si l'opérateur ne les a pas dans ses health checks, puis on ordonne
|
||
/// pour que les guardians soient tués EN PREMIER.
|
||
/// </summary>
|
||
private List<string> BuildSteamVrProcessKillList()
|
||
{
|
||
// 1) Processus configurés dans les health checks (case-insensitive).
|
||
var fromHealth = _config.HealthChecks.Checks
|
||
.Where(c => string.Equals(c.Kind, "Process", StringComparison.OrdinalIgnoreCase))
|
||
.Select(c => (c.Target ?? string.Empty).Trim())
|
||
.Where(t => !string.IsNullOrEmpty(t))
|
||
.Select(StripExeSuffix);
|
||
|
||
// 2) Fallback / défense : la liste config (qui contient VBS + SteamVR
|
||
// canoniques par défaut). Si l'opérateur n'a rien configuré en health
|
||
// checks, on aura quand même de quoi tuer ce qui faut.
|
||
var fromConfig = _config.SteamVr.ProcessesToKill
|
||
.Select(t => (t ?? string.Empty).Trim())
|
||
.Where(t => !string.IsNullOrEmpty(t))
|
||
.Select(StripExeSuffix);
|
||
|
||
// 3) Dédup case-insensitive en préservant l'ORDRE D'INSERTION : on
|
||
// insère d'abord les "guardians" connus (VBS, RR*, Htc*), puis les
|
||
// health checks, puis le reste de la config. Ainsi VBS est garanti
|
||
// tué en premier (sinon il relance SteamVR avant qu'on touche au fichier).
|
||
var guardians = new[]
|
||
{
|
||
"VIVE Business Streaming",
|
||
"RRConsole",
|
||
"RRServer",
|
||
"HtcConnectionUtility",
|
||
};
|
||
|
||
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||
var result = new List<string>();
|
||
foreach (var name in guardians.Concat(fromHealth).Concat(fromConfig))
|
||
{
|
||
if (seen.Add(name)) result.Add(name);
|
||
}
|
||
return result;
|
||
}
|
||
|
||
private static string StripExeSuffix(string name)
|
||
{
|
||
if (name.EndsWith(".exe", StringComparison.OrdinalIgnoreCase))
|
||
return name[..^4].Trim();
|
||
return name;
|
||
}
|
||
|
||
private static string[] BuildAutoModeCliArgs(IEnumerable<AutoModeArg> args)
|
||
{
|
||
var list = new List<string>();
|
||
foreach (var a in args)
|
||
{
|
||
// Sanitize Key : trim whitespace + leading dashes éventuels.
|
||
var key = (a.Key ?? string.Empty).Trim().TrimStart('-').Trim();
|
||
if (string.IsNullOrEmpty(key)) continue;
|
||
|
||
// Sanitize Value : trim whitespace + un éventuel `=` en tête.
|
||
var val = (a.Value ?? string.Empty).Trim().TrimStart('=').Trim();
|
||
|
||
if (string.IsNullOrEmpty(val))
|
||
{
|
||
list.Add($"-{key}");
|
||
}
|
||
else if (val.IndexOfAny(new[] { ' ', '\t', '"' }) >= 0)
|
||
{
|
||
// Quote + escape les " internes (rare mais possible pour un mdp).
|
||
var escaped = val.Replace("\"", "\\\"");
|
||
list.Add($"-{key}=\"{escaped}\"");
|
||
}
|
||
else
|
||
{
|
||
list.Add($"-{key}={val}");
|
||
}
|
||
}
|
||
return list.ToArray();
|
||
}
|
||
|
||
/// <summary>
|
||
/// Extrait les deux hashes SHA-256 du message de l'<see cref="InvalidDataException"/>
|
||
/// jetée par <c>VerifyAndFinalizeAsync</c>. Format source :
|
||
/// <c>"Checksum SHA-256 invalide : attendu {expected}, calculé {computed}"</c>
|
||
/// Si parsing échoue (changement de format, log custom, etc.), retourne
|
||
/// "?" pour les deux — le reste du message reste informatif.
|
||
/// Méthode statique sans dépendance externe (regex pure), facile à tester.
|
||
/// </summary>
|
||
private static (string Expected, string Computed) ParseShaMismatchValues(string exceptionMessage)
|
||
{
|
||
// 64 chars hex = SHA-256 hex. Non-greedy, case-insensitive — couvre les
|
||
// cas où le format de message évolue légèrement (ponctuation, langue).
|
||
var m = System.Text.RegularExpressions.Regex.Match(
|
||
exceptionMessage,
|
||
@"attendu\s+([0-9a-fA-F]{64}).*calcul[eé]\s+([0-9a-fA-F]{64})",
|
||
System.Text.RegularExpressions.RegexOptions.IgnoreCase);
|
||
if (m.Success) return (m.Groups[1].Value, m.Groups[2].Value);
|
||
return ("?", "?");
|
||
}
|
||
|
||
/// <summary>
|
||
/// Point d'entrée UNIQUE pour le lancement auto avec countdown. Utilisé par :
|
||
/// 1. Clic sur bouton AUTO → désigne la version + lance après countdown
|
||
/// 2. Startup du launcher → si une version était désignée, idem countdown + launch
|
||
/// 3. Exit de PROSERVE en mode auto → countdown + relance
|
||
///
|
||
/// Comportement :
|
||
/// - Affiche un popup modal avec compte-à-rebours (3-5 s configurables)
|
||
/// - Countdown expire → <see cref="LaunchVersion"/> avec args CLI mode auto
|
||
/// - Annuler → désactive le mode auto (<c>SelectedVersion=null</c>, bouton AUTO
|
||
/// repasse en gris sur la row CURRENT, persisté dans config.json)
|
||
/// </summary>
|
||
private void StartAutoLaunchCountdown(VersionRowViewModel row)
|
||
{
|
||
if (row.Installed is null) return;
|
||
|
||
// Garde-fou tôt : si PROSERVE est déjà lancé, ne pas afficher de countdown.
|
||
// Sinon le countdown défile → LaunchVersion → refus silencieux → confusing.
|
||
// On log simplement et on désactive le mode auto pour cette session pour
|
||
// que l'opérateur reprenne explicitement la main (sinon un exit-event
|
||
// glitché pourrait re-trigger ce path en boucle).
|
||
if (IsAnyProserveAlreadyRunning(row.Installed, out var why))
|
||
{
|
||
_logger.LogInformation(
|
||
"Auto-launch countdown skipped for v{Version} : already running ({Why})",
|
||
row.Version, why);
|
||
return;
|
||
}
|
||
|
||
var grace = Math.Clamp(_config.AutoMode.GracePeriodSeconds, 1, 60);
|
||
|
||
// Callback santé : non-null SEULEMENT si l'option WaitForHealthChecks
|
||
// est activée ET qu'il y a au moins un indicateur configuré. Sinon le
|
||
// dialog saute la phase santé et démarre direct le countdown.
|
||
Func<IReadOnlyList<string>>? healthSnapshot = null;
|
||
if (_config.AutoMode.WaitForHealthChecks && HealthIndicators.Count > 0)
|
||
{
|
||
healthSnapshot = () => HealthIndicators
|
||
.Where(h => h.Severity != Core.Health.HealthSeverity.Ok)
|
||
.Select(h => h.Name)
|
||
.ToList();
|
||
}
|
||
|
||
_isAutoLaunchInFlight = true;
|
||
try
|
||
{
|
||
var dialog = new Views.AutoRelaunchDialog(row.Version, grace, healthSnapshot)
|
||
{
|
||
Owner = Application.Current.MainWindow,
|
||
};
|
||
var result = dialog.ShowDialog();
|
||
if (result == true)
|
||
{
|
||
_logger.LogInformation("Auto-launching PROSERVE v{Version}", row.Version);
|
||
LaunchVersion(row);
|
||
}
|
||
else
|
||
{
|
||
// Cancel : désactive le mode auto sur cette version. L'opérateur peut
|
||
// reprendre la main et re-cliquer plus tard pour réactiver.
|
||
_logger.LogInformation("Auto-launch cancelled by user for v{Version}", row.Version);
|
||
_config.AutoMode.SelectedVersion = null;
|
||
_configStore.Save(_config);
|
||
|
||
// Trouve la row CURRENT (RebuildList a pu remplacer l'instance entre temps
|
||
// — auquel cas `row` est orpheline et le bouton de la UI ne se mettrait
|
||
// pas à jour). Pour le visuel, on désélectionne TOUTES les rows par sûreté.
|
||
if (FeaturedVersion is not null)
|
||
FeaturedVersion.IsAutoModeSelected = false;
|
||
foreach (var r in OtherVersions)
|
||
r.IsAutoModeSelected = false;
|
||
}
|
||
}
|
||
finally
|
||
{
|
||
_isAutoLaunchInFlight = false;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Au démarrage du launcher, si le mode auto est configuré avec une version
|
||
/// sélectionnée ET que cette version est bien installée, on déclenche le
|
||
/// countdown + lancement (avec possibilité d'annuler via Cancel sur la modal).
|
||
/// Appelé depuis le constructeur après RebuildList().
|
||
/// </summary>
|
||
private void TryAutoLaunchAtStartup()
|
||
{
|
||
if (!_config.AutoMode.FeatureEnabled) return;
|
||
var target = _config.AutoMode.SelectedVersion;
|
||
if (string.IsNullOrEmpty(target)) return;
|
||
|
||
var row = (FeaturedVersion?.Version == target ? FeaturedVersion : null)
|
||
?? OtherVersions.FirstOrDefault(r => r.Version == target);
|
||
if (row is null || !row.IsInstalled)
|
||
{
|
||
_logger.LogInformation("Auto-launch skipped: v{Version} not installed locally", target);
|
||
return;
|
||
}
|
||
|
||
_logger.LogInformation("Auto-launching v{Version} at startup (with countdown)", target);
|
||
StartAutoLaunchCountdown(row);
|
||
}
|
||
|
||
private async Task InstallVersionAsync(VersionRowViewModel row)
|
||
{
|
||
if (row.Remote is null) return;
|
||
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;
|
||
|
||
// Note : on NE reset PAS _install404RetryConsumed ici. Le retry après
|
||
// 404 réinvoke SafeInstallAsync(freshRow) qui re-entre dans cette
|
||
// méthode — si on resetait, on aurait potentiellement une boucle
|
||
// infinie 404→retry→404→retry. Le flag est seulement remis à false
|
||
// dans le `finally` du HAUT (= install qui n'est PAS un retry) ou
|
||
// après un install réussi.
|
||
|
||
try
|
||
{
|
||
// Si reprise d'un DL interrompu, on saute la popup de release notes :
|
||
// l'utilisateur a déjà confirmé sa décision la première fois.
|
||
var isResume = row.HasResumableDownload;
|
||
|
||
// Préservation des savegames : par défaut TRUE (option cochée dans la
|
||
// popup). Si l'install est un resume, l'utilisateur a déjà confirmé sa
|
||
// décision la première fois — on garde le défaut (préserver) plutôt que
|
||
// de re-bother avec un dialog.
|
||
bool preserveSaveGames = true;
|
||
|
||
if (!isResume)
|
||
{
|
||
// 1) Récupère release notes (best effort).
|
||
// Skip si on a fetché le manifest depuis un peer ou cache (= probablement
|
||
// offline) : l'URL release notes pointe sur OVH et le fetch va timeout
|
||
// bêtement. Mieux vaut ouvrir le dialog tout de suite avec "indisponible".
|
||
string notes = Strings.ReleaseNotesNone;
|
||
var manifestFromOvh = _manifestService.LastSource == ManifestSource.Ovh;
|
||
if (!string.IsNullOrEmpty(row.Remote.ReleaseNotesUrl) && manifestFromOvh)
|
||
{
|
||
try
|
||
{
|
||
notes = await _manifestService.FetchReleaseNotesAsync(row.Remote.ReleaseNotesUrl, ct);
|
||
}
|
||
catch (Exception ex) { _logger.LogWarning(ex, "Release notes fetch failed"); notes = Strings.ReleaseNotesUnavailable; }
|
||
}
|
||
else if (!string.IsNullOrEmpty(row.Remote.ReleaseNotesUrl))
|
||
{
|
||
_logger.LogInformation("Skipping release notes fetch (manifest source = {Source}, OVH probably unreachable)", _manifestService.LastSource);
|
||
notes = Strings.ReleaseNotesUnavailable;
|
||
}
|
||
|
||
// 2) Dialog de confirmation avec release notes
|
||
var dialog = new UpdateAvailableDialog(row.Remote, notes) { Owner = Application.Current.MainWindow };
|
||
dialog.ShowDialog();
|
||
if (!dialog.DownloadRequested) return;
|
||
preserveSaveGames = dialog.PreserveSaveGames;
|
||
}
|
||
|
||
// 3) Download — résolution d'URL en deux temps :
|
||
// A) Cache LAN : probe les peers (auto-découverts + manuels) ; si un
|
||
// d'eux a la version (taille + SHA-256 manifest match), DL depuis lui.
|
||
// Économise la bande passante OVH dans les setups multi-PCs.
|
||
// B) Sinon, fallback sur l'URL HMAC-signée serveur, puis sur l'URL
|
||
// publique du manifest si le endpoint /download-url n'est pas configuré.
|
||
row.State = VersionRowState.Downloading;
|
||
var peerSrc = await _peerResolver.TryResolveAsync(
|
||
row.Version, row.Remote.Download.SizeBytes, row.Remote.Download.Sha256, ct);
|
||
Uri url;
|
||
if (peerSrc is not null)
|
||
{
|
||
url = peerSrc.ZipUrl;
|
||
_currentPeerHost = peerSrc.PeerHost;
|
||
StatusMessage = Strings.StatusDownloadingFromPeer(peerSrc.PeerHost, row.Version);
|
||
}
|
||
else
|
||
{
|
||
_currentPeerHost = null;
|
||
var signed = await _licenseService.GetSignedDownloadUrlAsync(row.Version, ct);
|
||
var urlString = signed ?? row.Remote.Download.Url;
|
||
url = new Uri(urlString);
|
||
|
||
// ★ Détection serveur-side bug : si l'URL signée pointe vers un
|
||
// nom de fichier DIFFÉRENT de celui du manifest, on a un endpoint
|
||
// /download-url cassé (construit l'URL depuis un template hardcodé
|
||
// au lieu de lire le manifest). Continuer le DL est garanti de
|
||
// donner 404 → on remonte une erreur claire AVANT de DL 14 Go.
|
||
// Bug rapporté côté Asterion : DownloadUrl.php avant fix utilisait
|
||
// `proserve-{version}.zip` au lieu de basename(manifest.download.url).
|
||
if (signed is not null)
|
||
{
|
||
var signedFilename = Path.GetFileName(url.AbsolutePath);
|
||
var manifestFilename = Path.GetFileName(new Uri(row.Remote.Download.Url).AbsolutePath);
|
||
if (!string.Equals(signedFilename, manifestFilename, StringComparison.Ordinal))
|
||
{
|
||
_logger.LogError(
|
||
"Server-side bug : /download-url returned filename « {Signed} » but manifest references « {Manifest} ». Aborting DL — fix DownloadUrl.php server-side.",
|
||
signedFilename, manifestFilename);
|
||
throw new InvalidOperationException(
|
||
$"Incohérence serveur : l'endpoint /download-url retourne un nom de fichier différent du manifest. " +
|
||
$"Manifest : « {manifestFilename} ». Signé : « {signedFilename} ». " +
|
||
$"→ DownloadUrl.php côté serveur doit lire le filename depuis manifest.download.url, pas via un template hardcodé.");
|
||
}
|
||
}
|
||
}
|
||
// Diagnostic : log l'URL utilisée pour ce DL. Permet de comparer
|
||
// avec l'URL après un éventuel refresh manifest pour détecter le
|
||
// cas "le manifest serveur est aussi obsolète" (les deux URLs sont
|
||
// identiques alors qu'on attendait un changement).
|
||
_logger.LogInformation(
|
||
"Install v{Version}: download URL resolved to {Url} (source: {Source})",
|
||
row.Version, url, _currentPeerHost ?? "OVH");
|
||
// Callback de refresh d'URL : appelé par DownloadManager quand un segment
|
||
// reçoit 403/410 (URL HMAC expirée mid-DL). Pour un peer LAN l'URL n'expire
|
||
// jamais, on retourne juste la même. Pour OVH, on rappelle GetSignedDownloadUrl
|
||
// (le TTL serveur HMAC est de 6 h, suffit pour la plupart des DL mais pas pour
|
||
// les très lentes connexions ADSL).
|
||
async Task<Uri?> RefreshUrlAsync(CancellationToken c)
|
||
{
|
||
if (peerSrc is not null) return peerSrc.ZipUrl;
|
||
var fresh = await _licenseService.GetSignedDownloadUrlAsync(row.Version, c);
|
||
return fresh is null ? null : new Uri(fresh);
|
||
}
|
||
var job = new DownloadJob(row.Version, url, row.Remote.Download.SizeBytes, row.Remote.Download.Sha256)
|
||
{
|
||
HashAlgorithm = row.Remote.Download.HashAlgorithm,
|
||
RefreshUrlAsync = RefreshUrlAsync,
|
||
};
|
||
// Status « Préparation… » immédiat : entre le clic et la première réponse
|
||
// HEAD du serveur + pré-allocation du fichier 14 Go, il peut s'écouler
|
||
// 5-30 s silencieuses. On rassure l'utilisateur tout de suite ; le message
|
||
// sera remplacé automatiquement par « ⬇ v1.4.6 : … » dès le premier byte.
|
||
StatusMessage = Strings.StatusPreparingDownload(row.Version);
|
||
ProgressDetail = null;
|
||
ProgressPercent = 0;
|
||
row.ProgressDetail = null;
|
||
row.ProgressPercent = 0;
|
||
// Les progress callbacks ciblent _activeRow (résolution dynamique) plutôt que
|
||
// de capturer le local `row` par closure — sinon, si un RebuildList intervient
|
||
// en cours de DL (cf. OpenSettings/OpenLicense), l'instance row capturée serait
|
||
// orpheline et la barre per-row n'avancerait plus côté UI.
|
||
var dlProgress = new Progress<DownloadProgress>(p =>
|
||
{
|
||
if (_activeRow is { } target) UpdateDlProgress(target, p);
|
||
});
|
||
// Reporter dédié pour la phase verify SHA-256 (peut prendre 30s-3min sur 14 Go).
|
||
// On bascule la row active sur Verifying à la première remontée, ce qui change
|
||
// le badge de "⬇ Téléchargement…" vers "🔍 Vérification…" — les deux phases
|
||
// sont distinctes côté UX bien que `DownloadAsync` les enchaîne.
|
||
var hashProgress = new Progress<double>(pct =>
|
||
{
|
||
var target = _activeRow;
|
||
if (target is null) return;
|
||
if (target.State == VersionRowState.Downloading)
|
||
target.State = VersionRowState.Verifying;
|
||
var percent = (int)Math.Round(pct * 100.0);
|
||
ProgressPercent = percent;
|
||
target.ProgressPercent = percent;
|
||
ProgressDetail = Strings.ProgressVerifying(target.Version, percent);
|
||
target.ProgressDetail = ProgressDetail;
|
||
});
|
||
// NB: pas de StatusMessage = "Téléchargement…" ici — on garde « Préparation… »
|
||
// visible pendant la phase HEAD/SetLength, puis le footer passe directement
|
||
// au format progress « ⬇ v1.4.6 : X / 14 Go • Y Mo/s » dès le premier rapport.
|
||
var zipPath = await _downloadManager.DownloadAsync(job, dlProgress, hashProgress, ct);
|
||
|
||
// Promote au cache LAN IMMÉDIATEMENT après le DL (avant l'extract local).
|
||
// Pourquoi avant : sans ça, le sidecar SHA n'existe qu'après l'install complet
|
||
// (extract + redist + deploy ~ 20-60 s), pendant lesquelles les autres PCs
|
||
// qui probe /v1/has/{version} reçoivent 404 et tombent sur OVH inutilement.
|
||
// En promotant ici, dès que notre DL OVH est fini, on devient seed LAN pour
|
||
// les autres pendant que notre propre install local continue en parallèle.
|
||
try
|
||
{
|
||
await _zipCacheStore.PromoteAsync(zipPath, row.Version, row.Remote.Download.Sha256, ct);
|
||
var cachedNow = _zipCacheStore.ListCached().Select(c => c.Version).ToList();
|
||
_logger.LogInformation("LAN cache after DL (pre-install) : {Count} version(s) [{Versions}]",
|
||
cachedNow.Count, string.Join(", ", cachedNow));
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_logger.LogWarning(ex, "ZIP cache promote failed (non-critical, LAN serving will lag until next install)");
|
||
}
|
||
|
||
// 4) Install
|
||
row.State = VersionRowState.Installing;
|
||
StatusMessage = Strings.StatusInstallingVersion(row.Version);
|
||
var folderName = row.Remote.GetInstallFolderName();
|
||
var target = Path.Combine(_config.InstallRoot, folderName);
|
||
var installProgress = new Progress<InstallProgress>(ip =>
|
||
{
|
||
if (ip.BytesTotal <= 0) return;
|
||
var pct = (double)ip.BytesDone / ip.BytesTotal * 100.0;
|
||
ProgressPercent = pct;
|
||
row.ProgressPercent = pct;
|
||
ProgressDetail = Strings.ProgressExtraction(row.Version, ip.EntriesDone, ip.EntriesTotal, FormatSize(ip.BytesDone), FormatSize(ip.BytesTotal));
|
||
row.ProgressDetail = ProgressDetail;
|
||
});
|
||
await _zipInstaller.InstallAsync(zipPath, target, installProgress, ct);
|
||
|
||
// Persiste le nom du .exe à lancer dans le dossier d'install fraîchement
|
||
// extrait. InstallationRegistry.Scan() le lira pour résoudre l'exe sans
|
||
// avoir besoin du manifest en mémoire (cas démarrage offline / pré-check).
|
||
// Le nom vient du manifest serveur (champ "executable" par version),
|
||
// configurable dans le backoffice.
|
||
try
|
||
{
|
||
if (!string.IsNullOrWhiteSpace(row.Remote.Executable))
|
||
await _registry.WriteInstallMetadataAsync(target, row.Remote.Executable, ct);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_logger.LogWarning(ex, "Failed to write install metadata (will fall back to glob detection)");
|
||
}
|
||
|
||
// Préservation des savegames — copie les .sav (PROSERVE_UE_*/Saved/SaveGames/*)
|
||
// depuis la version installée la plus récente AUTRE que celle qu'on vient
|
||
// d'installer vers le nouveau dossier d'install. Best-effort : si la
|
||
// copie échoue (handle verrouillé, accès refusé), on log un warn mais on
|
||
// ne bloque pas l'install — l'utilisateur peut copier manuellement.
|
||
// L'option est cochée par défaut dans le dialog mais l'utilisateur peut
|
||
// la décocher (cas : premier install propre, ou release qui invalide
|
||
// explicitement les anciennes saves).
|
||
if (preserveSaveGames)
|
||
{
|
||
try
|
||
{
|
||
await CopyPreviousSaveGamesAsync(target, row.Version, ct);
|
||
}
|
||
catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; }
|
||
catch (Exception ex)
|
||
{
|
||
_logger.LogWarning(ex, "Save games copy threw an unhandled exception (continuing install)");
|
||
}
|
||
}
|
||
else
|
||
{
|
||
_logger.LogInformation("Save games preservation disabled by user for v{Version}", row.Version);
|
||
}
|
||
|
||
// Installation des redists Unreal (VC_redist, UEPrereqSetup, etc.) si la
|
||
// version contient un dossier `_redist/`. Run silent + UAC élévation par
|
||
// installer. Track via metadata pour ne pas re-runner à chaque réinstall.
|
||
// Si un installer fail (already-installed retournent souvent un exit code
|
||
// non-zero), on log mais on continue — l'install PROSERVE ne doit pas être
|
||
// bloquée par un redist mineur. Wrappé en try/catch : si une exception
|
||
// non gérée fuit (Process.Start qui throw bizarre, UAC denied catastrophique,
|
||
// …), on ne doit SURTOUT PAS skip le PromoteAsync ci-dessous, sinon les
|
||
// peers LAN ne verraient pas la version.
|
||
try
|
||
{
|
||
await InstallRedistsAsync(target, row.Version, ct);
|
||
}
|
||
catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; }
|
||
catch (Exception ex)
|
||
{
|
||
_logger.LogError(ex, "Redists install threw an unhandled exception (continuing to cache promote)");
|
||
}
|
||
|
||
// Prune les vieilles versions selon MaxCachedVersions (le Promote a déjà
|
||
// eu lieu juste après le DL, voir bloc plus haut). On prune ici post-install
|
||
// pour que les versions encore installées soient protégées (la version qu'on
|
||
// vient d'installer compte comme "encore installée").
|
||
try
|
||
{
|
||
await _zipCacheStore.PruneAsync(_config.LanCache.MaxCachedVersions, ct);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_logger.LogWarning(ex, "ZIP cache prune failed (non-critical)");
|
||
}
|
||
|
||
// Merge SteamVR settings : si _steamvr/steamvr.vrsettings est bundled
|
||
// dans le ZIP, tue SteamVR + Vive Business Streaming (qui le relance
|
||
// sinon) et fusionne les blocs racine dans le fichier de l'utilisateur.
|
||
// Best-effort : si SteamVR n'est pas installé ou si le merge échoue,
|
||
// on log mais on ne bloque pas l'install PROSERVE — le jeu peut tourner
|
||
// sans, juste sans le mapping tracker custom.
|
||
try
|
||
{
|
||
// ÉTAPE 1 — DRY-RUN : on commence par déterminer si un vrai
|
||
// merge changerait QUOI QUE CE SOIT. Aucun processus tué à ce
|
||
// stade, aucun fichier touché. Bénéfice : sur un PC où la conf
|
||
// SteamVR est déjà conforme à la release (cas typique d'une
|
||
// ré-install ou d'un PC mis-à-jour récemment), on saute TOUT
|
||
// le processus de fermeture sans déranger l'opérateur.
|
||
var check = await _steamVrDeployer.CheckMergeNeededAsync(target, ct);
|
||
switch (check.Outcome)
|
||
{
|
||
case SteamVrMergeCheckOutcome.NoSourceFile:
|
||
_logger.LogDebug("No _steamvr/ in v{Version} ZIP, SteamVR settings unchanged", row.Version);
|
||
goto AfterSteamVrMerge;
|
||
case SteamVrMergeCheckOutcome.DisabledByConfig:
|
||
_logger.LogInformation("SteamVR merge skipped for v{Version} (disabled in config)", row.Version);
|
||
goto AfterSteamVrMerge;
|
||
case SteamVrMergeCheckOutcome.SteamNotFound:
|
||
_logger.LogInformation(
|
||
"SteamVR merge skipped for v{Version}: Steam not found on this machine",
|
||
row.Version);
|
||
goto AfterSteamVrMerge;
|
||
case SteamVrMergeCheckOutcome.UpToDate:
|
||
// ★ Cas optimal : la conf est déjà alignée → on ne ferme
|
||
// PAS SteamVR/VBS, on ne montre PAS de popup, on log juste.
|
||
_logger.LogInformation(
|
||
"SteamVR settings already up-to-date for v{Version} (target: {Path}) — skipping kill+merge",
|
||
row.Version, check.TargetPath);
|
||
goto AfterSteamVrMerge;
|
||
case SteamVrMergeCheckOutcome.InvalidJson:
|
||
ThemedMessageBox.Show(
|
||
Strings.MsgSteamVrMergeFailed(check.ErrorMessage ?? "?"),
|
||
Strings.MsgBoxSteamVrMergeFailed,
|
||
MessageBoxButton.OK, MessageBoxImage.Warning);
|
||
goto AfterSteamVrMerge;
|
||
case SteamVrMergeCheckOutcome.Needed:
|
||
// On continue vers le pop-up + kill + merge.
|
||
break;
|
||
}
|
||
|
||
// ÉTAPE 2 — Construit la liste des processus à tuer. Source :
|
||
// les health checks de type "Process" configurés par l'opérateur.
|
||
// C'est lui qui sait quelle VR runtime tourne sur la machine ;
|
||
// on lui fait confiance plutôt que de hardcoder une liste obsolète.
|
||
//
|
||
// Sécurité : on s'assure que VIVE Business Streaming et SteamVR
|
||
// sont DANS la liste (sinon le merge échouera car SteamVR garde
|
||
// son fichier ouvert). On dédup case-insensitive et on ordonne
|
||
// VBS en tête (autres "guardians" qui relancent SteamVR aussi).
|
||
var killList = BuildSteamVrProcessKillList();
|
||
|
||
// ÉTAPE 3 — Pré-flight : popup d'avertissement avec liste des
|
||
// programmes à fermer + nombre de blocs qui changeront.
|
||
if (killList.Count > 0)
|
||
{
|
||
var listPreview = string.Join("\n • ", killList.Take(10));
|
||
if (killList.Count > 10) listPreview += $"\n • … (+{killList.Count - 10})";
|
||
var ok = ThemedMessageBox.Show(
|
||
Strings.MsgSteamVrPreFlightBody(listPreview, check.RootKeysWouldChange),
|
||
Strings.MsgSteamVrPreFlightTitle,
|
||
MessageBoxButton.OKCancel, MessageBoxImage.Information,
|
||
MessageBoxResult.OK);
|
||
if (ok != MessageBoxResult.OK)
|
||
{
|
||
// Annulé par l'opérateur : on skip le merge mais on
|
||
// continue l'install (les blocs SteamVR ne seront pas
|
||
// mis à jour ; l'opérateur le sait).
|
||
_logger.LogInformation(
|
||
"User cancelled SteamVR merge phase, skipping merge but continuing install");
|
||
goto AfterSteamVrMerge;
|
||
}
|
||
}
|
||
|
||
StatusMessage = Strings.StatusDeployingSteamVr(row.Version);
|
||
ProgressDetail = null;
|
||
ProgressPercent = 0;
|
||
var svResult = await _steamVrDeployer.MergeAsync(target, killList, ct);
|
||
switch (svResult.Status)
|
||
{
|
||
case SteamVrMergeStatus.Merged:
|
||
_logger.LogInformation(
|
||
"SteamVR merge for v{Version}: replaced={Replaced}, added={Added} (target: {Path})",
|
||
row.Version, svResult.KeysReplaced, svResult.KeysAdded, svResult.TargetPath);
|
||
break;
|
||
case SteamVrMergeStatus.SkippedNoSourceFile:
|
||
_logger.LogDebug("No _steamvr/ in v{Version} ZIP, SteamVR settings unchanged", row.Version);
|
||
break;
|
||
case SteamVrMergeStatus.SkippedDisabledByConfig:
|
||
_logger.LogInformation("SteamVR merge skipped for v{Version} (disabled in config)", row.Version);
|
||
break;
|
||
case SteamVrMergeStatus.SteamNotFound:
|
||
// Pas de popup — beaucoup de machines de dev n'ont pas Steam,
|
||
// pas la peine de spammer l'opérateur.
|
||
_logger.LogInformation(
|
||
"SteamVR merge skipped for v{Version}: Steam not found on this machine",
|
||
row.Version);
|
||
break;
|
||
case SteamVrMergeStatus.InvalidJson:
|
||
case SteamVrMergeStatus.Failed:
|
||
// Popup quand-même : c'est probablement un bug à investiguer
|
||
// (ZIP corrompu, fichier SteamVR endommagé manuellement, etc.).
|
||
ThemedMessageBox.Show(
|
||
Strings.MsgSteamVrMergeFailed(svResult.ErrorMessage ?? "?"),
|
||
Strings.MsgBoxSteamVrMergeFailed,
|
||
MessageBoxButton.OK, MessageBoxImage.Warning);
|
||
break;
|
||
}
|
||
}
|
||
catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; }
|
||
catch (Exception ex)
|
||
{
|
||
_logger.LogError(ex, "SteamVR merge threw an unhandled exception (continuing install)");
|
||
}
|
||
AfterSteamVrMerge:
|
||
|
||
// 5) Migrations DB MySQL bundled dans _migrations/. Skip si la config
|
||
// a désactivé l'auto-apply ou si le ZIP ne contient pas de répertoire.
|
||
if (_config.Database.AutoApplyMigrations)
|
||
{
|
||
StatusMessage = Strings.StatusApplyingMigrations(row.Version);
|
||
ProgressDetail = null;
|
||
ProgressPercent = 0;
|
||
var migProgress = new Progress<MigrationProgress>(mp =>
|
||
{
|
||
if (mp.Total > 0)
|
||
{
|
||
var pct = (double)mp.Done / mp.Total * 100.0;
|
||
ProgressPercent = pct;
|
||
row.ProgressPercent = pct;
|
||
}
|
||
var label = string.IsNullOrEmpty(mp.CurrentFilename)
|
||
? Strings.StatusApplyingMigrations(row.Version)
|
||
: Strings.ProgressMigration(mp.Done + 1, mp.Total, mp.CurrentFilename);
|
||
ProgressDetail = label;
|
||
row.ProgressDetail = label;
|
||
});
|
||
var migResult = await _migrationService.ApplyMigrationsAsync(target, migProgress, ct);
|
||
if (!migResult.AllSucceeded)
|
||
{
|
||
// L'install a réussi côté fichiers ; on garde la version installée mais on
|
||
// notifie l'utilisateur que la DB n'a pas été migrée — la prochaine relance
|
||
// de l'install (ou un Settings → "Rejouer migrations") tentera à nouveau.
|
||
_logger.LogError("DB migration failed for v{Version}: {Detail}", row.Version, migResult.FailureMessage);
|
||
ThemedMessageBox.Show(
|
||
Strings.MsgMigrationFailed(migResult.FailureMessage ?? "?"),
|
||
Strings.MsgBoxMigrationFailed,
|
||
MessageBoxButton.OK, MessageBoxImage.Warning);
|
||
}
|
||
else if (migResult.AppliedCount > 0)
|
||
{
|
||
_logger.LogInformation("Applied {N} DB migration(s) for v{Version}", migResult.AppliedCount, row.Version);
|
||
}
|
||
}
|
||
|
||
// 6) Déploiement de l'outil Report (page web XAMPP).
|
||
// Cherche _report/ dans le ZIP extrait et copie vers htdocs en
|
||
// atomic rename. Skip silencieux si _report/ absent du ZIP.
|
||
if (_config.ReportTool.AutoDeploy)
|
||
{
|
||
StatusMessage = Strings.StatusDeployingReport(row.Version);
|
||
ProgressDetail = null;
|
||
ProgressPercent = 0;
|
||
var rdProgress = 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.ProgressReportDeploy(dp.FilesDone, dp.FilesTotal, dp.CurrentFilename);
|
||
ProgressDetail = label;
|
||
row.ProgressDetail = label;
|
||
});
|
||
var rdResult = await _reportDeployer.DeployAsync(target, rdProgress, ct);
|
||
switch (rdResult.Status)
|
||
{
|
||
case DeployStatus.Deployed:
|
||
_logger.LogInformation("Report tool deployed ({N} files) for v{Version}", rdResult.FilesDeployed, row.Version);
|
||
break;
|
||
case DeployStatus.SkippedNoSourceFolder:
|
||
_logger.LogInformation("No _report/ in v{Version} ZIP, report tool unchanged", row.Version);
|
||
break;
|
||
case DeployStatus.XamppNotFound:
|
||
ThemedMessageBox.Show(
|
||
Strings.MsgReportXamppNotFound(_config.ReportTool.HtdocsRoot),
|
||
Strings.MsgBoxReportDeployFailed,
|
||
MessageBoxButton.OK, MessageBoxImage.Warning);
|
||
break;
|
||
case DeployStatus.Failed:
|
||
ThemedMessageBox.Show(
|
||
Strings.MsgReportDeployFailed(rdResult.ErrorMessage ?? "?"),
|
||
Strings.MsgBoxReportDeployFailed,
|
||
MessageBoxButton.OK, MessageBoxImage.Warning);
|
||
break;
|
||
}
|
||
}
|
||
|
||
// 7) Déploiement de l'outil Documentation (parallèle à Report).
|
||
// Cherche _doc/ dans le ZIP extrait et copie vers htdocs en
|
||
// atomic rename. Skip silencieux si _doc/ absent du ZIP.
|
||
if (_config.DocTool.AutoDeploy)
|
||
{
|
||
StatusMessage = Strings.StatusDeployingDoc(row.Version);
|
||
ProgressDetail = null;
|
||
ProgressPercent = 0;
|
||
var ddProgress = 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.ProgressDocDeploy(dp.FilesDone, dp.FilesTotal, dp.CurrentFilename);
|
||
ProgressDetail = label;
|
||
row.ProgressDetail = label;
|
||
});
|
||
var ddResult = await _docDeployer.DeployAsync(target, ddProgress, ct);
|
||
switch (ddResult.Status)
|
||
{
|
||
case DeployStatus.Deployed:
|
||
_logger.LogInformation("Doc tool deployed ({N} files) for v{Version}", ddResult.FilesDeployed, row.Version);
|
||
break;
|
||
case DeployStatus.SkippedNoSourceFolder:
|
||
_logger.LogInformation("No _doc/ in v{Version} ZIP, doc tool unchanged", row.Version);
|
||
break;
|
||
case DeployStatus.XamppNotFound:
|
||
// Le warning Report a déjà été affiché si nécessaire ; on reste silencieux ici
|
||
// (même cause = même symptôme, inutile de faire 2 popups identiques).
|
||
_logger.LogWarning("Doc tool deploy : XAMPP htdocs not found at {Root}", _config.DocTool.HtdocsRoot);
|
||
break;
|
||
case DeployStatus.Failed:
|
||
ThemedMessageBox.Show(
|
||
Strings.MsgDocDeployFailed(ddResult.ErrorMessage ?? "?"),
|
||
Strings.MsgBoxDocDeployFailed,
|
||
MessageBoxButton.OK, MessageBoxImage.Warning);
|
||
break;
|
||
}
|
||
}
|
||
|
||
// 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;
|
||
}
|
||
}
|
||
|
||
// Cleanup explicite de l'ancien row instance AVANT le RebuildList.
|
||
// Sans ça :
|
||
// 1. `row.State` reste à `Installing` (jamais resété depuis le step 4
|
||
// ligne ~1260) → si un binding survit sur cette instance le temps
|
||
// que WPF rafraîchisse OtherVersions.Clear()+Add(), l'utilisateur
|
||
// voit brièvement (ou durablement, cf. bug rapporté) une row
|
||
// « en cours d'installation » à côté du toast "✓ installée".
|
||
// 2. `_activeRow` pointe encore sur cette OLD instance, ce qui
|
||
// maintient `HasActiveDownload` truthy le temps que `finally`
|
||
// tourne — pas grave en pratique mais pas propre.
|
||
// Le RebuildList va de toute façon créer une NOUVELLE instance
|
||
// `ForInstalled(...)` pour cette version puisque le scan disque la
|
||
// détecte maintenant. Cet edit purge l'ancienne instance avant que
|
||
// les bindings puissent s'y raccrocher.
|
||
row.State = VersionRowState.InstalledIdle;
|
||
row.ProgressPercent = 0;
|
||
row.ProgressDetail = null;
|
||
_activeRow = null;
|
||
|
||
StatusMessage = Strings.StatusInstalledSuccess(row.Version);
|
||
ProgressDetail = null;
|
||
ProgressPercent = 0;
|
||
// Reset le flag retry — un futur install (même version ou autre)
|
||
// a droit à sa propre tentative de retry sur 404.
|
||
_install404RetryConsumed = false;
|
||
RebuildList();
|
||
_toastService.ShowSuccess(
|
||
Strings.ToastInstallSuccessTitle,
|
||
Strings.ToastInstallSuccessBody(row.Version));
|
||
}
|
||
catch (OperationCanceledException)
|
||
{
|
||
StatusMessage = Strings.StatusDownloadCancelled;
|
||
row.State = VersionRowState.AvailableIdle;
|
||
row.ProgressDetail = null;
|
||
row.ProgressPercent = 0;
|
||
// Rafraîchit le pourcentage de reprise depuis le state.json sauvegardé
|
||
// au moment du cancel. Sinon le bouton « Reprendre (X%) » garderait la
|
||
// valeur d'avant le DL (souvent 0% ou l'ancien %).
|
||
RefreshResumableBytes(row);
|
||
}
|
||
catch (InvalidDataException shaEx) when (shaEx.Message.Contains("Checksum SHA-256", StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
// Cas spécifique : SHA-256 mismatch après DL. Causes typiques :
|
||
// • OVH (CDN) sert un ZIP obsolète parce que le cache n'a pas
|
||
// encore expiré (TTL 24h-72h) suite à un re-build serveur
|
||
// • Un peer LAN sert un ZIP dont le sidecar est corrompu/menteur
|
||
// • Le manifest a été modifié manuellement sans re-build du ZIP
|
||
//
|
||
// Actions automatiques :
|
||
// 1. Purge le cache local pour cette version (le ZIP que NOUS
|
||
// avions cached avant cette tentative pourrait être obsolète
|
||
// lui aussi ; mieux vaut tout vider pour ne pas servir du
|
||
// contenu pourri aux autres peers du LAN)
|
||
// 2. Affiche un message d'erreur SPÉCIFIQUE avec la source du
|
||
// DL (peer LAN ou OVH) pour aider l'opérateur à diagnostiquer
|
||
// 3. Suggère le menu "↻ Forcer re-téléchargement" pour réessayer
|
||
// après que le cache CDN OVH ait expiré
|
||
_logger.LogError(shaEx,
|
||
"SHA-256 verify failed for v{Version} (source: {Source})",
|
||
row.Version, _currentPeerHost ?? "OVH");
|
||
row.State = VersionRowState.AvailableIdle;
|
||
row.ProgressDetail = null;
|
||
row.ProgressPercent = 0;
|
||
try
|
||
{
|
||
await _zipCacheStore.InvalidateAsync(row.Version, CancellationToken.None);
|
||
}
|
||
catch (Exception purgeEx)
|
||
{
|
||
_logger.LogWarning(purgeEx, "Could not purge LAN cache for v{Version} after SHA mismatch", row.Version);
|
||
}
|
||
var sourceLabel = _currentPeerHost is null
|
||
? Strings.SourceOvh
|
||
: Strings.SourcePeer(_currentPeerHost);
|
||
// Extrait les deux SHAs depuis le message de l'exception (format :
|
||
// "Checksum SHA-256 invalide : attendu X, calculé Y") — permet à
|
||
// l'opérateur de comparer ce qui est attendu (manifest signé) vs ce
|
||
// qui a été effectivement téléchargé (= ce que la source a servi).
|
||
// Si parse échoue, on tombe en fallback avec "?" — le message a
|
||
// toujours du sens grâce au texte explicatif.
|
||
var (expectedSha, computedSha) = ParseShaMismatchValues(shaEx.Message);
|
||
var body = Strings.MsgShaMismatchBody(row.Version, sourceLabel, expectedSha, computedSha);
|
||
ThemedMessageBox.Show(body, Strings.MsgBoxShaMismatch,
|
||
MessageBoxButton.OK, MessageBoxImage.Warning);
|
||
StatusMessage = Strings.StatusShaMismatch(row.Version);
|
||
_toastService.ShowError(Strings.ToastInstallErrorTitle(row.Version), Strings.MsgBoxShaMismatch);
|
||
RefreshResumableBytes(row);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_logger.LogError(ex, "Install failed for {Version}", row.Version);
|
||
row.State = VersionRowState.AvailableIdle;
|
||
row.ProgressDetail = null;
|
||
row.ProgressPercent = 0;
|
||
|
||
// Cas spécifique HTTP 404 : le fichier source a été renommé/supprimé
|
||
// sur le serveur (typiquement après un re-upload pour buster le cache
|
||
// CDN OVH). Le manifest local référence l'ancienne URL → la DL fail
|
||
// en 404.
|
||
//
|
||
// Stratégie en 2 temps :
|
||
// 1. Premier 404 → auto-refresh du manifest + AUTO-RETRY install
|
||
// transparent (l'opérateur ne voit rien d'autre qu'un message
|
||
// "rafraîchissement…" puis la nouvelle DL qui démarre).
|
||
// 2. Deuxième 404 après retry → le manifest serveur ELLE-MÊME
|
||
// est obsolète (le rename a été fait au filesystem sans MAJ
|
||
// du manifest serveur). Message clair pour que l'opérateur
|
||
// sache que c'est un problème côté serveur, pas client.
|
||
bool is404 = ex.Message.Contains("HTTP 404", StringComparison.OrdinalIgnoreCase)
|
||
|| ex.Message.Contains("404 on segment", StringComparison.OrdinalIgnoreCase);
|
||
if (is404 && !_install404RetryConsumed)
|
||
{
|
||
// PREMIER 404 : on tente le refresh + retry une fois.
|
||
_install404RetryConsumed = true;
|
||
_logger.LogInformation(
|
||
"404 detected for v{Version} (first attempt) — auto-refreshing manifest then retrying install",
|
||
row.Version);
|
||
|
||
// Capture l'URL actuelle pour diagnostic (avant refresh).
|
||
var urlBefore = row.Remote?.Download.Url;
|
||
|
||
bool refreshOk = false;
|
||
try
|
||
{
|
||
await CheckForUpdatesAsync();
|
||
refreshOk = true;
|
||
}
|
||
catch (Exception refreshEx)
|
||
{
|
||
_logger.LogWarning(refreshEx, "Auto-refresh manifest after 404 failed");
|
||
}
|
||
|
||
// Récupère la row fraîche depuis le rebuild (l'instance peut
|
||
// avoir changé) pour relire la nouvelle URL du manifest.
|
||
var freshRow = (FeaturedVersion?.Version == row.Version ? FeaturedVersion : null)
|
||
?? OtherVersions.FirstOrDefault(r => r.Version == row.Version);
|
||
var urlAfter = freshRow?.Remote?.Download.Url;
|
||
_logger.LogInformation(
|
||
"Manifest URL for v{Version} — before: {Before}, after refresh: {After}",
|
||
row.Version, urlBefore, urlAfter);
|
||
|
||
if (refreshOk && freshRow is not null && freshRow.Remote is not null
|
||
&& !string.Equals(urlBefore, urlAfter, StringComparison.Ordinal))
|
||
{
|
||
// L'URL a changé → le manifest s'est bien rafraîchi avec une
|
||
// nouvelle URL pointant vers le fichier renommé. Auto-retry
|
||
// l'install. On déclenche via la même mécanique que le clic
|
||
// Install (SafeInstallAsync) pour bénéficier du try/catch
|
||
// global et de la mécanique standard.
|
||
StatusMessage = Strings.StatusAutoRetryAfter404(row.Version);
|
||
// ⚠ on libère les flags du finally en avance pour que le
|
||
// retry puisse re-prendre _activeRow / IsBusy. Le finally
|
||
// s'exécutera quand même mais idempotent (déjà null).
|
||
_activeDownloadCts?.Dispose();
|
||
_activeDownloadCts = null;
|
||
_activeRow = null;
|
||
_activeInstallTask = null;
|
||
_currentPeerHost = null;
|
||
IsBusy = false;
|
||
IsDownloadActive = false;
|
||
// Fire-and-forget : Polly et SafeInstallAsync gèrent les exceptions.
|
||
_activeInstallTask = SafeInstallAsync(freshRow);
|
||
return;
|
||
}
|
||
|
||
// URL identique après refresh OU refresh échoué → tomber dans
|
||
// le message "manifest serveur stale" (cas 2).
|
||
_logger.LogWarning(
|
||
"Manifest refresh did not produce a new URL for v{Version} — server-side manifest is also stale",
|
||
row.Version);
|
||
ThemedMessageBox.Show(
|
||
Strings.MsgServerSideStaleBody(row.Version),
|
||
Strings.MsgBoxStaleManifest,
|
||
MessageBoxButton.OK, MessageBoxImage.Warning);
|
||
StatusMessage = Strings.StatusManifestRefreshed;
|
||
}
|
||
else if (is404)
|
||
{
|
||
// DEUXIÈME 404 dans la même session install (déjà retried).
|
||
// On reset le flag pour que la prochaine action user manuelle
|
||
// (autre clic Install) reparte avec une tentative de retry fraîche.
|
||
_install404RetryConsumed = false;
|
||
ThemedMessageBox.Show(
|
||
Strings.MsgServerSideStaleBody(row.Version),
|
||
Strings.MsgBoxStaleManifest,
|
||
MessageBoxButton.OK, MessageBoxImage.Warning);
|
||
StatusMessage = Strings.StatusManifestRefreshed;
|
||
}
|
||
// Cas spécifique HTTP 416 : range demandé hors du fichier. Le ZIP
|
||
// sur le serveur est plus PETIT que ce que le manifest annonce
|
||
// (sizeBytes). Causes typiques :
|
||
// • Upload SFTP tronqué (coupé en cours) → le ZIP fait p.ex. 7 Go
|
||
// au lieu des 13 Go attendus, le manifest a la vieille taille
|
||
// • Le manifest n'a pas été re-synchronisé après un rebuild
|
||
// (nouvelle taille pas re-hashée → sizeBytes obsolète)
|
||
//
|
||
// On AUTO-PURGE le cache local (state.json + partial + LAN cache
|
||
// .zip + sidecar) pour éviter qu'un partial à mi-route reste en
|
||
// ligne et garbage les futurs retries. L'opérateur reçoit un message
|
||
// d'erreur explicite avec les tailles, et l'action concrète à faire
|
||
// côté serveur (re-upload + re-sync manifest).
|
||
else if (ex.Message.Contains("HTTP 416", StringComparison.OrdinalIgnoreCase)
|
||
|| ex.Message.Contains("416 on segment", StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
_logger.LogError(
|
||
"416 detected during install of v{Version} — file size mismatch server vs manifest",
|
||
row.Version);
|
||
try
|
||
{
|
||
_downloadManager.DiscardResumableState(row.Version);
|
||
await _zipCacheStore.InvalidateAsync(row.Version, CancellationToken.None);
|
||
}
|
||
catch (Exception purgeEx)
|
||
{
|
||
_logger.LogWarning(purgeEx, "Could not purge after 416 for v{Version}", row.Version);
|
||
}
|
||
ThemedMessageBox.Show(
|
||
ex.Message,
|
||
Strings.MsgBoxSizeMismatch,
|
||
MessageBoxButton.OK, MessageBoxImage.Warning);
|
||
StatusMessage = Strings.StatusSizeMismatch(row.Version);
|
||
}
|
||
else
|
||
{
|
||
ThemedMessageBox.Show(
|
||
Strings.MsgInstallFailed(ex.Message),
|
||
Strings.MsgBoxError, MessageBoxButton.OK, MessageBoxImage.Error);
|
||
StatusMessage = Strings.StatusError(ex.Message);
|
||
}
|
||
_toastService.ShowError(Strings.ToastInstallErrorTitle(row.Version), ex.Message);
|
||
// Idem : si une erreur réseau interrompt le DL en cours de route, le
|
||
// partial est plus gros qu'avant — il faut refléter le nouveau %.
|
||
RefreshResumableBytes(row);
|
||
}
|
||
finally
|
||
{
|
||
_activeDownloadCts?.Dispose();
|
||
_activeDownloadCts = null;
|
||
_activeRow = null;
|
||
_activeInstallTask = null;
|
||
_currentPeerHost = null;
|
||
IsBusy = false;
|
||
IsDownloadActive = false;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Si <c>_redist/</c> existe dans le dossier d'install, lance tous les .exe
|
||
/// dedans en silent + UAC élevé pour installer les redists Unreal (VC_redist,
|
||
/// UEPrereqSetup, etc.). Skip si déjà fait (tracé dans .proserve-meta.json).
|
||
/// Best-effort : on log mais on ne fail pas l'install si un redist plante
|
||
/// (typiquement les "already installed" retournent un exit code non-zero).
|
||
/// </summary>
|
||
private async Task InstallRedistsAsync(string installDir, string version, CancellationToken ct)
|
||
{
|
||
var redistDir = Path.Combine(installDir, "_redist");
|
||
if (!Directory.Exists(redistDir))
|
||
{
|
||
_logger.LogDebug("No _redist/ in v{Version}, skipping redists install", version);
|
||
return;
|
||
}
|
||
if (_registry.IsRedistInstalled(installDir))
|
||
{
|
||
_logger.LogInformation("Redists already installed for v{Version} (per metadata), skipping", version);
|
||
return;
|
||
}
|
||
|
||
var exes = Directory.GetFiles(redistDir, "*.exe", SearchOption.TopDirectoryOnly);
|
||
if (exes.Length == 0)
|
||
{
|
||
_logger.LogDebug("_redist/ exists but contains no .exe, skipping");
|
||
return;
|
||
}
|
||
Array.Sort(exes, StringComparer.OrdinalIgnoreCase);
|
||
|
||
StatusMessage = Strings.StatusInstallingRedists(version);
|
||
ProgressDetail = null;
|
||
for (int i = 0; i < exes.Length; i++)
|
||
{
|
||
ct.ThrowIfCancellationRequested();
|
||
var exe = exes[i];
|
||
var name = Path.GetFileName(exe);
|
||
ProgressDetail = Strings.ProgressRedistInstalling(i + 1, exes.Length, name);
|
||
_logger.LogInformation("Running redist {Idx}/{Total} : {Exe}", i + 1, exes.Length, name);
|
||
try
|
||
{
|
||
// Silent flags qui couvrent VC_redist (/install /quiet /norestart) et
|
||
// UEPrereqSetup (/install /quiet /norestart). MSI std accepte aussi.
|
||
// UseShellExecute=true + Verb=runas → UAC popup pour élévation admin.
|
||
var psi = new System.Diagnostics.ProcessStartInfo
|
||
{
|
||
FileName = exe,
|
||
Arguments = "/install /quiet /norestart",
|
||
UseShellExecute = true,
|
||
Verb = "runas",
|
||
CreateNoWindow = true,
|
||
};
|
||
using var proc = System.Diagnostics.Process.Start(psi);
|
||
if (proc is null) continue;
|
||
await proc.WaitForExitAsync(ct).ConfigureAwait(false);
|
||
_logger.LogInformation("Redist {Name} exit code {Code}", name, proc.ExitCode);
|
||
// Codes typiques :
|
||
// 0 = succès
|
||
// 3010 = succès mais reboot requis (on ne fait pas, le user gérera)
|
||
// 1638 = "already installed, newer version" — OK, on continue
|
||
// 1602/1603 = user cancel UAC ou install error — log mais continue
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_logger.LogWarning(ex, "Redist {Name} install failed (continuing)", name);
|
||
}
|
||
}
|
||
|
||
try
|
||
{
|
||
await _registry.MarkRedistInstalledAsync(installDir, ct).ConfigureAwait(false);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_logger.LogWarning(ex, "Failed to mark redist installed in metadata");
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Liste des sous-dossiers <c>PROSERVE_UE_*/Saved/{sub}/</c> à préserver
|
||
/// entre versions, avec le glob filtre par sous-dossier. La copie est
|
||
/// récursive (<c>AllDirectories</c>) et non destructive (les fichiers
|
||
/// déjà présents dans la nouvelle install ne sont jamais écrasés).
|
||
///
|
||
/// <list type="bullet">
|
||
/// <item><b>SaveGames / *.sav</b> — profils Unreal, progression
|
||
/// d'entraînement, données opérateur.</item>
|
||
/// <item><b>Demos / *.replay</b> — replays Unreal (enregistrements de
|
||
/// session, extension <c>.replay</c> dans PROSERVE). Précieux pour le
|
||
/// debriefing post-formation.</item>
|
||
/// </list>
|
||
/// </summary>
|
||
private static readonly (string SubDir, string Glob)[] PreservedSavedSubdirs =
|
||
{
|
||
("SaveGames", "*.sav"),
|
||
("Demos", "*.replay"),
|
||
};
|
||
|
||
/// <summary>
|
||
/// Copie les fichiers utilisateur (.sav + .demo) depuis les sous-dossiers
|
||
/// <c>PROSERVE_UE_*/Saved/SaveGames/</c> et <c>PROSERVE_UE_*/Saved/Demos/</c>
|
||
/// de la version installée la plus récente (autre que celle qu'on vient
|
||
/// d'installer) vers le même chemin relatif dans le nouveau dossier d'install.
|
||
///
|
||
/// Cas d'usage : l'opérateur installe v1.5.5 par-dessus v1.5.4 — sans ça, les
|
||
/// .sav Unreal (progression d'entraînement, profils) et les .demo (replays
|
||
/// de session) de v1.5.4 ne seraient pas visibles depuis v1.5.5 car ces
|
||
/// fichiers vivent dans le dossier d'install du jeu, pas dans %LocalAppData%.
|
||
/// Activé par défaut (case cochée dans <see cref="UpdateAvailableDialog"/>),
|
||
/// décochable.
|
||
///
|
||
/// Détails :
|
||
/// <list type="bullet">
|
||
/// <item>Source = version installée au plus haut SemVer, autre que
|
||
/// <paramref name="newVersion"/>. Si l'opérateur fait une réinstall ou un
|
||
/// downgrade, on copie quand même depuis la plus récente DISPONIBLE.</item>
|
||
/// <item>Dossier projet UE détecté via le nom de l'exe
|
||
/// (<c>PROSERVE_UE_5_7.exe</c> → <c>PROSERVE_UE_5_7/</c>). Compatible avec
|
||
/// les passages d'une version UE à l'autre (UE_5_5 → UE_5_7) — on copie
|
||
/// du dossier UE source vers le dossier UE cible (renommage transparent).</item>
|
||
/// <item>Mode COPIE NON DESTRUCTIVE : si le nouveau ZIP livre déjà un
|
||
/// fichier au même chemin (ex : profil par défaut bundlé), on NE
|
||
/// l'écrase PAS. Seuls les fichiers absents côté cible sont copiés.</item>
|
||
/// <item>Best-effort : exceptions IO loggées en warn mais non remontées —
|
||
/// l'install ne doit pas échouer pour une copie de saves.</item>
|
||
/// </list>
|
||
/// </summary>
|
||
private async Task CopyPreviousSaveGamesAsync(string newInstallDir, string newVersion, CancellationToken ct)
|
||
{
|
||
var installed = _registry.Scan();
|
||
var previous = installed
|
||
.Where(v => !string.Equals(v.Version, newVersion, StringComparison.OrdinalIgnoreCase))
|
||
.FirstOrDefault();
|
||
if (previous is null)
|
||
{
|
||
_logger.LogDebug("No previous version installed, nothing to copy for v{Version}", newVersion);
|
||
return;
|
||
}
|
||
|
||
// Dossier projet UE = nom de l'exe sans extension. L'exe live à la racine
|
||
// de l'install, le projet UE dans un sous-dossier homonyme :
|
||
// <install>/PROSERVE_UE_5_7.exe
|
||
// <install>/PROSERVE_UE_5_7/Saved/SaveGames/*.sav
|
||
// <install>/PROSERVE_UE_5_7/Saved/Demos/*.replay
|
||
var srcProjectName = Path.GetFileNameWithoutExtension(previous.ExecutablePath);
|
||
if (string.IsNullOrWhiteSpace(srcProjectName))
|
||
{
|
||
_logger.LogDebug("Cannot resolve project name from previous exe {Exe}", previous.ExecutablePath);
|
||
return;
|
||
}
|
||
var srcSavedRoot = Path.Combine(previous.FolderPath, srcProjectName, "Saved");
|
||
|
||
// Pre-scan : on liste TOUS les fichiers à copier (savegames + demos) AVANT
|
||
// de toucher quoi que ce soit, pour pouvoir afficher un count total dans
|
||
// le StatusMessage et early-return si vraiment rien à faire.
|
||
var plan = new List<(string SubDir, string SrcFile)>();
|
||
foreach (var (subDir, glob) in PreservedSavedSubdirs)
|
||
{
|
||
var srcSubDir = Path.Combine(srcSavedRoot, subDir);
|
||
if (!Directory.Exists(srcSubDir)) continue;
|
||
foreach (var file in Directory.GetFiles(srcSubDir, glob, SearchOption.AllDirectories))
|
||
plan.Add((subDir, file));
|
||
}
|
||
if (plan.Count == 0)
|
||
{
|
||
_logger.LogDebug("Previous v{Version} has no save files / demos to copy", previous.Version);
|
||
return;
|
||
}
|
||
|
||
// Côté cible, on résout le NOUVEAU dossier UE via la même règle : lit la
|
||
// metadata fraîchement écrite (qui contient l'exe du manifest), tombe sur
|
||
// glob PROSERVE_UE_*.exe sinon. Le dossier projet peut différer du source
|
||
// (downgrade UE, ou bump UE_5_5 → UE_5_7).
|
||
var newExe = Directory.GetFiles(newInstallDir, "PROSERVE_UE_*.exe", SearchOption.TopDirectoryOnly)
|
||
.OrderBy(p => p, StringComparer.OrdinalIgnoreCase)
|
||
.FirstOrDefault();
|
||
if (newExe is null)
|
||
{
|
||
_logger.LogWarning("Cannot find PROSERVE_UE_*.exe in {Dir} — skipping save games copy", newInstallDir);
|
||
return;
|
||
}
|
||
var dstProjectName = Path.GetFileNameWithoutExtension(newExe);
|
||
var dstSavedRoot = Path.Combine(newInstallDir, dstProjectName, "Saved");
|
||
|
||
StatusMessage = Strings.StatusCopyingSaveGames(previous.Version, plan.Count);
|
||
ProgressDetail = null;
|
||
_logger.LogInformation(
|
||
"Copying {Count} user file(s) from v{From} ({SrcProj}) to v{To} ({DstProj}) — savegames+demos",
|
||
plan.Count, previous.Version, srcProjectName, newVersion, dstProjectName);
|
||
|
||
int copied = 0, skippedExisting = 0;
|
||
foreach (var (subDir, srcFile) in plan)
|
||
{
|
||
ct.ThrowIfCancellationRequested();
|
||
var srcSubDir = Path.Combine(srcSavedRoot, subDir);
|
||
var relPath = Path.GetRelativePath(srcSubDir, srcFile);
|
||
var dstFile = Path.Combine(dstSavedRoot, subDir, relPath);
|
||
try
|
||
{
|
||
if (File.Exists(dstFile))
|
||
{
|
||
// Le ZIP livre déjà ce fichier (profil/replay bundlé) — on ne
|
||
// l'écrase pas. La version la plus à jour côté installer prime
|
||
// pour ce slot précis. Les autres fichiers (créés par l'opérateur
|
||
// en cours d'utilisation) sont copiés normalement.
|
||
skippedExisting++;
|
||
continue;
|
||
}
|
||
Directory.CreateDirectory(Path.GetDirectoryName(dstFile)!);
|
||
await using var src = new FileStream(srcFile, FileMode.Open, FileAccess.Read, FileShare.Read, 81920, useAsync: true);
|
||
await using var dst = new FileStream(dstFile, FileMode.CreateNew, FileAccess.Write, FileShare.None, 81920, useAsync: true);
|
||
await src.CopyToAsync(dst, ct).ConfigureAwait(false);
|
||
copied++;
|
||
}
|
||
catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; }
|
||
catch (Exception ex)
|
||
{
|
||
_logger.LogWarning(ex, "Failed to copy user file {Src} → {Dst}", srcFile, dstFile);
|
||
}
|
||
}
|
||
|
||
_logger.LogInformation(
|
||
"User data copy complete : {Copied} copied, {Skipped} skipped (already in new install)",
|
||
copied, skippedExisting);
|
||
}
|
||
|
||
private async Task UninstallVersionAsync(VersionRowViewModel row)
|
||
{
|
||
if (row.Installed is null) return;
|
||
|
||
var sizeStr = FormatSize(row.SizeBytes);
|
||
var confirm = ThemedMessageBox.Show(
|
||
Strings.MsgUninstallConfirm(row.Version, row.FolderPath, sizeStr),
|
||
Strings.MsgBoxConfirm,
|
||
MessageBoxButton.YesNo, MessageBoxImage.Warning, MessageBoxResult.No);
|
||
|
||
if (confirm != MessageBoxResult.Yes) return;
|
||
|
||
if (IsBusy) { ThemedMessageBox.Show(Strings.MsgBusy, Strings.MsgBoxPatience, MessageBoxButton.OK, MessageBoxImage.Information); return; }
|
||
|
||
IsBusy = true;
|
||
row.State = VersionRowState.Uninstalling;
|
||
StatusMessage = Strings.StatusUninstallingVersion(row.Version);
|
||
try
|
||
{
|
||
await _registry.DeleteAsync(row.Version, null, CancellationToken.None);
|
||
StatusMessage = Strings.StatusUninstalledVersion(row.Version);
|
||
RebuildList();
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_logger.LogError(ex, "Uninstall failed for {Version}", row.Version);
|
||
row.State = VersionRowState.InstalledIdle;
|
||
ThemedMessageBox.Show(Strings.MsgUninstallFailed(ex.Message), Strings.MsgBoxError,
|
||
MessageBoxButton.OK, MessageBoxImage.Error);
|
||
StatusMessage = Strings.StatusError(ex.Message);
|
||
}
|
||
finally
|
||
{
|
||
IsBusy = false;
|
||
}
|
||
}
|
||
|
||
private async Task ShowReleaseNotesAsync(VersionRowViewModel row)
|
||
{
|
||
var url = row.Remote?.ReleaseNotesUrl;
|
||
if (string.IsNullOrEmpty(url))
|
||
{
|
||
ThemedMessageBox.Show(Strings.MsgNoReleaseNotes,
|
||
Strings.MsgBoxReleaseNotes, MessageBoxButton.OK, MessageBoxImage.Information);
|
||
return;
|
||
}
|
||
string notes;
|
||
try { notes = await _manifestService.FetchReleaseNotesAsync(url, CancellationToken.None); }
|
||
catch (Exception ex)
|
||
{
|
||
_logger.LogWarning(ex, "Failed to fetch release notes");
|
||
ThemedMessageBox.Show(Strings.MsgReleaseNotesFetchFailed(ex.Message),
|
||
Strings.MsgBoxError, MessageBoxButton.OK, MessageBoxImage.Warning);
|
||
return;
|
||
}
|
||
|
||
var dlg = new ReleaseNotesViewerDialog(row.Version, notes) { Owner = Application.Current.MainWindow };
|
||
dlg.ShowDialog();
|
||
}
|
||
|
||
private void OpenVersionFolder(VersionRowViewModel row)
|
||
{
|
||
if (string.IsNullOrEmpty(row.FolderPath) || !Directory.Exists(row.FolderPath)) return;
|
||
Process.Start(new ProcessStartInfo
|
||
{
|
||
FileName = row.FolderPath,
|
||
UseShellExecute = true,
|
||
Verb = "open"
|
||
});
|
||
}
|
||
|
||
/// <summary>
|
||
/// Re-lit le state.json depuis disque pour cette version et met à jour
|
||
/// <see cref="VersionRowViewModel.ResumableBytes"/>. À appeler après un cancel ou
|
||
/// une exception en milieu de DL pour que le bouton « Reprendre (X%) » reflète
|
||
/// le pourcentage réellement atteint et pas la valeur d'avant le DL.
|
||
/// </summary>
|
||
private void RefreshResumableBytes(VersionRowViewModel row)
|
||
{
|
||
try
|
||
{
|
||
var st = _downloadManager.GetResumableState(row.Version);
|
||
row.ResumableBytes = st?.DownloadedBytes ?? 0;
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_logger.LogDebug(ex, "Failed to refresh resumable bytes for {Version}", row.Version);
|
||
}
|
||
}
|
||
|
||
private void UpdateDlProgress(VersionRowViewModel row, DownloadProgress p)
|
||
{
|
||
if (p.TotalBytes > 0)
|
||
{
|
||
var pct = (double)p.BytesDownloaded / p.TotalBytes * 100.0;
|
||
ProgressPercent = pct;
|
||
row.ProgressPercent = pct;
|
||
}
|
||
|
||
var sb = new System.Text.StringBuilder();
|
||
// Préfixe différencié pour rendre la source du DL visible en permanence
|
||
// dans le footer : 📡 LAN (avec IP du peer) vs ⬇ Internet (OVH).
|
||
if (_currentPeerHost is not null)
|
||
sb.Append("📡 LAN ").Append(_currentPeerHost).Append(" • v").Append(row.Version).Append(" : ");
|
||
else
|
||
sb.Append("⬇ v").Append(row.Version).Append(" : ");
|
||
sb.Append(FormatSize(p.BytesDownloaded));
|
||
if (p.TotalBytes > 0) sb.Append(" / ").Append(FormatSize(p.TotalBytes));
|
||
if (p.BytesPerSecond > 0) sb.Append(" • ").Append(FormatSize((long)p.BytesPerSecond)).Append("/s");
|
||
if (p.Eta is { } eta && eta.TotalSeconds > 1) sb.Append(" • ETA ").Append(FormatEta(eta));
|
||
var s = sb.ToString();
|
||
ProgressDetail = s;
|
||
row.ProgressDetail = s;
|
||
}
|
||
|
||
private static string FormatSize(long bytes) => Strings.FormatSize(bytes);
|
||
|
||
private static string FormatEta(TimeSpan eta)
|
||
{
|
||
if (eta.TotalHours >= 1) return $"{(int)eta.TotalHours}h{eta.Minutes:D2}";
|
||
if (eta.TotalMinutes >= 1) return $"{(int)eta.TotalMinutes}m{eta.Seconds:D2}";
|
||
return $"{(int)eta.TotalSeconds}s";
|
||
}
|
||
}
|