From 7a29dbb0492f33cd5d70ad5b0b50cc080156769f Mon Sep 17 00:00:00 2001 From: "j.foucher" Date: Fri, 1 May 2026 10:12:37 +0200 Subject: [PATCH] =?UTF-8?q?v0.4:=20licensing=20=E2=80=94=20server=20valida?= =?UTF-8?q?tion,=20DPAPI=20cache,=20Ed25519=20signed=20responses?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UX bonus: clicking "Reprendre" on an interrupted download skips the release-notes confirmation dialog (the user already approved when they first clicked Installer). Server side ----------- - migrations/001_init.sql: licenses, license_machines, rate_limit, audit_log on InnoDB/utf8mb4. Foreign keys, unique on license_key, slot uniqueness per (license_id, machine_id). - api/lib/Db.php: thin PDO singleton (exception mode, prepared, no emul). - api/lib/Crypto.php: Ed25519 sign/verify via libsodium (sodium_crypto_*), HMAC-SHA-256 helper for v0.6, canonicalJson() that strips `signature` before serializing — must match exactly the encoding done client-side before verify. - api/routes/ValidateLicense.php: POST /license/validate. Looks up the key, walks the machine slot logic (insert or update last_seen), enforces max_machines, returns a payload signed Ed25519 + status of valid/expired/revoked/machine_limit_exceeded/invalid. Audit logs every outcome. Rate-limit 10/min/IP via the rate_limit table. - tools/generate-keypair.php: prints a fresh sodium keypair so the operator drops the hex into config.php and the public_key_hex into the launcher resource. - tools/issue-license.php: PRSRV-XXXX-XXXX-XXXX-XXXX generator (32-char unambiguous alphabet), inserts the license, prints the key once. - tools/sign-manifest.php: now also signs the manifest itself with Ed25519 after computing the per-zip sha256s. - config.example.php: schema rewritten with sections db / hmac / ed25519 / jwt / rate-limit. config.php remains gitignored. Client side ----------- - Models/License.cs: LicenseValidationRequest + LicenseValidationResponse with CanDownload(VersionManifest) — entitlement_until vs version's minLicenseDate. The status valid|expired|revoked|machine_limit_exceeded flow is preserved end-to-end. - Core/Licensing/LicenseService.cs: * machineId = SHA-256 of HKLM/Software/Microsoft/Cryptography/MachineGuid + UserName (stable, no PII leak) * online ValidateAsync calls /license/validate with launcher version * embedded server-pubkey.txt drives Ed25519 verification of the response (skipped gracefully if pubkey not yet provisioned) * SaveCached / GetCached use DPAPI CurrentUser scope on the license key; the cleartext key never touches disk * GetCached has a 7-day offline grace window after the last successful validation, so going offline doesn't lock the user out - Core/Resources/server-pubkey.txt: EmbeddedResource. Default content is a comment, which the service treats as "no pubkey configured" and bypasses verification. Operator pastes the real hex post-deploy and rebuilds. - Core/PSLauncher.Core.csproj: Polly, NSec.Cryptography (Ed25519), System.Security.Cryptography.ProtectedData (DPAPI). - App/Views/OnboardingDialog.xaml(.cs): first-launch / "🔑 Activer" modal. Calls LicenseService, displays status messages with red foreground on errors and green-tinted secondary text otherwise. - ViewModels/VersionRowViewModel.cs: new LicenseAllowsDownload property. Install button label switches to "🔒 License insuffisante" when the user's entitlement_until precedes the version's minLicenseDate; CanInstall is false in that case so the click is a no-op too. - ViewModels/MainViewModel.cs: loads the cached license at startup (no network call), surfaces it as LicenseSummary in the top bar, exposes ActivateLicenseCommand to (re)open the onboarding dialog. RebuildList applies the per-version license filter so older installed versions remain launchable but newer-than-license ones can't be downloaded. - Views/MainWindow.xaml: top bar gains a "🔑 Activer / changer" button next to the license summary. Co-Authored-By: Claude Opus 4.7 (1M context) --- server/api/config.example.php | 30 ++- server/api/index.php | 8 + server/api/lib/Crypto.php | 65 +++++ server/api/lib/Db.php | 26 ++ server/api/routes/ValidateLicense.php | 135 ++++++++++ server/migrations/001_init.sql | 49 ++++ server/tools/generate-keypair.php | 22 ++ server/tools/issue-license.php | 63 +++++ server/tools/sign-manifest.php | 23 +- src/PSLauncher.App/App.xaml.cs | 9 + .../ViewModels/MainViewModel.cs | 78 ++++-- .../ViewModels/VersionRowViewModel.cs | 10 +- src/PSLauncher.App/Views/MainWindow.xaml | 5 +- .../Views/OnboardingDialog.xaml | 66 +++++ .../Views/OnboardingDialog.xaml.cs | 91 +++++++ .../Licensing/ILicenseService.cs | 14 + .../Licensing/LicenseService.cs | 253 ++++++++++++++++++ src/PSLauncher.Core/PSLauncher.Core.csproj | 6 + .../Resources/server-pubkey.txt | 6 + src/PSLauncher.Models/License.cs | 52 ++++ 20 files changed, 977 insertions(+), 34 deletions(-) create mode 100644 server/api/lib/Crypto.php create mode 100644 server/api/lib/Db.php create mode 100644 server/api/routes/ValidateLicense.php create mode 100644 server/migrations/001_init.sql create mode 100644 server/tools/generate-keypair.php create mode 100644 server/tools/issue-license.php create mode 100644 src/PSLauncher.App/Views/OnboardingDialog.xaml create mode 100644 src/PSLauncher.App/Views/OnboardingDialog.xaml.cs create mode 100644 src/PSLauncher.Core/Licensing/ILicenseService.cs create mode 100644 src/PSLauncher.Core/Licensing/LicenseService.cs create mode 100644 src/PSLauncher.Core/Resources/server-pubkey.txt create mode 100644 src/PSLauncher.Models/License.cs diff --git a/server/api/config.example.php b/server/api/config.example.php index 34c30f1..3103896 100644 --- a/server/api/config.example.php +++ b/server/api/config.example.php @@ -1,30 +1,36 @@ 'https://www.exemple-asterion.com/PS_Launcher', + // Base path public sous lequel le launcher est servi + 'base_url' => 'https://asterionvr.com/PS_Launcher', - // MySQL (utilisé à partir de v0.4, license) + // MySQL (depuis le manager OVH : Hébergements -> Bases de données) 'db' => [ - 'dsn' => 'mysql:host=localhost;dbname=pslauncher;charset=utf8mb4', + // Format DSN OVH typique : mysql:host=.mysql.db;dbname=;charset=utf8mb4 + 'dsn' => 'mysql:host=localhost;dbname=replace_me;charset=utf8mb4', 'username' => 'replace_me', 'password' => 'replace_me', ], - // HMAC secret pour les URLs présignées de /builds/ (v0.6) + // HMAC secret pour les URLs présignées de /builds/ (v0.6, optionnel) + // Génère 64 hex chars : `php -r "echo bin2hex(random_bytes(32));"` 'hmac_secret' => 'replace_with_random_64_bytes_hex', - // Clés Ed25519 pour signer manifest et réponses license (v0.4) - // Génère un keypair via tools/generate-keypair.php + // Clés Ed25519 pour signer la réponse de validation license et le manifest. + // Génère le keypair via : `php tools/generate-keypair.php` + // Recopie les hex strings ci-dessous, et embarque la public_key_hex dans le launcher. 'ed25519' => [ - 'private_key_hex' => '', // 64 bytes hex - 'public_key_hex' => '', // 32 bytes hex (à embarquer aussi dans le launcher) + 'private_key_hex' => '', // 128 hex chars (sodium secret key, contient la pub key) + 'public_key_hex' => '', // 64 hex chars (32 bytes) ], - // JWT (v0.4) + // (v0.4-β) JWT pour les URLs de download protégées (optionnel pour le moment) 'jwt_secret' => 'replace_with_random_secret', 'jwt_ttl_seconds' => 900, // 15 min + + // Limites de validation + 'validate_max_per_minute_per_ip' => 10, ]; diff --git a/server/api/index.php b/server/api/index.php index 2b62eb1..994224d 100644 --- a/server/api/index.php +++ b/server/api/index.php @@ -52,6 +52,14 @@ if ($method === 'GET' && preg_match('#^releasenotes/([0-9]+\.[0-9]+\.[0-9]+)$#', return; } +if ($method === 'POST' && $route === 'license/validate') { + require __DIR__ . '/lib/Db.php'; + require __DIR__ . '/lib/Crypto.php'; + require __DIR__ . '/routes/ValidateLicense.php'; + \PSLauncher\Routes\ValidateLicense::handle($config); + return; +} + if ($method === 'GET' && $route === 'health') { Response::json([ 'status' => 'ok', diff --git a/server/api/lib/Crypto.php b/server/api/lib/Crypto.php new file mode 100644 index 0000000..0556f43 --- /dev/null +++ b/server/api/lib/Crypto.php @@ -0,0 +1,65 @@ + sodium_bin2hex($sk), + 'public_key_hex' => sodium_bin2hex($pk), + ]; + } + + /** + * Encodage canonical d'un objet JSON pour la signature : on retire le champ + * `signature` (s'il est là), on encode sans escapes inutiles, on signe ce blob. + * Le client doit faire EXACTEMENT le même encodage avant verify. + */ + public static function canonicalJson(array $data): string + { + unset($data['signature']); + return json_encode( + $data, + JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRESERVE_ZERO_FRACTION + ); + } + + /** + * Hash bcrypt-comparable d'une license_key (constant-time compare). + */ + public static function constantTimeEquals(string $a, string $b): bool + { + return hash_equals($a, $b); + } + + /** + * HMAC-SHA-256 hex pour les URLs présignées (v0.6). + */ + public static function hmacHex(string $message, string $secret): string + { + return hash_hmac('sha256', $message, $secret); + } +} diff --git a/server/api/lib/Db.php b/server/api/lib/Db.php new file mode 100644 index 0000000..7a81c14 --- /dev/null +++ b/server/api/lib/Db.php @@ -0,0 +1,26 @@ + \PDO::ERRMODE_EXCEPTION, + \PDO::ATTR_DEFAULT_FETCH_MODE => \PDO::FETCH_ASSOC, + \PDO::ATTR_EMULATE_PREPARES => false, + ] + ); + return self::$pdo; + } +} diff --git a/server/api/routes/ValidateLicense.php b/server/api/routes/ValidateLicense.php new file mode 100644 index 0000000..af21cfe --- /dev/null +++ b/server/api/routes/ValidateLicense.php @@ -0,0 +1,135 @@ +format('Y-m-d H:i:s'); + + $stmt = $db->prepare( + 'SELECT id, license_key, owner_name, issued_at, download_entitlement_until, + max_machines, revoked_at + FROM licenses WHERE license_key = ? LIMIT 1' + ); + $stmt->execute([$licenseKey]); + $lic = $stmt->fetch(); + + if (!$lic) { + self::audit($db, null, $ip, 'validate_invalid', ['key_prefix' => substr($licenseKey, 0, 6)]); + Response::error('invalid', 'Clé de license invalide', 401); + } + if ($lic['revoked_at'] !== null) { + self::audit($db, (int)$lic['id'], $ip, 'validate_revoked', []); + Response::error('revoked', 'License révoquée', 403); + } + + // Slot machine + $slotStmt = $db->prepare( + 'SELECT id FROM license_machines WHERE license_id = ? AND machine_id = ? LIMIT 1' + ); + $slotStmt->execute([$lic['id'], $machineId]); + $existingSlot = $slotStmt->fetch(); + + if ($existingSlot) { + $db->prepare('UPDATE license_machines SET last_seen = ? WHERE id = ?') + ->execute([$now, $existingSlot['id']]); + } else { + $countStmt = $db->prepare('SELECT COUNT(*) AS c FROM license_machines WHERE license_id = ?'); + $countStmt->execute([$lic['id']]); + $count = (int)($countStmt->fetch()['c'] ?? 0); + if ($count >= (int)$lic['max_machines']) { + self::audit($db, (int)$lic['id'], $ip, 'machine_limit', ['count' => $count]); + Response::error('machine_limit_exceeded', + "Cette license autorise {$lic['max_machines']} machine(s) ; toutes les places sont prises.", + 403, ['ownerName' => $lic['owner_name']]); + } + $db->prepare( + 'INSERT INTO license_machines (license_id, machine_id, machine_label, first_seen, last_seen) + VALUES (?, ?, ?, ?, ?)' + )->execute([$lic['id'], $machineId, $machineLabel ?: null, $now, $now]); + } + + // Construit la réponse, signe-la, renvoie + $entUntil = (new \DateTimeImmutable($lic['download_entitlement_until']))->format(\DateTimeInterface::ATOM); + $serverTime = (new \DateTimeImmutable('now'))->format(\DateTimeInterface::ATOM); + $expired = strtotime($lic['download_entitlement_until']) < time(); + + $payload = [ + 'status' => $expired ? 'expired' : 'valid', + 'licenseId' => 'lic_' . $lic['id'], + 'ownerName' => $lic['owner_name'], + 'issuedAt' => (new \DateTimeImmutable($lic['issued_at']))->format(\DateTimeInterface::ATOM), + 'downloadEntitlementUntil' => $entUntil, + 'maxMachines' => (int)$lic['max_machines'], + 'serverTime' => $serverTime, + ]; + + // Signature Ed25519 du payload canonical + $sk = $config['ed25519']['private_key_hex'] ?? ''; + if ($sk !== '') { + $payload['signature'] = Crypto::signEd25519(Crypto::canonicalJson($payload), $sk); + } + + self::audit($db, (int)$lic['id'], $ip, $expired ? 'validate_expired' : 'validate_ok', [ + 'launcher_version' => $launcherVer, + ]); + + Response::json($payload, $expired ? 200 : 200); + } + + private static function enforceRateLimit(array $config, string $ip): void + { + $max = (int)($config['validate_max_per_minute_per_ip'] ?? 10); + if ($max <= 0) return; + + $db = Db::get($config); + $now = new \DateTimeImmutable('now'); + $window = $now->modify('-1 minute')->format('Y-m-d H:i:s'); + + $stmt = $db->prepare('SELECT counter, window_start FROM rate_limit WHERE ip = ?'); + $stmt->execute([$ip]); + $row = $stmt->fetch(); + + if ($row === false || $row['window_start'] < $window) { + $db->prepare('REPLACE INTO rate_limit (ip, window_start, counter) VALUES (?, ?, 1)') + ->execute([$ip, $now->format('Y-m-d H:i:s')]); + return; + } + + if ((int)$row['counter'] >= $max) { + Response::error('rate_limited', 'Trop de tentatives. Réessaie dans 1 minute.', 429); + } + $db->prepare('UPDATE rate_limit SET counter = counter + 1 WHERE ip = ?')->execute([$ip]); + } + + private static function audit(\PDO $db, ?int $licenseId, string $ip, string $event, array $detail): void + { + try { + $db->prepare( + 'INSERT INTO audit_log (ts, license_id, ip, event, detail) VALUES (NOW(), ?, ?, ?, ?)' + )->execute([$licenseId, $ip, $event, json_encode($detail, JSON_UNESCAPED_UNICODE)]); + } catch (\Throwable) { /* best-effort */ } + } +} diff --git a/server/migrations/001_init.sql b/server/migrations/001_init.sql new file mode 100644 index 0000000..650fc35 --- /dev/null +++ b/server/migrations/001_init.sql @@ -0,0 +1,49 @@ +-- PS_Launcher schema v1 +-- À jouer dans la base MySQL OVH créée via le manager. +-- Charset : utf8mb4 obligatoire (ownership names accentués, JSON details). + +CREATE TABLE IF NOT EXISTS licenses ( + id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT, + license_key VARCHAR(64) NOT NULL UNIQUE, + owner_name VARCHAR(255) NOT NULL, + issued_at DATETIME NOT NULL, + download_entitlement_until DATETIME NOT NULL, + max_machines INT UNSIGNED NOT NULL DEFAULT 1, + revoked_at DATETIME NULL, + notes TEXT NULL, + INDEX idx_license_key (license_key), + INDEX idx_revoked (revoked_at), + INDEX idx_entitlement (download_entitlement_until) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS license_machines ( + id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT, + license_id BIGINT UNSIGNED NOT NULL, + machine_id VARCHAR(128) NOT NULL, + machine_label VARCHAR(255) NULL, + first_seen DATETIME NOT NULL, + last_seen DATETIME NOT NULL, + UNIQUE KEY uk_license_machine (license_id, machine_id), + INDEX idx_last_seen (last_seen), + CONSTRAINT fk_machine_license FOREIGN KEY (license_id) + REFERENCES licenses(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS rate_limit ( + ip VARCHAR(45) PRIMARY KEY, + window_start DATETIME NOT NULL, + counter INT UNSIGNED NOT NULL DEFAULT 0 +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS audit_log ( + id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT, + ts DATETIME NOT NULL, + license_id BIGINT UNSIGNED NULL, + ip VARCHAR(45) NULL, + event VARCHAR(64) NOT NULL, + detail JSON NULL, + INDEX idx_ts (ts), + INDEX idx_event (event), + CONSTRAINT fk_audit_license FOREIGN KEY (license_id) + REFERENCES licenses(id) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/server/tools/generate-keypair.php b/server/tools/generate-keypair.php new file mode 100644 index 0000000..11288b7 --- /dev/null +++ b/server/tools/generate-keypair.php @@ -0,0 +1,22 @@ +\" [max_machines=1]\n"); + exit(1); +} + +$owner = $argv[1]; +$until = $argv[2]; +$maxMachines = (int)($argv[3] ?? 1); + +if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $until)) { + fwrite(STDERR, "entitlement_until must be YYYY-MM-DD\n"); exit(2); +} + +$db = \PSLauncher\Db::get($config); + +// Génère une clé du type PRSRV-XXXX-XXXX-XXXX-XXXX (groupes alphanumériques) +function genKey(): string { + $alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; // pas de 0/O/1/I/L + $groups = []; + for ($g = 0; $g < 4; $g++) { + $s = ''; + for ($i = 0; $i < 4; $i++) $s .= $alphabet[random_int(0, strlen($alphabet) - 1)]; + $groups[] = $s; + } + return 'PRSRV-' . implode('-', $groups); +} + +// Boucle au cas (très improbable) où collision UNIQUE +for ($attempts = 0; $attempts < 5; $attempts++) { + $key = genKey(); + try { + $stmt = $db->prepare( + 'INSERT INTO licenses (license_key, owner_name, issued_at, download_entitlement_until, max_machines) + VALUES (?, ?, NOW(), ?, ?)' + ); + $stmt->execute([$key, $owner, $until . ' 23:59:59', $maxMachines]); + $id = $db->lastInsertId(); + echo "==== License émise ====\n"; + echo "id : {$id}\n"; + echo "owner : {$owner}\n"; + echo "until : {$until}\n"; + echo "key : {$key}\n"; + exit(0); + } catch (\PDOException $e) { + if (str_contains($e->getMessage(), 'Duplicate')) continue; + throw $e; + } +} +fwrite(STDERR, "Failed to generate unique key after retries\n"); exit(3); diff --git a/server/tools/sign-manifest.php b/server/tools/sign-manifest.php index 7608476..97e0359 100644 --- a/server/tools/sign-manifest.php +++ b/server/tools/sign-manifest.php @@ -78,11 +78,24 @@ if (!empty($hashedVersions)) { } } -// (v0.4) Signature Ed25519 — pour l'instant on laisse 'signature' = null. -// $config = require __DIR__ . '/../api/config.php'; -// $sk = sodium_hex2bin($config['ed25519']['private_key_hex']); -// $payload = json_encode($manifest, JSON_UNESCAPED_SLASHES); -// $manifest['signature'] = base64_encode(sodium_crypto_sign_detached($payload, $sk)); +// (v0.4) Signature Ed25519 du manifest +$configPath = dirname(__DIR__) . '/api/config.php'; +if (is_file($configPath)) { + require_once dirname(__DIR__) . '/api/lib/Crypto.php'; + $config = require $configPath; + $sk = $config['ed25519']['private_key_hex'] ?? ''; + if ($sk !== '' && strlen($sk) === 128) { + // Retire signature précédente, encode canonical, signe, ré-injecte + $manifest['signature'] = null; + $payload = \PSLauncher\Crypto::canonicalJson($manifest); + $manifest['signature'] = \PSLauncher\Crypto::signEd25519($payload, $sk); + echo " [sign] manifest signed (Ed25519)\n"; + } else { + echo " [warn] ed25519.private_key_hex non configuré, manifest non signé\n"; + } +} else { + echo " [warn] config.php absent, manifest non signé\n"; +} file_put_contents( $manifestPath, diff --git a/src/PSLauncher.App/App.xaml.cs b/src/PSLauncher.App/App.xaml.cs index dd5c313..13a48d6 100644 --- a/src/PSLauncher.App/App.xaml.cs +++ b/src/PSLauncher.App/App.xaml.cs @@ -10,6 +10,7 @@ using PSLauncher.Core.Configuration; using PSLauncher.Core.Downloads; using PSLauncher.Core.Installations; using PSLauncher.Core.Integrity; +using PSLauncher.Core.Licensing; using PSLauncher.Core.Manifests; using PSLauncher.Core.Process; using PSLauncher.Core.Updates; @@ -66,6 +67,14 @@ public partial class App : Application services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(sp => + new LicenseService( + sp.GetRequiredService(), + () => sp.GetRequiredService().ServerBaseUrl, + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService>())); + services.AddSingleton(); services.AddSingleton(); }) diff --git a/src/PSLauncher.App/ViewModels/MainViewModel.cs b/src/PSLauncher.App/ViewModels/MainViewModel.cs index 9f28376..0b36e9c 100644 --- a/src/PSLauncher.App/ViewModels/MainViewModel.cs +++ b/src/PSLauncher.App/ViewModels/MainViewModel.cs @@ -9,6 +9,7 @@ using PSLauncher.App.Views; using PSLauncher.Core.Configuration; using PSLauncher.Core.Downloads; using PSLauncher.Core.Installations; +using PSLauncher.Core.Licensing; using PSLauncher.Core.Manifests; using PSLauncher.Core.Process; using PSLauncher.Core.Updates; @@ -26,8 +27,11 @@ public sealed partial class MainViewModel : ObservableObject private readonly IUpdateChecker _updateChecker; private readonly IDownloadManager _downloadManager; private readonly IZipInstaller _zipInstaller; + private readonly ILicenseService _licenseService; private readonly ILogger _logger; + private LicenseValidationResponse? _license; + private RemoteManifest? _lastManifest; private CancellationTokenSource? _activeDownloadCts; private VersionRowViewModel? _activeRow; @@ -57,7 +61,18 @@ public sealed partial class MainViewModel : ObservableObject [ObservableProperty] private double _progressPercent; [ObservableProperty] private string? _progressDetail; - public string LicenseSummary => "License : non configurée (v0.4)"; + public string LicenseSummary + { + get + { + if (_license is null) return "License : non configurée"; + if (_license.Status == "valid") + return $"License : {_license.OwnerName} • exp. {_license.DownloadEntitlementUntil:dd/MM/yyyy}"; + if (_license.Status == "expired") + return $"License expirée le {_license.DownloadEntitlementUntil:dd/MM/yyyy}"; + return $"License : {_license.Status}"; + } + } public string EmptyHint => "Aucune version locale ni distante.\n" + @@ -85,6 +100,7 @@ public sealed partial class MainViewModel : ObservableObject IUpdateChecker updateChecker, IDownloadManager downloadManager, IZipInstaller zipInstaller, + ILicenseService licenseService, ILogger logger) { _registry = registry; @@ -95,7 +111,13 @@ public sealed partial class MainViewModel : ObservableObject _updateChecker = updateChecker; _downloadManager = downloadManager; _zipInstaller = zipInstaller; + _licenseService = licenseService; _logger = logger; + + // Charge la license depuis le cache (pas d'appel réseau au démarrage, + // ça reste rapide ; un refresh proactif arrive dès qu'on clique « Vérifier les MAJ ») + _license = _licenseService.GetCached(); + RebuildList(); } @@ -131,10 +153,15 @@ public sealed partial class MainViewModel : ObservableObject } // Marque les rows distantes qui ont un DL en pause (partial + state.json présents) + // et applique le filtre license foreach (var r in rows.Where(r => r.IsRemoteOnly)) { var st = _downloadManager.GetResumableState(r.Version); if (st is not null) r.ResumableBytes = st.DownloadedBytes; + + // License : la version est-elle téléchargeable selon notre entitlement ? + r.LicenseAllowsDownload = r.Remote is not null + && _licenseService.CanDownloadVersion(_license, r.Remote); } // Featured : plus haute installée, sinon plus haute distante @@ -199,6 +226,20 @@ public sealed partial class MainViewModel : ObservableObject } private bool CanCheckUpdates() => !IsBusy; + [RelayCommand] + private async Task ActivateLicenseAsync() + { + var dialog = new Views.OnboardingDialog(_licenseService) { Owner = Application.Current.MainWindow }; + dialog.ShowDialog(); + if (dialog.LicenseActivated) + { + _license = _licenseService.GetCached(); + OnPropertyChanged(nameof(LicenseSummary)); + RebuildList(); + } + await Task.CompletedTask; + } + [RelayCommand] private void OpenInstallRoot() { @@ -245,21 +286,28 @@ public sealed partial class MainViewModel : ObservableObject try { - // 1) Récupère release notes (best effort) - string notes = "_Aucune release note fournie._"; - if (!string.IsNullOrEmpty(row.Remote.ReleaseNotesUrl)) - { - try - { - notes = await _manifestService.FetchReleaseNotesAsync(row.Remote.ReleaseNotesUrl, ct); - } - catch (Exception ex) { _logger.LogWarning(ex, "Release notes fetch failed"); notes = "_Release notes indisponibles._"; } - } + // Si reprise d'un DL interrompu, on saute la popup de release notes : + // l'utilisateur a déjà confirmé sa décision la première fois. + var isResume = row.HasResumableDownload; - // 2) Dialog de confirmation avec release notes - var dialog = new UpdateAvailableDialog(row.Remote, notes) { Owner = Application.Current.MainWindow }; - dialog.ShowDialog(); - if (!dialog.DownloadRequested) return; + if (!isResume) + { + // 1) Récupère release notes (best effort) + string notes = "_Aucune release note fournie._"; + if (!string.IsNullOrEmpty(row.Remote.ReleaseNotesUrl)) + { + try + { + notes = await _manifestService.FetchReleaseNotesAsync(row.Remote.ReleaseNotesUrl, ct); + } + catch (Exception ex) { _logger.LogWarning(ex, "Release notes fetch failed"); notes = "_Release notes indisponibles._"; } + } + + // 2) Dialog de confirmation avec release notes + var dialog = new UpdateAvailableDialog(row.Remote, notes) { Owner = Application.Current.MainWindow }; + dialog.ShowDialog(); + if (!dialog.DownloadRequested) return; + } // 3) Download row.State = VersionRowState.Downloading; diff --git a/src/PSLauncher.App/ViewModels/VersionRowViewModel.cs b/src/PSLauncher.App/ViewModels/VersionRowViewModel.cs index a4b043a..8524131 100644 --- a/src/PSLauncher.App/ViewModels/VersionRowViewModel.cs +++ b/src/PSLauncher.App/ViewModels/VersionRowViewModel.cs @@ -42,12 +42,20 @@ public sealed partial class VersionRowViewModel : ObservableObject [NotifyPropertyChangedFor(nameof(HasResumableDownload))] private long _resumableBytes; + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(InstallButtonLabel))] + [NotifyPropertyChangedFor(nameof(LicenseAllowsInstall))] + [NotifyCanExecuteChangedFor(nameof(InstallCommand))] + private bool _licenseAllowsDownload = true; + public bool HasResumableDownload => ResumableBytes > 0; + public bool LicenseAllowsInstall => LicenseAllowsDownload; public string InstallButtonLabel { get { + if (!LicenseAllowsDownload) return "🔒 License insuffisante"; if (!HasResumableDownload) return "⬇ Installer"; if (Remote is null || Remote.Download.SizeBytes <= 0) return "↻ Reprendre"; var pct = (double)ResumableBytes / Remote.Download.SizeBytes * 100.0; @@ -133,7 +141,7 @@ public sealed partial class VersionRowViewModel : ObservableObject [RelayCommand(CanExecute = nameof(CanInstall))] private void Install() => InstallHandler?.Invoke(this); - private bool CanInstall() => State == VersionRowState.AvailableIdle; + private bool CanInstall() => State == VersionRowState.AvailableIdle && LicenseAllowsDownload; [RelayCommand(CanExecute = nameof(CanUninstall))] private void Uninstall() => UninstallHandler?.Invoke(this); diff --git a/src/PSLauncher.App/Views/MainWindow.xaml b/src/PSLauncher.App/Views/MainWindow.xaml index c114fb8..9609387 100644 --- a/src/PSLauncher.App/Views/MainWindow.xaml +++ b/src/PSLauncher.App/Views/MainWindow.xaml @@ -172,7 +172,10 @@ Margin="0,0,12,0" /> + VerticalAlignment="Center" Margin="0,0,8,0" /> +