v0.27.4 — Exe per-version configurable + auto-install redists Unreal

== Exe par-version, configurable via le backoffice ==

Pour éviter de devoir bumper le launcher à chaque changement d'Unreal Engine
(PROSERVE_UE_5_5.exe → PROSERVE_UE_5_7.exe), l'exe à lancer est maintenant
déclaré par version dans le manifest serveur, configurable depuis le form
admin.

- server/admin/versions.php : nouveau champ "Nom de l'exécutable" dans le
  form d'ajout de version (regex strict anti-path-traversal). Par défaut
  PROSERVE_UE_5_7.exe.
- InstallationRegistry : nouveau .proserve-meta.json écrit dans chaque dossier
  d'install fraîchement extrait (contient l'exe declaré par le manifest).
  Scan() lit cette meta pour résoudre l'exe sans avoir besoin du manifest
  en mémoire (offline / pré-check).
- Fallback glob PROSERVE_UE_*.exe pour les vieux installs sans metadata —
  garantit la backward compat de l'UE 5.5 actuel sans intervention.

== Auto-install des redists Unreal depuis _redist/ ==

Quand une version change d'Unreal (typique : UE 5.5 → 5.7), les redists
Microsoft VC + UEPrereqSetup doivent être installés sur le PC. Au lieu de
demander à l'opérateur de les installer à la main, le launcher détecte
maintenant un dossier _redist/ dans la racine du ZIP de release et lance
automatiquement tous les .exe dedans.

- MainViewModel.InstallRedistsAsync : après le ZipInstaller, scan _redist/
  pour .exe, lance chacun avec /install /quiet /norestart + Verb=runas
  (UAC popup par installer, inévitable car les redists écrivent dans
  Program Files). Tri alphabétique (préfixe 01_, 02_, … pour forcer un
  ordre si besoin).
- InstallationRegistry.MarkRedistInstalledAsync : trace redistInstalledAt
  dans .proserve-meta.json après succès. Reinstall de la même version :
  skip silencieux, pas de UAC popup chain.
- Best-effort sur les exit codes : les "already installed" retournent
  souvent un code non-zero, on log mais on continue (l'install PROSERVE
  ne doit pas être bloquée par un redist mineur).

Côté ZIP de release : crée _redist/ à la racine avec les .exe à installer
(typiquement VC_redist.x64.exe + UEPrereqSetup_x64.exe).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-11 15:11:57 +02:00
parent aab2e41152
commit 9b17dfa84c
7 changed files with 263 additions and 9 deletions

View File

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

View File

@@ -166,11 +166,16 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$zipFilename = $zipFilenameProposed; $zipFilename = $zipFilenameProposed;
$channels = sanitizeChannels((array)($_POST['channels'] ?? []), $channelNames); $channels = sanitizeChannels((array)($_POST['channels'] ?? []), $channelNames);
$entryId = generate_entry_id(); $entryId = generate_entry_id();
$exeName = trim((string)($_POST['executable'] ?? 'PROSERVE_UE_5_7.exe'));
// Validation stricte : pas de path traversal possible dans le nom du fichier.
if (!preg_match('/^[A-Za-z0-9_.\-]+\.exe$/', $exeName)) {
$exeName = 'PROSERVE_UE_5_7.exe';
}
$entry = [ $entry = [
'id' => $entryId, 'id' => $entryId,
'version' => $version, 'version' => $version,
'releasedAt' => $releasedAtIso, 'releasedAt' => $releasedAtIso,
'executable' => 'PROSERVE_UE_5_5.exe', 'executable' => $exeName,
'installFolderTemplate' => 'PROSERVE v{version}', 'installFolderTemplate' => 'PROSERVE v{version}',
'channels' => $channels, 'channels' => $channels,
'download' => [ 'download' => [
@@ -423,6 +428,10 @@ Layout::header('Versions', 'versions');
<label>Nom du fichier ZIP <span class="muted" style="font-weight: normal; font-size: 11px;">(optionnel, par défaut <code>proserve-{version}.zip</code>)</span></label> <label>Nom du fichier ZIP <span class="muted" style="font-weight: normal; font-size: 11px;">(optionnel, par défaut <code>proserve-{version}.zip</code>)</span></label>
<input type="text" name="zip_filename" placeholder="proserve-1.4.8-police.zip" maxlength="120"> <input type="text" name="zip_filename" placeholder="proserve-1.4.8-police.zip" maxlength="120">
</div> </div>
<div class="field">
<label>Nom de l'exécutable <span class="muted" style="font-weight: normal; font-size: 11px;">(le .exe à lancer dans le dossier d'install — varie selon la version d'Unreal Engine du build)</span></label>
<input type="text" name="executable" placeholder="PROSERVE_UE_5_7.exe" value="PROSERVE_UE_5_7.exe" required pattern="[A-Za-z0-9_.\-]+\.exe" maxlength="120">
</div>
<div class="field"> <div class="field">
<label>Channels qui voient cette version <span class="muted" style="font-weight: normal; font-size: 11px;">(coche au moins « default » pour rendre publique, ou un channel privé pour la cibler)</span></label> <label>Channels qui voient cette version <span class="muted" style="font-weight: normal; font-size: 11px;">(coche au moins « default » pour rendre publique, ou un channel privé pour la cibler)</span></label>
<div style="display: flex; flex-wrap: wrap; gap: 12px; padding: 8px 0;"> <div style="display: flex; flex-wrap: wrap; gap: 12px; padding: 8px 0;">

View File

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

View File

@@ -1081,6 +1081,29 @@ public sealed partial class MainViewModel : ObservableObject
}); });
await _zipInstaller.InstallAsync(zipPath, target, installProgress, ct); 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 // Promote le ZIP au cache LAN (sidecar SHA écrit) puis prune les vieilles
// versions selon MaxCachedVersions. Le ZIP est déjà au bon endroit // versions selon MaxCachedVersions. Le ZIP est déjà au bon endroit
// (DownloadManager.VerifyAndFinalizeAsync l'a Move'd vers `proserve-X.zip`), // (DownloadManager.VerifyAndFinalizeAsync l'a Move'd vers `proserve-X.zip`),
@@ -1311,6 +1334,83 @@ public sealed partial class MainViewModel : ObservableObject
} }
} }
/// <summary>
/// Si <c>_redist/</c> 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).
/// </summary>
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) private async Task UninstallVersionAsync(VersionRowViewModel row)
{ {
if (row.Installed is null) return; if (row.Installed is null) return;

View File

@@ -8,4 +8,18 @@ public interface IInstallationRegistry
InstalledVersion? Get(string version); InstalledVersion? Get(string version);
bool IsInstalled(string version); bool IsInstalled(string version);
Task DeleteAsync(string version, IProgress<double>? progress, CancellationToken ct); Task DeleteAsync(string version, IProgress<double>? progress, CancellationToken ct);
/// <summary>
/// Écrit le metadata <c>.proserve-meta.json</c> dans le dossier d'install avec
/// le nom du .exe à lancer (récupéré du manifest serveur). À appeler par
/// <c>MainViewModel</c> après l'extraction du ZIP, pour que le futur <c>Scan</c>
/// trouve le bon exe sans avoir besoin du manifest en mémoire.
/// </summary>
Task WriteInstallMetadataAsync(string installDir, string executableName, CancellationToken ct);
/// <summary>Marque les redists comme installés (timestamp UTC) dans la metadata.</summary>
Task MarkRedistInstalledAsync(string installDir, CancellationToken ct);
/// <summary>Vrai si la metadata indique que les redists ont déjà été installés.</summary>
bool IsRedistInstalled(string installDir);
} }

