v0.27.0 — Channels comme TAGS sur versions (refactor du modèle)

Changement majeur du modèle de channels en réponse au feedback :
« c'est trop compliqué. Il faudrait juste pouvoir tout laisser dans
builds, et taguer une version dans un canal ou un autre voir plusieurs
canaux. ». Le précédent design (1 fichier manifest par channel +
sous-dossier builds/{channel}/) imposait une duplication de structure
et empêchait une version d'être visible sur plusieurs channels à la fois.

NOUVEAU MODÈLE :
- 1 seul fichier manifest/versions.json
- 1 seul dossier builds/ flat (les ZIPs ont des noms libres pour éviter
  les collisions homonymes — proserve-1.5.3.zip, proserve-1.5.3-police.zip…)
- Chaque entry de version a un champ `channels: ["default", "police"]`
  qui dit qui peut la voir
- Sémantique additive : un client sur channel "police" voit les versions
  taggées "default" + celles taggées "police". "default" = public.
- Une version peut être taggée sur plusieurs channels en une fois.

NOUVEAU REGISTRE channels.json :
Le registre des channels (avec name + label + description) vit dans
manifest/channels.json. Le channel "default" est implicite et ne peut
pas être supprimé. Nouvelle page admin Channels (CRUD) pour gérer la
liste, avec garde-fou anti-suppression : compte les versions taggées
et les licenses attribuées avant d'autoriser la suppression.

SERVEUR :
- api/routes/Manifest.php : reçoit ?channel=X depuis la license,
  filtre versions où channels[] contient X ou "default", re-signe avec
  la clé privée Ed25519 à la volée. Coût ~1ms par requête.
- admin/versions.php : refactor — plus de session "channel actif", plus
  de switcher en haut, plus de prefix builds/{channel}/. Add form a
  des checkboxes channels[]. Nouveau bouton "Channels" par-row pour
  retager.
- admin/licenses.php : dropdown channel alimenté depuis channels.json
  au lieu de scanner les fichiers manifest.
- tools/SignManifest.php : revert du constructeur channel-aware,
  toujours sur versions.json + builds/ flat.

CLIENT :
- VersionManifest.Channels (List<string>) ajouté pour debug, mais le
  filtrage est server-side donc le client n'a rien à faire de ce champ
  côté UX.
- L'envoi du ?channel=X depuis la license signée fonctionne déjà
  depuis v0.26.0, pas de changement nécessaire.

MIGRATION :
Les fichiers manifest/versions-{X}.json créés en v0.26.0 deviennent
inutiles. Si tu en avais (test "police" par exemple), copie les
versions concernées dans versions.json et tague-les avec les bons
channels via la nouvelle UI.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-05 08:51:10 +02:00
parent 0c69bb1c85
commit 49a3b855af
10 changed files with 559 additions and 276 deletions

View File

