Compare commits

..

13 Commits

Author SHA1 Message Date
aab2e41152 Admin licenses : bouton BÊTA visible directement, plus dans un menu replié
Avant : pour basculer l'accès BÊTA d'un client, il fallait deviner qu'il
fallait cliquer sur le code <channel> dans la cellule pour ouvrir un
<details> caché — pas de chevron, pas de label « modifier ». L'admin
trouvait pas l'option et pensait qu'elle n'existait pas.

Après : sur la page Licenses, chaque ligne affiche directement deux
boutons cliquables :
- « Activer BÊTA » / « ✓ BÊTA actif » (bouton secondary / warning selon
  l'état) — toggle en un clic sans menu
- « ✎ Channel » qui déplie le dropdown pour changer le channel

L'état actuel reste visible au-dessus (code du channel + badge β BÊTA
si actif). Beaucoup plus discoverable.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 09:55:22 +02:00
d9c3f09e9a v0.27.2 — Persiste channel + canSeeBetas dans le cache license
Bug : le LicenseValidationResponse réhydraté par GetCached() ne contenait
plus les champs Channel et CanSeeBetas (introduits en v0.26.0). Cause :
LocalConfig.LicenseConfig ne persistait pas ces deux champs, donc à chaque
relance du launcher (ou simple GetCached), Channel retombait à null et
CanSeeBetas à false.

Conséquence visible : un client avec license channel='firefighter'
côté serveur fetchait toujours /api/manifest (sans ?channel=), recevait
le manifest default-only, et ne voyait jamais le build firefighter même
après re-validation de license.

Fix :
- Ajout des champs CachedChannel + CachedCanSeeBetas sur LicenseConfig
  (persistés dans %LocalAppData%\PSLauncher\config.json)
- SaveCached() écrit ces champs depuis la réponse signée du serveur
- GetCached() les restaure dans la LicenseValidationResponse renvoyée

Migration silencieuse : un launcher pre-v0.27.2 a CachedChannel=null
et CachedCanSeeBetas=false dans son config.json. Au prochain Vérifier
les MAJ, RefreshLicenseFromServerAsync re-valide la license, le serveur
renvoie les valeurs courantes, SaveCached les persiste. La fois d'après
le launcher fetch correctement avec ?channel=X.

À tester chez l'utilisateur :
1. Recompiler + réinstaller le launcher en v0.27.2
2. Settings → Vérifier les MAJ (force la revalidation license)
3. Vérifier %LocalAppData%\PSLauncher\config.json :
   "CachedChannel": "firefighter" doit apparaître
4. Le manifest-cache.json devrait alors contenir le bon ZIP

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 09:37:19 +02:00
67678fe173 Manifest : dédup par version, l'entry channel-spécifique gagne sur default
Cas reporté : un client firefighter recevait DEUX entries v1.5.3 (default
+ firefighter) parce que la sémantique additive est correcte pour la
visibilité, mais ne dédupe pas. Le launcher faisait alors un
ToDictionary(v => v.Version) qui collisionne sur "1.5.3" et garde
silencieusement le premier (la default), donc le mauvais ZIP s'affichait
dans la UI.

Fix : Manifest.php groupe par version après le filtre, et pour chaque
groupe à plusieurs entries pick l'entry la plus spécifique au client :
1. priorité à celle taggée avec le channel propre du client (firefighter)
2. sinon l'entry default sert de fallback

Sémantique pour un user firefighter :
- v1.5.3 (default) + v1.5.3 (firefighter) → renvoie uniquement firefighter
- v1.4.0 (default uniquement) → renvoie default (visible)
- v1.5.3 (firefighter uniquement) → renvoie firefighter

Fix purement server-side, pas de modif client requise. Le launcher
recevra naturellement un seul ZIP par version, le bon.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 09:30:34 +02:00
38194c6f0c Fix : require Crypto.php pour la route manifest + accepter id v8hex pour releasenotes
Deux bugs introduits par v0.27.x dans api/index.php :

1. La route /api/manifest ne require pas lib/Crypto.php → erreur 500
   « Class PSLauncher\Crypto not found » dès qu'on tente la re-signature
   du manifest filtré (depuis v0.27.0). Conséquence : le client voit
   tout cassé, pas seulement les versions filtrées.

2. La regex de route /api/releasenotes/X.Y.Z n'accepte que les versions
   au format SemVer. Avec v0.27.1, les release notes sont addressables
   par id stable (v + 8 hex chars), donc le manifest pointe vers
   /api/releasenotes/va3f2c891 qui matchait pas la regex → 404.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 09:12:55 +02:00
72f99e0c56 v0.27.1 — Versions : permettre numéro identique sur channels différents
Cas d'usage rapporté : « je peux avoir une version 1.5.3 pour les
pompiers et une version 1.5.3 pour la police ». Le check anti-doublon
sur le numéro de version bloquait la création — c'était un faux check
puisque la duplication SUR DES CHANNELS DIFFÉRENTS est légitime.

Changements :

1. ID stable interne : chaque entry de version reçoit un `id` immutable
   généré à la création (`v` + 8 hex aléatoires). Ce id devient l'identifiant
   primaire dans les forms et URLs admin. Le numéro de version reste un
   libellé human-readable, possiblement dupliqué.

2. Migration douce : loadManifest() ajoute un id aux entrées qui n'en
   ont pas, et auto-save pour persister. Stable aux reloads suivants.
   Les manifests legacy fonctionnent sans aucune action manuelle.

3. Check anti-doublon déplacé sur le NOM DE ZIP au lieu du numéro de
   version. Deux entries v1.5.3 sont autorisées si elles pointent vers
   des ZIPs différents (proserve-1.5.3-police.zip + proserve-1.5.3-pompier.zip).
   Une vraie ambiguïté sur disque (deux entries → même ZIP) est rejetée.

4. Toutes les actions row-level (edit_meta, edit_notes, set_beta,
   set_channels, toggle_available, delete, set_skip_hash, sync_one)
   identifient l'entry par `id` au lieu de `version`. Forms HTML mis
   à jour en conséquence.

5. Release notes : addressables par id stable. Stockées en
   releasenotes/{id}.md. Compat ascendante : si un fichier {id}.md
   n'existe pas, l'admin lit le legacy {version}.md (pour les notes
   créées avant v0.27.1). L'endpoint API releasenotes/{key} accepte
   les deux formats.

6. Suppression d'une entry : nettoie le fichier {id}.md correspondant.

7. SignManifest::run filtre toujours par version string : si plusieurs
   entries partagent un numéro de version, toutes sont re-hashées via
   « 🔁 Hash » sur une row. Comportement correct (chaque entry a sa
   propre URL/ZIP, donc son hash spécifique), juste moins efficient.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 09:02:00 +02:00
49a3b855af 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>
2026-05-05 08:51:10 +02:00
0c69bb1c85 Fix : badge 'absent' faux positif sur ZIPs en sous-dossier de channel
Le commit 7ff5e56 avait changé $zips pour stocker des chemins relatifs
(asterion-vr/proserve-1.5.3.zip) afin de lister les ZIPs en sous-dossier,
mais le check par-row utilisait toujours basename(URL) → comparait
"proserve-1.5.3.zip" contre ["asterion-vr/proserve-1.5.3.zip"], donc
in_array() retournait toujours false → badge "absent" alors que le ZIP
était bien là.

Fix : on compute zipRel depuis l'URL de la même façon que $zips (extrait
tout après /builds/), pour que les deux côtés du in_array() utilisent le
même format. Le tooltip "attendu :" affiche aussi le chemin relatif
complet, c'est plus clair pour le SFTP.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 08:37:35 +02:00
7638a7a25c Admin versions : nom du ZIP libre + renommage post-création
À l'ajout d'une version, nouveau champ "Nom du fichier ZIP (optionnel)" :
par défaut proserve-{version}.zip mais l'admin peut surcharger pour
distinguer des builds homonymes entre channels :
  builds/asterion-vr/proserve-1.4.6-asterion.zip
  builds/client-foo/proserve-1.4.6-foo.zip

