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:
@@ -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 %.
|
||||
|
||||
Reference in New Issue
Block a user