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

@@ -11,7 +11,7 @@
#define MyAppName "PROSERVE Launcher"
#define MyAppShortName "PS_Launcher"
#define MyAppVersion "1.0.1"
#define MyAppVersion "1.0.2"
#define MyAppPublisher "ASTERION VR"
#define MyAppURL "https://asterionvr.com"
#define MyAppExeName "PS_Launcher.exe"

View File

@@ -18,9 +18,9 @@
<Product>PROSERVE Launcher</Product>
<Copyright>© 2026 ASTERION VR — All rights reserved</Copyright>
<RootNamespace>PSLauncher.App</RootNamespace>
<Version>1.0.1</Version>
<AssemblyVersion>1.0.1.0</AssemblyVersion>
<FileVersion>1.0.1.0</FileVersion>
<Version>1.0.2</Version>
<AssemblyVersion>1.0.2.0</AssemblyVersion>
<FileVersion>1.0.2.0</FileVersion>
<!-- Single-file self-contained publish profile (used by `dotnet publish`) -->
<PublishSingleFile>true</PublishSingleFile>

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;

View File

@@ -14,6 +14,7 @@
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<TextBlock Grid.Row="0"
@@ -37,7 +38,16 @@
Background="Transparent" />
</Border>
<StackPanel Grid.Row="3" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,18,0,0">
<CheckBox Grid.Row="3"
x:Name="PreserveSaveGamesCheckBox"
Content="{x:Static loc:Strings.UpdatePreserveSaveGames}"
ToolTip="{x:Static loc:Strings.UpdatePreserveSaveGamesTooltip}"
IsChecked="True"
Foreground="{StaticResource Brush.Text.Primary}"
Margin="0,14,0,0"
HorizontalAlignment="Left" />
<StackPanel Grid.Row="4" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,14,0,0">
<Button Style="{StaticResource SecondaryButton}"
Content="{x:Static loc:Strings.ActionLater}"
IsCancel="True"

View File

@@ -24,9 +24,18 @@ public partial class UpdateAvailableDialog : Window
public bool DownloadRequested { get; private set; }
/// <summary>
/// État de la case « Conserver les sauvegardes de la version précédente ».
/// Cochée par défaut, lue par MainViewModel APRÈS l'extraction du ZIP pour
/// décider s'il faut copier les .sav depuis l'install précédente vers la
/// nouvelle (voir CopyPreviousSaveGamesAsync).
/// </summary>
public bool PreserveSaveGames { get; private set; } = true;
private void OnDownload(object sender, RoutedEventArgs e)
{
DownloadRequested = true;
PreserveSaveGames = PreserveSaveGamesCheckBox.IsChecked == true;
DialogResult = true;
Close();
}

View File

@@ -278,6 +278,24 @@ public static class Strings
// ==================== UPDATE ====================
public static string UpdateAvailableTitle => T("Mise à jour disponible", "Update available", "有可用更新", "มีการอัปเดต", "تحديث متاح");
public static string UpdateDownload => T("⬇ Télécharger", "⬇ Download", "⬇ 下载", "⬇ ดาวน์โหลด", "⬇ تنزيل");
public static string UpdatePreserveSaveGames => T(
"Conserver les sauvegardes et replays de la version précédente",
"Keep save games and replays from the previous version",
"保留上一版本的存档和录像",
"เก็บไฟล์เซฟและรีเพลย์จากเวอร์ชันก่อนหน้า",
"الاحتفاظ بحفظات اللعبة والإعادات من النسخة السابقة",
"Conservar las partidas guardadas y repeticiones de la versión anterior",
"Spielstände und Replays der vorherigen Version beibehalten"
);
public static string UpdatePreserveSaveGamesTooltip => T(
"Copie les fichiers .sav (PROSERVE_UE_*/Saved/SaveGames) ET les replays .replay (PROSERVE_UE_*/Saved/Demos) de la version la plus récente déjà installée vers la nouvelle installation, après extraction. Si aucune version précédente n'est trouvée, l'option est sans effet.",
"Copies .sav files (PROSERVE_UE_*/Saved/SaveGames) AND .replay files (PROSERVE_UE_*/Saved/Demos) from the most recent already-installed version into the new install, after extraction. If no previous version is found, the option has no effect.",
"在解压后将最近已安装版本的 .sav 存档 (PROSERVE_UE_*/Saved/SaveGames) 和 .replay 录像 (PROSERVE_UE_*/Saved/Demos) 复制到新安装中。如果未找到之前的版本,则此选项无效。",
"คัดลอกไฟล์ .sav (PROSERVE_UE_*/Saved/SaveGames) และไฟล์ .replay (PROSERVE_UE_*/Saved/Demos) จากเวอร์ชันที่ติดตั้งล่าสุดไปยังการติดตั้งใหม่ หลังจากการแตกไฟล์ หากไม่พบเวอร์ชันก่อนหน้า ตัวเลือกนี้จะไม่มีผล",
"ينسخ ملفات .sav (PROSERVE_UE_*/Saved/SaveGames) وملفات .replay (PROSERVE_UE_*/Saved/Demos) من أحدث نسخة مثبتة بالفعل إلى التثبيت الجديد، بعد الاستخراج. إذا لم يتم العثور على نسخة سابقة، فلن يكون لهذا الخيار أي تأثير.",
"Copia los archivos .sav (PROSERVE_UE_*/Saved/SaveGames) y los archivos .replay (PROSERVE_UE_*/Saved/Demos) de la versión más reciente ya instalada en la nueva instalación, tras la extracción. Si no se encuentra una versión anterior, la opción no surte efecto.",
"Kopiert die .sav-Dateien (PROSERVE_UE_*/Saved/SaveGames) UND .replay-Dateien (PROSERVE_UE_*/Saved/Demos) der zuletzt installierten Version nach dem Entpacken in die neue Installation. Wird keine vorherige Version gefunden, hat die Option keine Wirkung."
);
public static string LauncherUpdateTitle => T("Mise à jour du launcher", "Launcher update", "启动器更新", "อัปเดต Launcher", "تحديث المُشغِّل");
public static string LauncherUpdateAvailable => T("Mise à jour du launcher disponible", "Launcher update available", "启动器更新可用", "มีการอัปเดต Launcher", "تحديث المُشغِّل متاح");
public static string LauncherUpdateBody => T(
@@ -565,6 +583,16 @@ public static class Strings
$"تم تثبيت v{version} بنجاح"
);
public static string StatusCopyingSaveGames(string fromVersion, int fileCount) => T(
$"Copie des sauvegardes et replays depuis v{fromVersion} ({fileCount} fichier{(fileCount > 1 ? "s" : "")})…",
$"Copying save games and replays from v{fromVersion} ({fileCount} file{(fileCount > 1 ? "s" : "")})…",
$"正在从 v{fromVersion} 复制存档和录像({fileCount} 个文件)…",
$"กำลังคัดลอกไฟล์เซฟและรีเพลย์จาก v{fromVersion} ({fileCount} ไฟล์)…",
$"جارٍ نسخ حفظات اللعبة والإعادات من v{fromVersion} ({fileCount} ملف)…",
$"Copiando partidas guardadas y repeticiones desde v{fromVersion} ({fileCount} archivo{(fileCount > 1 ? "s" : "")})…",
$"Spielstände und Replays werden von v{fromVersion} kopiert ({fileCount} Datei{(fileCount > 1 ? "en" : "")})…"
);
public static string StatusUninstallingVersion(string version) => T(
$"Suppression v{version}…",
$"Uninstalling v{version}…",