Sur les versions existantes, la dialog "Méta" gagne un champ "Renommer
le ZIP" qui :
- ne touche que le filename, garde le préfixe builds/{channel}/
- invalide le sha256 (REPLACE_AFTER_BUILD) + sizeBytes pour forcer le
  recalcul au prochain Sync — le ZIP physique change, le manifest
  refléterait sinon l'ancien hash et le client ferait fail la vérif.

Normalisation côté serveur via ps_normalize_zip_filename() :
- basename() pour empêcher tout path traversal (../)
- whitelist [a-zA-Z0-9_.-] (laisse les majuscules, points, tirets)
- auto-suffix .zip si manquant
- fallback sur proserve-{version}.zip si l'input devient vide après nettoyage

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 08:19:24 +02:00
7ff5e56102 Fix : SignManifest et listing ZIPs supportent les sous-dossiers de channel
v0.26.0 générait des URLs builds/{channel}/proserve-X.Y.Z.zip dans le
manifest pour les channels non-default, mais SignManifest faisait juste
basename(url) → cherchait builds/proserve-X.Y.Z.zip à plat → ZIP introuvable
au moment du Sync.

Fixes :

1. SignManifest::resolveZipPath() : extrait tout le chemin après /builds/
   dans l'URL et le mappe sur {$buildsDir}/. Strip ../ pour anti-traversal
   même si le manifest est de toute façon signé Ed25519.

2. SignManifest fallback fuzzy étendu à 1 niveau de sous-dossier (glob */*.zip)
   pour le cas "admin a renommé le ZIP".

3. admin/versions.php $zips inclut maintenant builds/ + builds/*/ et stocke
   les chemins relatifs (asterion-vr/proserve-X.Y.Z.zip) au lieu du basename.
   Le check "référencé ?" matche aussi sur le chemin relatif.

4. Workflow text en haut de la page indique le bon chemin SFTP selon le
   channel actif (avec rappel "crée le sous-dossier si absent").

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 08:14:02 +02:00
95c70903e7 Fix : 'Aller' silencieux sur création de channel + dropdown vide
Deux bugs UX rapportés sur v0.26.0 admin/versions.php :

1. Cliquer 'Aller' avec un nom contenant majuscules / espaces / accents
   ne faisait RIEN. Cause : pattern HTML5 strict (`[a-z0-9_-]{1,64}`) qui
   bloque la submission du form GET sans message visible. Selon le browser
   le tooltip de validation est barely visible, donc le user a l'impression
   que le bouton est cassé.

   Fix : retire le pattern strict, normalise côté serveur :
   « ASTERION VR » → « asterion-vr », « éàç!? » → « » (et on dit pourquoi).
   Flash success qui annonce la normalisation, ou flash error si le nom
   est inexploitable après nettoyage.

2. Après création d'un channel, le dropdown affichait encore '(default)'
   parce que listExistingChannels() filtre sur les versions-*.json
   existants et l'admin n'a pas encore ajouté de version donc le fichier
   n'existe pas. Du coup l'admin pensait que la création n'avait pas
   marché alors que la session était bien sur le nouveau channel.

   Fix : injection forcée du channel actif dans le tableau des options du
   dropdown avec label « X (vide — pas encore de versions) ».

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 08:09:59 +02:00
2b95472393 Fix : 500 sur admin/versions.php (PHP imbriqué)
Mon edit v0.26.0 avait laissé un <?php à l'intérieur d'un bloc PHP déjà
ouvert. Erreur de syntaxe → 500. Refactor pour fermer le bloc PHP
proprement après Layout::header() avant de basculer sur le HTML.

Vérifié avec php -l (XAMPP) : « No syntax errors detected ».

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 08:06:12 +02:00
9499c6ae58 Migration 002 : version portable MariaDB pré-10.0.2 (OVH mutualisé)
ALTER TABLE ... IF NOT EXISTS n'est dispo qu'à partir de MariaDB 10.0.2.
OVH mutualisé tourne sur du plus ancien → erreur 1064 syntaxe.

Remplacement par une PROCEDURE temporaire qui consulte INFORMATION_SCHEMA
avant chaque ALTER. Marche sur toutes versions MariaDB / MySQL 5.5+ et
reste idempotent.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 07:55:58 +02:00
c371a79f93 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>
2026-05-05 07:38:00 +02:00
22 changed files with 1215 additions and 98 deletions

View File

@@ -11,7 +11,7 @@
#define MyAppName "PROSERVE Launcher"
#define MyAppShortName "PS_Launcher"
#define MyAppVersion "0.25.10"
#define MyAppVersion "0.27.2"
#define MyAppPublisher "ASTERION VR"
#define MyAppURL "https://asterionvr.com"
#define MyAppExeName "PS_Launcher.exe"

231
server/admin/channels.php Normal file
View File

@@ -0,0 +1,231 @@
<?php
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';
$manifestDir = dirname(__DIR__) . '/manifest';
$message = null; $messageType = 'success';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
Auth::checkCsrf();
$action = $_POST['action'] ?? '';
try {
$registry = Channels::load($manifestDir);
if ($action === 'create') {
$rawName = (string)($_POST['name'] ?? '');
$name = Channels::normalizeName($rawName);
$label = trim((string)($_POST['label'] ?? '')) ?: $name;
$desc = trim((string)($_POST['description'] ?? '')) ?: null;
if (!Channels::isValidName($name)) {
throw new \RuntimeException("Nom invalide après normalisation : « {$rawName} » → « {$name} ». Saisis quelque chose comme « asterion-vr » ou « police ».");
}
if ($name === 'default') {
throw new \RuntimeException("Le channel « default » est implicite, on ne peut pas en créer un autre.");
}
foreach ($registry['channels'] as $c) {
if (($c['name'] ?? '') === $name) {
throw new \RuntimeException("Channel « {$name} » existe déjà.");
}
}
$entry = ['name' => $name, 'label' => $label];
if ($desc !== null) $entry['description'] = $desc;
$registry['channels'][] = $entry;
Channels::save($manifestDir, $registry);
$message = "Channel « {$name} » créé.";
if ($name !== $rawName) {
$message .= " (Nom normalisé depuis « {$rawName} ».)";
}
}
elseif ($action === 'edit') {
$name = trim((string)($_POST['name'] ?? ''));
$label = trim((string)($_POST['label'] ?? '')) ?: $name;
$desc = trim((string)($_POST['description'] ?? '')) ?: null;
$found = false;
foreach ($registry['channels'] as &$c) {
if (($c['name'] ?? '') === $name) {
$c['label'] = $label;
if ($desc === null) unset($c['description']);
else $c['description'] = $desc;
$found = true;
break;
}
}
unset($c);
if (!$found) throw new \RuntimeException("Channel « {$name} » introuvable.");
Channels::save($manifestDir, $registry);
$message = "Channel « {$name} » mis à jour.";
}
elseif ($action === 'delete') {
$name = trim((string)($_POST['name'] ?? ''));
if ($name === 'default') throw new \RuntimeException("Le channel « default » ne peut pas être supprimé.");
// Sécurité : empêche la suppression si des versions ou licenses
// référencent encore ce channel — sinon ces refs deviennent silencieusement
// pendantes et l'admin oublie pourquoi tel client ne voit rien.
$usage = checkChannelUsage($manifestDir, $name);
if ($usage['versions'] > 0 || $usage['licenses'] > 0) {
throw new \RuntimeException(
"Impossible de supprimer « {$name} » : "
. "{$usage['versions']} version(s) taggée(s) et {$usage['licenses']} license(s) attribuée(s) à ce channel. "
. "Détague d'abord les versions (page Versions) et change les licenses concernées (page Licenses)."
);
}
$registry['channels'] = array_values(array_filter(
$registry['channels'],
fn($c) => ($c['name'] ?? '') !== $name
));
Channels::save($manifestDir, $registry);
$message = "Channel « {$name} » supprimé.";
}
} catch (\Throwable $e) {
$message = $e->getMessage();
$messageType = 'error';
}
}
/**
* Compte les versions et licenses qui pointent encore sur un channel.
* Sert de garde-fou avant suppression.
*/
function checkChannelUsage(string $manifestDir, string $channelName): array
{
$usage = ['versions' => 0, 'licenses' => 0];
$manifestPath = $manifestDir . '/versions.json';
if (is_file($manifestPath)) {
$m = json_decode(file_get_contents($manifestPath), true);
if (is_array($m) && isset($m['versions'])) {
foreach ($m['versions'] as $v) {
$channels = $v['channels'] ?? [];
if (in_array($channelName, $channels, true)) $usage['versions']++;
}
}
}
try {
$config = require __DIR__ . '/../api/config.php';
$db = \PSLauncher\Db::get($config);
$stmt = $db->prepare('SELECT COUNT(*) FROM licenses WHERE channel = ?');
$stmt->execute([$channelName]);
$usage['licenses'] = (int)$stmt->fetchColumn();
} catch (\Throwable) { /* DB not reachable from this context, best effort */ }
return $usage;
}
require_once __DIR__ . '/../api/lib/Db.php';
$registry = Channels::load($manifestDir);
Layout::header('Channels', 'channels');
?>
<h1>Channels de distribution</h1>
<?php Layout::flash($message, $messageType); ?>
<div class="card">
<h2>Comment ça marche</h2>
<ul class="muted">
<li><strong>default</strong> = channel public implicite. Toutes les versions taggées « default » (ou sans tag) sont visibles par tous les clients, peu importe leur license.</li>
<li>Les autres channels (<strong>asterion-vr</strong>, <strong>police</strong>, …) sont des canaux privés. Une version taggée uniquement sur un channel privé n'est vue QUE par les licenses attribuées à ce channel.</li>
<li>Une version peut être taggée sur plusieurs channels en même temps (multi-channel). Une license n'est attribuée qu'à un seul channel à la fois.</li>
<li>Sémantique additive : un client sur le channel « police » voit les versions « default » + les versions « police ». Pour rendre une version invisible au public, ne la tague PAS « default ».</li>
</ul>
</div>
<div class="card">
<h2>Créer un channel</h2>
<form method="post">
<?= Layout::csrfField() ?>
<input type="hidden" name="action" value="create">
<div class="row">
<div class="col field">
<label>Nom (identifiant ASCII)</label>
<input type="text" name="name" required maxlength="80" placeholder="asterion-vr"
title="Sera normalisé en lowercase + tirets côté serveur (« ASTERION VR » → « asterion-vr »).">
</div>
<div class="col field">
<label>Label (affiché dans les UI)</label>
<input type="text" name="label" maxlength="120" placeholder="ASTERION VR (interne)">
</div>
</div>
<div class="field">
<label>Description (optionnel)</label>
<input type="text" name="description" maxlength="255" placeholder="Builds internes pour l'équipe ASTERION">
</div>
<button class="btn btn-success" type="submit">Créer</button>
</form>
</div>
<div class="card">
<h2>Channels existants</h2>
<table>
<thead>
<tr>
<th>Nom</th>
<th>Label</th>
<th>Description</th>
<th style="text-align:right">Actions</th>
</tr>
</thead>
<tbody>
<?php foreach ($registry['channels'] as $c):
$isDefault = ($c['name'] ?? '') === 'default';
?>
<tr>
<td>
<code><?= htmlspecialchars($c['name'] ?? '') ?></code>
<?php if ($isDefault): ?>
<span class="badge badge-success" style="margin-left: 6px;" title="Channel implicite, immutable">implicite</span>
<?php endif; ?>
</td>
<td><?= htmlspecialchars($c['label'] ?? $c['name'] ?? '') ?></td>
<td class="muted"><?= htmlspecialchars($c['description'] ?? '') ?></td>
<td style="text-align:right; white-space: nowrap;">
<details style="display: inline-block; margin: 0 4px;">
<summary class="btn btn-secondary">Éditer</summary>
<form method="post" style="margin-top: 8px; min-width: 320px;">
<?= Layout::csrfField() ?>
<input type="hidden" name="action" value="edit">
<input type="hidden" name="name" value="<?= htmlspecialchars($c['name'] ?? '') ?>">
<div class="field">
<label>Label</label>
<input type="text" name="label" value="<?= htmlspecialchars($c['label'] ?? '') ?>" required>
</div>
<div class="field">
<label>Description</label>
<input type="text" name="description" value="<?= htmlspecialchars($c['description'] ?? '') ?>">
</div>
<button class="btn btn-primary" type="submit">Enregistrer</button>
</form>
</details>
<?php if (!$isDefault): ?>
<form method="post" style="display:inline" onsubmit="return confirm('Supprimer le channel <?= htmlspecialchars($c['name']) ?> ?\nLes versions et licenses qui le référencent doivent être détaguées d\'abord.')">
<?= Layout::csrfField() ?>
<input type="hidden" name="action" value="delete">
<input type="hidden" name="name" value="<?= htmlspecialchars($c['name'] ?? '') ?>">
<button class="btn btn-danger" type="submit">Supprimer</button>
</form>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php Layout::footer();

