diff --git a/installer/PSLauncher.iss b/installer/PSLauncher.iss index c1ae1a8..006c22a 100644 --- a/installer/PSLauncher.iss +++ b/installer/PSLauncher.iss @@ -11,7 +11,7 @@ #define MyAppName "PROSERVE Launcher" #define MyAppShortName "PS_Launcher" -#define MyAppVersion "0.20.0" +#define MyAppVersion "0.21.0" #define MyAppPublisher "ASTERION VR" #define MyAppURL "https://asterionvr.com" #define MyAppExeName "PS_Launcher.exe" diff --git a/src/PSLauncher.App/App.xaml.cs b/src/PSLauncher.App/App.xaml.cs index 1490fd4..ca08d72 100644 --- a/src/PSLauncher.App/App.xaml.cs +++ b/src/PSLauncher.App/App.xaml.cs @@ -17,6 +17,7 @@ using PSLauncher.Core.Licensing; using PSLauncher.Core.Localization; using PSLauncher.Core.Manifests; using PSLauncher.Core.DocTool; +using PSLauncher.Core.Health; using PSLauncher.Core.Migrations; using PSLauncher.Core.Process; using PSLauncher.Core.ReportTool; @@ -190,6 +191,8 @@ public partial class App : Application configProvider: () => sp.GetRequiredService().DocTool, sp.GetRequiredService>())); + services.AddSingleton(); + services.AddTransient(); services.AddSingleton(); services.AddSingleton(); diff --git a/src/PSLauncher.App/PSLauncher.App.csproj b/src/PSLauncher.App/PSLauncher.App.csproj index 8cb1f06..c378d1c 100644 --- a/src/PSLauncher.App/PSLauncher.App.csproj +++ b/src/PSLauncher.App/PSLauncher.App.csproj @@ -15,9 +15,9 @@ PROSERVE Launcher © 2026 ASTERION VR — All rights reserved PSLauncher.App - 0.20.0 - 0.20.0.0 - 0.20.0.0 + 0.21.0 + 0.21.0.0 + 0.21.0.0 true diff --git a/src/PSLauncher.App/ViewModels/HealthIndicatorViewModel.cs b/src/PSLauncher.App/ViewModels/HealthIndicatorViewModel.cs new file mode 100644 index 0000000..ee73d42 --- /dev/null +++ b/src/PSLauncher.App/ViewModels/HealthIndicatorViewModel.cs @@ -0,0 +1,92 @@ +using System.Windows.Media; +using CommunityToolkit.Mvvm.ComponentModel; +using PSLauncher.Core.Health; +using PSLauncher.Models; + +namespace PSLauncher.App.ViewModels; + +/// +/// Représente un indicateur visuel (pill colorée) dans le bandeau de santé +/// système. Mis à jour périodiquement par à +/// partir du résultat de . +/// +public sealed partial class HealthIndicatorViewModel : ObservableObject +{ + public HealthCheckEntry Entry { get; } + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(StatusBrush))] + [NotifyPropertyChangedFor(nameof(BorderBrush))] + [NotifyPropertyChangedFor(nameof(IconForeground))] + [NotifyPropertyChangedFor(nameof(Tooltip))] + private HealthSeverity _severity = HealthSeverity.Unknown; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(Tooltip))] + private string _detail = "Pas encore vérifié"; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(Tooltip))] + private DateTime _lastCheckedUtc = DateTime.MinValue; + + public string Name => Entry.Name; + public string Icon => Entry.Icon; + + public Brush StatusBrush => Severity switch + { + // Pill background : un peu transparent pour que le bandeau reste discret + HealthSeverity.Ok => new SolidColorBrush(Color.FromArgb(0x40, 0x16, 0xA3, 0x4A)), // vert + HealthSeverity.Warning => new SolidColorBrush(Color.FromArgb(0x50, 0xF5, 0x9E, 0x0B)), // orange + HealthSeverity.Error => new SolidColorBrush(Color.FromArgb(0x50, 0xEF, 0x44, 0x44)), // rouge + _ => new SolidColorBrush(Color.FromArgb(0x30, 0x80, 0x80, 0x90)), // gris + }; + + public Brush BorderBrush => Severity switch + { + HealthSeverity.Ok => new SolidColorBrush(Color.FromRgb(0x16, 0xA3, 0x4A)), + HealthSeverity.Warning => new SolidColorBrush(Color.FromRgb(0xF5, 0x9E, 0x0B)), + HealthSeverity.Error => new SolidColorBrush(Color.FromRgb(0xEF, 0x44, 0x44)), + _ => new SolidColorBrush(Color.FromRgb(0x60, 0x60, 0x70)), + }; + + public Brush IconForeground => Severity switch + { + HealthSeverity.Ok => new SolidColorBrush(Color.FromRgb(0x4A, 0xDE, 0x80)), + HealthSeverity.Warning => new SolidColorBrush(Color.FromRgb(0xFB, 0xBF, 0x24)), + HealthSeverity.Error => new SolidColorBrush(Color.FromRgb(0xF8, 0x71, 0x71)), + _ => new SolidColorBrush(Color.FromRgb(0xA0, 0xA0, 0xA8)), + }; + + public string Tooltip + { + get + { + var status = Severity switch + { + HealthSeverity.Ok => "✓ OK", + HealthSeverity.Warning => "⚠ Limitation détectée", + HealthSeverity.Error => "⛔ Problème", + _ => "❓ Non vérifié", + }; + var ts = LastCheckedUtc == DateTime.MinValue + ? "" + : $"\n\nDernière vérif : {LastCheckedUtc.ToLocalTime():HH:mm:ss}"; + var kindLabel = Entry.Kind?.Equals("ping", StringComparison.OrdinalIgnoreCase) == true + ? $"Ping {Entry.Target}" + : $"Processus {Entry.Target}"; + return $"{Entry.Icon} {Entry.Name} — {status}\n{kindLabel}\n\n{Detail}{ts}"; + } + } + + public HealthIndicatorViewModel(HealthCheckEntry entry) + { + Entry = entry; + } + + public void Apply(HealthResult result) + { + Severity = result.Severity; + Detail = result.Detail; + LastCheckedUtc = DateTime.UtcNow; + } +} diff --git a/src/PSLauncher.App/ViewModels/MainViewModel.cs b/src/PSLauncher.App/ViewModels/MainViewModel.cs index 7168365..05d635a 100644 --- a/src/PSLauncher.App/ViewModels/MainViewModel.cs +++ b/src/PSLauncher.App/ViewModels/MainViewModel.cs @@ -15,6 +15,7 @@ using PSLauncher.Core.Licensing; using PSLauncher.Core.Localization; using PSLauncher.Core.Manifests; using PSLauncher.Core.DocTool; +using PSLauncher.Core.Health; using PSLauncher.Core.Migrations; using PSLauncher.Core.Process; using PSLauncher.Core.ReportTool; @@ -45,6 +46,7 @@ public sealed partial class MainViewModel : ObservableObject private readonly IDatabaseMigrationService _migrationService; private readonly IReportToolDeployer _reportDeployer; private readonly IDocToolDeployer _docDeployer; + private readonly ISystemHealthService _healthService; private readonly IServiceProvider _serviceProvider; private readonly ILogger _logger; @@ -63,6 +65,15 @@ public sealed partial class MainViewModel : ObservableObject public ObservableCollection OtherVersions { get; } = new(); + /// + /// Indicateurs affichés dans le bandeau de santé sous la top bar : pings, + /// processus requis (SteamVR, Vive Streaming, etc.). Liste construite depuis + /// au démarrage. Refresh périodique + /// piloté par . + /// + public ObservableCollection HealthIndicators { get; } = new(); + public bool HasHealthIndicators => HealthIndicators.Count > 0; + [ObservableProperty] [NotifyPropertyChangedFor(nameof(HasFeaturedVersion))] [NotifyPropertyChangedFor(nameof(HasOtherVersions))] @@ -236,6 +247,7 @@ public sealed partial class MainViewModel : ObservableObject IDatabaseMigrationService migrationService, IReportToolDeployer reportDeployer, IDocToolDeployer docDeployer, + ISystemHealthService healthService, IServiceProvider serviceProvider, ILogger logger) { @@ -253,6 +265,7 @@ public sealed partial class MainViewModel : ObservableObject _migrationService = migrationService; _reportDeployer = reportDeployer; _docDeployer = docDeployer; + _healthService = healthService; _serviceProvider = serviceProvider; _logger = logger; @@ -261,6 +274,7 @@ public sealed partial class MainViewModel : ObservableObject _license = _licenseService.GetCached(); RebuildList(); + InitHealthIndicators(); // Vérification automatique des MAJ au démarrage (silencieuse et non bloquante) _ = Task.Run(async () => @@ -275,6 +289,62 @@ public sealed partial class MainViewModel : ObservableObject } catch (Exception ex) { _logger.LogWarning(ex, "Auto check at startup failed"); } }); + + // Boucle périodique des health checks. Ne tourne que si la config en + // contient au moins un. Premier passage immédiat puis re-check tous les + // RefreshIntervalSeconds. + StartHealthLoop(); + } + + /// + /// Construit la collection observable des indicateurs depuis la config et + /// notifie HasHealthIndicators pour que la UI cache le bandeau si la liste + /// est vide. Idempotent — appelée au startup uniquement, pas à chaque refresh. + /// + private void InitHealthIndicators() + { + HealthIndicators.Clear(); + foreach (var entry in _config.HealthChecks.Checks) + HealthIndicators.Add(new HealthIndicatorViewModel(entry)); + OnPropertyChanged(nameof(HasHealthIndicators)); + } + + /// + /// Lance une tâche en arrière-plan qui exécute tous les checks en parallèle, + /// applique les résultats sur les VM via le dispatcher UI, puis attend + /// RefreshIntervalSeconds avant de recommencer. La tâche tourne tant que + /// l'app vit ; pas de cancel explicite (l'arrêt suit la fermeture du process). + /// + private void StartHealthLoop() + { + if (HealthIndicators.Count == 0) return; + var interval = TimeSpan.FromSeconds(Math.Max(2, _config.HealthChecks.RefreshIntervalSeconds)); + _ = Task.Run(async () => + { + while (true) + { + try + { + var snapshot = HealthIndicators.ToList(); + var tasks = snapshot.Select(async vm => + { + try + { + var result = await _healthService.RunCheckAsync(vm.Entry, CancellationToken.None); + await Application.Current.Dispatcher.InvokeAsync(() => vm.Apply(result)); + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Health check {Name} threw", vm.Entry.Name); + } + }); + await Task.WhenAll(tasks).ConfigureAwait(false); + } + catch (Exception ex) { _logger.LogDebug(ex, "Health loop tick"); } + try { await Task.Delay(interval).ConfigureAwait(false); } + catch { break; } + } + }); } /// diff --git a/src/PSLauncher.App/Views/MainWindow.xaml b/src/PSLauncher.App/Views/MainWindow.xaml index 460b514..617f55d 100644 --- a/src/PSLauncher.App/Views/MainWindow.xaml +++ b/src/PSLauncher.App/Views/MainWindow.xaml @@ -198,16 +198,15 @@ - - - + + + + - - + + - - @@ -345,10 +341,54 @@ - + + + + + + + + + + + + + + + + + + + + + - + @@ -611,10 +651,10 @@ - + @@ -647,9 +687,10 @@ - - + +/// Effectue les vérifications de santé système configurées dans +/// : ping IP, processus tournant, etc. +/// +public interface ISystemHealthService +{ + /// + /// Exécute un check unique en async (ping ou check de process). Le résultat + /// porte la sévérité + un détail textuel destiné au tooltip de la UI. + /// + Task RunCheckAsync(HealthCheckEntry entry, CancellationToken ct); +} + +public sealed record HealthResult( + HealthSeverity Severity, + string Detail); + +/// +/// Sévérité d'un check. = check pas configuré +/// (Target vide), affiché en gris. +/// +public enum HealthSeverity +{ + Unknown, + Ok, + Warning, + Error, +} diff --git a/src/PSLauncher.Core/Health/SystemHealthService.cs b/src/PSLauncher.Core/Health/SystemHealthService.cs new file mode 100644 index 0000000..46f5b64 --- /dev/null +++ b/src/PSLauncher.Core/Health/SystemHealthService.cs @@ -0,0 +1,110 @@ +using System.Net.NetworkInformation; +using Microsoft.Extensions.Logging; +using PSLauncher.Models; +// NB : on n'importe PAS System.Diagnostics car le namespace PSLauncher.Core.Process +// existe déjà et provoque une ambiguïté avec System.Diagnostics.Process. +// On qualifie complètement chaque usage pour rester sans ambiguïté. +using SysProcess = System.Diagnostics.Process; + +namespace PSLauncher.Core.Health; + +/// +/// Implémentation : Ping ICMP via , et listing de processus +/// via . Pas de privilèges +/// administrateur requis (le ping classique ICMP fonctionne en user-mode sur +/// Windows 10+ ; pas besoin du raw socket). +/// +public sealed class SystemHealthService : ISystemHealthService +{ + private readonly ILogger _logger; + + public SystemHealthService(ILogger logger) + { + _logger = logger; + } + + public async Task RunCheckAsync(HealthCheckEntry entry, CancellationToken ct) + { + if (string.IsNullOrWhiteSpace(entry.Target)) + return new HealthResult(HealthSeverity.Unknown, "Non configuré (Target vide)"); + + try + { + return entry.Kind?.Trim().ToLowerInvariant() switch + { + "ping" => await CheckPingAsync(entry, ct).ConfigureAwait(false), + "process" => CheckProcess(entry), + _ => new HealthResult(HealthSeverity.Unknown, $"Kind inconnu : '{entry.Kind}'"), + }; + } + catch (OperationCanceledException) { throw; } + catch (Exception ex) + { + _logger.LogDebug(ex, "Health check {Name} ({Kind} {Target}) failed", entry.Name, entry.Kind, entry.Target); + return new HealthResult(HealthSeverity.Error, $"Exception : {ex.GetType().Name} — {ex.Message}"); + } + } + + private static async Task CheckPingAsync(HealthCheckEntry entry, CancellationToken ct) + { + using var ping = new Ping(); + var timeout = entry.PingTimeoutMs ?? 1000; + PingReply reply; + try + { + reply = await ping.SendPingAsync(entry.Target, timeout).WaitAsync(ct).ConfigureAwait(false); + } + catch (PingException pex) + { + return new HealthResult(HealthSeverity.Error, $"Ping {entry.Target} : DNS introuvable ou réseau inaccessible ({pex.InnerException?.Message ?? pex.Message})"); + } + + if (reply.Status != IPStatus.Success) + return new HealthResult(HealthSeverity.Error, + $"Ping {entry.Target} : pas de réponse ({reply.Status})"); + + var rtt = reply.RoundtripTime; + var warn = entry.PingWarnMs ?? 50; + var err = entry.PingErrorMs ?? 200; + + if (rtt > err) + return new HealthResult(HealthSeverity.Error, + $"Ping {entry.Target} : {rtt} ms (seuil rouge {err} ms)"); + if (rtt > warn) + return new HealthResult(HealthSeverity.Warning, + $"Ping {entry.Target} : {rtt} ms (seuil orange {warn} ms)"); + return new HealthResult(HealthSeverity.Ok, + $"Ping {entry.Target} : {rtt} ms"); + } + + private static HealthResult CheckProcess(HealthCheckEntry entry) + { + // Process.GetProcessesByName accepte le nom sans extension .exe + var name = entry.Target.Trim(); + if (name.EndsWith(".exe", StringComparison.OrdinalIgnoreCase)) + name = name[..^4]; + + SysProcess[] procs; + try + { + procs = SysProcess.GetProcessesByName(name); + } + catch (Exception ex) + { + return new HealthResult(HealthSeverity.Error, $"Lookup processus échoué : {ex.Message}"); + } + try + { + if (procs.Length == 0) + return new HealthResult(HealthSeverity.Error, + $"Processus « {name}.exe » non lancé"); + return new HealthResult(HealthSeverity.Ok, + $"« {name}.exe » est en cours d'exécution ({procs.Length} instance{(procs.Length > 1 ? "s" : "")})"); + } + finally + { + // Process objects portent un handle Win32 à libérer + foreach (var p in procs) p.Dispose(); + } + } +} diff --git a/src/PSLauncher.Models/LocalConfig.cs b/src/PSLauncher.Models/LocalConfig.cs index 5a170c6..df9b503 100644 --- a/src/PSLauncher.Models/LocalConfig.cs +++ b/src/PSLauncher.Models/LocalConfig.cs @@ -64,6 +64,14 @@ public sealed class LocalConfig /// public DocToolConfig DocTool { get; set; } = new(); + /// + /// Surveillance d'état système affichée en bandeau sous la top bar + /// (pings d'IP, processus requis comme SteamVR / Vive Business Streaming…). + /// Chaque check a un statut Vert (OK) / Orange (warn, ex. ping élevé) / Rouge + /// (KO). Tooltip détaillé au survol. + /// + public HealthChecksConfig HealthChecks { get; set; } = new(); + /// /// Connexion à la base MySQL locale (XAMPP) utilisée par PROSERVE pour ses /// statistiques. Le launcher s'en sert pour appliquer les migrations bundled @@ -142,6 +150,74 @@ public sealed class DocToolConfig public int MaxBackups { get; set; } = 3; } +public sealed class HealthChecksConfig +{ + /// Période de re-vérification, en secondes. 0 = désactivé. + public int RefreshIntervalSeconds { get; set; } = 10; + + /// + /// Liste des checks à effectuer. Defaults = exemples typiques setup VR + /// (SteamVR + Vive Business Streaming + ping casque). L'utilisateur édite + /// la liste via %LocalAppData%\PSLauncher\config.json pour ajuster + /// les noms de processus, IPs et seuils ; un éditeur Settings est prévu + /// en v2 si le besoin est récurrent. + /// + public List Checks { get; set; } = new() + { + new HealthCheckEntry + { + Name = "SteamVR", + Icon = "🎮", + Kind = "Process", + Target = "vrserver", + }, + new HealthCheckEntry + { + Name = "Vive Business Streaming", + Icon = "📡", + Kind = "Process", + Target = "HtcConnectionUtility", + }, + new HealthCheckEntry + { + Name = "Casque VR", + Icon = "🥽", + Kind = "Ping", + Target = "", // À remplir avec l'IP réseau du casque ; vide = check désactivé + PingWarnMs = 50, + PingErrorMs = 200, + PingTimeoutMs = 1000, + }, + }; +} + +public sealed class HealthCheckEntry +{ + /// Libellé affiché dans le bandeau, ex. "SteamVR". + public string Name { get; set; } = ""; + + /// Pictogramme emoji affiché à côté du nom, ex. "🎮", "📡", "🥽". + public string Icon { get; set; } = "🔌"; + + /// "Process" ou "Ping" (case-insensitive). + public string Kind { get; set; } = "Process"; + + /// + /// Selon Kind : nom du processus (sans .exe) pour Process, + /// IP/hostname pour Ping. Vide = check ignoré (statut Unknown). + /// + public string Target { get; set; } = ""; + + /// Pour Ping : RTT au-dessus duquel le statut passe en Warning (Orange). Default 50 ms. + public int? PingWarnMs { get; set; } = 50; + + /// Pour Ping : RTT au-dessus duquel le statut passe en Error (Rouge). Default 200 ms. + public int? PingErrorMs { get; set; } = 200; + + /// Pour Ping : timeout de la requête ICMP. Default 1 s. + public int? PingTimeoutMs { get; set; } = 1000; +} + public sealed class LicenseConfig { public string? EncryptedKey { get; set; } diff --git a/src/PSLauncher.Updater/PSLauncher.Updater.csproj b/src/PSLauncher.Updater/PSLauncher.Updater.csproj index 4adacda..bb273a3 100644 --- a/src/PSLauncher.Updater/PSLauncher.Updater.csproj +++ b/src/PSLauncher.Updater/PSLauncher.Updater.csproj @@ -8,7 +8,7 @@ latest PS_Launcher.Updater PSLauncher.Updater - 0.20.0 + 0.21.0 ..\PSLauncher.App\Resources\favicon.ico ASTERION VR © 2026 ASTERION VR — All rights reserved