From c371a79f93f8177b9b02cc4af020c79913ed472d Mon Sep 17 00:00:00 2001 From: "j.foucher" Date: Tue, 5 May 2026 07:38:00 +0200 Subject: [PATCH] =?UTF-8?q?v0.26.0=20=E2=80=94=20Channels=20per-license=20?= =?UTF-8?q?+=20tag=20B=C3=8ATA=20sur=20versions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- installer/PSLauncher.iss | 2 +- server/admin/licenses.php | 112 +++++++++++- server/admin/versions.php | 167 ++++++++++++++++-- server/api/routes/Manifest.php | 34 +++- server/api/routes/ValidateLicense.php | 15 +- server/migrations/002_channel_betas.sql | 17 ++ server/tools/SignManifest.php | 14 +- src/PSLauncher.App/App.xaml.cs | 5 + src/PSLauncher.App/PSLauncher.App.csproj | 6 +- .../ViewModels/MainViewModel.cs | 12 +- .../ViewModels/VersionRowViewModel.cs | 18 ++ src/PSLauncher.App/Views/MainWindow.xaml | 23 +++ .../Licensing/LicenseService.cs | 48 ++++- src/PSLauncher.Core/Localization/Strings.cs | 22 +++ .../Manifests/ManifestService.cs | 13 +- src/PSLauncher.Models/License.cs | 18 ++ src/PSLauncher.Models/RemoteManifest.cs | 16 ++ 17 files changed, 510 insertions(+), 32 deletions(-) create mode 100644 server/migrations/002_channel_betas.sql diff --git a/installer/PSLauncher.iss b/installer/PSLauncher.iss index 7544b70..3414565 100644 --- a/installer/PSLauncher.iss +++ b/installer/PSLauncher.iss @@ -11,7 +11,7 @@ #define MyAppName "PROSERVE Launcher" #define MyAppShortName "PS_Launcher" -#define MyAppVersion "0.25.10" +#define MyAppVersion "0.26.0" #define MyAppPublisher "ASTERION VR" #define MyAppURL "https://asterionvr.com" #define MyAppExeName "PS_Launcher.exe" diff --git a/server/admin/licenses.php b/server/admin/licenses.php index a03182a..60eaf4a 100644 --- a/server/admin/licenses.php +++ b/server/admin/licenses.php @@ -27,6 +27,26 @@ function generateLicenseKey(): string return 'PRSRV-' . implode('-', $groups); } +/** + * Liste les channels disponibles en scannant manifest/versions-*.json. + * Le channel "default" est toujours présent (= manifest/versions.json). + * Whitelist [a-z0-9_-]{1,64} pour cohérence avec PHP\Routes\Manifest. + */ +function listAvailableChannels(): array +{ + $dir = dirname(__DIR__) . '/manifest'; + $channels = ['' => '(default)']; // clé vide = NULL en DB + if (is_dir($dir)) { + foreach (glob($dir . '/versions-*.json') as $file) { + $name = basename($file); + if (preg_match('/^versions-([a-z0-9_-]{1,64})\.json$/', $name, $m)) { + $channels[$m[1]] = $m[1]; + } + } + } + return $channels; +} + if ($_SERVER['REQUEST_METHOD'] === 'POST') { Auth::checkCsrf(); $action = $_POST['action'] ?? ''; @@ -37,6 +57,14 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { $until = trim($_POST['until'] ?? ''); $maxMachines = max(1, (int)($_POST['max_machines'] ?? 1)); $notes = trim($_POST['notes'] ?? '') ?: null; + // Channel = '' ↦ NULL en DB (default manifest). Whitelist regex anti-injection. + $channel = trim((string)($_POST['channel'] ?? '')); + if ($channel === '') { + $channel = null; + } elseif (!preg_match('/^[a-z0-9_-]{1,64}$/', $channel)) { + throw new Exception('Channel invalide : seules les lettres minuscules, chiffres, _ et - sont autorisés (max 64 caractères).'); + } + $canSeeBetas = isset($_POST['can_see_betas']) ? 1 : 0; if ($owner === '' || !preg_match('/^\d{4}-\d{2}-\d{2}$/', $until)) { throw new Exception('Nom du client et date d\'expiration sont requis (date au format YYYY-MM-DD).'); @@ -45,8 +73,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { for ($attempts = 0; $attempts < 5; $attempts++) { $newKey = generateLicenseKey(); try { - $db->prepare('INSERT INTO licenses (license_key, owner_name, issued_at, download_entitlement_until, max_machines, notes) VALUES (?, ?, NOW(), ?, ?, ?)') - ->execute([$newKey, $owner, $until . ' 23:59:59', $maxMachines, $notes]); + $db->prepare('INSERT INTO licenses (license_key, owner_name, issued_at, download_entitlement_until, max_machines, channel, can_see_betas, notes) VALUES (?, ?, NOW(), ?, ?, ?, ?, ?)') + ->execute([$newKey, $owner, $until . ' 23:59:59', $maxMachines, $channel, $canSeeBetas, $notes]); $message = "License émise avec succès. Note la clé maintenant — elle ne sera plus jamais affichée."; break; } catch (PDOException $e) { @@ -75,6 +103,29 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { ->execute([$newUntil . ' 23:59:59', $id]); $message = "License #{$id} prolongée jusqu'au {$newUntil}."; } + elseif ($action === 'set_channel') { + $id = (int)($_POST['id'] ?? 0); + $channel = trim((string)($_POST['channel'] ?? '')); + if ($channel === '') { + $channel = null; // default manifest + } elseif (!preg_match('/^[a-z0-9_-]{1,64}$/', $channel)) { + throw new Exception('Channel invalide.'); + } + $db->prepare('UPDATE licenses SET channel = ? WHERE id = ?')->execute([$channel, $id]); + $label = $channel ?? 'default'; + $message = "License #{$id} : channel passé à « {$label} ». Le client devra rouvrir le launcher pour prendre en compte le changement (revalidation license)."; + } + elseif ($action === 'toggle_betas') { + $id = (int)($_POST['id'] ?? 0); + $stmt = $db->prepare('UPDATE licenses SET can_see_betas = 1 - can_see_betas WHERE id = ?'); + $stmt->execute([$id]); + $row = $db->prepare('SELECT can_see_betas FROM licenses WHERE id = ?'); + $row->execute([$id]); + $now = (int)$row->fetchColumn(); + $message = $now + ? "License #{$id} : accès aux versions BÊTA activé." + : "License #{$id} : accès aux versions BÊTA désactivé."; + } elseif ($action === 'set_max_machines') { $id = (int)($_POST['id'] ?? 0); $newMax = (int)($_POST['max_machines'] ?? 0); @@ -118,6 +169,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { } } +$availableChannels = listAvailableChannels(); + $licenses = $db->query( 'SELECT l.*, (SELECT COUNT(*) FROM license_machines m WHERE m.license_id = l.id) AS machines_count @@ -170,6 +223,23 @@ Layout::header('Licenses', 'licenses'); +
+
+ + +
+
+ + Coche pour les testeurs internes / clients pilotes. +
+
@@ -187,6 +257,7 @@ Layout::header('Licenses', 'licenses'); Émise Expire Machines + Channel / Bêta Statut Actions @@ -220,6 +291,41 @@ Layout::header('Licenses', 'licenses'); 0 / + +
+ + + + + + β + + +
+
+ + + + + +
+
+ + + + +
+
+
+
@@ -278,7 +384,7 @@ Layout::header('Licenses', 'licenses'); if (!empty($machines)): ?> - +
diff --git a/server/admin/versions.php b/server/admin/versions.php index f31318b..0053b4b 100644 --- a/server/admin/versions.php +++ b/server/admin/versions.php @@ -10,12 +10,41 @@ Auth::requireLogin(); $config = require __DIR__ . '/../api/config.php'; $root = dirname(__DIR__); -$manifestPath = "$root/manifest/versions.json"; +$manifestDir = "$root/manifest"; $buildsDir = "$root/builds"; $notesDir = "$root/releasenotes"; +// Channel actif pour cette session d'édition. ?channel=X bascule sur +// versions-X.json (créé à la volée si nécessaire), vide = manifest default. +// Whitelist regex anti-injection. +$channel = trim((string)($_GET['channel'] ?? $_POST['__channel'] ?? '')); +if ($channel !== '' && !preg_match('/^[a-z0-9_-]{1,64}$/', $channel)) { + $channel = ''; +} +$manifestFileName = $channel === '' ? 'versions.json' : "versions-{$channel}.json"; +$manifestPath = "$manifestDir/$manifestFileName"; + $message = null; $messageType = 'success'; +/** + * Liste les channels existants (= versions-*.json présents) + ajoute toujours + * 'default'. L'admin peut taper n'importe quel nouveau nom dans le formulaire + * "Créer un channel" — le fichier sera créé au premier 'add'. + */ +function listExistingChannels(string $manifestDir): array +{ + $channels = ['' => '(default)']; + if (is_dir($manifestDir)) { + foreach (glob($manifestDir . '/versions-*.json') as $file) { + $name = basename($file); + if (preg_match('/^versions-([a-z0-9_-]{1,64})\.json$/', $name, $m)) { + $channels[$m[1]] = $m[1]; + } + } + } + return $channels; +} + function loadManifest(string $path): array { if (!is_file($path)) return ['schemaVersion' => 1, 'product' => 'PROSERVE_UE', 'latest' => null, 'versions' => []]; @@ -44,6 +73,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { $releasedAt = trim($_POST['released_at'] ?? ''); $notes = $_POST['notes'] ?? ''; $available = isset($_POST['available']); + $isBeta = isset($_POST['is_beta']); + $betaNotes = trim($_POST['beta_notes'] ?? '') ?: null; if (!preg_match('/^\d+\.\d+\.\d+$/', $version)) { throw new Exception('Numéro de version invalide (format X.Y.Z attendu).'); @@ -60,13 +91,16 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { : (new DateTimeImmutable('now', new DateTimeZone('UTC')))->format('Y-m-d\TH:i:s\Z'); $base = $config['base_url'] ?? 'https://asterionvr.com/PS_Launcher'; - $manifest['versions'][] = [ + // Pour un channel non-default, le ZIP vit dans builds/{channel}/. + // L'admin peut surcharger l'URL après-coup si la convention diffère. + $zipPathPrefix = $channel === '' ? 'builds' : "builds/{$channel}"; + $entry = [ 'version' => $version, 'releasedAt' => $releasedAtIso, 'executable' => 'PROSERVE_UE_5_5.exe', 'installFolderTemplate' => 'PROSERVE v{version}', 'download' => [ - 'url' => "{$base}/builds/proserve-{$version}.zip", + 'url' => "{$base}/{$zipPathPrefix}/proserve-{$version}.zip", 'sizeBytes' => 0, 'sha256' => 'REPLACE_AFTER_BUILD', ], @@ -74,6 +108,11 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { 'minLicenseDate' => $minLicDate, 'availableForDownload' => $available, ]; + if ($isBeta) { + $entry['isBeta'] = true; + if ($betaNotes !== null) $entry['betaNotes'] = $betaNotes; + } + $manifest['versions'][] = $entry; saveManifest($manifestPath, $manifest); @@ -111,6 +150,30 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { saveManifest($manifestPath, $manifest); $message = "Méta de v{$version} mises à jour."; } + elseif ($action === 'set_beta') { + $version = $_POST['version'] ?? ''; + $isBeta = !empty($_POST['is_beta']); + $betaNotes = trim($_POST['beta_notes'] ?? '') ?: null; + foreach ($manifest['versions'] as &$v) { + if ($v['version'] === $version) { + if ($isBeta) { + $v['isBeta'] = true; + if ($betaNotes !== null) { + $v['betaNotes'] = $betaNotes; + } else { + unset($v['betaNotes']); + } + } else { + unset($v['isBeta']); + unset($v['betaNotes']); + } + break; + } + } + unset($v); + saveManifest($manifestPath, $manifest); + $message = "Statut BÊTA de v{$version} mis à jour. N'oublie pas de re-signer le manifest (« 🔁 Sync »)."; + } elseif ($action === 'toggle_available') { $version = $_POST['version'] ?? ''; foreach ($manifest['versions'] as &$v) { @@ -134,7 +197,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { } elseif ($action === 'sync' || $action === 'sync_versions') { require_once "$root/tools/SignManifest.php"; - $signer = new \PSLauncher\Tools\SignManifest($root); + $signer = new \PSLauncher\Tools\SignManifest($root, $channel ?: null); $scope = $action === 'sync_versions' ? 'versions' : 'all'; $force = !empty($_POST['force']); $result = $signer->run($scope, $force); @@ -147,7 +210,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { $version = $_POST['version'] ?? ''; if ($version === '') throw new Exception("Version manquante"); require_once "$root/tools/SignManifest.php"; - $signer = new \PSLauncher\Tools\SignManifest($root); + $signer = new \PSLauncher\Tools\SignManifest($root, $channel ?: null); $force = !empty($_POST['force']); $result = $signer->run('versions', $force, $version); $forceLabel = $force ? ' [FORCE]' : ''; @@ -178,7 +241,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { // En mode skip, on n'a rien à hasher : on appelle run() qui se contentera // de détecter hashAlgorithm=none et re-signera. Pas de calcul lourd. require_once "$root/tools/SignManifest.php"; - $signer = new \PSLauncher\Tools\SignManifest($root); + $signer = new \PSLauncher\Tools\SignManifest($root, $channel ?: null); $resign = $signer->run('versions', false); $message = ($skipHash ? "Vérif SHA-256 désactivée pour v{$version} et manifest re-signé." @@ -204,12 +267,43 @@ foreach ($manifest['versions'] ?? [] as $v) { $existingNotes[$v['version']] = is_file($f) ? file_get_contents($f) : ''; } -Layout::header('Versions', 'versions'); + -