View File

@@ -0,0 +1,96 @@
<?php
declare(strict_types=1);
namespace PSLauncher\Admin;
/**
* Registre des channels de distribution. Stocké dans manifest/channels.json
* pour rester portable / pas dépendre de la DB pour cette config simple.
*
* Une entrée a 3 champs :
* - name : identifiant ASCII utilisé en code partout (whitelist a-z0-9_-)
* - label : libellé human-friendly affiché dans les UI admin
* - description : contexte libre pour l'admin (optionnel)
*
* Le channel "default" est implicite et toujours présent : il représente les
* versions publiques (vues par toutes les licenses, peu importe leur tag). Les
* versions sans tag explicite sont implicitement dans "default".
*
* Les licenses pointent vers UN channel — elles voient les versions taggées
* avec ce channel ET celles taggées "default" (sémantique additive).
*/
final class Channels
{
/** @return array{channels: array<int, array{name:string,label:string,description?:string}>} */
public static function load(string $manifestDir): array
{
$path = $manifestDir . '/channels.json';
$base = ['channels' => []];
if (is_file($path)) {
$raw = file_get_contents($path);
$json = json_decode((string)$raw, true);
if (is_array($json) && isset($json['channels']) && is_array($json['channels'])) {
$base['channels'] = $json['channels'];
}
}
// Garantit que "default" est toujours présent en tête (immutable depuis la UI).
$hasDefault = false;
foreach ($base['channels'] as $c) {
if (($c['name'] ?? '') === 'default') { $hasDefault = true; break; }
}
if (!$hasDefault) {
array_unshift($base['channels'], [
'name' => 'default',
'label' => 'Public',
'description' => 'Versions publiques visibles par tous les clients',
]);
}
return $base;
}
public static function save(string $manifestDir, array $registry): void
{
$path = $manifestDir . '/channels.json';
if (!is_dir($manifestDir)) mkdir($manifestDir, 0755, true);
file_put_contents(
$path,
json_encode($registry, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . "\n"
);
}
/**
* Retourne true si <code>$name</code> est un identifiant de channel valide :
* lowercase, chiffres, tirets et underscores, 1 à 64 caractères.
*/
public static function isValidName(string $name): bool
{
return (bool)preg_match('/^[a-z0-9_-]{1,64}$/', $name);
}
/**
* Normalise un nom saisi par l'admin : lowercase, espaces → tirets, retire
* les chars hors whitelist, compresse les tirets multiples, trim.
* Identique à ps_normalize_channel() dans versions.php — centralisé ici
* pour les futurs ajouts.
*/
public static function normalizeName(string $raw): string
{
$s = strtolower(trim($raw));
$s = (string)preg_replace('/[\s\x{2010}-\x{2015}]+/u', '-', $s);
$s = (string)preg_replace('/[^a-z0-9_-]/', '', $s);
$s = (string)preg_replace('/-+/', '-', $s);
$s = trim($s, '-_');
return substr($s, 0, 64);
}
/**
* @return list<string> Liste des noms de channel pour les dropdowns.
*/
public static function listNames(string $manifestDir): array
{
$reg = self::load($manifestDir);
return array_map(fn($c) => (string)($c['name'] ?? ''), $reg['channels']);
}
}

View File

@@ -38,6 +38,7 @@ HTML;
'dashboard' => ['index.php', 'Dashboard'],
'licenses' => ['licenses.php', 'Licenses'],
'versions' => ['versions.php', 'Versions'],
'channels' => ['channels.php', 'Channels'],
'launcher' => ['launcher.php', 'Launcher'],
'audit' => ['audit.php', 'Audit'],
];

