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:
2026-05-02 09:24:01 +02:00
parent 74f48419e6
commit b10a3fbabf
9 changed files with 609 additions and 246 deletions

View File

@@ -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)."; $message = "v{$version} retirée du manifest. Le ZIP reste dans builds/ (à supprimer en SFTP si voulu).";
} }
elseif ($action === 'sync') { elseif ($action === 'sync') {
$output = []; // Appel direct à la classe — pas d'exec(), fonctionne même quand la fonction
$exitCode = 0; // est désactivée par le mutualisé OVH.
$cwd = escapeshellarg($root); require_once "$root/tools/SignManifest.php";
exec("cd $cwd && php tools/sign-manifest.php 2>&1", $output, $exitCode); $signer = new \PSLauncher\Tools\SignManifest($root);
$message = "Sortie du script :\n" . implode("\n", $output); $result = $signer->run();
if ($exitCode !== 0) $messageType = 'error'; $message = "Sortie du script :\n" . implode("\n", $result['log']);
if (!$result['ok']) $messageType = 'error';
} }
} catch (Exception $e) { } catch (Exception $e) {
$message = $e->getMessage(); $message = $e->getMessage();

View 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];
}
}

View File

@@ -1,104 +1,16 @@
<?php <?php
/** /**
* Met à jour les champs `download.sizeBytes` et `download.sha256` de versions.json * Wrapper CLI pour PSLauncher\Tools\SignManifest.
* 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.
* *
* Usage (à exécuter via SSH OVH dans www/PS_Launcher/) : * Usage : cd ~/www/PS_Launcher && php tools/sign-manifest.php
* 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); declare(strict_types=1);
$root = dirname(__DIR__); require __DIR__ . '/SignManifest.php';
$manifestPath = "$root/manifest/versions.json";
$buildsDir = "$root/builds";
if (!is_file($manifestPath)) { $signer = new \PSLauncher\Tools\SignManifest(dirname(__DIR__));
fwrite(STDERR, "Manifest not found: $manifestPath\n"); $result = $signer->run();
exit(1); foreach ($result['log'] as $line) echo $line . "\n";
} exit($result['ok'] ? 0 : 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";

View File

@@ -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] [RelayCommand]
private async Task ActivateLicenseAsync() private async Task OpenLicenseAsync()
{ {
var dialog = new Views.OnboardingDialog(_licenseService) { Owner = Application.Current.MainWindow }; var current = _licenseService.GetCached();
dialog.ShowDialog();
if (dialog.LicenseActivated) 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(); _license = _licenseService.GetCached();
NotifyLicenseChanged(); NotifyLicenseChanged();

View File

@@ -38,11 +38,53 @@ public sealed partial class SettingsViewModel : ObservableObject
get get
{ {
var l = _licenseService.GetCached(); var l = _licenseService.GetCached();
if (l is null) return "Aucune license activée"; if (l is null) return "Aucune license activée — clique pour saisir une clé";
return $"{l.Status} • {l.OwnerName} • exp. {l.DownloadEntitlementUntil:dd/MM/yyyy}"; 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( public SettingsViewModel(
IConfigStore configStore, IConfigStore configStore,
LocalConfig config, LocalConfig config,

View 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>

View 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();
}
}

View File

@@ -175,12 +175,18 @@
Opacity="0.25" Opacity="0.25"
IsHitTestVisible="False" /> IsHitTestVisible="False" />
<!-- Top bar --> <!-- Top bar : 3 colonnes (brand / license center / chrome) -->
<Border Grid.Row="0" Background="{StaticResource Brush.Bg.Sidebar}" <Border Grid.Row="0" Background="{StaticResource Brush.Bg.Sidebar}"
BorderBrush="{StaticResource Brush.Border}" BorderThickness="0,0,0,1" BorderBrush="{StaticResource Brush.Border}" BorderThickness="0,0,0,1"
Padding="20,12"> Padding="20,12">
<Grid> <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" <TextBlock Text="PROSERVE"
FontFamily="{StaticResource Font.Brand}" FontFamily="{StaticResource Font.Brand}"
FontSize="22" FontSize="22"
@@ -193,92 +199,81 @@
VerticalAlignment="Center" VerticalAlignment="Center"
Margin="14,0,0,0" /> Margin="14,0,0,0" />
</StackPanel> </StackPanel>
<StackPanel HorizontalAlignment="Right" Orientation="Horizontal" VerticalAlignment="Center">
<Button Style="{StaticResource SecondaryButton}" <!-- Col 2 : badge license cliquable, centré -->
Content="🔄 Vérifier les MAJ" <Border Grid.Column="1"
Command="{Binding CheckForUpdatesCommand}" HorizontalAlignment="Center" VerticalAlignment="Center"
shell:WindowChrome.IsHitTestVisibleInChrome="True" Cursor="Hand"
Margin="0,0,8,0" /> CornerRadius="16" Padding="14,6"
<Button Style="{StaticResource SecondaryButton}" shell:WindowChrome.IsHitTestVisibleInChrome="True"
Content="📁 Dossier" ToolTip="Voir / changer la license">
Command="{Binding OpenInstallRootCommand}" <Border.Style>
shell:WindowChrome.IsHitTestVisibleInChrome="True" <Style TargetType="Border">
Margin="0,0,12,0" /> <Setter Property="Background" Value="#2A1414" />
<!-- Badge license --> <Setter Property="BorderBrush" Value="#EF4444" />
<Border CornerRadius="14" Padding="10,4" <Setter Property="BorderThickness" Value="1" />
VerticalAlignment="Center" Margin="0,0,8,0"> <Style.Triggers>
<Border.Style> <DataTrigger Binding="{Binding LicenseSeverity}" Value="Ok">
<Style TargetType="Border"> <Setter Property="Background" Value="{StaticResource Brush.Status.InstalledBg}" />
<!-- Default: error (rouge) --> <Setter Property="BorderBrush" Value="{StaticResource Brush.Status.Installed}" />
<Setter Property="Background" Value="#2A1414" /> </DataTrigger>
<Setter Property="BorderBrush" Value="{StaticResource Brush.Status.AvailableBg}" /> <DataTrigger Binding="{Binding LicenseSeverity}" Value="Warning">
<Setter Property="BorderThickness" Value="1" /> <Setter Property="Background" Value="{StaticResource Brush.Status.BusyBg}" />
<Style.Triggers> <Setter Property="BorderBrush" Value="{StaticResource Brush.Status.Busy}" />
<DataTrigger Binding="{Binding LicenseSeverity}" Value="Ok"> </DataTrigger>
<Setter Property="Background" Value="{StaticResource Brush.Status.InstalledBg}" /> </Style.Triggers>
<Setter Property="BorderBrush" Value="{StaticResource Brush.Status.Installed}" /> </Style>
</DataTrigger> </Border.Style>
<DataTrigger Binding="{Binding LicenseSeverity}" Value="Warning"> <Border.InputBindings>
<Setter Property="Background" Value="{StaticResource Brush.Status.BusyBg}" /> <MouseBinding Gesture="LeftClick" Command="{Binding OpenLicenseCommand}" />
<Setter Property="BorderBrush" Value="{StaticResource Brush.Status.Busy}" /> </Border.InputBindings>
</DataTrigger> <StackPanel Orientation="Horizontal">
<DataTrigger Binding="{Binding LicenseSeverity}" Value="Error"> <TextBlock Text="{Binding LicenseIcon}"
<Setter Property="Background" Value="#2A1414" /> FontSize="15" FontWeight="Bold"
<Setter Property="BorderBrush" Value="#EF4444" /> VerticalAlignment="Center"
</DataTrigger> Margin="0,0,8,0">
</Style.Triggers> <TextBlock.Style>
</Style> <Style TargetType="TextBlock">
</Border.Style> <Setter Property="Foreground" Value="#EF4444" />
<StackPanel Orientation="Horizontal"> <Style.Triggers>
<TextBlock Text="{Binding LicenseIcon}" <DataTrigger Binding="{Binding LicenseSeverity}" Value="Ok">
FontSize="14" FontWeight="Bold" <Setter Property="Foreground" Value="{StaticResource Brush.Status.Installed}" />
VerticalAlignment="Center" </DataTrigger>
Margin="0,0,8,0"> <DataTrigger Binding="{Binding LicenseSeverity}" Value="Warning">
<TextBlock.Style> <Setter Property="Foreground" Value="{StaticResource Brush.Status.Busy}" />
<Style TargetType="TextBlock"> </DataTrigger>
<Setter Property="Foreground" Value="#EF4444" /> </Style.Triggers>
<Style.Triggers> </Style>
<DataTrigger Binding="{Binding LicenseSeverity}" Value="Ok"> </TextBlock.Style>
<Setter Property="Foreground" Value="{StaticResource Brush.Status.Installed}" /> </TextBlock>
</DataTrigger> <TextBlock Text="{Binding LicenseSummary}"
<DataTrigger Binding="{Binding LicenseSeverity}" Value="Warning"> FontSize="13" FontWeight="SemiBold"
<Setter Property="Foreground" Value="{StaticResource Brush.Status.Busy}" /> VerticalAlignment="Center">
</DataTrigger> <TextBlock.Style>
</Style.Triggers> <Style TargetType="TextBlock">
</Style> <Setter Property="Foreground" Value="#FCA5A5" />
</TextBlock.Style> <Style.Triggers>
</TextBlock> <DataTrigger Binding="{Binding LicenseSeverity}" Value="Ok">
<TextBlock Text="{Binding LicenseSummary}" <Setter Property="Foreground" Value="#86EFAC" />
FontSize="12" FontWeight="SemiBold" </DataTrigger>
VerticalAlignment="Center"> <DataTrigger Binding="{Binding LicenseSeverity}" Value="Warning">
<TextBlock.Style> <Setter Property="Foreground" Value="#FCD34D" />
<Style TargetType="TextBlock"> </DataTrigger>
<Setter Property="Foreground" Value="#FCA5A5" /> </Style.Triggers>
<Style.Triggers> </Style>
<DataTrigger Binding="{Binding LicenseSeverity}" Value="Ok"> </TextBlock.Style>
<Setter Property="Foreground" Value="#86EFAC" /> </TextBlock>
</DataTrigger> </StackPanel>
<DataTrigger Binding="{Binding LicenseSeverity}" Value="Warning"> </Border>
<Setter Property="Foreground" Value="#FCD34D" />
</DataTrigger> <!-- Col 3 : Paramètres + window chrome -->
</Style.Triggers> <StackPanel Grid.Column="2" Orientation="Horizontal"
</Style> HorizontalAlignment="Right" VerticalAlignment="Center">
</TextBlock.Style>
</TextBlock>
</StackPanel>
</Border>
<Button Style="{StaticResource SecondaryButton}"
Content="🔑 Activer / changer"
Command="{Binding ActivateLicenseCommand}"
shell:WindowChrome.IsHitTestVisibleInChrome="True"
Margin="0,0,8,0" />
<Button Style="{StaticResource SecondaryButton}" <Button Style="{StaticResource SecondaryButton}"
Content="⚙ Paramètres" Content="⚙ Paramètres"
shell:WindowChrome.IsHitTestVisibleInChrome="True" shell:WindowChrome.IsHitTestVisibleInChrome="True"
Command="{Binding OpenSettingsCommand}" Command="{Binding OpenSettingsCommand}"
Margin="0,0,16,0" /> Margin="0,0,16,0" />
<!-- Window controls custom (style chrome dark) -->
<Button Style="{StaticResource WindowControlButton}" <Button Style="{StaticResource WindowControlButton}"
Content="—" ToolTip="Réduire" Content="—" ToolTip="Réduire"
shell:WindowChrome.IsHitTestVisibleInChrome="True" shell:WindowChrome.IsHitTestVisibleInChrome="True"
@@ -491,31 +486,43 @@
</StackPanel> </StackPanel>
</ScrollViewer> </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}" <Border Grid.Row="2" Background="{StaticResource Brush.Bg.Footer}"
BorderBrush="{StaticResource Brush.Border}" BorderThickness="0,1,0,0" BorderBrush="{StaticResource Brush.Border}" BorderThickness="0,1,0,0"
Padding="20,8" Padding="20,10">
Visibility="{Binding FooterVisibility}"> <StackPanel>
<Grid> <!-- Ligne 1 : actions permanentes -->
<Grid.ColumnDefinitions> <Grid>
<ColumnDefinition Width="*" /> <Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" /> <ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions> <ColumnDefinition Width="*" />
<StackPanel Grid.Column="0"> <ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<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}" <TextBlock Text="{Binding FooterText}"
Foreground="{StaticResource Brush.Text.Secondary}" /> Foreground="{StaticResource Brush.Text.Secondary}"
FontSize="12" />
<ProgressBar Height="4" Margin="0,4,0,0" <ProgressBar Height="4" Margin="0,4,0,0"
Value="{Binding ProgressPercent}" Maximum="100" Value="{Binding ProgressPercent}" Maximum="100"
Foreground="{StaticResource Brush.Accent}" Foreground="{StaticResource Brush.Accent}"
Background="#2A2A30" BorderThickness="0" /> Background="#2A2A30" BorderThickness="0" />
</StackPanel> </StackPanel>
<Button Grid.Column="1" </StackPanel>
Style="{StaticResource SecondaryButton}"
Content="Annuler"
VerticalAlignment="Center" Margin="12,0,0,0"
Command="{Binding CancelDownloadCommand}"
Visibility="{Binding IsBusy, Converter={StaticResource BoolToVisibility}}" />
</Grid>
</Border> </Border>
</Grid> </Grid>
</Window> </Window>

View File

@@ -28,6 +28,93 @@
<ScrollViewer Grid.Row="1" VerticalScrollBarVisibility="Auto" Padding="32,0,32,16"> <ScrollViewer Grid.Row="1" VerticalScrollBarVisibility="Auto" Padding="32,0,32,16">
<StackPanel> <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 --> <!-- Serveur -->
<Border Background="{StaticResource Brush.Bg.Card}" <Border Background="{StaticResource Brush.Bg.Card}"
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1" BorderBrush="{StaticResource Brush.Border}" BorderThickness="1"
@@ -95,44 +182,6 @@
</StackPanel> </StackPanel>
</Border> </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 --> <!-- Cache -->
<Border Background="{StaticResource Brush.Bg.Card}" <Border Background="{StaticResource Brush.Bg.Card}"
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1" BorderBrush="{StaticResource Brush.Border}" BorderThickness="1"