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