View File

@@ -2,10 +2,12 @@
declare(strict_types=1);
require __DIR__ . '/lib/Auth.php';
require __DIR__ . '/lib/Layout.php';
require __DIR__ . '/lib/Channels.php';
require __DIR__ . '/../api/lib/Db.php';
use PSLauncher\Admin\Auth;
use PSLauncher\Admin\Layout;
use PSLauncher\Admin\Channels;
use PSLauncher\Db;
Auth::requireLogin();
@@ -27,6 +29,25 @@ function generateLicenseKey(): string
return 'PRSRV-' . implode('-', $groups);
}
/**
* Liste les channels disponibles depuis le registre channels.json. Le channel
* « default » apparaît avec une clé vide (= NULL en DB → comportement legacy
* « pas de tag, voit que default »).
*/
function listAvailableChannels(): array
{
$manifestDir = dirname(__DIR__) . '/manifest';
$registry = Channels::load($manifestDir);
$out = ['' => '(aucun — voit uniquement « default »)'];
foreach ($registry['channels'] as $c) {
$name = (string)($c['name'] ?? '');
if ($name === '' || $name === 'default') continue; // default est l'option implicite (clé vide)
$label = $c['label'] ?? $name;
$out[$name] = "{$name}{$label}";
}
return $out;
}
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
Auth::checkCsrf();
$action = $_POST['action'] ?? '';
@@ -37,6 +58,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 +74,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 +104,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 +170,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 +224,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 +258,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 +292,52 @@ Layout::header('Licenses', 'licenses');
0 / <?= $l['max_machines'] ?>
<?php endif; ?>
</td>
<td>
<!-- Affichage de l'état actuel : channel + badge BÊTA si actif -->
<div style="margin-bottom: 6px;">
<code style="background: rgba(0,0,0,0.3); padding: 2px 6px; border-radius: 4px; font-size: 11px;"
title="Channel actuellement attribué à cette license">
<?= 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">β BÊTA</span>
<?php endif; ?>
</div>
<!-- Toggle BÊTA en bouton DIRECTEMENT visible (pas dans un details).
L'admin n'a plus à deviner qu'il faut cliquer sur le code du channel
pour trouver l'option. -->
<form method="post" style="display: inline-block; margin-right: 4px;">
<?= Layout::csrfField() ?>
<input type="hidden" name="action" value="toggle_betas">
<input type="hidden" name="id" value="<?= $l['id'] ?>">
<button class="btn <?= (int)($l['can_see_betas'] ?? 0) === 1 ? 'btn-warning' : 'btn-secondary' ?>"
type="submit" style="font-size: 11px; padding: 4px 10px;"
title="Bascule l'accès aux versions taggées BÊTA pour cette license">
<?= (int)($l['can_see_betas'] ?? 0) === 1 ? '✓ BÊTA actif' : 'Activer BÊTA' ?>
</button>
</form>
<!-- Channel : modifier via un menu compact -->
<details style="display: inline-block;">
<summary class="btn btn-secondary" style="font-size: 11px; padding: 4px 10px; list-style: none;">
✎ Channel
</summary>
<form method="post" style="margin-top: 8px; display: flex; gap: 4px; align-items: center;">
<?= Layout::csrfField() ?>
<input type="hidden" name="action" value="set_channel">
<input type="hidden" name="id" value="<?= $l['id'] ?>">
<select name="channel" style="min-width: 160px;">
<?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>
</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 +396,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);">

View File