@@ -2,9 +2,11 @@
declare(strict_types=1);
require __DIR__ . '/lib/Auth.php';
require __DIR__ . '/lib/Layout.php';
require __DIR__ . '/lib/Channels.php';
use PSLauncher\Admin\Auth;
use PSLauncher\Admin\Layout;
use PSLauncher\Admin\Channels;
Auth::requireLogin();
$config = require __DIR__ . '/../api/config.php';
@@ -13,63 +15,30 @@ $root = dirname(__DIR__);
$manifestDir = "$root/manifest";
$buildsDir = "$root/builds";
$notesDir = "$root/releasenotes";
// Channel actif pour cette session d'édition. ?channel=X bascule sur
// versions-X.json (créé à la volée si nécessaire), vide = manifest default.
//
// Normalisation tolérante : on lowercase, on remplace espaces et tirets-cadratins
// par des '-', on retire tout le reste. Comme ça l'admin peut taper "ASTERION VR"
// dans le champ et ça devient "asterion-vr" — pas de submit silencieusement
// bloqué par une regex stricte côté HTML (ce qui faisait rien quand on cliquait
// "Aller" en v0.26.0).
function ps_normalize_channel(string $raw): string
{
$s = strtolower(trim($raw));
// Espaces, tirets typo, tabs, etc. → tiret simple
$s = preg_replace('/[\s\x{2010}-\x{2015}]+/u', '-', $s);
// Retire tout caractère non whitelisté
$s = preg_replace('/[^a-z0-9_-]/', '', $s);
// Compresse les tirets multiples + trim de tirets en début/fin
$s = preg_replace('/-+/', '-', $s);
$s = trim($s, '-_');
return substr($s, 0, 64);
}
$channelRaw = trim((string)($_GET['channel'] ?? $_POST['__channel'] ?? ''));
$channelNormalized = ps_normalize_channel($channelRaw);
$channelWasNormalized = $channelRaw !== '' && $channelNormalized !== $channelRaw;
$channelEmptyAfterNorm = $channelRaw !== '' && $channelNormalized === '';
$channel = $channelNormalized;
$manifestFileName = $channel === '' ? 'versions.json' : "versions-{$channel}.json";
$manifestPath = "$manifestDir/$manifestFileName";
$manifestPath = "$manifestDir/versions.json";
$message = null; $messageType = 'success';
if ($channelEmptyAfterNorm) {
$message = "Nom de channel inexploitable « {$channelRaw} » (que des caractères non autorisés). Tape un nom avec des lettres / chiffres, ex. asterion-vr.";
$messageType = 'error';
} elseif ($channelWasNormalized) {
$message = "Nom de channel normalisé : « {$channelRaw} » → « {$channelNormalized} ».";
$messageType = 'success';
}
// Liste des channels déclarés dans channels.json (incluant "default" implicite)
$channelRegistry = Channels::load($manifestDir);
$channelNames = array_map(fn($c) => (string)($c['name'] ?? ''), $channelRegistry['channels']);
/**
* Liste les channels existants (= versions-*.json présents) + ajoute toujours
* 'default'. L'admin peut taper n'importe quel nouveau nom dans le formulaire
* "Créer un channel" — le fichier sera créé au premier 'add'.
* Sanitize la liste de channels postée. On garde uniquement ceux qui existent
* dans le registre — un attaquant ne peut pas tagger une version sur un channel
* non déclaré. Si la liste finit vide, on retombe sur ['default'] (= public).
*/
function listExistingChannels(string $manifestDir): array
function sanitizeChannels(array $posted, array $allowedNames): array
{
$channels = ['' => '(default)'];
if (is_dir($manifestDir)) {
foreach (glob($manifestDir . '/versions-*.json') as $file) {
$name = basename($file);
if (preg_match('/^versions-([a-z0-9_-]{1,64})\.json$/', $name, $m)) {
$channels[$m[1]] = $m[1];
}
$out = [];
foreach ($posted as $c) {
$c = (string)$c;
if (in_array($c, $allowedNames, true) && !in_array($c, $out, true)) {
$out[] = $c;
}
}
return $channels;
if (empty($out)) $out = ['default'];
return $out;
}
function loadManifest(string $path): array
@@ -139,20 +108,21 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
: (new DateTimeImmutable('now', new DateTimeZone('UTC')))->format('Y-m-d\TH:i:s\Z');
$base = $config['base_url'] ?? 'https://asterionvr.com/PS_Launcher';
// Pour un channel non-default, le ZIP vit dans builds/{channel}/.
$zipPathPrefix = $channel === '' ? 'builds' : "builds/{$channel}";
// Le nom du fichier ZIP est libre : par défaut proserve-{version}.zip,
// mais l'admin peut surcharger pour distinguer des builds homonymes
// entre channels (ex. proserve-1.4.6-asterion.zip vs proserve-1.4.6-foo.zip)
// ou pour un naming custom du fournisseur.
// Tous les ZIPs vivent dans builds/ flat. Le nom du fichier est libre :
// par défaut proserve-{version}.zip, l'admin peut surcharger pour des
// builds homonymes (proserve-1.4.6-police.zip, proserve-1.4.6-asterion.zip…).
$zipFilename = ps_normalize_zip_filename((string)($_POST['zip_filename'] ?? ''), $version);
// Tags : liste des channels qui voient cette version. Sanitized contre
// le registre — un channel inconnu est silencieusement filtré. Vide → ['default'].
$channels = sanitizeChannels((array)($_POST['channels'] ?? []), $channelNames);
$entry = [
'version' => $version,
'releasedAt' => $releasedAtIso,
'executable' => 'PROSERVE_UE_5_5.exe',
'installFolderTemplate' => 'PROSERVE v{version}',
'channels' => $channels,
'download' => [
'url' => "{$base}/{$zipPathPrefix}/{$zipFilename}",
'url' => "{$base}/builds/{$zipFilename}",
'sizeBytes' => 0,
'sha256' => 'REPLACE_AFTER_BUILD',
],
@@ -172,7 +142,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (!is_dir($notesDir)) mkdir($notesDir, 0755, true);
file_put_contents("$notesDir/{$version}.md", $notes);
}
$message = "v{$version} ajoutée. Upload le ZIP en SFTP dans builds/, puis clique « Sync (sign-manifest) ».";
$message = "v{$version} ajoutée. Upload le ZIP <code>{$zipFilename}</code> en SFTP dans <code>builds/</code>, puis clique « Sync (sign-manifest) ».";
}
elseif ($action === 'edit_notes') {
$version = $_POST['version'] ?? '';
@@ -201,20 +171,13 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
}
if ($newZipName !== '') {
$normalized = ps_normalize_zip_filename($newZipName, $version);
// Reconstruit l'URL en remplaçant juste le filename, pas
// le path. Permet de garder un éventuel sous-dossier custom
// tout en renommant le ZIP. Force aussi la re-vérif du sha256
// au prochain Sync (le ZIP cherché change physiquement).
$oldUrl = $v['download']['url'] ?? '';
$parsed = parse_url($oldUrl);
if ($parsed !== false && isset($parsed['path'])) {
$newPath = rtrim(dirname($parsed['path']), '/') . '/' . $normalized;
$newUrl = ($parsed['scheme'] ?? 'https') . '://' . ($parsed['host'] ?? 'asterionvr.com') . $newPath;
$v['download']['url'] = $newUrl;
// Invalide l'ancien hash : un nouveau ZIP est attendu
$v['download']['sha256'] = 'REPLACE_AFTER_BUILD';
$v['download']['sizeBytes'] = 0;
}
// Reconstruit l'URL : tous les ZIPs sont à la racine de builds/
// donc on remplace juste le filename, sans sous-dossier.
$base = $config['base_url'] ?? 'https://asterionvr.com/PS_Launcher';
$v['download']['url'] = "{$base}/builds/{$normalized}";
// Invalide l'ancien hash : un nouveau ZIP est attendu
$v['download']['sha256'] = 'REPLACE_AFTER_BUILD';
$v['download']['sizeBytes'] = 0;
}
break;
}
@@ -223,6 +186,19 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
saveManifest($manifestPath, $manifest);
$message = "Méta de v{$version} mises à jour" . ($newZipName !== '' ? ". ⚠️ Le ZIP a été renommé : upload le nouveau fichier puis re-Sync." : '.');
}
elseif ($action === 'set_channels') {
$version = $_POST['version'] ?? '';
$channels = sanitizeChannels((array)($_POST['channels'] ?? []), $channelNames);
foreach ($manifest['versions'] as &$v) {
if ($v['version'] === $version) {
$v['channels'] = $channels;
break;
}
}
unset($v);
saveManifest($manifestPath, $manifest);
$message = "Channels de v{$version} : " . implode(', ', $channels) . ". Re-Sync pour rafraîchir la signature du manifest.";
}
elseif ($action === 'set_beta') {
$version = $_POST['version'] ?? '';
$isBeta = !empty($_POST['is_beta']);
@@ -270,7 +246,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
}
elseif ($action === 'sync' || $action === 'sync_versions') {
require_once "$root/tools/SignManifest.php";
$signer = new \PSLauncher\Tools\SignManifest($root, $channel ?: null);
$signer = new \PSLauncher\Tools\SignManifest($root);
$scope = $action === 'sync_versions' ? 'versions' : 'all';
$force = !empty($_POST['force']);
$result = $signer->run($scope, $force);
@@ -283,7 +259,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$version = $_POST['version'] ?? '';
if ($version === '') throw new Exception("Version manquante");
require_once "$root/tools/SignManifest.php";
$signer = new \PSLauncher\Tools\SignManifest($root, $channel ?: null);
$signer = new \PSLauncher\Tools\SignManifest($root);
$force = !empty($_POST['force']);
$result = $signer->run('versions', $force, $version);
$forceLabel = $force ? ' [FORCE]' : '';
@@ -314,7 +290,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
// En mode skip, on n'a rien à hasher : on appelle run() qui se contentera
// de détecter hashAlgorithm=none et re-signera. Pas de calcul lourd.
require_once "$root/tools/SignManifest.php";
$signer = new \PSLauncher\Tools\SignManifest($root, $channel ?: null);
$signer = new \PSLauncher\Tools\SignManifest($root);
$resign = $signer->run('versions', false);
$message = ($skipHash
? "Vérif SHA-256 désactivée pour v{$version} et manifest re-signé."
@@ -329,23 +305,10 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
}
$manifest = loadManifest($manifestPath);
// Liste des ZIPs : on inclut builds/ + builds/{channel}/ (1 niveau de
// sous-dossier) pour lister aussi les ZIPs d'un channel spécifique. Les
// chemins affichés sont relatifs à $buildsDir pour distinguer les ZIPs
// "racine" (default) des ZIPs en sous-dossier (asterion-vr/proserve-X.zip).
$zips = [];
// Liste des ZIPs : tout est flat dans builds/, indexé par filename simple.
$zips = is_dir($buildsDir) ? array_map('basename', glob("$buildsDir/*.zip") ?: []) : [];
$zipSizes = [];
if (is_dir($buildsDir)) {
foreach (array_merge(
glob("$buildsDir/*.zip") ?: [],
glob("$buildsDir/*/*.zip") ?: []
) as $z) {
$rel = ltrim(str_replace($buildsDir, '', $z), '/\\');
$rel = str_replace('\\', '/', $rel);
$zips[] = $rel;
$zipSizes[$rel] = filesize($z);
}
}
foreach ($zips as $z) $zipSizes[$z] = filesize("$buildsDir/$z");
// Pour l'édition : pré-charge les release notes existantes
$existingNotes = [];
@@ -354,66 +317,23 @@ foreach ($manifest['versions'] ?? [] as $v) {
$existingNotes[$v['version']] = is_file($f) ? file_get_contents($f) : '';
}
$existingChannels = listExistingChannels($manifestDir);
// On force la présence du channel actif dans la liste, même si versions-X.json
// n'existe pas encore (cas où l'utilisateur vient de "créer" un channel et n'y
// a pas encore ajouté de version). Sinon le dropdown afficherait "(default)"
// alors que la session est sur le nouveau channel — confusion classique.
if ($channel !== '' && !isset($existingChannels[$channel])) {
$existingChannels[$channel] = $channel . ' (vide — pas encore de versions)';
}
$channelLabel = $channel === '' ? 'default' : $channel;
Layout::header('Versions', 'versions');
?>
<h1>Versions <span class="muted" style="font-size: 14px; font-weight: normal;">— channel actif : <code style="background: rgba(0,0,0,0.3); padding: 2px 8px; border-radius: 4px;"><?= htmlspecialchars($channelLabel) ?></code></span></h1>
<h1>Versions</h1>
<?php Layout::flash($message, $messageType); ?>
<!--
Channel switcher : bascule la session admin sur un autre fichier manifest.
Toutes les actions (add/edit/delete/sync) qui suivent opéreront sur ce channel.
Le hidden __channel propage la sélection à chaque POST pour ne pas perdre le
contexte au refresh.
-->
<div class="card" style="display: flex; align-items: center; gap: 12px; flex-wrap: wrap;">
<strong>Channel à éditer :</strong>
<form method="get" style="display: inline-flex; gap: 6px; margin: 0;">
<select name="channel" onchange="this.form.submit()">
<?php foreach ($existingChannels as $value => $label):
$selected = $value === $channel ? 'selected' : '';
?>
<option value="<?= htmlspecialchars($value) ?>" <?= $selected ?>><?= htmlspecialchars($label) ?></option>
<?php endforeach; ?>
</select>
</form>
<span class="muted" style="font-size: 12px;">
Les licenses avec ce channel verront ces versions. Le default sert les licenses sans channel.
</span>
<form method="get" style="display: inline-flex; gap: 6px; margin-left: auto;">
<input type="text" name="channel" placeholder="Créer/utiliser un nouveau channel : asterion-vr"
required maxlength="80" style="width: 280px;"
title="Sera normalisé en lowercase + tirets côté serveur (ex. « ASTERION VR » → « asterion-vr »).">
<button class="btn btn-secondary" type="submit">Aller</button>
</form>
</div>
<div class="card">
<h2>Workflow d'une nouvelle release</h2>
<ol class="muted">
<li>Ajoute l'entrée du manifest avec le formulaire ci-dessous (version, date min de license, release notes).</li>
<li>Upload le ZIP correspondant via SFTP en respectant le nom <code>proserve-{version}.zip</code>, dans le dossier qui correspond au channel actif :
<ul>
<?php if ($channel === ''): ?>
<li>Channel <strong>default</strong> → <code>www/PS_Launcher/builds/proserve-{version}.zip</code></li>
<?php else: ?>
<li>Channel <strong><?= htmlspecialchars($channel) ?></strong> → <code>www/PS_Launcher/builds/<?= htmlspecialchars($channel) ?>/proserve-{version}.zip</code> <span class="muted">(crée le sous-dossier en SFTP s'il n'existe pas)</span></li>
<?php endif; ?>
</ul>
</li>
<li>Ajoute l'entrée du manifest avec le formulaire ci-dessous (version, date min de license, release notes, channels).</li>
<li>Upload le ZIP en SFTP dans <code>www/PS_Launcher/builds/</code> (le nom doit matcher celui que tu as saisi, par défaut <code>proserve-{version}.zip</code>).</li>
<li>Clique <strong>🔁 Sync (sign-manifest)</strong> pour calculer le SHA-256, mettre à jour <code>sizeBytes</code>, bumper <code>latest</code>, et signer le manifest avec Ed25519.</li>
<li>Les clients PS_Launcher détecteront la nouvelle version au prochain « Vérifier les MAJ ».</li>
<li>Les clients PS_Launcher détecteront la nouvelle version au prochain « Vérifier les MAJ », filtrés selon le channel de leur license.</li>
</ol>
<p class="muted" style="margin: 12px 0 0;">
💡 La gestion des channels (création / édition / suppression) se fait sur la page <a href="channels.php">Channels</a>.
</p>
</div>
<div class="card">
@@ -421,7 +341,6 @@ Layout::header('Versions', 'versions');
<form method="post">
<?= Layout::csrfField() ?>
<input type="hidden" name="action" value="add">
<input type="hidden" name="__channel" value="<?= htmlspecialchars($channel) ?>">
<div class="row">
<div class="col field">
<label>Version (X.Y.Z)</label>
@@ -437,8 +356,23 @@ Layout::header('Versions', 'versions');
</div>
</div>
<div class="field">
<label>Nom du fichier ZIP <span class="muted" style="font-weight: normal; font-size: 11px;">(optionnel, par défaut <code>proserve-{version}.zip</code> ; utile pour distinguer un build homonyme entre channels)</span></label>
<input type="text" name="zip_filename" placeholder="proserve-1.4.8-asterion.zip" maxlength="120">
<label>Nom du fichier ZIP <span class="muted" style="font-weight: normal; font-size: 11px;">(optionnel, par défaut <code>proserve-{version}.zip</code>)</span></label>
<input type="text" name="zip_filename" placeholder="proserve-1.4.8-police.zip" maxlength="120">
</div>
<div class="field">
<label>Channels qui voient cette version <span class="muted" style="font-weight: normal; font-size: 11px;">(coche au moins « default » pour rendre publique, ou un channel privé pour la cibler)</span></label>
<div style="display: flex; flex-wrap: wrap; gap: 12px; padding: 8px 0;">
<?php foreach ($channelRegistry['channels'] as $c):
$cn = $c['name'] ?? '';
$isDefault = $cn === 'default';
?>
<label style="display: flex; align-items: center; gap: 6px; cursor: pointer;">
<input type="checkbox" name="channels[]" value="<?= htmlspecialchars($cn) ?>" <?= $isDefault ? 'checked' : '' ?>>
<code><?= htmlspecialchars($cn) ?></code>
<span class="muted" style="font-size: 11px;"><?= htmlspecialchars($c['label'] ?? $cn) ?></span>
</label>
<?php endforeach; ?>
</div>
</div>
<div class="field">
<label>Release notes (Markdown)</label>
@@ -486,6 +420,7 @@ Layout::header('Versions', 'versions');
<thead>
<tr>
<th>Version</th>
<th>Channels</th>
<th>Release</th>
<th>min_license</th>
<th>ZIP</th>
@@ -496,19 +431,17 @@ Layout::header('Versions', 'versions');
</thead>
<tbody>
<?php foreach ($manifest['versions'] ?? [] as $v):
// Format relatif depuis builds/. Pour le default channel, ça donne juste
// "proserve-X.Y.Z.zip" ; pour les autres channels, "asterion-vr/proserve-X.Y.Z.zip".
// Doit matcher exactement ce que produit $zips (cf. plus haut, glob sur 1
// niveau de sous-dossier max + chemin relatif normalisé en slash forward).
$urlPath = parse_url($v['download']['url'] ?? '', PHP_URL_PATH) ?: '';
$zipPos = strpos($urlPath, '/builds/');
$zipRel = $zipPos !== false
? ltrim(substr($urlPath, $zipPos + strlen('/builds/')), '/\\')
: basename($urlPath);
$zipExists = in_array($zipRel, $zips, true);
$hashed = !empty($v['download']['sha256']) && !str_starts_with($v['download']['sha256'], 'REPLACE');
$zipBase = basename(parse_url($v['download']['url'] ?? '', PHP_URL_PATH) ?: '');
$zipExists = in_array($zipBase, $zips, true);
$hashed = !empty($v['download']['sha256']) && !str_starts_with($v['download']['sha256'], 'REPLACE');
$hashSkipped = strtolower((string)($v['download']['hashAlgorithm'] ?? 'sha256')) === 'none';
?>
<?php
// Channels du version : array si défini, sinon implicite ['default']
$vChannels = isset($v['channels']) && is_array($v['channels']) && !empty($v['channels'])
? $v['channels']
: ['default'];
?>
<tr>
<td>
<strong>v<?= htmlspecialchars($v['version']) ?></strong>
@@ -516,15 +449,20 @@ Layout::header('Versions', 'versions');
<span class="badge badge-warning" title="<?= htmlspecialchars($v['betaNotes'] ?? 'Version BÊTA') ?>" style="margin-left: 4px;">BÊTA</span>
<?php endif; ?>
</td>
<td>
<?php foreach ($vChannels as $ch): ?>
<code style="background: rgba(0,0,0,0.3); padding: 2px 6px; border-radius: 4px; font-size: 11px; margin-right: 4px;"><?= htmlspecialchars($ch) ?></code>
<?php endforeach; ?>
</td>
<td class="muted"><?= htmlspecialchars(substr($v['releasedAt'] ?? '', 0, 10)) ?></td>
<td class="muted"><?= htmlspecialchars($v['minLicenseDate'] ?? '—') ?></td>
<td>
<?php if ($zipExists): ?>
<span class="badge badge-success">présent</span>
<span class="muted"><?= Layout::formatBytes($zipSizes[$zipRel] ?? 0) ?></span>
<span class="muted"><?= Layout::formatBytes($zipSizes[$zipBase] ?? 0) ?></span>
<?php else: ?>
<span class="badge badge-warning">absent</span>
<div class="muted" style="font-size: 11px;">attendu : <code><?= htmlspecialchars($zipRel) ?></code></div>
<div class="muted" style="font-size: 11px;">attendu : <code><?= htmlspecialchars($zipBase) ?></code></div>
<?php endif; ?>
</td>
<td>
@@ -641,6 +579,28 @@ Layout::header('Versions', 'versions');
<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="version" value="<?= htmlspecialchars($v['version']) ?>">
<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">
@@ -651,7 +611,7 @@ Layout::header('Versions', 'versions');
</tr>
<?php endforeach; ?>
<?php if (empty($manifest['versions'])): ?>
<tr><td colspan="7" 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>
<?php endif; ?>
</tbody>
</table>
@@ -666,18 +626,12 @@ Layout::header('Versions', 'versions');
<thead><tr><th>Fichier</th><th>Taille</th><th>Référencé ?</th></tr></thead>
<tbody>
<?php
// On référence avec le chemin relatif après /builds/ pour matcher
// les ZIPs en sous-dossier (channels). Ex. asterion-vr/proserve-1.4.6.zip.
$referencedRel = [];
$referencedNames = [];
foreach ($manifest['versions'] ?? [] as $v) {
$urlPath = parse_url($v['download']['url'] ?? '', PHP_URL_PATH) ?: '';
$pos = strpos($urlPath, '/builds/');
if ($pos !== false) {
$referencedRel[] = ltrim(substr($urlPath, $pos + strlen('/builds/')), '/\\');
}
$referencedNames[] = basename(parse_url($v['download']['url'] ?? '', PHP_URL_PATH) ?: '');
}
foreach ($zips as $z):
$referenced = in_array($z, $referencedRel, true);
$referenced = in_array($z, $referencedNames, true);
?>
<tr>
<td><code><?= htmlspecialchars($z) ?></code></td>
@@ -691,22 +645,4 @@ Layout::header('Versions', 'versions');
</table>
<?php endif; ?>
</div>
<script>
// Propage le channel actif à toutes les formes POST qui n'ont pas explicitement
// leur propre __channel — évite d'éditer chaque form individuellement et
// préserve le contexte channel après chaque submit (sinon on retomberait sur
// le default à la prochaine action).
(function() {
var ch = <?= json_encode($channel) ?>;
document.querySelectorAll('form[method="post"]').forEach(function(form) {
if (!form.querySelector('input[name="__channel"]')) {
var inp = document.createElement('input');
inp.type = 'hidden';
inp.name = '__channel';
inp.value = ch;
form.appendChild(inp);
}
});
})();
</script>
<?php Layout::footer();