From b10a3fbabf8034e2495132f4ff81fbe86531e296 Mon Sep 17 00:00:00 2001 From: "j.foucher" Date: Sat, 2 May 2026 09:24:01 +0200 Subject: [PATCH] UI overhaul: minimal top bar, license-first settings, footer-pinned check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Top bar (3-column layout) ------------------------- - Col 1: PROSERVE Launcher wordmark. - Col 2: license badge centered, the badge IS the click target now (a Border with an InputBindings MouseBinding LeftClick to OpenLicense). No more separate "🔑 Activer / changer" button cluttering the right. - Col 3: ⚙ ParamĂštres + window chrome (min/max/close). - "📁 Dossier" button removed from the top bar — install root is still reachable from Settings. Footer (always visible) ----------------------- - Row 1: "🔄 VĂ©rifier les MAJ" pinned bottom-left, always shown. The optional "Annuler" button stays bottom-right while a download runs. - Row 2: status text + progress bar, only shown when busy or after a status message — the previous "footer entirely hidden when idle" hid the check button too. License flow split in two ------------------------- Click on the license badge: - License is active (valid / expired / revoked) → LicenseDetailsDialog opens. Header pill in matching status color (green/amber/red), shows owner, validity, issued date, machine ID with copy-to-clipboard. Two buttons: "🗑 DĂ©sactiver la license" (with confirmation) and Close. - No license OR after deactivation → falls through to the existing OnboardingDialog for re-keying. Settings rework --------------- LICENSE section is now first in SettingsDialog with the same green/amber/red colored chrome as the top bar — at a glance the user sees the same status everywhere. Machine ID copy moved into this card. Sign-manifest no longer needs exec() ------------------------------------ The "🔁 Sync" button in admin/versions.php previously shelled out to `php tools/sign-manifest.php` via exec(). OVH mutualisĂ© often disables exec(), causing silent no-ops and the symptom the user just hit: the ZIP changed (new size 469,657,770 vs manifest's stale 469,831,428) and the launcher rejected it as size mismatch. Refactor: - New PSLauncher\Tools\SignManifest class with ->run() that does the hashing, latest-bump and Ed25519 signing in-process. - tools/sign-manifest.php is now a 6-line wrapper for the class. - admin/versions.php's 'sync' action calls the class directly via require_once + new — works on any host, no exec dependency. Co-Authored-By: Claude Opus 4.7 (1M context) --- server/admin/versions.php | 13 +- server/tools/SignManifest.php | 124 +++++++++++ server/tools/sign-manifest.php | 104 +-------- .../ViewModels/MainViewModel.cs | 37 +++- .../ViewModels/SettingsViewModel.cs | 46 +++- .../Views/LicenseDetailsDialog.xaml | 113 ++++++++++ .../Views/LicenseDetailsDialog.xaml.cs | 86 ++++++++ src/PSLauncher.App/Views/MainWindow.xaml | 207 +++++++++--------- src/PSLauncher.App/Views/SettingsDialog.xaml | 125 +++++++---- 9 files changed, 609 insertions(+), 246 deletions(-) create mode 100644 server/tools/SignManifest.php create mode 100644 src/PSLauncher.App/Views/LicenseDetailsDialog.xaml create mode 100644 src/PSLauncher.App/Views/LicenseDetailsDialog.xaml.cs diff --git a/server/admin/versions.php b/server/admin/versions.php index f78b691..09534d6 100644 --- a/server/admin/versions.php +++ b/server/admin/versions.php @@ -133,12 +133,13 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { $message = "v{$version} retirĂ©e du manifest. Le ZIP reste dans builds/ (Ă  supprimer en SFTP si voulu)."; } elseif ($action === 'sync') { - $output = []; - $exitCode = 0; - $cwd = escapeshellarg($root); - exec("cd $cwd && php tools/sign-manifest.php 2>&1", $output, $exitCode); - $message = "Sortie du script :\n" . implode("\n", $output); - if ($exitCode !== 0) $messageType = 'error'; + // Appel direct Ă  la classe — pas d'exec(), fonctionne mĂȘme quand la fonction + // est dĂ©sactivĂ©e par le mutualisĂ© OVH. + require_once "$root/tools/SignManifest.php"; + $signer = new \PSLauncher\Tools\SignManifest($root); + $result = $signer->run(); + $message = "Sortie du script :\n" . implode("\n", $result['log']); + if (!$result['ok']) $messageType = 'error'; } } catch (Exception $e) { $message = $e->getMessage(); diff --git a/server/tools/SignManifest.php b/server/tools/SignManifest.php new file mode 100644 index 0000000..03e479b --- /dev/null +++ b/server/tools/SignManifest.php @@ -0,0 +1,124 @@ +manifestPath = $rootDir . '/manifest/versions.json'; + $this->buildsDir = $rootDir . '/builds'; + $this->configPath = $rootDir . '/api/config.php'; + } + + private function out(string $line): void { $this->log[] = $line; } + + /** + * Recompute sizes / sha256 for every version that has its ZIP uploaded, + * bump 'latest' to the highest signed version, then sign the manifest + * with the Ed25519 private key from config.php (if available). + * + * @return array{ok:bool, log:string[]} + */ + public function run(): array + { + if (!is_file($this->manifestPath)) { + $this->out("Manifest introuvable : {$this->manifestPath}"); + return ['ok' => false, 'log' => $this->log]; + } + + $manifest = json_decode(file_get_contents($this->manifestPath), true); + if (!is_array($manifest)) { + $this->out("Manifest illisible (JSON invalide)"); + return ['ok' => false, 'log' => $this->log]; + } + + $hashedVersions = []; + foreach ($manifest['versions'] as &$v) { + $version = $v['version'] ?? '?'; + $url = $v['download']['url'] ?? ''; + if ($url === '') { + $this->out(" [skip] $version : pas d'URL dans le manifest"); + continue; + } + + $filename = basename(parse_url($url, PHP_URL_PATH) ?: ''); + $zip = "{$this->buildsDir}/$filename"; + + if (!is_file($zip)) { + $candidates = glob("{$this->buildsDir}/*{$version}*.zip", GLOB_NOSORT) ?: []; + $candidates = array_values(array_filter($candidates, 'is_file')); + if (count($candidates) === 1) { + $zip = $candidates[0]; + $this->out(" [info] $version : URL pointait vers '{$filename}', utilisĂ© '" . basename($zip) . "' Ă  la place"); + } else { + $this->out(" [skip] $version : ZIP introuvable pour $url"); + if (count($candidates) > 1) { + $this->out(" Plusieurs candidats : " . implode(', ', array_map('basename', $candidates))); + } + continue; + } + } + + $size = filesize($zip); + $sha = hash_file('sha256', $zip); + $this->out(" [hash] $version : " . basename($zip) . " ($size octets) sha256={$sha}"); + + $v['download']['sizeBytes'] = $size; + $v['download']['sha256'] = $sha; + $hashedVersions[] = $version; + } + unset($v); + + // Bump auto de `latest` sur la plus haute version effectivement uploadĂ©e + if (!empty($hashedVersions)) { + usort($hashedVersions, 'version_compare'); + $newLatest = end($hashedVersions); + if (($manifest['latest'] ?? null) !== $newLatest) { + $this->out(" [latest] " . ($manifest['latest'] ?? '(none)') . " -> {$newLatest}"); + $manifest['latest'] = $newLatest; + } + } + + // Signature Ed25519 + if ($this->configPath && is_file($this->configPath)) { + $config = require $this->configPath; + $sk = $config['ed25519']['private_key_hex'] ?? ''; + if ($sk !== '' && strlen($sk) === 128) { + $manifest['signature'] = null; + $cryptoPath = dirname($this->configPath) . '/lib/Crypto.php'; + if (is_file($cryptoPath)) require_once $cryptoPath; + if (class_exists('\PSLauncher\Crypto')) { + $payload = \PSLauncher\Crypto::canonicalJson($manifest); + $manifest['signature'] = \PSLauncher\Crypto::signEd25519($payload, $sk); + $this->out(" [sign] manifest signĂ© (Ed25519)"); + } else { + $this->out(" [warn] Classe \\PSLauncher\\Crypto introuvable, manifest non signĂ©"); + } + } else { + $this->out(" [warn] ed25519.private_key_hex non configurĂ©, manifest non signĂ©"); + } + } + + $jsonOut = json_encode($manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n"; + if (file_put_contents($this->manifestPath, $jsonOut) === false) { + $this->out("Échec d'Ă©criture du manifest : {$this->manifestPath}"); + return ['ok' => false, 'log' => $this->log]; + } + + $this->out("Manifest mis Ă  jour (" . count($hashedVersions) . " version(s))."); + return ['ok' => true, 'log' => $this->log]; + } +} diff --git a/server/tools/sign-manifest.php b/server/tools/sign-manifest.php index 97e0359..927aa7b 100644 --- a/server/tools/sign-manifest.php +++ b/server/tools/sign-manifest.php @@ -1,104 +1,16 @@ 1) { - echo " Plusieurs candidats : " . implode(', ', array_map('basename', $candidates)) . "\n"; - } - continue; - } - } - - $size = filesize($zip); - echo " [hash] $version : " . basename($zip) . " ($size octets)..."; - $sha = hash_file('sha256', $zip); - echo " sha256={$sha}\n"; - - $v['download']['sizeBytes'] = $size; - $v['download']['sha256'] = $sha; - $updated++; - $hashedVersions[] = $version; -} -unset($v); - -// Met Ă  jour automatiquement le champ `latest` avec la plus haute version -// effectivement uploadĂ©e (celle pour laquelle on a calculĂ© un hash). -if (!empty($hashedVersions)) { - usort($hashedVersions, function ($a, $b) { - return version_compare($a, $b); - }); - $newLatest = end($hashedVersions); - if (($manifest['latest'] ?? null) !== $newLatest) { - echo " [latest] {$manifest['latest']} -> {$newLatest}\n"; - $manifest['latest'] = $newLatest; - } -} - -// (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, - json_encode($manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n" -); -echo "Manifest mis Ă  jour ({$updated} version(s)) : $manifestPath\n"; +$signer = new \PSLauncher\Tools\SignManifest(dirname(__DIR__)); +$result = $signer->run(); +foreach ($result['log'] as $line) echo $line . "\n"; +exit($result['ok'] ? 0 : 1); diff --git a/src/PSLauncher.App/ViewModels/MainViewModel.cs b/src/PSLauncher.App/ViewModels/MainViewModel.cs index 425fbb6..0c46fd2 100644 --- a/src/PSLauncher.App/ViewModels/MainViewModel.cs +++ b/src/PSLauncher.App/ViewModels/MainViewModel.cs @@ -315,12 +315,41 @@ public sealed partial class MainViewModel : ObservableObject } } + /// + /// Clic sur le badge license dans la top bar. + /// - License active (valid/expired/revoked) → ouvre LicenseDetailsDialog (dĂ©tails + dĂ©sactiver) + /// - Sinon → ouvre OnboardingDialog (saisie de clĂ©) + /// Si l'utilisateur dĂ©sactive depuis les dĂ©tails, on enchaĂźne sur l'onboarding pour + /// faciliter le re-keying Ă©ventuel — sinon on revient simplement Ă  l'Ă©tat "Aucune license". + /// [RelayCommand] - private async Task ActivateLicenseAsync() + private async Task OpenLicenseAsync() { - var dialog = new Views.OnboardingDialog(_licenseService) { Owner = Application.Current.MainWindow }; - dialog.ShowDialog(); - if (dialog.LicenseActivated) + var current = _licenseService.GetCached(); + + if (current is not null) + { + var details = new Views.LicenseDetailsDialog(_licenseService, current) + { + Owner = Application.Current.MainWindow + }; + details.ShowDialog(); + if (!details.DeactivateRequested) + { + // Pas de changement + await Task.CompletedTask; + return; + } + // DĂ©sactivation : on a dĂ©jĂ  vidĂ© le cache, on rafraĂźchit la UI puis on reboucle + _license = null; + NotifyLicenseChanged(); + RebuildList(); + } + + // Aucune license → propose l'onboarding + var onboard = new Views.OnboardingDialog(_licenseService) { Owner = Application.Current.MainWindow }; + onboard.ShowDialog(); + if (onboard.LicenseActivated) { _license = _licenseService.GetCached(); NotifyLicenseChanged(); diff --git a/src/PSLauncher.App/ViewModels/SettingsViewModel.cs b/src/PSLauncher.App/ViewModels/SettingsViewModel.cs index 9f189d9..e459b29 100644 --- a/src/PSLauncher.App/ViewModels/SettingsViewModel.cs +++ b/src/PSLauncher.App/ViewModels/SettingsViewModel.cs @@ -38,11 +38,53 @@ public sealed partial class SettingsViewModel : ObservableObject get { var l = _licenseService.GetCached(); - if (l is null) return "Aucune license activĂ©e"; - return $"{l.Status} ‱ {l.OwnerName} ‱ exp. {l.DownloadEntitlementUntil:dd/MM/yyyy}"; + if (l is null) return "Aucune license activĂ©e — clique pour saisir une clĂ©"; + if (l.Status == "valid") + return $"{l.OwnerName} ‱ valable jusqu'au {l.DownloadEntitlementUntil:dd/MM/yyyy}"; + if (l.Status == "expired") + return $"{l.OwnerName} ‱ expirĂ©e le {l.DownloadEntitlementUntil:dd/MM/yyyy}"; + return $"{l.OwnerName} ‱ {l.Status}"; } } + public string LicenseIcon + { + get + { + var l = _licenseService.GetCached(); + if (l is null) return "🔒"; + return l.Status switch + { + "valid" when IsExpiringSoon(l) => "⏰", + "valid" => "✓", + "expired" => "⚠", + "revoked" => "đŸš«", + _ => "❓", + }; + } + } + + public string LicenseSeverity + { + get + { + var l = _licenseService.GetCached(); + if (l is null) return "Error"; + return l.Status switch + { + "valid" when IsExpiringSoon(l) => "Warning", + "valid" => "Ok", + _ => "Error", + }; + } + } + + private static bool IsExpiringSoon(LicenseValidationResponse l) + { + if (l.DownloadEntitlementUntil is not { } until) return false; + return (until - DateTime.UtcNow).TotalDays < 30; + } + public SettingsViewModel( IConfigStore configStore, LocalConfig config, diff --git a/src/PSLauncher.App/Views/LicenseDetailsDialog.xaml b/src/PSLauncher.App/Views/LicenseDetailsDialog.xaml new file mode 100644 index 0000000..1add959 --- /dev/null +++ b/src/PSLauncher.App/Views/LicenseDetailsDialog.xaml @@ -0,0 +1,113 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +