Compare commits

...

2 Commits

Author SHA1 Message Date
500f7d12e6 v0.29.10 — Admin UI refonte (modals + onglets), emails de release, 4-digit versions
Admin backoffice — UI redesign :
 - Pages versions.php + licenses.php : remplace les <details> inline qui
   débordaient horizontalement par UN bouton « ✎ Modifier » par row qui
   ouvre une modal <dialog> avec onglets. 5 tabs versions (Méta, Notes,
   BÊTA, Channels, Avancé) + 6 tabs licenses (Prolonger, Slots, Channel,
   BÊTA, Lock, Contacts, Machines). Délégation JS unique pour les onglets.
 - Bouton 📋 Copier la clé license dans chaque row (Clipboard API + feedback
   visuel ✓ vert 1.5s). Évite le détour par phpMyAdmin pour transmettre la
   clé aux clients.
 - Overlay « hashing en cours » plein écran sur tous les boutons de hash
   (3-5 min sur OVH pour 13 Go ZIP). Spinner CSS + message contextualisé
   par scope (bulk vs single).
 - Date de release passée de datetime-local à date (l'heure n'a pas de
   sens UX), avec défaut = aujourd'hui pour release_at et aujourd'hui-1an
   pour min_license_date (= license standard couvre les releases sur 1 an).

Emails de notification release :
 - Migration 004 : colonne contact_emails TEXT NULL sur licenses (CSV)
 - Onglet « Contacts » sur la modal licenses pour saisir les emails par
   license (parsing tolérant : CSV, ligne par ligne, point-virgule)
 - Bouton « ✉ Notifier » par version : POST notify_release filtre les
   licenses éligibles (channel match + min_license_date + can_see_betas
   pour BÊTA) et envoie un email HTML à chaque contact (dédup global)
 - Template email table-based + bgcolor (compat Outlook/Word engine),
   navy foncé #0F172A, logo Asterion en CID embed (= affichage direct
   sans demande de permission Outlook), bouton download installer
   centré (align="center" + margin auto), release notes en <pre>
 - Mailer.php helper : parse emails, multipart/related avec attachments
   inline, fallback execCommand pour clipboard

4-digit version support (X.Y.Z.B) :
 - SemVer Parse/CompareTo/ToString gèrent 3 ou 4 digits ; Build absent =
   0 implicite (1.5.4 == 1.5.4.0 < 1.5.4.13). HasExplicitBuild préserve
   le format d'origine au round-trip.
 - Regex InstallationRegistry étendue avec (?:\.\d+)? → reconnaît
   « PROSERVE v1.5.4.13 » côte-à-côte avec « PROSERVE v1.5.4 » sur disque
 - Server-side : versions.php, launcher.php, DownloadUrl.php, api/index.php,
   Releasenotes.php — toutes les regex de validation acceptent le 4ᵉ digit
 - Use case : dev/test iterations cohabitant avec leur release stable

Bugs fixes :
 - migrate.php : strip ligne par ligne les commentaires SQL avant le
   check is-empty. Sans ça, le PREMIER chunk d'un fichier migration
   (= header + premier ALTER) commençait par `--` et était silencieusement
   skip → ALTER jamais appliqué. Affectait migrations 003, 004.
 - SignManifest::getOrComputeSha256 ignorait son cache interne quand force
   demandé par le caller, retournant l'ancien hash en 0 ms même après
   re-upload SFTP (avec mtime préservé). Propage maintenant le flag $force.
   Bouton « 🔁 Hash » per-row force maintenant un re-calcul systématique.
 - DownloadManager : 416 (Range Not Satisfiable) ajouté aux URL-refresh
   triggers, avec HEAD probe pour comparer taille serveur vs manifest →
   message d'erreur explicite si ZIP tronqué. Bps display lissé sur une
   fenêtre glissante de 12 samples (3 s) → plus de clignotement quand un
   segment finit / Polly retry. SHA mismatch popup enrichi avec les
   deux SHAs (attendu vs calculé) extraits via regex de l'exception.
 - DownloadUrl.php : signature de l'URL utilisait un template hardcodé
   /builds/proserve-{version}.zip, ignorant tout rename serveur. Lit
   maintenant download.url du manifest et signe le filename réel.

Strings i18n (5 langues) :
 - ~15 nouveaux : SHA mismatch enrichi avec sources, 416 size mismatch,
   stale manifest, manifest refreshed auto-retry, force fresh menu

Bumps : 0.29.7 → 0.29.10 (4-digit support + accumulated UI fixes).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 17:24:50 +02:00
50da755a92 v0.29.7 — SteamVR merge + résilience DL (404 retry, force fresh, SHA purge)
SteamVR + Vive Business Streaming settings merge :
 - Nouveau ISteamVrSettingsDeployer : si _steamvr/steamvr.vrsettings est
   dans le ZIP, deep-merge récursif des blocs racine dans le fichier user
   (clé absente → ajout, deux objets → recurse, sinon → replace ; les
   sous-clés cible non touchées sont préservées — crucial pour "trackers")
 - Auto-localisation via registre Steam (HKCU/HKLM) + fallback Program Files
 - Phase pré-check (CheckMergeNeededAsync) : skip silencieux si la cible
   est déjà à jour (deep equality) → pas de kill SteamVR/VBS inutile, pas
   de popup à l'opérateur. Si changements nécessaires → popup OK/Annuler
   avec nombre de blocs qui changeront + liste des process à fermer.
 - Liste de kill construite depuis les health checks de type "Process"
   (l'opérateur connaît déjà sa stack VR) + guardians (Vive Business
   Streaming en tête car il relance SteamVR auto)
 - Settings UI : section dédiée avec opt-in, override path, liste process
   éditable + affichage du path canonique attendu

Résilience téléchargements :
 - 404 mid-DL traité comme 403/410 (URL refresh trigger) au niveau segment :
   on extrait le nouveau filename via /download-url server-side, retry transparent
 - Auto-retry install une fois après refresh manifest sur 404 : l'opérateur
   ne voit rien si la cause était un manifest local stale
 - Si retry échoue aussi en 404 → message ciblé "manifest serveur stale"
   (problème côté serveur, pas client)
 - Sur SHA-256 mismatch : auto-purge du cache LAN local (.zip + .sha256)
   + message d'erreur dédié avec source du DL (peer ou OVH) + nouveau menu
   "↻ Forcer re-téléchargement" pour purger état + cache manuellement
 - Détection client-side de l'incohérence "signed URL filename != manifest
   URL filename" avant DL : abort immédiat plutôt que 14 Go pour rien
 - IZipCacheStore.InvalidateAsync : nouvelle API pour purger un cache par
   version (utilisée par SHA mismatch handler + Force Fresh menu)

Bug serveur (DownloadUrl.php) :
 - L'endpoint construisait l'URL signée avec un template hardcodé
   `/builds/proserve-{version}.zip`, ignorant complètement
   download.url du manifest. Conséquence : un opérateur qui rename
   son ZIP pour buster le cache CDN OVH (ex. proserve-full-1.5.4.zip)
   voyait toutes ses releases retournent du 404 silencieux côté client.
 - Fix : on lit basename(parse_url(entry.download.url).path), whitelist
   sur le filename, vérif is_file() avant de signer, erreur 500 explicite
   si manifest et filesystem désynchros.

Strings (5 langues) :
 - ~20 nouvelles : SteamVR popups + status, SHA mismatch dialog + source
   labels, force fresh confirm + menu, manifest stale + auto-retry status

Bumps : 0.29.6 (déployé hors-commit pendant la session) → 0.29.7.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 14:52:00 +02:00
36 changed files with 3430 additions and 279 deletions

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

View File

@@ -130,3 +130,163 @@ details[open] { background: var(--bg-elevated); padding: 8px; border-radius: 4px
a { color: var(--accent); }
a:hover { text-decoration: underline; }
/* ============================================================
Modal (<dialog>) — éditeur d'une entrée du manifest
Remplace les <details> inline qui débordaient horizontalement
============================================================ */
/* Styles visuels appliqués au dialog OUVERT ([open] est posé par
showModal() automatiquement). Sans le sélecteur [open], les règles
`display: flex` etc. écrasaient le `display: none` du user-agent
stylesheet → tous les dialogs apparaissaient inline au lieu d'être
cachés tant que pas explicitement ouverts. */
dialog.edit-modal[open] {
background: var(--bg-card); color: var(--text);
border: 1px solid var(--border); border-radius: 8px;
padding: 0; margin: auto;
width: min(640px, 92vw); max-height: 90vh;
overflow: hidden;
display: flex; flex-direction: column;
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.6);
}
dialog.edit-modal::backdrop {
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(2px);
}
dialog.edit-modal > header {
padding: 14px 20px; border-bottom: 1px solid var(--border);
display: flex; align-items: center; gap: 12px;
background: var(--bg-elevated);
}
dialog.edit-modal > header h3 {
margin: 0; font-size: 15px; font-weight: 600;
flex: 1; color: var(--text);
}
dialog.edit-modal > header .close-x {
background: transparent; border: none;
color: var(--text-secondary); cursor: pointer;
font-size: 20px; line-height: 1; padding: 4px 8px;
border-radius: 4px;
}
dialog.edit-modal > header .close-x:hover {
background: var(--bg-card); color: var(--text);
}
/* Onglets verticaux compacts en tête */
.modal-tabs {
display: flex; gap: 4px; padding: 8px 12px 0 12px;
border-bottom: 1px solid var(--border);
background: var(--bg-card);
flex-wrap: wrap;
}
.modal-tabs button {
background: transparent; border: none;
color: var(--text-secondary); cursor: pointer;
font-size: 13px; font-weight: 500;
padding: 8px 14px; border-radius: 4px 4px 0 0;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
}
.modal-tabs button:hover {
color: var(--text); background: var(--bg-elevated);
}
.modal-tabs button.active {
color: var(--text); border-bottom-color: var(--accent);
}
.modal-body {
flex: 1; overflow-y: auto;
padding: 20px;
}
.modal-body > section[hidden] { display: none; }
.modal-body > section { display: block; }
.modal-body .field:last-child { margin-bottom: 0; }
.modal-body .hint {
color: var(--text-secondary); font-size: 11px;
margin: 4px 0 0 0; line-height: 1.4;
}
.modal-body .warn {
color: #FBBF24; font-size: 11px;
margin: 4px 0 0 0; line-height: 1.4;
}
.modal-footer {
padding: 12px 20px;
border-top: 1px solid var(--border);
background: var(--bg-elevated);
display: flex; justify-content: flex-end; gap: 8px;
}
/* Tooltip-style note in the modal sections explaining a section purpose */
.section-intro {
background: var(--bg-input); border-left: 3px solid var(--accent);
padding: 10px 14px; border-radius: 4px;
font-size: 12px; color: var(--text-secondary);
margin-bottom: 16px; line-height: 1.5;
}
/* Liste de checkboxes verticale propre (pour Channels) */
.checkbox-list {
display: flex; flex-direction: column; gap: 2px;
}
.checkbox-list label {
display: flex; align-items: center; gap: 10px;
padding: 8px 10px; border-radius: 4px;
font-size: 13px; color: var(--text); font-weight: normal;
cursor: pointer; margin-bottom: 0;
}
.checkbox-list label:hover { background: var(--bg-elevated); }
.checkbox-list label code { flex: 0 0 auto; }
.checkbox-list label .muted { margin-left: auto; }
/* Actions inline d'une row de version : harmonise les hauteurs et l'espacement */
.row-actions {
display: inline-flex; gap: 6px; align-items: center;
flex-wrap: nowrap; justify-content: flex-end;
}
.row-actions .btn { white-space: nowrap; }
/* ============================================================
Overlay blocant affiché pendant les opérations longues
(re-hash d'un ZIP de 13 Go = 3-5 min côté serveur OVH).
Visible tant que le browser n'a pas reçu la réponse POST,
disparaît naturellement au reload de page suivant.
============================================================ */
.busy-overlay {
position: fixed; inset: 0;
background: rgba(0, 0, 0, 0.85);
backdrop-filter: blur(4px);
z-index: 9999;
display: flex; align-items: center; justify-content: center;
}
.busy-overlay[hidden] { display: none; }
.busy-content {
background: var(--bg-card);
border: 1px solid var(--border);
padding: 40px 56px;
border-radius: 12px;
text-align: center;
max-width: 480px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
}
.busy-content h3 {
margin: 0 0 10px;
font-size: 18px;
color: var(--text);
text-transform: none; letter-spacing: 0;
}
.busy-content p {
margin: 6px 0;
color: var(--text-secondary);
font-size: 13px;
line-height: 1.6;
}
.busy-spinner {
width: 56px; height: 56px;
border: 4px solid var(--bg-elevated);
border-top-color: var(--accent);
border-radius: 50%;
animation: busy-spin 1s linear infinite;
margin: 0 auto 24px;
}
@keyframes busy-spin { to { transform: rotate(360deg); } }

View File

