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>
This commit is contained in:
2026-05-28 17:24:50 +02:00
parent 50da755a92
commit 500f7d12e6
21 changed files with 1674 additions and 264 deletions

View File

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

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 { color: var(--accent); }
a:hover { text-decoration: underline; } 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') { if ($action === 'set_launcher') {
$lver = trim($_POST['launcher_version'] ?? ''); $lver = trim($_POST['launcher_version'] ?? '');
$lmin = trim($_POST['launcher_min_required'] ?? ''); $lmin = trim($_POST['launcher_min_required'] ?? '');
if (!preg_match('/^\d+\.\d+\.\d+$/', $lver)) { // X.Y.Z (release publique) ou X.Y.Z.B (itération dev/test interne).
throw new Exception('Numéro de version launcher invalide (X.Y.Z attendu).'); 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.'); throw new Exception('Numéro de version "minRequired" invalide.');
} }
$base = $config['base_url'] ?? 'https://asterionvr.com/PS_Launcher'; $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"> <input type="hidden" name="action" value="set_launcher">
<div class="row"> <div class="row">
<div class="col field"> <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" <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>
<div class="col field"> <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" <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>
</div> </div>
<button class="btn btn-success" type="submit">Définir</button> <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/Auth.php';
require __DIR__ . '/lib/Layout.php'; require __DIR__ . '/lib/Layout.php';
require __DIR__ . '/lib/Channels.php'; require __DIR__ . '/lib/Channels.php';
require __DIR__ . '/lib/Mailer.php';
require __DIR__ . '/../api/lib/Db.php'; require __DIR__ . '/../api/lib/Db.php';
use PSLauncher\Admin\Auth; use PSLauncher\Admin\Auth;
use PSLauncher\Admin\Layout; use PSLauncher\Admin\Layout;
use PSLauncher\Admin\Channels; use PSLauncher\Admin\Channels;
use PSLauncher\Admin\Mailer;
use PSLauncher\Db; use PSLauncher\Db;
Auth::requireLogin(); 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)."; $message = "License #{$id} : channel passé à « {$label} ». Le client devra rouvrir le launcher pour prendre en compte le changement (revalidation license).";
} }
elseif ($action === 'toggle_betas') { 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); $id = (int)($_POST['id'] ?? 0);
$stmt = $db->prepare('UPDATE licenses SET can_see_betas = 1 - can_see_betas WHERE id = ?'); $enabled = isset($_POST['enabled']) ? 1 : 0;
$stmt->execute([$id]); $db->prepare('UPDATE licenses SET can_see_betas = ? WHERE id = ?')->execute([$enabled, $id]);
$row = $db->prepare('SELECT can_see_betas FROM licenses WHERE id = ?'); $message = $enabled
$row->execute([$id]);
$now = (int)$row->fetchColumn();
$message = $now
? "License #{$id} : accès aux versions BÊTA activé." ? "License #{$id} : accès aux versions BÊTA activé."
: "License #{$id} : accès aux versions BÊTA désactivé."; : "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."; $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') { elseif ($action === 'reset_machines') {
$id = (int)($_POST['id'] ?? 0); $id = (int)($_POST['id'] ?? 0);
$db->prepare('DELETE FROM license_machines WHERE license_id = ?')->execute([$id]); $db->prepare('DELETE FROM license_machines WHERE license_id = ?')->execute([$id]);
@@ -295,8 +319,21 @@ Layout::header('Licenses', 'licenses');
<td>#<?= $l['id'] ?></td> <td>#<?= $l['id'] ?></td>
<td> <td>
<strong><?= htmlspecialchars($l['owner_name']) ?></strong> <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'])): ?> <?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; ?> <?php endif; ?>
</td> </td>
<td class="muted"><?= date('d/m/Y', strtotime($l['issued_at'])) ?></td> <td class="muted"><?= date('d/m/Y', strtotime($l['issued_at'])) ?></td>
@@ -313,119 +350,259 @@ Layout::header('Licenses', 'licenses');
<?php endif; ?> <?php endif; ?>
</td> </td>
<td> <td>
<!-- Affichage de l'état actuel : channel + badge BÊTA si actif --> <!-- READ-ONLY : channel actuel + badge BÊTA si actif. Tous
<div style="margin-bottom: 6px;"> 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;" <code style="background: rgba(0,0,0,0.3); padding: 2px 6px; border-radius: 4px; font-size: 11px;"
title="Channel actuellement attribué à cette license"> title="Channel actuellement attribué">
<?= htmlspecialchars($l['channel'] ?? 'default') ?> <?= htmlspecialchars($l['channel'] ?? 'default') ?>
</code> </code>
<?php if ((int)($l['can_see_betas'] ?? 0) === 1): ?> <?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> <span class="badge badge-warning" style="margin-left: 4px;" title="Voit les versions taggées beta">β BÊTA</span>
<?php endif; ?> <?php endif; ?>
</div> <?php $hasLock = !empty($l['settings_lock_password_hash']); ?>
<?php if ($hasLock): ?>
<!-- Toggle BÊTA en bouton DIRECTEMENT visible (pas dans un details). <span class="badge badge-secondary" style="margin-left: 4px;" title="Section Settings → Avancés verrouillée par mot de passe">🔒</span>
L'admin n'a plus à deviner qu'il faut cliquer sur le code du channel <?php endif; ?>
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>
</td> </td>
<td><span class="badge <?= $cls ?>"><?= $status ?></span></td> <td><span class="badge <?= $cls ?>"><?= $status ?></span></td>
<td style="text-align:right; white-space: nowrap;"> <td style="text-align:right; white-space: nowrap;">
<details style="display: inline-block; margin: 0 4px;"> <?php $modalId = 'edit-license-' . (int)$l['id']; ?>
<summary class="btn btn-secondary">Prolonger</summary> <div class="row-actions">
<form method="post" style="margin-top: 8px; display: flex; gap: 4px; align-items: center;"> <button type="button" class="btn btn-primary"
<?= Layout::csrfField() ?> onclick="document.getElementById('<?= $modalId ?>').showModal()">
<input type="hidden" name="action" value="extend"> ✎ Modifier
<input type="hidden" name="id" value="<?= $l['id'] ?>"> </button>
<input type="date" name="new_until" required style="width: 150px;">
<button class="btn btn-primary" type="submit">OK</button>
</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;">
<?= 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>
</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;">
<?= 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>
</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): ?> <?php if (!$isRevoked): ?>
<form method="post" style="display:inline" onsubmit="return confirm('Révoquer définitivement cette license ?')"> <form method="post" style="display:inline" onsubmit="return confirm('Révoquer définitivement cette license ?')">
<?= Layout::csrfField() ?> <?= Layout::csrfField() ?>
<input type="hidden" name="action" value="revoke"> <input type="hidden" name="action" value="revoke">
<input type="hidden" name="id" value="<?= $l['id'] ?>"> <input type="hidden" name="id" value="<?= $l['id'] ?>">
<button class="btn btn-danger" type="submit">Révoquer</button> <button class="btn btn-danger" type="submit">Révoquer</button>
</form> </form>
<?php else: ?> <?php else: ?>
<form method="post" style="display:inline"> <form method="post" style="display:inline">
<?= Layout::csrfField() ?> <?= Layout::csrfField() ?>
<input type="hidden" name="action" value="unrevoke"> <input type="hidden" name="action" value="unrevoke">
<input type="hidden" name="id" value="<?= $l['id'] ?>"> <input type="hidden" name="id" value="<?= $l['id'] ?>">
<button class="btn btn-secondary" type="submit">Réactiver</button> <button class="btn btn-secondary" type="submit">Réactiver</button>
</form> </form>
<?php endif; ?> <?php endif; ?>
</div>
</td> </td>
</tr> </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'] ?>">
<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>
</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'] ?>">
<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>
</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'] ?>">
<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>
</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 <?php
// Sous-row dépliable : liste des machines avec remove individuel. // Sous-row dépliable : liste des machines avec remove individuel.
@@ -472,6 +649,14 @@ Layout::header('Licenses', 'licenses');
<?php endif; ?> <?php endif; ?>
</td> </td>
<td style="text-align:right;"> <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" <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.')"> onsubmit="return confirm('Libérer cette machine de la license ?\n\nLe slot redeviendra disponible pour une autre activation.')">
<?= Layout::csrfField() ?> <?= Layout::csrfField() ?>
@@ -500,4 +685,70 @@ Layout::header('Licenses', 'licenses');
</tbody> </tbody>
</table> </table>
</div> </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(); <?php Layout::footer();

