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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 17:24:50 +02:00

200 lines
8.7 KiB
PHP

<?php
declare(strict_types=1);
require __DIR__ . '/lib/Auth.php';
require __DIR__ . '/lib/Layout.php';
use PSLauncher\Admin\Auth;
use PSLauncher\Admin\Layout;
Auth::requireLogin();
$config = require __DIR__ . '/../api/config.php';
$root = dirname(__DIR__);
$manifestPath = "$root/manifest/versions.json";
$buildsDir = "$root/builds";
$message = null; $messageType = 'success';
function loadManifest(string $path): array
{
if (!is_file($path)) return ['schemaVersion' => 1, 'versions' => []];
return json_decode(file_get_contents($path), true) ?? [];
}
function saveManifest(string $path, array $manifest): void
{
// usort attend une référence — on ne peut pas lui passer `?? []` en PHP 8.
if (isset($manifest['versions']) && is_array($manifest['versions'])) {
usort($manifest['versions'], fn($a, $b) => version_compare($b['version'], $a['version']));
}
file_put_contents(
$path,
json_encode($manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n"
);
}
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
Auth::checkCsrf();
$action = $_POST['action'] ?? '';
$manifest = loadManifest($manifestPath);
try {
if ($action === 'set_launcher') {
$lver = trim($_POST['launcher_version'] ?? '');
$lmin = trim($_POST['launcher_min_required'] ?? '');
// X.Y.Z (release publique) ou X.Y.Z.B (itération dev/test interne).
if (!preg_match('/^\d+\.\d+\.\d+(\.\d+)?$/', $lver)) {
throw new Exception('Numéro de version launcher invalide (X.Y.Z ou X.Y.Z.B attendu).');
}
if ($lmin !== '' && !preg_match('/^\d+\.\d+\.\d+(\.\d+)?$/', $lmin)) {
throw new Exception('Numéro de version "minRequired" invalide.');
}
$base = $config['base_url'] ?? 'https://asterionvr.com/PS_Launcher';
$manifest['launcher'] = [
'version' => $lver,
'minRequired' => $lmin !== '' ? $lmin : '0.5.0',
'download' => [
'url' => "{$base}/builds/launcher/PS_Launcher-{$lver}.exe",
'sizeBytes' => 0,
'sha256' => 'REPLACE_AFTER_BUILD',
],
'releaseNotesUrl' => null,
];
saveManifest($manifestPath, $manifest);
$message = "Section launcher mise à jour (v{$lver}). Upload PS_Launcher-{$lver}.exe dans builds/launcher/ puis clique « Hasher le launcher + signer ».";
}
elseif ($action === 'remove_launcher') {
unset($manifest['launcher']);
saveManifest($manifestPath, $manifest);
$message = "Section launcher retirée du manifest (auto-update désactivé).";
}
elseif ($action === 'sync_launcher') {
require_once "$root/tools/SignManifest.php";
$signer = new \PSLauncher\Tools\SignManifest($root);
$force = !empty($_POST['force']);
$result = $signer->run('launcher', $force);
$forceLabel = $force ? ' [FORCE: cache ignoré]' : '';
$message = "Sortie du script (scope: launcher{$forceLabel}) :\n" . implode("\n", $result['log']);
if (!$result['ok']) $messageType = 'error';
}
// (Le settings lock est maintenant par-license, géré dans licenses.php — voir migration 003.)
} catch (Exception $e) {
$message = $e->getMessage();
$messageType = 'error';
}
}
$manifest = loadManifest($manifestPath);
$launcher = $manifest['launcher'] ?? null;
$launcherFiles = is_dir("$buildsDir/launcher")
? array_map('basename', glob("$buildsDir/launcher/*.exe") ?: [])
: [];
$launcherZipPresent = false;
$launcherHashed = false;
if ($launcher) {
$expectedFile = basename(parse_url($launcher['download']['url'] ?? '', PHP_URL_PATH) ?? '');
$launcherZipPresent = in_array($expectedFile, $launcherFiles, true);
$launcherHashed = !empty($launcher['download']['sha256'])
&& !str_starts_with($launcher['download']['sha256'], 'REPLACE');
}
Layout::header('Launcher', 'launcher');
?>
<h1>Launcher — auto-update</h1>
<?php Layout::flash($message, $messageType); ?>
<div class="card">
<h2>État actuel</h2>
<?php if ($launcher): ?>
<p>
Version annoncée : <strong>v<?= htmlspecialchars($launcher['version']) ?></strong>
• minRequired : <code><?= htmlspecialchars($launcher['minRequired'] ?? '—') ?></code>
• Exe : <?= $launcherZipPresent
? "<span class='badge badge-success'>présent</span>"
: "<span class='badge badge-warning'>absent</span>" ?>
• Hash : <?= $launcherHashed
? "<span class='badge badge-success'>OK</span>"
: "<span class='badge badge-warning'>à calculer</span>" ?>
• Signature manifest : <?= empty($manifest['signature'])
? "<span class='badge badge-warning'>NON SIGNÉ</span>"
: "<span class='badge badge-success'>SIGNÉ Ed25519</span>" ?>
</p>
<p class="muted">URL attendue de l'exe : <code><?= htmlspecialchars($launcher['download']['url']) ?></code></p>
<?php else: ?>
<p class="muted">Aucune version du launcher déclarée — l'auto-update est désactivé. Configure-la ci-dessous pour l'activer.</p>
<?php endif; ?>
</div>
<div class="card">
<h2>Workflow d'une nouvelle version du launcher</h2>
<ol class="muted">
<li>Remplis le formulaire <strong>Définir / mettre à jour</strong> ci-dessous (ex : <code>0.6.0</code>).</li>
<li>Sur ton poste : <code>dotnet publish src\PSLauncher.App -c Release</code> et <code>dotnet publish src\PSLauncher.Updater -c Release</code> (les exes sont copiés à la racine du repo).</li>
<li>Renomme la copie : <code>PS_Launcher.exe → PS_Launcher-X.Y.Z.exe</code>.</li>
<li>Upload via SFTP dans <code>www/PS_Launcher/builds/launcher/</code>.</li>
<li>Clique <strong>🔁 Hasher le launcher + signer</strong>.</li>
<li>Les launchers déjà déployés détecteront la nouvelle version au prochain « Vérifier les MAJ ».</li>
</ol>
</div>
<div class="card">
<h2>Définir / mettre à jour</h2>
<form method="post">
<?= Layout::csrfField() ?>
<input type="hidden" name="action" value="set_launcher">
<div class="row">
<div class="col field">
<label>Version launcher (X.Y.Z ou X.Y.Z.B)</label>
<input type="text" name="launcher_version" placeholder="0.6.0"
value="<?= htmlspecialchars($launcher['version'] ?? '') ?>" required pattern="\d+\.\d+\.\d+(\.\d+)?">
</div>
<div class="col field">
<label>Version minimale requise (X.Y.Z ou X.Y.Z.B)</label>
<input type="text" name="launcher_min_required" placeholder="0.5.0"
value="<?= htmlspecialchars($launcher['minRequired'] ?? '0.5.0') ?>" pattern="\d+\.\d+\.\d+(\.\d+)?">
</div>
</div>
<button class="btn btn-success" type="submit">Définir</button>
</form>
<form method="post" style="margin-top: 16px;">
<?= Layout::csrfField() ?>
<input type="hidden" name="action" value="sync_launcher">
<button class="btn btn-primary" type="submit">🔁 Hasher le launcher + signer</button>
<label style="margin-left:12px;font-size:12px;color:#888" title="Recalcule le SHA-256 même si l'exe n'a pas changé.">
<input type="checkbox" name="force" value="1"> Force re-hash
</label>
</form>
<?php if ($launcher): ?>
<form method="post" style="margin-top: 16px;"
onsubmit="return confirm('Retirer la section launcher du manifest ? L\'auto-update sera désactivé.')">
<?= Layout::csrfField() ?>
<input type="hidden" name="action" value="remove_launcher">
<button class="btn btn-danger" type="submit">Retirer la section launcher</button>
</form>
<?php endif; ?>
</div>
<div class="card">
<h2>Exes présents dans builds/launcher/</h2>
<?php if (empty($launcherFiles)): ?>
<p class="muted">Aucun exe pour l'instant. Upload les binaires via SFTP dans <code>www/PS_Launcher/builds/launcher/</code>.</p>
<?php else: ?>
<table>
<thead><tr><th>Fichier</th><th>Taille</th></tr></thead>
<tbody>
<?php foreach ($launcherFiles as $f): ?>
<tr>
<td><code><?= htmlspecialchars($f) ?></code></td>
<td class="muted"><?= Layout::formatBytes(filesize("$buildsDir/launcher/$f")) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
</div>
<?php Layout::footer();