@@ -2,36 +2,122 @@
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';
$root = dirname(__DIR__);
$manifestPath = "$root/manifest/versions.json";
$manifestDir = "$root/manifest";
$buildsDir = "$root/builds";
$notesDir = "$root/releasenotes";
$manifestPath = "$manifestDir/versions.json";
$message = null; $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']);
/**
* 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 sanitizeChannels(array $posted, array $allowedNames): array
{
$out = [];
foreach ($posted as $c) {
$c = (string)$c;
if (in_array($c, $allowedNames, true) && !in_array($c, $out, true)) {
$out[] = $c;
}
}
if (empty($out)) $out = ['default'];
return $out;
}
/**
* ID stable interne pour identifier une entrée du manifest. Permet d'avoir
* plusieurs entrées avec le même numéro de version (ex. v1.5.3 pour police +
* v1.5.3 pour pompier) — la "version" devient un libellé human-readable, pas
* un identifiant unique. Le tag de channels distingue qui voit quoi.
*
* Format : 'v' + 8 hex chars random. Immutable une fois généré.
*/
function generate_entry_id(): string
{
try { return 'v' . bin2hex(random_bytes(4)); }
catch (\Throwable) { return 'v' . dechex(mt_rand(0, 0xffffffff)); }
}
function loadManifest(string $path): array
{
if (!is_file($path)) return ['schemaVersion' => 1, 'product' => 'PROSERVE_UE', 'latest' => null, 'versions' => []];
return json_decode(file_get_contents($path), true) ?? [];
$manifest = json_decode(file_get_contents($path), true) ?? [];
// Migration douce : on ajoute un `id` aux entrées qui n'en ont pas (legacy
// pre-v0.27.1). Auto-save à la fin pour que l'id soit persisté et ne change
// plus aux reloads suivants. Sans ça, les forms pointeraient sur des ids
// jetables et les actions tomberaient à plat au refresh.
$dirty = false;
foreach ($manifest['versions'] ?? [] as &$v) {
if (empty($v['id'])) { $v['id'] = generate_entry_id(); $dirty = true; }
}
unset($v);
if ($dirty) saveManifest($path, $manifest);
return $manifest;
}
function saveManifest(string $path, array $manifest): void
{
// Re-tri par SemVer descendant
usort($manifest['versions'], fn($a, $b) => version_compare($b['version'], $a['version']));
// Re-tri par SemVer descendant. Pour des entrées de même version,
// tiebreak sur l'id (stable mais arbitraire) — l'admin peut compter
// sur un ordre déterministe.
usort($manifest['versions'], function($a, $b) {
$cmp = version_compare($b['version'] ?? '0.0.0', $a['version'] ?? '0.0.0');
return $cmp !== 0 ? $cmp : strcmp($a['id'] ?? '', $b['id'] ?? '');
});
file_put_contents(
$path,
json_encode($manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n"
);
}
function find_entry_index_by_id(array $manifest, string $id): ?int
{
foreach ($manifest['versions'] ?? [] as $i => $v) {
if (($v['id'] ?? '') === $id) return $i;
}
return null;
}
/**
* Normalise un nom de fichier ZIP saisi par l'admin :
* - strip tout chemin (path traversal défense, basename)
* - whitelist [a-zA-Z0-9_.-] (laisse les majuscules, points, tirets, underscores)
* - assure le suffixe .zip
* - retombe sur la valeur par défaut si l'input est vide ou inexploitable
*/
function ps_normalize_zip_filename(string $raw, string $version): string
{
$default = "proserve-{$version}.zip";
$name = trim($raw);
if ($name === '') return $default;
// Strip tout chemin → garde juste le filename (sécurité)
$name = basename($name);
// Whitelist : on garde un set de chars filename-safe sur Windows + Linux
$name = preg_replace('/[^a-zA-Z0-9_.\-]/', '', $name);
if ($name === '' || $name === '.zip' || $name === '.') return $default;
if (!str_ends_with(strtolower($name), '.zip')) $name .= '.zip';
return $name;
}
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
Auth::checkCsrf();
$action = $_POST['action'] ?? '';
@@ -44,6 +130,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).');
@@ -51,8 +139,22 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $minLicDate)) {
throw new Exception('Date min_license_date invalide.');
}
// Le numéro de version peut être DUPLIQUÉ entre channels — c'est
// exactement le cas d'usage (v1.5.3 pour police + v1.5.3 pour
// pompier coexistent). On rejette uniquement la collision sur le
// nom de ZIP, parce que là on aurait une vraie ambiguïté physique
// : deux entries pointant sur le même fichier sur disque.
$zipFilenameProposed = ps_normalize_zip_filename((string)($_POST['zip_filename'] ?? ''), $version);
foreach ($manifest['versions'] as $v) {
if ($v['version'] === $version) throw new Exception("v{$version} existe déjà dans le manifest.");
$existingZip = basename(parse_url($v['download']['url'] ?? '', PHP_URL_PATH) ?: '');
if ($existingZip === $zipFilenameProposed) {
throw new Exception(
"Le ZIP « {$zipFilenameProposed} » est déjà utilisé par v"
. htmlspecialchars($v['version'] ?? '?')
. ". Choisis un autre nom de ZIP (ex. proserve-{$version}-police.zip)."
);
}
}
$releasedAtIso = $releasedAt !== ''
@@ -60,74 +162,137 @@ 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'][] = [
// Le ZIP a déjà été normalisé pour le check anti-collision plus haut.
$zipFilename = $zipFilenameProposed;
$channels = sanitizeChannels((array)($_POST['channels'] ?? []), $channelNames);
$entryId = generate_entry_id();
$entry = [
'id' => $entryId,
'version' => $version,
'releasedAt' => $releasedAtIso,
'executable' => 'PROSERVE_UE_5_5.exe',
'installFolderTemplate' => 'PROSERVE v{version}',
'channels' => $channels,
'download' => [
'url' => "{$base}/builds/proserve-{$version}.zip",
'url' => "{$base}/builds/{$zipFilename}",
'sizeBytes' => 0,
'sha256' => 'REPLACE_AFTER_BUILD',
],
'releaseNotesUrl' => "{$base}/api/releasenotes/{$version}",
// Release notes adressables par id stable — supporte plusieurs entries
// de même version sans collision (chaque release a son propre .md).
'releaseNotesUrl' => "{$base}/api/releasenotes/{$entryId}",
'minLicenseDate' => $minLicDate,
'availableForDownload' => $available,
];
if ($isBeta) {
$entry['isBeta'] = true;
if ($betaNotes !== null) $entry['betaNotes'] = $betaNotes;
}
$manifest['versions'][] = $entry;
saveManifest($manifestPath, $manifest);
if ($notes !== '') {
if (!is_dir($notesDir)) mkdir($notesDir, 0755, true);
file_put_contents("$notesDir/{$version}.md", $notes);
file_put_contents("$notesDir/{$entryId}.md", $notes);
}
$message = "v{$version} ajoutée. Upload le ZIP en SFTP dans builds/, puis clique « Sync (sign-manifest) ».";
$message = "v{$version} ajoutée (id <code>{$entryId}</code>). Upload le ZIP <code>{$zipFilename}</code> en SFTP dans <code>builds/</code>, puis clique « Sync (sign-manifest) ».";
}
elseif ($action === 'edit_notes') {
$version = $_POST['version'] ?? '';
$entryId = (string)($_POST['id'] ?? '');
$notes = $_POST['notes'] ?? '';
if (!preg_match('/^\d+\.\d+\.\d+$/', $version)) throw new Exception('Version invalide.');
$idx = find_entry_index_by_id($manifest, $entryId);
if ($idx === null) throw new Exception("Entry id={$entryId} introuvable.");
if (!is_dir($notesDir)) mkdir($notesDir, 0755, true);
file_put_contents("$notesDir/{$version}.md", $notes);
$message = "Release notes de v{$version} mises à jour.";
file_put_contents("$notesDir/{$entryId}.md", $notes);
$message = "Release notes de v" . ($manifest['versions'][$idx]['version'] ?? '?') . " mises à jour.";
}
elseif ($action === 'edit_meta') {
$version = $_POST['version'] ?? '';
$entryId = (string)($_POST['id'] ?? '');
$minLicDate = trim($_POST['min_license_date'] ?? '');
$releasedAt = trim($_POST['released_at'] ?? '');
$newZipName = trim((string)($_POST['zip_filename'] ?? ''));
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $minLicDate)) {
throw new Exception('min_license_date invalide.');
}
foreach ($manifest['versions'] as &$v) {
if ($v['version'] === $version) {
$v['minLicenseDate'] = $minLicDate;
if ($releasedAt !== '') {
$v['releasedAt'] = (new DateTime($releasedAt, new DateTimeZone('UTC')))->format('Y-m-d\TH:i:s\Z');
$idx = find_entry_index_by_id($manifest, $entryId);
if ($idx === null) throw new Exception("Entry id={$entryId} introuvable.");
$version = $manifest['versions'][$idx]['version'] ?? '';
$manifest['versions'][$idx]['minLicenseDate'] = $minLicDate;
if ($releasedAt !== '') {
$manifest['versions'][$idx]['releasedAt'] =
(new DateTime($releasedAt, new DateTimeZone('UTC')))->format('Y-m-d\TH:i:s\Z');
}
if ($newZipName !== '') {
$normalized = ps_normalize_zip_filename($newZipName, $version);
// Anti-collision : interdit de renommer vers un ZIP déjà utilisé par une autre entry
foreach ($manifest['versions'] as $other) {
if (($other['id'] ?? '') === $entryId) continue;
$otherZip = basename(parse_url($other['download']['url'] ?? '', PHP_URL_PATH) ?: '');
if ($otherZip === $normalized) {
throw new Exception("Le ZIP « {$normalized} » est déjà utilisé par une autre entry. Choisis un autre nom.");
}
break;
}
$base = $config['base_url'] ?? 'https://asterionvr.com/PS_Launcher';
$manifest['versions'][$idx]['download']['url'] = "{$base}/builds/{$normalized}";
$manifest['versions'][$idx]['download']['sha256'] = 'REPLACE_AFTER_BUILD';
$manifest['versions'][$idx]['download']['sizeBytes'] = 0;
}
unset($v);
saveManifest($manifestPath, $manifest);
$message = "Méta de v{$version} mises à jour.";
$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') {
$entryId = (string)($_POST['id'] ?? '');
$idx = find_entry_index_by_id($manifest, $entryId);
if ($idx === null) throw new Exception("Entry id={$entryId} introuvable.");
$channels = sanitizeChannels((array)($_POST['channels'] ?? []), $channelNames);
$manifest['versions'][$idx]['channels'] = $channels;
saveManifest($manifestPath, $manifest);
$version = $manifest['versions'][$idx]['version'] ?? '?';
$message = "Channels de v{$version} : " . implode(', ', $channels) . ". Re-Sync pour rafraîchir la signature du manifest.";
}
elseif ($action === 'set_beta') {
$entryId = (string)($_POST['id'] ?? '');
$idx = find_entry_index_by_id($manifest, $entryId);
if ($idx === null) throw new Exception("Entry id={$entryId} introuvable.");
$isBeta = !empty($_POST['is_beta']);
$betaNotes = trim($_POST['beta_notes'] ?? '') ?: null;
if ($isBeta) {
$manifest['versions'][$idx]['isBeta'] = true;
if ($betaNotes !== null) {
$manifest['versions'][$idx]['betaNotes'] = $betaNotes;
} else {
unset($manifest['versions'][$idx]['betaNotes']);
}
} else {
unset($manifest['versions'][$idx]['isBeta']);
unset($manifest['versions'][$idx]['betaNotes']);
}
saveManifest($manifestPath, $manifest);
$version = $manifest['versions'][$idx]['version'] ?? '?';
$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) {
if ($v['version'] === $version) {
$v['availableForDownload'] = !($v['availableForDownload'] ?? true);
break;
}
}
unset($v);
$entryId = (string)($_POST['id'] ?? '');
$idx = find_entry_index_by_id($manifest, $entryId);
if ($idx === null) throw new Exception("Entry id={$entryId} introuvable.");
$manifest['versions'][$idx]['availableForDownload'] = !($manifest['versions'][$idx]['availableForDownload'] ?? true);
saveManifest($manifestPath, $manifest);
$version = $manifest['versions'][$idx]['version'] ?? '?';
$message = "Disponibilité de v{$version} mise à jour.";
}
elseif ($action === 'delete') {
$version = $_POST['version'] ?? '';
$entryId = (string)($_POST['id'] ?? '');
$idx = find_entry_index_by_id($manifest, $entryId);
if ($idx === null) throw new Exception("Entry id={$entryId} introuvable.");
$version = $manifest['versions'][$idx]['version'] ?? '?';
// Cleanup release notes file (best effort)
$notesFile = "$notesDir/{$entryId}.md";
if (is_file($notesFile)) @unlink($notesFile);
$manifest['versions'] = array_values(array_filter(
$manifest['versions'],
fn($v) => $v['version'] !== $version
fn($v) => ($v['id'] ?? '') !== $entryId
));
saveManifest($manifestPath, $manifest);
$message = "v{$version} retirée du manifest. Le ZIP reste dans builds/ (à supprimer en SFTP si voulu).";
@@ -144,39 +309,40 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (!$result['ok']) $messageType = 'error';
}
elseif ($action === 'sync_one') {
$version = $_POST['version'] ?? '';
if ($version === '') throw new Exception("Version manquante");
$entryId = (string)($_POST['id'] ?? '');
$idx = find_entry_index_by_id($manifest, $entryId);
if ($idx === null) throw new Exception("Entry id={$entryId} introuvable.");
$version = $manifest['versions'][$idx]['version'] ?? '?';
require_once "$root/tools/SignManifest.php";
$signer = new \PSLauncher\Tools\SignManifest($root);
$force = !empty($_POST['force']);
// SignManifest::run filtre par numéro de version ; en cas d'entries
// de même version sur des channels différents, le hash sera recalculé
// pour toutes celles qui matchent (chacune pointe sur son propre ZIP,
// donc le résultat est correct, juste un peu plus de boulot).
$result = $signer->run('versions', $force, $version);
$forceLabel = $force ? ' [FORCE]' : '';
$message = "Hash de v{$version}{$forceLabel} :\n" . implode("\n", $result['log']);
if (!$result['ok']) $messageType = 'error';
}
elseif ($action === 'set_skip_hash') {
$version = $_POST['version'] ?? '';
$entryId = (string)($_POST['id'] ?? '');
$idx = find_entry_index_by_id($manifest, $entryId);
if ($idx === null) throw new Exception("Entry id={$entryId} introuvable.");
$skipHash = !empty($_POST['skip']);
foreach ($manifest['versions'] as &$v) {
if ($v['version'] === $version) {
if ($skipHash) {
$v['download']['hashAlgorithm'] = 'none';
$v['download']['sha256'] = '';
} else {
unset($v['download']['hashAlgorithm']);
if (empty($v['download']['sha256'])) {
$v['download']['sha256'] = 'REPLACE_AFTER_BUILD';
}
}
break;
$version = $manifest['versions'][$idx]['version'] ?? '?';
if ($skipHash) {
$manifest['versions'][$idx]['download']['hashAlgorithm'] = 'none';
$manifest['versions'][$idx]['download']['sha256'] = '';
} else {
unset($manifest['versions'][$idx]['download']['hashAlgorithm']);
if (empty($manifest['versions'][$idx]['download']['sha256'])) {
$manifest['versions'][$idx]['download']['sha256'] = 'REPLACE_AFTER_BUILD';
}
}
unset($v);
saveManifest($manifestPath, $manifest);
// Re-signature auto pour garder le manifest valide après le changement.
// 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);
$resign = $signer->run('versions', false);
@@ -193,15 +359,26 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
}
$manifest = loadManifest($manifestPath);
// Liste des ZIPs : tout est flat dans builds/, indexé par filename simple.
$zips = is_dir($buildsDir) ? array_map('basename', glob("$buildsDir/*.zip") ?: []) : [];
$zipSizes = [];
foreach ($zips as $z) $zipSizes[$z] = filesize("$buildsDir/$z");
// Pour l'édition : pré-charge les release notes existantes
// Pour l'édition : pré-charge les release notes existantes (indexées par
// id stable maintenant). Fallback sur l'ancien format {version}.md pour les
// notes créées avant v0.27.1, qu'on n'a pas encore migrées.
$existingNotes = [];
foreach ($manifest['versions'] ?? [] as $v) {
$f = "$notesDir/{$v['version']}.md";
$existingNotes[$v['version']] = is_file($f) ? file_get_contents($f) : '';
$id = $v['id'] ?? '';
$byId = $id !== '' ? "$notesDir/{$id}.md" : null;
$byVersion = "$notesDir/{$v['version']}.md";
if ($byId !== null && is_file($byId)) {
$existingNotes[$id] = file_get_contents($byId);
} elseif (is_file($byVersion)) {
$existingNotes[$id] = file_get_contents($byVersion);
} else {
$existingNotes[$id] = '';
}
}
Layout::header('Versions', 'versions');
@@ -213,11 +390,14 @@ Layout::header('Versions', 'versions');
<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 dans <code>www/PS_Launcher/builds/</code> en respectant le nom <code>proserve-{version}.zip</code>.</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">
@@ -239,12 +419,40 @@ Layout::header('Versions', 'versions');
<input type="date" name="min_license_date" required>
</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>)</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>
<textarea name="notes" rows="8" placeholder="# Proserve v1.4.8&#10;&#10;## Nouveautés&#10;- ..." 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>
@@ -276,6 +484,7 @@ Layout::header('Versions', 'versions');
<thead>
<tr>
<th>Version</th>
<th>Channels</th>
<th>Release</th>
<th>min_license</th>
<th>ZIP</th>
@@ -286,13 +495,29 @@ Layout::header('Versions', 'versions');
</thead>
<tbody>
<?php foreach ($manifest['versions'] ?? [] as $v):
$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');
$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></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>
<?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>
@@ -317,7 +542,7 @@ Layout::header('Versions', 'versions');
<form method="post" style="display:inline">
<?= Layout::csrfField() ?>
<input type="hidden" name="action" value="toggle_available">
<input type="hidden" name="version" value="<?= htmlspecialchars($v['version']) ?>">
<input type="hidden" name="id" value="<?= htmlspecialchars($v['id'] ?? '') ?>">
<button class="btn btn-secondary" type="submit">
<?= ($v['availableForDownload'] ?? true) ? '✓' : '✗' ?>
</button>
@@ -328,7 +553,7 @@ Layout::header('Versions', 'versions');
<form method="post" style="display:inline" title="Recalcule le SHA-256 de cette version uniquement (utilise le cache si le ZIP n'a pas changé)">
<?= Layout::csrfField() ?>
<input type="hidden" name="action" value="sync_one">
<input type="hidden" name="version" value="<?= htmlspecialchars($v['version']) ?>">
<input type="hidden" name="id" value="<?= htmlspecialchars($v['id'] ?? '') ?>">
<button class="btn btn-secondary" type="submit">🔁 Hash</button>
</form>
<?php endif; ?>
@@ -348,7 +573,7 @@ Layout::header('Versions', 'versions');
<form method="post" style="display:inline">
<?= Layout::csrfField() ?>
<input type="hidden" name="action" value="set_skip_hash">
<input type="hidden" name="version" value="<?= htmlspecialchars($v['version']) ?>">
<input type="hidden" name="id" value="<?= htmlspecialchars($v['id'] ?? '') ?>">
<input type="hidden" name="skip" value="<?= $hashSkipped ? '0' : '1' ?>">
<button class="btn <?= $hashSkipped ? 'btn-success' : 'btn-warning' ?>" type="submit">
<?= $hashSkipped ? '✓ Réactiver la vérif' : '⏭ Désactiver la vérif' ?>
@@ -358,7 +583,7 @@ Layout::header('Versions', 'versions');
<form method="post" style="margin-top: 8px;" title="Recalcule en ignorant le cache même si le ZIP n'a pas changé">
<?= Layout::csrfField() ?>
<input type="hidden" name="action" value="sync_one">
<input type="hidden" name="version" value="<?= htmlspecialchars($v['version']) ?>">
<input type="hidden" name="id" value="<?= htmlspecialchars($v['id'] ?? '') ?>">
<input type="hidden" name="force" value="1">
<button class="btn btn-secondary" type="submit">🔄 Force re-hash</button>
</form>
@@ -367,10 +592,10 @@ Layout::header('Versions', 'versions');
</details>
<details style="display: inline-block; margin: 0 4px;">
<summary class="btn btn-secondary">Méta</summary>
<form method="post" style="margin-top: 8px;">
<form method="post" style="margin-top: 8px; min-width: 360px;">
<?= Layout::csrfField() ?>
<input type="hidden" name="action" value="edit_meta">
<input type="hidden" name="version" value="<?= htmlspecialchars($v['version']) ?>">
<input type="hidden" name="id" value="<?= htmlspecialchars($v['id'] ?? '') ?>">
<div class="field">
<label>min_license_date</label>
<input type="date" name="min_license_date" value="<?= htmlspecialchars($v['minLicenseDate'] ?? '') ?>" required>
@@ -379,6 +604,11 @@ Layout::header('Versions', 'versions');
<label>Date de release (UTC)</label>
<input type="datetime-local" name="released_at" value="<?= htmlspecialchars(substr($v['releasedAt'] ?? '', 0, 16)) ?>">
</div>
<div class="field">
<label>Renommer le ZIP <span class="muted" style="font-weight: normal; font-size: 11px;">(actuel : <code><?= htmlspecialchars(basename(parse_url($v['download']['url'] ?? '', PHP_URL_PATH) ?: '')) ?></code>)</span></label>
<input type="text" name="zip_filename" placeholder="laisse vide pour ne rien changer">
<span class="muted" style="font-size: 11px;">⚠️ Renommer = invalide le sha256, il faudra re-uploader le nouveau ZIP en SFTP puis re-Sync.</span>
</div>
<button class="btn btn-primary" type="submit">Enregistrer</button>
</form>
</details>
@@ -387,23 +617,65 @@ Layout::header('Versions', 'versions');
<form method="post" style="margin-top: 8px;">
<?= Layout::csrfField() ?>
<input type="hidden" name="action" value="edit_notes">
<input type="hidden" name="version" value="<?= htmlspecialchars($v['version']) ?>">
<textarea name="notes" rows="10" style="font-family: 'Cascadia Code', Consolas, monospace; min-width: 400px;"><?= htmlspecialchars($existingNotes[$v['version']] ?? '') ?></textarea>
<input type="hidden" name="id" value="<?= htmlspecialchars($v['id'] ?? '') ?>">
<textarea name="notes" rows="10" style="font-family: 'Cascadia Code', Consolas, monospace; min-width: 400px;"><?= htmlspecialchars($existingNotes[$v['id'] ?? ''] ?? '') ?></textarea>
<br><br>
<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="id" value="<?= htmlspecialchars($v['id'] ?? '') ?>">
<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>
<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="id" value="<?= htmlspecialchars($v['id'] ?? '') ?>">
<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">
<input type="hidden" name="version" value="<?= htmlspecialchars($v['version']) ?>">
<input type="hidden" name="id" value="<?= htmlspecialchars($v['id'] ?? '') ?>">
<button class="btn btn-danger" type="submit">Retirer</button>
</form>
</td>
</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>
@@ -420,7 +692,7 @@ Layout::header('Versions', 'versions');
<?php
$referencedNames = [];
foreach ($manifest['versions'] ?? [] as $v) {
$referencedNames[] = basename(parse_url($v['download']['url'], PHP_URL_PATH) ?? '');
$referencedNames[] = basename(parse_url($v['download']['url'] ?? '', PHP_URL_PATH) ?: '');
}
foreach ($zips as $z):
$referenced = in_array($z, $referencedNames, true);

View File

@@ -41,12 +41,16 @@ $route = trim((string)($_GET['route'] ?? ''), '/');
$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
if ($method === 'GET' && ($route === '' || $route === 'manifest')) {
// Crypto requis : depuis v0.27.0 le manifest est re-signé à la volée
// selon le channel demandé (filtrage server-side).
require __DIR__ . '/lib/Crypto.php';
require __DIR__ . '/routes/Manifest.php';
\PSLauncher\Routes\Manifest::handle($config);
return;
}
if ($method === 'GET' && preg_match('#^releasenotes/([0-9]+\.[0-9]+\.[0-9]+)$#', $route, $m)) {
// Release notes : accepte 'X.Y.Z' (legacy) ou id stable 'v' + 8 hex (v0.27.1+)
if ($method === 'GET' && preg_match('#^releasenotes/(v[0-9a-f]{8}|[0-9]+\.[0-9]+\.[0-9]+)$#', $route, $m)) {
require __DIR__ . '/routes/Releasenotes.php';
\PSLauncher\Routes\Releasenotes::handle($m[1]);
return;

View File

@@ -3,21 +3,137 @@ declare(strict_types=1);
namespace PSLauncher\Routes;
use PSLauncher\Crypto;
use PSLauncher\Response;
/**
* Endpoint manifest public. Reçoit ?channel=X depuis le launcher (channel
* provenant de la license signée Ed25519). On filtre les versions pour ne
* renvoyer que celles taggées sur ce channel ou sur "default", et on re-signe
* le manifest filtré à la volée avec la clé privée Ed25519.
*
* Sémantique additive : un client sur channel "police" voit "default" + "police".
* Pour rendre une version invisible au public, ne PAS la tagger "default".
*/
final class Manifest
{
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)) {
$manifestPath = dirname(__DIR__, 2) . '/manifest/versions.json';
if (!is_file($manifestPath)) {
Response::error('manifest_missing', 'versions.json not found on server', 404);
}
// Cache court pour absorber les pics, doit être revalidé pour ne pas masquer une nouvelle version
$raw = file_get_contents($manifestPath);
$manifest = json_decode((string)$raw, true);
if (!is_array($manifest)) {
Response::error('manifest_invalid', 'versions.json is not valid JSON', 500);
}
// Channel demandé par le client. Whitelist regex anti-injection. Un
// channel inconnu / mal formé est traité comme NULL → le client voit
// uniquement les versions "default".
$clientChannel = $_GET['channel'] ?? null;
if (!is_string($clientChannel) || $clientChannel === '' || !preg_match(self::CHANNEL_REGEX, $clientChannel)) {
$clientChannel = null;
}
$manifest['versions'] = self::filterVersions(
$manifest['versions'] ?? [],
$clientChannel
);
// Re-signature à la volée. La clé privée Ed25519 est dans config.php
// (sert déjà aux licenses), accessible uniquement depuis le serveur.
// Coût : ~1ms d'Ed25519 par requête, négligeable vs le réseau.
$sk = $config['ed25519']['private_key_hex'] ?? '';
if ($sk !== '') {
unset($manifest['signature']); // important : canonical sans la signature
$manifest['signature'] = Crypto::signEd25519(Crypto::canonicalJson($manifest), $sk);
} else {
unset($manifest['signature']); // pas de clé = pas de signature, le client échouera la vérif
}
$body = json_encode($manifest, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
// Cache court pour absorber les pics. ETag inclut le channel pour que
// les caches HTTP intermédiaires séparent bien les vues par client.
header('Cache-Control: public, max-age=300, must-revalidate');
header('ETag: "' . md5_file($file) . '"');
header('ETag: "' . md5(($clientChannel ?? '') . '|' . md5_file($manifestPath)) . '"');
header('Content-Type: application/json; charset=utf-8');
header('Content-Length: ' . filesize($file));
readfile($file);
header('Content-Length: ' . strlen((string)$body));
echo $body;
}
/**
* Filtre + dédupe par numéro de version.
*
* Étape 1 — filter : on garde les versions visibles par ce client. Un
* client sur channel X voit les entries taggées 'default' OU 'X'.
*
* Étape 2 — dedupe : pour chaque numéro de version qui apparaît plusieurs
* fois (cas v1.5.3 default + v1.5.3 firefighter), on ne renvoie qu'UNE
* seule entry — la plus spécifique au client. Priorité : l'entry taggée
* avec le channel propre du client gagne ; à défaut, l'entry default
* sert de fallback. Sans cette dédup côté serveur, le launcher recevait
* deux entries v1.5.3 et son `ToDictionary(v => v.Version)` collisionnait
* silencieusement, ce qui faisait apparaître dans l'UI le mauvais ZIP
* pour un user firefighter.
*
* @param array<int, array<string,mixed>> $versions
* @return list<array<string,mixed>>
*/
private static function filterVersions(array $versions, ?string $clientChannel): array
{
// Étape 1 : filter visible par ce client
$visible = [];
foreach ($versions as $v) {
$channels = (isset($v['channels']) && is_array($v['channels']) && !empty($v['channels']))
? $v['channels']
: ['default'];
$isPublic = in_array('default', $channels, true);
$matchesUser = $clientChannel !== null && in_array($clientChannel, $channels, true);
if ($isPublic || $matchesUser) {
$visible[] = ['entry' => $v, 'channels' => $channels];
}
}
// Étape 2 : group by version, pick most specific per group
$byVersion = [];
foreach ($visible as $item) {
$key = (string)($item['entry']['version'] ?? '?');
$byVersion[$key][] = $item;
}
$result = [];
foreach ($byVersion as $items) {
if (count($items) === 1) {
$result[] = $items[0]['entry'];
continue;
}
// Plusieurs entries pour ce numéro : on cherche d'abord une qui
// matche le channel spécifique du client (firefighter, police…),
// sinon on retombe sur l'entry default.
$specific = null;
$defaultEntry = null;
foreach ($items as $item) {
if ($clientChannel !== null
&& $specific === null
&& in_array($clientChannel, $item['channels'], true)) {
$specific = $item;
}
if ($defaultEntry === null && in_array('default', $item['channels'], true)) {
$defaultEntry = $item;
}
}
$picked = $specific ?? $defaultEntry ?? $items[0];
$result[] = $picked['entry'];
}
return array_values($result);
}
}

View File

@@ -5,16 +5,27 @@ namespace PSLauncher\Routes;
use PSLauncher\Response;
/**
* Release notes endpoint. Accepte deux formats d'identifiant :
* - id stable d'entrée : 'v' + 8 hex chars (format depuis v0.27.1, supporte
* plusieurs entries de même version)
* - numéro de version legacy : X.Y.Z (compat ascendante avec les notes
* créées avant v0.27.1)
*/
final class Releasenotes
{
public static function handle(string $version): void
public static function handle(string $key): void
{
if (!preg_match('/^[0-9]+\.[0-9]+\.[0-9]+$/', $version)) {
Response::error('invalid_version', 'Bad version format', 400);
$isEntryId = (bool)preg_match('/^v[0-9a-f]{8}$/', $key);
$isVersion = (bool)preg_match('/^[0-9]+\.[0-9]+\.[0-9]+$/', $key);
if (!$isEntryId && !$isVersion) {
Response::error('invalid_id', 'Bad release-notes identifier', 400);
}
$file = dirname(__DIR__, 2) . "/releasenotes/{$version}.md";
$file = dirname(__DIR__, 2) . "/releasenotes/{$key}.md";
if (!is_file($file)) {
Response::error('not_found', "Release notes for {$version} not found", 404);
Response::error('not_found', "Release notes for {$key} not found", 404);
}
header('Cache-Control: public, max-age=3600, must-revalidate');
header('Content-Type: text/markdown; charset=utf-8');

View File

@@ -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,
];

View File

@@ -0,0 +1,50 @@
-- 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.
--
-- IDEMPOTENCE : OVH mutualisé peut tourner sur MariaDB pré-10.0.2 où
-- ALTER TABLE ... IF NOT EXISTS n'existe pas. On contourne avec une PROCEDURE
-- temporaire qui consulte INFORMATION_SCHEMA avant chaque ALTER. Comme ça la
-- migration peut être re-jouée sans erreur sur n'importe quelle version
-- MariaDB / MySQL 5.5+.
DROP PROCEDURE IF EXISTS ps_launcher_migrate_002;
DELIMITER $$
CREATE PROCEDURE ps_launcher_migrate_002()
BEGIN
-- channel
IF NOT EXISTS (
SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'licenses' AND COLUMN_NAME = 'channel'
) THEN
ALTER TABLE licenses ADD COLUMN channel VARCHAR(64) NULL AFTER max_machines;
END IF;
-- can_see_betas
IF NOT EXISTS (
SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'licenses' AND COLUMN_NAME = 'can_see_betas'
) THEN
ALTER TABLE licenses ADD COLUMN can_see_betas TINYINT(1) NOT NULL DEFAULT 0 AFTER channel;
END IF;
-- index sur channel (une simple recherche bénéficie peu mais c'est pas cher)
IF NOT EXISTS (
SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'licenses' AND INDEX_NAME = 'idx_channel'
) THEN
ALTER TABLE licenses ADD INDEX idx_channel (channel);
END IF;
END $$
DELIMITER ;
CALL ps_launcher_migrate_002();
DROP PROCEDURE ps_launcher_migrate_002;
-- 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.

View File

@@ -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>>()));

View File

@@ -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.27.2</Version>
<AssemblyVersion>0.27.2.0</AssemblyVersion>
<FileVersion>0.27.2.0</FileVersion>
<!-- Single-file self-contained publish profile (used by `dotnet publish`) -->
<PublishSingleFile>true</PublishSingleFile>

View File

@@ -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)

View File

@@ -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))]

