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:
@@ -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"
|
||||
|
||||
@@ -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');
|
||||
<input type="number" name="max_machines" value="1" min="1" max="100">
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col field">
|
||||
<label>Channel <span class="muted" style="font-weight: normal; font-size: 11px;">(quel manifest sert le client ?)</span></label>
|
||||
<select name="channel">
|
||||
<?php foreach ($availableChannels as $value => $label): ?>
|
||||
<option value="<?= htmlspecialchars($value) ?>"><?= htmlspecialchars($label) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col field">
|
||||
<label style="display: flex; align-items: center; gap: 8px;">
|
||||
<input type="checkbox" name="can_see_betas" value="1" style="width: auto; margin: 0;">
|
||||
Accès aux versions BÊTA
|
||||
</label>
|
||||
<span class="muted" style="font-size: 11px;">Coche pour les testeurs internes / clients pilotes.</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Notes internes (optionnel)</label>
|
||||
<input type="text" name="notes" placeholder="Contact : jean@acme.com">
|
||||
@@ -187,6 +257,7 @@ Layout::header('Licenses', 'licenses');
|
||||
<th>Émise</th>
|
||||
<th>Expire</th>
|
||||
<th>Machines</th>
|
||||
<th>Channel / Bêta</th>
|
||||
<th>Statut</th>
|
||||
<th style="text-align:right">Actions</th>
|
||||
</tr>
|
||||
@@ -220,6 +291,41 @@ Layout::header('Licenses', 'licenses');
|
||||
0 / <?= $l['max_machines'] ?>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td>
|
||||
<details style="display: inline-block;">
|
||||
<summary style="cursor: pointer; list-style: none;">
|
||||
<code style="background: rgba(0,0,0,0.3); padding: 2px 6px; border-radius: 4px; font-size: 11px;">
|
||||
<?= htmlspecialchars($l['channel'] ?? 'default') ?>
|
||||
</code>
|
||||
<?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">β</span>
|
||||
<?php endif; ?>
|
||||
</summary>
|
||||
<div style="margin-top: 8px; min-width: 220px;">
|
||||
<form method="post" style="display: flex; gap: 4px; align-items: center; margin-bottom: 6px;">
|
||||
<?= Layout::csrfField() ?>
|
||||
<input type="hidden" name="action" value="set_channel">
|
||||
<input type="hidden" name="id" value="<?= $l['id'] ?>">
|
||||
<select name="channel" style="flex: 1;">
|
||||
<?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>
|
||||
<form method="post">
|
||||
<?= Layout::csrfField() ?>
|
||||
<input type="hidden" name="action" value="toggle_betas">
|
||||
<input type="hidden" name="id" value="<?= $l['id'] ?>">
|
||||
<button class="btn btn-secondary" type="submit" style="width: 100%;">
|
||||
<?= (int)($l['can_see_betas'] ?? 0) === 1 ? 'Retirer accès BÊTA' : 'Activer accès BÊTA' ?>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</details>
|
||||
</td>
|
||||
<td><span class="badge <?= $cls ?>"><?= $status ?></span></td>
|
||||
<td style="text-align:right; white-space: nowrap;">
|
||||
<details style="display: inline-block; margin: 0 4px;">
|
||||
@@ -278,7 +384,7 @@ Layout::header('Licenses', 'licenses');
|
||||
if (!empty($machines)):
|
||||
?>
|
||||
<tr>
|
||||
<td colspan="7" style="padding: 0;">
|
||||
<td colspan="8" style="padding: 0;">
|
||||
<details id="machines-<?= $l['id'] ?>" style="margin: 0;">
|
||||
<summary style="display: none;"></summary>
|
||||
<div style="background: rgba(0,0,0,0.2); padding: 12px 16px; border-top: 1px solid var(--border);">
|
||||
|
||||
@@ -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,9 +338,18 @@ 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="field">
|
||||
<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>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>
|
||||
</div>
|
||||
@@ -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();
|
||||
|
||||
@@ -7,17 +7,37 @@ use PSLauncher\Response;
|
||||
|
||||
final class Manifest
|
||||
{
|
||||
/// Whitelist du paramètre ?channel=X : on autorise [a-z0-9_-]{1,64}
|
||||
/// pour empêcher tout path traversal (..) ou injection de caractères exotiques.
|
||||
private const CHANNEL_REGEX = '/^[a-z0-9_-]{1,64}$/';
|
||||
|
||||
public static function handle(array $config): void
|
||||
{
|
||||
$file = dirname(__DIR__, 2) . '/manifest/versions.json';
|
||||
if (!is_file($file)) {
|
||||
Response::error('manifest_missing', 'versions.json not found on server', 404);
|
||||
$manifestDir = dirname(__DIR__, 2) . '/manifest';
|
||||
$defaultFile = $manifestDir . '/versions.json';
|
||||
|
||||
$channel = $_GET['channel'] ?? null;
|
||||
$resolved = $defaultFile;
|
||||
|
||||
if (is_string($channel) && $channel !== '' && preg_match(self::CHANNEL_REGEX, $channel)) {
|
||||
$candidate = $manifestDir . '/versions-' . $channel . '.json';
|
||||
if (is_file($candidate)) {
|
||||
$resolved = $candidate;
|
||||
}
|
||||
// Cache court pour absorber les pics, doit être revalidé pour ne pas masquer une nouvelle version
|
||||
// Sinon : silencieux, on retombe sur versions.json. Évite de leak la
|
||||
// liste exhaustive des channels par probing.
|
||||
}
|
||||
|
||||
if (!is_file($resolved)) {
|
||||
Response::error('manifest_missing', 'manifest not found on server', 404);
|
||||
}
|
||||
|
||||
// Cache court pour absorber les pics, doit être revalidé pour ne pas masquer une nouvelle version.
|
||||
// ETag inclut le nom du fichier servi pour qu'un changement de channel cassé l'ancien cache HTTP.
|
||||
header('Cache-Control: public, max-age=300, must-revalidate');
|
||||
header('ETag: "' . md5_file($file) . '"');
|
||||
header('ETag: "' . md5(basename($resolved) . '|' . md5_file($resolved)) . '"');
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
header('Content-Length: ' . filesize($file));
|
||||
readfile($file);
|
||||
header('Content-Length: ' . filesize($resolved));
|
||||
readfile($resolved);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ final class ValidateLicense
|
||||
|
||||
$stmt = $db->prepare(
|
||||
'SELECT id, license_key, owner_name, issued_at, download_entitlement_until,
|
||||
max_machines, revoked_at
|
||||
max_machines, channel, can_see_betas, revoked_at
|
||||
FROM licenses WHERE license_key = ? LIMIT 1'
|
||||
);
|
||||
$stmt->execute([$licenseKey]);
|
||||
@@ -82,6 +82,17 @@ final class ValidateLicense
|
||||
$serverTime = (new \DateTimeImmutable('now', $utc))->format($fmt);
|
||||
$expired = strtotime($lic['download_entitlement_until']) < time();
|
||||
|
||||
// ATTENTION : ordre des clés CRITIQUE pour la signature Ed25519.
|
||||
// Crypto::canonicalJson NE TRIE PAS — il prend l'ordre tel quel. Le client
|
||||
// C# (LicenseService.CanonicalBytesFor) reconstruit le même dictionnaire
|
||||
// dans le même ordre. Toute modif ici doit être miroir côté client.
|
||||
// channel : NULL en DB → null JSON (visible dans le canonical comme "channel":null).
|
||||
// can_see_betas : 0/1 en DB → bool JSON.
|
||||
$channel = isset($lic['channel']) && $lic['channel'] !== null && $lic['channel'] !== ''
|
||||
? (string)$lic['channel']
|
||||
: null;
|
||||
$canSeeBetas = (bool)($lic['can_see_betas'] ?? 0);
|
||||
|
||||
$payload = [
|
||||
'status' => $expired ? 'expired' : 'valid',
|
||||
'licenseId' => 'lic_' . $lic['id'],
|
||||
@@ -89,6 +100,8 @@ final class ValidateLicense
|
||||
'issuedAt' => $issuedAt,
|
||||
'downloadEntitlementUntil' => $entUntil,
|
||||
'maxMachines' => (int)$lic['max_machines'],
|
||||
'channel' => $channel,
|
||||
'canSeeBetas' => $canSeeBetas,
|
||||
'serverTime' => $serverTime,
|
||||
];
|
||||
|
||||
|
||||
17
server/migrations/002_channel_betas.sql
Normal file
17
server/migrations/002_channel_betas.sql
Normal file
@@ -0,0 +1,17 @@
|
||||
-- PS_Launcher schema v2
|
||||
-- Ajoute deux flags par-license :
|
||||
-- * channel : nom du manifest à servir (NULL = default = manifest/versions.json)
|
||||
-- * can_see_betas : autorise la license à voir les versions taggées isBeta=true
|
||||
--
|
||||
-- À jouer après 001_init.sql. Idempotent grâce à IF NOT EXISTS (MariaDB 10.4+).
|
||||
-- Pour MySQL/MariaDB plus ancien sans IF NOT EXISTS sur ALTER, voir variante en
|
||||
-- bas (mais OVH mutualisé tourne en MariaDB récent, on devrait être bon).
|
||||
|
||||
ALTER TABLE licenses
|
||||
ADD COLUMN IF NOT EXISTS channel VARCHAR(64) NULL AFTER max_machines,
|
||||
ADD COLUMN IF NOT EXISTS can_see_betas TINYINT(1) NOT NULL DEFAULT 0 AFTER channel,
|
||||
ADD INDEX IF NOT EXISTS idx_channel (channel);
|
||||
|
||||
-- Note : pas de migration de data. Les licenses existantes gardent
|
||||
-- channel = NULL (= manifest default) et can_see_betas = 0 (= pas d'accès aux betas).
|
||||
-- L'admin attribue manuellement les flags via la page licenses.php.
|
||||
@@ -17,9 +17,19 @@ final class SignManifest
|
||||
/** @var string[] */
|
||||
public array $log = [];
|
||||
|
||||
public function __construct(string $rootDir)
|
||||
/**
|
||||
* @param string $rootDir Racine PS_Launcher (parent de api/, manifest/, builds/).
|
||||
* @param string|null $channel Channel à signer. NULL/'' = manifest default
|
||||
* (versions.json). Sinon versions-{channel}.json. Whitelist [a-z0-9_-]{1,64}.
|
||||
* Le hashcache est partagé entre tous les channels (les ZIPs peuvent être
|
||||
* référencés par plusieurs manifests, économise les recalculs).
|
||||
*/
|
||||
public function __construct(string $rootDir, ?string $channel = null)
|
||||
{
|
||||
$this->manifestPath = $rootDir . '/manifest/versions.json';
|
||||
$manifestFile = ($channel !== null && $channel !== '' && preg_match('/^[a-z0-9_-]{1,64}$/', $channel))
|
||||
? "versions-{$channel}.json"
|
||||
: 'versions.json';
|
||||
$this->manifestPath = $rootDir . '/manifest/' . $manifestFile;
|
||||
$this->buildsDir = $rootDir . '/builds';
|
||||
$this->configPath = $rootDir . '/api/config.php';
|
||||
// Cache des hashs déjà calculés. Indexé par chemin absolu du ZIP, contient
|
||||
|
||||
@@ -179,6 +179,11 @@ public partial class App : Application
|
||||
new ManifestService(
|
||||
sp.GetRequiredService<HttpClient>(),
|
||||
() => sp.GetRequiredService<LocalConfig>().ServerBaseUrl,
|
||||
// Channel provider : lit le channel depuis la license cachée à
|
||||
// chaque fetch (sp.GetRequiredService est lazy, donc pas de
|
||||
// dépendance circulaire avec ILicenseService au démarrage).
|
||||
// Si pas de license cachée → null → manifest default.
|
||||
() => sp.GetRequiredService<ILicenseService>().GetCached()?.Channel,
|
||||
sp.GetRequiredService<IManifestCache>(),
|
||||
sp.GetRequiredService<IPeerManifestFetcher>(),
|
||||
sp.GetRequiredService<ILogger<ManifestService>>()));
|
||||
|
||||
@@ -18,9 +18,9 @@
|
||||
<Product>PROSERVE Launcher</Product>
|
||||
<Copyright>© 2026 ASTERION VR — All rights reserved</Copyright>
|
||||
<RootNamespace>PSLauncher.App</RootNamespace>
|
||||
<Version>0.25.10</Version>
|
||||
<AssemblyVersion>0.25.10.0</AssemblyVersion>
|
||||
<FileVersion>0.25.10.0</FileVersion>
|
||||
<Version>0.26.0</Version>
|
||||
<AssemblyVersion>0.26.0.0</AssemblyVersion>
|
||||
<FileVersion>0.26.0.0</FileVersion>
|
||||
|
||||
<!-- Single-file self-contained publish profile (used by `dotnet publish`) -->
|
||||
<PublishSingleFile>true</PublishSingleFile>
|
||||
|
||||
@@ -513,7 +513,17 @@ public sealed partial class MainViewModel : ObservableObject
|
||||
&& oldActive.State is VersionRowState.Downloading or VersionRowState.Verifying;
|
||||
|
||||
var installed = _registry.Scan().ToDictionary(v => v.Version);
|
||||
var remote = _lastManifest?.Versions ?? new List<VersionManifest>();
|
||||
|
||||
// Filtrage BÊTA : si la license n'a pas le flag canSeeBetas, on cache
|
||||
// les versions taggées isBeta=true. Les installations locales déjà
|
||||
// présentes ne sont pas filtrées (un client qui avait installé une
|
||||
// bêta puis perd l'accès continue de la voir et de pouvoir la lancer
|
||||
// — on n'efface pas son disque).
|
||||
var canSeeBetas = _license?.CanSeeBetas ?? false;
|
||||
var rawRemote = _lastManifest?.Versions ?? new List<VersionManifest>();
|
||||
var remote = canSeeBetas
|
||||
? rawRemote
|
||||
: rawRemote.Where(v => !v.IsBeta).ToList();
|
||||
var remoteByVer = remote.ToDictionary(v => v.Version);
|
||||
|
||||
var allVersions = installed.Keys.Union(remoteByVer.Keys)
|
||||
|
||||
@@ -26,6 +26,24 @@ public sealed partial class VersionRowViewModel : ObservableObject
|
||||
public VersionManifest? Remote { get; }
|
||||
public bool HasReleaseNotes => Remote?.ReleaseNotesUrl is not null;
|
||||
|
||||
/// <summary>
|
||||
/// True si la version est taggée BÊTA dans le manifest (champ
|
||||
/// <see cref="VersionManifest.IsBeta"/>). Pilote l'affichage du badge
|
||||
/// orange "BÊTA" + tooltip avec <see cref="BetaNotes"/>.
|
||||
/// </summary>
|
||||
public bool IsBeta => Remote?.IsBeta ?? false;
|
||||
|
||||
/// <summary>
|
||||
/// Note des testeurs (texte libre). Affichée en tooltip quand on hover
|
||||
/// le badge BÊTA. Null si non renseigné côté serveur.
|
||||
/// </summary>
|
||||
public string? BetaNotes => Remote?.BetaNotes;
|
||||
|
||||
/// <summary>Tooltip composé pour le hover sur le badge BÊTA.</summary>
|
||||
public string BetaTooltip => string.IsNullOrEmpty(BetaNotes)
|
||||
? Strings.BetaBadgeTooltipDefault
|
||||
: Strings.BetaBadgeTooltip(BetaNotes!);
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(StateLabel))]
|
||||
[NotifyPropertyChangedFor(nameof(IsBusy))]
|
||||
|
||||
@@ -85,6 +85,17 @@
|
||||
FontSize="15" FontWeight="SemiBold"
|
||||
VerticalAlignment="Center"
|
||||
Foreground="{StaticResource Brush.Text.Primary}" />
|
||||
<!-- Badge BÊTA pour les rows secondaires (compact). Même look
|
||||
que celui du featured mais en plus petit pour matcher la row. -->
|
||||
<Border CornerRadius="8" Padding="6,2" Margin="10,0,0,0"
|
||||
VerticalAlignment="Center"
|
||||
Background="#F59E0B"
|
||||
ToolTip="{Binding BetaTooltip}"
|
||||
Visibility="{Binding IsBeta, Converter={StaticResource BoolToVisibility}}">
|
||||
<TextBlock Text="{x:Static loc:Strings.BetaBadgeLabel}"
|
||||
FontSize="10" FontWeight="Bold"
|
||||
Foreground="#1A0A00" />
|
||||
</Border>
|
||||
<Border CornerRadius="10" Padding="8,3" Margin="12,0,0,0"
|
||||
VerticalAlignment="Center">
|
||||
<Border.Style>
|
||||
@@ -472,6 +483,18 @@
|
||||
<TextBlock Text="{Binding FeaturedVersion.Version, StringFormat='PROSERVE v{0}'}"
|
||||
FontSize="32" FontWeight="Bold"
|
||||
Foreground="{StaticResource Brush.Text.Primary}" />
|
||||
<!-- Badge BÊTA : pill orange à côté de la version. Visible
|
||||
uniquement quand isBeta=true dans le manifest. Tooltip
|
||||
dynamique avec les notes des testeurs s'il y en a. -->
|
||||
<Border CornerRadius="10" Padding="8,3" Margin="12,8,0,0"
|
||||
VerticalAlignment="Center"
|
||||
Background="#F59E0B"
|
||||
ToolTip="{Binding FeaturedVersion.BetaTooltip}"
|
||||
Visibility="{Binding FeaturedVersion.IsBeta, Converter={StaticResource BoolToVisibility}}">
|
||||
<TextBlock Text="{x:Static loc:Strings.BetaBadgeLabel}"
|
||||
FontSize="11" FontWeight="Bold"
|
||||
Foreground="#1A0A00" />
|
||||
</Border>
|
||||
<Border CornerRadius="12" Padding="10,4" Margin="16,8,0,0"
|
||||
VerticalAlignment="Center">
|
||||
<Border.Style>
|
||||
|
||||
@@ -310,7 +310,10 @@ public sealed class LicenseService : ILicenseService
|
||||
/// </summary>
|
||||
public static byte[] CanonicalBytesFor(LicenseValidationResponse response)
|
||||
{
|
||||
// On reconstruit un dictionnaire ordonné comme côté PHP, sans signature
|
||||
// On reconstruit un dictionnaire ordonné comme côté PHP, sans signature.
|
||||
// ATTENTION : ordre CRITIQUE — Crypto::canonicalJson côté PHP ne trie pas
|
||||
// les clés, il prend l'ordre du tableau associatif. Toute modif ici doit
|
||||
// être miroir exact du dictionnaire PHP dans ValidateLicense.php.
|
||||
var dict = new Dictionary<string, object?>
|
||||
{
|
||||
["status"] = response.Status,
|
||||
@@ -319,6 +322,8 @@ public sealed class LicenseService : ILicenseService
|
||||
["issuedAt"] = FormatDateAtom(response.IssuedAt),
|
||||
["downloadEntitlementUntil"] = FormatDateAtom(response.DownloadEntitlementUntil),
|
||||
["maxMachines"] = response.MaxMachines,
|
||||
["channel"] = response.Channel, // null si default
|
||||
["canSeeBetas"] = response.CanSeeBetas,
|
||||
["serverTime"] = FormatDateAtom(response.ServerTime),
|
||||
};
|
||||
var opts = new JsonSerializerOptions
|
||||
@@ -347,8 +352,18 @@ public sealed class LicenseService : ILicenseService
|
||||
var pkBytes = Convert.FromHexString(publicKeyHex);
|
||||
var pk = PublicKey.Import(alg, pkBytes, KeyBlobFormat.RawPublicKey);
|
||||
var sig = Convert.FromBase64String(base64Signature);
|
||||
var payload = CanonicalBytesFor(response);
|
||||
return alg.Verify(pk, payload, sig);
|
||||
|
||||
// Tente d'abord le canonical actuel (avec channel + canSeeBetas).
|
||||
// Si ça échoue, tente le canonical legacy (sans ces champs) — c'est
|
||||
// le format des réponses signées par les serveurs OU lancées par
|
||||
// les clients d'avant v0.26. Sans ce fallback, toutes les licenses
|
||||
// cachées localement avant l'update à v0.26 deviendraient invalides
|
||||
// et l'utilisateur serait forcé de revalider online — bloqué hors-ligne.
|
||||
var payloadCurrent = CanonicalBytesFor(response);
|
||||
if (alg.Verify(pk, payloadCurrent, sig)) return true;
|
||||
|
||||
var payloadLegacy = CanonicalBytesForLegacy(response);
|
||||
return alg.Verify(pk, payloadLegacy, sig);
|
||||
}
|
||||
catch
|
||||
{
|
||||
@@ -356,6 +371,33 @@ public sealed class LicenseService : ILicenseService
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Canonical historique (avant v0.26.0) : pas de <c>channel</c> ni
|
||||
/// <c>canSeeBetas</c>. Conservé pour valider les réponses cachées par
|
||||
/// les anciennes versions du client. Une fois que toutes les licenses
|
||||
/// auront été re-validées online avec le nouveau serveur, on pourra
|
||||
/// retirer cette méthode (mais on prévient pas de coût à la garder).
|
||||
/// </summary>
|
||||
private static byte[] CanonicalBytesForLegacy(LicenseValidationResponse response)
|
||||
{
|
||||
var dict = new Dictionary<string, object?>
|
||||
{
|
||||
["status"] = response.Status,
|
||||
["licenseId"] = response.LicenseId,
|
||||
["ownerName"] = response.OwnerName,
|
||||
["issuedAt"] = FormatDateAtom(response.IssuedAt),
|
||||
["downloadEntitlementUntil"] = FormatDateAtom(response.DownloadEntitlementUntil),
|
||||
["maxMachines"] = response.MaxMachines,
|
||||
["serverTime"] = FormatDateAtom(response.ServerTime),
|
||||
};
|
||||
var opts = new JsonSerializerOptions
|
||||
{
|
||||
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.Never,
|
||||
};
|
||||
return JsonSerializer.SerializeToUtf8Bytes(dict, opts);
|
||||
}
|
||||
|
||||
// ----- Public key embarquée -----
|
||||
|
||||
private static string? TryReadEmbeddedPublicKey()
|
||||
|
||||
@@ -97,6 +97,28 @@ public static class Strings
|
||||
public static string StatusUninstalling => T("🗑 Suppression…", "🗑 Uninstalling…", "🗑 卸载中…", "🗑 กำลังถอนการติดตั้ง…", "🗑 جارٍ الإزالة…");
|
||||
public static string StatusVerifying => T("🔍 Vérification…", "🔍 Verifying…", "🔍 校验中…", "🔍 กำลังตรวจสอบ…", "🔍 جارٍ التحقق…");
|
||||
|
||||
// ==================== BETA BADGE ====================
|
||||
/// <summary>Texte affiché dans la pill orange à côté de la version. Court et localisé.</summary>
|
||||
public static string BetaBadgeLabel => T("BÊTA", "BETA", "测试版", "เบต้า", "تجريبي");
|
||||
|
||||
/// <summary>Tooltip par défaut quand la version est tag BÊTA mais sans note des testeurs.</summary>
|
||||
public static string BetaBadgeTooltipDefault => T(
|
||||
"Version BÊTA — utilisée pour les tests internes. Vos retours nous aident à la valider avant publication officielle.",
|
||||
"BETA version — used for internal testing. Your feedback helps us validate it before official release.",
|
||||
"测试版 — 用于内部测试。您的反馈有助于我们在正式发布前验证此版本。",
|
||||
"เวอร์ชันเบต้า — ใช้สำหรับการทดสอบภายใน ความคิดเห็นของคุณช่วยให้เรายืนยันก่อนการเปิดตัวอย่างเป็นทางการ",
|
||||
"إصدار تجريبي — يُستخدم للاختبار الداخلي. ملاحظاتك تساعدنا في التحقق منه قبل الإصدار الرسمي."
|
||||
);
|
||||
|
||||
/// <summary>Tooltip avec les notes spécifiques fournies par l'admin côté serveur.</summary>
|
||||
public static string BetaBadgeTooltip(string testerNotes) => T(
|
||||
$"Version BÊTA — {testerNotes}",
|
||||
$"BETA version — {testerNotes}",
|
||||
$"测试版 — {testerNotes}",
|
||||
$"เวอร์ชันเบต้า — {testerNotes}",
|
||||
$"إصدار تجريبي — {testerNotes}"
|
||||
);
|
||||
|
||||
// ==================== ACTIONS ====================
|
||||
public static string ActionLaunch => T("▶ Lancer", "▶ Launch", "▶ 启动", "▶ เปิด", "▶ تشغيل");
|
||||
public static string ActionLaunchBig => T("▶ LANCER", "▶ LAUNCH", "▶ 启动", "▶ เปิด", "▶ تشغيل");
|
||||
|
||||
@@ -17,6 +17,7 @@ public sealed class ManifestService : IManifestService
|
||||
|
||||
private readonly HttpClient _http;
|
||||
private readonly Func<string> _serverBaseUrlProvider;
|
||||
private readonly Func<string?> _channelProvider;
|
||||
private readonly IManifestCache _manifestCache;
|
||||
private readonly IPeerManifestFetcher _peerManifestFetcher;
|
||||
private readonly ILogger<ManifestService> _logger;
|
||||
@@ -26,12 +27,14 @@ public sealed class ManifestService : IManifestService
|
||||
public ManifestService(
|
||||
HttpClient http,
|
||||
Func<string> serverBaseUrlProvider,
|
||||
Func<string?> channelProvider,
|
||||
IManifestCache manifestCache,
|
||||
IPeerManifestFetcher peerManifestFetcher,
|
||||
ILogger<ManifestService> logger)
|
||||
{
|
||||
_http = http;
|
||||
_serverBaseUrlProvider = serverBaseUrlProvider;
|
||||
_channelProvider = channelProvider;
|
||||
_manifestCache = manifestCache;
|
||||
_peerManifestFetcher = peerManifestFetcher;
|
||||
_logger = logger;
|
||||
@@ -116,7 +119,15 @@ public sealed class ManifestService : IManifestService
|
||||
|
||||
private async Task<string> FetchFromOvhAsync(CancellationToken ct)
|
||||
{
|
||||
var url = TrimSlash(_serverBaseUrlProvider()) + "/manifest";
|
||||
// Channel = manifest filter par-license. NULL ou vide = on appelle
|
||||
// /manifest tout court (= versions.json default). Sinon /manifest?channel=X
|
||||
// (le serveur charge versions-{X}.json avec fallback sur default si fichier absent).
|
||||
// Whitelist côté client aussi pour éviter des chars exotiques qui casseraient l'URL.
|
||||
var channel = _channelProvider();
|
||||
var baseUrl = TrimSlash(_serverBaseUrlProvider()) + "/manifest";
|
||||
var url = !string.IsNullOrWhiteSpace(channel) && System.Text.RegularExpressions.Regex.IsMatch(channel!, "^[a-z0-9_-]{1,64}$")
|
||||
? $"{baseUrl}?channel={Uri.EscapeDataString(channel!)}"
|
||||
: baseUrl;
|
||||
using var ovhCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
ovhCts.CancelAfter(OvhFetchTimeoutMs);
|
||||
using var req = new HttpRequestMessage(HttpMethod.Get, url);
|
||||
|
||||
@@ -22,6 +22,24 @@ public sealed class LicenseValidationResponse
|
||||
[JsonPropertyName("maxMachines")]
|
||||
public int? MaxMachines { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Channel de manifest associé à la license. NULL = manifest default
|
||||
/// (versions.json). Sinon le launcher fetch versions-{Channel}.json. Le
|
||||
/// champ est inclus dans la signature Ed25519, donc un user ne peut pas
|
||||
/// modifier sa réponse cachée pour basculer sur un autre channel.
|
||||
/// </summary>
|
||||
[JsonPropertyName("channel")]
|
||||
public string? Channel { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// True si la license autorise l'affichage des versions taggées
|
||||
/// <c>isBeta=true</c> dans le launcher. Le filtrage est côté client (le
|
||||
/// serveur peut servir le manifest complet — la signature Ed25519
|
||||
/// protège contre toute modif locale du flag).
|
||||
/// </summary>
|
||||
[JsonPropertyName("canSeeBetas")]
|
||||
public bool CanSeeBetas { get; set; }
|
||||
|
||||
[JsonPropertyName("serverTime")]
|
||||
public DateTime? ServerTime { get; set; }
|
||||
|
||||
|
||||
@@ -70,6 +70,22 @@ public sealed class VersionManifest
|
||||
[JsonPropertyName("availableForDownload")]
|
||||
public bool AvailableForDownload { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Si true, version taggée BÊTA — affichage d'un badge orange dans le
|
||||
/// launcher + filtrage : seules les licenses avec <c>canSeeBetas=true</c>
|
||||
/// voient cette version, les autres l'ignorent (dans <c>RebuildList</c>).
|
||||
/// Default false → comportement standard (visible par tous).
|
||||
/// </summary>
|
||||
[JsonPropertyName("isBeta")]
|
||||
public bool IsBeta { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Note libre destinée aux testeurs, affichée en tooltip quand on hover le
|
||||
/// badge BÊTA. Optionnel.
|
||||
/// </summary>
|
||||
[JsonPropertyName("betaNotes")]
|
||||
public string? BetaNotes { get; set; }
|
||||
|
||||
[JsonPropertyName("heroImageUrl")]
|
||||
public string? HeroImageUrl { get; set; }
|
||||
|
||||
|
||||
Reference in New Issue
Block a user