Versions

+ +

Versions — channel actif :

+ +
+ Channel à éditer : +
+ +
+ + Les licenses avec ce channel verront ces versions. Le default sert les licenses sans channel. + +
+ + +
+
+

Workflow d'une nouvelle release

    @@ -225,6 +319,7 @@ Layout::header('Versions', 'versions');
    +
    @@ -243,8 +338,17 @@ Layout::header('Versions', 'versions');
    +
    +
    + +
    +
    + +
    +
    - + +
    @@ -292,7 +396,12 @@ Layout::header('Versions', 'versions'); $hashSkipped = strtolower((string)($v['download']['hashAlgorithm'] ?? 'sha256')) === 'none'; ?> - v + + v + + BÊTA + + @@ -393,6 +502,26 @@ Layout::header('Versions', 'versions');
+
+ +
+ + + +
+ +
+
+ + +
+ +

⚠️ Re-signe le manifest (« 🔁 Sync ») après modif pour que les launchers acceptent.

+
+
@@ -437,4 +566,22 @@ Layout::header('Versions', 'versions');
+ 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, ]; diff --git a/server/migrations/002_channel_betas.sql b/server/migrations/002_channel_betas.sql new file mode 100644 index 0000000..891f03d --- /dev/null +++ b/server/migrations/002_channel_betas.sql @@ -0,0 +1,17 @@ +-- PS_Launcher schema v2 +-- Ajoute deux flags par-license : +-- * channel : nom du manifest à servir (NULL = default = manifest/versions.json) +-- * can_see_betas : autorise la license à voir les versions taggées isBeta=true +-- +-- À jouer après 001_init.sql. Idempotent grâce à IF NOT EXISTS (MariaDB 10.4+). +-- Pour MySQL/MariaDB plus ancien sans IF NOT EXISTS sur ALTER, voir variante en +-- bas (mais OVH mutualisé tourne en MariaDB récent, on devrait être bon). + +ALTER TABLE licenses + ADD COLUMN IF NOT EXISTS channel VARCHAR(64) NULL AFTER max_machines, + ADD COLUMN IF NOT EXISTS can_see_betas TINYINT(1) NOT NULL DEFAULT 0 AFTER channel, + ADD INDEX IF NOT EXISTS idx_channel (channel); + +-- Note : pas de migration de data. Les licenses existantes gardent +-- channel = NULL (= manifest default) et can_see_betas = 0 (= pas d'accès aux betas). +-- L'admin attribue manuellement les flags via la page licenses.php. diff --git a/server/tools/SignManifest.php b/server/tools/SignManifest.php index cc302dd..fa6fe62 100644 --- a/server/tools/SignManifest.php +++ b/server/tools/SignManifest.php @@ -17,9 +17,19 @@ final class SignManifest /** @var string[] */ public array $log = []; - public function __construct(string $rootDir) + /** + * @param string $rootDir Racine PS_Launcher (parent de api/, manifest/, builds/). + * @param string|null $channel Channel à signer. NULL/'' = manifest default + * (versions.json). Sinon versions-{channel}.json. Whitelist [a-z0-9_-]{1,64}. + * Le hashcache est partagé entre tous les channels (les ZIPs peuvent être + * référencés par plusieurs manifests, économise les recalculs). + */ + public function __construct(string $rootDir, ?string $channel = null) { - $this->manifestPath = $rootDir . '/manifest/versions.json'; + $manifestFile = ($channel !== null && $channel !== '' && preg_match('/^[a-z0-9_-]{1,64}$/', $channel)) + ? "versions-{$channel}.json" + : 'versions.json'; + $this->manifestPath = $rootDir . '/manifest/' . $manifestFile; $this->buildsDir = $rootDir . '/builds'; $this->configPath = $rootDir . '/api/config.php'; // Cache des hashs déjà calculés. Indexé par chemin absolu du ZIP, contient diff --git a/src/PSLauncher.App/App.xaml.cs b/src/PSLauncher.App/App.xaml.cs index 7b3f6f5..bbcd760 100644 --- a/src/PSLauncher.App/App.xaml.cs +++ b/src/PSLauncher.App/App.xaml.cs @@ -179,6 +179,11 @@ public partial class App : Application new ManifestService( sp.GetRequiredService(), () => sp.GetRequiredService().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().GetCached()?.Channel, sp.GetRequiredService(), sp.GetRequiredService(), sp.GetRequiredService>())); diff --git a/src/PSLauncher.App/PSLauncher.App.csproj b/src/PSLauncher.App/PSLauncher.App.csproj index 244e6b0..89211a0 100644 --- a/src/PSLauncher.App/PSLauncher.App.csproj +++ b/src/PSLauncher.App/PSLauncher.App.csproj @@ -18,9 +18,9 @@ PROSERVE Launcher © 2026 ASTERION VR — All rights reserved PSLauncher.App - 0.25.10 - 0.25.10.0 - 0.25.10.0 + 0.26.0 + 0.26.0.0 + 0.26.0.0 true diff --git a/src/PSLauncher.App/ViewModels/MainViewModel.cs b/src/PSLauncher.App/ViewModels/MainViewModel.cs index 34ee65b..88f5a92 100644 --- a/src/PSLauncher.App/ViewModels/MainViewModel.cs +++ b/src/PSLauncher.App/ViewModels/MainViewModel.cs @@ -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(); + + // 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(); + var remote = canSeeBetas + ? rawRemote + : rawRemote.Where(v => !v.IsBeta).ToList(); var remoteByVer = remote.ToDictionary(v => v.Version); var allVersions = installed.Keys.Union(remoteByVer.Keys) diff --git a/src/PSLauncher.App/ViewModels/VersionRowViewModel.cs b/src/PSLauncher.App/ViewModels/VersionRowViewModel.cs index 2fbe606..f71697b 100644 --- a/src/PSLauncher.App/ViewModels/VersionRowViewModel.cs +++ b/src/PSLauncher.App/ViewModels/VersionRowViewModel.cs @@ -26,6 +26,24 @@ public sealed partial class VersionRowViewModel : ObservableObject public VersionManifest? Remote { get; } public bool HasReleaseNotes => Remote?.ReleaseNotesUrl is not null; + /// + /// True si la version est taggée BÊTA dans le manifest (champ + /// ). Pilote l'affichage du badge + /// orange "BÊTA" + tooltip avec . + /// + public bool IsBeta => Remote?.IsBeta ?? false; + + /// + /// Note des testeurs (texte libre). Affichée en tooltip quand on hover + /// le badge BÊTA. Null si non renseigné côté serveur. + /// + public string? BetaNotes => Remote?.BetaNotes; + + /// Tooltip composé pour le hover sur le badge BÊTA. + public string BetaTooltip => string.IsNullOrEmpty(BetaNotes) + ? Strings.BetaBadgeTooltipDefault + : Strings.BetaBadgeTooltip(BetaNotes!); + [ObservableProperty] [NotifyPropertyChangedFor(nameof(StateLabel))] [NotifyPropertyChangedFor(nameof(IsBusy))] diff --git a/src/PSLauncher.App/Views/MainWindow.xaml b/src/PSLauncher.App/Views/MainWindow.xaml index 459719f..1defddb 100644 --- a/src/PSLauncher.App/Views/MainWindow.xaml +++ b/src/PSLauncher.App/Views/MainWindow.xaml @@ -85,6 +85,17 @@ FontSize="15" FontWeight="SemiBold" VerticalAlignment="Center" Foreground="{StaticResource Brush.Text.Primary}" /> + + + + @@ -472,6 +483,18 @@ + + + + diff --git a/src/PSLauncher.Core/Licensing/LicenseService.cs b/src/PSLauncher.Core/Licensing/LicenseService.cs index 6403c3a..2640294 100644 --- a/src/PSLauncher.Core/Licensing/LicenseService.cs +++ b/src/PSLauncher.Core/Licensing/LicenseService.cs @@ -310,7 +310,10 @@ public sealed class LicenseService : ILicenseService /// 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 { ["status"] = response.Status, @@ -319,6 +322,8 @@ public sealed class LicenseService : ILicenseService ["issuedAt"] = FormatDateAtom(response.IssuedAt), ["downloadEntitlementUntil"] = FormatDateAtom(response.DownloadEntitlementUntil), ["maxMachines"] = response.MaxMachines, + ["channel"] = response.Channel, // null si default + ["canSeeBetas"] = response.CanSeeBetas, ["serverTime"] = FormatDateAtom(response.ServerTime), }; var opts = new JsonSerializerOptions @@ -347,8 +352,18 @@ public sealed class LicenseService : ILicenseService var pkBytes = Convert.FromHexString(publicKeyHex); var pk = PublicKey.Import(alg, pkBytes, KeyBlobFormat.RawPublicKey); var sig = Convert.FromBase64String(base64Signature); - var payload = CanonicalBytesFor(response); - return alg.Verify(pk, payload, sig); + + // Tente d'abord le canonical actuel (avec channel + canSeeBetas). + // Si ça échoue, tente le canonical legacy (sans ces champs) — c'est + // le format des réponses signées par les serveurs OU lancées par + // les clients d'avant v0.26. Sans ce fallback, toutes les licenses + // cachées localement avant l'update à v0.26 deviendraient invalides + // et l'utilisateur serait forcé de revalider online — bloqué hors-ligne. + var payloadCurrent = CanonicalBytesFor(response); + if (alg.Verify(pk, payloadCurrent, sig)) return true; + + var payloadLegacy = CanonicalBytesForLegacy(response); + return alg.Verify(pk, payloadLegacy, sig); } catch { @@ -356,6 +371,33 @@ public sealed class LicenseService : ILicenseService } } + /// + /// Canonical historique (avant v0.26.0) : pas de channel ni + /// canSeeBetas. 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). + /// + private static byte[] CanonicalBytesForLegacy(LicenseValidationResponse response) + { + var dict = new Dictionary + { + ["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() diff --git a/src/PSLauncher.Core/Localization/Strings.cs b/src/PSLauncher.Core/Localization/Strings.cs index ab52540..603c6e4 100644 --- a/src/PSLauncher.Core/Localization/Strings.cs +++ b/src/PSLauncher.Core/Localization/Strings.cs @@ -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 ==================== + /// Texte affiché dans la pill orange à côté de la version. Court et localisé. + public static string BetaBadgeLabel => T("BÊTA", "BETA", "测试版", "เบต้า", "تجريبي"); + + /// Tooltip par défaut quand la version est tag BÊTA mais sans note des testeurs. + 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.", + "测试版 — 用于内部测试。您的反馈有助于我们在正式发布前验证此版本。", + "เวอร์ชันเบต้า — ใช้สำหรับการทดสอบภายใน ความคิดเห็นของคุณช่วยให้เรายืนยันก่อนการเปิดตัวอย่างเป็นทางการ", + "إصدار تجريبي — يُستخدم للاختبار الداخلي. ملاحظاتك تساعدنا في التحقق منه قبل الإصدار الرسمي." + ); + + /// Tooltip avec les notes spécifiques fournies par l'admin côté serveur. + 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", "▶ 启动", "▶ เปิด", "▶ تشغيل"); diff --git a/src/PSLauncher.Core/Manifests/ManifestService.cs b/src/PSLauncher.Core/Manifests/ManifestService.cs index 9304bf5..c0fa53e 100644 --- a/src/PSLauncher.Core/Manifests/ManifestService.cs +++ b/src/PSLauncher.Core/Manifests/ManifestService.cs @@ -17,6 +17,7 @@ public sealed class ManifestService : IManifestService private readonly HttpClient _http; private readonly Func _serverBaseUrlProvider; + private readonly Func _channelProvider; private readonly IManifestCache _manifestCache; private readonly IPeerManifestFetcher _peerManifestFetcher; private readonly ILogger _logger; @@ -26,12 +27,14 @@ public sealed class ManifestService : IManifestService public ManifestService( HttpClient http, Func serverBaseUrlProvider, + Func channelProvider, IManifestCache manifestCache, IPeerManifestFetcher peerManifestFetcher, ILogger 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 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); diff --git a/src/PSLauncher.Models/License.cs b/src/PSLauncher.Models/License.cs index 0b13a0e..5c2d039 100644 --- a/src/PSLauncher.Models/License.cs +++ b/src/PSLauncher.Models/License.cs @@ -22,6 +22,24 @@ public sealed class LicenseValidationResponse [JsonPropertyName("maxMachines")] public int? MaxMachines { get; set; } + /// + /// 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. + /// + [JsonPropertyName("channel")] + public string? Channel { get; set; } + + /// + /// True si la license autorise l'affichage des versions taggées + /// isBeta=true 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). + /// + [JsonPropertyName("canSeeBetas")] + public bool CanSeeBetas { get; set; } + [JsonPropertyName("serverTime")] public DateTime? ServerTime { get; set; } diff --git a/src/PSLauncher.Models/RemoteManifest.cs b/src/PSLauncher.Models/RemoteManifest.cs index e89be88..2caaa79 100644 --- a/src/PSLauncher.Models/RemoteManifest.cs +++ b/src/PSLauncher.Models/RemoteManifest.cs @@ -70,6 +70,22 @@ public sealed class VersionManifest [JsonPropertyName("availableForDownload")] public bool AvailableForDownload { get; set; } = true; + /// + /// Si true, version taggée BÊTA — affichage d'un badge orange dans le + /// launcher + filtrage : seules les licenses avec canSeeBetas=true + /// voient cette version, les autres l'ignorent (dans RebuildList). + /// Default false → comportement standard (visible par tous). + /// + [JsonPropertyName("isBeta")] + public bool IsBeta { get; set; } + + /// + /// Note libre destinée aux testeurs, affichée en tooltip quand on hover le + /// badge BÊTA. Optionnel. + /// + [JsonPropertyName("betaNotes")] + public string? BetaNotes { get; set; } + [JsonPropertyName("heroImageUrl")] public string? HeroImageUrl { get; set; }