View File

@@ -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>

View File

@@ -212,6 +212,13 @@ public sealed class LicenseService : ILicenseService
OwnerName = _config.License.CachedOwnerName,
DownloadEntitlementUntil = _config.License.CachedEntitlementUntil,
ServerTime = lastValidation,
// Restauration des champs channel + canSeeBetas (ajoutés persistants
// depuis v0.27.2). Sans ça, le channelProvider du ManifestService
// retombait sur null à chaque relance, le client fetch sans
// ?channel=, et l'utilisateur ne voyait que les versions default —
// jamais les builds spécifiques à son channel (firefighter, police…).
Channel = _config.License.CachedChannel,
CanSeeBetas = _config.License.CachedCanSeeBetas,
};
}
@@ -227,6 +234,8 @@ public sealed class LicenseService : ILicenseService
_config.License.CachedOwnerName = response.OwnerName;
_config.License.CachedEntitlementUntil = response.DownloadEntitlementUntil;
_config.License.CachedStatus = response.Status;
_config.License.CachedChannel = response.Channel;
_config.License.CachedCanSeeBetas = response.CanSeeBetas;
_configStore.Save(_config);
}
@@ -310,7 +319,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 +331,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 +361,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 +380,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()

View File

@@ -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", "▶ 启动", "▶ เปิด", "▶ تشغيل");

