From 9b17dfa84cefb13a1ccb5d6cbde62c36590962c9 Mon Sep 17 00:00:00 2001 From: "j.foucher" Date: Mon, 11 May 2026 15:11:57 +0200 Subject: [PATCH] =?UTF-8?q?v0.27.4=20=E2=80=94=20Exe=20per-version=20confi?= =?UTF-8?q?gurable=20+=20auto-install=20redists=20Unreal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit == 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) --- installer/PSLauncher.iss | 2 +- server/admin/versions.php | 11 +- src/PSLauncher.App/PSLauncher.App.csproj | 6 +- .../ViewModels/MainViewModel.cs | 100 ++++++++++++++ .../Installations/IInstallationRegistry.cs | 14 ++ .../Installations/InstallationRegistry.cs | 123 +++++++++++++++++- src/PSLauncher.Core/Localization/Strings.cs | 16 +++ 7 files changed, 263 insertions(+), 9 deletions(-) diff --git a/installer/PSLauncher.iss b/installer/PSLauncher.iss index f1a43c6..794bee4 100644 --- a/installer/PSLauncher.iss +++ b/installer/PSLauncher.iss @@ -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" diff --git a/server/admin/versions.php b/server/admin/versions.php index 8d0685c..3efb793 100644 --- a/server/admin/versions.php +++ b/server/admin/versions.php @@ -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'); +
+ + +
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 (غير متصل)");