UI overhaul: minimal top bar, license-first settings, footer-pinned check
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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();
|
||||
|
||||
124
server/tools/SignManifest.php
Normal file
124
server/tools/SignManifest.php
Normal file
@@ -0,0 +1,124 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace PSLauncher\Tools;
|
||||
|
||||
/**
|
||||
* Logique de re-signature du manifest, sans dépendance shell.
|
||||
* Utilisable depuis le CLI (tools/sign-manifest.php) et depuis le backoffice
|
||||
* web (admin/versions.php → action 'sync'). Évite tout appel à exec().
|
||||
*/
|
||||
final class SignManifest
|
||||
{
|
||||
public string $manifestPath;
|
||||
public string $buildsDir;
|
||||
public ?string $configPath;
|
||||
/** @var string[] */
|
||||
public array $log = [];
|
||||
|
||||
public function __construct(string $rootDir)
|
||||
{
|
||||
$this->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];
|
||||
}
|
||||
}
|
||||
@@ -1,104 +1,16 @@
|
||||
<?php
|
||||
/**
|
||||
* Met à jour les champs `download.sizeBytes` et `download.sha256` de versions.json
|
||||
* en lisant les ZIPs présents dans /builds/. Le nom local du ZIP est dérivé du
|
||||
* champ `download.url` (basename de l'URL), donc tu peux nommer ton fichier comme
|
||||
* tu veux tant que le manifest pointe vers le bon nom.
|
||||
* Wrapper CLI pour PSLauncher\Tools\SignManifest.
|
||||
*
|
||||
* Usage (à exécuter via SSH OVH dans www/PS_Launcher/) :
|
||||
* php tools/sign-manifest.php
|
||||
* Usage : cd ~/www/PS_Launcher && php tools/sign-manifest.php
|
||||
*
|
||||
* (v0.4) Ajoutera la signature Ed25519 globale du manifest.
|
||||
* Le backoffice admin appelle directement la classe (pas d'exec).
|
||||
*/
|
||||
declare(strict_types=1);
|
||||
|
||||
$root = dirname(__DIR__);
|
||||
$manifestPath = "$root/manifest/versions.json";
|
||||
$buildsDir = "$root/builds";
|
||||
require __DIR__ . '/SignManifest.php';
|
||||
|
||||
if (!is_file($manifestPath)) {
|
||||
fwrite(STDERR, "Manifest not found: $manifestPath\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$manifest = json_decode(file_get_contents($manifestPath), true, 512, JSON_THROW_ON_ERROR);
|
||||
|
||||
$updated = 0;
|
||||
$hashedVersions = [];
|
||||
foreach ($manifest['versions'] as &$v) {
|
||||
$version = $v['version'] ?? '?';
|
||||
$url = $v['download']['url'] ?? '';
|
||||
if ($url === '') {
|
||||
echo " [skip] $version : pas d'URL dans le manifest\n";
|
||||
continue;
|
||||
}
|
||||
|
||||
$filename = basename(parse_url($url, PHP_URL_PATH) ?: '');
|
||||
$zip = "$buildsDir/$filename";
|
||||
|
||||
if (!is_file($zip)) {
|
||||
// Fallback : si le fichier n'existe pas exactement avec le nom de l'URL,
|
||||
// on tente une recherche tolérante par version (espaces / casse / séparateurs).
|
||||
$candidates = glob("$buildsDir/*{$version}*.zip", GLOB_NOSORT) ?: [];
|
||||
$candidates = array_values(array_filter($candidates, 'is_file'));
|
||||
if (count($candidates) === 1) {
|
||||
$zip = $candidates[0];
|
||||
echo " [info] $version : URL pointait vers '{$filename}', utilisé '" . basename($zip) . "' à la place\n";
|
||||
} else {
|
||||
echo " [skip] $version : ZIP introuvable pour $url\n";
|
||||
if (count($candidates) > 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);
|
||||
|
||||
@@ -315,12 +315,41 @@ public sealed partial class MainViewModel : ObservableObject
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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".
|
||||
/// </summary>
|
||||
[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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
113
src/PSLauncher.App/Views/LicenseDetailsDialog.xaml
Normal file
113
src/PSLauncher.App/Views/LicenseDetailsDialog.xaml
Normal file
@@ -0,0 +1,113 @@
|
||||
<Window x:Class="PSLauncher.App.Views.LicenseDetailsDialog"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
Title="License"
|
||||
Width="500" Height="480"
|
||||
ResizeMode="NoResize"
|
||||
WindowStartupLocation="CenterOwner"
|
||||
Background="{StaticResource Brush.Bg.Window}">
|
||||
<Grid Margin="32,28">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="*" />
|
||||
<RowDefinition Height="Auto" />
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<!-- Header avec badge couleur -->
|
||||
<Border Grid.Row="0" CornerRadius="8" Padding="20,16"
|
||||
BorderThickness="2"
|
||||
x:Name="HeaderBorder">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<TextBlock x:Name="StatusIconText"
|
||||
FontSize="32" FontWeight="Bold"
|
||||
VerticalAlignment="Center" Margin="0,0,16,0" />
|
||||
<StackPanel VerticalAlignment="Center">
|
||||
<TextBlock x:Name="StatusTitleText"
|
||||
FontSize="18" FontWeight="SemiBold"
|
||||
Foreground="{StaticResource Brush.Text.Primary}" />
|
||||
<TextBlock x:Name="StatusSubtitleText"
|
||||
FontSize="13"
|
||||
Foreground="{StaticResource Brush.Text.Secondary}"
|
||||
Margin="0,2,0,0" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Détails -->
|
||||
<Border Grid.Row="2" Background="{StaticResource Brush.Bg.Card}"
|
||||
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1"
|
||||
CornerRadius="6" Padding="20" Margin="0,16,0,0">
|
||||
<StackPanel>
|
||||
<Grid Margin="0,0,0,12">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="160" />
|
||||
<ColumnDefinition Width="*" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBlock Grid.Column="0" Text="Client"
|
||||
Foreground="{StaticResource Brush.Text.Secondary}" FontSize="12" />
|
||||
<TextBlock Grid.Column="1" x:Name="OwnerText"
|
||||
FontWeight="SemiBold"
|
||||
Foreground="{StaticResource Brush.Text.Primary}" />
|
||||
</Grid>
|
||||
<Grid Margin="0,0,0,12">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="160" />
|
||||
<ColumnDefinition Width="*" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBlock Grid.Column="0" Text="Validité téléchargements"
|
||||
Foreground="{StaticResource Brush.Text.Secondary}" FontSize="12" />
|
||||
<TextBlock Grid.Column="1" x:Name="UntilText"
|
||||
FontWeight="SemiBold"
|
||||
Foreground="{StaticResource Brush.Text.Primary}" />
|
||||
</Grid>
|
||||
<Grid Margin="0,0,0,12">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="160" />
|
||||
<ColumnDefinition Width="*" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBlock Grid.Column="0" Text="Activée le"
|
||||
Foreground="{StaticResource Brush.Text.Secondary}" FontSize="12" />
|
||||
<TextBlock Grid.Column="1" x:Name="IssuedText"
|
||||
Foreground="{StaticResource Brush.Text.Primary}" />
|
||||
</Grid>
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="160" />
|
||||
<ColumnDefinition Width="*" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBlock Grid.Column="0" Text="ID machine"
|
||||
Foreground="{StaticResource Brush.Text.Secondary}" FontSize="12"
|
||||
VerticalAlignment="Center" />
|
||||
<Grid Grid.Column="1">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBlock Grid.Column="0" x:Name="MachineIdText"
|
||||
FontFamily="Consolas" FontSize="11"
|
||||
Foreground="{StaticResource Brush.Text.Primary}"
|
||||
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
|
||||
<Button Grid.Column="1" Style="{StaticResource SecondaryButton}"
|
||||
Content="📋"
|
||||
ToolTip="Copier"
|
||||
Click="OnCopyMachineId" />
|
||||
</Grid>
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Footer actions -->
|
||||
<StackPanel Grid.Row="3" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,18,0,0">
|
||||
<Button Style="{StaticResource SecondaryButton}"
|
||||
Content="🗑 Désactiver la license"
|
||||
Click="OnDeactivate"
|
||||
Margin="0,0,12,0" />
|
||||
<Button Style="{StaticResource AccentButton}"
|
||||
Content="Fermer"
|
||||
IsCancel="True" IsDefault="True"
|
||||
Padding="32,8"
|
||||
Click="OnClose" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Window>
|
||||
86
src/PSLauncher.App/Views/LicenseDetailsDialog.xaml.cs
Normal file
86
src/PSLauncher.App/Views/LicenseDetailsDialog.xaml.cs
Normal file
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -175,12 +175,18 @@
|
||||
Opacity="0.25"
|
||||
IsHitTestVisible="False" />
|
||||
|
||||
<!-- Top bar -->
|
||||
<!-- Top bar : 3 colonnes (brand / license center / chrome) -->
|
||||
<Border Grid.Row="0" Background="{StaticResource Brush.Bg.Sidebar}"
|
||||
BorderBrush="{StaticResource Brush.Border}" BorderThickness="0,0,0,1"
|
||||
Padding="20,12">
|
||||
<Grid>
|
||||
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*" /> <!-- brand -->
|
||||
<ColumnDefinition Width="*" /> <!-- license (center) -->
|
||||
<ColumnDefinition Width="*" /> <!-- chrome (right) -->
|
||||
</Grid.ColumnDefinitions>
|
||||
<!-- Col 1 : brand -->
|
||||
<StackPanel Grid.Column="0" Orientation="Horizontal" VerticalAlignment="Center">
|
||||
<TextBlock Text="PROSERVE"
|
||||
FontFamily="{StaticResource Font.Brand}"
|
||||
FontSize="22"
|
||||
@@ -193,25 +199,18 @@
|
||||
VerticalAlignment="Center"
|
||||
Margin="14,0,0,0" />
|
||||
</StackPanel>
|
||||
<StackPanel HorizontalAlignment="Right" Orientation="Horizontal" VerticalAlignment="Center">
|
||||
<Button Style="{StaticResource SecondaryButton}"
|
||||
Content="🔄 Vérifier les MAJ"
|
||||
Command="{Binding CheckForUpdatesCommand}"
|
||||
|
||||
<!-- Col 2 : badge license cliquable, centré -->
|
||||
<Border Grid.Column="1"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center"
|
||||
Cursor="Hand"
|
||||
CornerRadius="16" Padding="14,6"
|
||||
shell:WindowChrome.IsHitTestVisibleInChrome="True"
|
||||
Margin="0,0,8,0" />
|
||||
<Button Style="{StaticResource SecondaryButton}"
|
||||
Content="📁 Dossier"
|
||||
Command="{Binding OpenInstallRootCommand}"
|
||||
shell:WindowChrome.IsHitTestVisibleInChrome="True"
|
||||
Margin="0,0,12,0" />
|
||||
<!-- Badge license -->
|
||||
<Border CornerRadius="14" Padding="10,4"
|
||||
VerticalAlignment="Center" Margin="0,0,8,0">
|
||||
ToolTip="Voir / changer la license">
|
||||
<Border.Style>
|
||||
<Style TargetType="Border">
|
||||
<!-- Default: error (rouge) -->
|
||||
<Setter Property="Background" Value="#2A1414" />
|
||||
<Setter Property="BorderBrush" Value="{StaticResource Brush.Status.AvailableBg}" />
|
||||
<Setter Property="BorderBrush" Value="#EF4444" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Style.Triggers>
|
||||
<DataTrigger Binding="{Binding LicenseSeverity}" Value="Ok">
|
||||
@@ -222,16 +221,15 @@
|
||||
<Setter Property="Background" Value="{StaticResource Brush.Status.BusyBg}" />
|
||||
<Setter Property="BorderBrush" Value="{StaticResource Brush.Status.Busy}" />
|
||||
</DataTrigger>
|
||||
<DataTrigger Binding="{Binding LicenseSeverity}" Value="Error">
|
||||
<Setter Property="Background" Value="#2A1414" />
|
||||
<Setter Property="BorderBrush" Value="#EF4444" />
|
||||
</DataTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
</Border.Style>
|
||||
<Border.InputBindings>
|
||||
<MouseBinding Gesture="LeftClick" Command="{Binding OpenLicenseCommand}" />
|
||||
</Border.InputBindings>
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<TextBlock Text="{Binding LicenseIcon}"
|
||||
FontSize="14" FontWeight="Bold"
|
||||
FontSize="15" FontWeight="Bold"
|
||||
VerticalAlignment="Center"
|
||||
Margin="0,0,8,0">
|
||||
<TextBlock.Style>
|
||||
@@ -249,7 +247,7 @@
|
||||
</TextBlock.Style>
|
||||
</TextBlock>
|
||||
<TextBlock Text="{Binding LicenseSummary}"
|
||||
FontSize="12" FontWeight="SemiBold"
|
||||
FontSize="13" FontWeight="SemiBold"
|
||||
VerticalAlignment="Center">
|
||||
<TextBlock.Style>
|
||||
<Style TargetType="TextBlock">
|
||||
@@ -267,18 +265,15 @@
|
||||
</TextBlock>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
<Button Style="{StaticResource SecondaryButton}"
|
||||
Content="🔑 Activer / changer"
|
||||
Command="{Binding ActivateLicenseCommand}"
|
||||
shell:WindowChrome.IsHitTestVisibleInChrome="True"
|
||||
Margin="0,0,8,0" />
|
||||
|
||||
<!-- Col 3 : Paramètres + window chrome -->
|
||||
<StackPanel Grid.Column="2" Orientation="Horizontal"
|
||||
HorizontalAlignment="Right" VerticalAlignment="Center">
|
||||
<Button Style="{StaticResource SecondaryButton}"
|
||||
Content="⚙ Paramètres"
|
||||
shell:WindowChrome.IsHitTestVisibleInChrome="True"
|
||||
Command="{Binding OpenSettingsCommand}"
|
||||
Margin="0,0,16,0" />
|
||||
|
||||
<!-- Window controls custom (style chrome dark) -->
|
||||
<Button Style="{StaticResource WindowControlButton}"
|
||||
Content="—" ToolTip="Réduire"
|
||||
shell:WindowChrome.IsHitTestVisibleInChrome="True"
|
||||
@@ -491,31 +486,43 @@
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
|
||||
<!-- Footer (busy / status) -->
|
||||
<!-- Footer : toujours visible.
|
||||
Ligne 1 : bouton "Vérifier les MAJ" à gauche (toujours présent)
|
||||
Ligne 2 : statut + progress bar (visible quand busy / status non vide) -->
|
||||
<Border Grid.Row="2" Background="{StaticResource Brush.Bg.Footer}"
|
||||
BorderBrush="{StaticResource Brush.Border}" BorderThickness="0,1,0,0"
|
||||
Padding="20,8"
|
||||
Visibility="{Binding FooterVisibility}">
|
||||
Padding="20,10">
|
||||
<StackPanel>
|
||||
<!-- Ligne 1 : actions permanentes -->
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto" />
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<StackPanel Grid.Column="0">
|
||||
<Button Grid.Column="0"
|
||||
Style="{StaticResource SecondaryButton}"
|
||||
Content="🔄 Vérifier les MAJ"
|
||||
Command="{Binding CheckForUpdatesCommand}" />
|
||||
<Button Grid.Column="2"
|
||||
Style="{StaticResource SecondaryButton}"
|
||||
Content="Annuler"
|
||||
Command="{Binding CancelDownloadCommand}"
|
||||
Visibility="{Binding IsBusy, Converter={StaticResource BoolToVisibility}}" />
|
||||
</Grid>
|
||||
|
||||
<!-- Ligne 2 : statut + progression (visible si busy ou message) -->
|
||||
<StackPanel Margin="0,8,0,0"
|
||||
Visibility="{Binding FooterVisibility}">
|
||||
<TextBlock Text="{Binding FooterText}"
|
||||
Foreground="{StaticResource Brush.Text.Secondary}" />
|
||||
Foreground="{StaticResource Brush.Text.Secondary}"
|
||||
FontSize="12" />
|
||||
<ProgressBar Height="4" Margin="0,4,0,0"
|
||||
Value="{Binding ProgressPercent}" Maximum="100"
|
||||
Foreground="{StaticResource Brush.Accent}"
|
||||
Background="#2A2A30" BorderThickness="0" />
|
||||
</StackPanel>
|
||||
<Button Grid.Column="1"
|
||||
Style="{StaticResource SecondaryButton}"
|
||||
Content="Annuler"
|
||||
VerticalAlignment="Center" Margin="12,0,0,0"
|
||||
Command="{Binding CancelDownloadCommand}"
|
||||
Visibility="{Binding IsBusy, Converter={StaticResource BoolToVisibility}}" />
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</Grid>
|
||||
</Window>
|
||||
|
||||
@@ -28,6 +28,93 @@
|
||||
<ScrollViewer Grid.Row="1" VerticalScrollBarVisibility="Auto" Padding="32,0,32,16">
|
||||
<StackPanel>
|
||||
|
||||
<!-- License (en tête, bordure colorée selon état) -->
|
||||
<Border BorderThickness="2"
|
||||
CornerRadius="6" Padding="20" Margin="0,0,0,16">
|
||||
<Border.Style>
|
||||
<Style TargetType="Border">
|
||||
<Setter Property="Background" Value="#2A1414" />
|
||||
<Setter Property="BorderBrush" Value="#EF4444" />
|
||||
<Style.Triggers>
|
||||
<DataTrigger Binding="{Binding LicenseSeverity}" Value="Ok">
|
||||
<Setter Property="Background" Value="{StaticResource Brush.Status.InstalledBg}" />
|
||||
<Setter Property="BorderBrush" Value="{StaticResource Brush.Status.Installed}" />
|
||||
</DataTrigger>
|
||||
<DataTrigger Binding="{Binding LicenseSeverity}" Value="Warning">
|
||||
<Setter Property="Background" Value="{StaticResource Brush.Status.BusyBg}" />
|
||||
<Setter Property="BorderBrush" Value="{StaticResource Brush.Status.Busy}" />
|
||||
</DataTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
</Border.Style>
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto" />
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<!-- Icône -->
|
||||
<TextBlock Grid.Row="0" Grid.RowSpan="2" Grid.Column="0"
|
||||
Text="{Binding LicenseIcon}"
|
||||
FontSize="36" FontWeight="Bold"
|
||||
VerticalAlignment="Center"
|
||||
Margin="0,0,16,0">
|
||||
<TextBlock.Style>
|
||||
<Style TargetType="TextBlock">
|
||||
<Setter Property="Foreground" Value="#EF4444" />
|
||||
<Style.Triggers>
|
||||
<DataTrigger Binding="{Binding LicenseSeverity}" Value="Ok">
|
||||
<Setter Property="Foreground" Value="{StaticResource Brush.Status.Installed}" />
|
||||
</DataTrigger>
|
||||
<DataTrigger Binding="{Binding LicenseSeverity}" Value="Warning">
|
||||
<Setter Property="Foreground" Value="{StaticResource Brush.Status.Busy}" />
|
||||
</DataTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
</TextBlock.Style>
|
||||
</TextBlock>
|
||||
|
||||
<!-- Header text -->
|
||||
<TextBlock Grid.Row="0" Grid.Column="1"
|
||||
Text="LICENSE"
|
||||
FontSize="11" FontWeight="Bold"
|
||||
Foreground="{StaticResource Brush.Text.Secondary}" />
|
||||
<TextBlock Grid.Row="1" Grid.Column="1"
|
||||
Text="{Binding LicenseInfo}"
|
||||
Foreground="{StaticResource Brush.Text.Primary}"
|
||||
FontWeight="SemiBold" FontSize="14"
|
||||
Margin="0,2,0,0"
|
||||
TextWrapping="Wrap" />
|
||||
|
||||
<!-- Machine ID en dessous -->
|
||||
<Grid Grid.Row="2" Grid.Column="1" Grid.ColumnSpan="2" Margin="0,12,0,0">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<StackPanel Grid.Column="0">
|
||||
<TextBlock Text="Identifiant machine (anonyme)"
|
||||
FontSize="11"
|
||||
Foreground="{StaticResource Brush.Text.Secondary}" />
|
||||
<TextBlock Text="{Binding MachineId}"
|
||||
FontFamily="Consolas" FontSize="11"
|
||||
Foreground="{StaticResource Brush.Text.Primary}"
|
||||
Margin="0,2,0,0" TextTrimming="CharacterEllipsis" />
|
||||
</StackPanel>
|
||||
<Button Grid.Column="1"
|
||||
Style="{StaticResource SecondaryButton}"
|
||||
Content="📋 Copier"
|
||||
Command="{Binding CopyMachineIdCommand}" />
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- Serveur -->
|
||||
<Border Background="{StaticResource Brush.Bg.Card}"
|
||||
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1"
|
||||
@@ -95,44 +182,6 @@
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- License -->
|
||||
<Border Background="{StaticResource Brush.Bg.Card}"
|
||||
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1"
|
||||
CornerRadius="6" Padding="20" Margin="0,0,0,16">
|
||||
<StackPanel>
|
||||
<TextBlock Text="LICENSE"
|
||||
FontSize="11" FontWeight="Bold"
|
||||
Foreground="{StaticResource Brush.Text.Secondary}"
|
||||
Margin="0,0,0,8" />
|
||||
<TextBlock Text="{Binding LicenseInfo}"
|
||||
Foreground="{StaticResource Brush.Text.Primary}"
|
||||
FontSize="13" />
|
||||
<Grid Margin="0,12,0,0">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<StackPanel Grid.Column="0">
|
||||
<TextBlock Text="Identifiant machine (anonyme)"
|
||||
FontSize="12"
|
||||
Foreground="{StaticResource Brush.Text.Secondary}" />
|
||||
<TextBlock Text="{Binding MachineId}"
|
||||
FontFamily="Consolas" FontSize="11"
|
||||
Foreground="{StaticResource Brush.Text.Primary}"
|
||||
Margin="0,2,0,0" TextTrimming="CharacterEllipsis" />
|
||||
</StackPanel>
|
||||
<Button Grid.Column="1"
|
||||
Style="{StaticResource SecondaryButton}"
|
||||
Content="📋 Copier"
|
||||
Command="{Binding CopyMachineIdCommand}" />
|
||||
</Grid>
|
||||
<Button Style="{StaticResource SecondaryButton}"
|
||||
Content="Désactiver la license sur cette machine"
|
||||
Command="{Binding DeactivateLicenseCommand}"
|
||||
Margin="0,12,0,0" HorizontalAlignment="Left" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Cache -->
|
||||
<Border Background="{StaticResource Brush.Bg.Card}"
|
||||
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1"
|
||||
|
||||
Reference in New Issue
Block a user