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:
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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}…",
|
||||
|
||||
Reference in New Issue
Block a user