View File

@@ -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);

View File

@@ -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; }

View File

@@ -322,6 +322,22 @@ public sealed class LicenseConfig
public DateTime? CachedEntitlementUntil { get; set; }
public string? CachedOwnerName { get; set; }
/// <summary>
/// Channel de distribution attribué à cette license côté serveur (NULL =
/// aucun ⇒ accès uniquement aux versions taggées 'default'). Persisté ici
/// pour que <see cref="ILicenseService.GetCached"/> restaure ce champ entre
/// deux lancements du launcher — sans ça, le channelProvider du
/// ManifestService retombait sur null à chaque relance et le client
/// fetchait toujours le manifest default.
/// </summary>
public string? CachedChannel { get; set; }
/// <summary>
/// Flag <c>canSeeBetas</c> de la license. Persisté pour la même raison
/// que <see cref="CachedChannel"/> : restauration entre deux lancements.
/// </summary>
public bool CachedCanSeeBetas { get; set; }
/// <summary>
/// Statut tel que renvoyé par le serveur lors de la dernière validation : "valid",
/// "expired", "revoked", "invalid", "machine_limit_exceeded". Persisté pour qu'une

View File

@@ -70,6 +70,37 @@ public sealed class VersionManifest
[JsonPropertyName("availableForDownload")]
public bool AvailableForDownload { get; set; } = true;
/// <summary>
/// Channels qui voient cette version. Liste vide ou champ absent ⇒
/// implicitement <c>["default"]</c> (= visible par tous les clients,
/// peu importe leur channel).
///
/// La sémantique est additive côté serveur : si une license est sur
/// channel "police", elle reçoit (déjà filtrées par le serveur) les
/// versions taggées "default" + celles taggées "police". Le client n'a
/// pas besoin de re-filtrer côté UI — Manifest.php a déjà fait le tri,
/// signé le résultat. On expose quand même la liste ici pour debug et
/// pour un éventuel affichage admin du tag dans l'UI.
/// </summary>
[JsonPropertyName("channels")]
public List<string> Channels { get; set; } = new();
/// <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; }