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:
@@ -11,7 +11,7 @@
|
||||
|
||||
#define MyAppName "PROSERVE Launcher"
|
||||
#define MyAppShortName "PS_Launcher"
|
||||
#define MyAppVersion "0.27.2"
|
||||
#define MyAppVersion "0.27.4"
|
||||
#define MyAppPublisher "ASTERION VR"
|
||||
#define MyAppURL "https://asterionvr.com"
|
||||
#define MyAppExeName "PS_Launcher.exe"
|
||||
|
||||
@@ -166,11 +166,16 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$zipFilename = $zipFilenameProposed;
|
||||
$channels = sanitizeChannels((array)($_POST['channels'] ?? []), $channelNames);
|
||||
$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 = [
|
||||
'id' => $entryId,
|
||||
'version' => $version,
|
||||
'releasedAt' => $releasedAtIso,
|
||||
'executable' => 'PROSERVE_UE_5_5.exe',
|
||||
'executable' => $exeName,
|
||||
'installFolderTemplate' => 'PROSERVE v{version}',
|
||||
'channels' => $channels,
|
||||
'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>
|
||||
<input type="text" name="zip_filename" placeholder="proserve-1.4.8-police.zip" maxlength="120">
|
||||
</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">
|
||||
<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;">
|
||||
|
||||
@@ -18,9 +18,9 @@
|
||||
<Product>PROSERVE Launcher</Product>
|
||||
<Copyright>© 2026 ASTERION VR — All rights reserved</Copyright>
|
||||
<RootNamespace>PSLauncher.App</RootNamespace>
|
||||
<Version>0.27.2</Version>
|
||||
<AssemblyVersion>0.27.2.0</AssemblyVersion>
|
||||
<FileVersion>0.27.2.0</FileVersion>
|
||||
<Version>0.27.4</Version>
|
||||
<AssemblyVersion>0.27.4.0</AssemblyVersion>
|
||||
<FileVersion>0.27.4.0</FileVersion>
|
||||
|
||||
<!-- Single-file self-contained publish profile (used by `dotnet publish`) -->
|
||||
<PublishSingleFile>true</PublishSingleFile>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
/// <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)
|
||||
{
|
||||
if (row.Installed is null) return;
|
||||
|
||||
@@ -8,4 +8,18 @@ public interface IInstallationRegistry
|
||||
InstalledVersion? Get(string version);
|
||||
bool IsInstalled(string version);
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
/// <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
|
||||
// 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();
|
||||
}
|
||||
|
||||
/// <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) =>
|
||||
Scan().FirstOrDefault(v => v.Version == version);
|
||||
|
||||
|
||||
@@ -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 (غير متصل)");
|
||||
|
||||
Reference in New Issue
Block a user