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.exePROSERVE_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 @@ + - + + +