diff --git a/installer/PSLauncher.iss b/installer/PSLauncher.iss index 82f342c..ee1efd8 100644 --- a/installer/PSLauncher.iss +++ b/installer/PSLauncher.iss @@ -11,7 +11,7 @@ #define MyAppName "PROSERVE Launcher" #define MyAppShortName "PS_Launcher" -#define MyAppVersion "0.29.7" +#define MyAppVersion "0.29.10" #define MyAppPublisher "ASTERION VR" #define MyAppURL "https://asterionvr.com" #define MyAppExeName "PS_Launcher.exe" diff --git a/server/admin/assets/asterion-logo.png b/server/admin/assets/asterion-logo.png new file mode 100644 index 0000000..73990de Binary files /dev/null and b/server/admin/assets/asterion-logo.png differ diff --git a/server/admin/assets/style.css b/server/admin/assets/style.css index a128709..20d2ea2 100644 --- a/server/admin/assets/style.css +++ b/server/admin/assets/style.css @@ -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 () — éditeur d'une entrée du manifest + Remplace les
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); } } diff --git a/server/admin/launcher.php b/server/admin/launcher.php index 540963c..2d3b52c 100644 --- a/server/admin/launcher.php +++ b/server/admin/launcher.php @@ -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');
- + + value="" required pattern="\d+\.\d+\.\d+(\.\d+)?">
- + + value="" pattern="\d+\.\d+\.\d+(\.\d+)?">
diff --git a/server/admin/lib/Mailer.php b/server/admin/lib/Mailer.php new file mode 100644 index 0000000..c7f35fc --- /dev/null +++ b/server/admin/lib/Mailer.php @@ -0,0 +1,201 @@ +mail() 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 par un appel + * $mailer->send(). + */ +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 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 est non vide, on construit une structure + * MIME multipart/related (CID embed) au lieu de multipart/alternative. + * Les images sont attachées comme pièces jointes inline, référencées dans + * le HTML via <img src="cid:...">. 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
et

en sauts de ligne avant strip_tags + $s = preg_replace('##i', "\n", $html) ?? $html; + $s = preg_replace('#

\s*]*>#i', "\n\n", $s) ?? $s; + $s = preg_replace('#]*href="([^"]+)"[^>]*>([^<]+)#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 " 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}>"; + } +} diff --git a/server/admin/licenses.php b/server/admin/licenses.php index 93a234a..1aaf073 100644 --- a/server/admin/licenses.php +++ b/server/admin/licenses.php @@ -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'); # + +
+ + +
-
+
@@ -313,119 +350,259 @@ Layout::header('Licenses', 'licenses'); - -
- - - - - β BÊTA - -
- - -
- - - - -
- - -
- - ✎ Channel - -
- - - - - -
-
+ + + + + + β BÊTA + + + + 🔒 + -
- Prolonger -
+ +
+ + + + + + + + + +
+ + + + +
+ +
+ + + ). + // Même pattern que versions.php : dans est + // foster-parented imprévisiblement → on émet hors de la table. + ob_start(); + ?> + +
+

License #

+ +
+ + + + +
+
+
@@ -500,4 +685,70 @@ Layout::header('Licenses', 'licenses');
+ + pour que showModal() fonctionne. ?> + + + + + notifications.logo_url) 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
 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 !== ''
+        ? "

Release notes

\n" + . "
{$notesEsc}
" + : '

No release notes for this version.

'; + + // 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 !== '' + ? "\"ASTERION" + : 'ASTERION VR'; + + // 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 : + // • → fond bleu solide visible dans TOUS les MUA + // • Styles inline sur chaque + + + // — un dans un est exclu du + // layout tree par certains browsers, ce qui empêche showModal() + // de fonctionner (le bouton « ✎ Modifier » devient muet). ?> + */ ?> + +
+

Éditer v + + BÊTA + +

+ +
+ + + + +
+ + + + + + . Pourquoi : directement + // ou indirectement dans
→ 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 << + + + + + + + + + + + + + +
+
{$logoBlock}
+

