v0.29.7 — SteamVR merge + résilience DL (404 retry, force fresh, SHA purge)

SteamVR + Vive Business Streaming settings merge :
 - Nouveau ISteamVrSettingsDeployer : si _steamvr/steamvr.vrsettings est
   dans le ZIP, deep-merge récursif des blocs racine dans le fichier user
   (clé absente → ajout, deux objets → recurse, sinon → replace ; les
   sous-clés cible non touchées sont préservées — crucial pour "trackers")
 - Auto-localisation via registre Steam (HKCU/HKLM) + fallback Program Files
 - Phase pré-check (CheckMergeNeededAsync) : skip silencieux si la cible
   est déjà à jour (deep equality) → pas de kill SteamVR/VBS inutile, pas
   de popup à l'opérateur. Si changements nécessaires → popup OK/Annuler
   avec nombre de blocs qui changeront + liste des process à fermer.
 - Liste de kill construite depuis les health checks de type "Process"
   (l'opérateur connaît déjà sa stack VR) + guardians (Vive Business
   Streaming en tête car il relance SteamVR auto)
 - Settings UI : section dédiée avec opt-in, override path, liste process
   éditable + affichage du path canonique attendu

Résilience téléchargements :
 - 404 mid-DL traité comme 403/410 (URL refresh trigger) au niveau segment :
   on extrait le nouveau filename via /download-url server-side, retry transparent
 - Auto-retry install une fois après refresh manifest sur 404 : l'opérateur
   ne voit rien si la cause était un manifest local stale
 - Si retry échoue aussi en 404 → message ciblé "manifest serveur stale"
   (problème côté serveur, pas client)
 - Sur SHA-256 mismatch : auto-purge du cache LAN local (.zip + .sha256)
   + message d'erreur dédié avec source du DL (peer ou OVH) + nouveau menu
   "↻ Forcer re-téléchargement" pour purger état + cache manuellement
 - Détection client-side de l'incohérence "signed URL filename != manifest
   URL filename" avant DL : abort immédiat plutôt que 14 Go pour rien
 - IZipCacheStore.InvalidateAsync : nouvelle API pour purger un cache par
   version (utilisée par SHA mismatch handler + Force Fresh menu)

Bug serveur (DownloadUrl.php) :
 - L'endpoint construisait l'URL signée avec un template hardcodé
   `/builds/proserve-{version}.zip`, ignorant complètement
   download.url du manifest. Conséquence : un opérateur qui rename
   son ZIP pour buster le cache CDN OVH (ex. proserve-full-1.5.4.zip)
   voyait toutes ses releases retournent du 404 silencieux côté client.
 - Fix : on lit basename(parse_url(entry.download.url).path), whitelist
   sur le filename, vérif is_file() avant de signer, erreur 500 explicite
   si manifest et filesystem désynchros.

Strings (5 langues) :
 - ~20 nouvelles : SteamVR popups + status, SHA mismatch dialog + source
   labels, force fresh confirm + menu, manifest stale + auto-retry status

Bumps : 0.29.6 (déployé hors-commit pendant la session) → 0.29.7.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-27 14:45:01 +02:00
parent 01793eb32d
commit 50da755a92
21 changed files with 1768 additions and 27 deletions

View File

@@ -24,6 +24,7 @@ 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;
@@ -52,6 +53,7 @@ public sealed partial class MainViewModel : ObservableObject
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;
@@ -91,6 +93,14 @@ public sealed partial class MainViewModel : ObservableObject
/// </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>
@@ -368,6 +378,7 @@ public sealed partial class MainViewModel : ObservableObject
IReportToolDeployer reportDeployer,
IDocToolDeployer docDeployer,
IApiToolDeployer apiDeployer,
ISteamVrSettingsDeployer steamVrDeployer,
IPeerSourceResolver peerResolver,
IZipCacheStore zipCacheStore,
ILanCacheServer lanCacheServer,
@@ -391,6 +402,7 @@ public sealed partial class MainViewModel : ObservableObject
_reportDeployer = reportDeployer;
_docDeployer = docDeployer;
_apiDeployer = apiDeployer;
_steamVrDeployer = steamVrDeployer;
_peerResolver = peerResolver;
_zipCacheStore = zipCacheStore;
_lanCacheServer = lanCacheServer;
@@ -626,6 +638,7 @@ public sealed partial class MainViewModel : ObservableObject
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)
@@ -977,6 +990,69 @@ public sealed partial class MainViewModel : ObservableObject
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)
@@ -1170,6 +1246,60 @@ public sealed partial class MainViewModel : ObservableObject
/// 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>();
@@ -1314,6 +1444,13 @@ public sealed partial class MainViewModel : ObservableObject
_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 :
@@ -1370,7 +1507,37 @@ public sealed partial class MainViewModel : ObservableObject
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
@@ -1506,6 +1673,127 @@ public sealed partial class MainViewModel : ObservableObject
_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)
@@ -1697,6 +1985,9 @@ public sealed partial class MainViewModel : ObservableObject
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,
@@ -1713,16 +2004,154 @@ public sealed partial class MainViewModel : ObservableObject
// 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);
var body = Strings.MsgShaMismatchBody(row.Version, sourceLabel);
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;
ThemedMessageBox.Show(
Strings.MsgInstallFailed(ex.Message),
Strings.MsgBoxError, MessageBoxButton.OK, MessageBoxImage.Error);
StatusMessage = Strings.StatusError(ex.Message);
// 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;
}
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 %.