v0.26.0 — Channels per-license + tag BÊTA sur versions
DEUX features liées :
1. CHANNELS : chaque license peut être attribuée à un manifest distinct
(« channel »). Permet de servir des versions différentes selon le
client. Le serveur lit ?channel=X et sert manifest/versions-{X}.json
avec fallback transparent sur versions.json. NULL = default.
2. BÊTA : nouveau flag isBeta + betaNotes par version dans le manifest.
Visible uniquement par les licenses avec can_see_betas=1. Affichage
d'une pill orange « BÊTA » sur la row + tooltip avec les notes pour
les testeurs. Les installations locales déjà présentes restent
visibles même si l'accès BÊTA est retiré ensuite (on n'efface pas
le disque du client).
DB :
002_channel_betas.sql ajoute channel + can_see_betas sur licenses.
Idempotent (ALTER TABLE IF NOT EXISTS), zero data migration.
Serveur PHP :
- ValidateLicense.php signe channel + canSeeBetas dans la réponse
(ordre des clés CRITIQUE pour matcher le canonical client).
- Manifest.php : whitelist regex anti-traversal sur ?channel=, fallback
silencieux sur versions.json si channel inconnu (évite leak de la
liste de channels par probing).
- SignManifest.php prend un channel optionnel → l'admin peut signer
chaque manifest indépendamment.
- admin/licenses.php : dropdown channel + checkbox bêta sur create,
bouton détails repliable par-row pour edit.
- admin/versions.php : channel switcher en tête, badge BÊTA sur chaque
row, dialog repliable « Bêta » avec checkbox + notes des testeurs.
Client C# :
- License.Channel + License.CanSeeBetas (dans le canonical signé).
- VersionManifest.IsBeta + BetaNotes.
- ManifestService prend un channelProvider via DI, lu depuis license
cachée à chaque fetch (lazy, pas de circular dep).
- MainViewModel.RebuildList filtre les versions IsBeta si !CanSeeBetas
(mais conserve les installées locales — on ne retire pas l'accès
rétroactivement à ce qui est déjà sur disque).
- VersionRowViewModel : props IsBeta / BetaNotes / BetaTooltip.
- MainWindow.xaml : pill orange à côté du n° version pour le featured
et les rows compactes, tooltip dynamique avec les notes testeurs.
Backward compat signature :
Anciennes licenses cachées (signées sans channel/canSeeBetas) sont
toujours validées via un fallback canonical legacy dans VerifySignature.
Sans ce fallback, le passage à v0.26 invaliderait toutes les caches
hors-ligne et bloquerait les users en mobilité.
Migration côté admin : jouer 002_channel_betas.sql sur la base, déployer
les fichiers PHP, créer manifest/versions-{channel}.json pour les
nouveaux channels (l'admin versions.php propose un input « Créer/utiliser
un nouveau channel »). Les licenses existantes restent en channel=NULL
= default = comportement actuel.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -10,12 +10,41 @@ Auth::requireLogin();
|
||||
$config = require __DIR__ . '/../api/config.php';
|
||||
|
||||
$root = dirname(__DIR__);
|
||||
$manifestPath = "$root/manifest/versions.json";
|
||||
$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.
|
||||
// Whitelist regex anti-injection.
|
||||
$channel = trim((string)($_GET['channel'] ?? $_POST['__channel'] ?? ''));
|
||||
if ($channel !== '' && !preg_match('/^[a-z0-9_-]{1,64}$/', $channel)) {
|
||||
$channel = '';
|
||||
}
|
||||
$manifestFileName = $channel === '' ? 'versions.json' : "versions-{$channel}.json";
|
||||
$manifestPath = "$manifestDir/$manifestFileName";
|
||||
|
||||
$message = null; $messageType = 'success';
|
||||
|
||||
/**
|
||||
* 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'.
|
||||
*/
|
||||
function listExistingChannels(string $manifestDir): 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];
|
||||
}
|
||||
}
|
||||
}
|
||||
return $channels;
|
||||
}
|
||||
|
||||
function loadManifest(string $path): array
|
||||
{
|
||||
if (!is_file($path)) return ['schemaVersion' => 1, 'product' => 'PROSERVE_UE', 'latest' => null, 'versions' => []];
|
||||
@@ -44,6 +73,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$releasedAt = trim($_POST['released_at'] ?? '');
|
||||
$notes = $_POST['notes'] ?? '';
|
||||
$available = isset($_POST['available']);
|
||||
$isBeta = isset($_POST['is_beta']);
|
||||
$betaNotes = trim($_POST['beta_notes'] ?? '') ?: null;
|
||||
|
||||
if (!preg_match('/^\d+\.\d+\.\d+$/', $version)) {
|
||||
throw new Exception('Numéro de version invalide (format X.Y.Z attendu).');
|
||||
@@ -60,13 +91,16 @@ 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';
|
||||
$manifest['versions'][] = [
|
||||
// Pour un channel non-default, le ZIP vit dans builds/{channel}/.
|
||||
// L'admin peut surcharger l'URL après-coup si la convention diffère.
|
||||
$zipPathPrefix = $channel === '' ? 'builds' : "builds/{$channel}";
|
||||
$entry = [
|
||||
'version' => $version,
|
||||
'releasedAt' => $releasedAtIso,
|
||||
'executable' => 'PROSERVE_UE_5_5.exe',
|
||||
'installFolderTemplate' => 'PROSERVE v{version}',
|
||||
'download' => [
|
||||
'url' => "{$base}/builds/proserve-{$version}.zip",
|
||||
'url' => "{$base}/{$zipPathPrefix}/proserve-{$version}.zip",
|
||||
'sizeBytes' => 0,
|
||||
'sha256' => 'REPLACE_AFTER_BUILD',
|
||||
],
|
||||
@@ -74,6 +108,11 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
'minLicenseDate' => $minLicDate,
|
||||
'availableForDownload' => $available,
|
||||
];
|
||||
if ($isBeta) {
|
||||
$entry['isBeta'] = true;
|
||||
if ($betaNotes !== null) $entry['betaNotes'] = $betaNotes;
|
||||
}
|
||||
$manifest['versions'][] = $entry;
|
||||
|
||||
saveManifest($manifestPath, $manifest);
|
||||
|
||||
@@ -111,6 +150,30 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
saveManifest($manifestPath, $manifest);
|
||||
$message = "Méta de v{$version} mises à jour.";
|
||||
}
|
||||
elseif ($action === 'set_beta') {
|
||||
$version = $_POST['version'] ?? '';
|
||||
$isBeta = !empty($_POST['is_beta']);
|
||||
$betaNotes = trim($_POST['beta_notes'] ?? '') ?: null;
|
||||
foreach ($manifest['versions'] as &$v) {
|
||||
if ($v['version'] === $version) {
|
||||
if ($isBeta) {
|
||||
$v['isBeta'] = true;
|
||||
if ($betaNotes !== null) {
|
||||
$v['betaNotes'] = $betaNotes;
|
||||
} else {
|
||||
unset($v['betaNotes']);
|
||||
}
|
||||
} else {
|
||||
unset($v['isBeta']);
|
||||
unset($v['betaNotes']);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
unset($v);
|
||||
saveManifest($manifestPath, $manifest);
|
||||
$message = "Statut BÊTA de v{$version} mis à jour. N'oublie pas de re-signer le manifest (« 🔁 Sync »).";
|
||||
}
|
||||
elseif ($action === 'toggle_available') {
|
||||
$version = $_POST['version'] ?? '';
|
||||
foreach ($manifest['versions'] as &$v) {
|
||||
@@ -134,7 +197,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
}
|
||||
elseif ($action === 'sync' || $action === 'sync_versions') {
|
||||
require_once "$root/tools/SignManifest.php";
|
||||
$signer = new \PSLauncher\Tools\SignManifest($root);
|
||||
$signer = new \PSLauncher\Tools\SignManifest($root, $channel ?: null);
|
||||
$scope = $action === 'sync_versions' ? 'versions' : 'all';
|
||||
$force = !empty($_POST['force']);
|
||||
$result = $signer->run($scope, $force);
|
||||
@@ -147,7 +210,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);
|
||||
$signer = new \PSLauncher\Tools\SignManifest($root, $channel ?: null);
|
||||
$force = !empty($_POST['force']);
|
||||
$result = $signer->run('versions', $force, $version);
|
||||
$forceLabel = $force ? ' [FORCE]' : '';
|
||||
@@ -178,7 +241,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);
|
||||
$signer = new \PSLauncher\Tools\SignManifest($root, $channel ?: null);
|
||||
$resign = $signer->run('versions', false);
|
||||
$message = ($skipHash
|
||||
? "Vérif SHA-256 désactivée pour v{$version} et manifest re-signé."
|
||||
@@ -204,12 +267,43 @@ foreach ($manifest['versions'] ?? [] as $v) {
|
||||
$existingNotes[$v['version']] = is_file($f) ? file_get_contents($f) : '';
|
||||
}
|
||||
|
||||
Layout::header('Versions', 'versions');
|
||||
<?php
|
||||
$existingChannels = listExistingChannels($manifestDir);
|
||||
$channelLabel = $channel === '' ? 'default' : $channel;
|
||||
?>
|
||||
<h1>Versions</h1>
|
||||
<?php 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>
|
||||
|
||||
<?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"
|
||||
pattern="[a-z0-9_-]{1,64}" required style="width: 280px;"
|
||||
title="Lettres minuscules, chiffres, _ ou - (max 64 caractères)">
|
||||
<button class="btn btn-secondary" type="submit">Aller</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Workflow d'une nouvelle release</h2>
|
||||
<ol class="muted">
|
||||
@@ -225,6 +319,7 @@ 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>
|
||||
@@ -243,8 +338,17 @@ Layout::header('Versions', 'versions');
|
||||
<label>Release notes (Markdown)</label>
|
||||
<textarea name="notes" rows="8" placeholder="# Proserve v1.4.8 ## Nouveautés - ..." style="font-family: 'Cascadia Code', Consolas, monospace;"></textarea>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col field">
|
||||
<label><input type="checkbox" name="available" checked> Disponible au téléchargement</label>
|
||||
</div>
|
||||
<div class="col field">
|
||||
<label><input type="checkbox" name="is_beta"> Version BÊTA <span class="muted" style="font-weight: normal; font-size: 11px;">(visible uniquement par les licenses « can_see_betas »)</span></label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label><input type="checkbox" name="available" checked> Disponible au téléchargement</label>
|
||||
<label>Notes pour les testeurs <span class="muted" style="font-weight: normal; font-size: 11px;">(affichées en tooltip dans le launcher quand BÊTA est coché)</span></label>
|
||||
<input type="text" name="beta_notes" placeholder="Tester en priorité : nouvelle UI scènes, intégration capteurs.">
|
||||
</div>
|
||||
<button class="btn btn-success" type="submit">Ajouter au manifest</button>
|
||||
</form>
|
||||
@@ -292,7 +396,12 @@ Layout::header('Versions', 'versions');
|
||||
$hashSkipped = strtolower((string)($v['download']['hashAlgorithm'] ?? 'sha256')) === 'none';
|
||||
?>
|
||||
<tr>
|
||||
<td><strong>v<?= htmlspecialchars($v['version']) ?></strong></td>
|
||||
<td>
|
||||
<strong>v<?= htmlspecialchars($v['version']) ?></strong>
|
||||
<?php if (!empty($v['isBeta'])): ?>
|
||||
<span class="badge badge-warning" title="<?= htmlspecialchars($v['betaNotes'] ?? 'Version BÊTA') ?>" style="margin-left: 4px;">BÊTA</span>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td class="muted"><?= htmlspecialchars(substr($v['releasedAt'] ?? '', 0, 10)) ?></td>
|
||||
<td class="muted"><?= htmlspecialchars($v['minLicenseDate'] ?? '—') ?></td>
|
||||
<td>
|
||||
@@ -393,6 +502,26 @@ Layout::header('Versions', 'versions');
|
||||
<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="version" value="<?= htmlspecialchars($v['version']) ?>">
|
||||
<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>
|
||||
<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">
|
||||
@@ -437,4 +566,22 @@ 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();
|
||||
|
||||
Reference in New Issue
Block a user