+ PROSERVE v{$versionEsc} is available! +

+

+ A new version is ready to install via your PROSERVE Launcher. +

+
+

Hi {$ownerEsc},

+

+ We're happy to let you know that + PROSERVE version {$versionEsc} + has just been published and is now available for download. +

+

+ If you already have the PROSERVE Launcher installed, + simply open it on any of your machines, click + « Check for updates », and install the new version with one click. +

+ + + + + + +
+

+ First install or new workstation? +

+

+ If this is your first time, or you're setting up a new PC, + download the PROSERVE Launcher installer below. + You'll need to run it on EACH workstation + where PROSERVE will be used. +

+ + + + + +
+ + ⬇ Download PROSERVE Launcher (Windows) + +
+

+ Direct link: + {$installerUrlEsc} +

+
+ + {$notesBlock} +

+ 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. +

+

+ Happy training!
+ The ASTERION VR team +

+
+ This automated notification was sent because your contact email is registered with one of your PROSERVE licenses.
+ To stop receiving these, ask your ASTERION VR account manager to remove your address from the license contacts. +
+ +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');
- - + +
+
- +
- - + +
@@ -479,7 +782,7 @@ Layout::header('Versions', 'versions'); • latest : v - + @@ -558,130 +861,261 @@ Layout::header('Versions', 'versions');
- -
- - - - -
- -
- ⚙ Hash -
-

+ +

+ +
+ + + + + +
+ + +
+ + + + +
+
+ + + + +
+
+
// 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(); + ?> @@ -690,6 +1124,11 @@ Layout::header('Versions', 'versions');
Aucune version dans le manifest.
+ d'édition sont émis ici, hors de la 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). ?> + +

ZIPs présents dans builds/

