diff --git a/installer/PSLauncher.iss b/installer/PSLauncher.iss
index 7544b70..3414565 100644
--- a/installer/PSLauncher.iss
+++ b/installer/PSLauncher.iss
@@ -11,7 +11,7 @@
#define MyAppName "PROSERVE Launcher"
#define MyAppShortName "PS_Launcher"
-#define MyAppVersion "0.25.10"
+#define MyAppVersion "0.26.0"
#define MyAppPublisher "ASTERION VR"
#define MyAppURL "https://asterionvr.com"
#define MyAppExeName "PS_Launcher.exe"
diff --git a/server/admin/licenses.php b/server/admin/licenses.php
index a03182a..60eaf4a 100644
--- a/server/admin/licenses.php
+++ b/server/admin/licenses.php
@@ -27,6 +27,26 @@ function generateLicenseKey(): string
return 'PRSRV-' . implode('-', $groups);
}
+/**
+ * Liste les channels disponibles en scannant manifest/versions-*.json.
+ * Le channel "default" est toujours présent (= manifest/versions.json).
+ * Whitelist [a-z0-9_-]{1,64} pour cohérence avec PHP\Routes\Manifest.
+ */
+function listAvailableChannels(): array
+{
+ $dir = dirname(__DIR__) . '/manifest';
+ $channels = ['' => '(default)']; // clé vide = NULL en DB
+ if (is_dir($dir)) {
+ foreach (glob($dir . '/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;
+}
+
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
Auth::checkCsrf();
$action = $_POST['action'] ?? '';
@@ -37,6 +57,14 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$until = trim($_POST['until'] ?? '');
$maxMachines = max(1, (int)($_POST['max_machines'] ?? 1));
$notes = trim($_POST['notes'] ?? '') ?: null;
+ // Channel = '' ↦ NULL en DB (default manifest). Whitelist regex anti-injection.
+ $channel = trim((string)($_POST['channel'] ?? ''));
+ if ($channel === '') {
+ $channel = null;
+ } elseif (!preg_match('/^[a-z0-9_-]{1,64}$/', $channel)) {
+ throw new Exception('Channel invalide : seules les lettres minuscules, chiffres, _ et - sont autorisés (max 64 caractères).');
+ }
+ $canSeeBetas = isset($_POST['can_see_betas']) ? 1 : 0;
if ($owner === '' || !preg_match('/^\d{4}-\d{2}-\d{2}$/', $until)) {
throw new Exception('Nom du client et date d\'expiration sont requis (date au format YYYY-MM-DD).');
@@ -45,8 +73,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
for ($attempts = 0; $attempts < 5; $attempts++) {
$newKey = generateLicenseKey();
try {
- $db->prepare('INSERT INTO licenses (license_key, owner_name, issued_at, download_entitlement_until, max_machines, notes) VALUES (?, ?, NOW(), ?, ?, ?)')
- ->execute([$newKey, $owner, $until . ' 23:59:59', $maxMachines, $notes]);
+ $db->prepare('INSERT INTO licenses (license_key, owner_name, issued_at, download_entitlement_until, max_machines, channel, can_see_betas, notes) VALUES (?, ?, NOW(), ?, ?, ?, ?, ?)')
+ ->execute([$newKey, $owner, $until . ' 23:59:59', $maxMachines, $channel, $canSeeBetas, $notes]);
$message = "License émise avec succès. Note la clé maintenant — elle ne sera plus jamais affichée.";
break;
} catch (PDOException $e) {
@@ -75,6 +103,29 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
->execute([$newUntil . ' 23:59:59', $id]);
$message = "License #{$id} prolongée jusqu'au {$newUntil}.";
}
+ elseif ($action === 'set_channel') {
+ $id = (int)($_POST['id'] ?? 0);
+ $channel = trim((string)($_POST['channel'] ?? ''));
+ if ($channel === '') {
+ $channel = null; // default manifest
+ } elseif (!preg_match('/^[a-z0-9_-]{1,64}$/', $channel)) {
+ throw new Exception('Channel invalide.');
+ }
+ $db->prepare('UPDATE licenses SET channel = ? WHERE id = ?')->execute([$channel, $id]);
+ $label = $channel ?? 'default';
+ $message = "License #{$id} : channel passé à « {$label} ». Le client devra rouvrir le launcher pour prendre en compte le changement (revalidation license).";
+ }
+ elseif ($action === 'toggle_betas') {
+ $id = (int)($_POST['id'] ?? 0);
+ $stmt = $db->prepare('UPDATE licenses SET can_see_betas = 1 - can_see_betas WHERE id = ?');
+ $stmt->execute([$id]);
+ $row = $db->prepare('SELECT can_see_betas FROM licenses WHERE id = ?');
+ $row->execute([$id]);
+ $now = (int)$row->fetchColumn();
+ $message = $now
+ ? "License #{$id} : accès aux versions BÊTA activé."
+ : "License #{$id} : accès aux versions BÊTA désactivé.";
+ }
elseif ($action === 'set_max_machines') {
$id = (int)($_POST['id'] ?? 0);
$newMax = (int)($_POST['max_machines'] ?? 0);
@@ -118,6 +169,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
}
}
+$availableChannels = listAvailableChannels();
+
$licenses = $db->query(
'SELECT l.*,
(SELECT COUNT(*) FROM license_machines m WHERE m.license_id = l.id) AS machines_count
@@ -170,6 +223,23 @@ Layout::header('Licenses', 'licenses');
+
@@ -187,6 +257,7 @@ Layout::header('Licenses', 'licenses');
Émise |
Expire |
Machines |
+
Channel / Bêta |
Statut |
Actions |
@@ -220,6 +291,41 @@ Layout::header('Licenses', 'licenses');
0 / = $l['max_machines'] ?>
+
+
+
+
+ = htmlspecialchars($l['channel'] ?? 'default') ?>
+
+
+ β
+
+
+
+
+
+
+
+ |
= $status ?> |
@@ -278,7 +384,7 @@ Layout::header('Licenses', 'licenses');
if (!empty($machines)):
?>
- |
+ |
diff --git a/server/admin/versions.php b/server/admin/versions.php
index f31318b..0053b4b 100644
--- a/server/admin/versions.php
+++ b/server/admin/versions.php
@@ -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');
+
- Versions
+
+ Versions — channel actif : = htmlspecialchars($channelLabel) ?>
+
+
+ Channel à éditer :
+
+
+ Les licenses avec ce channel verront ces versions. Le default sert les licenses sans channel.
+
+
+
+
Workflow d'une nouvelle release
@@ -225,6 +319,7 @@ Layout::header('Versions', 'versions');
| |