diff --git a/installer/PSLauncher.iss b/installer/PSLauncher.iss
index ee3b21e..f9e7775 100644
--- a/installer/PSLauncher.iss
+++ b/installer/PSLauncher.iss
@@ -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"
diff --git a/src/PSLauncher.App/PSLauncher.App.csproj b/src/PSLauncher.App/PSLauncher.App.csproj
index 50359f3..72c37ed 100644
--- a/src/PSLauncher.App/PSLauncher.App.csproj
+++ b/src/PSLauncher.App/PSLauncher.App.csproj
@@ -18,9 +18,9 @@
PROSERVE Launcher
© 2026 ASTERION VR — All rights reserved
PSLauncher.App
- 1.0.1
- 1.0.1.0
- 1.0.1.0
+ 1.0.2
+ 1.0.2.0
+ 1.0.2.0
true
diff --git a/src/PSLauncher.App/ViewModels/MainViewModel.cs b/src/PSLauncher.App/ViewModels/MainViewModel.cs
index cdfabe9..f2364c4 100644
--- a/src/PSLauncher.App/ViewModels/MainViewModel.cs
+++ b/src/PSLauncher.App/ViewModels/MainViewModel.cs
@@ -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
}
}
+ ///
+ /// Liste des sous-dossiers PROSERVE_UE_*/Saved/{sub}/ à préserver
+ /// entre versions, avec le glob filtre par sous-dossier. La copie est
+ /// récursive (AllDirectories) et non destructive (les fichiers
+ /// déjà présents dans la nouvelle install ne sont jamais écrasés).
+ ///
+ ///
+ /// - SaveGames / *.sav — profils Unreal, progression
+ /// d'entraînement, données opérateur.
+ /// - Demos / *.replay — replays Unreal (enregistrements de
+ /// session, extension .replay dans PROSERVE). Précieux pour le
+ /// debriefing post-formation.
+ ///
+ ///
+ private static readonly (string SubDir, string Glob)[] PreservedSavedSubdirs =
+ {
+ ("SaveGames", "*.sav"),
+ ("Demos", "*.replay"),
+ };
+
+ ///
+ /// Copie les fichiers utilisateur (.sav + .demo) depuis les sous-dossiers
+ /// PROSERVE_UE_*/Saved/SaveGames/ et PROSERVE_UE_*/Saved/Demos/
+ /// 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 ),
+ /// décochable.
+ ///
+ /// Détails :
+ ///
+ /// - Source = version installée au plus haut SemVer, autre que
+ /// . Si l'opérateur fait une réinstall ou un
+ /// downgrade, on copie quand même depuis la plus récente DISPONIBLE.
+ /// - Dossier projet UE détecté via le nom de l'exe
+ /// (PROSERVE_UE_5_7.exe → PROSERVE_UE_5_7/). 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).
+ /// - 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.
+ /// - Best-effort : exceptions IO loggées en warn mais non remontées —
+ /// l'install ne doit pas échouer pour une copie de saves.
+ ///
+ ///
+ 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 :
+ // /PROSERVE_UE_5_7.exe
+ // /PROSERVE_UE_5_7/Saved/SaveGames/*.sav
+ // /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;
diff --git a/src/PSLauncher.App/Views/UpdateAvailableDialog.xaml b/src/PSLauncher.App/Views/UpdateAvailableDialog.xaml
index af899ad..7da894f 100644
--- a/src/PSLauncher.App/Views/UpdateAvailableDialog.xaml
+++ b/src/PSLauncher.App/Views/UpdateAvailableDialog.xaml
@@ -14,6 +14,7 @@
+
-
+
+
+