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