@@ -42,10 +42,11 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if ($action === 'set_launcher') {
$lver = trim($_POST['launcher_version'] ?? '');
$lmin = trim($_POST['launcher_min_required'] ?? '');
if (!preg_match('/^\d+\.\d+\.\d+$/', $lver)) {
throw new Exception('Numéro de version launcher invalide (X.Y.Z attendu).');
// X.Y.Z (release publique) ou X.Y.Z.B (itération dev/test interne).
if (!preg_match('/^\d+\.\d+\.\d+(\.\d+)?$/', $lver)) {
throw new Exception('Numéro de version launcher invalide (X.Y.Z ou X.Y.Z.B attendu).');
}
if ($lmin !== '' && !preg_match('/^\d+\.\d+\.\d+$/', $lmin)) {
if ($lmin !== '' && !preg_match('/^\d+\.\d+\.\d+(\.\d+)?$/', $lmin)) {
throw new Exception('Numéro de version "minRequired" invalide.');
}
$base = $config['base_url'] ?? 'https://asterionvr.com/PS_Launcher';
@@ -145,14 +146,14 @@ Layout::header('Launcher', 'launcher');
<input type="hidden" name="action" value="set_launcher">
<div class="row">
<div class="col field">
<label>Version launcher (X.Y.Z)</label>
<label>Version launcher (X.Y.Z ou X.Y.Z.B)</label>
<input type="text" name="launcher_version" placeholder="0.6.0"
value="<?= htmlspecialchars($launcher['version'] ?? '') ?>" required pattern="\d+\.\d+\.\d+">
value="<?= htmlspecialchars($launcher['version'] ?? '') ?>" required pattern="\d+\.\d+\.\d+(\.\d+)?">
</div>
<div class="col field">
<label>Version minimale requise (X.Y.Z)</label>
<label>Version minimale requise (X.Y.Z ou X.Y.Z.B)</label>
<input type="text" name="launcher_min_required" placeholder="0.5.0"
value="<?= htmlspecialchars($launcher['minRequired'] ?? '0.5.0') ?>" pattern="\d+\.\d+\.\d+">
value="<?= htmlspecialchars($launcher['minRequired'] ?? '0.5.0') ?>" pattern="\d+\.\d+\.\d+(\.\d+)?">
</div>
</div>
<button class="btn btn-success" type="submit">Définir</button>

201
server/admin/lib/Mailer.php Normal file
View File

@@ -0,0 +1,201 @@
<?php
declare(strict_types=1);
namespace PSLauncher\Admin;
/**
* Petit helper d'envoi d'emails de notification (release announce).
*
* Stratégie : on utilise <code>mail()</code> natif PHP, qui sur OVH mutualisé
* passe par le relais SMTP local et marche out-of-the-box sans config
* additionnelle. Pas de dépendance composer (PHPMailer, etc.) volontairement
* — on a besoin de notifications opportunistes, pas de delivery garantie.
*
* Pour passer à PHPMailer / SMTP externe plus tard, la surface API reste la
* même : on remplace juste <see cref="sendInternal"/> par un appel
* <code>$mailer->send()</code>.
*/
final class Mailer
{
/**
* Parse un blob texte saisi par l'admin (CSV, ligne par ligne, mélange…)
* en liste d'emails normalisés et validés. Tout token qui ne matche pas
* la regex email est rejeté SILENCIEUSEMENT (l'admin n'a pas besoin d'un
* popup pour un copy/paste avec une virgule en trop).
*
* @return list<string> emails uniques lowercase
*/
public static function parseEmails(?string $raw): array
{
if ($raw === null || trim($raw) === '') return [];
// Split sur tout séparateur courant : virgule, point-virgule, espace,
// newline. Permet à l'admin de coller un copy/paste depuis Outlook,
// Excel, ou ligne par ligne — peu importe.
$tokens = preg_split('/[\s,;]+/', $raw, -1, PREG_SPLIT_NO_EMPTY) ?: [];
$out = [];
foreach ($tokens as $t) {
$t = strtolower(trim($t));
if ($t === '') continue;
if (!filter_var($t, FILTER_VALIDATE_EMAIL)) continue;
if (!in_array($t, $out, true)) $out[] = $t;
}
return $out;
}
/**
* Inverse : prend un tableau d'emails et le formate pour stockage DB
* (séparé par virgules). NULL si la liste est vide pour économiser
* une ligne en DB sur les licenses sans contacts.
*/
public static function joinForStorage(array $emails): ?string
{
$emails = array_values(array_filter(
array_map('trim', $emails),
fn($e) => $e !== '' && filter_var($e, FILTER_VALIDATE_EMAIL)
));
return empty($emails) ? null : implode(', ', $emails);
}
/**
* Envoie un email HTML (avec partie texte alternative pour les MUA legacy)
* à un destinataire. Best-effort : retourne true en cas de succès apparent
* du sink mail(), false si rejet immédiat. Erreurs de delivery côté SMTP
* relais (bounce, deferred) ne sont PAS détectées ici — on log juste le
* résultat brut.
*
* Si <paramref name="$inlineImages"/> est non vide, on construit une structure
* MIME <c>multipart/related</c> (CID embed) au lieu de <c>multipart/alternative</c>.
* Les images sont attachées comme pièces jointes inline, référencées dans
* le HTML via <c>&lt;img src="cid:..."&gt;</c>. Avantage vs hotlink HTTP :
* Outlook (et MUA généralement) affiche l'image SANS demander de permission
* (vs hotlink bloqué par défaut pour anti-tracking pixel).
*
* @param array $notifConfig bloc $config['notifications'] (from_address, from_name, reply_to)
* @param string $to destinataire UNIQUE (pour multi-destinataires, appeler en boucle)
* @param string $subject sujet en clair (sera Q-encoded UTF-8 automatiquement)
* @param string $bodyHtml contenu HTML (multipart alt avec text auto-généré)
* @param array $inlineImages liste d'images à embed inline. Chaque entrée :
* ['cid' => 'logo', 'path' => '/abs/path/file.png', 'type' => 'image/png']
*/
public static function send(array $notifConfig, string $to, string $subject, string $bodyHtml, array $inlineImages = []): bool
{
$from = $notifConfig['from_address'] ?? 'no-reply@localhost';
$fromName = $notifConfig['from_name'] ?? 'PROSERVE Launcher';
$replyTo = $notifConfig['reply_to'] ?? '';
// Filtre les inline images : ne garde que celles dont le fichier existe
// ET est lisible. Si une image manque, on log silencieusement et on
// continue avec les autres — l'email part de toute façon.
$validImages = [];
foreach ($inlineImages as $img) {
$p = $img['path'] ?? '';
if ($p === '' || !is_file($p) || !is_readable($p)) continue;
$validImages[] = $img;
}
// Boundaries uniques. Si on a des images, on a 2 niveaux d'imbrication :
// multipart/related contenant multipart/alternative + image(s).
$boundaryAlt = '=_PSLauncherAlt_' . bin2hex(random_bytes(8));
$boundaryRel = '=_PSLauncherRel_' . bin2hex(random_bytes(8));
// Headers RFC 5322 + MIME. Pas de "Bcc:" — on appelle send() par destinataire
// pour avoir des "Per-user emails" et faciliter le debugging.
$contentType = empty($validImages)
? "multipart/alternative; boundary=\"{$boundaryAlt}\""
: "multipart/related; type=\"multipart/alternative\"; boundary=\"{$boundaryRel}\"";
$headers = [
'MIME-Version: 1.0',
"Content-Type: {$contentType}",
'From: ' . self::formatAddress($fromName, $from),
'X-Mailer: PSLauncher-Admin',
'X-Auto-Response-Suppress: All', // mute autoresponders OOO
];
if ($replyTo !== '' && filter_var($replyTo, FILTER_VALIDATE_EMAIL)) {
$headers[] = 'Reply-To: ' . $replyTo;
}
// === Construction du body ===
$bodyText = self::htmlToPlainText($bodyHtml);
// Bloc multipart/alternative (text + html)
$altPart = "--{$boundaryAlt}\r\n";
$altPart .= "Content-Type: text/plain; charset=UTF-8\r\n";
$altPart .= "Content-Transfer-Encoding: 8bit\r\n\r\n";
$altPart .= $bodyText . "\r\n\r\n";
$altPart .= "--{$boundaryAlt}\r\n";
$altPart .= "Content-Type: text/html; charset=UTF-8\r\n";
$altPart .= "Content-Transfer-Encoding: 8bit\r\n\r\n";
$altPart .= $bodyHtml . "\r\n\r\n";
$altPart .= "--{$boundaryAlt}--\r\n";
if (empty($validImages)) {
// Pas d'images → email simple, juste l'alternative
$body = $altPart;
} else {
// Avec images → multipart/related wrappant l'alt + chaque image
$body = "--{$boundaryRel}\r\n";
$body .= "Content-Type: multipart/alternative; boundary=\"{$boundaryAlt}\"\r\n\r\n";
$body .= $altPart;
foreach ($validImages as $img) {
$cid = $img['cid'] ?? bin2hex(random_bytes(6));
$type = $img['type'] ?? 'application/octet-stream';
$path = $img['path'];
$name = basename($path);
$data = @file_get_contents($path);
if ($data === false) continue;
$b64 = chunk_split(base64_encode($data), 76, "\r\n");
$body .= "\r\n--{$boundaryRel}\r\n";
$body .= "Content-Type: {$type}; name=\"{$name}\"\r\n";
$body .= "Content-Transfer-Encoding: base64\r\n";
$body .= "Content-ID: <{$cid}>\r\n";
$body .= "Content-Disposition: inline; filename=\"{$name}\"\r\n\r\n";
$body .= $b64;
}
$body .= "\r\n--{$boundaryRel}--\r\n";
}
$subjectEncoded = '=?UTF-8?B?' . base64_encode($subject) . '?=';
try {
return @mail($to, $subjectEncoded, $body, implode("\r\n", $headers));
} catch (\Throwable $e) {
return false;
}
}
/**
* Convertit un HTML simple en plain text pour la partie text/plain du
* multipart. Pas une vraie conversion (ce n'est pas html2text), mais
* suffisant pour nos templates qui sont quasi-text avec quelques tags.
*/
private static function htmlToPlainText(string $html): string
{
// Convert <br> et <p> en sauts de ligne avant strip_tags
$s = preg_replace('#<br\s*/?>#i', "\n", $html) ?? $html;
$s = preg_replace('#</p>\s*<p[^>]*>#i', "\n\n", $s) ?? $s;
$s = preg_replace('#<a\s[^>]*href="([^"]+)"[^>]*>([^<]+)</a>#i', '$2 ($1)', $s) ?? $s;
$s = strip_tags($s);
$s = html_entity_decode($s, ENT_QUOTES | ENT_HTML5, 'UTF-8');
return trim($s);
}
/**
* "Pretty Name <email@x>" si name non vide, sinon "email@x" brut.
* Échappe les chars spéciaux conformément à RFC 5322.
*/
private static function formatAddress(string $name, string $email): string
{
$name = trim($name);
if ($name === '') return $email;
// Encode le name en MIME si pas pur ASCII
if (preg_match('/[\x80-\xFF]/', $name)) {
$name = '=?UTF-8?B?' . base64_encode($name) . '?=';
} else {
// Quote si chars spéciaux RFC : "()<>@,;:\\\"/.\\[\\]"
if (preg_match('/[()<>@,;:\\\"\/\[\]]/', $name)) {
$name = '"' . str_replace('"', '\\"', $name) . '"';
}
}
return "{$name} <{$email}>";
}
}

View File

@@ -3,11 +3,13 @@ declare(strict_types=1);
require __DIR__ . '/lib/Auth.php';
require __DIR__ . '/lib/Layout.php';
require __DIR__ . '/lib/Channels.php';
require __DIR__ . '/lib/Mailer.php';
require __DIR__ . '/../api/lib/Db.php';
use PSLauncher\Admin\Auth;
use PSLauncher\Admin\Layout;
use PSLauncher\Admin\Channels;
use PSLauncher\Admin\Mailer;
use PSLauncher\Db;
Auth::requireLogin();
@@ -117,13 +119,14 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$message = "License #{$id} : channel passé à « {$label} ». Le client devra rouvrir le launcher pour prendre en compte le changement (revalidation license).";
}
elseif ($action === 'toggle_betas') {
// Renommé semantically (toggle → set explicit) sans casser le nom
// de l'action POST pour préserver les éventuels liens externes
// existants. La modal envoie maintenant `enabled=1` si checkbox
// cochée, sinon le param est absent (= 0). Plus de flip aveugle.
$id = (int)($_POST['id'] ?? 0);
$stmt = $db->prepare('UPDATE licenses SET can_see_betas = 1 - can_see_betas WHERE id = ?');
$stmt->execute([$id]);
$row = $db->prepare('SELECT can_see_betas FROM licenses WHERE id = ?');
$row->execute([$id]);
$now = (int)$row->fetchColumn();
$message = $now
$enabled = isset($_POST['enabled']) ? 1 : 0;
$db->prepare('UPDATE licenses SET can_see_betas = ? WHERE id = ?')->execute([$enabled, $id]);
$message = $enabled
? "License #{$id} : accès aux versions BÊTA activé."
: "License #{$id} : accès aux versions BÊTA désactivé.";
}
@@ -165,6 +168,27 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$message = "License #{$id} : mot de passe des paramètres avancés mis à jour.";
}
}
elseif ($action === 'set_contact_emails') {
// Met à jour la liste des emails de contact pour les notifications
// de nouvelles versions. L'admin saisit en texte libre (1 par ligne,
// virgules, point-virgules — peu importe), on parse + valide via
// FILTER_VALIDATE_EMAIL et on stocke en CSV. Vide / aucun email
// valide → on stocke NULL (= pas de notif possible pour cette
// license, juste skip silencieux à l'envoi).
$id = (int)($_POST['id'] ?? 0);
$raw = (string)($_POST['contact_emails'] ?? '');
if ($id <= 0) {
throw new Exception('License id invalide.');
}
$parsed = Mailer::parseEmails($raw);
$stored = Mailer::joinForStorage($parsed);
$db->prepare('UPDATE licenses SET contact_emails = ? WHERE id = ?')
->execute([$stored, $id]);
$count = count($parsed);
$message = $count === 0
? "License #{$id} : contacts de notification supprimés."
: "License #{$id} : {$count} contact(s) email enregistré(s) pour les notifications de release.";
}
elseif ($action === 'reset_machines') {
$id = (int)($_POST['id'] ?? 0);
$db->prepare('DELETE FROM license_machines WHERE license_id = ?')->execute([$id]);
@@ -295,8 +319,21 @@ Layout::header('Licenses', 'licenses');
<td>#<?= $l['id'] ?></td>
<td>
<strong><?= htmlspecialchars($l['owner_name']) ?></strong>
<?php // Clé license affichée en monospace + bouton 1-clic
// pour copier dans le presse-papier. Permet à l'admin
// de la transmettre rapidement à un client sans passer
// par phpMyAdmin. Sensibilité OK : la page admin est
// déjà derrière auth + la clé seule ne suffit pas
// (validation serveur HMAC + machine_id requis). ?>
<div style="font-size: 11px; margin-top: 4px; display: flex; align-items: center; gap: 6px;">
<code style="background: rgba(0,0,0,0.3); padding: 2px 6px; border-radius: 3px; font-family: 'Cascadia Code', Consolas, monospace; font-size: 11px; color: #C5E1A5; word-break: break-all;"><?= htmlspecialchars($l['license_key']) ?></code>
<button type="button" class="copy-btn"
data-copy="<?= htmlspecialchars($l['license_key'], ENT_QUOTES) ?>"
title="Copier la clé license"
style="background: transparent; border: 1px solid var(--border); color: var(--text-secondary); cursor: pointer; padding: 2px 8px; border-radius: 3px; font-size: 11px; line-height: 1;">📋</button>
</div>
<?php if (!empty($l['notes'])): ?>
<div class="muted" style="font-size: 11px;"><?= htmlspecialchars($l['notes']) ?></div>
<div class="muted" style="font-size: 11px; margin-top: 4px;"><?= htmlspecialchars($l['notes']) ?></div>
<?php endif; ?>
</td>
<td class="muted"><?= date('d/m/Y', strtotime($l['issued_at'])) ?></td>
@@ -313,119 +350,259 @@ Layout::header('Licenses', 'licenses');
<?php endif; ?>
</td>
<td>
<!-- Affichage de l'état actuel : channel + badge BÊTA si actif -->
<div style="margin-bottom: 6px;">
<code style="background: rgba(0,0,0,0.3); padding: 2px 6px; border-radius: 4px; font-size: 11px;"
title="Channel actuellement attribué à cette license">
<?= htmlspecialchars($l['channel'] ?? 'default') ?>
</code>
<?php if ((int)($l['can_see_betas'] ?? 0) === 1): ?>
<span class="badge badge-warning" style="margin-left: 4px;" title="Voit les versions taggées beta">β BÊTA</span>
<?php endif; ?>
</div>
<!-- Toggle BÊTA en bouton DIRECTEMENT visible (pas dans un details).
L'admin n'a plus à deviner qu'il faut cliquer sur le code du channel
pour trouver l'option. -->
<form method="post" style="display: inline-block; margin-right: 4px;">
<?= Layout::csrfField() ?>
<input type="hidden" name="action" value="toggle_betas">
<input type="hidden" name="id" value="<?= $l['id'] ?>">
<button class="btn <?= (int)($l['can_see_betas'] ?? 0) === 1 ? 'btn-warning' : 'btn-secondary' ?>"
type="submit" style="font-size: 11px; padding: 4px 10px;"
title="Bascule l'accès aux versions taggées BÊTA pour cette license">
<?= (int)($l['can_see_betas'] ?? 0) === 1 ? '✓ BÊTA actif' : 'Activer BÊTA' ?>
</button>
</form>
<!-- Channel : modifier via un menu compact -->
<details style="display: inline-block;">
<summary class="btn btn-secondary" style="font-size: 11px; padding: 4px 10px; list-style: none;">
✎ Channel
</summary>
<form method="post" style="margin-top: 8px; display: flex; gap: 4px; align-items: center;">
<?= Layout::csrfField() ?>
<input type="hidden" name="action" value="set_channel">
<input type="hidden" name="id" value="<?= $l['id'] ?>">
<select name="channel" style="min-width: 160px;">
<?php foreach ($availableChannels as $value => $label):
$selected = ($l['channel'] ?? '') === $value ? 'selected' : '';
?>
<option value="<?= htmlspecialchars($value) ?>" <?= $selected ?>><?= htmlspecialchars($label) ?></option>
<?php endforeach; ?>
</select>
<button class="btn btn-primary" type="submit">OK</button>
</form>
</details>
<!-- READ-ONLY : channel actuel + badge BÊTA si actif. Tous
les éditeurs sont passés dans le modal pour éviter
l'encombrement de la cellule. -->
<code style="background: rgba(0,0,0,0.3); padding: 2px 6px; border-radius: 4px; font-size: 11px;"
title="Channel actuellement attribué">
<?= htmlspecialchars($l['channel'] ?? 'default') ?>
</code>
<?php if ((int)($l['can_see_betas'] ?? 0) === 1): ?>
<span class="badge badge-warning" style="margin-left: 4px;" title="Voit les versions taggées beta">β BÊTA</span>
<?php endif; ?>
<?php $hasLock = !empty($l['settings_lock_password_hash']); ?>
<?php if ($hasLock): ?>
<span class="badge badge-secondary" style="margin-left: 4px;" title="Section Settings → Avancés verrouillée par mot de passe">🔒</span>
<?php endif; ?>
</td>
<td><span class="badge <?= $cls ?>"><?= $status ?></span></td>
<td style="text-align:right; white-space: nowrap;">
<details style="display: inline-block; margin: 0 4px;">
<summary class="btn btn-secondary">Prolonger</summary>
<form method="post" style="margin-top: 8px; display: flex; gap: 4px; align-items: center;">
<?php $modalId = 'edit-license-' . (int)$l['id']; ?>
<div class="row-actions">
<button type="button" class="btn btn-primary"
onclick="document.getElementById('<?= $modalId ?>').showModal()">
✎ Modifier
</button>
<?php if (!$isRevoked): ?>
<form method="post" style="display:inline" onsubmit="return confirm('Révoquer définitivement cette license ?')">
<?= Layout::csrfField() ?>
<input type="hidden" name="action" value="revoke">
<input type="hidden" name="id" value="<?= $l['id'] ?>">
<button class="btn btn-danger" type="submit">✕ Révoquer</button>
</form>
<?php else: ?>
<form method="post" style="display:inline">
<?= Layout::csrfField() ?>
<input type="hidden" name="action" value="unrevoke">
<input type="hidden" name="id" value="<?= $l['id'] ?>">
<button class="btn btn-secondary" type="submit">↩ Réactiver</button>
</form>
<?php endif; ?>
</div>
</td>
</tr>
<?php
// Modal d'édition (capturé en buffer, flush après </table>).
// Même pattern que versions.php : <dialog> dans <table> est
// foster-parented imprévisiblement → on émet hors de la table.
ob_start();
?>
<dialog id="<?= $modalId ?>" class="edit-modal">
<header>
<h3>License #<?= $l['id'] ?> — <?= htmlspecialchars($l['owner_name']) ?></h3>
<button type="button" class="close-x" aria-label="Fermer"
onclick="this.closest('dialog').close()">×</button>
</header>
<nav class="modal-tabs">
<button type="button" data-tab="prolong" class="active">Prolonger</button>
<button type="button" data-tab="slots">Slots</button>
<button type="button" data-tab="channel">Channel</button>
<button type="button" data-tab="beta">BÊTA</button>
<button type="button" data-tab="lock">Lock</button>
<button type="button" data-tab="contacts">Contacts</button>
<button type="button" data-tab="machines">Machines</button>
</nav>
<div class="modal-body">
<!-- TAB : Prolonger -->
<section data-tab="prolong">
<p class="section-intro">
Repousse la date d'expiration de l'entitlement.
Actuelle : <strong><?= date('d/m/Y', strtotime($l['download_entitlement_until'])) ?></strong>
</p>
<form method="post">
<?= Layout::csrfField() ?>
<input type="hidden" name="action" value="extend">
<input type="hidden" name="id" value="<?= $l['id'] ?>">
<input type="date" name="new_until" required style="width: 150px;">
<button class="btn btn-primary" type="submit">OK</button>
<div class="field">
<label>Nouvelle date d'expiration</label>
<input type="date" name="new_until" required>
</div>
<div class="modal-footer" style="margin: 20px -20px -20px;">
<button type="button" class="btn btn-secondary" onclick="this.closest('dialog').close()">Annuler</button>
<button type="submit" class="btn btn-primary">Prolonger</button>
</div>
</form>
</details>
<details style="display: inline-block; margin: 0 4px;">
<summary class="btn btn-secondary">Slots</summary>
<form method="post" style="margin-top: 8px; display: flex; gap: 4px; align-items: center;">
</section>
<!-- TAB : Slots -->
<section data-tab="slots" hidden>
<p class="section-intro">
Nombre maximum de machines pouvant être activées simultanément.
Actuellement <strong><?= (int)$l['machines_count'] ?></strong>
active(s) sur <strong><?= (int)$l['max_machines'] ?></strong> autorisée(s).
Pour réduire en-dessous du nombre actif, libère d'abord les slots
via l'onglet « Machines ».
</p>
<form method="post">
<?= Layout::csrfField() ?>
<input type="hidden" name="action" value="set_max_machines">
<input type="hidden" name="id" value="<?= $l['id'] ?>">
<input type="number" name="max_machines" min="1" max="100"
value="<?= (int)$l['max_machines'] ?>" required
style="width: 70px;"
title="Nombre max de machines (≥ <?= (int)$l['machines_count'] ?> actuellement actives)">
<button class="btn btn-primary" type="submit">OK</button>
<div class="field">
<label>Slots max</label>
<input type="number" name="max_machines" min="1" max="100"
value="<?= (int)$l['max_machines'] ?>" required>
</div>
<div class="modal-footer" style="margin: 20px -20px -20px;">
<button type="button" class="btn btn-secondary" onclick="this.closest('dialog').close()">Annuler</button>
<button type="submit" class="btn btn-primary">Enregistrer Slots</button>
</div>
</form>
</details>
<?php $hasLock = !empty($l['settings_lock_password_hash']); ?>
<details style="display: inline-block; margin: 0 4px;">
<summary class="btn btn-secondary"
title="Mot de passe qui verrouille la section Settings → Avancés du launcher pour cette license. Laisser vide pour retirer le verrouillage.">
<?= $hasLock ? '🔒 Lock' : 'Lock' ?>
</summary>
<form method="post" style="margin-top: 8px; display: flex; gap: 4px; align-items: center;">
</section>
<!-- TAB : Channel -->
<section data-tab="channel" hidden>
<p class="section-intro">
Quel manifest sert cette license ? « default » = manifest public.
Les channels privés (gérés sur la page <a href="channels.php">Channels</a>)
permettent de servir des versions ciblées (police, firefighter, …).
</p>
<form method="post">
<?= Layout::csrfField() ?>
<input type="hidden" name="action" value="set_channel">
<input type="hidden" name="id" value="<?= $l['id'] ?>">
<div class="field">
<label>Channel</label>
<select name="channel">
<?php foreach ($availableChannels as $value => $label):
$selected = ($l['channel'] ?? '') === $value ? 'selected' : '';
?>
<option value="<?= htmlspecialchars($value) ?>" <?= $selected ?>><?= htmlspecialchars($label) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="modal-footer" style="margin: 20px -20px -20px;">
<button type="button" class="btn btn-secondary" onclick="this.closest('dialog').close()">Annuler</button>
<button type="submit" class="btn btn-primary">Enregistrer Channel</button>
</div>
</form>
</section>
<!-- TAB : BÊTA -->
<section data-tab="beta" hidden>
<p class="section-intro">
Si activé, le client voit en plus des versions stables toutes les
versions taggées <code>isBeta=true</code> dans le manifest. Sinon
elles lui sont invisibles (filtrées côté manifest signé).
</p>
<form method="post">
<?= Layout::csrfField() ?>
<input type="hidden" name="action" value="toggle_betas">
<input type="hidden" name="id" value="<?= $l['id'] ?>">
<div class="field">
<label>
<input type="checkbox" name="enabled" value="1"
<?= (int)($l['can_see_betas'] ?? 0) === 1 ? 'checked' : '' ?>>
Voir les versions BÊTA
</label>
</div>
<div class="modal-footer" style="margin: 20px -20px -20px;">
<button type="button" class="btn btn-secondary" onclick="this.closest('dialog').close()">Annuler</button>
<button type="submit" class="btn btn-primary">Enregistrer BÊTA</button>
</div>
</form>
</section>
<!-- TAB : Lock (settings password) -->
<section data-tab="lock" hidden>
<p class="section-intro">
Mot de passe qui verrouille la section Settings → Avancés du
launcher pour cette license. Vide = retirer le verrouillage.
Actuellement : <strong><?= $hasLock ? 'verrouillé 🔒' : 'pas verrouillé' ?></strong>
</p>
<form method="post">
<?= Layout::csrfField() ?>
<input type="hidden" name="action" value="set_settings_lock_password">
<input type="hidden" name="id" value="<?= $l['id'] ?>">
<input type="password" name="settings_lock_password"
placeholder="<?= $hasLock ? 'Nouveau mot de passe (vide = retirer)' : 'Mot de passe (≥4)' ?>"
minlength="0" maxlength="100"
style="width: 200px;"
autocomplete="new-password">
<button class="btn btn-primary" type="submit">OK</button>
<div class="field">
<label>Mot de passe (≥4 caractères, ou vide pour retirer)</label>
<input type="password" name="settings_lock_password"
placeholder="<?= $hasLock ? 'Nouveau mot de passe (vide = retirer)' : 'Mot de passe' ?>"
minlength="0" maxlength="100"
autocomplete="new-password">
</div>
<div class="modal-footer" style="margin: 20px -20px -20px;">
<button type="button" class="btn btn-secondary" onclick="this.closest('dialog').close()">Annuler</button>
<button type="submit" class="btn btn-primary">Enregistrer Lock</button>
</div>
</form>
</details>
<?php if ($l['machines_count'] > 0): ?>
<form method="post" style="display:inline" onsubmit="return confirm('Libérer TOUTES les machines de cette license ?\n\nUtilise plutôt « Voir machines » ci-dessous pour libérer un slot précis.')">
<?= Layout::csrfField() ?>
<input type="hidden" name="action" value="reset_machines">
<input type="hidden" name="id" value="<?= $l['id'] ?>">
<button class="btn btn-secondary" type="submit" title="Libère TOUS les slots machines de cette license d'un coup">Libérer toutes</button>
</form>
<?php endif; ?>
<?php if (!$isRevoked): ?>
<form method="post" style="display:inline" onsubmit="return confirm('Révoquer définitivement cette license ?')">
<?= Layout::csrfField() ?>
<input type="hidden" name="action" value="revoke">
<input type="hidden" name="id" value="<?= $l['id'] ?>">
<button class="btn btn-danger" type="submit">Révoquer</button>
</form>
<?php else: ?>
<form method="post" style="display:inline">
<?= Layout::csrfField() ?>
<input type="hidden" name="action" value="unrevoke">
<input type="hidden" name="id" value="<?= $l['id'] ?>">
<button class="btn btn-secondary" type="submit">Réactiver</button>
</form>
<?php endif; ?>
</td>
</tr>
</section>
<!-- TAB : Contacts (emails de notification release) -->
<section data-tab="contacts" hidden>
<p class="section-intro">
Liste d'adresses email à notifier quand tu publies une nouvelle
version de PROSERVE accessible à cette license (matching channel +
entitlement). Format libre : une adresse par ligne, virgules, ou
point-virgules. Adresses invalides ignorées silencieusement.
</p>
<?php
// Affichage en mode "1 par ligne" pour la lisibilité, peu importe
// comment c'est stocké en DB (CSV).
$currentEmails = Mailer::parseEmails($l['contact_emails'] ?? '');
$currentDisplay = implode("\n", $currentEmails);
?>
<form method="post">
<?= Layout::csrfField() ?>
<input type="hidden" name="action" value="set_contact_emails">
<input type="hidden" name="id" value="<?= $l['id'] ?>">
<div class="field">
<label>Adresses email <span class="muted" style="font-weight: normal;">(<?= count($currentEmails) ?> actuellement)</span></label>
<textarea name="contact_emails" rows="6"
placeholder="ops@asterionvr.com&#10;jerome@client.com&#10;technicien@partner.fr"
style="font-family: 'Cascadia Code', Consolas, monospace; font-size: 13px;"><?= htmlspecialchars($currentDisplay) ?></textarea>
<p class="hint" style="margin: 4px 0 0;">
Une par ligne, ou séparées par <code>,</code> / <code>;</code>.
Vider le champ = retirer toutes les notifications.
</p>
</div>
<div class="modal-footer" style="margin: 20px -20px -20px;">
<button type="button" class="btn btn-secondary" onclick="this.closest('dialog').close()">Annuler</button>
<button type="submit" class="btn btn-primary">Enregistrer Contacts</button>
</div>
</form>
</section>
<!-- TAB : Machines -->
<section data-tab="machines" hidden>
<p class="section-intro">
<strong><?= (int)$l['machines_count'] ?></strong> machine(s) active(s)
sur <strong><?= (int)$l['max_machines'] ?></strong> autorisée(s).
La gestion fine machine par machine se fait via le tableau dépliable
ci-dessous (clic sur « X / Y » dans la colonne Machines de la row).
</p>
<?php if ($l['machines_count'] > 0): ?>
<div class="field">
<label>Action en bloc</label>
<form method="post" style="display:inline"
onsubmit="return confirm('Libérer TOUTES les machines de cette license ?\n\nLes slots redeviendront disponibles pour de nouvelles activations.')">
<?= Layout::csrfField() ?>
<input type="hidden" name="action" value="reset_machines">
<input type="hidden" name="id" value="<?= $l['id'] ?>">
<button class="btn btn-warning" type="submit">Libérer TOUTES les machines</button>
</form>
</div>
<?php else: ?>
<p class="muted" style="font-style: italic;">Aucune machine active sur cette license.</p>
<?php endif; ?>
<div class="modal-footer" style="margin: 20px -20px -20px;">
<button type="button" class="btn btn-secondary" onclick="this.closest('dialog').close()">Fermer</button>
</div>
</section>
</div>
</dialog>
<?php $GLOBALS['__editDialogs'] = ($GLOBALS['__editDialogs'] ?? '') . ob_get_clean(); ?>
<?php
// Sous-row dépliable : liste des machines avec remove individuel.
@@ -472,6 +649,14 @@ Layout::header('Licenses', 'licenses');
<?php endif; ?>
</td>
<td style="text-align:right;">
<?php // Note : pas de "Déplacer vers une autre license" ici —
// ce serait une feature DB-only fragile (le launcher
// ré-activerait automatiquement la machine sur sa
// license d'origine au prochain validate puisque sa
// clé license stockée n'a pas changé). La migration
// d'une machine vers une autre license se fait côté
// launcher : Settings → License → entrer la nouvelle
// clé. ?>
<form method="post" style="display:inline"
onsubmit="return confirm('Libérer cette machine de la license ?\n\nLe slot redeviendra disponible pour une autre activation.')">
<?= Layout::csrfField() ?>
@@ -500,4 +685,70 @@ Layout::header('Licenses', 'licenses');
</tbody>
</table>
</div>
<?php // Dialogs émis hors de la <table> pour que showModal() fonctionne. ?>
<?= $GLOBALS['__editDialogs'] ?? '' ?>
<!-- Handler global pour les onglets des modals (délégation sur document). -->
<script>
(function () {
document.addEventListener('click', function (e) {
// 1. Tabs des modals d'édition license
var btn = e.target.closest('.modal-tabs button[data-tab]');
if (btn) {
var tabName = btn.dataset.tab;
var modal = btn.closest('dialog');
if (!modal) return;
modal.querySelectorAll('.modal-tabs button').forEach(function (b) {
b.classList.toggle('active', b === btn);
});
modal.querySelectorAll('.modal-body > section[data-tab]').forEach(function (s) {
s.hidden = s.dataset.tab !== tabName;
});
return;
}
// 2. Boutons "📋 Copier" (clé license) — copie data-copy via Clipboard API
// + feedback visuel temporaire (1.5 s) sur le bouton lui-même.
var copyBtn = e.target.closest('[data-copy]');
if (copyBtn) {
var text = copyBtn.dataset.copy || '';
var original = copyBtn.textContent;
// Clipboard API moderne (Chrome 66+, Firefox 63+, Edge 79+, Safari 13.1+).
// Fallback execCommand pour les browsers très anciens.
var copyOk = function () {
copyBtn.textContent = '✓';
copyBtn.style.color = '#16A34A';
setTimeout(function () {
copyBtn.textContent = original;
copyBtn.style.color = '';
}, 1500);
};
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(copyOk, function () {
// Échec d'autorisation (rare en HTTPS sur même origine) — fallback
fallbackCopy(text, copyOk);
});
} else {
fallbackCopy(text, copyOk);
}
}
});
// Fallback copie via textarea hidden + execCommand. Marche dans tous les
// browsers sans avoir besoin de la permission Clipboard API.
function fallbackCopy(text, onSuccess) {
var ta = document.createElement('textarea');
ta.value = text;
ta.style.position = 'fixed';
ta.style.opacity = '0';
document.body.appendChild(ta);
ta.select();
try { document.execCommand('copy'); onSuccess(); }
catch (e) { /* silent */ }
document.body.removeChild(ta);
}
})();
</script>
<?php Layout::footer();

View File

