v1.0.2 — Conserver les sauvegardes et replays entre versions

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>
This commit is contained in:
2026-06-22 18:03:57 +02:00
parent b3844bfe16
commit a6ea9b11af
6 changed files with 232 additions and 5 deletions

View File

@@ -1477,6 +1477,12 @@ public sealed partial class MainViewModel : ObservableObject
// 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).
@@ -1503,6 +1509,7 @@ public sealed partial class MainViewModel : ObservableObject
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 :
@@ -1661,6 +1668,31 @@ public sealed partial class MainViewModel : ObservableObject
_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.
@@ -2307,6 +2339,154 @@ public sealed partial class MainViewModel : ObservableObject
}
}
/// <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;