@@ -718,4 +1157,62 @@ Layout::header('Versions', 'versions');
+ + + + + + + '', + + // === 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' => '', + ], ]; diff --git a/server/api/index.php b/server/api/index.php index 64c9ceb..6de3422 100644 --- a/server/api/index.php +++ b/server/api/index.php @@ -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'; diff --git a/server/api/routes/DownloadUrl.php b/server/api/routes/DownloadUrl.php index 9d72ef5..39f1014 100644 --- a/server/api/routes/DownloadUrl.php +++ b/server/api/routes/DownloadUrl.php @@ -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); } diff --git a/server/api/routes/Releasenotes.php b/server/api/routes/Releasenotes.php index 056568f..b44293b 100644 --- a/server/api/routes/Releasenotes.php +++ b/server/api/routes/Releasenotes.php @@ -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); diff --git a/server/migrations/004_license_contact_emails.sql b/server/migrations/004_license_contact_emails.sql new file mode 100644 index 0000000..41f18ba --- /dev/null +++ b/server/migrations/004_license_contact_emails.sql @@ -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 ». diff --git a/server/releasenotes/1.5.4.md b/server/releasenotes/1.5.4.md new file mode 100644 index 0000000..c1c1a10 --- /dev/null +++ b/server/releasenotes/1.5.4.md @@ -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: + +- **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.* \ No newline at end of file diff --git a/server/tools/SignManifest.php b/server/tools/SignManifest.php index cc302dd..b1237d0 100644 --- a/server/tools/SignManifest.php +++ b/server/tools/SignManifest.php @@ -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 $force 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 $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)"; diff --git a/server/tools/migrate.php b/server/tools/migrate.php index c115f3b..56e2e8c 100644 --- a/server/tools/migrate.php +++ b/server/tools/migrate.php @@ -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); diff --git a/src/PSLauncher.App/PSLauncher.App.csproj b/src/PSLauncher.App/PSLauncher.App.csproj index 8078336..a659731 100644 --- a/src/PSLauncher.App/PSLauncher.App.csproj +++ b/src/PSLauncher.App/PSLauncher.App.csproj @@ -18,9 +18,9 @@ PROSERVE Launcher © 2026 ASTERION VR — All rights reserved PSLauncher.App - 0.29.7 - 0.29.7.0 - 0.29.7.0 + 0.29.10 + 0.29.10.0 + 0.29.10.0 true diff --git a/src/PSLauncher.App/ViewModels/MainViewModel.cs b/src/PSLauncher.App/ViewModels/MainViewModel.cs index e6150ff..cdfabe9 100644 --- a/src/PSLauncher.App/ViewModels/MainViewModel.cs +++ b/src/PSLauncher.App/ViewModels/MainViewModel.cs @@ -1330,6 +1330,26 @@ public sealed partial class MainViewModel : ObservableObject return list.ToArray(); } + /// + /// Extrait les deux hashes SHA-256 du message de l' + /// jetée par VerifyAndFinalizeAsync. Format source : + /// "Checksum SHA-256 invalide : attendu {expected}, calculé {computed}" + /// 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. + /// + 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 ("?", "?"); + } + /// /// 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 @@ -2038,7 +2058,14 @@ public sealed partial class MainViewModel : ObservableObject var sourceLabel = _currentPeerHost is null ? Strings.SourceOvh : Strings.SourcePeer(_currentPeerHost); - var body = Strings.MsgShaMismatchBody(row.Version, sourceLabel); + // 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); @@ -2145,6 +2172,40 @@ public sealed partial class MainViewModel : ObservableObject 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( diff --git a/src/PSLauncher.Core/Downloads/DownloadManager.cs b/src/PSLauncher.Core/Downloads/DownloadManager.cs index eec3c0c..83ea3ee 100644 --- a/src/PSLauncher.Core/Downloads/DownloadManager.cs +++ b/src/PSLauncher.Core/Downloads/DownloadManager.cs @@ -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) @@ -475,7 +498,8 @@ public sealed class DownloadManager : IDownloadManager // 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.NotFound + || resp.StatusCode == HttpStatusCode.RequestedRangeNotSatisfiable; if (isUrlRefreshTrigger && job.RefreshUrlAsync is not null) { await GetOrRefreshUrlAsync(job, forceRefresh: true, ct).ConfigureAwait(false); @@ -499,6 +523,37 @@ public sealed class DownloadManager : IDownloadManager $"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); } diff --git a/src/PSLauncher.Core/Installations/InstallationRegistry.cs b/src/PSLauncher.Core/Installations/InstallationRegistry.cs index 32aba94..c427ecd 100644 --- a/src/PSLauncher.Core/Installations/InstallationRegistry.cs +++ b/src/PSLauncher.Core/Installations/InstallationRegistry.cs @@ -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(?\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(?\d+\.\d+\.\d+(?:\.\d+)?)$", RegexOptions.IgnoreCase)] private static partial Regex VersionFolderRegex(); private readonly ILogger _logger; diff --git a/src/PSLauncher.Core/Installations/SemVer.cs b/src/PSLauncher.Core/Installations/SemVer.cs index 7db1006..d0af851 100644 --- a/src/PSLauncher.Core/Installations/SemVer.cs +++ b/src/PSLauncher.Core/Installations/SemVer.cs @@ -1,18 +1,52 @@ namespace PSLauncher.Core.Installations; -public readonly record struct SemVer(int Major, int Minor, int Patch) : IComparable +/// +/// Version produit PROSERVE. Formats acceptés : +/// +/// X.Y.Z (3 digits — releases publiques) +/// X.Y.Z.B (4 digits — itérations dev/test internes) +/// +/// Le 4ᵉ digit est sémantiquement "post-release" : +/// 1.5.4 == 1.5.4.0 < 1.5.4.1 < 1.5.4.13 < 1.5.5. +/// L'absence de 4ᵉ digit est équivalente à .0 pour la comparaison, +/// mais conservée distincte dans (= 1.5.4, +/// PAS 1.5.4.0) pour ne pas re-écrire les versions historiques +/// du manifest qui n'avaient que 3 digits. +/// +public readonly record struct SemVer(int Major, int Minor, int Patch, int Build = 0) : IComparable { + /// + /// True si la version originale avait un 4ᵉ digit explicite (même .0). + /// Sert uniquement à pour préserver le format d'origine + /// lors d'un round-trip Parse → ToString. + /// + 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}"; } diff --git a/src/PSLauncher.Core/Localization/Strings.cs b/src/PSLauncher.Core/Localization/Strings.cs index 55a94e2..3311314 100644 --- a/src/PSLauncher.Core/Localization/Strings.cs +++ b/src/PSLauncher.Core/Localization/Strings.cs @@ -323,12 +323,12 @@ public static class Strings "ไฟล์ที่ดาวน์โหลดเสียหายหรือล้าสมัย", "الملف المنزّل تالف أو قديم" ); - public static string MsgShaMismatchBody(string version, string sourceLabel) => 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\nCauses probables :\n • Le cache CDN OVH n'a pas encore expiré après un re-build serveur (TTL 24-72 h)\n • Un peer LAN sert un ZIP avec un sidecar SHA corrompu\n • Le SHA dans le manifest a été modifié sans re-build du ZIP\n\nLe cache local pour cette version a été automatiquement purgé. Réessaye l'installation dans quelques minutes (le temps que la source se rafraîchisse) ou utilise « ↻ Forcer re-téléchargement » dans le menu \"…\" de la version.", - $"The v{version} ZIP was downloaded from {sourceLabel}, but its SHA-256 does not match the manifest's expected value.\n\nLikely causes:\n • OVH CDN cache hasn't expired yet after a server rebuild (TTL 24-72h)\n • A LAN peer is serving a ZIP with a corrupt SHA sidecar\n • The manifest SHA was changed without rebuilding the ZIP\n\nThe local cache for this version was automatically purged. Retry the install in a few minutes (let the source refresh) or use « ↻ Force re-download » in the version's \"…\" menu.", - $"v{version} 的 ZIP 是从 {sourceLabel} 下载的,但其 SHA-256 与清单中预期的值不匹配。\n\n可能的原因:\n • 服务器重建后 OVH CDN 缓存尚未过期(TTL 24-72 小时)\n • 局域网节点提供带有损坏的 SHA sidecar 的 ZIP\n • 清单 SHA 已更改但未重建 ZIP\n\n此版本的本地缓存已自动清除。请在几分钟后重试安装(让源刷新)或使用版本的 \"…\" 菜单中的 \"↻ 强制重新下载\"。", - $"ZIP ของ v{version} ดาวน์โหลดจาก {sourceLabel} แต่ SHA-256 ไม่ตรงกับค่าที่คาดหวังจาก manifest\n\nสาเหตุที่เป็นไปได้:\n • แคช OVH CDN ยังไม่หมดอายุหลังการ rebuild เซิร์ฟเวอร์ (TTL 24-72 ชม.)\n • LAN peer ให้บริการ ZIP ที่มี SHA sidecar เสียหาย\n • SHA ใน manifest ถูกแก้ไขโดยไม่ rebuild ZIP\n\nแคชในเครื่องสำหรับเวอร์ชันนี้ถูกล้างอัตโนมัติ ลองติดตั้งอีกครั้งในไม่กี่นาที (ให้ source รีเฟรช) หรือใช้ \"↻ บังคับดาวน์โหลดใหม่\" ในเมนู \"…\" ของเวอร์ชัน", - $"تم تنزيل ZIP v{version} من {sourceLabel}، لكن SHA-256 الخاص به لا يطابق القيمة المتوقعة من البيان.\n\nالأسباب المحتملة:\n • لم تنته صلاحية ذاكرة التخزين المؤقت لـ OVH CDN بعد إعادة بناء الخادم (TTL 24-72 ساعة)\n • عقدة LAN تقدم ZIP بـ SHA sidecar تالف\n • تم تعديل SHA في البيان دون إعادة بناء ZIP\n\nتم مسح ذاكرة التخزين المؤقت المحلية لهذا الإصدار تلقائياً. أعد المحاولة بعد دقائق (دع المصدر ينعش) أو استخدم \"↻ فرض إعادة التنزيل\" في قائمة \"…\" للإصدار." + 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", @@ -359,6 +359,22 @@ public static class Strings "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…",