@@ -3,10 +3,14 @@ declare(strict_types=1);
require __DIR__ . '/lib/Auth.php';
require __DIR__ . '/lib/Layout.php';
require __DIR__ . '/lib/Channels.php';
require __DIR__ . '/lib/Mailer.php';
require __DIR__ . '/../api/lib/Db.php';
use PSLauncher\Admin\Auth;
use PSLauncher\Admin\Layout;
use PSLauncher\Admin\Channels;
use PSLauncher\Admin\Mailer;
use PSLauncher\Db;
Auth::requireLogin();
$config = require __DIR__ . '/../api/config.php';
@@ -97,6 +101,162 @@ function find_entry_index_by_id(array $manifest, string $id): ?int
return null;
}
/**
* Construit le corps HTML de l'email de release announce. Message en anglais
* (= langue par défaut du parc client international VR PROSERVE), inclut les
* release notes brutes en Markdown (sans rendu — les MUA modernes Markdown
* basique en clair reste très lisible et évite la dépendance Markdig PHP).
* Le owner_name de la license est utilisé pour personnaliser le greeting.
* Le logo (optionnel via <c>notifications.logo_url</c>) est inséré dans le
* bandeau d'en-tête bleu — hotlinkable depuis n'importe quel MUA (pas de CID
* embed pour rester compatible avec Gmail/Outlook qui bloquent les data URIs).
*/
function renderReleaseAnnounceEmail(string $version, string $ownerName, string $notesMarkdown, string $baseUrl, string $logoSrc = ''): string
{
$versionEsc = htmlspecialchars($version, ENT_QUOTES, 'UTF-8');
$ownerEsc = htmlspecialchars($ownerName !== '' ? $ownerName : 'team', ENT_QUOTES, 'UTF-8');
$notesEsc = htmlspecialchars(trim($notesMarkdown), ENT_QUOTES, 'UTF-8');
// $logoSrc peut être une URL https://… (hotlink) OU un cid:xxx (inline embed).
// Le caller décide selon ce qu'il préfère ; on échappe juste pour mettre dans src=.
$logoEsc = htmlspecialchars($logoSrc, ENT_QUOTES, 'UTF-8');
// Wrap les release notes en <pre> pour préserver le Markdown formatting
// (indentation, listes, line breaks). Pas de rendu HTML — l'admin sait que
// ses notes sont en Markdown et l'opérateur peut lire le format brut.
$notesBlock = $notesEsc !== ''
? "<h3 style=\"color: #1F2937; margin: 24px 0 8px;\">Release notes</h3>\n"
. "<pre style=\"background: #F3F4F6; padding: 16px; border-radius: 6px; "
. "font-family: 'Cascadia Code', Consolas, monospace; font-size: 13px; "
. "white-space: pre-wrap; word-wrap: break-word; line-height: 1.5; "
. "border-left: 3px solid #3B82F6;\">{$notesEsc}</pre>"
: '<p style="color: #6B7280; font-style: italic;">No release notes for this version.</p>';
// Logo block (optionnel). Image hotlinkée + alt fallback : si le MUA bloque
// les images (Outlook protégé par défaut), l'alt "ASTERION VR" s'affiche
// en blanc sur le fond bleu et le destinataire voit quand même la marque.
$logoBlock = $logoEsc !== ''
? "<img src=\"{$logoEsc}\" alt=\"ASTERION VR\" width=\"180\" "
. "style=\"display:inline-block; max-height:48px; max-width:240px; height:auto; "
. "color:#FFFFFF; font-family:Segoe UI,Arial,sans-serif; font-size:18px; "
. "font-weight:700; text-align:center; line-height:48px;\"/>"
: '<span style="color:#FFFFFF; font-family:Segoe UI,Arial,sans-serif; font-size:22px; font-weight:700;">ASTERION VR</span>';
// Email HTML doit utiliser table-based layout + bgcolor attribute (pas juste
// CSS background) parce qu'Outlook desktop utilise le moteur HTML de Word,
// qui ignore linear-gradient(), :root, position:fixed, et beaucoup d'autres
// règles CSS modernes. Le résultat précédent ressemblait à un email plain
// text sans bandeau bleu. Avec ce layout :
// • <table bgcolor="#1E40AF"> → fond bleu solide visible dans TOUS les MUA
// • Styles inline sur chaque <td> → pas de strip Outlook
// • width="640" attribute (vs CSS max-width) → respecté par Outlook
// • Pas de gradient (impossible OOTB en HTML email standard) — solid blue
// URL d'install du launcher (= installer Inno Setup self-extracting Windows).
// Servi statiquement par Apache depuis server/installer/. Si l'opérateur
// change ce path, mettre à jour ici. Pas de paramétrage config volontaire :
// le path est canonique côté projet et tous les launchers en partagent un.
$installerUrl = rtrim($baseUrl, '/') !== ''
? rtrim($baseUrl, '/') . '/installer/PS_Launcher-Setup.exe'
: 'https://asterionvr.com/PS_Launcher/installer/PS_Launcher-Setup.exe';
$installerUrlEsc = htmlspecialchars($installerUrl, ENT_QUOTES, 'UTF-8');
return <<<HTML
<!DOCTYPE html>
<html>
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"></head>
<body style="margin:0; padding:24px 12px; background-color:#F3F4F6; font-family:'Segoe UI',Arial,sans-serif; color:#1F2937;">
<table role="presentation" cellpadding="0" cellspacing="0" border="0" align="center" width="640"
style="max-width:640px; margin:0 auto; border-collapse:collapse;">
<tr>
<td bgcolor="#0F172A" align="center"
style="background-color:#0F172A; padding:32px 24px; color:#FFFFFF; border-radius:8px 8px 0 0;">
<div style="margin-bottom:16px;">{$logoBlock}</div>
<h1 style="margin:0; font-size:24px; color:#FFFFFF; font-weight:600;">
PROSERVE v{$versionEsc} is available!
</h1>
<p style="margin:8px 0 0; color:#94A3B8; font-size:14px;">
A new version is ready to install via your PROSERVE Launcher.
</p>
</td>
</tr>
<tr>
<td bgcolor="#FFFFFF"
style="background-color:#FFFFFF; padding:28px 28px; border:1px solid #E5E7EB; border-top:none; border-radius:0 0 8px 8px;">
<p style="margin:0 0 14px;">Hi {$ownerEsc},</p>
<p style="margin:0 0 14px;">
We're happy to let you know that
<strong>PROSERVE version {$versionEsc}</strong>
has just been published and is now available for download.
</p>
<p style="margin:0 0 14px;">
If you already have the <strong>PROSERVE Launcher</strong> installed,
simply open it on any of your machines, click
<em>« Check for updates »</em>, and install the new version with one click.
</p>
<!-- Section « Don't have the launcher yet ? » : bouton de téléchargement
+ brève instruction. Bordure haut/bas pour le détacher visuellement
du paragraphe d'install standard ci-dessus. -->
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%"
style="margin:20px 0; border-top:1px solid #E5E7EB; border-bottom:1px solid #E5E7EB;">
<tr>
<td style="padding:18px 0;">
<p style="margin:0 0 12px; font-weight:600; color:#1F2937;">
First install or new workstation?
</p>
<p style="margin:0 0 16px; color:#4B5563;">
If this is your first time, or you're setting up a new PC,
download the PROSERVE Launcher installer below.
<strong>You'll need to run it on EACH workstation</strong>
where PROSERVE will be used.
</p>
<!-- Bouton stylé via table + bgcolor (vs CSS button) pour
compat Outlook qui ne supporte ni border-radius CSS sur
<a> ni padding CSS bien rendu. Centrage via align="center"
sur la table wrapper, qui marche dans Outlook ET les MUA
modernes (vs `margin:auto` sur table ignoré par Outlook). -->
<table role="presentation" cellpadding="0" cellspacing="0" border="0" align="center" style="margin:0 auto;">
<tr>
<td bgcolor="#3B82F6" align="center"
style="background-color:#3B82F6; border-radius:6px;">
<a href="{$installerUrlEsc}"
style="display:inline-block; padding:12px 28px;
color:#FFFFFF; text-decoration:none;
font-family:'Segoe UI',Arial,sans-serif;
font-size:14px; font-weight:600;">
⬇ Download PROSERVE Launcher (Windows)
</a>
</td>
</tr>
</table>
<p style="margin:12px 0 0; color:#9CA3AF; font-size:11px; text-align:center;">
Direct link:
<a href="{$installerUrlEsc}" style="color:#3B82F6; text-decoration:underline; word-break:break-all;">{$installerUrlEsc}</a>
</p>
</td>
</tr>
</table>
{$notesBlock}
<p style="margin:24px 0 14px;">
As always, if you run into any issue (download failure, install error,
anything weird in-game), don't hesitate to reach out — we're here to help.
</p>
<p style="margin:0;">
Happy training!<br/>
<strong>The ASTERION VR team</strong>
</p>
</td>
</tr>
<tr>
<td align="center" style="padding:16px 8px; color:#9CA3AF; font-size:11px; line-height:1.5;">
This automated notification was sent because your contact email is registered with one of your PROSERVE licenses.<br/>
To stop receiving these, ask your ASTERION VR account manager to remove your address from the license contacts.
</td>
</tr>
</table>
</body></html>
HTML;
}
/**
* Normalise un nom de fichier ZIP saisi par l'admin :
* - strip tout chemin (path traversal défense, basename)
@@ -133,8 +293,12 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$isBeta = isset($_POST['is_beta']);
$betaNotes = trim($_POST['beta_notes'] ?? '') ?: null;
if (!preg_match('/^\d+\.\d+\.\d+$/', $version)) {
throw new Exception('Numéro de version invalide (format X.Y.Z attendu).');
// X.Y.Z (release publique) ou X.Y.Z.B (itération dev/test interne).
// Le 4ᵉ digit est optionnel et permet de gérer plusieurs builds de
// pré-release sans bumper le numéro de release final (ex. 1.5.4.13
// pendant le dev, 1.5.4 au release).
if (!preg_match('/^\d+\.\d+\.\d+(\.\d+)?$/', $version)) {
throw new Exception('Numéro de version invalide (format X.Y.Z ou X.Y.Z.B attendu).');
}
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $minLicDate)) {
throw new Exception('Date min_license_date invalide.');
@@ -357,6 +521,127 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
. "\n" . implode("\n", $resign['log']);
if (!$resign['ok']) $messageType = 'error';
}
elseif ($action === 'notify_release') {
// Envoie une notif email aux contacts des licenses ÉLIGIBLES pour
// cette version. Critères d'éligibilité (= license verra la version
// dans son manifest signé) :
// • Pas révoquée
// • download_entitlement_until >= minLicenseDate de la version
// • channel de la license ∈ channels de la version (ou « default »)
// • can_see_betas = 1 si la version est isBeta=true
// Pour chaque license éligible : split contact_emails + send via Mailer.
// Compteurs retournés dans le message admin.
$entryId = (string)($_POST['id'] ?? '');
$idx = find_entry_index_by_id($manifest, $entryId);
if ($idx === null) throw new Exception("Entry id={$entryId} introuvable.");
$entry = $manifest['versions'][$idx];
$version = $entry['version'] ?? '?';
$isBeta = !empty($entry['isBeta']);
$vChannels = isset($entry['channels']) && is_array($entry['channels']) && !empty($entry['channels'])
? $entry['channels']
: ['default'];
$minLic = $entry['minLicenseDate'] ?? '1970-01-01';
// Charge release notes (Markdown brut → on l'envoie tel quel,
// les MUA modernes l'affichent OK même sans rendu)
$notesFile = "$notesDir/{$entryId}.md";
$notesBody = is_file($notesFile) ? file_get_contents($notesFile) : '';
// Récupère les licenses éligibles
$db = Db::get($config);
$stmt = $db->prepare(
'SELECT id, owner_name, channel, can_see_betas, contact_emails, download_entitlement_until
FROM licenses
WHERE revoked_at IS NULL
AND download_entitlement_until >= ?
AND contact_emails IS NOT NULL
AND contact_emails != \'\''
);
$stmt->execute([$minLic]);
$licenses = $stmt->fetchAll();
$notifConfig = $config['notifications'] ?? [];
// Prépare le logo en CID embed (vs hotlink) pour qu'Outlook l'affiche
// SANS demander de permission "Télécharger les images". Le path
// pointe vers admin/assets/asterion-logo.png par défaut, ou un
// override via $notifConfig['logo_path']. Si le fichier n'existe
// pas, $logoCid reste vide → le template skip le bloc image et
// utilise le fallback texte "ASTERION VR" en blanc.
//
// PAS DE FALLBACK HOTLINK si le fichier est introuvable : Outlook
// bloque tout hotlink par défaut, et quand l'URL retourne 404,
// il n'offre même pas le bouton « Télécharger les images »
// (il affiche juste un placeholder rouge cassé). Mieux vaut le
// fallback texte que ce visuel anti-pro.
$logoPath = $notifConfig['logo_path'] ?? (__DIR__ . '/assets/asterion-logo.png');
$logoCid = '';
$logoMissing = false;
$inlineImages = [];
if (is_file($logoPath) && is_readable($logoPath)) {
$logoCid = 'asterion-logo@pslauncher';
$inlineImages[] = [
'cid' => $logoCid,
'path' => $logoPath,
'type' => 'image/png',
];
} else {
$logoMissing = true;
}
$totalSent = 0; $totalFailed = 0; $licsTouched = 0; $emailsSet = [];
foreach ($licenses as $lic) {
$licChannel = $lic['channel'] ?? 'default';
// Match : la license voit la version si son channel est dans
// la liste des channels de la version (ou si version='default'
// et license sans channel = public)
$licChannelEffective = $licChannel ?? 'default';
if (!in_array($licChannelEffective, $vChannels, true)) continue;
if ($isBeta && (int)$lic['can_see_betas'] !== 1) continue;
$emails = Mailer::parseEmails($lic['contact_emails']);
if (empty($emails)) continue;
$licsTouched++;
$subject = "PROSERVE v{$version} is now available";
// Logo via CID inline si fichier disponible sur disque, sinon
// chaîne vide → le template tombera sur le fallback texte
// "ASTERION VR" en blanc sur le bandeau dark navy. On NE
// tombe PAS sur logo_url (hotlink) parce qu'Outlook bloque
// tout hotlink par défaut et affiche un placeholder cassé
// sans même offrir le bouton "Télécharger les images" si
// l'URL retourne 404. Cf logoMissing détecté plus haut.
$logoSrc = $logoCid !== '' ? "cid:{$logoCid}" : '';
$bodyHtml = renderReleaseAnnounceEmail(
$version,
$lic['owner_name'] ?? '',
$notesBody,
$config['base_url'] ?? '',
$logoSrc
);
foreach ($emails as $email) {
// Dédup : ne pas envoyer 2× à la même adresse si elle est
// contact sur plusieurs licenses du même client.
if (isset($emailsSet[$email])) continue;
$emailsSet[$email] = true;
$ok = Mailer::send($notifConfig, $email, $subject, $bodyHtml, $inlineImages);
if ($ok) $totalSent++; else $totalFailed++;
}
}
$message = "Notification release v{$version} : {$totalSent} email(s) envoyé(s) " .
"{$licsTouched} license(s) éligibles, " .
($totalFailed > 0 ? "{$totalFailed} échec(s) — vérifier mail() / SMTP serveur)" : "0 échec)");
// Si le logo PNG est introuvable côté serveur, le préviens — sinon
// l'opérateur découvre le bug seulement quand un destinataire lui
// dit que l'email n'a pas de logo.
if ($logoMissing) {
$message .= "\n\n⚠ Logo Asterion non trouvé à : {$logoPath}\n" .
" Upload `asterion-logo.png` dans admin/assets/ via SFTP, sinon les emails partent avec le fallback texte 'ASTERION VR' au lieu du logo.";
$messageType = 'error';
}
if ($totalFailed > 0) $messageType = 'error';
}
} catch (Exception $e) {
$message = $e->getMessage();
$messageType = 'error';
@@ -412,16 +697,34 @@ Layout::header('Versions', 'versions');
<input type="hidden" name="action" value="add">
<div class="row">
<div class="col field">
<label>Version (X.Y.Z)</label>
<input type="text" name="version" placeholder="1.4.8" required pattern="\d+\.\d+\.\d+">
<label>Version (X.Y.Z, ou X.Y.Z.B pour une itération dev)</label>
<input type="text" name="version" placeholder="1.4.8 ou 1.5.4.13" required pattern="\d+\.\d+\.\d+(\.\d+)?"
title="Format X.Y.Z (release publique) ou X.Y.Z.B (itération dev/test interne avec build number)">
</div>
<?php
// Défauts conventionnels :
// • released_at = aujourd'hui (l'opérateur est en train d'éditer
// l'entry au moment du release, ça serait absurde d'avoir un
// champ vide qu'il faut systématiquement remplir)
// • min_license_date = aujourd'hui - 1 an (les licenses sont
// valides 1 an, donc une license émise il y a moins d'un an
// a accès à cette version par défaut). Modifiable pour les
// cas particuliers (release LTS, security patch limité aux
// dernières licenses, etc.).
$defaultReleasedAt = date('Y-m-d');
$defaultMinLicDate = date('Y-m-d', strtotime('-1 year'));
?>
<div class="col field">
<label>Date de release</label>
<input type="datetime-local" name="released_at">
<input type="date" name="released_at"
value="<?= $defaultReleasedAt ?>"
title="Date publique de la release. Default = aujourd'hui. L'heure n'est pas conservée — toutes les releases sont datées à minuit UTC.">
</div>
<div class="col field">
<label>min_license_date (license requise)</label>
<input type="date" name="min_license_date" required>
<label>min_license_date <span class="muted" style="font-weight: normal; font-size: 11px;">(license émise après cette date requise)</span></label>
<input type="date" name="min_license_date" required
value="<?= $defaultMinLicDate ?>"
title="Une license n'a accès à cette version que si elle a été émise APRÈS cette date. Default = aujourd'hui - 1 an (= les licenses 'standard' couvrent les releases du dernier an).">
</div>
</div>
<div class="field">
@@ -479,7 +782,7 @@ Layout::header('Versions', 'versions');
• latest : <code>v<?= htmlspecialchars($manifest['latest']) ?></code>
<?php endif; ?>
</span>
<form method="post" style="display:inline">
<form method="post" style="display:inline" onsubmit="showBusyOverlay('bulk')">
<?= Layout::csrfField() ?>
<input type="hidden" name="action" value="sync_versions">
<button class="btn btn-primary" type="submit">🔁 Hasher les versions + signer</button>
@@ -558,130 +861,261 @@ Layout::header('Versions', 'versions');
</form>
</td>
<td style="text-align: right; white-space: nowrap;">
<?php if ($zipExists && !$hashSkipped): ?>
<form method="post" style="display:inline" title="Recalcule le SHA-256 de cette version uniquement (utilise le cache si le ZIP n'a pas changé)">
<?= Layout::csrfField() ?>
<input type="hidden" name="action" value="sync_one">
<input type="hidden" name="id" value="<?= htmlspecialchars($v['id'] ?? '') ?>">
<button class="btn btn-secondary" type="submit">🔁 Hash</button>
</form>
<?php endif; ?>
<details style="display: inline-block; margin: 0 4px;">
<summary class="btn btn-secondary">⚙ Hash</summary>
<div style="margin-top: 8px; min-width: 280px;">
<p class="muted" style="font-size: 12px; margin: 0 0 8px;">
<?php
// entryId est utilisé partout (boutons row + modal IDs).
$entryId = (string)($v['id'] ?? '');
$entryIdEsc = htmlspecialchars($entryId);
$modalId = 'edit-' . $entryIdEsc;
?>
<div class="row-actions">
<?php if ($zipExists && !$hashSkipped): ?>
<form method="post" style="display:inline"
onsubmit="showBusyOverlay('single', '<?= htmlspecialchars($v['version']) ?>')"
title="Recalcule de force le SHA-256 de cette version, en ignorant le cache .hashcache.json. À utiliser après un re-upload SFTP — le cache server-side peut considérer le fichier 'inchangé' si SFTP a préservé le mtime, ce qui retourne l'ancien hash sans recalcul.">
<?= Layout::csrfField() ?>
<input type="hidden" name="action" value="sync_one">
<input type="hidden" name="id" value="<?= $entryIdEsc ?>">
<input type="hidden" name="force" value="1">
<button class="btn btn-secondary" type="submit">🔁 Hash</button>
</form>
<?php endif; ?>
<button type="button" class="btn btn-primary"
onclick="document.getElementById('<?= $modalId ?>').showModal()">
✎ Modifier
</button>
<form method="post" style="display:inline"
onsubmit="return confirm('Envoyer un email d\'annonce de v<?= htmlspecialchars($v['version']) ?> à tous les contacts des licenses éligibles ?\n\nUn email par contact (dédup automatique). Inclut les release notes actuelles.')"
title="Envoie une notification email à toutes les licenses qui voient cette version (channel + entitlement + can_see_betas pour les BÊTA). Inclut les release notes.">
<?= Layout::csrfField() ?>
<input type="hidden" name="action" value="notify_release">
<input type="hidden" name="id" value="<?= $entryIdEsc ?>">
<button class="btn btn-secondary" type="submit">✉ Notifier</button>
</form>
<form method="post" style="display:inline" onsubmit="return confirm('Retirer v<?= htmlspecialchars($v['version']) ?> du manifest ?\n(Le ZIP reste dans builds/, supprime-le en SFTP si voulu.)')">
<?= Layout::csrfField() ?>
<input type="hidden" name="action" value="delete">
<input type="hidden" name="id" value="<?= $entryIdEsc ?>">
<button class="btn btn-danger" type="submit">✕ Retirer</button>
</form>
</div>
</td>
</tr>
<?php // Dialog rendu plus bas dans un foreach séparé hors de la <table>
// — un <dialog> dans un <tr style="display:none"> est exclu du
// layout tree par certains browsers, ce qui empêche showModal()
// de fonctionner (le bouton « ✎ Modifier » devient muet). ?>
<?php ob_start(); /* début capture HTML dialog → flush après </table> */ ?>
<dialog id="<?= $modalId ?>" class="edit-modal">
<header>
<h3>Éditer v<?= htmlspecialchars($v['version']) ?>
<?php if (!empty($v['isBeta'])): ?>
<span class="badge badge-warning" style="margin-left: 6px; font-size: 10px;">BÊTA</span>
<?php endif; ?>
</h3>
<button type="button" class="close-x" aria-label="Fermer"
onclick="this.closest('dialog').close()">×</button>
</header>
<!-- Tabs : data-tab pointe vers la section à afficher, géré par
un seul handler global JS déclaré en bas de page (délégation
d'événement sur document → pas besoin de rebind par modal). -->
<nav class="modal-tabs" data-modal="<?= $modalId ?>">
<button type="button" data-tab="meta" class="active">Méta</button>
<button type="button" data-tab="notes">Notes</button>
<button type="button" data-tab="beta">BÊTA</button>
<button type="button" data-tab="channels">Channels</button>
<button type="button" data-tab="advanced">Avancé</button>
</nav>
<div class="modal-body">
<!-- TAB : Méta (min_license, released_at, rename ZIP) -->
<section data-tab="meta">
<p class="section-intro">
Date d'expiration mini d'une license pour télécharger cette version,
date de release publique, et nom du fichier ZIP côté serveur (utile pour
invalider le cache CDN après un re-build).
</p>
<form method="post">
<?= Layout::csrfField() ?>
<input type="hidden" name="action" value="edit_meta">
<input type="hidden" name="id" value="<?= $entryIdEsc ?>">
<div class="field">
<label>min_license_date <span class="muted" style="font-weight: normal;">(license émise après cette date requise)</span></label>
<input type="date" name="min_license_date"
value="<?= htmlspecialchars($v['minLicenseDate'] ?? '') ?>" required>
</div>
<div class="field">
<label>Date de release</label>
<?php
// On extrait UNIQUEMENT la portion YYYY-MM-DD du releasedAt
// stocké (qui peut être un ISO complet avec heure : « 2026-05-27T00:00:00Z »).
// L'heure n'est pas affichée à l'opérateur — toutes les releases
// sont datées à minuit UTC, le moment précis n'a pas de sens UX.
$releasedDateOnly = substr($v['releasedAt'] ?? '', 0, 10);
?>
<input type="date" name="released_at"
value="<?= htmlspecialchars($releasedDateOnly) ?>">
</div>
<div class="field">
<label>Renommer le ZIP
<span class="muted" style="font-weight: normal;">
(actuel : <code><?= htmlspecialchars(basename(parse_url($v['download']['url'] ?? '', PHP_URL_PATH) ?: '')) ?></code>)
</span>
</label>
<input type="text" name="zip_filename" placeholder="laisse vide pour ne rien changer">
<p class="warn">⚠ Renommer invalide le sha256 — re-upload le nouveau ZIP en SFTP puis « 🔁 Sync ».</p>
</div>
<div class="modal-footer" style="margin: 20px -20px -20px;">
<button type="button" class="btn btn-secondary" onclick="this.closest('dialog').close()">Annuler</button>
<button type="submit" class="btn btn-primary">Enregistrer Méta</button>
</div>
</form>
</section>
<!-- TAB : Notes -->
<section data-tab="notes" hidden>
<p class="section-intro">
Markdown des release notes de cette version. Affichées dans le dialog
d'install côté launcher et accessibles via le menu « Notes de release ».
</p>
<form method="post">
<?= Layout::csrfField() ?>
<input type="hidden" name="action" value="edit_notes">
<input type="hidden" name="id" value="<?= $entryIdEsc ?>">
<div class="field">
<textarea name="notes" rows="14"
style="font-family: 'Cascadia Code', Consolas, monospace;"><?= htmlspecialchars($existingNotes[$entryId] ?? '') ?></textarea>
</div>
<div class="modal-footer" style="margin: 20px -20px -20px;">
<button type="button" class="btn btn-secondary" onclick="this.closest('dialog').close()">Annuler</button>
<button type="submit" class="btn btn-primary">Enregistrer Notes</button>
</div>
</form>
</section>
<!-- TAB : BÊTA -->
<section data-tab="beta" hidden>
<p class="section-intro">
Une version BÊTA n'est visible que par les licenses qui ont le flag
<code>can_see_betas</code> activé. Les autres clients voient la version
comme inexistante.
</p>
<form method="post">
<?= Layout::csrfField() ?>
<input type="hidden" name="action" value="set_beta">
<input type="hidden" name="id" value="<?= $entryIdEsc ?>">
<div class="field">
<label>
<input type="checkbox" name="is_beta" value="1" <?= !empty($v['isBeta']) ? 'checked' : '' ?>>
Cette version est une BÊTA
</label>
</div>
<div class="field">
<label>Notes pour les testeurs <span class="muted" style="font-weight: normal;">(affichées en tooltip dans le launcher)</span></label>
<input type="text" name="beta_notes"
value="<?= htmlspecialchars($v['betaNotes'] ?? '') ?>"
placeholder="Tester en priorité…">
</div>
<p class="warn">⚠ Re-signe le manifest (« 🔁 Sync ») après modif pour que les launchers acceptent.</p>
<div class="modal-footer" style="margin: 20px -20px -20px;">
<button type="button" class="btn btn-secondary" onclick="this.closest('dialog').close()">Annuler</button>
<button type="submit" class="btn btn-primary">Enregistrer BÊTA</button>
</div>
</form>
</section>
<!-- TAB : Channels -->
<section data-tab="channels" hidden>
<p class="section-intro">
Détermine quelles licenses voient cette version. Coche « default » pour
la rendre publique, ou cible un ou plusieurs channels privés (gérés sur
la page <a href="channels.php">Channels</a>).
</p>
<form method="post">
<?= Layout::csrfField() ?>
<input type="hidden" name="action" value="set_channels">
<input type="hidden" name="id" value="<?= $entryIdEsc ?>">
<div class="field">
<div class="checkbox-list">
<?php foreach ($channelRegistry['channels'] as $c):
$cn = $c['name'] ?? '';
$checked = in_array($cn, $vChannels, true) ? 'checked' : '';
?>
<label>
<input type="checkbox" name="channels[]" value="<?= htmlspecialchars($cn) ?>" <?= $checked ?>>
<code><?= htmlspecialchars($cn) ?></code>
<span class="muted"><?= htmlspecialchars($c['label'] ?? $cn) ?></span>
</label>
<?php endforeach; ?>
</div>
</div>
<p class="warn">⚠ Re-signe le manifest après modif.</p>
<div class="modal-footer" style="margin: 20px -20px -20px;">
<button type="button" class="btn btn-secondary" onclick="this.closest('dialog').close()">Annuler</button>
<button type="submit" class="btn btn-primary">Enregistrer Channels</button>
</div>
</form>
</section>
<!-- TAB : Avancé (skip hash + force re-hash) -->
<section data-tab="advanced" hidden>
<p class="section-intro">
Options de vérification d'intégrité. La sécurité repose sur la signature
Ed25519 du manifest + HTTPS — désactiver SHA-256 reste relativement sûr,
utile sur très gros builds pour gagner 30s-3min en fin de DL.
</p>
<div class="field">
<label>Vérification SHA-256 chez les clients</label>
<p class="hint" style="margin: 0 0 8px;">
<?php if ($hashSkipped): ?>
La vérif SHA-256 est <strong>désactivée</strong> pour cette release.
Le manifest sert <code>hashAlgorithm: "none"</code>. La sécurité repose
uniquement sur la signature Ed25519 du manifest + HTTPS.
Actuellement <strong style="color: var(--busy);">désactivée</strong>
pour cette release (le manifest sert <code>hashAlgorithm: "none"</code>).
<?php else: ?>
Désactive la vérif SHA-256 chez les clients pour cette release
(utile sur très gros builds où on accepte le compromis perf/sécurité).
Actuellement <strong style="color: var(--installed);">activée</strong>
(default sain).
<?php endif; ?>
</p>
<form method="post" style="display:inline">
<?= Layout::csrfField() ?>
<input type="hidden" name="action" value="set_skip_hash">
<input type="hidden" name="id" value="<?= htmlspecialchars($v['id'] ?? '') ?>">
<input type="hidden" name="id" value="<?= $entryIdEsc ?>">
<input type="hidden" name="skip" value="<?= $hashSkipped ? '0' : '1' ?>">
<button class="btn <?= $hashSkipped ? 'btn-success' : 'btn-warning' ?>" type="submit">
<?= $hashSkipped ? '✓ Réactiver la vérif' : '⏭ Désactiver la vérif' ?>
</button>
</form>
<?php if ($zipExists): ?>
<form method="post" style="margin-top: 8px;" title="Recalcule en ignorant le cache même si le ZIP n'a pas changé">
</div>
<?php if ($zipExists): ?>
<div class="field">
<label>Forcer un re-hash du ZIP</label>
<p class="hint" style="margin: 0 0 8px;">
Recalcule le SHA-256 en ignorant le cache, même si le ZIP n'a pas changé.
Utile pour valider une intégrité après transfert SFTP douteux.
</p>
<form method="post" style="display:inline"
onsubmit="showBusyOverlay('single', '<?= htmlspecialchars($v['version']) ?>')">
<?= Layout::csrfField() ?>
<input type="hidden" name="action" value="sync_one">
<input type="hidden" name="id" value="<?= htmlspecialchars($v['id'] ?? '') ?>">
<input type="hidden" name="id" value="<?= $entryIdEsc ?>">
<input type="hidden" name="force" value="1">
<button class="btn btn-secondary" type="submit">🔄 Force re-hash</button>
</form>
<?php endif; ?>
</div>
</details>
<details style="display: inline-block; margin: 0 4px;">
<summary class="btn btn-secondary">Méta</summary>
<form method="post" style="margin-top: 8px; min-width: 360px;">
<?= Layout::csrfField() ?>
<input type="hidden" name="action" value="edit_meta">
<input type="hidden" name="id" value="<?= htmlspecialchars($v['id'] ?? '') ?>">
<div class="field">
<label>min_license_date</label>
<input type="date" name="min_license_date" value="<?= htmlspecialchars($v['minLicenseDate'] ?? '') ?>" required>
</div>
<div class="field">
<label>Date de release (UTC)</label>
<input type="datetime-local" name="released_at" value="<?= htmlspecialchars(substr($v['releasedAt'] ?? '', 0, 16)) ?>">
</div>
<div class="field">
<label>Renommer le ZIP <span class="muted" style="font-weight: normal; font-size: 11px;">(actuel : <code><?= htmlspecialchars(basename(parse_url($v['download']['url'] ?? '', PHP_URL_PATH) ?: '')) ?></code>)</span></label>
<input type="text" name="zip_filename" placeholder="laisse vide pour ne rien changer">
<span class="muted" style="font-size: 11px;">⚠️ Renommer = invalide le sha256, il faudra re-uploader le nouveau ZIP en SFTP puis re-Sync.</span>
</div>
<button class="btn btn-primary" type="submit">Enregistrer</button>
</form>
</details>
<details style="display: inline-block; margin: 0 4px;">
<summary class="btn btn-secondary">Notes</summary>
<form method="post" style="margin-top: 8px;">
<?= Layout::csrfField() ?>
<input type="hidden" name="action" value="edit_notes">
<input type="hidden" name="id" value="<?= htmlspecialchars($v['id'] ?? '') ?>">
<textarea name="notes" rows="10" style="font-family: 'Cascadia Code', Consolas, monospace; min-width: 400px;"><?= htmlspecialchars($existingNotes[$v['id'] ?? ''] ?? '') ?></textarea>
<br><br>
<button class="btn btn-primary" type="submit">Enregistrer les notes</button>
</form>
</details>
<details style="display: inline-block; margin: 0 4px;">
<summary class="btn <?= !empty($v['isBeta']) ? 'btn-warning' : 'btn-secondary' ?>"><?= !empty($v['isBeta']) ? '🟡 BÊTA' : 'Bêta' ?></summary>
<form method="post" style="margin-top: 8px; min-width: 360px;">
<?= Layout::csrfField() ?>
<input type="hidden" name="action" value="set_beta">
<input type="hidden" name="id" value="<?= htmlspecialchars($v['id'] ?? '') ?>">
<div class="field">
<label><input type="checkbox" name="is_beta" value="1" <?= !empty($v['isBeta']) ? 'checked' : '' ?>>
Cette version est une BÊTA
</label>
</div>
<div class="field">
<label>Notes pour les testeurs</label>
<input type="text" name="beta_notes" value="<?= htmlspecialchars($v['betaNotes'] ?? '') ?>"
placeholder="Tester en priorité…">
</div>
<button class="btn btn-primary" type="submit">Enregistrer</button>
<p class="muted" style="font-size: 11px; margin: 8px 0 0;">⚠️ Re-signe le manifest (« 🔁 Sync ») après modif pour que les launchers acceptent.</p>
</form>
</details>
<details style="display: inline-block; margin: 0 4px;">
<summary class="btn btn-secondary">Channels</summary>
<form method="post" style="margin-top: 8px; min-width: 280px;">
<?= Layout::csrfField() ?>
<input type="hidden" name="action" value="set_channels">
<input type="hidden" name="id" value="<?= htmlspecialchars($v['id'] ?? '') ?>">
<div class="field">
<?php foreach ($channelRegistry['channels'] as $c):
$cn = $c['name'] ?? '';
$checked = in_array($cn, $vChannels, true) ? 'checked' : '';
?>
<label style="display: flex; align-items: center; gap: 6px; padding: 4px 0; cursor: pointer;">
<input type="checkbox" name="channels[]" value="<?= htmlspecialchars($cn) ?>" <?= $checked ?>>
<code><?= htmlspecialchars($cn) ?></code>
<span class="muted" style="font-size: 11px;"><?= htmlspecialchars($c['label'] ?? $cn) ?></span>
</label>
<?php endforeach; ?>
</div>
<button class="btn btn-primary" type="submit">Enregistrer</button>
<p class="muted" style="font-size: 11px; margin: 8px 0 0;">⚠️ Re-signe le manifest après modif.</p>
</form>
</details>
<form method="post" style="display:inline" onsubmit="return confirm('Retirer v<?= htmlspecialchars($v['version']) ?> du manifest ?\n(Le ZIP reste dans builds/, supprime-le en SFTP si voulu.)')">
<?= Layout::csrfField() ?>
<input type="hidden" name="action" value="delete">
<input type="hidden" name="id" value="<?= htmlspecialchars($v['id'] ?? '') ?>">
<button class="btn btn-danger" type="submit">Retirer</button>
</form>
</td>
</tr>
<?php endif; ?>
<div class="modal-footer" style="margin: 20px -20px -20px;">
<button type="button" class="btn btn-secondary" onclick="this.closest('dialog').close()">Fermer</button>
</div>
</section>
</div>
</dialog>
<?php
// Capture le markup du dialog et le pousse dans un buffer global
// qui sera vidé APRÈS </table>. Pourquoi : <dialog> directement
// ou indirectement dans <table>/<tbody>/<tr> est foster-parented
// de manière imprévisible par le parser HTML, ce qui peut casser
// showModal() (élément non connecté au layout tree).
$GLOBALS['__editDialogs'] = ($GLOBALS['__editDialogs'] ?? '') . ob_get_clean();
?>
<?php endforeach; ?>
<?php if (empty($manifest['versions'])): ?>
<tr><td colspan="8" class="muted" style="text-align:center; padding: 32px;">Aucune version dans le manifest.</td></tr>
@@ -690,6 +1124,11 @@ Layout::header('Versions', 'versions');
</table>
</div>
<?php // Tous les <dialog> d'édition sont émis ici, hors de la <table> parent,
// pour que showModal() fonctionne fiablement (le top layer est indépendant
// du DOM context mais l'élément doit être dans le layout tree d'abord). ?>
<?= $GLOBALS['__editDialogs'] ?? '' ?>
<div class="card">
<h2>ZIPs présents dans builds/</h2>
<?php if (empty($zips)): ?>
@@ -718,4 +1157,62 @@ Layout::header('Versions', 'versions');
</table>
<?php endif; ?>
</div>
<!-- Overlay busy : visible pendant les opérations longues côté serveur
(hash SHA-256 d'un build de 13 Go = 3-5 min sur OVH mutualisé).
Affiché en synchrone juste avant le submit du form ; disparaît
naturellement au reload de page qui suit la réponse POST. -->
<div id="busy-overlay" class="busy-overlay" hidden>
<div class="busy-content">
<div class="busy-spinner"></div>
<h3 id="busy-title">Calcul du SHA-256 en cours…</h3>
<p id="busy-detail">Sur un build de 13 Go, ça prend 35 minutes selon les performances disque du serveur.</p>
<p class="muted">La page se rafraîchira automatiquement quand c'est fini.<br>
Tu peux laisser l'onglet ouvert en arrière-plan.</p>
</div>
</div>
<!-- Handler global pour les onglets des modals d'édition de version.
Délégation d'événement sur document → marche pour tous les .modal-tabs,
y compris ceux qui seraient ajoutés dynamiquement plus tard. -->
<script>
(function () {
document.addEventListener('click', function (e) {
var btn = e.target.closest('.modal-tabs button[data-tab]');
if (!btn) return;
var tabName = btn.dataset.tab;
var modal = btn.closest('dialog');
if (!modal) return;
// Active button → toggle .active sur les boutons du même <nav>
modal.querySelectorAll('.modal-tabs button').forEach(function (b) {
b.classList.toggle('active', b === btn);
});
// Sections → toggle [hidden] selon data-tab
modal.querySelectorAll('.modal-body > section[data-tab]').forEach(function (s) {
s.hidden = s.dataset.tab !== tabName;
});
});
})();
// Affiche l'overlay busy au submit des forms de hash. Message adapté au scope :
// • 'bulk' → "tous les ZIPs" (bouton global Hasher les versions + signer)
// • 'single' → version précise (bouton 🔁 Hash per-row ou Force re-hash modal)
// Note : on N'EMPÊCHE PAS le submit (return undefined) — l'overlay reste affiché
// jusqu'au reload qui suit la réponse POST, donc disparaît naturellement.
function showBusyOverlay(scope, version) {
var overlay = document.getElementById('busy-overlay');
if (!overlay) return;
var title = document.getElementById('busy-title');
var detail = document.getElementById('busy-detail');
if (scope === 'bulk') {
if (title) title.textContent = 'Hashing + signing tous les ZIPs…';
if (detail) detail.textContent = 'Recalcul SHA-256 sur chaque ZIP (35 min par ZIP de 13 Go). Les ZIPs déjà hashés et inchangés sont skip via cache, sauf si « Force re-hash » coché.';
} else {
if (title) title.textContent = 'Hashing SHA-256 v' + (version || '?') + '…';
if (detail) detail.textContent = 'Sur un build de 13 Go, ça prend 35 minutes selon les performances disque du serveur.';
}
overlay.hidden = false;
}
</script>
<?php Layout::footer();

View File

@@ -40,4 +40,32 @@ return [
// php -r "echo password_hash('motdepasse_choisi', PASSWORD_DEFAULT);"
// puis colle le résultat ci-dessous.
'admin_password_hash' => '',
// === NOTIFICATIONS EMAIL (release announce) ===
// Utilisé par versions.php → action « ✉ Notifier » pour prévenir les
// contacts d'une license de la sortie d'une nouvelle version.
// Si SMTP non configuré, on retombe sur mail() natif PHP (qui marche sur
// OVH mutualisé via le relais SMTP local sans config additionnelle).
'notifications' => [
// Adresse "From" de l'email (doit être autorisée par OVH — typiquement
// une adresse hébergée sur ton domaine principal).
'from_address' => 'no-reply@asterionvr.com',
'from_name' => 'ASTERION VR — PROSERVE Launcher',
// Optionnel : Reply-To si tu veux que les réponses arrivent ailleurs
// (ex. boîte support partagée). Vide = pas de header Reply-To.
'reply_to' => '',
// Logo affiché en haut de l'email. Par défaut, l'image est EMBED INLINE
// via CID (Content-ID, pièce jointe MIME multipart/related). Avantage
// vs hotlink HTTP : Outlook (et la plupart des MUA) affiche l'image
// SANS demander la permission « Télécharger les images » au destinataire.
// Le path par défaut pointe vers admin/assets/asterion-logo.png — copié
// depuis le repo, tu peux remplacer le fichier librement (même URL servie).
'logo_path' => null, // null = auto-détect admin/assets/asterion-logo.png
// Fallback URL hotlink si tu préfères servir le logo depuis un CDN
// public au lieu du CID embed. UTILISÉ UNIQUEMENT si logo_path est null
// ou que le fichier est introuvable. Note : les MUA bloquent souvent
// les hotlinks par défaut → le destinataire devra cliquer « afficher
// les images » pour voir le logo. Préfère le CID embed (laisse logo_path).
'logo_url' => '',
],
];

View File

@@ -49,8 +49,9 @@ if ($method === 'GET' && ($route === '' || $route === 'manifest')) {
return;
}
// Release notes : accepte 'X.Y.Z' (legacy) ou id stable 'v' + 8 hex (v0.27.1+)
if ($method === 'GET' && preg_match('#^releasenotes/(v[0-9a-f]{8}|[0-9]+\.[0-9]+\.[0-9]+)$#', $route, $m)) {
// Release notes : accepte 'X.Y.Z' ou 'X.Y.Z.B' (4ᵉ digit = itération dev/test
// optionnelle) OU id stable 'v' + 8 hex (v0.27.1+).
if ($method === 'GET' && preg_match('#^releasenotes/(v[0-9a-f]{8}|[0-9]+\.[0-9]+\.[0-9]+(?:\.[0-9]+)?)$#', $route, $m)) {
require __DIR__ . '/routes/Releasenotes.php';
\PSLauncher\Routes\Releasenotes::handle($m[1]);
return;
@@ -64,7 +65,8 @@ if ($method === 'POST' && $route === 'license/validate') {
return;
}
if ($method === 'GET' && preg_match('#^download-url/([0-9]+\.[0-9]+\.[0-9]+)$#', $route, $m)) {
// Download URL : accepte X.Y.Z (release publique) ou X.Y.Z.B (itération dev/test).
if ($method === 'GET' && preg_match('#^download-url/([0-9]+\.[0-9]+\.[0-9]+(?:\.[0-9]+)?)$#', $route, $m)) {
require __DIR__ . '/lib/Db.php';
require __DIR__ . '/lib/Crypto.php';
require __DIR__ . '/routes/DownloadUrl.php';

View File

@@ -19,7 +19,8 @@ final class DownloadUrl
{
public static function handle(array $config, string $version): void
{
if (!preg_match('/^[0-9]+\.[0-9]+\.[0-9]+$/', $version)) {
// Format X.Y.Z (release publique) ou X.Y.Z.B (itération dev/test).
if (!preg_match('/^[0-9]+\.[0-9]+\.[0-9]+(\.[0-9]+)?$/', $version)) {
Response::error('invalid_version', 'Version invalide', 400);
}
@@ -95,6 +96,46 @@ final class DownloadUrl
]);
}
// Extrait le NOM DE FICHIER depuis l'URL du manifest (= source de vérité).
// Avant : on utilisait un template hardcodé `proserve-{version}.zip`,
// ce qui ignorait toute personnalisation du nom (ex. opérateur qui rename
// en `proserve-full-1.5.4.zip` pour buster un cache CDN). Conséquence :
// l'endpoint retournait une URL signée pointant vers un fichier inexistant
// → 404 systématique côté client sans aucune indication serveur.
// Maintenant : on lit `download.url` du manifest, on extrait le filename
// via parse_url + basename, on vérifie qu'il existe physiquement dans
// /builds/, et seulement après on signe.
$manifestUrl = $entry['download']['url'] ?? '';
if ($manifestUrl === '') {
Response::error('manifest_incomplete',
"L'entrée manifest pour v{$version} est sans download.url", 500);
}
$parsedUrl = parse_url($manifestUrl);
$urlPath = $parsedUrl['path'] ?? '';
$filename = basename($urlPath);
// Whitelist défensive sur le filename : caractères safe + suffixe .zip.
// Évite path traversal et autres injections via un manifest corrompu.
if ($filename === '' || !preg_match('/^[a-zA-Z0-9._-]+\.zip$/', $filename)) {
Response::error('manifest_invalid_filename',
"Nom de fichier invalide extrait du manifest pour v{$version} : '{$filename}'", 500);
}
// Vérifie que le fichier existe physiquement avant de signer une URL morte.
// Cas concret de bug remonté côté client : l'opérateur rename le ZIP dans
// /builds/ mais oublie de mettre à jour versions.json (ou inverse). Au
// lieu d'envoyer le client en 404 silencieux, on retourne une erreur
// serveur claire qui apparaît dans les logs PHP + client.
$buildsDir = dirname(__DIR__, 2) . '/builds';
$physicalPath = $buildsDir . '/' . $filename;
if (!is_file($physicalPath)) {
Response::error('file_missing',
"Le fichier ZIP « {$filename} » référencé par le manifest pour v{$version} est absent du dossier /builds/. Vérifie que le manifest et le filesystem sont synchros.",
500,
[
'manifestUrl' => $manifestUrl,
'expectedPath' => $physicalPath,
]);
}
// Génère l'URL HMAC-signée
// TTL : 6 h. Compromis entre :
// - sécurité (limite la fenêtre de replay si une URL fuit)
@@ -102,7 +143,7 @@ final class DownloadUrl
// Pour une connexion plus lente, le client sait auto-refresher l'URL
// pendant le DL (cf DownloadManager → 403 retry avec nouvelle URL).
$baseUrl = rtrim($config['base_url'], '/');
$relPath = '/builds/proserve-' . $version . '.zip';
$relPath = '/builds/' . $filename;
$exp = time() + 21600; // 6 h
$secret = $config['hmac_secret'] ?? '';
if ($secret === '') {

View File

@@ -17,7 +17,8 @@ final class Releasenotes
public static function handle(string $key): void
{
$isEntryId = (bool)preg_match('/^v[0-9a-f]{8}$/', $key);
$isVersion = (bool)preg_match('/^[0-9]+\.[0-9]+\.[0-9]+$/', $key);
// X.Y.Z (release publique) ou X.Y.Z.B (itération dev/test interne).
$isVersion = (bool)preg_match('/^[0-9]+\.[0-9]+\.[0-9]+(\.[0-9]+)?$/', $key);
if (!$isEntryId && !$isVersion) {
Response::error('invalid_id', 'Bad release-notes identifier', 400);

View File

@@ -0,0 +1,18 @@
-- PS_Launcher schema v4
-- Ajoute contact_emails sur la table licenses : liste d'adresses séparées
-- par virgules ou retours-ligne (parsing tolérant côté admin), à utiliser
-- pour notifier les clients quand une nouvelle version PROSERVE est publiée.
--
-- Format de stockage : TEXT brut (pas JSON) — l'admin saisit librement, le
-- backend split sur [,;\n\r\s] et filtre par regex email avant l'envoi.
-- Pourquoi pas une table normalisée license_contacts(license_id, email) :
-- overkill pour une feature de notification opportuniste sans relations.
--
-- À jouer après 001_init.sql, 002_channel_betas.sql, 003_settings_lock.sql.
-- migrate.php attrape "Duplicate column" comme idempotent.
ALTER TABLE licenses ADD COLUMN contact_emails TEXT NULL AFTER settings_lock_password_hash;
-- Note : les licenses existantes ont contact_emails = NULL → aucun email
-- à notifier → comportement identique à avant. L'admin doit explicitement
-- attribuer les contacts via le modal d'édition license, onglet « Contacts ».

View File

@@ -0,0 +1,51 @@
# PROSERVE v1.5.4
Built on **Unreal Engine 5.7** — major visual fidelity and runtime
performance improvements across all scenarios.
This release is packed with new tooling and a major rework of our AI
systems. Here's what's new:
## ✨ New features
- **PROSERVE Editor (v0.5 Alpha)** — first integrated editor for
building and tweaking scenarios directly inside PROSERVE.
*Still alpha — feedback welcome.*
- **AI Autonomous Agent — full rework** — smarter decision-making,
more believable behaviors, smoother pathfinding and reactions.
- **Conversational AI Agent (Alpha)** — talk to NPCs in natural
language. First iteration, expect rapid improvements over the
upcoming releases.
- **PS_Launcher** — new companion tool that simplifies version
updates and the launch of PROSERVE on each workstation. Auto-detects
new releases, handles install + redists, and surfaces stats and
documentation in one place.
Download: <https://asterionvr.com/PS_Launcher/installer/PS_Launcher-Setup.exe>
- **PS_Report** — view, export, and print your mission results and
trainee statistics directly from the launcher.
## 🚀 Gameplay improvements
- **Aiming** — reduced reaction time and improved shooting precision
across all firearms.
- **Character gaze** — characters now realistically track the
trainee and points of interest, adding presence and immersion to
every interaction.
- **Hose behavior (Firefighter edition)** — reworked physics and
grip feel for the firehose; more predictable, less jittery, more
satisfying to handle.
## 🌍 Localization
- **Thai language** added — full UI translation.
---
*As always, your feedback is invaluable. If you spot any issue or
have suggestions, reach out to your ASTERION VR account manager.*

View File

@@ -56,16 +56,26 @@ final class SignManifest
* Le cache est invalidé dès que la taille OU mtime du fichier a changé,
* ce qui permet de garder le cache à jour sans intervention manuelle.
*
* IMPORTANT : si <code>$force</code> est true, on IGNORE le cache et on
* recalcule (puis on rafraîchit le cache). Sans ça, le cache interne
* supplantait silencieusement le `--force` du caller — bug subtil parce
* que la couche outer de `run()` avait déjà skip le cache (sha REPLACE
* ou force=true), mais ce helper retombait sur sa propre cache lookup
* (clé = realpath) → retournait instantanément l'ancien hash sans
* recalculer, même après que l'opérateur ait re-uploadé le ZIP via
* SFTP avec mtime préservé.
*
* @param array<string, array{size:int,mtime:int,sha256:string}> $cache
* @return array{sha256:string, fromCache:bool, durationMs:int}
*/
private function getOrComputeSha256(string $path, array &$cache): array
private function getOrComputeSha256(string $path, array &$cache, bool $force = false): array
{
$size = filesize($path) ?: 0;
$mtime = filemtime($path) ?: 0;
$key = realpath($path) ?: $path;
if (isset($cache[$key])
if (!$force
&& isset($cache[$key])
&& ($cache[$key]['size'] ?? -1) === $size
&& ($cache[$key]['mtime'] ?? -1) === $mtime
&& !empty($cache[$key]['sha256'])) {
@@ -192,7 +202,7 @@ final class SignManifest
}
}
$r = $this->getOrComputeSha256($zip, $cache);
$r = $this->getOrComputeSha256($zip, $cache, $force);
$cacheChanged = true;
$note = $r['fromCache'] ? '(cache)' : "(calculé en {$r['durationMs']} ms)";
$this->out(" [hash] $version : " . basename($zip) . " ($size octets) sha256={$r['sha256']} $note");
@@ -231,7 +241,7 @@ final class SignManifest
&& ($cache[$lkey]['sha256'] ?? '') === $existingLsha) {
$this->out(" [launcher] v{$lver} : " . basename($lpath) . " ({$lsize} octets) — cache hit");
} else {
$r = $this->getOrComputeSha256($lpath, $cache);
$r = $this->getOrComputeSha256($lpath, $cache, $force);
$cacheChanged = true;
$launcher['download']['sha256'] = $r['sha256'];
$note = $r['fromCache'] ? '(cache)' : "(calculé en {$r['durationMs']} ms)";

View File

@@ -70,7 +70,23 @@ foreach ($files as $f) {
// Découpe naïve sur les ';' suivis d'un saut de ligne (suffit pour notre schéma)
$statements = array_filter(array_map('trim', preg_split('/;\s*\n/', $sql) ?: []));
foreach ($statements as $stmt) {
if ($stmt === '' || str_starts_with($stmt, '--')) continue;
// Strip les lignes de commentaire SQL (--) du début / intercalées avant
// de checker si le statement est vide. Sinon : un fichier migration qui
// commence par un header de commentaires se retrouve dans le PREMIER
// chunk avec son premier ALTER (séparés par \n pas par ;\n) ; le chunk
// commence donc par `--`, le check str_starts_with le déclare vide,
// et l'ALTER est silencieusement skip. Bug rapporté : migrations 003+
// qui n'ont qu'un seul ALTER après un bloc de commentaires en tête
// n'étaient jamais appliquées (output vide entre "--- 003 ---" et "===").
$lines = preg_split('/\r?\n/', $stmt) ?: [];
$kept = [];
foreach ($lines as $line) {
$t = ltrim($line);
if ($t === '' || str_starts_with($t, '--')) continue;
$kept[] = $line;
}
$stmt = trim(implode("\n", $kept));
if ($stmt === '') continue;
try {
$pdo->exec($stmt);
$first40 = substr(preg_replace('/\s+/', ' ', $stmt), 0, 60);

View File

@@ -25,6 +25,7 @@ using PSLauncher.Core.Health.OpenVRInterop;
using PSLauncher.Core.Migrations;
using PSLauncher.Core.Process;
using PSLauncher.Core.ReportTool;
using PSLauncher.Core.SteamVr;
using PSLauncher.Core.Updates;
using PSLauncher.Models;
using Serilog;
@@ -245,6 +246,15 @@ public partial class App : Application
configProvider: () => sp.GetRequiredService<LocalConfig>().ApiTool,
sp.GetRequiredService<ILogger<ApiToolDeployer>>()));
// Merge des settings SteamVR : si _steamvr/steamvr.vrsettings est
// présent dans le ZIP, tue SteamVR + Vive Business Streaming et
// fusionne les blocs racine dans le fichier de l'utilisateur
// (replace si existe, ajoute sinon). Voir SteamVrSettingsDeployer.
services.AddSingleton<ISteamVrSettingsDeployer>(sp =>
new SteamVrSettingsDeployer(
configProvider: () => sp.GetRequiredService<LocalConfig>().SteamVr,
sp.GetRequiredService<ILogger<SteamVrSettingsDeployer>>()));
// Cache LAN P2P : permet aux PCs du LAN de partager les ZIPs déjà
// téléchargés au lieu de re-télécharger 14 Go depuis OVH.
// - ZipCacheStore : couche de persistence (downloads + sidecar SHA)

View File

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

View File

@@ -24,6 +24,7 @@ using PSLauncher.Core.Health;
using PSLauncher.Core.Migrations;
using PSLauncher.Core.Process;
using PSLauncher.Core.ReportTool;
using PSLauncher.Core.SteamVr;
using PSLauncher.Core.Updates;
using PSLauncher.Models;
@@ -52,6 +53,7 @@ public sealed partial class MainViewModel : ObservableObject
private readonly IReportToolDeployer _reportDeployer;
private readonly IDocToolDeployer _docDeployer;
private readonly IApiToolDeployer _apiDeployer;
private readonly ISteamVrSettingsDeployer _steamVrDeployer;
private readonly IPeerSourceResolver _peerResolver;
private readonly IZipCacheStore _zipCacheStore;
private readonly ILanCacheServer _lanCacheServer;
@@ -91,6 +93,14 @@ public sealed partial class MainViewModel : ObservableObject
/// </summary>
private System.Diagnostics.Process? _runningProserve;
/// <summary>
/// True quand un install vient d'auto-retry suite à un 404 + manifest refresh.
/// Évite la boucle infinie : si l'install retry échoue ENCORE avec 404,
/// on bascule sur le message "manifest serveur aussi obsolète" au lieu de
/// retry indéfiniment. Reseté à false en début d'install et après le retry.
/// </summary>
private bool _install404RetryConsumed;
public ObservableCollection<VersionRowViewModel> OtherVersions { get; } = new();
/// <summary>
@@ -368,6 +378,7 @@ public sealed partial class MainViewModel : ObservableObject
IReportToolDeployer reportDeployer,
IDocToolDeployer docDeployer,
IApiToolDeployer apiDeployer,
ISteamVrSettingsDeployer steamVrDeployer,
IPeerSourceResolver peerResolver,
IZipCacheStore zipCacheStore,
ILanCacheServer lanCacheServer,
@@ -391,6 +402,7 @@ public sealed partial class MainViewModel : ObservableObject
_reportDeployer = reportDeployer;
_docDeployer = docDeployer;
_apiDeployer = apiDeployer;
_steamVrDeployer = steamVrDeployer;
_peerResolver = peerResolver;
_zipCacheStore = zipCacheStore;
_lanCacheServer = lanCacheServer;
@@ -626,6 +638,7 @@ public sealed partial class MainViewModel : ObservableObject
row.ShowReleaseNotesHandler = r => _ = ShowReleaseNotesAsync(r);
row.OpenFolderHandler = OpenVersionFolder;
row.RestartFromZeroHandler = r => _ = RestartFromZeroAsync(r);
row.ForceFreshDownloadHandler = r => _ = ForceFreshDownloadAsync(r);
row.ToggleAutoModeHandler = ToggleAutoModeForRow;
// Hydrate l'état mode auto depuis la config persistée (à chaque RebuildList)
@@ -977,6 +990,69 @@ public sealed partial class MainViewModel : ObservableObject
ProgressPercent = 0;
}
/// <summary>
/// Purge TOUT pour cette version (state.json + .partial + .zip cache + sidecar
/// SHA), afin de forcer un re-DL complet au prochain Install. Utile quand le
/// SHA-256 a changé côté serveur et que la source précédente (OVH CDN, peer
/// LAN) était obsolète — le cache local du launcher pourrait perpétuer le
/// problème en servant aux peers du LAN un ZIP avec sidecar incohérent.
///
/// Cas d'usage typique : opérateur backoffice qui a rebuild un release (ex.
/// v1.5.4) avec nouveau contenu + nouveau SHA, et un PC client qui n'arrive
/// pas à re-DL parce que sa source est en cache stale.
/// </summary>
private async Task ForceFreshDownloadAsync(VersionRowViewModel row)
{
var confirm = ThemedMessageBox.Show(
Strings.MsgForceFreshConfirm(row.Version),
Strings.MsgBoxForceFresh,
MessageBoxButton.YesNo, MessageBoxImage.Question, MessageBoxResult.No);
if (confirm != MessageBoxResult.Yes) return;
// Si un DL est actif sur cette même row, on le cancel proprement avant
// de purger (même pattern que RestartFromZeroAsync).
if (_activeRow is not null && _activeRow == row && _activeDownloadCts is not null)
{
_activeDownloadCts.Cancel();
if (_activeInstallTask is { } running && !running.IsCompleted)
{
StatusMessage = "Annulation en cours…";
try
{
var awaitTask = await Task.WhenAny(running, Task.Delay(TimeSpan.FromSeconds(10)));
if (!ReferenceEquals(awaitTask, running))
_logger.LogWarning("Install task did not finish cancelling within 10s, proceeding with purge anyway");
}
catch (Exception ex) { _logger.LogDebug(ex, "Awaiting install task during force-fresh"); }
}
}
// 1. Purge state.json + .partial (ces deux sont DownloadStateStore-only)
_downloadManager.DiscardResumableState(row.Version);
// 2. Purge le cache LAN (.zip + .sha256 sidecar). Best-effort : si un
// autre process garde un handle (rare : LanCacheServer en cours de
// serve), on log warning et on continue.
try
{
await _zipCacheStore.InvalidateAsync(row.Version, CancellationToken.None);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Could not fully invalidate cache for v{Version} during force-fresh", row.Version);
}
// 3. Reset UI state pour que la row reprenne "Installer" propre.
row.ResumableBytes = 0;
row.State = VersionRowState.AvailableIdle;
row.ProgressDetail = null;
row.ProgressPercent = 0;
StatusMessage = null;
ProgressDetail = null;
ProgressPercent = 0;
_logger.LogInformation("Force-fresh download purge completed for v{Version}", row.Version);
}
// ------------ Per-row actions ------------
private void LaunchVersion(VersionRowViewModel row)
@@ -1170,6 +1246,60 @@ public sealed partial class MainViewModel : ObservableObject
/// réflexe et obtenaient <c>--login</c> ou <c>-login==Jerome</c> à l'exécution).
/// Les clés vides sont skippées silencieusement.
/// </summary>
/// <summary>
/// Construit la liste finale des processus à terminer avant le merge SteamVR.
/// Source principale : les health checks de type "Process" (= les programmes
/// que l'opérateur surveille sur le bandeau de santé = ceux qu'il sait
/// présents sur sa machine VR). On y ajoute systématiquement les processus
/// "guardian" (VIVE Business Streaming, RR*, etc.) qui relancent SteamVR
/// même si l'opérateur ne les a pas dans ses health checks, puis on ordonne
/// pour que les guardians soient tués EN PREMIER.
/// </summary>
private List<string> BuildSteamVrProcessKillList()
{
// 1) Processus configurés dans les health checks (case-insensitive).
var fromHealth = _config.HealthChecks.Checks
.Where(c => string.Equals(c.Kind, "Process", StringComparison.OrdinalIgnoreCase))
.Select(c => (c.Target ?? string.Empty).Trim())
.Where(t => !string.IsNullOrEmpty(t))
.Select(StripExeSuffix);
// 2) Fallback / défense : la liste config (qui contient VBS + SteamVR
// canoniques par défaut). Si l'opérateur n'a rien configuré en health
// checks, on aura quand même de quoi tuer ce qui faut.
var fromConfig = _config.SteamVr.ProcessesToKill
.Select(t => (t ?? string.Empty).Trim())
.Where(t => !string.IsNullOrEmpty(t))
.Select(StripExeSuffix);
// 3) Dédup case-insensitive en préservant l'ORDRE D'INSERTION : on
// insère d'abord les "guardians" connus (VBS, RR*, Htc*), puis les
// health checks, puis le reste de la config. Ainsi VBS est garanti
// tué en premier (sinon il relance SteamVR avant qu'on touche au fichier).
var guardians = new[]
{
"VIVE Business Streaming",
"RRConsole",
"RRServer",
"HtcConnectionUtility",
};
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var result = new List<string>();
foreach (var name in guardians.Concat(fromHealth).Concat(fromConfig))
{
if (seen.Add(name)) result.Add(name);
}
return result;
}
private static string StripExeSuffix(string name)
{
if (name.EndsWith(".exe", StringComparison.OrdinalIgnoreCase))
return name[..^4].Trim();
return name;
}
private static string[] BuildAutoModeCliArgs(IEnumerable<AutoModeArg> args)
{
var list = new List<string>();
@@ -1200,6 +1330,26 @@ public sealed partial class MainViewModel : ObservableObject
return list.ToArray();
}
/// <summary>
/// Extrait les deux hashes SHA-256 du message de l'<see cref="InvalidDataException"/>
/// jetée par <c>VerifyAndFinalizeAsync</c>. Format source :
/// <c>"Checksum SHA-256 invalide : attendu {expected}, calculé {computed}"</c>
/// Si parsing échoue (changement de format, log custom, etc.), retourne
/// "?" pour les deux — le reste du message reste informatif.
/// Méthode statique sans dépendance externe (regex pure), facile à tester.
/// </summary>
private static (string Expected, string Computed) ParseShaMismatchValues(string exceptionMessage)
{
// 64 chars hex = SHA-256 hex. Non-greedy, case-insensitive — couvre les
// cas où le format de message évolue légèrement (ponctuation, langue).
var m = System.Text.RegularExpressions.Regex.Match(
exceptionMessage,
@"attendu\s+([0-9a-fA-F]{64}).*calcul[eé]\s+([0-9a-fA-F]{64})",
System.Text.RegularExpressions.RegexOptions.IgnoreCase);
if (m.Success) return (m.Groups[1].Value, m.Groups[2].Value);
return ("?", "?");
}
/// <summary>
/// Point d'entrée UNIQUE pour le lancement auto avec countdown. Utilisé par :
/// 1. Clic sur bouton AUTO → désigne la version + lance après countdown
@@ -1314,6 +1464,13 @@ public sealed partial class MainViewModel : ObservableObject
_activeDownloadCts = new CancellationTokenSource();
var ct = _activeDownloadCts.Token;
// Note : on NE reset PAS _install404RetryConsumed ici. Le retry après
// 404 réinvoke SafeInstallAsync(freshRow) qui re-entre dans cette
// méthode — si on resetait, on aurait potentiellement une boucle
// infinie 404→retry→404→retry. Le flag est seulement remis à false
// dans le `finally` du HAUT (= install qui n'est PAS un retry) ou
// après un install réussi.
try
{
// Si reprise d'un DL interrompu, on saute la popup de release notes :
@@ -1370,7 +1527,37 @@ public sealed partial class MainViewModel : ObservableObject
var signed = await _licenseService.GetSignedDownloadUrlAsync(row.Version, ct);
var urlString = signed ?? row.Remote.Download.Url;
url = new Uri(urlString);
// ★ Détection serveur-side bug : si l'URL signée pointe vers un
// nom de fichier DIFFÉRENT de celui du manifest, on a un endpoint
// /download-url cassé (construit l'URL depuis un template hardcodé
// au lieu de lire le manifest). Continuer le DL est garanti de
// donner 404 → on remonte une erreur claire AVANT de DL 14 Go.
// Bug rapporté côté Asterion : DownloadUrl.php avant fix utilisait
// `proserve-{version}.zip` au lieu de basename(manifest.download.url).
if (signed is not null)
{
var signedFilename = Path.GetFileName(url.AbsolutePath);
var manifestFilename = Path.GetFileName(new Uri(row.Remote.Download.Url).AbsolutePath);
if (!string.Equals(signedFilename, manifestFilename, StringComparison.Ordinal))
{
_logger.LogError(
"Server-side bug : /download-url returned filename « {Signed} » but manifest references « {Manifest} ». Aborting DL — fix DownloadUrl.php server-side.",
signedFilename, manifestFilename);
throw new InvalidOperationException(
$"Incohérence serveur : l'endpoint /download-url retourne un nom de fichier différent du manifest. " +
$"Manifest : « {manifestFilename} ». Signé : « {signedFilename} ». " +
$"→ DownloadUrl.php côté serveur doit lire le filename depuis manifest.download.url, pas via un template hardcodé.");
}
}
}
// Diagnostic : log l'URL utilisée pour ce DL. Permet de comparer
// avec l'URL après un éventuel refresh manifest pour détecter le
// cas "le manifest serveur est aussi obsolète" (les deux URLs sont
// identiques alors qu'on attendait un changement).
_logger.LogInformation(
"Install v{Version}: download URL resolved to {Url} (source: {Source})",
row.Version, url, _currentPeerHost ?? "OVH");
// Callback de refresh d'URL : appelé par DownloadManager quand un segment
// reçoit 403/410 (URL HMAC expirée mid-DL). Pour un peer LAN l'URL n'expire
// jamais, on retourne juste la même. Pour OVH, on rappelle GetSignedDownloadUrl
@@ -1506,6 +1693,127 @@ public sealed partial class MainViewModel : ObservableObject
_logger.LogWarning(ex, "ZIP cache prune failed (non-critical)");
}
// Merge SteamVR settings : si _steamvr/steamvr.vrsettings est bundled
// dans le ZIP, tue SteamVR + Vive Business Streaming (qui le relance
// sinon) et fusionne les blocs racine dans le fichier de l'utilisateur.
// Best-effort : si SteamVR n'est pas installé ou si le merge échoue,
// on log mais on ne bloque pas l'install PROSERVE — le jeu peut tourner
// sans, juste sans le mapping tracker custom.
try
{
// ÉTAPE 1 — DRY-RUN : on commence par déterminer si un vrai
// merge changerait QUOI QUE CE SOIT. Aucun processus tué à ce
// stade, aucun fichier touché. Bénéfice : sur un PC où la conf
// SteamVR est déjà conforme à la release (cas typique d'une
// ré-install ou d'un PC mis-à-jour récemment), on saute TOUT
// le processus de fermeture sans déranger l'opérateur.
var check = await _steamVrDeployer.CheckMergeNeededAsync(target, ct);
switch (check.Outcome)
{
case SteamVrMergeCheckOutcome.NoSourceFile:
_logger.LogDebug("No _steamvr/ in v{Version} ZIP, SteamVR settings unchanged", row.Version);
goto AfterSteamVrMerge;
case SteamVrMergeCheckOutcome.DisabledByConfig:
_logger.LogInformation("SteamVR merge skipped for v{Version} (disabled in config)", row.Version);
goto AfterSteamVrMerge;
case SteamVrMergeCheckOutcome.SteamNotFound:
_logger.LogInformation(
"SteamVR merge skipped for v{Version}: Steam not found on this machine",
row.Version);
goto AfterSteamVrMerge;
case SteamVrMergeCheckOutcome.UpToDate:
// ★ Cas optimal : la conf est déjà alignée → on ne ferme
// PAS SteamVR/VBS, on ne montre PAS de popup, on log juste.
_logger.LogInformation(
"SteamVR settings already up-to-date for v{Version} (target: {Path}) — skipping kill+merge",
row.Version, check.TargetPath);
goto AfterSteamVrMerge;
case SteamVrMergeCheckOutcome.InvalidJson:
ThemedMessageBox.Show(
Strings.MsgSteamVrMergeFailed(check.ErrorMessage ?? "?"),
Strings.MsgBoxSteamVrMergeFailed,
MessageBoxButton.OK, MessageBoxImage.Warning);
goto AfterSteamVrMerge;
case SteamVrMergeCheckOutcome.Needed:
// On continue vers le pop-up + kill + merge.
break;
}
// ÉTAPE 2 — Construit la liste des processus à tuer. Source :
// les health checks de type "Process" configurés par l'opérateur.
// C'est lui qui sait quelle VR runtime tourne sur la machine ;
// on lui fait confiance plutôt que de hardcoder une liste obsolète.
//
// Sécurité : on s'assure que VIVE Business Streaming et SteamVR
// sont DANS la liste (sinon le merge échouera car SteamVR garde
// son fichier ouvert). On dédup case-insensitive et on ordonne
// VBS en tête (autres "guardians" qui relancent SteamVR aussi).
var killList = BuildSteamVrProcessKillList();
// ÉTAPE 3 — Pré-flight : popup d'avertissement avec liste des
// programmes à fermer + nombre de blocs qui changeront.
if (killList.Count > 0)
{
var listPreview = string.Join("\n • ", killList.Take(10));
if (killList.Count > 10) listPreview += $"\n • … (+{killList.Count - 10})";
var ok = ThemedMessageBox.Show(
Strings.MsgSteamVrPreFlightBody(listPreview, check.RootKeysWouldChange),
Strings.MsgSteamVrPreFlightTitle,
MessageBoxButton.OKCancel, MessageBoxImage.Information,
MessageBoxResult.OK);
if (ok != MessageBoxResult.OK)
{
// Annulé par l'opérateur : on skip le merge mais on
// continue l'install (les blocs SteamVR ne seront pas
// mis à jour ; l'opérateur le sait).
_logger.LogInformation(
"User cancelled SteamVR merge phase, skipping merge but continuing install");
goto AfterSteamVrMerge;
}
}
StatusMessage = Strings.StatusDeployingSteamVr(row.Version);
ProgressDetail = null;
ProgressPercent = 0;
var svResult = await _steamVrDeployer.MergeAsync(target, killList, ct);
switch (svResult.Status)
{
case SteamVrMergeStatus.Merged:
_logger.LogInformation(
"SteamVR merge for v{Version}: replaced={Replaced}, added={Added} (target: {Path})",
row.Version, svResult.KeysReplaced, svResult.KeysAdded, svResult.TargetPath);
break;
case SteamVrMergeStatus.SkippedNoSourceFile:
_logger.LogDebug("No _steamvr/ in v{Version} ZIP, SteamVR settings unchanged", row.Version);
break;
case SteamVrMergeStatus.SkippedDisabledByConfig:
_logger.LogInformation("SteamVR merge skipped for v{Version} (disabled in config)", row.Version);
break;
case SteamVrMergeStatus.SteamNotFound:
// Pas de popup — beaucoup de machines de dev n'ont pas Steam,
// pas la peine de spammer l'opérateur.
_logger.LogInformation(
"SteamVR merge skipped for v{Version}: Steam not found on this machine",
row.Version);
break;
case SteamVrMergeStatus.InvalidJson:
case SteamVrMergeStatus.Failed:
// Popup quand-même : c'est probablement un bug à investiguer
// (ZIP corrompu, fichier SteamVR endommagé manuellement, etc.).
ThemedMessageBox.Show(
Strings.MsgSteamVrMergeFailed(svResult.ErrorMessage ?? "?"),
Strings.MsgBoxSteamVrMergeFailed,
MessageBoxButton.OK, MessageBoxImage.Warning);
break;
}
}
catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; }
catch (Exception ex)
{
_logger.LogError(ex, "SteamVR merge threw an unhandled exception (continuing install)");
}
AfterSteamVrMerge:
// 5) Migrations DB MySQL bundled dans _migrations/. Skip si la config
// a désactivé l'auto-apply ou si le ZIP ne contient pas de répertoire.
if (_config.Database.AutoApplyMigrations)
@@ -1697,6 +2005,9 @@ public sealed partial class MainViewModel : ObservableObject
StatusMessage = Strings.StatusInstalledSuccess(row.Version);
ProgressDetail = null;
ProgressPercent = 0;
// Reset le flag retry — un futur install (même version ou autre)
// a droit à sa propre tentative de retry sur 404.
_install404RetryConsumed = false;
RebuildList();
_toastService.ShowSuccess(
Strings.ToastInstallSuccessTitle,
@@ -1713,16 +2024,195 @@ public sealed partial class MainViewModel : ObservableObject
// valeur d'avant le DL (souvent 0% ou l'ancien %).
RefreshResumableBytes(row);
}
catch (InvalidDataException shaEx) when (shaEx.Message.Contains("Checksum SHA-256", StringComparison.OrdinalIgnoreCase))
{
// Cas spécifique : SHA-256 mismatch après DL. Causes typiques :
// • OVH (CDN) sert un ZIP obsolète parce que le cache n'a pas
// encore expiré (TTL 24h-72h) suite à un re-build serveur
// • Un peer LAN sert un ZIP dont le sidecar est corrompu/menteur
// • Le manifest a été modifié manuellement sans re-build du ZIP
//
// Actions automatiques :
// 1. Purge le cache local pour cette version (le ZIP que NOUS
// avions cached avant cette tentative pourrait être obsolète
// lui aussi ; mieux vaut tout vider pour ne pas servir du
// contenu pourri aux autres peers du LAN)
// 2. Affiche un message d'erreur SPÉCIFIQUE avec la source du
// DL (peer LAN ou OVH) pour aider l'opérateur à diagnostiquer
// 3. Suggère le menu "↻ Forcer re-téléchargement" pour réessayer
// après que le cache CDN OVH ait expiré
_logger.LogError(shaEx,
"SHA-256 verify failed for v{Version} (source: {Source})",
row.Version, _currentPeerHost ?? "OVH");
row.State = VersionRowState.AvailableIdle;
row.ProgressDetail = null;
row.ProgressPercent = 0;
try
{
await _zipCacheStore.InvalidateAsync(row.Version, CancellationToken.None);
}
catch (Exception purgeEx)
{
_logger.LogWarning(purgeEx, "Could not purge LAN cache for v{Version} after SHA mismatch", row.Version);
}
var sourceLabel = _currentPeerHost is null
? Strings.SourceOvh
: Strings.SourcePeer(_currentPeerHost);
// Extrait les deux SHAs depuis le message de l'exception (format :
// "Checksum SHA-256 invalide : attendu X, calculé Y") — permet à
// l'opérateur de comparer ce qui est attendu (manifest signé) vs ce
// qui a été effectivement téléchargé (= ce que la source a servi).
// Si parse échoue, on tombe en fallback avec "?" — le message a
// toujours du sens grâce au texte explicatif.
var (expectedSha, computedSha) = ParseShaMismatchValues(shaEx.Message);
var body = Strings.MsgShaMismatchBody(row.Version, sourceLabel, expectedSha, computedSha);
ThemedMessageBox.Show(body, Strings.MsgBoxShaMismatch,
MessageBoxButton.OK, MessageBoxImage.Warning);
StatusMessage = Strings.StatusShaMismatch(row.Version);
_toastService.ShowError(Strings.ToastInstallErrorTitle(row.Version), Strings.MsgBoxShaMismatch);
RefreshResumableBytes(row);
}
catch (Exception ex)
{
_logger.LogError(ex, "Install failed for {Version}", row.Version);
row.State = VersionRowState.AvailableIdle;
row.ProgressDetail = null;
row.ProgressPercent = 0;
ThemedMessageBox.Show(
Strings.MsgInstallFailed(ex.Message),
Strings.MsgBoxError, MessageBoxButton.OK, MessageBoxImage.Error);
StatusMessage = Strings.StatusError(ex.Message);
// Cas spécifique HTTP 404 : le fichier source a été renommé/supprimé
// sur le serveur (typiquement après un re-upload pour buster le cache
// CDN OVH). Le manifest local référence l'ancienne URL → la DL fail
// en 404.
//
// Stratégie en 2 temps :
// 1. Premier 404 → auto-refresh du manifest + AUTO-RETRY install
// transparent (l'opérateur ne voit rien d'autre qu'un message
// "rafraîchissement…" puis la nouvelle DL qui démarre).
// 2. Deuxième 404 après retry → le manifest serveur ELLE-MÊME
// est obsolète (le rename a été fait au filesystem sans MAJ
// du manifest serveur). Message clair pour que l'opérateur
// sache que c'est un problème côté serveur, pas client.
bool is404 = ex.Message.Contains("HTTP 404", StringComparison.OrdinalIgnoreCase)
|| ex.Message.Contains("404 on segment", StringComparison.OrdinalIgnoreCase);
if (is404 && !_install404RetryConsumed)
{
// PREMIER 404 : on tente le refresh + retry une fois.
_install404RetryConsumed = true;
_logger.LogInformation(
"404 detected for v{Version} (first attempt) — auto-refreshing manifest then retrying install",
row.Version);
// Capture l'URL actuelle pour diagnostic (avant refresh).
var urlBefore = row.Remote?.Download.Url;
bool refreshOk = false;
try
{
await CheckForUpdatesAsync();
refreshOk = true;
}
catch (Exception refreshEx)
{
_logger.LogWarning(refreshEx, "Auto-refresh manifest after 404 failed");
}
// Récupère la row fraîche depuis le rebuild (l'instance peut
// avoir changé) pour relire la nouvelle URL du manifest.
var freshRow = (FeaturedVersion?.Version == row.Version ? FeaturedVersion : null)
?? OtherVersions.FirstOrDefault(r => r.Version == row.Version);
var urlAfter = freshRow?.Remote?.Download.Url;
_logger.LogInformation(
"Manifest URL for v{Version} — before: {Before}, after refresh: {After}",
row.Version, urlBefore, urlAfter);
if (refreshOk && freshRow is not null && freshRow.Remote is not null
&& !string.Equals(urlBefore, urlAfter, StringComparison.Ordinal))
{
// L'URL a changé → le manifest s'est bien rafraîchi avec une
// nouvelle URL pointant vers le fichier renommé. Auto-retry
// l'install. On déclenche via la même mécanique que le clic
// Install (SafeInstallAsync) pour bénéficier du try/catch
// global et de la mécanique standard.
StatusMessage = Strings.StatusAutoRetryAfter404(row.Version);
// ⚠ on libère les flags du finally en avance pour que le
// retry puisse re-prendre _activeRow / IsBusy. Le finally
// s'exécutera quand même mais idempotent (déjà null).
_activeDownloadCts?.Dispose();
_activeDownloadCts = null;
_activeRow = null;
_activeInstallTask = null;
_currentPeerHost = null;
IsBusy = false;
IsDownloadActive = false;
// Fire-and-forget : Polly et SafeInstallAsync gèrent les exceptions.
_activeInstallTask = SafeInstallAsync(freshRow);
return;
}
// URL identique après refresh OU refresh échoué → tomber dans
// le message "manifest serveur stale" (cas 2).
_logger.LogWarning(
"Manifest refresh did not produce a new URL for v{Version} — server-side manifest is also stale",
row.Version);
ThemedMessageBox.Show(
Strings.MsgServerSideStaleBody(row.Version),
Strings.MsgBoxStaleManifest,
MessageBoxButton.OK, MessageBoxImage.Warning);
StatusMessage = Strings.StatusManifestRefreshed;
}
else if (is404)
{
// DEUXIÈME 404 dans la même session install (déjà retried).
// On reset le flag pour que la prochaine action user manuelle
// (autre clic Install) reparte avec une tentative de retry fraîche.
_install404RetryConsumed = false;
ThemedMessageBox.Show(
Strings.MsgServerSideStaleBody(row.Version),
Strings.MsgBoxStaleManifest,
MessageBoxButton.OK, MessageBoxImage.Warning);
StatusMessage = Strings.StatusManifestRefreshed;
}
// Cas spécifique HTTP 416 : range demandé hors du fichier. Le ZIP
// sur le serveur est plus PETIT que ce que le manifest annonce
// (sizeBytes). Causes typiques :
// • Upload SFTP tronqué (coupé en cours) → le ZIP fait p.ex. 7 Go
// au lieu des 13 Go attendus, le manifest a la vieille taille
// • Le manifest n'a pas été re-synchronisé après un rebuild
// (nouvelle taille pas re-hashée → sizeBytes obsolète)
//
// On AUTO-PURGE le cache local (state.json + partial + LAN cache
// .zip + sidecar) pour éviter qu'un partial à mi-route reste en
// ligne et garbage les futurs retries. L'opérateur reçoit un message
// d'erreur explicite avec les tailles, et l'action concrète à faire
// côté serveur (re-upload + re-sync manifest).
else if (ex.Message.Contains("HTTP 416", StringComparison.OrdinalIgnoreCase)
|| ex.Message.Contains("416 on segment", StringComparison.OrdinalIgnoreCase))
{
_logger.LogError(
"416 detected during install of v{Version} — file size mismatch server vs manifest",
row.Version);
try
{
_downloadManager.DiscardResumableState(row.Version);
await _zipCacheStore.InvalidateAsync(row.Version, CancellationToken.None);
}
catch (Exception purgeEx)
{
_logger.LogWarning(purgeEx, "Could not purge after 416 for v{Version}", row.Version);
}
ThemedMessageBox.Show(
ex.Message,
Strings.MsgBoxSizeMismatch,
MessageBoxButton.OK, MessageBoxImage.Warning);
StatusMessage = Strings.StatusSizeMismatch(row.Version);
}
else
{
ThemedMessageBox.Show(
Strings.MsgInstallFailed(ex.Message),
Strings.MsgBoxError, MessageBoxButton.OK, MessageBoxImage.Error);
StatusMessage = Strings.StatusError(ex.Message);
}
_toastService.ShowError(Strings.ToastInstallErrorTitle(row.Version), ex.Message);
// Idem : si une erreur réseau interrompt le DL en cours de route, le
// partial est plus gros qu'avant — il faut refléter le nouveau %.

View File

@@ -102,6 +102,19 @@ public sealed partial class SettingsViewModel : ObservableObject
= new();
public bool HasApiBackups => ApiBackups.Count > 0;
// ----- SteamVR settings merge -----
[ObservableProperty] private bool _steamVrAutoMerge;
[ObservableProperty] private string _steamVrSettingsPathOverride = string.Empty;
/// <summary>Multi-line text bindé sur un TextBox : 1 process name par ligne (sans .exe).</summary>
[ObservableProperty] private string _steamVrProcessesToKill = string.Empty;
/// <summary>
/// Path canonique attendu si Steam est installé à l'emplacement par défaut.
/// Affiché sous le TextBox d'override pour que l'opérateur sache à quoi ressemble
/// la résolution standard et n'ait pas à le deviner / chercher dans la doc.
/// </summary>
public string SteamVrCanonicalPath =>
PSLauncher.Core.SteamVr.SteamVrSettingsDeployer.CanonicalSettingsPath;
// ----- Cache LAN P2P -----
[ObservableProperty] private bool _lanServerEnabled;
[ObservableProperty] private bool _lanClientEnabled;
@@ -257,6 +270,12 @@ public sealed partial class SettingsViewModel : ObservableObject
_apiAutoDeploy = config.ApiTool.AutoDeploy;
_apiMaxBackups = config.ApiTool.MaxBackups;
_steamVrAutoMerge = config.SteamVr.AutoMerge;
_steamVrSettingsPathOverride = config.SteamVr.SettingsFilePathOverride ?? string.Empty;
_steamVrProcessesToKill = string.Join(
Environment.NewLine,
config.SteamVr.ProcessesToKill ?? new List<string>());
_autoModeFeatureEnabled = config.AutoMode.FeatureEnabled;
_autoModeGraceSeconds = config.AutoMode.GracePeriodSeconds;
_autoModeSelectedVersion = config.AutoMode.SelectedVersion ?? string.Empty;
@@ -429,6 +448,23 @@ public sealed partial class SettingsViewModel : ObservableObject
_config.ApiTool.AutoDeploy = ApiAutoDeploy;
_config.ApiTool.MaxBackups = Math.Max(0, ApiMaxBackups);
_config.SteamVr.AutoMerge = SteamVrAutoMerge;
_config.SteamVr.SettingsFilePathOverride = string.IsNullOrWhiteSpace(SteamVrSettingsPathOverride)
? null : SteamVrSettingsPathOverride.Trim();
_config.SteamVr.ProcessesToKill = (SteamVrProcessesToKill ?? string.Empty)
.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries)
.Select(l =>
{
var name = l.Trim();
// Strip .exe défensif (case-insensitive) au cas où l'opérateur le tape.
if (name.EndsWith(".exe", StringComparison.OrdinalIgnoreCase))
name = name[..^4].Trim();
return name;
})
.Where(l => l.Length > 0)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
_config.AutoMode.FeatureEnabled = AutoModeFeatureEnabled;
_config.AutoMode.GracePeriodSeconds = Math.Clamp(AutoModeGraceSeconds, 1, 60);
_config.AutoMode.SelectedVersion = string.IsNullOrWhiteSpace(AutoModeSelectedVersion)

View File

@@ -208,6 +208,7 @@ public sealed partial class VersionRowViewModel : ObservableObject
public Action<VersionRowViewModel>? ShowReleaseNotesHandler { get; set; }
public Action<VersionRowViewModel>? OpenFolderHandler { get; set; }
public Action<VersionRowViewModel>? RestartFromZeroHandler { get; set; }
public Action<VersionRowViewModel>? ForceFreshDownloadHandler { get; set; }
[RelayCommand(CanExecute = nameof(CanLaunch))]
private void Launch() => LaunchHandler?.Invoke(this);
@@ -231,6 +232,15 @@ public sealed partial class VersionRowViewModel : ObservableObject
private void RestartFromZero() => RestartFromZeroHandler?.Invoke(this);
private bool CanRestartFromZero() => HasResumableDownload;
/// <summary>
/// Visible quand la row n'est pas en cours de DL (sinon le user devrait
/// d'abord cancel via le bouton dédié). Pas conditionnée par "partial existe"
/// car cette action a pour but spécifique de purger TOUT (cache + partial +
/// sidecar SHA), même quand il n'y a pas de partial en cours.
/// </summary>
[RelayCommand]
private void ForceFreshDownload() => ForceFreshDownloadHandler?.Invoke(this);
private static string FormatSize(long bytes)
=> bytes <= 0 ? "—" : Strings.FormatSize(bytes);
}

View File

@@ -194,6 +194,8 @@
<MenuItem Header="{x:Static loc:Strings.MenuRestartFromZero}"
Command="{Binding PlacementTarget.Tag.RestartFromZeroCommand, RelativeSource={RelativeSource AncestorType=ContextMenu}}"
Visibility="{Binding PlacementTarget.Tag.HasResumableDownload, RelativeSource={RelativeSource AncestorType=ContextMenu}, Converter={StaticResource BoolToVisibility}}" />
<MenuItem Header="{x:Static loc:Strings.MenuForceFreshDownload}"
Command="{Binding PlacementTarget.Tag.ForceFreshDownloadCommand, RelativeSource={RelativeSource AncestorType=ContextMenu}}" />
<Separator Visibility="{Binding PlacementTarget.Tag.IsInstalled, RelativeSource={RelativeSource AncestorType=ContextMenu}, Converter={StaticResource BoolToVisibility}}" />
<MenuItem Header="{x:Static loc:Strings.MenuUninstall}"
Command="{Binding PlacementTarget.Tag.UninstallCommand, RelativeSource={RelativeSource AncestorType=ContextMenu}}"
@@ -631,6 +633,8 @@
<MenuItem Header="{x:Static loc:Strings.MenuRestartFromZero}"
Command="{Binding PlacementTarget.Tag.RestartFromZeroCommand, RelativeSource={RelativeSource AncestorType=ContextMenu}}"
Visibility="{Binding PlacementTarget.Tag.HasResumableDownload, RelativeSource={RelativeSource AncestorType=ContextMenu}, Converter={StaticResource BoolToVisibility}}" />
<MenuItem Header="{x:Static loc:Strings.MenuForceFreshDownload}"
Command="{Binding PlacementTarget.Tag.ForceFreshDownloadCommand, RelativeSource={RelativeSource AncestorType=ContextMenu}}" />
<Separator Visibility="{Binding PlacementTarget.Tag.IsInstalled, RelativeSource={RelativeSource AncestorType=ContextMenu}, Converter={StaticResource BoolToVisibility}}" />
<MenuItem Header="{x:Static loc:Strings.MenuUninstall}"
Command="{Binding PlacementTarget.Tag.UninstallCommand, RelativeSource={RelativeSource AncestorType=ContextMenu}}"

View File

@@ -650,6 +650,52 @@
</StackPanel>
</Border>
<!-- SteamVR : merge _steamvr/steamvr.vrsettings → fichier user à l'install -->
<Border Background="{StaticResource Brush.Bg.Card}"
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1"
CornerRadius="6" Padding="20" Margin="0,0,0,16">
<StackPanel>
<TextBlock Text="{x:Static loc:Strings.SettingsSteamVr}"
FontSize="11" FontWeight="Bold"
Foreground="{StaticResource Brush.Text.Secondary}"
Margin="0,0,0,8" />
<CheckBox IsChecked="{Binding SteamVrAutoMerge}"
Margin="0,4,0,0"
Foreground="{StaticResource Brush.Text.Primary}"
Content="{x:Static loc:Strings.SettingsSteamVrAutoMerge}" />
<TextBlock Text="{x:Static loc:Strings.SettingsSteamVrSettingsPath}"
Foreground="{StaticResource Brush.Text.Secondary}"
FontSize="12" Margin="0,16,0,4" TextWrapping="Wrap" />
<TextBox Text="{Binding SteamVrSettingsPathOverride, UpdateSourceTrigger=PropertyChanged}"
Background="#1A1A20" Foreground="{StaticResource Brush.Text.Primary}"
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1" Padding="8"
FontFamily="Consolas" />
<!-- Affiche le path canonique attendu si Steam est à l'emplacement
par défaut. Évite à l'opérateur d'avoir à chercher dans la doc. -->
<TextBlock FontSize="11" FontStyle="Italic"
Foreground="{StaticResource Brush.Text.Secondary}"
Margin="0,4,0,0"
TextWrapping="Wrap">
<Run Text="Auto-détection standard : " />
<Run Text="{Binding SteamVrCanonicalPath, Mode=OneWay}"
FontFamily="Consolas" />
</TextBlock>
<TextBlock Text="{x:Static loc:Strings.SettingsSteamVrProcesses}"
Foreground="{StaticResource Brush.Text.Secondary}"
FontSize="12" Margin="0,16,0,4" TextWrapping="Wrap" />
<TextBox Text="{Binding SteamVrProcessesToKill, UpdateSourceTrigger=PropertyChanged}"
Background="#1A1A20" Foreground="{StaticResource Brush.Text.Primary}"
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1" Padding="8"
FontFamily="Consolas"
AcceptsReturn="True" TextWrapping="Wrap"
MinHeight="120" MaxHeight="200"
VerticalScrollBarVisibility="Auto" />
</StackPanel>
</Border>
<!-- Mode auto : auto-launch + auto-relaunch d'une version désignée -->
<Border Background="{StaticResource Brush.Bg.Card}"
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1"

View File

@@ -45,9 +45,20 @@ public sealed class ApiToolDeployer : IApiToolDeployer
if (!Directory.Exists(cfg.HtdocsRoot))
{
_logger.LogWarning("HtdocsRoot {Root} not found — XAMPP not installed at expected location", cfg.HtdocsRoot);
return new DeployResult(DeployStatus.XamppNotFound, 0, 0, target,
$"Le dossier {cfg.HtdocsRoot} est introuvable. XAMPP est-il installé à cet emplacement ? (Settings → Avancés)");
// Crée le dossier htdocs si absent (cf. ReportToolDeployer pour le rationale).
try
{
Directory.CreateDirectory(cfg.HtdocsRoot);
_logger.LogWarning(
"HtdocsRoot {Root} did not exist, created it. Verify that XAMPP/Apache is configured for this path.",
cfg.HtdocsRoot);
}
catch (Exception ex)
{
_logger.LogError(ex, "Could not create HtdocsRoot {Root}", cfg.HtdocsRoot);
return new DeployResult(DeployStatus.XamppNotFound, 0, 0, target,
$"Impossible de créer {cfg.HtdocsRoot} : {ex.Message}");
}
}
var stagingTarget = target + ".new";

View File

@@ -45,9 +45,20 @@ public sealed class DocToolDeployer : IDocToolDeployer
if (!Directory.Exists(cfg.HtdocsRoot))
{
_logger.LogWarning("HtdocsRoot {Root} not found — XAMPP not installed at expected location", cfg.HtdocsRoot);
return new DeployResult(DeployStatus.XamppNotFound, 0, 0, target,
$"Le dossier {cfg.HtdocsRoot} est introuvable. XAMPP est-il installé à cet emplacement ? (Settings → Avancés)");
// Crée le dossier htdocs si absent (cf. ReportToolDeployer pour le rationale).
try
{
Directory.CreateDirectory(cfg.HtdocsRoot);
_logger.LogWarning(
"HtdocsRoot {Root} did not exist, created it. Verify that XAMPP/Apache is configured for this path.",
cfg.HtdocsRoot);
}
catch (Exception ex)
{
_logger.LogError(ex, "Could not create HtdocsRoot {Root}", cfg.HtdocsRoot);
return new DeployResult(DeployStatus.XamppNotFound, 0, 0, target,
$"Impossible de créer {cfg.HtdocsRoot} : {ex.Message}");
}
}
var stagingTarget = target + ".new";

View File

@@ -312,14 +312,26 @@ public sealed class DownloadManager : IDownloadManager
// Reporter dédié : toutes les 250 ms, snapshot le compteur, calcule bps/ETA, reporte.
// Tous les 5 s ou 50 Mo, persist le state.json.
// Tourne sur un thread séparé pour ne jamais être starvé par les workers DL.
//
// Calcul du débit : on utilise une FENÊTRE GLISSANTE de ~3 s (12 samples
// de 250ms) plutôt qu'un delta instantané sur le dernier tick. Pourquoi :
// avec un delta tick-à-tick, dès qu'un segment finit OU que Polly retry
// entre 2 tentatives OU qu'il y a un micro-stall réseau, le compteur ne
// bouge pas pendant 1-2 ticks → bps = 0 → ETA = null → UI cache les
// deux 250-500 ms → clignotement permanent à l'écran (symptôme rapporté).
// Avec la fenêtre, un trou de 1-2 ticks est dilué dans 12 samples → le
// débit affiché reste stable. La latence d'adaptation est ~3s, ce qui
// est largement OK visuellement (l'opérateur ne perçoit pas 3s de retard
// sur un chiffre qui de toute façon fluctue de ±5-10 % au cours d'un DL).
const int BpsWindowSize = 12; // 12 × 250 ms = 3 s
using var reporterCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
var reporterTask = Task.Run(async () =>
{
var sw = Stopwatch.StartNew();
var lastReport = TimeSpan.Zero;
long lastReportBytes = Interlocked.Read(ref aggregateBytes);
var lastFlush = TimeSpan.Zero;
long lastFlushBytes = lastReportBytes;
long lastFlushBytes = Interlocked.Read(ref aggregateBytes);
// Fenêtre glissante (time, bytes) pour lissage du débit.
var bpsWindow = new Queue<(TimeSpan At, long Bytes)>(BpsWindowSize + 1);
try
{
while (!reporterCts.IsCancellationRequested)
@@ -327,13 +339,24 @@ public sealed class DownloadManager : IDownloadManager
await Task.Delay(250, reporterCts.Token).ConfigureAwait(false);
var snapshot = Interlocked.Read(ref aggregateBytes);
var elapsed = sw.Elapsed;
var deltaSec = (elapsed - lastReport).TotalSeconds;
var bps = deltaSec > 0 ? (snapshot - lastReportBytes) / deltaSec : 0;
// Push le nouveau sample, évince le plus ancien si on dépasse.
bpsWindow.Enqueue((elapsed, snapshot));
while (bpsWindow.Count > BpsWindowSize) bpsWindow.Dequeue();
// bps = (bytes_now - bytes_oldest_in_window) / time_span_window
// Fallback à 0 tant qu'on n'a pas au moins 2 samples (le 1er tick).
double bps = 0;
if (bpsWindow.Count >= 2)
{
var oldest = bpsWindow.Peek();
var span = (elapsed - oldest.At).TotalSeconds;
if (span > 0) bps = (snapshot - oldest.Bytes) / span;
}
TimeSpan? eta = null;
if (bps > 0 && total > snapshot) eta = TimeSpan.FromSeconds((total - snapshot) / bps);
if (bps > 0 && total > snapshot)
eta = TimeSpan.FromSeconds((total - snapshot) / bps);
progress?.Report(new DownloadProgress(snapshot, total, bps, eta));
lastReport = elapsed;
lastReportBytes = snapshot;
if ((elapsed - lastFlush).TotalSeconds >= StateFlushSecondsInterval ||
snapshot - lastFlushBytes >= StateFlushBytesInterval)
@@ -455,16 +478,84 @@ public sealed class DownloadManager : IDownloadManager
throw new HttpResumableException("Server returned 200 for segment range request", isTransient: true);
}
// 403 / 410 = URL HMAC expirée (gate.php renvoie 403 "Forbidden: expired").
// On force un refresh d'URL via le callback du job, puis on jette une
// exception transitoire pour que Polly retry avec la nouvelle URL.
if (resp.StatusCode == HttpStatusCode.Forbidden || resp.StatusCode == HttpStatusCode.Gone)
// 403 / 410 / 404 = URL HMAC expirée (gate.php renvoie 403 "Forbidden:
// expired"), OU le fichier a été renommé/supprimé côté serveur (cas
// typique : ré-upload du ZIP avec un nouveau nom pour invalider le cache
// OVH CDN). Dans les deux cas, refresh l'URL via le callback du job —
// l'endpoint /download-url/{version} renvoie la NOUVELLE URL pointant
// vers le fichier actuellement présent sur le serveur. Si refresh donne
// une URL DIFFÉRENTE de celle qu'on a utilisée dans la requête → retry
// avec la nouvelle. Sinon → c'est un vrai 404, on bubble.
//
// Note importante : on compare à `url` (la valeur utilisée dans la
// requête HTTP, capturée ligne 438 avant le send) plutôt qu'à un
// snapshot pris juste avant forceRefresh. Pourquoi : si 8 segments
// tombent en 404 simultanément, le PREMIER refresh met _currentUrl à
// jour, les autres voient le debounce 5s (no-op). Comparer à `url`
// (= URL réellement utilisée par CETTE requête, OLD) détecte
// correctement que _currentUrl est maintenant NEW → retry. Si on
// comparait à un snapshot pré-refresh, on raterait ce cas et on jetterait
// un faux 404 fatal sur tous les segments sauf le premier.
bool isUrlRefreshTrigger = resp.StatusCode == HttpStatusCode.Forbidden
|| resp.StatusCode == HttpStatusCode.Gone
|| resp.StatusCode == HttpStatusCode.NotFound
|| resp.StatusCode == HttpStatusCode.RequestedRangeNotSatisfiable;
if (isUrlRefreshTrigger && job.RefreshUrlAsync is not null)
{
if (job.RefreshUrlAsync is not null)
await GetOrRefreshUrlAsync(job, forceRefresh: true, ct).ConfigureAwait(false);
bool urlChanged = !Uri.Equals(url, _currentUrl);
if (urlChanged)
{
await GetOrRefreshUrlAsync(job, forceRefresh: true, ct).ConfigureAwait(false);
throw new HttpResumableException("Signed URL expired, refreshed for next try", isTransient: true);
_logger.LogInformation(
"Got HTTP {Status} on segment {Seg}, URL refreshed (old={Old} → new={New}), retrying",
(int)resp.StatusCode, seg.Index, url, _currentUrl);
throw new HttpResumableException(
$"HTTP {(int)resp.StatusCode} on segment {seg.Index}, URL refreshed",
isTransient: true);
}
// URL unchanged → soit le serveur insiste sur la même URL morte,
// soit le refresh a fallback sur le manifest cached (donc stale).
// 404 non-transient pour arrêter Polly et remonter une erreur claire
// qui guide l'opérateur vers « Vérifier les MAJ ».
if (resp.StatusCode == HttpStatusCode.NotFound)
{
throw new HttpResumableException(
$"HTTP 404 on segment {seg.Index} — le fichier a été renommé/supprimé côté serveur et le manifest local est obsolète. Clique « Vérifier les MAJ » avant de réessayer.",
isTransient: false);
}
// 416 unchanged URL = vrai mismatch de taille. On fait un HEAD probe
// pour obtenir la taille RÉELLE côté serveur et la rapporter dans le
// message d'erreur — l'opérateur saura ainsi s'il a un upload SFTP
// tronqué (file plus petit que prévu) ou un manifest qui ment sur
// sizeBytes. Cas concret : SFTP coupe au milieu d'un upload de 13 Go
// → le file fait 7 Go sur disque, mais le manifest dit 13 Go.
// Segment N qui demande bytes 9G-10G reçoit 416 (out of range).
if (resp.StatusCode == HttpStatusCode.RequestedRangeNotSatisfiable)
{
long? actualSize = null;
try
{
using var headReq = new HttpRequestMessage(HttpMethod.Head, url);
using var headResp = await _http.SendAsync(headReq, HttpCompletionOption.ResponseHeadersRead, ct).ConfigureAwait(false);
if (headResp.IsSuccessStatusCode)
actualSize = headResp.Content.Headers.ContentLength;
}
catch (Exception ex) { _logger.LogDebug(ex, "HEAD probe after 416 failed"); }
var actualStr = actualSize.HasValue ? $"{actualSize.Value:N0}" : "?";
var expectedStr = $"{state.TotalBytes:N0}";
_logger.LogError(
"HTTP 416 on segment {Seg} — actual server file size: {Actual} bytes, manifest expects: {Expected} bytes",
seg.Index, actualStr, expectedStr);
throw new HttpResumableException(
$"HTTP 416 on segment {seg.Index} — incohérence taille de fichier serveur : " +
$"le ZIP sur le serveur fait {actualStr} octets, le manifest attend {expectedStr} octets. " +
$"Probable cause : upload SFTP tronqué OU manifest avec sizeBytes/sha256 obsolètes. " +
$"Re-upload le ZIP complet puis « 🔁 Hasher les versions + signer » côté admin.",
isTransient: false);
}
throw new HttpResumableException(
$"Signed URL expired (HTTP {(int)resp.StatusCode}), refreshed for next try", isTransient: true);
}
if (!resp.IsSuccessStatusCode)
{

View File

@@ -23,8 +23,10 @@ public sealed partial class InstallationRegistry : IInstallationRegistry
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).
[GeneratedRegex(@"^PROSERVE v(?<v>\d+\.\d+\.\d+)$", RegexOptions.IgnoreCase)]
// nouveaux "PROSERVE vX.Y.Z" (changement de casse de la marque). Le 4ᵉ digit
// optionnel (?:\.\d+)? permet aux itérations dev/test (1.5.4.13) de
// cohabiter avec leur release stable (1.5.4) dans deux dossiers distincts.
[GeneratedRegex(@"^PROSERVE v(?<v>\d+\.\d+\.\d+(?:\.\d+)?)$", RegexOptions.IgnoreCase)]
private static partial Regex VersionFolderRegex();
private readonly ILogger<InstallationRegistry> _logger;

View File

@@ -1,18 +1,52 @@
namespace PSLauncher.Core.Installations;
public readonly record struct SemVer(int Major, int Minor, int Patch) : IComparable<SemVer>
/// <summary>
/// Version produit PROSERVE. Formats acceptés :
/// <list type="bullet">
/// <item><c>X.Y.Z</c> (3 digits — releases publiques)</item>
/// <item><c>X.Y.Z.B</c> (4 digits — itérations dev/test internes)</item>
/// </list>
/// Le 4ᵉ digit <see cref="Build"/> est sémantiquement "post-release" :
/// <c>1.5.4 == 1.5.4.0 &lt; 1.5.4.1 &lt; 1.5.4.13 &lt; 1.5.5</c>.
/// L'absence de 4ᵉ digit est équivalente à <c>.0</c> pour la comparaison,
/// mais conservée distincte dans <see cref="ToString"/> (= <c>1.5.4</c>,
/// PAS <c>1.5.4.0</c>) pour ne pas re-écrire les versions historiques
/// du manifest qui n'avaient que 3 digits.
/// </summary>
public readonly record struct SemVer(int Major, int Minor, int Patch, int Build = 0) : IComparable<SemVer>
{
/// <summary>
/// True si la version originale avait un 4ᵉ digit explicite (même <c>.0</c>).
/// Sert uniquement à <see cref="ToString"/> pour préserver le format d'origine
/// lors d'un round-trip Parse → ToString.
/// </summary>
public bool HasExplicitBuild { get; init; }
public static SemVer Parse(string s)
{
if (string.IsNullOrWhiteSpace(s)) return new SemVer(0, 0, 0);
var parts = s.Split('.');
if (parts.Length != 3
|| !int.TryParse(parts[0], out var maj)
// 3 digits = release publique (X.Y.Z), 4 digits = itération dev (X.Y.Z.B).
// Tout autre format = parse-fail → on retourne 0.0.0 (silencieux, comme
// l'ancien comportement, pour ne pas casser sur un manifest exotique).
if (parts.Length < 3 || parts.Length > 4) return new SemVer(0, 0, 0);
if (!int.TryParse(parts[0], out var maj)
|| !int.TryParse(parts[1], out var min)
|| !int.TryParse(parts[2], out var pat))
{
return new SemVer(0, 0, 0);
}
return new SemVer(maj, min, pat);
if (parts.Length == 3)
{
return new SemVer(maj, min, pat, 0) { HasExplicitBuild = false };
}
// 4 digits — itération dev
if (!int.TryParse(parts[3], out var build))
{
return new SemVer(0, 0, 0);
}
return new SemVer(maj, min, pat, build) { HasExplicitBuild = true };
}
public int CompareTo(SemVer other)
@@ -21,8 +55,13 @@ public readonly record struct SemVer(int Major, int Minor, int Patch) : ICompara
if (c != 0) return c;
c = Minor.CompareTo(other.Minor);
if (c != 0) return c;
return Patch.CompareTo(other.Patch);
c = Patch.CompareTo(other.Patch);
if (c != 0) return c;
// Build absent = 0 implicite → 1.5.4 == 1.5.4.0 < 1.5.4.1
return Build.CompareTo(other.Build);
}
public override string ToString() => $"{Major}.{Minor}.{Patch}";
public override string ToString() => HasExplicitBuild || Build > 0
? $"{Major}.{Minor}.{Patch}.{Build}"
: $"{Major}.{Minor}.{Patch}";
}

View File

@@ -26,11 +26,70 @@ public sealed class ZipInstaller : IZipInstaller
{
await Task.Run(() => ExtractZip(zipPath, stagingFolder, progress, ct), ct).ConfigureAwait(false);
// Si la version est DÉJÀ installée (cas typique : ré-installation
// d'un build sans bumper le numéro de version pendant des tests),
// on renomme l'existant en backup horodaté pour ne pas bloquer
// l'install. Le backup est supprimé en arrière-plan après que le
// swap soit confirmé (sinon on doublerait la conso disque le temps
// de la copie). Si le swap échoue, on restaure le backup.
string? renamedBackup = null;
if (Directory.Exists(targetFolder))
throw new InvalidOperationException($"Le dossier cible existe déjà : {targetFolder}");
{
renamedBackup = $"{targetFolder}.bak-{DateTime.UtcNow:yyyyMMdd-HHmmss}";
Directory.Move(targetFolder, renamedBackup);
_logger.LogInformation(
"Existing install at {Target} renamed to {Backup} before re-install",
targetFolder, renamedBackup);
}
try
{
Directory.Move(stagingFolder, targetFolder);
}
catch
{
// Rollback : restaure le backup si on a échoué après le rename
if (renamedBackup is not null && Directory.Exists(renamedBackup) && !Directory.Exists(targetFolder))
{
try
{
Directory.Move(renamedBackup, targetFolder);
_logger.LogWarning("Restored previous install from {Backup} after move failure", renamedBackup);
}
catch (Exception restoreEx)
{
_logger.LogError(restoreEx,
"FAILED to restore backup {Backup} — manual recovery needed", renamedBackup);
}
}
throw;
}
Directory.Move(stagingFolder, targetFolder);
_logger.LogInformation("Installed to {Target}", targetFolder);
// Cleanup background du backup (14 Go d'install × N réinstallations
// remplirait le disque sinon). Best-effort : si on ne peut pas
// delete (handle Windows tenace, AV qui scanne), on log et on
// laisse à l'opérateur (le nom .bak-{ts} le rend repérable).
if (renamedBackup is not null)
{
var bakToDelete = renamedBackup;
_ = Task.Run(() =>
{
try
{
Directory.Delete(bakToDelete, recursive: true);
_logger.LogInformation("Cleaned up old install backup {Backup}", bakToDelete);
}
catch (Exception ex)
{
_logger.LogWarning(ex,
"Could not delete old install backup {Backup} — operator can remove manually",
bakToDelete);
}
});
}
return targetFolder;
}
catch

View File

@@ -45,4 +45,20 @@ public interface IZipCacheStore
/// <summary>Liste tous les ZIPs cachés valides (avec sidecar SHA présent).</summary>
IReadOnlyList<CachedZip> ListCached();
/// <summary>
/// Supprime AGRESSIVEMENT le ZIP + sidecar SHA d'une version donnée du cache
/// local, peu importe son âge ou si la version est encore installée. Utilisé
/// dans deux cas :
/// <list type="number">
/// <item>Échec de vérif SHA après DL : on évacue l'éventuel ZIP cache de
/// la version concernée pour ne pas servir du contenu obsolète aux peers
/// LAN (et pour forcer un re-DL complet au prochain Install).</item>
/// <item>Action manuelle "Forcer re-téléchargement" depuis le menu UI :
/// l'opérateur veut purger explicitement (typiquement quand la source
/// OVH est en cache CDN stale et qu'il a besoin d'un re-DL frais).</item>
/// </list>
/// Best-effort : log les erreurs I/O mais ne throw pas.
/// </summary>
Task InvalidateAsync(string version, CancellationToken ct);
}

View File

@@ -149,6 +149,47 @@ public sealed class ZipCacheStore : IZipCacheStore
}, ct).ConfigureAwait(false);
}
public async Task InvalidateAsync(string version, CancellationToken ct)
{
if (!VersionRegex.IsMatch(version))
{
_logger.LogWarning("InvalidateAsync called with invalid version: {Version}", version);
return;
}
await Task.Run(() =>
{
ct.ThrowIfCancellationRequested();
var zipPath = GetCachedZipPath(version);
var shaPath = GetShaPath(version);
bool zipDeleted = false, shaDeleted = false;
try
{
if (File.Exists(zipPath)) { File.Delete(zipPath); zipDeleted = true; }
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Could not delete cached ZIP for v{Version} at {Path} (file lock?)",
version, zipPath);
}
try
{
if (File.Exists(shaPath)) { File.Delete(shaPath); shaDeleted = true; }
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Could not delete sidecar SHA for v{Version} at {Path}",
version, shaPath);
}
if (zipDeleted || shaDeleted)
{
_logger.LogInformation(
"Invalidated LAN cache for v{Version} (zip={ZipGone}, sidecar={ShaGone})",
version, zipDeleted, shaDeleted);
}
}, ct).ConfigureAwait(false);
}
public IReadOnlyList<CachedZip> ListCached()
{
var dir = _downloadStore.GetDownloadsDirectory();

View File

@@ -311,6 +311,107 @@ public static class Strings
$"فشل التنزيل أو التثبيت:\n\n{detail}"
);
// ---- SHA-256 mismatch après DL : message ciblé avec source + action ----
public static string SourceOvh => T("OVH (CDN public)", "OVH (public CDN)", "OVH公共 CDN", "OVH (CDN สาธารณะ)", "OVH (CDN عام)");
public static string SourcePeer(string host) => T(
$"peer LAN « {host} »", $"LAN peer « {host} »", $"局域网节点 « {host} »",
$"LAN peer « {host} »", $"عقدة LAN « {host} »");
public static string MsgBoxShaMismatch => T(
"Fichier téléchargé corrompu ou obsolète",
"Downloaded file corrupted or outdated",
"下载的文件已损坏或过时",
"ไฟล์ที่ดาวน์โหลดเสียหายหรือล้าสมัย",
"الملف المنزّل تالف أو قديم"
);
public static string MsgShaMismatchBody(string version, string sourceLabel, string expectedSha, string computedSha) => T(
$"Le ZIP de v{version} a été téléchargé depuis {sourceLabel}, mais son SHA-256 ne correspond pas à celui attendu par le manifest.\n\nAttendu (manifest) : {expectedSha}\nCalculé (téléchargé) : {computedSha}\n\nCauses probables :\n • Le cache CDN OVH n'a pas encore expiré après un re-build serveur (TTL 24-72 h) → l'URL est correcte mais le contenu servi est l'ancien\n • L'admin a re-uploadé le ZIP mais n'a pas cliqué « 🔁 Hasher les versions + signer » après → le manifest référence l'ancien SHA\n • SFTP a préservé le mtime du fichier → le cache .hashcache.json du serveur considère le ZIP inchangé et skip le re-hash\n\nLe cache local pour cette version a été automatiquement purgé. Actions à essayer dans l'ordre :\n 1. Côté admin serveur : « 🔁 Hasher les versions + signer » (avec « Force re-hash » si nécessaire)\n 2. Côté launcher : « ↻ Forcer re-téléchargement » dans le menu \"\" puis Installer\n 3. Si toujours échec : compare le SHA attendu ci-dessus avec le sha256 calculé manuellement sur ton ZIP serveur (sha256sum / certutil)",
$"The v{version} ZIP was downloaded from {sourceLabel}, but its SHA-256 does not match the manifest's expected value.\n\nExpected (manifest) : {expectedSha}\nComputed (downloaded): {computedSha}\n\nLikely causes:\n • OVH CDN cache hasn't expired yet after a server rebuild (TTL 24-72h) → URL is correct but stale content is served\n • Admin re-uploaded the ZIP but didn't click « 🔁 Hash + sign » after → manifest references old SHA\n • SFTP preserved the file mtime → server-side .hashcache.json considers the ZIP unchanged and skips re-hashing\n\nLocal cache for this version was automatically purged. Actions to try in order:\n 1. Server-side admin: « 🔁 Hash + sign » (with « Force re-hash » if needed)\n 2. Launcher: « ↻ Force re-download » in the \"\" menu then Install\n 3. Still failing: compare the expected SHA above with the sha256 manually computed on your server ZIP (sha256sum / certutil)",
$"v{version} 的 ZIP 是从 {sourceLabel} 下载的,但其 SHA-256 与清单中预期的值不匹配。\n\n预期清单{expectedSha}\n计算下载{computedSha}\n\n可能的原因\n • 服务器重建后 OVH CDN 缓存尚未过期TTL 24-72 小时)\n • 管理员重新上传 ZIP 后未点击「🔁 哈希 + 签名」→ 清单引用旧 SHA\n • SFTP 保留了文件 mtime → 服务器端缓存认为 ZIP 未变更并跳过重新哈希\n\n此版本的本地缓存已自动清除。",
$"ZIP ของ v{version} ดาวน์โหลดจาก {sourceLabel} แต่ SHA-256 ไม่ตรงกับค่าที่คาดหวังจาก manifest\n\nคาดหวัง (manifest) : {expectedSha}\nคำนวณ (ดาวน์โหลด) : {computedSha}\n\nสาเหตุที่เป็นไปได้:\n • แคช OVH CDN ยังไม่หมดอายุ\n • Admin อัปโหลด ZIP ใหม่แต่ไม่กด « 🔁 Hash + sign »\n • SFTP รักษา mtime ไฟล์\n\nแคชในเครื่องถูกล้างอัตโนมัติแล้ว",
$"تم تنزيل ZIP v{version} من {sourceLabel}، لكن SHA-256 لا يطابق البيان.\n\nمتوقع (البيان) : {expectedSha}\nمحسوب (المنزل) : {computedSha}\n\nالأسباب المحتملة:\n • لم تنته ذاكرة CDN OVH بعد\n • أعاد المسؤول رفع ZIP دون النقر « 🔁 Hash + sign »\n • حافظ SFTP على mtime الملف\n\nتم مسح ذاكرة التخزين المؤقت المحلية تلقائياً."
);
public static string StatusShaMismatch(string version) => T(
$"⚠ v{version} : SHA-256 invalide — cache purgé, réessaye",
$"⚠ v{version}: SHA-256 invalid — cache purged, retry",
$"⚠ v{version}SHA-256 无效 — 缓存已清除,请重试",
$"⚠ v{version}: SHA-256 ไม่ถูกต้อง — แคชถูกล้าง ลองใหม่",
$"⚠ v{version}: SHA-256 غير صالح — تم مسح الذاكرة، أعد المحاولة"
);
// ---- HTTP 404 mid-DL : manifest local obsolète (fichier renommé serveur) ----
public static string MsgBoxStaleManifest => T(
"Manifest local obsolète",
"Local manifest outdated",
"本地清单已过时",
"Manifest ในเครื่องล้าสมัย",
"البيان المحلي قديم"
);
public static string MsgStaleManifestBody(string version) => T(
$"Le fichier ZIP de v{version} a été renommé ou supprimé côté serveur (typique d'un re-upload pour invalider le cache OVH CDN).\n\nLe launcher a automatiquement re-fetché le manifest depuis le serveur — la nouvelle URL devrait maintenant être connue.\n\nRe-clique « Installer » pour réessayer avec l'URL fraîche.",
$"The v{version} ZIP file was renamed or deleted on the server (typical of a re-upload to invalidate OVH CDN cache).\n\nThe launcher has automatically re-fetched the manifest from the server — the new URL should now be known.\n\nClick « Install » again to retry with the fresh URL.",
$"v{version} 的 ZIP 文件已在服务器端重命名或删除(典型的重新上传以使 OVH CDN 缓存失效)。\n\n启动器已自动从服务器重新获取清单 — 新 URL 现在应该已知。\n\n再次点击 \"\" 以使用新 URL 重试。",
$"ไฟล์ ZIP ของ v{version} ถูกเปลี่ยนชื่อหรือลบบนเซิร์ฟเวอร์ (ทั่วไปจากการอัปโหลดใหม่เพื่อล้างแคช OVH CDN)\n\nLauncher ดึง manifest จากเซิร์ฟเวอร์อัตโนมัติแล้ว — ตอนนี้ควรรู้ URL ใหม่\n\nคลิก \"\" อีกครั้งเพื่อลองด้วย URL ใหม่",
$"تم إعادة تسمية أو حذف ملف ZIP الخاص بـ v{version} من جانب الخادم (نموذجي لإعادة الرفع لإبطال ذاكرة التخزين المؤقت لـ OVH CDN).\n\nقام المشغل بإعادة جلب البيان تلقائياً من الخادم — يجب أن يكون عنوان URL الجديد معروفاً الآن.\n\nانقر فوق \"تثبيت\" مرة أخرى لإعادة المحاولة باستخدام عنوان URL الجديد."
);
public static string StatusManifestRefreshed => T(
"Manifest rafraîchi — re-clique Installer",
"Manifest refreshed — click Install again",
"清单已刷新 — 再次点击安装",
"Manifest รีเฟรชแล้ว — คลิกติดตั้งอีกครั้ง",
"تم تحديث البيان — انقر فوق تثبيت مرة أخرى"
);
// ---- HTTP 416 : range demandé hors du fichier (file plus petit que sizeBytes) ----
public static string MsgBoxSizeMismatch => T(
"Taille de fichier incohérente sur le serveur",
"Server file size mismatch",
"服务器文件大小不一致",
"ขนาดไฟล์เซิร์ฟเวอร์ไม่ตรงกัน",
"حجم الملف على الخادم غير متطابق"
);
public static string StatusSizeMismatch(string version) => T(
$"⚠ v{version} : taille du ZIP serveur ≠ manifest — re-upload nécessaire",
$"⚠ v{version}: server ZIP size ≠ manifest — re-upload needed",
$"⚠ v{version}:服务器 ZIP 大小 ≠ 清单 — 需要重新上传",
$"⚠ v{version}: ขนาด ZIP เซิร์ฟเวอร์ ≠ manifest — ต้องอัปโหลดใหม่",
$"⚠ v{version}: حجم ZIP الخادم ≠ البيان — مطلوب إعادة الرفع"
);
public static string StatusAutoRetryAfter404(string version) => T(
$"v{version} : manifest rafraîchi, re-lancement automatique du téléchargement…",
$"v{version}: manifest refreshed, auto-retrying download…",
$"v{version}:清单已刷新,自动重试下载…",
$"v{version}: รีเฟรช manifest แล้ว ลองดาวน์โหลดใหม่อัตโนมัติ…",
$"v{version}: تم تحديث البيان، إعادة محاولة التنزيل تلقائياً…"
);
public static string MsgServerSideStaleBody(string version) => T(
$"Le téléchargement de v{version} échoue toujours en 404 même APRÈS un refresh du manifest depuis le serveur.\n\nDiagnostic :\n • Le manifest serveur lui-même est obsolète — il référence l'ancien nom de fichier qui n'existe plus.\n • Action côté serveur requise : le manifest doit être mis à jour pour pointer vers le nouveau nom du ZIP (ou re-uploader avec l'ancien nom).\n\nVérifie le backoffice serveur. Une fois le manifest corrigé, clique « Vérifier les MAJ » puis « Installer ».",
$"v{version} download still fails with 404 EVEN AFTER refreshing the manifest from the server.\n\nDiagnosis:\n • The server-side manifest itself is outdated — it references the old filename which no longer exists.\n • Server-side action required: the manifest must be updated to point to the new ZIP filename (or re-upload with the old name).\n\nCheck the server backoffice. Once the manifest is fixed, click « Check Updates » then « Install ».",
$"v{version} 下载即使在从服务器刷新清单后仍因 404 失败。\n\n诊断\n • 服务器端清单本身已过时 — 它引用了不再存在的旧文件名。\n • 需要服务器端操作:清单必须更新以指向新的 ZIP 文件名(或以旧名称重新上传)。\n\n检查服务器后台。修复清单后点击 \"\" 然后 \"安装\"。",
$"การดาวน์โหลด v{version} ยังคงล้มเหลวด้วย 404 แม้หลังจากรีเฟรช manifest จากเซิร์ฟเวอร์\n\nการวินิจฉัย:\n • Manifest ฝั่งเซิร์ฟเวอร์เองล้าสมัย — อ้างถึงชื่อไฟล์เก่าที่ไม่มีอยู่แล้ว\n • ต้องดำเนินการฝั่งเซิร์ฟเวอร์: manifest ต้องอัปเดตให้ชี้ไปยังชื่อ ZIP ใหม่ (หรืออัปโหลดใหม่ด้วยชื่อเก่า)\n\nตรวจสอบ backoffice เซิร์ฟเวอร์ เมื่อ manifest ถูกแก้ไขแล้ว ให้คลิก \"\" จากนั้น \"ติดตั้ง\"",
$"لا يزال تنزيل v{version} يفشل بـ 404 حتى بعد تحديث البيان من الخادم.\n\nالتشخيص:\n • البيان من جانب الخادم نفسه قديم — يشير إلى اسم الملف القديم الذي لم يعد موجوداً.\n • مطلوب إجراء من جانب الخادم: يجب تحديث البيان للإشارة إلى اسم ZIP الجديد (أو إعادة الرفع بالاسم القديم).\n\nتحقق من backoffice الخادم. بعد إصلاح البيان، انقر فوق \"التحقق من التحديثات\" ثم \"تثبيت\"."
);
public static string MenuForceFreshDownload => T(
"↻ Forcer re-téléchargement (vider cache local)",
"↻ Force re-download (purge local cache)",
"↻ 强制重新下载(清除本地缓存)",
"↻ บังคับดาวน์โหลดใหม่ (ล้างแคช)",
"↻ فرض إعادة التنزيل (مسح الذاكرة)"
);
public static string MsgBoxForceFresh => T(
"Forcer le re-téléchargement",
"Force re-download",
"强制重新下载",
"บังคับดาวน์โหลดใหม่",
"فرض إعادة التنزيل"
);
public static string MsgForceFreshConfirm(string version) => T(
$"Vider le cache local pour v{version} et forcer un re-téléchargement complet ?\n\nCela supprimera le ZIP cache + le sidecar SHA + tout partial de DL en cours pour cette version. Utile quand le SHA-256 a changé côté serveur et que la source précédente était obsolète.",
$"Purge the local cache for v{version} and force a full re-download?\n\nThis will delete the cached ZIP + SHA sidecar + any in-progress partial for this version. Useful when the server-side SHA-256 has changed and the previous source was stale.",
$"清除 v{version} 的本地缓存并强制完全重新下载?\n\n这将删除缓存的 ZIP + SHA sidecar + 此版本的任何进行中的部分下载。当服务器端 SHA-256 已更改且之前的源已过时时很有用。",
$"ล้างแคชในเครื่องสำหรับ v{version} และบังคับให้ดาวน์โหลดใหม่ทั้งหมด?\n\nสิ่งนี้จะลบ ZIP ที่แคช + SHA sidecar + การดาวน์โหลดบางส่วนที่กำลังดำเนินการสำหรับเวอร์ชันนี้ มีประโยชน์เมื่อ SHA-256 ฝั่งเซิร์ฟเวอร์เปลี่ยนแปลงและแหล่งที่มาก่อนหน้านี้ล้าสมัย",
$"مسح ذاكرة التخزين المؤقت المحلية لـ v{version} وفرض إعادة تنزيل كاملة؟\n\nسيؤدي هذا إلى حذف ZIP المخزن مؤقتاً + SHA sidecar + أي تنزيل جزئي قيد التقدم لهذا الإصدار. مفيد عندما يتم تغيير SHA-256 على جانب الخادم وكان المصدر السابق قديماً."
);
public static string MsgUninstallFailed(string detail) => T(
$"Suppression échouée : {detail}",
$"Uninstall failed: {detail}",
@@ -625,6 +726,72 @@ public static class Strings
public static string SettingsApiBackups => SettingsReportBackups;
public static string SettingsApiMaxBackups => SettingsReportMaxBackups;
// ---- SteamVR settings merge (tue SteamVR + Vive Business Streaming + merge JSON) ----
public static string StatusDeployingSteamVr(string version) => T(
$"Configuration SteamVR pour v{version}…",
$"Configuring SteamVR for v{version}…",
$"正在为 v{version} 配置 SteamVR…",
$"กำลังกำหนดค่า SteamVR สำหรับ v{version}…",
$"جارٍ تهيئة SteamVR لـ v{version}…"
);
public static string MsgBoxSteamVrMergeFailed => T(
"Configuration SteamVR échouée",
"SteamVR configuration failed",
"SteamVR 配置失败",
"การกำหนดค่า SteamVR ล้มเหลว",
"فشل تهيئة SteamVR"
);
public static string MsgSteamVrPreFlightTitle => T(
"Fermeture de SteamVR & Vive Business Streaming",
"Closing SteamVR & Vive Business Streaming",
"正在关闭 SteamVR 和 Vive Business Streaming",
"ปิด SteamVR และ Vive Business Streaming",
"إغلاق SteamVR و Vive Business Streaming"
);
public static string MsgSteamVrPreFlightBody(string processesPreview, int rootKeysChanging) => T(
$"{rootKeysChanging} bloc(s) de la configuration SteamVR vont être mis à jour.\n\nPour appliquer ces changements (mapping trackers, bindings), le launcher doit fermer ces programmes :\n\n • {processesPreview}\n\nIls pourront être relancés normalement après l'install. Continuer ?",
$"{rootKeysChanging} SteamVR config block(s) will be updated.\n\nTo apply these changes (tracker mappings, bindings), the launcher must close these programs:\n\n • {processesPreview}\n\nYou'll be able to relaunch them normally after the install. Proceed?",
$"将更新 {rootKeysChanging} 个 SteamVR 配置块。\n\n为应用这些更改追踪器映射、绑定启动器必须关闭这些程序\n\n • {processesPreview}\n\n安装完成后您可以正常重新启动它们。继续吗",
$"จะอัปเดตบล็อกการกำหนดค่า SteamVR {rootKeysChanging} บล็อก\n\nเพื่อใช้การเปลี่ยนแปลงเหล่านี้ (การจับคู่ตัวติดตาม การผูก) ตัวเปิดต้องปิดโปรแกรมเหล่านี้:\n\n • {processesPreview}\n\nคุณสามารถเปิดใหม่ได้ตามปกติหลังติดตั้ง ดำเนินการต่อ?",
$"سيتم تحديث {rootKeysChanging} كتلة (كتل) من تكوين SteamVR.\n\nلتطبيق هذه التغييرات (تعيينات أجهزة التتبع، الارتباطات)، يجب أن يغلق المشغل هذه البرامج:\n\n • {processesPreview}\n\nيمكنك إعادة تشغيلها بشكل طبيعي بعد التثبيت. متابعة؟"
);
public static string MsgSteamVrMergeFailed(string detail) => T(
$"Le merge des settings SteamVR a échoué :\n\n{detail}\n\nLa version PROSERVE est installée et fonctionnera, mais le mapping des trackers Vive Business Streaming pourrait être incorrect. Vérifie depuis SteamVR → Paramètres → Périphériques après le premier lancement.",
$"SteamVR settings merge failed:\n\n{detail}\n\nThe PROSERVE version is installed and will run, but Vive Business Streaming tracker bindings might be off. Verify from SteamVR → Settings → Devices after the first launch.",
$"SteamVR 设置合并失败:\n\n{detail}\n\nPROSERVE 版本已安装且将运行,但 Vive Business Streaming 跟踪器绑定可能不正确。首次启动后请通过 SteamVR → 设置 → 设备 进行检查。",
$"การรวมการตั้งค่า SteamVR ล้มเหลว:\n\n{detail}\n\nเวอร์ชัน PROSERVE ติดตั้งแล้วและจะทำงาน แต่การจับคู่ตัวติดตาม Vive Business Streaming อาจไม่ถูกต้อง ตรวจสอบจาก SteamVR → การตั้งค่า → อุปกรณ์ หลังเปิดครั้งแรก",
$"فشل دمج إعدادات SteamVR:\n\n{detail}\n\nنسخة PROSERVE مثبتة وستعمل، لكن قد يكون ربط أجهزة تتبع Vive Business Streaming غير صحيح. تحقق من SteamVR ← الإعدادات ← الأجهزة بعد التشغيل الأول."
);
public static string SettingsSteamVr => T(
"STEAMVR + VIVE BUSINESS STREAMING",
"STEAMVR + VIVE BUSINESS STREAMING",
"STEAMVR + VIVE BUSINESS STREAMING",
"STEAMVR + VIVE BUSINESS STREAMING",
"STEAMVR + VIVE BUSINESS STREAMING"
);
public static string SettingsSteamVrAutoMerge => T(
"Fusionner steamvr.vrsettings à l'install (tue SteamVR + Vive Business Streaming, modifie le JSON, laisse SteamVR redémarrer au prochain lancement PROSERVE)",
"Merge steamvr.vrsettings on install (kills SteamVR + Vive Business Streaming, edits the JSON, lets SteamVR restart on next PROSERVE launch)",
"安装时合并 steamvr.vrsettings终止 SteamVR + Vive Business Streaming编辑 JSON下次启动 PROSERVE 时让 SteamVR 重新启动)",
"รวม steamvr.vrsettings เมื่อติดตั้ง (ปิด SteamVR + Vive Business Streaming, แก้ JSON, ปล่อยให้ SteamVR เริ่มใหม่เมื่อเปิด PROSERVE ครั้งถัดไป)",
"دمج steamvr.vrsettings عند التثبيت (إنهاء SteamVR + Vive Business Streaming، تعديل JSON، السماح لـ SteamVR بإعادة التشغيل عند تشغيل PROSERVE التالي)"
);
public static string SettingsSteamVrSettingsPath => T(
"Chemin vers steamvr.vrsettings (vide = auto-détection via registre Steam)",
"Path to steamvr.vrsettings (empty = auto-detect via Steam registry)",
"steamvr.vrsettings 路径(空 = 通过 Steam 注册表自动检测)",
"พาธไปยัง steamvr.vrsettings (ว่าง = ตรวจหาอัตโนมัติผ่าน Steam registry)",
"مسار steamvr.vrsettings (فارغ = اكتشاف تلقائي عبر سجل Steam)"
);
public static string SettingsSteamVrProcesses => T(
"Processus à tuer avant le merge (un nom par ligne, sans .exe). Vive Business Streaming doit être en tête car il relance SteamVR.",
"Processes to kill before merging (one name per line, no .exe). Vive Business Streaming must be first because it auto-restarts SteamVR.",
"合并前要终止的进程(每行一个名称,无 .exe。Vive Business Streaming 必须放在第一位,因为它会自动重启 SteamVR。",
"โพรเซสที่ต้องปิดก่อนรวม (หนึ่งชื่อต่อบรรทัด ไม่ใส่ .exe) Vive Business Streaming ต้องอยู่บนสุดเพราะมันรีสตาร์ท SteamVR อัตโนมัติ",
"العمليات التي يجب إنهاؤها قبل الدمج (اسم واحد لكل سطر، بدون .exe). يجب أن يكون Vive Business Streaming في المقدمة لأنه يعيد تشغيل SteamVR تلقائياً."
);
// ---- Cache LAN P2P ----
public static string StatusDownloadingFromPeer(string host, string version) => T(
$"📡 Téléchargement de v{version} depuis {host} (LAN)…",

View File

@@ -79,6 +79,12 @@ public sealed class DatabaseMigrationService : IDatabaseMigrationService
return new MigrationResult(Array.Empty<MigrationOutcome>(), AllSucceeded: true, FailureMessage: null);
}
// Première barrière : vérifier que la base configurée existe, sinon la
// créer. Sans ça la toute première install d'un PC neuf (base pas
// encore présente côté MySQL) plantait dès `OpenAsync` avec
// "Unknown database 'proserveapi'" et l'install s'arrêtait là.
await EnsureDatabaseExistsAsync(ct).ConfigureAwait(false);
await using var conn = await OpenAsync(ct).ConfigureAwait(false);
await EnsureTrackingTableAsync(conn, ct).ConfigureAwait(false);
@@ -187,6 +193,57 @@ public sealed class DatabaseMigrationService : IDatabaseMigrationService
return conn;
}
/// <summary>
/// Ouvre une connexion SANS spécifier <c>Database</c> dans la connection
/// string, puis exécute <c>CREATE DATABASE IF NOT EXISTS</c>. Évite l'erreur
/// "Unknown database" à la toute première install d'un PC neuf où l'opérateur
/// n'a pas créé la base à la main.
///
/// L'identifier est quoté avec des backticks pour gérer les noms exotiques
/// (espaces, caractères spéciaux) ; on filtre quand même les backticks
/// pour éviter une injection si la config était trafiquée à la main.
/// </summary>
private async Task EnsureDatabaseExistsAsync(CancellationToken ct)
{
var cfg = _configProvider();
if (string.IsNullOrWhiteSpace(cfg.Database))
{
_logger.LogWarning("Database config has no Database name — cannot ensure-exists");
return;
}
var pwd = _passwordProvider() ?? string.Empty;
var csb = new MySqlConnectionStringBuilder
{
Server = cfg.Host,
Port = cfg.Port,
UserID = cfg.User,
Password = pwd,
// PAS de Database = on se connecte au serveur sans base sélectionnée.
// Le user mysql doit avoir CREATE DATABASE sinon ça échouera ici —
// dans ce cas l'opérateur sait que la base doit être créée à la main.
Pooling = false,
ConnectionTimeout = 10,
DefaultCommandTimeout = 30,
};
// Sanitize l'identifier : MySQL identifiers peuvent contenir n'importe
// quoi entre backticks, mais on rejette les backticks internes par
// sécurité (pas d'injection même si la config est falsifiée).
var safeDbName = cfg.Database.Replace("`", "");
var ddl = $"CREATE DATABASE IF NOT EXISTS `{safeDbName}` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci";
await using var conn = new MySqlConnection(csb.ConnectionString);
await conn.OpenAsync(ct).ConfigureAwait(false);
await using var cmd = new MySqlCommand(ddl, conn);
var affected = await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
// affected == 1 → base créée. affected == 0 → existait déjà.
if (affected > 0)
_logger.LogInformation("Created database `{Database}` (did not exist yet)", safeDbName);
else
_logger.LogDebug("Database `{Database}` already exists, no-op", safeDbName);
}
private static async Task EnsureTrackingTableAsync(MySqlConnection conn, CancellationToken ct)
{
const string ddl = $"""

View File

@@ -50,9 +50,23 @@ public sealed class ReportToolDeployer : IReportToolDeployer
if (!Directory.Exists(cfg.HtdocsRoot))
{
_logger.LogWarning("HtdocsRoot {Root} not found — XAMPP not installed at expected location", cfg.HtdocsRoot);
return new DeployResult(DeployStatus.XamppNotFound, 0, 0, target,
$"Le dossier {cfg.HtdocsRoot} est introuvable. XAMPP est-il installé à cet emplacement ? (Settings → Avancés)");
// Crée le dossier htdocs même si XAMPP n'est pas (encore) installé.
// Cas typique : PC fraichement déployé où XAMPP sera installé après.
// L'install PROSERVE peut tout de même s'exécuter — le déploiement
// produira un dossier prêt à servir dès qu'Apache sera up.
try
{
Directory.CreateDirectory(cfg.HtdocsRoot);
_logger.LogWarning(
"HtdocsRoot {Root} did not exist, created it. Verify that XAMPP/Apache is configured for this path.",
cfg.HtdocsRoot);
}
catch (Exception ex)
{
_logger.LogError(ex, "Could not create HtdocsRoot {Root}", cfg.HtdocsRoot);
return new DeployResult(DeployStatus.XamppNotFound, 0, 0, target,
$"Impossible de créer {cfg.HtdocsRoot} : {ex.Message}");
}
}
var stagingTarget = target + ".new";

View File

@@ -0,0 +1,114 @@
namespace PSLauncher.Core.SteamVr;
/// <summary>
/// Fusionne récursivement le <c>steamvr.vrsettings</c> bundled dans le ZIP
/// PROSERVE (sous-dossier <c>_steamvr/</c>) dans le fichier SteamVR de l'utilisateur.
///
/// Sémantique de merge (deep, récursive) :
/// <list type="bullet">
/// <item>Clé absente côté cible → ajoutée (clone profond du source)</item>
/// <item>Clé présente des deux côtés, valeurs JSON objet → <b>recursivement fusionnées</b>
/// (les sous-clés cible non touchées par le source sont préservées —
/// critique pour des blocs comme <c>"trackers"</c> qui contiennent
/// potentiellement déjà d'autres devices)</item>
/// <item>Clé présente, valeurs primitives ou de types différents (string,
/// bool, number, array) → la valeur source écrase celle de cible</item>
/// <item>Clés racine côté cible qui ne sont PAS dans le source → 100 %
/// préservées (on ne touche QUE ce que le source apporte)</item>
/// </list>
///
/// Tue préalablement SteamVR + Vive Business Streaming pour éviter qu'ils ne
/// rétablissent leurs valeurs en mémoire au prochain flush vers disque
/// (Vive Business Streaming en premier car il relance SteamVR sinon).
/// </summary>
public interface ISteamVrSettingsDeployer
{
/// <summary>
/// Dry-run : détermine si un merge réel modifierait la cible. Permet à
/// MainViewModel de skipper la phase entière (popup + kill SteamVR/VBS +
/// merge) quand la cible est déjà à jour. <b>Aucun processus tué, aucun
/// fichier touché.</b> Bénéfice opérationnel : sur un PC où la conf SteamVR
/// est déjà conforme à la release courante, la ré-install ne dérange pas
/// l'opérateur ni les sessions VR éventuellement en cours.
/// </summary>
Task<SteamVrMergeCheckResult> CheckMergeNeededAsync(
string installFolder,
CancellationToken ct);
/// <param name="processesToKill">
/// Liste explicite des noms de processus à terminer avant le merge.
/// MainViewModel construit cette liste depuis les health checks de type
/// "Process" (= les processus que l'opérateur surveille déjà = ceux qui
/// risquent de garder un handle sur le fichier SteamVR ou de relancer
/// SteamVR). VBS doit y être en tête. Si null ou vide, on retombe sur
/// la liste par défaut de <see cref="SteamVrConfig.ProcessesToKill"/>.
/// </param>
Task<SteamVrMergeResult> MergeAsync(
string installFolder,
IReadOnlyList<string>? processesToKill,
CancellationToken ct);
}
/// <summary>
/// Résultat du dry-run <see cref="ISteamVrSettingsDeployer.CheckMergeNeededAsync"/>.
/// </summary>
public enum SteamVrMergeCheckOutcome
{
/// <summary>La cible contient déjà toutes les clés du source avec les
/// mêmes valeurs (deep equality) — aucun changement ne serait apporté.
/// MainViewModel doit skipper la phase sans déranger l'opérateur.</summary>
UpToDate,
/// <summary>Le merge apporterait au moins un changement (clé ajoutée ou
/// valeur remplacée) — MainViewModel doit proposer le pop-up et procéder.</summary>
Needed,
/// <summary>Pas de <c>_steamvr/steamvr.vrsettings</c> dans le ZIP — rien à faire.</summary>
NoSourceFile,
/// <summary>Option <c>AutoMerge=false</c> dans la config — l'opérateur l'a désactivé.</summary>
DisabledByConfig,
/// <summary>Steam pas détecté sur la machine.</summary>
SteamNotFound,
/// <summary>JSON source ou cible invalide. MainViewModel peut log warn.</summary>
InvalidJson,
}
/// <param name="Outcome">État global du check.</param>
/// <param name="TargetPath">Chemin résolu vers <c>steamvr.vrsettings</c>. Non-null
/// si Steam a été localisé, même si le fichier n'existe pas encore.</param>
/// <param name="RootKeysWouldChange">Nombre de clés racine qui seraient ajoutées
/// ou modifiées. <c>0</c> ssi <see cref="Outcome"/> est <see cref="SteamVrMergeCheckOutcome.UpToDate"/>.
/// Affiché dans le popup pour donner du contexte à l'opérateur.</param>
/// <param name="ErrorMessage">Détail technique en cas d'<see cref="SteamVrMergeCheckOutcome.InvalidJson"/>.</param>
public sealed record SteamVrMergeCheckResult(
SteamVrMergeCheckOutcome Outcome,
string? TargetPath,
int RootKeysWouldChange,
string? ErrorMessage = null);
public enum SteamVrMergeStatus
{
/// <summary>Pas de <c>_steamvr/steamvr.vrsettings</c> dans le ZIP, no-op silencieux.</summary>
SkippedNoSourceFile,
/// <summary>Option <c>SteamVrConfig.AutoMerge=false</c>, l'opérateur l'a explicitement désactivé.</summary>
SkippedDisabledByConfig,
/// <summary>Steam introuvable (ni via registre ni au path par défaut).</summary>
SteamNotFound,
/// <summary>Le fichier source ou cible n'est pas un JSON valide.</summary>
InvalidJson,
/// <summary>Tout s'est bien passé.</summary>
Merged,
/// <summary>Erreur I/O ou autre exception non récupérable.</summary>
Failed,
}
/// <param name="KeysReplaced">Nombre de clés racine qui existaient déjà côté cible (overwritées).</param>
/// <param name="KeysAdded">Nombre de clés racine ajoutées (n'existaient pas côté cible).</param>
/// <param name="TargetPath">Chemin du fichier modifié. Null si le merge a été skippé.</param>
/// <param name="ProcessesKilled">Nombre total de processus tués avant le merge.</param>
/// <param name="ErrorMessage">Détail technique en cas de Failed / InvalidJson.</param>
public sealed record SteamVrMergeResult(
SteamVrMergeStatus Status,
int KeysReplaced,
int KeysAdded,
string? TargetPath,
int ProcessesKilled,
string? ErrorMessage = null);

View File

@@ -0,0 +1,589 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using Microsoft.Extensions.Logging;
using Microsoft.Win32;
using PSLauncher.Models;
using SysProcess = System.Diagnostics.Process;
namespace PSLauncher.Core.SteamVr;
/// <summary>
/// Implémentation du merge SteamVR. Voir <see cref="ISteamVrSettingsDeployer"/>.
/// </summary>
public sealed class SteamVrSettingsDeployer : ISteamVrSettingsDeployer
{
/// <summary>Sous-dossier dans le ZIP PROSERVE où chercher le fichier source.</summary>
private const string SourceFolderName = "_steamvr";
/// <summary>Nom canonique du fichier de settings SteamVR (côté source ET cible).</summary>
private const string SettingsFileName = "steamvr.vrsettings";
/// <summary>
/// Délai d'attente entre le kill des processus et l'ouverture du fichier.
/// SteamVR garde un handle sur steamvr.vrsettings et le flush ses changements
/// au shutdown ⇒ on attend que le process se termine vraiment avant de toucher
/// au fichier. 1 s suffit en pratique mais 2 s pour avoir une marge.
/// </summary>
private static readonly TimeSpan PostKillSettleDelay = TimeSpan.FromSeconds(2);
private readonly Func<SteamVrConfig> _configProvider;
private readonly ILogger<SteamVrSettingsDeployer> _logger;
public SteamVrSettingsDeployer(
Func<SteamVrConfig> configProvider,
ILogger<SteamVrSettingsDeployer> logger)
{
_configProvider = configProvider;
_logger = logger;
}
public async Task<SteamVrMergeCheckResult> CheckMergeNeededAsync(
string installFolder,
CancellationToken ct)
{
var config = _configProvider();
// 1) Fichier source présent ?
var sourcePath = Path.Combine(installFolder, SourceFolderName, SettingsFileName);
if (!File.Exists(sourcePath))
{
return new SteamVrMergeCheckResult(SteamVrMergeCheckOutcome.NoSourceFile, null, 0);
}
// 2) Option activée ?
if (!config.AutoMerge)
{
return new SteamVrMergeCheckResult(SteamVrMergeCheckOutcome.DisabledByConfig, null, 0);
}
// 3) Cible localisée ?
var targetPath = !string.IsNullOrWhiteSpace(config.SettingsFilePathOverride)
? config.SettingsFilePathOverride!
: FindSteamVrSettingsPath();
if (targetPath is null)
{
return new SteamVrMergeCheckResult(SteamVrMergeCheckOutcome.SteamNotFound, null, 0);
}
// 4) Parse source. Si invalide, le vrai merge échouera de toute façon.
JsonObject sourceObj;
try
{
var sourceText = await File.ReadAllTextAsync(sourcePath, ct);
sourceObj = JsonNode.Parse(sourceText) as JsonObject
?? throw new InvalidDataException("source root is not a JSON object");
}
catch (Exception ex)
{
return new SteamVrMergeCheckResult(
SteamVrMergeCheckOutcome.InvalidJson, targetPath, 0,
$"Source JSON invalide : {ex.Message}");
}
// 5) Si la cible n'existe pas, tout est à ajouter — merge nécessaire.
if (!File.Exists(targetPath))
{
_logger.LogInformation(
"SteamVR target file does not exist yet ({Path}) — merge needed (will create from scratch)",
targetPath);
return new SteamVrMergeCheckResult(
SteamVrMergeCheckOutcome.Needed, targetPath, sourceObj.Count);
}
// 6) Parse cible. Si invalide, on signale mais le vrai merge serait
// contraint d'écrire (= overwrite) une cible illisible.
JsonObject targetObj;
try
{
var targetText = await File.ReadAllTextAsync(targetPath, ct);
if (string.IsNullOrWhiteSpace(targetText))
{
// Fichier vide ⇒ merge ajouterait tout le source.
return new SteamVrMergeCheckResult(
SteamVrMergeCheckOutcome.Needed, targetPath, sourceObj.Count);
}
targetObj = JsonNode.Parse(targetText) as JsonObject
?? throw new InvalidDataException("target root is not a JSON object");
}
catch (Exception ex)
{
return new SteamVrMergeCheckResult(
SteamVrMergeCheckOutcome.InvalidJson, targetPath, 0,
$"Cible JSON invalide : {ex.Message}");
}
// 7) Deep-compare. On compte les clés RACINE qui changeraient (added ou
// modified) — c'est le compteur lisible pour l'opérateur. Les sous-clés
// d'un bloc qui change ne sont pas comptées individuellement.
int rootKeysChanging = 0;
foreach (var (key, srcValue) in sourceObj)
{
if (!targetObj.ContainsKey(key))
{
rootKeysChanging++;
continue;
}
var tgtValue = targetObj[key];
if (srcValue is JsonObject srcChild && tgtValue is JsonObject tgtChild)
{
if (WouldDeepMergeChange(tgtChild, srcChild))
rootKeysChanging++;
}
else if (!JsonNodesEqual(tgtValue, srcValue))
{
rootKeysChanging++;
}
}
var outcome = rootKeysChanging > 0
? SteamVrMergeCheckOutcome.Needed
: SteamVrMergeCheckOutcome.UpToDate;
_logger.LogInformation(
"SteamVR merge check: outcome={Outcome}, rootKeysChanging={Count}, target={Path}",
outcome, rootKeysChanging, targetPath);
return new SteamVrMergeCheckResult(outcome, targetPath, rootKeysChanging);
}
public async Task<SteamVrMergeResult> MergeAsync(
string installFolder,
IReadOnlyList<string>? processesToKill,
CancellationToken ct)
{
var config = _configProvider();
// 1. Cherche le fichier source dans le ZIP extrait. Pas de fichier ⇒
// pas de merge ⇒ silent skip (toutes les versions PROSERVE ne shippent
// pas forcément un _steamvr/).
var sourcePath = Path.Combine(installFolder, SourceFolderName, SettingsFileName);
if (!File.Exists(sourcePath))
{
_logger.LogDebug("No _steamvr/{File} in {Folder}, skipping SteamVR merge",
SettingsFileName, installFolder);
return new SteamVrMergeResult(
SteamVrMergeStatus.SkippedNoSourceFile, 0, 0, null, 0);
}
// 2. Opt-out par config (peut être désactivé sur des machines de dev).
if (!config.AutoMerge)
{
_logger.LogInformation("SteamVR merge disabled by config (AutoMerge=false)");
return new SteamVrMergeResult(
SteamVrMergeStatus.SkippedDisabledByConfig, 0, 0, null, 0);
}
// 3. Résout le path du fichier cible (override config OU registre OU fallback).
var targetPath = !string.IsNullOrWhiteSpace(config.SettingsFilePathOverride)
? config.SettingsFilePathOverride!
: FindSteamVrSettingsPath();
if (targetPath is null)
{
_logger.LogWarning(
"Steam install not found via registry + fallback, cannot merge SteamVR settings");
return new SteamVrMergeResult(
SteamVrMergeStatus.SteamNotFound, 0, 0, null, 0,
"Steam introuvable (registre HKCU\\Software\\Valve\\Steam + HKLM\\Wow6432Node + %ProgramFiles(x86)%\\Steam)");
}
try
{
// 4. Tue SteamVR + Vive Business Streaming. Vive en PREMIER car il
// relance auto SteamVR sinon. On parse + write avec un délai de
// règlement APRES, parce que les processus flushent leurs settings
// au shutdown.
//
// Le caller (MainViewModel) construit la liste depuis les health
// checks de type Process (= les programmes que l'opérateur surveille
// déjà). Si rien n'est passé, on retombe sur la liste par défaut
// de la config — utile pour les setups sans health checks configurés.
var killList = (processesToKill is not null && processesToKill.Count > 0)
? processesToKill
: config.ProcessesToKill;
var killed = await KillProcessesAsync(killList, ct);
if (killed > 0)
{
_logger.LogInformation(
"Killed {Count} SteamVR/VBS process(es), waiting {Delay}ms for file release",
killed, (int)PostKillSettleDelay.TotalMilliseconds);
await Task.Delay(PostKillSettleDelay, ct);
}
// 5. Parse le JSON source. Si le fichier est invalide, on log et on
// abandonne — pas la peine de risquer un overwrite avec une cible
// potentiellement valide.
JsonObject sourceObj;
try
{
var sourceText = await File.ReadAllTextAsync(sourcePath, ct);
sourceObj = JsonNode.Parse(sourceText) as JsonObject
?? throw new InvalidDataException("source root is not a JSON object");
}
catch (Exception ex)
{
_logger.LogError(ex, "Source SteamVR settings file is not valid JSON: {Path}", sourcePath);
return new SteamVrMergeResult(
SteamVrMergeStatus.InvalidJson, 0, 0, targetPath, killed,
$"Source JSON invalide : {ex.Message}");
}
// 6. Parse le JSON cible (si absent ou vide → on part d'un objet
// vide, on créera le fichier from scratch). Si le fichier existe
// mais est invalide, on abandonne (mieux vaut un fichier intact
// que de l'écraser).
JsonObject targetObj;
if (File.Exists(targetPath))
{
try
{
var targetText = await File.ReadAllTextAsync(targetPath, ct);
if (string.IsNullOrWhiteSpace(targetText))
{
targetObj = new JsonObject();
}
else
{
targetObj = JsonNode.Parse(targetText) as JsonObject
?? throw new InvalidDataException("target root is not a JSON object");
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Target SteamVR settings file is not valid JSON: {Path}", targetPath);
return new SteamVrMergeResult(
SteamVrMergeStatus.InvalidJson, 0, 0, targetPath, killed,
$"Cible JSON invalide : {ex.Message}");
}
}
else
{
_logger.LogInformation(
"Target SteamVR settings file does not exist yet, creating: {Path}",
targetPath);
Directory.CreateDirectory(Path.GetDirectoryName(targetPath)!);
targetObj = new JsonObject();
}
// 7. Deep merge récursif :
// • Clé absente côté cible → ajoute (copie complète du source)
// • Clé présente des DEUX côtés ET les deux valeurs sont des objets
// JSON → recurse (préserve les sous-clés cible non touchées par le source)
// • Sinon (types différents, ou tableau, ou primitive) → le source gagne
// (full replace de la valeur)
//
// Cas concret du bloc "trackers" : si le fichier user contient déjà
// "trackers" : { "/devices/oculus/.../X" : "TrackerRole_...",
// "/devices/htc/TKR_LEFT" : "ancienne valeur" }
// et que le source apporte
// "trackers" : { "/devices/htc_business_streaming/TKR_LEFT" : "...",
// "/devices/htc_business_streaming/TKR_RIGHT" : "..." }
// → résultat fusionné = les 3 clés oculus + 2 htc_business_streaming
// (oculus préservée, ancienne htc/TKR_LEFT préservée puisque clé
// différente, htc_business_streaming/* ajoutées).
//
// Les compteurs top-level reflètent uniquement les clés RACINE
// (remplacées ou ajoutées) parce que c'est le niveau visible dans
// les logs et le seul niveau que l'opérateur a besoin de monitorer.
int replaced = 0, added = 0;
foreach (var (key, srcValue) in sourceObj.ToList())
{
if (!targetObj.ContainsKey(key))
{
targetObj[key] = srcValue?.DeepClone();
added++;
continue;
}
// Clé présente des deux côtés. Si les deux valeurs sont des
// objets JSON, on deep-merge ; sinon le source remplace.
if (srcValue is JsonObject srcChild
&& targetObj[key] is JsonObject tgtChild)
{
DeepMergeInto(tgtChild, srcChild);
}
else
{
targetObj[key] = srcValue?.DeepClone();
}
replaced++;
}
// 8. Write atomique via .tmp + rename. SteamVR utilise un JSON
// indenté à 3 espaces dans ses propres écritures — on respecte le
// style pour minimiser le diff visuel pour un opérateur qui ouvre
// le fichier en éditeur.
var json = targetObj.ToJsonString(new JsonSerializerOptions
{
WriteIndented = true,
});
var tmpPath = targetPath + ".pslauncher-tmp";
await File.WriteAllTextAsync(tmpPath, json, ct);
// File.Move(overwrite=true) est atomique sur NTFS (.NET 5+)
File.Move(tmpPath, targetPath, overwrite: true);
_logger.LogInformation(
"SteamVR settings merge OK: replaced={Replaced}, added={Added}, target={Path}",
replaced, added, targetPath);
return new SteamVrMergeResult(
SteamVrMergeStatus.Merged, replaced, added, targetPath, killed);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
_logger.LogError(ex, "SteamVR merge failed unexpectedly");
return new SteamVrMergeResult(
SteamVrMergeStatus.Failed, 0, 0, targetPath, 0,
ex.Message);
}
}
/// <summary>
/// Path canonique de Steam x86 quand il est installé à l'emplacement
/// standard (90 % des cas). Exposé pour que la UI Settings puisse afficher
/// la valeur attendue en placeholder du TextBox d'override.
/// </summary>
public static string CanonicalSettingsPath { get; } = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86),
"Steam", "config", "steamvr.vrsettings");
/// <summary>
/// Résout le path vers <c>steamvr.vrsettings</c>. Stratégie : on essaie les
/// 3 sources dans l'ordre, et on retourne la PREMIÈRE qui pointe vers un
/// dossier Steam qui existe vraiment sur disque. Le fichier lui-même peut
/// ne pas exister encore — on le créera au write.
/// <list type="number">
/// <item>HKCU\Software\Valve\Steam → SteamPath (valeur typique : <c>C:/Program Files (x86)/Steam</c>)</item>
/// <item>HKLM\SOFTWARE\WOW6432Node\Valve\Steam → InstallPath (typique : <c>C:\Program Files (x86)\Steam</c>)</item>
/// <item>Fallback hardcodé : <c>%ProgramFiles(x86)%\Steam\config\steamvr.vrsettings</c></item>
/// </list>
/// Retourne null si AUCUNE des 3 sources ne pointe vers un dossier Steam
/// existant — Steam n'est probablement pas installé sur cette machine.
/// </summary>
private string? FindSteamVrSettingsPath()
{
// Énumère les candidats dans l'ordre de priorité. Pour chacun on
// vérifie que la racine Steam (= grand-parent du fichier final) existe
// physiquement avant d'accepter — sinon un opérateur qui a désinstallé
// Steam puis ré-installé ailleurs avait des fantômes du registre qui
// nous renvoyaient vers un dossier inexistant.
var candidates = new (string Path, string Source)[]
{
(TryRegistryPath(Registry.CurrentUser, @"Software\Valve\Steam", "SteamPath"),
"HKCU\\Software\\Valve\\Steam"),
(TryRegistryPath(Registry.LocalMachine, @"SOFTWARE\WOW6432Node\Valve\Steam", "InstallPath"),
"HKLM\\Wow6432Node\\Valve\\Steam"),
(CanonicalSettingsPath, "fallback %ProgramFiles(x86)%\\Steam"),
};
foreach (var (candidate, source) in candidates)
{
if (string.IsNullOrWhiteSpace(candidate)) continue;
// grand-parent = .../Steam (parent = .../Steam/config)
var steamRoot = Path.GetDirectoryName(Path.GetDirectoryName(candidate));
if (steamRoot is null || !Directory.Exists(steamRoot))
{
_logger.LogDebug(
"Skipping SteamVR candidate from {Source} (Steam root {Root} doesn't exist)",
source, steamRoot);
continue;
}
_logger.LogInformation(
"Resolved SteamVR settings target via {Source}: {Path} (file {Exists})",
source, candidate, File.Exists(candidate) ? "exists" : "will be created");
return candidate;
}
return null;
}
/// <summary>
/// Lit une string sous une clé registre + sous-clé nommée, normalise les
/// slashes Unix → Windows, puis compose le path vers <c>config\{SettingsFileName}</c>.
/// Retourne string.Empty si la clé n'existe pas ou si la valeur est vide.
/// Wrappé en try/catch silencieux : un manque de droits sur HKLM n'est pas
/// fatal — on tombera sur le candidat suivant.
/// </summary>
private string TryRegistryPath(RegistryKey root, string subKey, string valueName)
{
try
{
using var key = root.OpenSubKey(subKey);
if (key?.GetValue(valueName) is string raw && !string.IsNullOrWhiteSpace(raw))
{
var normalized = raw.Replace('/', '\\').TrimEnd('\\');
return Path.Combine(normalized, "config", SettingsFileName);
}
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Registry read failed for {Root}\\{SubKey}!{Value}",
root.Name, subKey, valueName);
}
return string.Empty;
}
/// <summary>
/// Tue tous les processus dont le nom (sans .exe) correspond à un des
/// patterns de <paramref name="processNames"/> (case-insensitive). Best-effort :
/// si on ne peut pas killer un process (AccessDenied, déjà parti), on log
/// et on continue. Retourne le nombre total de process effectivement tués.
///
/// L'ordre des entrées dans la liste compte : Vive Business Streaming
/// doit être en TÊTE car il relance SteamVR sinon.
/// </summary>
/// <summary>
/// Fusionne récursivement <paramref name="source"/> dans <paramref name="target"/>.
/// Règles par clé :
/// • absente côté cible → ajout (clone profond de la valeur source)
/// • présente des deux côtés ET les deux valeurs sont des objets JSON → recurse
/// • sinon → la valeur source écrase celle de cible
///
/// Les arrays JSON sont traités comme des "primitives" (= replace complet,
/// pas de merge par index ni dédup). SteamVR n'utilise pas d'arrays dans
/// ses settings de toute façon, c'est juste pour avoir un comportement
/// prévisible si jamais.
/// </summary>
/// <summary>
/// Dry-run récursif : applique mentalement le même algo que
/// <see cref="DeepMergeInto"/> et retourne true dès qu'au moins UNE clé du
/// source serait ajoutée ou modifiée côté target. Short-circuit pour ne
/// pas parcourir le JSON entier inutilement quand on a déjà trouvé un diff.
/// </summary>
private static bool WouldDeepMergeChange(JsonObject target, JsonObject source)
{
foreach (var (key, srcValue) in source)
{
if (!target.ContainsKey(key)) return true;
var tgtValue = target[key];
if (srcValue is JsonObject srcChild && tgtValue is JsonObject tgtChild)
{
if (WouldDeepMergeChange(tgtChild, srcChild)) return true;
}
else if (!JsonNodesEqual(tgtValue, srcValue))
{
return true;
}
}
return false;
}
/// <summary>
/// Comparaison d'égalité structurelle entre deux JsonNode (objects, arrays,
/// primitives). System.Text.Json n'expose pas <c>DeepEquals</c> avant .NET 9 ;
/// on l'implémente à la main. Pour les primitives, on compare via la
/// serialization JSON par défaut — ToJsonString produit un format canonique
/// pour chaque type (number, string, bool, null), donc deux valeurs
/// logiquement égales produiront la même chaîne.
/// </summary>
private static bool JsonNodesEqual(JsonNode? a, JsonNode? b)
{
if (a is null && b is null) return true;
if (a is null || b is null) return false;
if (a is JsonObject aObj && b is JsonObject bObj)
{
if (aObj.Count != bObj.Count) return false;
foreach (var (key, aVal) in aObj)
{
if (!bObj.TryGetPropertyValue(key, out var bVal)) return false;
if (!JsonNodesEqual(aVal, bVal)) return false;
}
return true;
}
if (a is JsonArray aArr && b is JsonArray bArr)
{
if (aArr.Count != bArr.Count) return false;
for (int i = 0; i < aArr.Count; i++)
{
if (!JsonNodesEqual(aArr[i], bArr[i])) return false;
}
return true;
}
if (a is JsonValue && b is JsonValue)
{
// Primitives : la sérialisation canonique gère uniformément les
// numbers (int/long/double), strings, bools, null.
return string.Equals(a.ToJsonString(), b.ToJsonString(), StringComparison.Ordinal);
}
// Types incompatibles (ex: object vs primitive)
return false;
}
private static void DeepMergeInto(JsonObject target, JsonObject source)
{
foreach (var (key, srcValue) in source.ToList())
{
if (!target.ContainsKey(key))
{
target[key] = srcValue?.DeepClone();
continue;
}
if (srcValue is JsonObject srcChild
&& target[key] is JsonObject tgtChild)
{
DeepMergeInto(tgtChild, srcChild);
}
else
{
target[key] = srcValue?.DeepClone();
}
}
}
private async Task<int> KillProcessesAsync(
IReadOnlyList<string> processNames,
CancellationToken ct)
{
int total = 0;
foreach (var name in processNames)
{
ct.ThrowIfCancellationRequested();
var trimmed = name?.Trim();
if (string.IsNullOrEmpty(trimmed)) continue;
// GetProcessesByName attend le nom SANS le .exe ; un opérateur peut
// l'avoir saisi avec → strip défensif.
if (trimmed.EndsWith(".exe", StringComparison.OrdinalIgnoreCase))
trimmed = trimmed[..^4];
SysProcess[] procs;
try { procs = SysProcess.GetProcessesByName(trimmed); }
catch (Exception ex)
{
_logger.LogDebug(ex, "GetProcessesByName({Name}) failed", trimmed);
continue;
}
foreach (var p in procs)
{
try
{
if (p.HasExited) { p.Dispose(); continue; }
_logger.LogInformation("Killing process {Name} (PID {Pid})", p.ProcessName, p.Id);
p.Kill(entireProcessTree: true);
// Wait un peu pour que le process release ses handles
try { await p.WaitForExitAsync(ct); }
catch (OperationCanceledException) { throw; }
catch { /* race avec Kill possible, ok */ }
total++;
}
catch (OperationCanceledException) { throw; }
catch (Exception ex)
{
_logger.LogWarning(ex,
"Could not kill process {Name} (PID {Pid}) — continuing",
p.ProcessName, p.Id);
}
finally
{
try { p.Dispose(); } catch { }
}
}
}
return total;
}
}

View File

@@ -71,6 +71,15 @@ public sealed class LocalConfig
/// </summary>
public ApiToolConfig ApiTool { get; set; } = new();
/// <summary>
/// Configuration du merge des settings SteamVR. Si le ZIP contient un sous-dossier
/// <c>_steamvr/</c> avec <c>steamvr.vrsettings</c>, le launcher arrête SteamVR
/// et Vive Business Streaming, fusionne les blocs racine du JSON source dans le
/// fichier SteamVR de l'utilisateur (replace si la clé existe, ajoute sinon),
/// puis laisse SteamVR redémarrer naturellement quand l'utilisateur lance PROSERVE.
/// </summary>
public SteamVrConfig SteamVr { get; set; } = new();
/// <summary>
/// Cache LAN P2P : permet à un PC du LAN de partager les ZIPs déjà téléchargés
/// aux autres PCs (mode serveur), et/ou de chercher les ZIPs sur le LAN avant
@@ -315,6 +324,64 @@ public sealed class HealthCheckEntry
public int? PingTimeoutMs { get; set; } = 1000;
}
/// <summary>
/// Configuration du merge SteamVR au moment de l'install d'une nouvelle version
/// PROSERVE. Le ZIP peut contenir un dossier <c>_steamvr/steamvr.vrsettings</c>
/// avec des blocs racine spécifiques au jeu (system.generated.openxr.*, trackers,
/// etc.). Si <see cref="AutoMerge"/> est true, le launcher :
/// <list type="number">
/// <item>tue SteamVR + Vive Business Streaming (qui le relance auto)</item>
/// <item>localise <c>steamvr.vrsettings</c> via le registre Steam (ou <see cref="SettingsFilePathOverride"/>)</item>
/// <item>parse les deux JSON, remplace chaque clé racine du source dans la cible (ou ajoute si absente)</item>
/// <item>écrit le résultat atomiquement (rename .tmp → final)</item>
/// </list>
/// SteamVR redémarrera naturellement quand l'opérateur relance PROSERVE après
/// l'install.
/// </summary>
public sealed class SteamVrConfig
{
/// <summary>
/// Si true, le launcher applique le merge automatiquement à chaque install
/// quand <c>_steamvr/steamvr.vrsettings</c> est présent dans le ZIP. Default
/// true (transparent pour l'opérateur), mais peut être désactivé pour les
/// machines de dev où le settings doit rester intact.
/// </summary>
public bool AutoMerge { get; set; } = true;
/// <summary>
/// Path explicite vers le <c>steamvr.vrsettings</c> à modifier. Null →
/// auto-détection via :
/// <list type="number">
/// <item>HKCU\Software\Valve\Steam → SteamPath → config\steamvr.vrsettings</item>
/// <item>HKLM\SOFTWARE\WOW6432Node\Valve\Steam → InstallPath → idem</item>
/// <item>fallback %ProgramFilesX86%\Steam\config\steamvr.vrsettings</item>
/// </list>
/// </summary>
public string? SettingsFilePathOverride { get; set; }
/// <summary>
/// Processus à tuer avant le merge (sans le suffixe .exe). Default = la
/// liste typique SteamVR + Vive Business Streaming. Vive Business Streaming
/// relance auto SteamVR ⇒ il faut le tuer EN PREMIER. Editable depuis
/// Settings pour les setups exotiques (autres runtimes XR).
/// </summary>
public List<string> ProcessesToKill { get; set; } = new()
{
// Vive Business Streaming (à tuer en premier — relance SteamVR sinon)
"VIVE Business Streaming",
"RRConsole",
"RRServer",
"HtcConnectionUtility",
// SteamVR
"vrserver",
"vrmonitor",
"vrcompositor",
"vrdashboard",
"vrwebhelper",
"vrstartup",
};
}
/// <summary>
/// Configuration du mode auto. <see cref="FeatureEnabled"/> est le kill-switch
/// global — quand false, les boutons AUTO ne sont pas affichés sur les rows et