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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/PSLauncher.App/Views/LicenseDetailsDialog.xaml.cs b/src/PSLauncher.App/Views/LicenseDetailsDialog.xaml.cs
new file mode 100644
index 0000000..d8f2560
--- /dev/null
+++ b/src/PSLauncher.App/Views/LicenseDetailsDialog.xaml.cs
@@ -0,0 +1,86 @@
+using System.Windows;
+using System.Windows.Media;
+using PSLauncher.Core.Licensing;
+using PSLauncher.Models;
+
+namespace PSLauncher.App.Views;
+
+public partial class LicenseDetailsDialog : Window
+{
+ private readonly ILicenseService _licenseService;
+ private readonly LicenseValidationResponse _license;
+
+ public bool DeactivateRequested { get; private set; }
+
+ public LicenseDetailsDialog(ILicenseService licenseService, LicenseValidationResponse license)
+ {
+ _licenseService = licenseService;
+ _license = license;
+ InitializeComponent();
+
+ var (icon, title, subtitle, color, bgColor) = ResolveStyle(license);
+ StatusIconText.Text = icon;
+ StatusIconText.Foreground = new SolidColorBrush(color);
+ StatusTitleText.Text = title;
+ StatusSubtitleText.Text = subtitle;
+ HeaderBorder.Background = new SolidColorBrush(bgColor);
+ HeaderBorder.BorderBrush = new SolidColorBrush(color);
+
+ OwnerText.Text = license.OwnerName ?? "—";
+ UntilText.Text = license.DownloadEntitlementUntil is { } until
+ ? $"jusqu'au {until.ToLocalTime():dddd dd MMMM yyyy}"
+ : "—";
+ IssuedText.Text = license.IssuedAt is { } issued
+ ? issued.ToLocalTime().ToString("dd/MM/yyyy")
+ : (license.ServerTime?.ToLocalTime().ToString("dd/MM/yyyy") ?? "—");
+ MachineIdText.Text = licenseService.GetMachineId();
+ }
+
+ private static (string Icon, string Title, string Subtitle, Color Color, Color BgColor) ResolveStyle(LicenseValidationResponse l)
+ {
+ var until = l.DownloadEntitlementUntil;
+ if (l.Status == "valid")
+ {
+ if (until is { } u && (u - DateTime.UtcNow).TotalDays < 30)
+ return ("⏰", "License valide", $"Expire bientôt — le {u.ToLocalTime():dd/MM/yyyy}",
+ Color.FromRgb(0xF5, 0x9E, 0x0B), Color.FromRgb(0x3A, 0x2A, 0x0E));
+ return ("✓", "License valide", "Téléchargements de nouvelles versions autorisés",
+ Color.FromRgb(0x16, 0xA3, 0x4A), Color.FromRgb(0x0E, 0x2A, 0x1B));
+ }
+ if (l.Status == "expired")
+ return ("⚠", "License expirée",
+ until is { } u ? $"Expirée le {u.ToLocalTime():dd/MM/yyyy}" : "Plus de téléchargements possibles",
+ Color.FromRgb(0xEF, 0x44, 0x44), Color.FromRgb(0x2A, 0x14, 0x14));
+ if (l.Status == "revoked")
+ return ("🚫", "License révoquée", "Contacte ASTERION pour plus d'informations",
+ Color.FromRgb(0xEF, 0x44, 0x44), Color.FromRgb(0x2A, 0x14, 0x14));
+ return ("❓", "License inconnue", l.Message ?? l.Status,
+ Color.FromRgb(0xEF, 0x44, 0x44), Color.FromRgb(0x2A, 0x14, 0x14));
+ }
+
+ private void OnCopyMachineId(object sender, RoutedEventArgs e)
+ {
+ try { Clipboard.SetText(MachineIdText.Text); } catch { /* ignore */ }
+ }
+
+ private void OnDeactivate(object sender, RoutedEventArgs e)
+ {
+ var confirm = MessageBox.Show(
+ "Désactiver la license sur cette machine ?\n\n" +
+ "Tu pourras la réactiver plus tard avec ta clé. " +
+ "Les versions déjà installées restent utilisables.",
+ "Confirmer la désactivation",
+ MessageBoxButton.YesNo, MessageBoxImage.Question, MessageBoxResult.No);
+ if (confirm != MessageBoxResult.Yes) return;
+ _licenseService.Clear();
+ DeactivateRequested = true;
+ DialogResult = true;
+ Close();
+ }
+
+ private void OnClose(object sender, RoutedEventArgs e)
+ {
+ DialogResult = false;
+ Close();
+ }
+}
diff --git a/src/PSLauncher.App/Views/MainWindow.xaml b/src/PSLauncher.App/Views/MainWindow.xaml
index 3b779bd..a8d1190 100644
--- a/src/PSLauncher.App/Views/MainWindow.xaml
+++ b/src/PSLauncher.App/Views/MainWindow.xaml
@@ -175,12 +175,18 @@
Opacity="0.25"
IsHitTestVisible="False" />
-
+
-
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
diff --git a/src/PSLauncher.App/Views/SettingsDialog.xaml b/src/PSLauncher.App/Views/SettingsDialog.xaml
index f7a821b..71de3ae 100644
--- a/src/PSLauncher.App/Views/SettingsDialog.xaml
+++ b/src/PSLauncher.App/Views/SettingsDialog.xaml
@@ -28,6 +28,93 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-