View File

@@ -1,3 +1,4 @@
using System.Text.Json;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using PSLauncher.Models; using PSLauncher.Models;
@@ -6,7 +7,20 @@ namespace PSLauncher.Core.Installations;
public sealed partial class InstallationRegistry : IInstallationRegistry public sealed partial class InstallationRegistry : IInstallationRegistry
{ {
private const string ExecutableName = "PROSERVE_UE_5_5.exe"; /// <summary>
/// Fichier metadata écrit à l'install par MainViewModel. Contient le nom de l'exe
/// déclaré dans le manifest pour cette version. Permet à <see cref="Scan"/> de
/// retrouver le bon .exe à lancer sans avoir le manifest en mémoire (cas du
/// démarrage offline ou avant le 1er Check Updates).
/// </summary>
public const string MetadataFileName = ".proserve-meta.json";
/// <summary>
/// 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.
/// </summary>
private const string FallbackExePattern = "PROSERVE_UE_*.exe";
// IgnoreCase pour matcher à la fois les anciens dossiers "Proserve vX.Y.Z" et les // 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). // 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); var match = VersionFolderRegex().Match(name);
if (!match.Success) continue; if (!match.Success) continue;
var exe = Path.Combine(dir, ExecutableName); var exe = ResolveExecutable(dir);
if (!File.Exists(exe)) 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; continue;
} }
@@ -59,6 +73,107 @@ public sealed partial class InstallationRegistry : IInstallationRegistry
.ToList(); .ToList();
} }
/// <summary>
/// Résout le .exe à lancer pour un dossier d'install donné. Stratégie :
/// 1. Lit <c>.proserve-meta.json</c> (écrit par MainViewModel à l'install,
/// contient l'exe déclaré dans le manifest serveur — source canonique).
/// 2. Fallback glob <c>PROSERVE_UE_*.exe</c> pour les vieux installs sans meta.
/// 3. Null si aucun match (dossier corrompu ou pas un install PROSERVE).
/// </summary>
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<InstallMetadata>(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;
}
/// <summary>
/// Écrit le fichier metadata <c>.proserve-meta.json</c> dans le dossier d'install
/// fraîchement extrait. Appelé par <see cref="MainViewModel"/> après le ZipInstaller,
/// avec l'exe déclaré dans le manifest (<c>VersionManifest.Executable</c>).
/// </summary>
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);
}
/// <summary>
/// Marque dans la metadata qu'on a installé avec succès les redists Unreal
/// (contenu de <c>_redist/</c> de la version). Évite de re-runner les UAC
/// popups si l'utilisateur réinstalle la même version.
/// </summary>
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<InstallMetadata>(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);
}
/// <summary>
/// Vrai si l'install dir a déjà eu son <c>_redist/</c> installé une fois (via
/// metadata). Permet de skip le UAC popup chain à chaque réinstall de la même
/// version.
/// </summary>
public bool IsRedistInstalled(string installDir)
{
var path = Path.Combine(installDir, MetadataFileName);
if (!File.Exists(path)) return false;
try
{
var meta = JsonSerializer.Deserialize<InstallMetadata>(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) => public InstalledVersion? Get(string version) =>
Scan().FirstOrDefault(v => v.Version == version); Scan().FirstOrDefault(v => v.Version == version);

View File

@@ -676,6 +676,22 @@ public static class Strings
); );
public static string MsgBoxLanCacheChange => T("Cache LAN P2P", "LAN P2P cache", "局域网 P2P 缓存", "แคช LAN P2P", "ذاكرة التخزين المؤقت LAN P2P"); 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) ---- // ---- 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 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 (غير متصل)"); public static string ManifestSourcePeer => T("📡 Peer LAN (hors ligne)", "📡 LAN peer (offline)", "📡 局域网 Peer离线", "📡 LAN Peer (ออฟไลน์)", "📡 LAN Peer (غير متصل)");