diff --git a/src/PSLauncher.App/PSLauncher.App.csproj b/src/PSLauncher.App/PSLauncher.App.csproj
index de7da73..734c4ad 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
-
0.27.2
-
0.27.2.0
-
0.27.2.0
+
0.27.4
+
0.27.4.0
+
0.27.4.0
true
diff --git a/src/PSLauncher.App/ViewModels/MainViewModel.cs b/src/PSLauncher.App/ViewModels/MainViewModel.cs
index 88f5a92..1ae4355 100644
--- a/src/PSLauncher.App/ViewModels/MainViewModel.cs
+++ b/src/PSLauncher.App/ViewModels/MainViewModel.cs
@@ -1081,6 +1081,29 @@ public sealed partial class MainViewModel : ObservableObject
});
await _zipInstaller.InstallAsync(zipPath, target, installProgress, ct);
+ // Persiste le nom du .exe à lancer dans le dossier d'install fraîchement
+ // extrait. InstallationRegistry.Scan() le lira pour résoudre l'exe sans
+ // avoir besoin du manifest en mémoire (cas démarrage offline / pré-check).
+ // Le nom vient du manifest serveur (champ "executable" par version),
+ // configurable dans le backoffice.
+ try
+ {
+ if (!string.IsNullOrWhiteSpace(row.Remote.Executable))
+ await _registry.WriteInstallMetadataAsync(target, row.Remote.Executable, ct);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "Failed to write install metadata (will fall back to glob detection)");
+ }
+
+ // 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.
+ // Si un installer fail (already-installed retournent souvent un exit code
+ // non-zero), on log mais on continue — l'install PROSERVE ne doit pas être
+ // bloquée par un redist mineur.
+ await InstallRedistsAsync(target, row.Version, ct);
+
// Promote le ZIP au cache LAN (sidecar SHA écrit) puis prune les vieilles
// versions selon MaxCachedVersions. Le ZIP est déjà au bon endroit
// (DownloadManager.VerifyAndFinalizeAsync l'a Move'd vers `proserve-X.zip`),
@@ -1311,6 +1334,83 @@ public sealed partial class MainViewModel : ObservableObject
}
}
+ ///
+ /// Si _redist/ existe dans le dossier d'install, lance tous les .exe
+ /// dedans en silent + UAC élevé pour installer les redists Unreal (VC_redist,
+ /// UEPrereqSetup, etc.). Skip si déjà fait (tracé dans .proserve-meta.json).
+ /// Best-effort : on log mais on ne fail pas l'install si un redist plante
+ /// (typiquement les "already installed" retournent un exit code non-zero).
+ ///
+ private async Task InstallRedistsAsync(string installDir, string version, CancellationToken ct)
+ {
+ var redistDir = Path.Combine(installDir, "_redist");
+ if (!Directory.Exists(redistDir))
+ {
+ _logger.LogDebug("No _redist/ in v{Version}, skipping redists install", version);
+ return;
+ }
+ if (_registry.IsRedistInstalled(installDir))
+ {
+ _logger.LogInformation("Redists already installed for v{Version} (per metadata), skipping", version);
+ return;
+ }
+
+ var exes = Directory.GetFiles(redistDir, "*.exe", SearchOption.TopDirectoryOnly);
+ if (exes.Length == 0)
+ {
+ _logger.LogDebug("_redist/ exists but contains no .exe, skipping");
+ return;
+ }
+ Array.Sort(exes, StringComparer.OrdinalIgnoreCase);
+
+ StatusMessage = Strings.StatusInstallingRedists(version);
+ ProgressDetail = null;
+ for (int i = 0; i < exes.Length; i++)
+ {
+ ct.ThrowIfCancellationRequested();
+ var exe = exes[i];
+ var name = Path.GetFileName(exe);
+ ProgressDetail = Strings.ProgressRedistInstalling(i + 1, exes.Length, name);
+ _logger.LogInformation("Running redist {Idx}/{Total} : {Exe}", i + 1, exes.Length, name);
+ try
+ {
+ // Silent flags qui couvrent VC_redist (/install /quiet /norestart) et
+ // UEPrereqSetup (/install /quiet /norestart). MSI std accepte aussi.
+ // UseShellExecute=true + Verb=runas → UAC popup pour élévation admin.
+ var psi = new System.Diagnostics.ProcessStartInfo
+ {
+ FileName = exe,
+ Arguments = "/install /quiet /norestart",
+ UseShellExecute = true,
+ Verb = "runas",
+ CreateNoWindow = true,
+ };
+ using var proc = System.Diagnostics.Process.Start(psi);
+ if (proc is null) continue;
+ await proc.WaitForExitAsync(ct).ConfigureAwait(false);
+ _logger.LogInformation("Redist {Name} exit code {Code}", name, proc.ExitCode);
+ // Codes typiques :
+ // 0 = succès
+ // 3010 = succès mais reboot requis (on ne fait pas, le user gérera)
+ // 1638 = "already installed, newer version" — OK, on continue
+ // 1602/1603 = user cancel UAC ou install error — log mais continue
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "Redist {Name} install failed (continuing)", name);
+ }
+ }
+
+ try
+ {
+ await _registry.MarkRedistInstalledAsync(installDir, ct).ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "Failed to mark redist installed in metadata");
+ }
+ }
+
private async Task UninstallVersionAsync(VersionRowViewModel row)
{
if (row.Installed is null) return;
diff --git a/src/PSLauncher.Core/Installations/IInstallationRegistry.cs b/src/PSLauncher.Core/Installations/IInstallationRegistry.cs
index 93fc308..f579de8 100644
--- a/src/PSLauncher.Core/Installations/IInstallationRegistry.cs
+++ b/src/PSLauncher.Core/Installations/IInstallationRegistry.cs
@@ -8,4 +8,18 @@ public interface IInstallationRegistry
InstalledVersion? Get(string version);
bool IsInstalled(string version);
Task DeleteAsync(string version, IProgress
? progress, CancellationToken ct);
+
+ ///
+ /// Écrit le metadata .proserve-meta.json dans le dossier d'install avec
+ /// le nom du .exe à lancer (récupéré du manifest serveur). À appeler par
+ /// MainViewModel après l'extraction du ZIP, pour que le futur Scan
+ /// trouve le bon exe sans avoir besoin du manifest en mémoire.
+ ///
+ Task WriteInstallMetadataAsync(string installDir, string executableName, CancellationToken ct);
+
+ /// Marque les redists comme installés (timestamp UTC) dans la metadata.
+ Task MarkRedistInstalledAsync(string installDir, CancellationToken ct);
+
+ /// Vrai si la metadata indique que les redists ont déjà été installés.
+ bool IsRedistInstalled(string installDir);
}
diff --git a/src/PSLauncher.Core/Installations/InstallationRegistry.cs b/src/PSLauncher.Core/Installations/InstallationRegistry.cs
index 145d12b..32aba94 100644
--- a/src/PSLauncher.Core/Installations/InstallationRegistry.cs
+++ b/src/PSLauncher.Core/Installations/InstallationRegistry.cs
@@ -1,3 +1,4 @@
+using System.Text.Json;
using System.Text.RegularExpressions;
using Microsoft.Extensions.Logging;
using PSLauncher.Models;
@@ -6,7 +7,20 @@ namespace PSLauncher.Core.Installations;
public sealed partial class InstallationRegistry : IInstallationRegistry
{
- private const string ExecutableName = "PROSERVE_UE_5_5.exe";
+ ///
+ /// Fichier metadata écrit à l'install par MainViewModel. Contient le nom de l'exe
+ /// déclaré dans le manifest pour cette version. Permet à de
+ /// retrouver le bon .exe à lancer sans avoir le manifest en mémoire (cas du
+ /// démarrage offline ou avant le 1er Check Updates).
+ ///
+ public const string MetadataFileName = ".proserve-meta.json";
+
+ ///
+ /// Pattern de fallback quand .proserve-meta.json est absent (vieux installs
+ /// d'avant l'introduction du metadata). Couvre toutes les versions Unreal
+ /// connues (UE_5_5, UE_5_7, etc.). Premier match alphabétique gagne.
+ ///
+ private const string FallbackExePattern = "PROSERVE_UE_*.exe";
// IgnoreCase pour matcher à la fois les anciens dossiers "Proserve vX.Y.Z" et les
// nouveaux "PROSERVE vX.Y.Z" (changement de casse de la marque).
@@ -38,10 +52,10 @@ public sealed partial class InstallationRegistry : IInstallationRegistry
var match = VersionFolderRegex().Match(name);
if (!match.Success) continue;
- var exe = Path.Combine(dir, ExecutableName);
- if (!File.Exists(exe))
+ var exe = ResolveExecutable(dir);
+ if (exe is null)
{
- _logger.LogDebug("Skipped {Dir}: missing {Exe}", dir, ExecutableName);
+ _logger.LogDebug("Skipped {Dir}: no executable found (no .proserve-meta.json + no fallback match)", dir);
continue;
}
@@ -59,6 +73,107 @@ public sealed partial class InstallationRegistry : IInstallationRegistry
.ToList();
}
+ ///
+ /// Résout le .exe à lancer pour un dossier d'install donné. Stratégie :
+ /// 1. Lit .proserve-meta.json (écrit par MainViewModel à l'install,
+ /// contient l'exe déclaré dans le manifest serveur — source canonique).
+ /// 2. Fallback glob PROSERVE_UE_*.exe pour les vieux installs sans meta.
+ /// 3. Null si aucun match (dossier corrompu ou pas un install PROSERVE).
+ ///
+ private string? ResolveExecutable(string installDir)
+ {
+ // Tentative 1 : metadata écrite par le launcher au moment de l'install
+ var metaPath = Path.Combine(installDir, MetadataFileName);
+ if (File.Exists(metaPath))
+ {
+ try
+ {
+ var raw = File.ReadAllText(metaPath);
+ var meta = JsonSerializer.Deserialize(raw);
+ if (meta is { Executable: var exeName } && !string.IsNullOrWhiteSpace(exeName))
+ {
+ var candidate = Path.Combine(installDir, exeName);
+ if (File.Exists(candidate)) return candidate;
+ _logger.LogWarning("Metadata declares {Exe} but file missing in {Dir} — falling back to glob", exeName, installDir);
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.LogDebug(ex, "Failed to parse {Meta} (will fall back to glob)", metaPath);
+ }
+ }
+
+ // Tentative 2 : glob pattern (couvre vieux installs sans metadata)
+ try
+ {
+ var matches = Directory.GetFiles(installDir, FallbackExePattern, SearchOption.TopDirectoryOnly);
+ if (matches.Length > 0)
+ {
+ Array.Sort(matches, StringComparer.OrdinalIgnoreCase);
+ return matches[0];
+ }
+ }
+ catch { /* ignore IO error */ }
+
+ return null;
+ }
+
+ ///
+ /// Écrit le fichier metadata .proserve-meta.json dans le dossier d'install
+ /// fraîchement extrait. Appelé par après le ZipInstaller,
+ /// avec l'exe déclaré dans le manifest (VersionManifest.Executable).
+ ///
+ public Task WriteInstallMetadataAsync(string installDir, string executableName, CancellationToken ct)
+ {
+ var path = Path.Combine(installDir, MetadataFileName);
+ var meta = new InstallMetadata { Executable = executableName };
+ var json = JsonSerializer.Serialize(meta, new JsonSerializerOptions { WriteIndented = true });
+ return File.WriteAllTextAsync(path, json, ct);
+ }
+
+ ///
+ /// Marque dans la metadata qu'on a installé avec succès les redists Unreal
+ /// (contenu de _redist/ de la version). Évite de re-runner les UAC
+ /// popups si l'utilisateur réinstalle la même version.
+ ///
+ public async Task MarkRedistInstalledAsync(string installDir, CancellationToken ct)
+ {
+ var path = Path.Combine(installDir, MetadataFileName);
+ InstallMetadata? meta = null;
+ if (File.Exists(path))
+ {
+ try { meta = JsonSerializer.Deserialize(await File.ReadAllTextAsync(path, ct).ConfigureAwait(false)); }
+ catch { meta = null; }
+ }
+ meta ??= new InstallMetadata();
+ meta.RedistInstalledAt = DateTime.UtcNow;
+ var json = JsonSerializer.Serialize(meta, new JsonSerializerOptions { WriteIndented = true });
+ await File.WriteAllTextAsync(path, json, ct).ConfigureAwait(false);
+ }
+
+ ///
+ /// Vrai si l'install dir a déjà eu son _redist/ installé une fois (via
+ /// metadata). Permet de skip le UAC popup chain à chaque réinstall de la même
+ /// version.
+ ///
+ public bool IsRedistInstalled(string installDir)
+ {
+ var path = Path.Combine(installDir, MetadataFileName);
+ if (!File.Exists(path)) return false;
+ try
+ {
+ var meta = JsonSerializer.Deserialize(File.ReadAllText(path));
+ return meta?.RedistInstalledAt is not null;
+ }
+ catch { return false; }
+ }
+
+ private sealed class InstallMetadata
+ {
+ public string Executable { get; set; } = string.Empty;
+ public DateTime? RedistInstalledAt { get; set; }
+ }
+
public InstalledVersion? Get(string version) =>
Scan().FirstOrDefault(v => v.Version == version);
diff --git a/src/PSLauncher.Core/Localization/Strings.cs b/src/PSLauncher.Core/Localization/Strings.cs
index 603c6e4..cdc9c3d 100644
--- a/src/PSLauncher.Core/Localization/Strings.cs
+++ b/src/PSLauncher.Core/Localization/Strings.cs
@@ -676,6 +676,22 @@ public static class Strings
);
public static string MsgBoxLanCacheChange => T("Cache LAN P2P", "LAN P2P cache", "局域网 P2P 缓存", "แคช LAN P2P", "ذاكرة التخزين المؤقت LAN P2P");
+ // ---- Redist Unreal (VC_redist, UEPrereqSetup, …) ----
+ public static string StatusInstallingRedists(string version) => T(
+ $"⚙️ Installation des composants requis pour v{version}…",
+ $"⚙️ Installing required components for v{version}…",
+ $"⚙️ 正在为 v{version} 安装必需组件…",
+ $"⚙️ กำลังติดตั้งส่วนประกอบที่จำเป็นสำหรับ v{version}…",
+ $"⚙️ جارٍ تثبيت المكونات المطلوبة للنسخة v{version}…"
+ );
+ public static string ProgressRedistInstalling(int done, int total, string filename) => T(
+ $"⚙️ Redist {done}/{total} : {filename}",
+ $"⚙️ Redist {done}/{total}: {filename}",
+ $"⚙️ Redist {done}/{total}:{filename}",
+ $"⚙️ Redist {done}/{total}: {filename}",
+ $"⚙️ Redist {done}/{total}: {filename}"
+ );
+
// ---- Badge source du manifest (origine de la liste de versions affichée) ----
public static string ManifestSourceOvh => T("🌐 OVH (en ligne)", "🌐 OVH (online)", "🌐 OVH(在线)", "🌐 OVH (ออนไลน์)", "🌐 OVH (متصل)");
public static string ManifestSourcePeer => T("📡 Peer LAN (hors ligne)", "📡 LAN peer (offline)", "📡 局域网 Peer(离线)", "📡 LAN Peer (ออฟไลน์)", "📡 LAN Peer (غير متصل)");