View File

@@ -3,10 +3,14 @@ declare(strict_types=1);
require __DIR__ . '/lib/Auth.php'; require __DIR__ . '/lib/Auth.php';
require __DIR__ . '/lib/Layout.php'; require __DIR__ . '/lib/Layout.php';
require __DIR__ . '/lib/Channels.php'; require __DIR__ . '/lib/Channels.php';
require __DIR__ . '/lib/Mailer.php';
require __DIR__ . '/../api/lib/Db.php';
use PSLauncher\Admin\Auth; use PSLauncher\Admin\Auth;
use PSLauncher\Admin\Layout; use PSLauncher\Admin\Layout;
use PSLauncher\Admin\Channels; use PSLauncher\Admin\Channels;
use PSLauncher\Admin\Mailer;
use PSLauncher\Db;
Auth::requireLogin(); Auth::requireLogin();
$config = require __DIR__ . '/../api/config.php'; $config = require __DIR__ . '/../api/config.php';
@@ -97,6 +101,162 @@ function find_entry_index_by_id(array $manifest, string $id): ?int
return null; 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 : * Normalise un nom de fichier ZIP saisi par l'admin :
* - strip tout chemin (path traversal défense, basename) * - strip tout chemin (path traversal défense, basename)
@@ -133,8 +293,12 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$isBeta = isset($_POST['is_beta']); $isBeta = isset($_POST['is_beta']);
$betaNotes = trim($_POST['beta_notes'] ?? '') ?: null; $betaNotes = trim($_POST['beta_notes'] ?? '') ?: null;
if (!preg_match('/^\d+\.\d+\.\d+$/', $version)) { // X.Y.Z (release publique) ou X.Y.Z.B (itération dev/test interne).
throw new Exception('Numéro de version invalide (format X.Y.Z attendu).'); // 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)) { if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $minLicDate)) {
throw new Exception('Date min_license_date invalide.'); throw new Exception('Date min_license_date invalide.');
@@ -357,6 +521,127 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
. "\n" . implode("\n", $resign['log']); . "\n" . implode("\n", $resign['log']);
if (!$resign['ok']) $messageType = 'error'; 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) { } catch (Exception $e) {
$message = $e->getMessage(); $message = $e->getMessage();
$messageType = 'error'; $messageType = 'error';
@@ -412,16 +697,34 @@ Layout::header('Versions', 'versions');
<input type="hidden" name="action" value="add"> <input type="hidden" name="action" value="add">
<div class="row"> <div class="row">
<div class="col field"> <div class="col field">
<label>Version (X.Y.Z)</label> <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" required pattern="\d+\.\d+\.\d+"> <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> </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"> <div class="col field">
<label>Date de release</label> <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>
<div class="col field"> <div class="col field">
<label>min_license_date (license requise)</label> <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> <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> </div>
<div class="field"> <div class="field">
@@ -479,7 +782,7 @@ Layout::header('Versions', 'versions');
• latest : <code>v<?= htmlspecialchars($manifest['latest']) ?></code> • latest : <code>v<?= htmlspecialchars($manifest['latest']) ?></code>
<?php endif; ?> <?php endif; ?>
</span> </span>
<form method="post" style="display:inline"> <form method="post" style="display:inline" onsubmit="showBusyOverlay('bulk')">
<?= Layout::csrfField() ?> <?= Layout::csrfField() ?>
<input type="hidden" name="action" value="sync_versions"> <input type="hidden" name="action" value="sync_versions">
<button class="btn btn-primary" type="submit">🔁 Hasher les versions + signer</button> <button class="btn btn-primary" type="submit">🔁 Hasher les versions + signer</button>
@@ -558,130 +861,261 @@ Layout::header('Versions', 'versions');
</form> </form>
</td> </td>
<td style="text-align: right; white-space: nowrap;"> <td style="text-align: right; white-space: nowrap;">
<?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): ?> <?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é)"> <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() ?> <?= Layout::csrfField() ?>
<input type="hidden" name="action" value="sync_one"> <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">🔁 Hash</button> <button class="btn btn-secondary" type="submit">🔁 Hash</button>
</form> </form>
<?php endif; ?> <?php endif; ?>
<details style="display: inline-block; margin: 0 4px;"> <button type="button" class="btn btn-primary"
<summary class="btn btn-secondary">⚙ Hash</summary> onclick="document.getElementById('<?= $modalId ?>').showModal()">
<div style="margin-top: 8px; min-width: 280px;"> ✎ Modifier
<p class="muted" style="font-size: 12px; margin: 0 0 8px;"> </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): ?> <?php if ($hashSkipped): ?>
La vérif SHA-256 est <strong>désactivée</strong> pour cette release. Actuellement <strong style="color: var(--busy);">désactivée</strong>
Le manifest sert <code>hashAlgorithm: "none"</code>. La sécurité repose pour cette release (le manifest sert <code>hashAlgorithm: "none"</code>).
uniquement sur la signature Ed25519 du manifest + HTTPS.
<?php else: ?> <?php else: ?>
Désactive la vérif SHA-256 chez les clients pour cette release Actuellement <strong style="color: var(--installed);">activée</strong>
(utile sur très gros builds où on accepte le compromis perf/sécurité). (default sain).
<?php endif; ?> <?php endif; ?>
</p> </p>
<form method="post" style="display:inline"> <form method="post" style="display:inline">
<?= Layout::csrfField() ?> <?= Layout::csrfField() ?>
<input type="hidden" name="action" value="set_skip_hash"> <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' ?>"> <input type="hidden" name="skip" value="<?= $hashSkipped ? '0' : '1' ?>">
<button class="btn <?= $hashSkipped ? 'btn-success' : 'btn-warning' ?>" type="submit"> <button class="btn <?= $hashSkipped ? 'btn-success' : 'btn-warning' ?>" type="submit">
<?= $hashSkipped ? '✓ Réactiver la vérif' : '⏭ Désactiver la vérif' ?> <?= $hashSkipped ? '✓ Réactiver la vérif' : '⏭ Désactiver la vérif' ?>
</button> </button>
</form> </form>
</div>
<?php if ($zipExists): ?> <?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 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() ?> <?= Layout::csrfField() ?>
<input type="hidden" name="action" value="sync_one"> <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"> <input type="hidden" name="force" value="1">
<button class="btn btn-secondary" type="submit">🔄 Force re-hash</button> <button class="btn btn-secondary" type="submit">🔄 Force re-hash</button>
</form> </form>
</div>
<?php endif; ?> <?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> </div>
</details> </section>
<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>
<div class="field"> </dialog>
<label>Date de release (UTC)</label> <?php
<input type="datetime-local" name="released_at" value="<?= htmlspecialchars(substr($v['releasedAt'] ?? '', 0, 16)) ?>"> // Capture le markup du dialog et le pousse dans un buffer global
</div> // qui sera vidé APRÈS </table>. Pourquoi : <dialog> directement
<div class="field"> // ou indirectement dans <table>/<tbody>/<tr> est foster-parented
<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> // de manière imprévisible par le parser HTML, ce qui peut casser
<input type="text" name="zip_filename" placeholder="laisse vide pour ne rien changer"> // showModal() (élément non connecté au layout tree).
<span class="muted" style="font-size: 11px;">⚠️ Renommer = invalide le sha256, il faudra re-uploader le nouveau ZIP en SFTP puis re-Sync.</span> $GLOBALS['__editDialogs'] = ($GLOBALS['__editDialogs'] ?? '') . ob_get_clean();
</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 endforeach; ?> <?php endforeach; ?>
<?php if (empty($manifest['versions'])): ?> <?php if (empty($manifest['versions'])): ?>
<tr><td colspan="8" class="muted" style="text-align:center; padding: 32px;">Aucune version dans le manifest.</td></tr> <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> </table>
</div> </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"> <div class="card">
<h2>ZIPs présents dans builds/</h2> <h2>ZIPs présents dans builds/</h2>
<?php if (empty($zips)): ?> <?php if (empty($zips)): ?>
@@ -718,4 +1157,62 @@ Layout::header('Versions', 'versions');
</table> </table>
<?php endif; ?> <?php endif; ?>
</div> </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(); <?php Layout::footer();

View File

@@ -40,4 +40,32 @@ return [
// php -r "echo password_hash('motdepasse_choisi', PASSWORD_DEFAULT);" // php -r "echo password_hash('motdepasse_choisi', PASSWORD_DEFAULT);"
// puis colle le résultat ci-dessous. // puis colle le résultat ci-dessous.
'admin_password_hash' => '', '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; return;
} }
// Release notes : accepte 'X.Y.Z' (legacy) ou id stable 'v' + 8 hex (v0.27.1+) // Release notes : accepte 'X.Y.Z' ou 'X.Y.Z.B' (4ᵉ digit = itération dev/test
if ($method === 'GET' && preg_match('#^releasenotes/(v[0-9a-f]{8}|[0-9]+\.[0-9]+\.[0-9]+)$#', $route, $m)) { // 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'; require __DIR__ . '/routes/Releasenotes.php';
\PSLauncher\Routes\Releasenotes::handle($m[1]); \PSLauncher\Routes\Releasenotes::handle($m[1]);
return; return;
@@ -64,7 +65,8 @@ if ($method === 'POST' && $route === 'license/validate') {
return; 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/Db.php';
require __DIR__ . '/lib/Crypto.php'; require __DIR__ . '/lib/Crypto.php';
require __DIR__ . '/routes/DownloadUrl.php'; require __DIR__ . '/routes/DownloadUrl.php';

View File

@@ -19,7 +19,8 @@ final class DownloadUrl
{ {
public static function handle(array $config, string $version): void 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); Response::error('invalid_version', 'Version invalide', 400);
} }

View File

@@ -17,7 +17,8 @@ final class Releasenotes
public static function handle(string $key): void public static function handle(string $key): void
{ {
$isEntryId = (bool)preg_match('/^v[0-9a-f]{8}$/', $key); $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) { if (!$isEntryId && !$isVersion) {
Response::error('invalid_id', 'Bad release-notes identifier', 400); 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é, * 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. * 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 * @param array<string, array{size:int,mtime:int,sha256:string}> $cache
* @return array{sha256:string, fromCache:bool, durationMs:int} * @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; $size = filesize($path) ?: 0;
$mtime = filemtime($path) ?: 0; $mtime = filemtime($path) ?: 0;
$key = realpath($path) ?: $path; $key = realpath($path) ?: $path;
if (isset($cache[$key]) if (!$force
&& isset($cache[$key])
&& ($cache[$key]['size'] ?? -1) === $size && ($cache[$key]['size'] ?? -1) === $size
&& ($cache[$key]['mtime'] ?? -1) === $mtime && ($cache[$key]['mtime'] ?? -1) === $mtime
&& !empty($cache[$key]['sha256'])) { && !empty($cache[$key]['sha256'])) {
@@ -192,7 +202,7 @@ final class SignManifest
} }
} }
$r = $this->getOrComputeSha256($zip, $cache); $r = $this->getOrComputeSha256($zip, $cache, $force);
$cacheChanged = true; $cacheChanged = true;
$note = $r['fromCache'] ? '(cache)' : "(calculé en {$r['durationMs']} ms)"; $note = $r['fromCache'] ? '(cache)' : "(calculé en {$r['durationMs']} ms)";
$this->out(" [hash] $version : " . basename($zip) . " ($size octets) sha256={$r['sha256']} $note"); $this->out(" [hash] $version : " . basename($zip) . " ($size octets) sha256={$r['sha256']} $note");
@@ -231,7 +241,7 @@ final class SignManifest
&& ($cache[$lkey]['sha256'] ?? '') === $existingLsha) { && ($cache[$lkey]['sha256'] ?? '') === $existingLsha) {
$this->out(" [launcher] v{$lver} : " . basename($lpath) . " ({$lsize} octets) — cache hit"); $this->out(" [launcher] v{$lver} : " . basename($lpath) . " ({$lsize} octets) — cache hit");
} else { } else {
$r = $this->getOrComputeSha256($lpath, $cache); $r = $this->getOrComputeSha256($lpath, $cache, $force);
$cacheChanged = true; $cacheChanged = true;
$launcher['download']['sha256'] = $r['sha256']; $launcher['download']['sha256'] = $r['sha256'];
$note = $r['fromCache'] ? '(cache)' : "(calculé en {$r['durationMs']} ms)"; $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) // 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) ?: [])); $statements = array_filter(array_map('trim', preg_split('/;\s*\n/', $sql) ?: []));
foreach ($statements as $stmt) { 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 { try {
$pdo->exec($stmt); $pdo->exec($stmt);
$first40 = substr(preg_replace('/\s+/', ' ', $stmt), 0, 60); $first40 = substr(preg_replace('/\s+/', ' ', $stmt), 0, 60);

View File

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

View File

@@ -1330,6 +1330,26 @@ public sealed partial class MainViewModel : ObservableObject
return list.ToArray(); 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> /// <summary>
/// Point d'entrée UNIQUE pour le lancement auto avec countdown. Utilisé par : /// 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 /// 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 var sourceLabel = _currentPeerHost is null
? Strings.SourceOvh ? Strings.SourceOvh
: Strings.SourcePeer(_currentPeerHost); : 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, ThemedMessageBox.Show(body, Strings.MsgBoxShaMismatch,
MessageBoxButton.OK, MessageBoxImage.Warning); MessageBoxButton.OK, MessageBoxImage.Warning);
StatusMessage = Strings.StatusShaMismatch(row.Version); StatusMessage = Strings.StatusShaMismatch(row.Version);
@@ -2145,6 +2172,40 @@ public sealed partial class MainViewModel : ObservableObject
MessageBoxButton.OK, MessageBoxImage.Warning); MessageBoxButton.OK, MessageBoxImage.Warning);
StatusMessage = Strings.StatusManifestRefreshed; 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 else
{ {
ThemedMessageBox.Show( ThemedMessageBox.Show(

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. // 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. // 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. // 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); using var reporterCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
var reporterTask = Task.Run(async () => var reporterTask = Task.Run(async () =>
{ {
var sw = Stopwatch.StartNew(); var sw = Stopwatch.StartNew();
var lastReport = TimeSpan.Zero;
long lastReportBytes = Interlocked.Read(ref aggregateBytes);
var lastFlush = TimeSpan.Zero; 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 try
{ {
while (!reporterCts.IsCancellationRequested) while (!reporterCts.IsCancellationRequested)
@@ -327,13 +339,24 @@ public sealed class DownloadManager : IDownloadManager
await Task.Delay(250, reporterCts.Token).ConfigureAwait(false); await Task.Delay(250, reporterCts.Token).ConfigureAwait(false);
var snapshot = Interlocked.Read(ref aggregateBytes); var snapshot = Interlocked.Read(ref aggregateBytes);
var elapsed = sw.Elapsed; 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; 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)); progress?.Report(new DownloadProgress(snapshot, total, bps, eta));
lastReport = elapsed;
lastReportBytes = snapshot;
if ((elapsed - lastFlush).TotalSeconds >= StateFlushSecondsInterval || if ((elapsed - lastFlush).TotalSeconds >= StateFlushSecondsInterval ||
snapshot - lastFlushBytes >= StateFlushBytesInterval) snapshot - lastFlushBytes >= StateFlushBytesInterval)
@@ -475,7 +498,8 @@ public sealed class DownloadManager : IDownloadManager
// un faux 404 fatal sur tous les segments sauf le premier. // un faux 404 fatal sur tous les segments sauf le premier.
bool isUrlRefreshTrigger = resp.StatusCode == HttpStatusCode.Forbidden bool isUrlRefreshTrigger = resp.StatusCode == HttpStatusCode.Forbidden
|| resp.StatusCode == HttpStatusCode.Gone || resp.StatusCode == HttpStatusCode.Gone
|| resp.StatusCode == HttpStatusCode.NotFound; || resp.StatusCode == HttpStatusCode.NotFound
|| resp.StatusCode == HttpStatusCode.RequestedRangeNotSatisfiable;
if (isUrlRefreshTrigger && job.RefreshUrlAsync is not null) if (isUrlRefreshTrigger && job.RefreshUrlAsync is not null)
{ {
await GetOrRefreshUrlAsync(job, forceRefresh: true, ct).ConfigureAwait(false); 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.", $"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); 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( throw new HttpResumableException(
$"Signed URL expired (HTTP {(int)resp.StatusCode}), refreshed for next try", isTransient: true); $"Signed URL expired (HTTP {(int)resp.StatusCode}), refreshed for next try", isTransient: true);
} }

View File

@@ -23,8 +23,10 @@ public sealed partial class InstallationRegistry : IInstallationRegistry
private const string FallbackExePattern = "PROSERVE_UE_*.exe"; private const string FallbackExePattern = "PROSERVE_UE_*.exe";
// IgnoreCase pour matcher à la fois les anciens dossiers "Proserve vX.Y.Z" et les // IgnoreCase pour matcher à la fois les anciens dossiers "Proserve vX.Y.Z" et les
// nouveaux "PROSERVE vX.Y.Z" (changement de casse de la marque). // nouveaux "PROSERVE vX.Y.Z" (changement de casse de la marque). Le 4ᵉ digit
[GeneratedRegex(@"^PROSERVE v(?<v>\d+\.\d+\.\d+)$", RegexOptions.IgnoreCase)] // 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 static partial Regex VersionFolderRegex();
private readonly ILogger<InstallationRegistry> _logger; private readonly ILogger<InstallationRegistry> _logger;

View File

@@ -1,18 +1,52 @@
namespace PSLauncher.Core.Installations; 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) public static SemVer Parse(string s)
{ {
if (string.IsNullOrWhiteSpace(s)) return new SemVer(0, 0, 0);
var parts = s.Split('.'); var parts = s.Split('.');
if (parts.Length != 3 // 3 digits = release publique (X.Y.Z), 4 digits = itération dev (X.Y.Z.B).
|| !int.TryParse(parts[0], out var maj) // 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[1], out var min)
|| !int.TryParse(parts[2], out var pat)) || !int.TryParse(parts[2], out var pat))
{ {
return new SemVer(0, 0, 0); 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) 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; if (c != 0) return c;
c = Minor.CompareTo(other.Minor); c = Minor.CompareTo(other.Minor);
if (c != 0) return c; 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

@@ -323,12 +323,12 @@ public static class Strings
"ไฟล์ที่ดาวน์โหลดเสียหายหรือล้าสมัย", "ไฟล์ที่ดาวน์โหลดเสียหายหรือล้าสมัย",
"الملف المنزّل تالف أو قديم" "الملف المنزّل تالف أو قديم"
); );
public static string MsgShaMismatchBody(string version, string sourceLabel) => T( 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\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.", $"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\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.", $"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可能的原因\n • 服务器重建后 OVH CDN 缓存尚未过期TTL 24-72 小时)\n • 局域网节点提供带有损坏的 SHA sidecar 的 ZIP\n • 清单 SHA 已更改但未重建 ZIP\n\n此版本的本地缓存已自动清除。请在几分钟后重试安装让源刷新或使用版本的 \"\" 菜单中的 \"↻ 强制重新下载\"。", $"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สาเหตุที่เป็นไปได้:\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 ไม่ตรงกับค่าที่คาดหวังจาก 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الأسباب المحتملة:\n • لم تنته صلاحية ذاكرة التخزين المؤقت لـ OVH CDN بعد إعادة بناء الخادم (TTL 24-72 ساعة)\n • عقدة LAN تقدم ZIP بـ SHA sidecar تالف\n • تم تعديل SHA في البيان دون إعادة بناء ZIP\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( public static string StatusShaMismatch(string version) => T(
$"⚠ v{version} : SHA-256 invalide — cache purgé, réessaye", $"⚠ v{version} : SHA-256 invalide — cache purgé, réessaye",
@@ -359,6 +359,22 @@ public static class Strings
"Manifest รีเฟรชแล้ว — คลิกติดตั้งอีกครั้ง", "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( public static string StatusAutoRetryAfter404(string version) => T(
$"v{version} : manifest rafraîchi, re-lancement automatique du téléchargement…", $"v{version} : manifest rafraîchi, re-lancement automatique du téléchargement…",
$"v{version}: manifest refreshed, auto-retrying download…", $"v{version}: manifest refreshed, auto-retrying download…",