Compare commits
17 Commits
c50abeb8d0
...
bebb701d70
| Author | SHA1 | Date | |
|---|---|---|---|
| bebb701d70 | |||
| 37a4cf424e | |||
| 7cb2b01403 | |||
| 07ead6011f | |||
| b94c0235b6 | |||
| c997859750 | |||
| 4d0e287227 | |||
| 211af9dd85 | |||
| 741e30b7ae | |||
| 93f54d094a | |||
| d61c1d85fa | |||
| 60358a6cf5 | |||
| 30eceaea2c | |||
| 3c8438f3fe | |||
| 0bd4c8a4be | |||
| 8366ca2669 | |||
| e608a5d29d |
@@ -11,7 +11,7 @@
|
|||||||
|
|
||||||
#define MyAppName "PROSERVE Launcher"
|
#define MyAppName "PROSERVE Launcher"
|
||||||
#define MyAppShortName "PS_Launcher"
|
#define MyAppShortName "PS_Launcher"
|
||||||
#define MyAppVersion "0.16.0"
|
#define MyAppVersion "0.23.5"
|
||||||
#define MyAppPublisher "ASTERION VR"
|
#define MyAppPublisher "ASTERION VR"
|
||||||
#define MyAppURL "https://asterionvr.com"
|
#define MyAppURL "https://asterionvr.com"
|
||||||
#define MyAppExeName "PS_Launcher.exe"
|
#define MyAppExeName "PS_Launcher.exe"
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ using PSLauncher.Core.Integrity;
|
|||||||
using PSLauncher.Core.Licensing;
|
using PSLauncher.Core.Licensing;
|
||||||
using PSLauncher.Core.Localization;
|
using PSLauncher.Core.Localization;
|
||||||
using PSLauncher.Core.Manifests;
|
using PSLauncher.Core.Manifests;
|
||||||
|
using PSLauncher.Core.DocTool;
|
||||||
|
using PSLauncher.Core.Health;
|
||||||
|
using PSLauncher.Core.Health.OpenVRInterop;
|
||||||
using PSLauncher.Core.Migrations;
|
using PSLauncher.Core.Migrations;
|
||||||
using PSLauncher.Core.Process;
|
using PSLauncher.Core.Process;
|
||||||
using PSLauncher.Core.ReportTool;
|
using PSLauncher.Core.ReportTool;
|
||||||
@@ -94,7 +97,19 @@ public partial class App : Application
|
|||||||
Log.Information("PSLauncher starting (logs in {Path})", LogsDirectory);
|
Log.Information("PSLauncher starting (logs in {Path})", LogsDirectory);
|
||||||
|
|
||||||
AppDomain.CurrentDomain.UnhandledException += (_, args) =>
|
AppDomain.CurrentDomain.UnhandledException += (_, args) =>
|
||||||
Log.Fatal((Exception)args.ExceptionObject, "Unhandled exception (AppDomain)");
|
{
|
||||||
|
// CRITIQUE : on flush AVANT que le process meure. Sans ça, sur les
|
||||||
|
// exceptions de corruption d'état (AccessViolation depuis P/Invoke,
|
||||||
|
// SEH natif…) le buffer Serilog n'est jamais écrit sur disque, et
|
||||||
|
// le crash est totalement invisible dans les logs — exactement le
|
||||||
|
// mode de panne qu'on a eu avec OpenVR pendant le démarrage SteamVR.
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Log.Fatal((Exception)args.ExceptionObject, "Unhandled exception (AppDomain) IsTerminating={IsTerminating}", args.IsTerminating);
|
||||||
|
Log.CloseAndFlush();
|
||||||
|
}
|
||||||
|
catch { /* dernier recours — on ne peut plus rien faire */ }
|
||||||
|
};
|
||||||
DispatcherUnhandledException += (_, args) =>
|
DispatcherUnhandledException += (_, args) =>
|
||||||
{
|
{
|
||||||
Log.Error(args.Exception, "Unhandled UI exception");
|
Log.Error(args.Exception, "Unhandled UI exception");
|
||||||
@@ -111,16 +126,16 @@ public partial class App : Application
|
|||||||
services.AddSingleton(sp =>
|
services.AddSingleton(sp =>
|
||||||
{
|
{
|
||||||
// Handler tuné pour les gros téléchargements parallèles :
|
// Handler tuné pour les gros téléchargements parallèles :
|
||||||
// - MaxConnectionsPerServer=16 pour autoriser le multi-segment (8 par défaut).
|
// - MaxConnectionsPerServer=32 pour aligner avec le clamp DownloadManager (8-32).
|
||||||
// - AutomaticDecompression sur l'API JSON uniquement (None ici car les builds
|
// Default WPF = 8 → trop bas pour notre multi-segment.
|
||||||
// sont des ZIPs déjà compressés ; éviter la CPU+RAM gaspillée par un éventuel
|
// - AutomaticDecompression=None : les builds sont des ZIPs déjà compressés ;
|
||||||
// wrapping gzip côté serveur).
|
// éviter la CPU+RAM gaspillée par un éventuel wrapping gzip côté serveur.
|
||||||
// - PooledConnectionLifetime court pour éviter qu'OVH ferme nos sockets sous nous.
|
// - PooledConnectionLifetime court pour éviter qu'OVH ferme nos sockets sous nous.
|
||||||
var handler = new SocketsHttpHandler
|
var handler = new SocketsHttpHandler
|
||||||
{
|
{
|
||||||
PooledConnectionLifetime = TimeSpan.FromMinutes(5),
|
PooledConnectionLifetime = TimeSpan.FromMinutes(5),
|
||||||
PooledConnectionIdleTimeout = TimeSpan.FromSeconds(60),
|
PooledConnectionIdleTimeout = TimeSpan.FromSeconds(60),
|
||||||
MaxConnectionsPerServer = 16,
|
MaxConnectionsPerServer = 32,
|
||||||
EnableMultipleHttp2Connections = true,
|
EnableMultipleHttp2Connections = true,
|
||||||
AutomaticDecompression = System.Net.DecompressionMethods.None,
|
AutomaticDecompression = System.Net.DecompressionMethods.None,
|
||||||
};
|
};
|
||||||
@@ -182,6 +197,16 @@ public partial class App : Application
|
|||||||
configProvider: () => sp.GetRequiredService<LocalConfig>().ReportTool,
|
configProvider: () => sp.GetRequiredService<LocalConfig>().ReportTool,
|
||||||
sp.GetRequiredService<ILogger<ReportToolDeployer>>()));
|
sp.GetRequiredService<ILogger<ReportToolDeployer>>()));
|
||||||
|
|
||||||
|
// Déploiement de l'outil Documentation (parallèle à Report) :
|
||||||
|
// copie atomique de _doc/ → C:\xampp\htdocs\ProserveDoc\
|
||||||
|
services.AddSingleton<IDocToolDeployer>(sp =>
|
||||||
|
new DocToolDeployer(
|
||||||
|
configProvider: () => sp.GetRequiredService<LocalConfig>().DocTool,
|
||||||
|
sp.GetRequiredService<ILogger<DocToolDeployer>>()));
|
||||||
|
|
||||||
|
services.AddSingleton<IOpenVrService, OpenVrService>();
|
||||||
|
services.AddSingleton<ISystemHealthService, SystemHealthService>();
|
||||||
|
|
||||||
services.AddTransient<SettingsViewModel>();
|
services.AddTransient<SettingsViewModel>();
|
||||||
services.AddSingleton<MainViewModel>();
|
services.AddSingleton<MainViewModel>();
|
||||||
services.AddSingleton<MainWindow>();
|
services.AddSingleton<MainWindow>();
|
||||||
|
|||||||
@@ -15,9 +15,9 @@
|
|||||||
<Product>PROSERVE Launcher</Product>
|
<Product>PROSERVE Launcher</Product>
|
||||||
<Copyright>© 2026 ASTERION VR — All rights reserved</Copyright>
|
<Copyright>© 2026 ASTERION VR — All rights reserved</Copyright>
|
||||||
<RootNamespace>PSLauncher.App</RootNamespace>
|
<RootNamespace>PSLauncher.App</RootNamespace>
|
||||||
<Version>0.16.0</Version>
|
<Version>0.23.5</Version>
|
||||||
<AssemblyVersion>0.16.0.0</AssemblyVersion>
|
<AssemblyVersion>0.23.5.0</AssemblyVersion>
|
||||||
<FileVersion>0.16.0.0</FileVersion>
|
<FileVersion>0.23.5.0</FileVersion>
|
||||||
|
|
||||||
<!-- Single-file self-contained publish profile (used by `dotnet publish`) -->
|
<!-- Single-file self-contained publish profile (used by `dotnet publish`) -->
|
||||||
<PublishSingleFile>true</PublishSingleFile>
|
<PublishSingleFile>true</PublishSingleFile>
|
||||||
|
|||||||
@@ -11,12 +11,18 @@
|
|||||||
<!-- Inverse bool : utilisé pour griser un bouton pendant qu'une opération est en cours -->
|
<!-- Inverse bool : utilisé pour griser un bouton pendant qu'une opération est en cours -->
|
||||||
<res:InverseBoolConverter x:Key="InverseBoolToBool" />
|
<res:InverseBoolConverter x:Key="InverseBoolToBool" />
|
||||||
|
|
||||||
<!-- Fond fenêtre : noir pur pour ne pas délaver le bitmap overlay -->
|
<!-- Fond fenêtre : presque noir avec un soupçon de bleu. v0.18 a tenté
|
||||||
<SolidColorBrush x:Key="Brush.Bg.Window" Color="#000000" />
|
un navy plus prononcé (#0A1220) mais c'était trop bleu — on revient
|
||||||
<!-- Cards & sidebar : très légère teinte bleutée pour le contraste -->
|
à un noir-bleuté très subtil pour rester sombre tout en gardant
|
||||||
<SolidColorBrush x:Key="Brush.Bg.Sidebar" Color="#0E1218" />
|
l'identité ASTERION VR. -->
|
||||||
<SolidColorBrush x:Key="Brush.Bg.Card" Color="#161B23" />
|
<SolidColorBrush x:Key="Brush.Bg.Window" Color="#050A14" />
|
||||||
<SolidColorBrush x:Key="Brush.Bg.Footer" Color="#050709" />
|
<SolidColorBrush x:Key="Brush.Bg.Sidebar" Color="#0B1018" />
|
||||||
|
<SolidColorBrush x:Key="Brush.Bg.Card" Color="#131826" />
|
||||||
|
<SolidColorBrush x:Key="Brush.Bg.Footer" Color="#04070D" />
|
||||||
|
|
||||||
|
<!-- Voile bleu très léger (4%) sur le bitmap de background. Juste de quoi
|
||||||
|
donner une nuance, pas un voile coloré franc. -->
|
||||||
|
<SolidColorBrush x:Key="Brush.Bg.BlueTint" Color="#3050A0" Opacity="0.04" />
|
||||||
<SolidColorBrush x:Key="Brush.Border" Color="#2A2F3A" />
|
<SolidColorBrush x:Key="Brush.Border" Color="#2A2F3A" />
|
||||||
<SolidColorBrush x:Key="Brush.Text.Primary" Color="#F2F2F2" />
|
<SolidColorBrush x:Key="Brush.Text.Primary" Color="#F2F2F2" />
|
||||||
<SolidColorBrush x:Key="Brush.Text.Secondary" Color="#A0A0A8" />
|
<SolidColorBrush x:Key="Brush.Text.Secondary" Color="#A0A0A8" />
|
||||||
@@ -281,6 +287,47 @@
|
|||||||
</Setter>
|
</Setter>
|
||||||
</Style>
|
</Style>
|
||||||
|
|
||||||
|
<!-- ToolTip sombre.
|
||||||
|
Le ToolTip natif WPF est blanc + texte noir + corner radius zéro,
|
||||||
|
look Win 7-ish qui jure complètement avec le thème dark. On le
|
||||||
|
re-template : fond Bg.Card, bordure Border, texte Text.Primary,
|
||||||
|
coins arrondis, padding plus généreux, et MaxWidth pour que les
|
||||||
|
tooltips longs (ex. health banner avec 4-5 lignes de détail)
|
||||||
|
wrappent au lieu de filer en hors-écran. -->
|
||||||
|
<Style TargetType="ToolTip">
|
||||||
|
<Setter Property="Background" Value="{StaticResource Brush.Bg.Card}" />
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource Brush.Text.Primary}" />
|
||||||
|
<Setter Property="BorderBrush" Value="{StaticResource Brush.Border}" />
|
||||||
|
<Setter Property="BorderThickness" Value="1" />
|
||||||
|
<Setter Property="Padding" Value="12,8" />
|
||||||
|
<Setter Property="FontSize" Value="12" />
|
||||||
|
<Setter Property="MaxWidth" Value="360" />
|
||||||
|
<Setter Property="HasDropShadow" Value="True" />
|
||||||
|
<Setter Property="Template">
|
||||||
|
<Setter.Value>
|
||||||
|
<ControlTemplate TargetType="ToolTip">
|
||||||
|
<Border Background="{TemplateBinding Background}"
|
||||||
|
BorderBrush="{TemplateBinding BorderBrush}"
|
||||||
|
BorderThickness="{TemplateBinding BorderThickness}"
|
||||||
|
Padding="{TemplateBinding Padding}"
|
||||||
|
CornerRadius="6">
|
||||||
|
<ContentPresenter>
|
||||||
|
<ContentPresenter.Resources>
|
||||||
|
<!-- ContentPresenter wrapping ToolTip Content :
|
||||||
|
si Content est une string, on veut un TextBlock
|
||||||
|
qui wrappe. Sinon c'est un visual user-fourni. -->
|
||||||
|
<Style TargetType="TextBlock">
|
||||||
|
<Setter Property="TextWrapping" Value="Wrap" />
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource Brush.Text.Primary}" />
|
||||||
|
</Style>
|
||||||
|
</ContentPresenter.Resources>
|
||||||
|
</ContentPresenter>
|
||||||
|
</Border>
|
||||||
|
</ControlTemplate>
|
||||||
|
</Setter.Value>
|
||||||
|
</Setter>
|
||||||
|
</Style>
|
||||||
|
|
||||||
<!-- ComboBox sombre.
|
<!-- ComboBox sombre.
|
||||||
Re-template minimal pour avoir le contenu, l'arrow et le popup tous en
|
Re-template minimal pour avoir le contenu, l'arrow et le popup tous en
|
||||||
palette dark. WPF native ComboBox style serait grisé sur blanc. -->
|
palette dark. WPF native ComboBox style serait grisé sur blanc. -->
|
||||||
|
|||||||
92
src/PSLauncher.App/ViewModels/HealthIndicatorViewModel.cs
Normal file
92
src/PSLauncher.App/ViewModels/HealthIndicatorViewModel.cs
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
using System.Windows.Media;
|
||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
using PSLauncher.Core.Health;
|
||||||
|
using PSLauncher.Models;
|
||||||
|
|
||||||
|
namespace PSLauncher.App.ViewModels;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Représente un indicateur visuel (pill colorée) dans le bandeau de santé
|
||||||
|
/// système. Mis à jour périodiquement par <see cref="MainViewModel"/> à
|
||||||
|
/// partir du résultat de <see cref="ISystemHealthService.RunCheckAsync"/>.
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,8 @@ using PSLauncher.Core.Installations;
|
|||||||
using PSLauncher.Core.Licensing;
|
using PSLauncher.Core.Licensing;
|
||||||
using PSLauncher.Core.Localization;
|
using PSLauncher.Core.Localization;
|
||||||
using PSLauncher.Core.Manifests;
|
using PSLauncher.Core.Manifests;
|
||||||
|
using PSLauncher.Core.DocTool;
|
||||||
|
using PSLauncher.Core.Health;
|
||||||
using PSLauncher.Core.Migrations;
|
using PSLauncher.Core.Migrations;
|
||||||
using PSLauncher.Core.Process;
|
using PSLauncher.Core.Process;
|
||||||
using PSLauncher.Core.ReportTool;
|
using PSLauncher.Core.ReportTool;
|
||||||
@@ -43,6 +45,8 @@ public sealed partial class MainViewModel : ObservableObject
|
|||||||
private readonly IToastService _toastService;
|
private readonly IToastService _toastService;
|
||||||
private readonly IDatabaseMigrationService _migrationService;
|
private readonly IDatabaseMigrationService _migrationService;
|
||||||
private readonly IReportToolDeployer _reportDeployer;
|
private readonly IReportToolDeployer _reportDeployer;
|
||||||
|
private readonly IDocToolDeployer _docDeployer;
|
||||||
|
private readonly ISystemHealthService _healthService;
|
||||||
private readonly IServiceProvider _serviceProvider;
|
private readonly IServiceProvider _serviceProvider;
|
||||||
private readonly ILogger<MainViewModel> _logger;
|
private readonly ILogger<MainViewModel> _logger;
|
||||||
|
|
||||||
@@ -51,9 +55,40 @@ public sealed partial class MainViewModel : ObservableObject
|
|||||||
private RemoteManifest? _lastManifest;
|
private RemoteManifest? _lastManifest;
|
||||||
private CancellationTokenSource? _activeDownloadCts;
|
private CancellationTokenSource? _activeDownloadCts;
|
||||||
private VersionRowViewModel? _activeRow;
|
private VersionRowViewModel? _activeRow;
|
||||||
|
/// <summary>
|
||||||
|
/// Référence au Task de l'install/DL en cours, exposée aux commandes externes
|
||||||
|
/// (ex. RestartFromZero) qui ont besoin d'attendre la fin réelle des écritures
|
||||||
|
/// disque avant de supprimer le partial. Sinon les segments continuent à écrire
|
||||||
|
/// pendant 1-2 sec après le Cancel() et recréent le fichier qu'on vient de delete.
|
||||||
|
/// </summary>
|
||||||
|
private Task? _activeInstallTask;
|
||||||
|
|
||||||
public ObservableCollection<VersionRowViewModel> OtherVersions { get; } = new();
|
public ObservableCollection<VersionRowViewModel> OtherVersions { get; } = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Indicateurs affichés dans le bandeau de santé sous la top bar : pings,
|
||||||
|
/// processus requis (SteamVR, Vive Streaming, etc.). Liste construite depuis
|
||||||
|
/// <see cref="HealthChecksConfig.Checks"/> au démarrage. Refresh périodique
|
||||||
|
/// piloté par <see cref="StartHealthLoop"/>.
|
||||||
|
/// </summary>
|
||||||
|
public ObservableCollection<HealthIndicatorViewModel> HealthIndicators { get; } = new();
|
||||||
|
public bool HasHealthIndicators => HealthIndicators.Count > 0;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Cancelle les boucles de polling per-check actuellement en vol. Recréée à
|
||||||
|
/// chaque appel à <see cref="StartHealthLoop"/> pour permettre un redémarrage
|
||||||
|
/// propre après édition des checks dans Settings (hot-reload sans relancer
|
||||||
|
/// l'app).
|
||||||
|
/// </summary>
|
||||||
|
private CancellationTokenSource? _healthLoopCts;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Le bandeau santé n'a de sens que sur la page Library — Reports et
|
||||||
|
/// Documentation sont des WebView pleine-page où afficher des pills
|
||||||
|
/// de monitoring système n'apporte rien et coupe visuellement.
|
||||||
|
/// </summary>
|
||||||
|
public bool ShowHealthBanner => HasHealthIndicators && IsLibrary;
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
[NotifyPropertyChangedFor(nameof(HasFeaturedVersion))]
|
[NotifyPropertyChangedFor(nameof(HasFeaturedVersion))]
|
||||||
[NotifyPropertyChangedFor(nameof(HasOtherVersions))]
|
[NotifyPropertyChangedFor(nameof(HasOtherVersions))]
|
||||||
@@ -90,6 +125,7 @@ public sealed partial class MainViewModel : ObservableObject
|
|||||||
[NotifyPropertyChangedFor(nameof(IsDocumentation))]
|
[NotifyPropertyChangedFor(nameof(IsDocumentation))]
|
||||||
[NotifyPropertyChangedFor(nameof(FooterVisibility))]
|
[NotifyPropertyChangedFor(nameof(FooterVisibility))]
|
||||||
[NotifyPropertyChangedFor(nameof(FooterText))]
|
[NotifyPropertyChangedFor(nameof(FooterText))]
|
||||||
|
[NotifyPropertyChangedFor(nameof(ShowHealthBanner))]
|
||||||
private LauncherPage _currentPage = LauncherPage.Library;
|
private LauncherPage _currentPage = LauncherPage.Library;
|
||||||
|
|
||||||
public bool IsLibrary => CurrentPage == LauncherPage.Library;
|
public bool IsLibrary => CurrentPage == LauncherPage.Library;
|
||||||
@@ -226,6 +262,8 @@ public sealed partial class MainViewModel : ObservableObject
|
|||||||
IToastService toastService,
|
IToastService toastService,
|
||||||
IDatabaseMigrationService migrationService,
|
IDatabaseMigrationService migrationService,
|
||||||
IReportToolDeployer reportDeployer,
|
IReportToolDeployer reportDeployer,
|
||||||
|
IDocToolDeployer docDeployer,
|
||||||
|
ISystemHealthService healthService,
|
||||||
IServiceProvider serviceProvider,
|
IServiceProvider serviceProvider,
|
||||||
ILogger<MainViewModel> logger)
|
ILogger<MainViewModel> logger)
|
||||||
{
|
{
|
||||||
@@ -242,6 +280,8 @@ public sealed partial class MainViewModel : ObservableObject
|
|||||||
_toastService = toastService;
|
_toastService = toastService;
|
||||||
_migrationService = migrationService;
|
_migrationService = migrationService;
|
||||||
_reportDeployer = reportDeployer;
|
_reportDeployer = reportDeployer;
|
||||||
|
_docDeployer = docDeployer;
|
||||||
|
_healthService = healthService;
|
||||||
_serviceProvider = serviceProvider;
|
_serviceProvider = serviceProvider;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
|
|
||||||
@@ -250,6 +290,7 @@ public sealed partial class MainViewModel : ObservableObject
|
|||||||
_license = _licenseService.GetCached();
|
_license = _licenseService.GetCached();
|
||||||
|
|
||||||
RebuildList();
|
RebuildList();
|
||||||
|
InitHealthIndicators();
|
||||||
|
|
||||||
// Vérification automatique des MAJ au démarrage (silencieuse et non bloquante)
|
// Vérification automatique des MAJ au démarrage (silencieuse et non bloquante)
|
||||||
_ = Task.Run(async () =>
|
_ = Task.Run(async () =>
|
||||||
@@ -264,6 +305,75 @@ public sealed partial class MainViewModel : ObservableObject
|
|||||||
}
|
}
|
||||||
catch (Exception ex) { _logger.LogWarning(ex, "Auto check at startup failed"); }
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
private void InitHealthIndicators()
|
||||||
|
{
|
||||||
|
HealthIndicators.Clear();
|
||||||
|
foreach (var entry in _config.HealthChecks.Checks)
|
||||||
|
HealthIndicators.Add(new HealthIndicatorViewModel(entry));
|
||||||
|
OnPropertyChanged(nameof(HasHealthIndicators));
|
||||||
|
OnPropertyChanged(nameof(ShowHealthBanner));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Lance une boucle indépendante par check, chacune cadencée sur son propre
|
||||||
|
/// <see cref="HealthCheckEntry.RefreshIntervalMs"/>. Un Process check rapide
|
||||||
|
/// (~1 ms) peut tourner toutes les 1-2 s sans impact CPU, alors qu'un Ping
|
||||||
|
/// (qui coûte un round-trip réseau) tourne typiquement à 5-10 s. Premier
|
||||||
|
/// passage immédiat puis re-check selon l'intervalle.
|
||||||
|
///
|
||||||
|
/// Cancelle la boucle précédente si elle existe (cas du hot-reload depuis
|
||||||
|
/// Settings → édition des checks).
|
||||||
|
/// </summary>
|
||||||
|
private void StartHealthLoop()
|
||||||
|
{
|
||||||
|
// Stop l'ancienne boucle si on est en hot-reload (édition Settings)
|
||||||
|
try { _healthLoopCts?.Cancel(); } catch { /* déjà disposée */ }
|
||||||
|
_healthLoopCts?.Dispose();
|
||||||
|
_healthLoopCts = null;
|
||||||
|
|
||||||
|
if (HealthIndicators.Count == 0) return;
|
||||||
|
|
||||||
|
_healthLoopCts = new CancellationTokenSource();
|
||||||
|
var ct = _healthLoopCts.Token;
|
||||||
|
|
||||||
|
foreach (var vm in HealthIndicators.ToList())
|
||||||
|
{
|
||||||
|
var localVm = vm;
|
||||||
|
// Floor de 500 ms pour éviter qu'un user mette 0/1 ms par erreur dans le json
|
||||||
|
// et sature un coeur CPU à pleins gaz.
|
||||||
|
var intervalMs = Math.Max(500, localVm.Entry.RefreshIntervalMs);
|
||||||
|
_ = Task.Run(async () =>
|
||||||
|
{
|
||||||
|
while (!ct.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await _healthService.RunCheckAsync(localVm.Entry, ct).ConfigureAwait(false);
|
||||||
|
if (ct.IsCancellationRequested) break;
|
||||||
|
await Application.Current.Dispatcher.InvokeAsync(() => localVm.Apply(result));
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) { break; }
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogDebug(ex, "Health check {Name} threw", localVm.Entry.Name);
|
||||||
|
}
|
||||||
|
try { await Task.Delay(intervalMs, ct).ConfigureAwait(false); }
|
||||||
|
catch { break; }
|
||||||
|
}
|
||||||
|
}, ct);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -329,7 +439,7 @@ public sealed partial class MainViewModel : ObservableObject
|
|||||||
private void WireRowHandlers(VersionRowViewModel row)
|
private void WireRowHandlers(VersionRowViewModel row)
|
||||||
{
|
{
|
||||||
row.LaunchHandler = LaunchVersion;
|
row.LaunchHandler = LaunchVersion;
|
||||||
row.InstallHandler = r => _ = InstallVersionAsync(r);
|
row.InstallHandler = r => _activeInstallTask = InstallVersionAsync(r);
|
||||||
row.UninstallHandler = r => _ = UninstallVersionAsync(r);
|
row.UninstallHandler = r => _ = UninstallVersionAsync(r);
|
||||||
row.ShowReleaseNotesHandler = r => _ = ShowReleaseNotesAsync(r);
|
row.ShowReleaseNotesHandler = r => _ = ShowReleaseNotesAsync(r);
|
||||||
row.OpenFolderHandler = OpenVersionFolder;
|
row.OpenFolderHandler = OpenVersionFolder;
|
||||||
@@ -456,6 +566,15 @@ public sealed partial class MainViewModel : ObservableObject
|
|||||||
_license = _licenseService.GetCached();
|
_license = _licenseService.GetCached();
|
||||||
NotifyLicenseChanged();
|
NotifyLicenseChanged();
|
||||||
RebuildList();
|
RebuildList();
|
||||||
|
|
||||||
|
// Hot-reload du bandeau de santé : la liste des checks (et leurs intervals)
|
||||||
|
// a pu être éditée. On reconstruit les indicateurs et on relance la boucle
|
||||||
|
// per-check sans avoir à fermer/rouvrir le launcher.
|
||||||
|
InitHealthIndicators();
|
||||||
|
StartHealthLoop();
|
||||||
|
OnPropertyChanged(nameof(ReportUri));
|
||||||
|
OnPropertyChanged(nameof(DocumentationUri));
|
||||||
|
OnPropertyChanged(nameof(HasDocumentationUrl));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -541,12 +660,26 @@ public sealed partial class MainViewModel : ObservableObject
|
|||||||
MessageBoxButton.YesNo, MessageBoxImage.Warning, MessageBoxResult.No);
|
MessageBoxButton.YesNo, MessageBoxImage.Warning, MessageBoxResult.No);
|
||||||
if (confirm != MessageBoxResult.Yes) return;
|
if (confirm != MessageBoxResult.Yes) return;
|
||||||
|
|
||||||
// Cancel le DL actif si c'est cette row
|
// Cancel le DL actif si c'est cette row, puis attend la propagation complète
|
||||||
|
// (les 16 segments doivent chacun stopper leurs écritures et fermer leur
|
||||||
|
// FileStream sinon DiscardResumableState échouera/sera réécrasé).
|
||||||
if (_activeRow is not null && _activeRow == row && _activeDownloadCts is not null)
|
if (_activeRow is not null && _activeRow == row && _activeDownloadCts is not null)
|
||||||
{
|
{
|
||||||
_activeDownloadCts.Cancel();
|
_activeDownloadCts.Cancel();
|
||||||
// Attend la fin de la tâche en cours pour libérer les handles fichier
|
// Attend que InstallVersionAsync termine son catch OperationCanceledException
|
||||||
try { await Task.Delay(200); } catch { }
|
// — y compris le flush + state save + libération des FileStream segment.
|
||||||
|
// Timeout de sécurité 10s au cas où une connexion HTTPS bloque sur close.
|
||||||
|
if (_activeInstallTask is { } running && !running.IsCompleted)
|
||||||
|
{
|
||||||
|
StatusMessage = "Annulation en cours…";
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var awaitTask = await Task.WhenAny(running, Task.Delay(TimeSpan.FromSeconds(10)));
|
||||||
|
if (!ReferenceEquals(awaitTask, running))
|
||||||
|
_logger.LogWarning("Install task did not finish cancelling within 10s, proceeding anyway");
|
||||||
|
}
|
||||||
|
catch (Exception ex) { _logger.LogDebug(ex, "Awaiting install task during restart"); }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_downloadManager.DiscardResumableState(row.Version);
|
_downloadManager.DiscardResumableState(row.Version);
|
||||||
@@ -678,9 +811,13 @@ public sealed partial class MainViewModel : ObservableObject
|
|||||||
row.ProgressPercent = 0;
|
row.ProgressPercent = 0;
|
||||||
var dlProgress = new Progress<DownloadProgress>(p => UpdateDlProgress(row, p));
|
var dlProgress = new Progress<DownloadProgress>(p => UpdateDlProgress(row, p));
|
||||||
// Reporter dédié pour la phase verify SHA-256 (peut prendre 30s-3min sur 14 Go).
|
// Reporter dédié pour la phase verify SHA-256 (peut prendre 30s-3min sur 14 Go).
|
||||||
// On bascule le StatusMessage à ce moment-là pour que l'utilisateur sache ce qui se passe.
|
// On bascule row.State sur Verifying à la première remontée, ce qui change
|
||||||
|
// le badge de la row de "⬇ Téléchargement…" vers "🔍 Vérification…" — les
|
||||||
|
// deux phases sont distinctes côté UX bien que `DownloadAsync` les enchaîne.
|
||||||
var hashProgress = new Progress<double>(pct =>
|
var hashProgress = new Progress<double>(pct =>
|
||||||
{
|
{
|
||||||
|
if (row.State == VersionRowState.Downloading)
|
||||||
|
row.State = VersionRowState.Verifying;
|
||||||
var percent = (int)Math.Round(pct * 100.0);
|
var percent = (int)Math.Round(pct * 100.0);
|
||||||
ProgressPercent = percent;
|
ProgressPercent = percent;
|
||||||
row.ProgressPercent = percent;
|
row.ProgressPercent = percent;
|
||||||
@@ -793,6 +930,49 @@ public sealed partial class MainViewModel : ObservableObject
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 7) Déploiement de l'outil Documentation (parallèle à Report).
|
||||||
|
// Cherche _doc/ dans le ZIP extrait et copie vers htdocs en
|
||||||
|
// atomic rename. Skip silencieux si _doc/ absent du ZIP.
|
||||||
|
if (_config.DocTool.AutoDeploy)
|
||||||
|
{
|
||||||
|
StatusMessage = Strings.StatusDeployingDoc(row.Version);
|
||||||
|
ProgressDetail = null;
|
||||||
|
ProgressPercent = 0;
|
||||||
|
var ddProgress = new Progress<DeployProgress>(dp =>
|
||||||
|
{
|
||||||
|
if (dp.FilesTotal > 0)
|
||||||
|
{
|
||||||
|
var pct = (double)dp.FilesDone / dp.FilesTotal * 100.0;
|
||||||
|
ProgressPercent = pct;
|
||||||
|
row.ProgressPercent = pct;
|
||||||
|
}
|
||||||
|
var label = Strings.ProgressDocDeploy(dp.FilesDone, dp.FilesTotal, dp.CurrentFilename);
|
||||||
|
ProgressDetail = label;
|
||||||
|
row.ProgressDetail = label;
|
||||||
|
});
|
||||||
|
var ddResult = await _docDeployer.DeployAsync(target, ddProgress, ct);
|
||||||
|
switch (ddResult.Status)
|
||||||
|
{
|
||||||
|
case DeployStatus.Deployed:
|
||||||
|
_logger.LogInformation("Doc tool deployed ({N} files) for v{Version}", ddResult.FilesDeployed, row.Version);
|
||||||
|
break;
|
||||||
|
case DeployStatus.SkippedNoSourceFolder:
|
||||||
|
_logger.LogInformation("No _doc/ in v{Version} ZIP, doc tool unchanged", row.Version);
|
||||||
|
break;
|
||||||
|
case DeployStatus.XamppNotFound:
|
||||||
|
// Le warning Report a déjà été affiché si nécessaire ; on reste silencieux ici
|
||||||
|
// (même cause = même symptôme, inutile de faire 2 popups identiques).
|
||||||
|
_logger.LogWarning("Doc tool deploy : XAMPP htdocs not found at {Root}", _config.DocTool.HtdocsRoot);
|
||||||
|
break;
|
||||||
|
case DeployStatus.Failed:
|
||||||
|
ThemedMessageBox.Show(
|
||||||
|
Strings.MsgDocDeployFailed(ddResult.ErrorMessage ?? "?"),
|
||||||
|
Strings.MsgBoxDocDeployFailed,
|
||||||
|
MessageBoxButton.OK, MessageBoxImage.Warning);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
StatusMessage = Strings.StatusInstalledSuccess(row.Version);
|
StatusMessage = Strings.StatusInstalledSuccess(row.Version);
|
||||||
ProgressDetail = null;
|
ProgressDetail = null;
|
||||||
ProgressPercent = 0;
|
ProgressPercent = 0;
|
||||||
@@ -832,6 +1012,7 @@ public sealed partial class MainViewModel : ObservableObject
|
|||||||
_activeDownloadCts?.Dispose();
|
_activeDownloadCts?.Dispose();
|
||||||
_activeDownloadCts = null;
|
_activeDownloadCts = null;
|
||||||
_activeRow = null;
|
_activeRow = null;
|
||||||
|
_activeInstallTask = null;
|
||||||
IsBusy = false;
|
IsBusy = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ using CommunityToolkit.Mvvm.Input;
|
|||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using PSLauncher.App.Views;
|
using PSLauncher.App.Views;
|
||||||
using PSLauncher.Core.Configuration;
|
using PSLauncher.Core.Configuration;
|
||||||
|
using PSLauncher.Core.DocTool;
|
||||||
using PSLauncher.Core.Downloads;
|
using PSLauncher.Core.Downloads;
|
||||||
using PSLauncher.Core.Installations;
|
using PSLauncher.Core.Installations;
|
||||||
using PSLauncher.Core.Licensing;
|
using PSLauncher.Core.Licensing;
|
||||||
@@ -33,6 +34,7 @@ public sealed partial class SettingsViewModel : ObservableObject
|
|||||||
private readonly IDownloadStateStore _downloadStore;
|
private readonly IDownloadStateStore _downloadStore;
|
||||||
private readonly IDatabaseMigrationService _migrationService;
|
private readonly IDatabaseMigrationService _migrationService;
|
||||||
private readonly IReportToolDeployer _reportDeployer;
|
private readonly IReportToolDeployer _reportDeployer;
|
||||||
|
private readonly IDocToolDeployer _docDeployer;
|
||||||
private readonly IInstallationRegistry _registry;
|
private readonly IInstallationRegistry _registry;
|
||||||
private readonly HttpClient _http;
|
private readonly HttpClient _http;
|
||||||
private readonly ILogger<SettingsViewModel> _logger;
|
private readonly ILogger<SettingsViewModel> _logger;
|
||||||
@@ -47,6 +49,7 @@ public sealed partial class SettingsViewModel : ObservableObject
|
|||||||
[ObservableProperty] private string _language;
|
[ObservableProperty] private string _language;
|
||||||
[ObservableProperty] private string _reportUrl;
|
[ObservableProperty] private string _reportUrl;
|
||||||
[ObservableProperty] private string _documentationUrl;
|
[ObservableProperty] private string _documentationUrl;
|
||||||
|
[ObservableProperty] private int _parallelSegments;
|
||||||
|
|
||||||
// ----- DB Migration settings -----
|
// ----- DB Migration settings -----
|
||||||
[ObservableProperty] private string _dbHost;
|
[ObservableProperty] private string _dbHost;
|
||||||
@@ -71,6 +74,28 @@ public sealed partial class SettingsViewModel : ObservableObject
|
|||||||
= new();
|
= new();
|
||||||
public bool HasReportBackups => ReportBackups.Count > 0;
|
public bool HasReportBackups => ReportBackups.Count > 0;
|
||||||
|
|
||||||
|
// ----- Doc Tool deploy settings (mirror of Report) -----
|
||||||
|
[ObservableProperty] private string _docHtdocsRoot;
|
||||||
|
[ObservableProperty] private string _docFolderName;
|
||||||
|
[ObservableProperty] private bool _docAutoDeploy;
|
||||||
|
[ObservableProperty] private int _docMaxBackups;
|
||||||
|
[ObservableProperty] private string? _docDeployStatus;
|
||||||
|
[ObservableProperty] private bool _isDeployingDoc;
|
||||||
|
public System.Collections.ObjectModel.ObservableCollection<DocBackupViewModel> DocBackups { get; }
|
||||||
|
= new();
|
||||||
|
public bool HasDocBackups => DocBackups.Count > 0;
|
||||||
|
|
||||||
|
// ----- Health checks (bandeau de santé système) -----
|
||||||
|
/// <summary>
|
||||||
|
/// Liste éditable des checks affichés dans le bandeau de santé. Chaque ligne
|
||||||
|
/// pointe vers une copie modifiable de l'entrée (l'original dans
|
||||||
|
/// <c>_config.HealthChecks.Checks</c> n'est écrasé qu'à <see cref="Save"/>),
|
||||||
|
/// pour que Cancel ne laisse pas de modifs partielles dans la config.
|
||||||
|
/// </summary>
|
||||||
|
public System.Collections.ObjectModel.ObservableCollection<HealthCheckRowViewModel> HealthChecks { get; }
|
||||||
|
= new();
|
||||||
|
public bool HasHealthChecks => HealthChecks.Count > 0;
|
||||||
|
|
||||||
public IReadOnlyList<LanguageOption> AvailableLanguages { get; } =
|
public IReadOnlyList<LanguageOption> AvailableLanguages { get; } =
|
||||||
PSLauncher.Core.Localization.Strings.Available
|
PSLauncher.Core.Localization.Strings.Available
|
||||||
.Select(t => new LanguageOption(t.Code, t.Name))
|
.Select(t => new LanguageOption(t.Code, t.Name))
|
||||||
@@ -139,6 +164,7 @@ public sealed partial class SettingsViewModel : ObservableObject
|
|||||||
IDownloadStateStore downloadStore,
|
IDownloadStateStore downloadStore,
|
||||||
IDatabaseMigrationService migrationService,
|
IDatabaseMigrationService migrationService,
|
||||||
IReportToolDeployer reportDeployer,
|
IReportToolDeployer reportDeployer,
|
||||||
|
IDocToolDeployer docDeployer,
|
||||||
IInstallationRegistry registry,
|
IInstallationRegistry registry,
|
||||||
HttpClient http,
|
HttpClient http,
|
||||||
ILogger<SettingsViewModel> logger)
|
ILogger<SettingsViewModel> logger)
|
||||||
@@ -149,6 +175,7 @@ public sealed partial class SettingsViewModel : ObservableObject
|
|||||||
_downloadStore = downloadStore;
|
_downloadStore = downloadStore;
|
||||||
_migrationService = migrationService;
|
_migrationService = migrationService;
|
||||||
_reportDeployer = reportDeployer;
|
_reportDeployer = reportDeployer;
|
||||||
|
_docDeployer = docDeployer;
|
||||||
_registry = registry;
|
_registry = registry;
|
||||||
_http = http;
|
_http = http;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
@@ -158,6 +185,7 @@ public sealed partial class SettingsViewModel : ObservableObject
|
|||||||
_language = string.IsNullOrWhiteSpace(config.Language) ? "auto" : config.Language;
|
_language = string.IsNullOrWhiteSpace(config.Language) ? "auto" : config.Language;
|
||||||
_reportUrl = config.ReportUrl;
|
_reportUrl = config.ReportUrl;
|
||||||
_documentationUrl = config.DocumentationUrl;
|
_documentationUrl = config.DocumentationUrl;
|
||||||
|
_parallelSegments = config.ParallelDownloadSegments;
|
||||||
|
|
||||||
_dbHost = config.Database.Host;
|
_dbHost = config.Database.Host;
|
||||||
_dbPort = config.Database.Port;
|
_dbPort = config.Database.Port;
|
||||||
@@ -170,8 +198,20 @@ public sealed partial class SettingsViewModel : ObservableObject
|
|||||||
_reportFolderName = config.ReportTool.FolderName;
|
_reportFolderName = config.ReportTool.FolderName;
|
||||||
_reportAutoDeploy = config.ReportTool.AutoDeploy;
|
_reportAutoDeploy = config.ReportTool.AutoDeploy;
|
||||||
_reportMaxBackups = config.ReportTool.MaxBackups;
|
_reportMaxBackups = config.ReportTool.MaxBackups;
|
||||||
// Charge la liste de backups en arrière-plan (UI immédiate puis remplie quand prête)
|
|
||||||
|
_docHtdocsRoot = config.DocTool.HtdocsRoot;
|
||||||
|
_docFolderName = config.DocTool.FolderName;
|
||||||
|
_docAutoDeploy = config.DocTool.AutoDeploy;
|
||||||
|
_docMaxBackups = config.DocTool.MaxBackups;
|
||||||
|
|
||||||
|
// Snapshot éditable des health checks. On clone chaque entry pour ne pas
|
||||||
|
// muter _config.HealthChecks.Checks tant que l'utilisateur n'a pas cliqué Save.
|
||||||
|
foreach (var entry in config.HealthChecks.Checks)
|
||||||
|
HealthChecks.Add(new HealthCheckRowViewModel(CloneEntry(entry), DeleteHealthCheck));
|
||||||
|
|
||||||
|
// Charge les listes de backups en arrière-plan (UI immédiate puis remplies quand prêtes)
|
||||||
_ = LoadReportBackupsAsync();
|
_ = LoadReportBackupsAsync();
|
||||||
|
_ = LoadDocBackupsAsync();
|
||||||
|
|
||||||
LauncherVersion = Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? "0.0.0";
|
LauncherVersion = Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? "0.0.0";
|
||||||
MachineId = licenseService.GetMachineId();
|
MachineId = licenseService.GetMachineId();
|
||||||
@@ -281,6 +321,7 @@ public sealed partial class SettingsViewModel : ObservableObject
|
|||||||
_config.Language = Language ?? "auto";
|
_config.Language = Language ?? "auto";
|
||||||
_config.ReportUrl = ReportUrl?.Trim() ?? "";
|
_config.ReportUrl = ReportUrl?.Trim() ?? "";
|
||||||
_config.DocumentationUrl = DocumentationUrl?.Trim() ?? "";
|
_config.DocumentationUrl = DocumentationUrl?.Trim() ?? "";
|
||||||
|
_config.ParallelDownloadSegments = Math.Clamp(ParallelSegments, 1, 32);
|
||||||
|
|
||||||
// DB settings
|
// DB settings
|
||||||
_config.Database.Host = DbHost.Trim();
|
_config.Database.Host = DbHost.Trim();
|
||||||
@@ -297,6 +338,16 @@ public sealed partial class SettingsViewModel : ObservableObject
|
|||||||
_config.ReportTool.AutoDeploy = ReportAutoDeploy;
|
_config.ReportTool.AutoDeploy = ReportAutoDeploy;
|
||||||
_config.ReportTool.MaxBackups = Math.Max(0, ReportMaxBackups);
|
_config.ReportTool.MaxBackups = Math.Max(0, ReportMaxBackups);
|
||||||
|
|
||||||
|
_config.DocTool.HtdocsRoot = DocHtdocsRoot.Trim();
|
||||||
|
_config.DocTool.FolderName = DocFolderName.Trim();
|
||||||
|
_config.DocTool.AutoDeploy = DocAutoDeploy;
|
||||||
|
_config.DocTool.MaxBackups = Math.Max(0, DocMaxBackups);
|
||||||
|
|
||||||
|
// Health checks : on remplace intégralement la liste persistée par
|
||||||
|
// les copies éditées. Les originaux restent en mémoire de toute façon
|
||||||
|
// (les VM les ont clonés), donc Cancel a déjà préservé l'état initial.
|
||||||
|
_config.HealthChecks.Checks = HealthChecks.Select(r => r.Entry).ToList();
|
||||||
|
|
||||||
_configStore.Save(_config);
|
_configStore.Save(_config);
|
||||||
_logger.LogInformation("Settings saved (language={Lang}, changed={Changed})", _config.Language, languageChanged);
|
_logger.LogInformation("Settings saved (language={Lang}, changed={Changed})", _config.Language, languageChanged);
|
||||||
|
|
||||||
@@ -553,6 +604,143 @@ public sealed partial class SettingsViewModel : ObservableObject
|
|||||||
IsDeployingReport = false;
|
IsDeployingReport = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===================== DOC TOOL BACKUPS (mirror of Report) =====================
|
||||||
|
|
||||||
|
private async Task LoadDocBackupsAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var list = await _docDeployer.ListBackupsAsync(CancellationToken.None);
|
||||||
|
DocBackups.Clear();
|
||||||
|
foreach (var b in list)
|
||||||
|
DocBackups.Add(new DocBackupViewModel(b, RevertDocToBackupAsync));
|
||||||
|
OnPropertyChanged(nameof(HasDocBackups));
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogDebug(ex, "Could not list doc backups");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task RevertDocToBackupAsync(BackupInfo backup)
|
||||||
|
{
|
||||||
|
var localDate = backup.TimestampUtc.ToLocalTime();
|
||||||
|
var dateLabel = Strings.FormatLongDate(localDate);
|
||||||
|
var confirm = ThemedMessageBox.Show(
|
||||||
|
Strings.MsgRevertReportConfirm(dateLabel),
|
||||||
|
Strings.MsgBoxRevertReport,
|
||||||
|
MessageBoxButton.YesNo, MessageBoxImage.Question, MessageBoxResult.No);
|
||||||
|
if (confirm != MessageBoxResult.Yes) return;
|
||||||
|
|
||||||
|
IsDeployingDoc = true;
|
||||||
|
DocDeployStatus = $"Revert vers {dateLabel}…";
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await _docDeployer.RevertAsync(backup.FolderName, CancellationToken.None);
|
||||||
|
DocDeployStatus = result.Status == DeployStatus.Deployed
|
||||||
|
? Strings.MsgRevertReportSuccess(dateLabel)
|
||||||
|
: $"✗ {result.ErrorMessage}";
|
||||||
|
await LoadDocBackupsAsync();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Revert doc failed");
|
||||||
|
DocDeployStatus = $"✗ {ex.Message}";
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
IsDeployingDoc = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================== HEALTH CHECKS =====================
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private void AddHealthCheck()
|
||||||
|
{
|
||||||
|
var entry = new HealthCheckEntry
|
||||||
|
{
|
||||||
|
Name = "",
|
||||||
|
Icon = "🔌",
|
||||||
|
Kind = "Process",
|
||||||
|
Target = "",
|
||||||
|
RefreshIntervalMs = 5000,
|
||||||
|
};
|
||||||
|
var dlg = new HealthCheckEditorDialog(entry, isNew: true)
|
||||||
|
{
|
||||||
|
Owner = Application.Current.MainWindow
|
||||||
|
};
|
||||||
|
if (dlg.ShowDialog() == true && dlg.Saved)
|
||||||
|
{
|
||||||
|
HealthChecks.Add(new HealthCheckRowViewModel(entry, DeleteHealthCheck));
|
||||||
|
OnPropertyChanged(nameof(HasHealthChecks));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void DeleteHealthCheck(HealthCheckRowViewModel row)
|
||||||
|
{
|
||||||
|
var confirm = ThemedMessageBox.Show(
|
||||||
|
Strings.SettingsHealthDeleteConfirm(string.IsNullOrEmpty(row.Name) ? row.Kind : row.Name),
|
||||||
|
Strings.MsgBoxConfirm,
|
||||||
|
MessageBoxButton.YesNo, MessageBoxImage.Question, MessageBoxResult.No);
|
||||||
|
if (confirm != MessageBoxResult.Yes) return;
|
||||||
|
HealthChecks.Remove(row);
|
||||||
|
OnPropertyChanged(nameof(HasHealthChecks));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static HealthCheckEntry CloneEntry(HealthCheckEntry src) => new()
|
||||||
|
{
|
||||||
|
Name = src.Name,
|
||||||
|
Icon = src.Icon,
|
||||||
|
Kind = src.Kind,
|
||||||
|
Target = src.Target,
|
||||||
|
RefreshIntervalMs = src.RefreshIntervalMs,
|
||||||
|
PingWarnMs = src.PingWarnMs,
|
||||||
|
PingErrorMs = src.PingErrorMs,
|
||||||
|
PingTimeoutMs = src.PingTimeoutMs,
|
||||||
|
};
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task RedeployDocAsync()
|
||||||
|
{
|
||||||
|
var installed = _registry.Scan().OrderByDescending(v => v.Version).FirstOrDefault();
|
||||||
|
if (installed is null)
|
||||||
|
{
|
||||||
|
DocDeployStatus = "Aucune version installée — installe d'abord une version qui contient _doc/.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
IsDeployingDoc = true;
|
||||||
|
DocDeployStatus = $"Déploiement depuis v{installed.Version}…";
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Save();
|
||||||
|
var progress = new Progress<DeployProgress>(dp =>
|
||||||
|
{
|
||||||
|
if (dp.FilesTotal > 0)
|
||||||
|
DocDeployStatus = $"{dp.FilesDone}/{dp.FilesTotal} : {dp.CurrentFilename}";
|
||||||
|
});
|
||||||
|
var result = await _docDeployer.DeployAsync(installed.FolderPath, progress, CancellationToken.None);
|
||||||
|
DocDeployStatus = result.Status switch
|
||||||
|
{
|
||||||
|
DeployStatus.Deployed => $"✓ {result.FilesDeployed} fichiers déployés vers {result.TargetPath}",
|
||||||
|
DeployStatus.SkippedNoSourceFolder => $"⚠ La version v{installed.Version} ne contient pas de _doc/ — rien à déployer",
|
||||||
|
DeployStatus.XamppNotFound => $"✗ {result.ErrorMessage}",
|
||||||
|
DeployStatus.Failed => $"✗ {result.ErrorMessage}",
|
||||||
|
_ => "?",
|
||||||
|
};
|
||||||
|
await LoadDocBackupsAsync();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Redeploy doc failed");
|
||||||
|
DocDeployStatus = $"✗ {ex.Message}";
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
IsDeployingDoc = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -576,3 +764,82 @@ public sealed partial class ReportBackupViewModel : ObservableObject
|
|||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private Task Revert() => _revertHandler(Info);
|
private Task Revert() => _revertHandler(Info);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ligne du tableau "Bandeau de santé système" dans Settings → Avancés. Pointe
|
||||||
|
/// vers une copie éditable de l'entrée (clonée à l'ouverture des Settings) ;
|
||||||
|
/// la persistence vers <c>config.HealthChecks.Checks</c> n'a lieu qu'au Save.
|
||||||
|
/// </summary>
|
||||||
|
public sealed partial class HealthCheckRowViewModel : ObservableObject
|
||||||
|
{
|
||||||
|
public HealthCheckEntry Entry { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>Re-publié à chaque édition pour rafraîchir le label dans la liste.</summary>
|
||||||
|
[ObservableProperty]
|
||||||
|
private string _displayName;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private string _displayIcon;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private string _displayDetail;
|
||||||
|
|
||||||
|
public string Name => Entry.Name;
|
||||||
|
public string Kind => Entry.Kind;
|
||||||
|
|
||||||
|
private readonly Action<HealthCheckRowViewModel> _onDelete;
|
||||||
|
|
||||||
|
public HealthCheckRowViewModel(HealthCheckEntry entry, Action<HealthCheckRowViewModel> onDelete)
|
||||||
|
{
|
||||||
|
Entry = entry;
|
||||||
|
_onDelete = onDelete;
|
||||||
|
_displayName = string.IsNullOrEmpty(entry.Name) ? "(sans nom)" : entry.Name;
|
||||||
|
_displayIcon = string.IsNullOrEmpty(entry.Icon) ? "🔌" : entry.Icon;
|
||||||
|
_displayDetail = ComputeDetail(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ComputeDetail(HealthCheckEntry e)
|
||||||
|
{
|
||||||
|
var kind = e.Kind?.Equals("ping", StringComparison.OrdinalIgnoreCase) == true ? "Ping" : "Process";
|
||||||
|
var target = string.IsNullOrEmpty(e.Target) ? "—" : e.Target;
|
||||||
|
return $"{kind} · {target} · {e.RefreshIntervalMs} ms";
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private void Edit()
|
||||||
|
{
|
||||||
|
var dlg = new PSLauncher.App.Views.HealthCheckEditorDialog(Entry)
|
||||||
|
{
|
||||||
|
Owner = Application.Current.MainWindow
|
||||||
|
};
|
||||||
|
if (dlg.ShowDialog() == true && dlg.Saved)
|
||||||
|
{
|
||||||
|
// Entry a été muté in-place par le dialog. Refresh des bindings d'affichage.
|
||||||
|
DisplayName = string.IsNullOrEmpty(Entry.Name) ? "(sans nom)" : Entry.Name;
|
||||||
|
DisplayIcon = string.IsNullOrEmpty(Entry.Icon) ? "🔌" : Entry.Icon;
|
||||||
|
DisplayDetail = ComputeDetail(Entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private void Delete() => _onDelete(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Variante doc — même structure que <see cref="ReportBackupViewModel"/>.</summary>
|
||||||
|
public sealed partial class DocBackupViewModel : ObservableObject
|
||||||
|
{
|
||||||
|
public BackupInfo Info { get; }
|
||||||
|
public string DateDisplay => Strings.FormatLongDate(Info.TimestampUtc.ToLocalTime());
|
||||||
|
public string SizeDisplay => Strings.SettingsReportBackupSize(Strings.FormatSize(Info.SizeBytes));
|
||||||
|
public string FolderName => Info.FolderName;
|
||||||
|
|
||||||
|
private readonly Func<BackupInfo, Task> _revertHandler;
|
||||||
|
public DocBackupViewModel(BackupInfo info, Func<BackupInfo, Task> revertHandler)
|
||||||
|
{
|
||||||
|
Info = info;
|
||||||
|
_revertHandler = revertHandler;
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private Task Revert() => _revertHandler(Info);
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ public enum VersionRowState
|
|||||||
InstalledIdle, // Installée localement, pas d'action en cours
|
InstalledIdle, // Installée localement, pas d'action en cours
|
||||||
AvailableIdle, // Disponible côté serveur, pas installée
|
AvailableIdle, // Disponible côté serveur, pas installée
|
||||||
Downloading,
|
Downloading,
|
||||||
|
Verifying, // SHA-256 du ZIP téléchargé en cours
|
||||||
Installing,
|
Installing,
|
||||||
Uninstalling,
|
Uninstalling,
|
||||||
}
|
}
|
||||||
@@ -77,13 +78,17 @@ public sealed partial class VersionRowViewModel : ObservableObject
|
|||||||
|
|
||||||
public bool IsInstalled => State is VersionRowState.InstalledIdle or VersionRowState.Uninstalling;
|
public bool IsInstalled => State is VersionRowState.InstalledIdle or VersionRowState.Uninstalling;
|
||||||
public bool IsRemoteOnly => State is VersionRowState.AvailableIdle;
|
public bool IsRemoteOnly => State is VersionRowState.AvailableIdle;
|
||||||
public bool IsBusy => State is VersionRowState.Downloading or VersionRowState.Installing or VersionRowState.Uninstalling;
|
public bool IsBusy => State is VersionRowState.Downloading
|
||||||
|
or VersionRowState.Verifying
|
||||||
|
or VersionRowState.Installing
|
||||||
|
or VersionRowState.Uninstalling;
|
||||||
|
|
||||||
public string StateLabel => State switch
|
public string StateLabel => State switch
|
||||||
{
|
{
|
||||||
VersionRowState.InstalledIdle => Strings.StatusInstalled,
|
VersionRowState.InstalledIdle => Strings.StatusInstalled,
|
||||||
VersionRowState.AvailableIdle => Strings.StatusAvailable,
|
VersionRowState.AvailableIdle => Strings.StatusAvailable,
|
||||||
VersionRowState.Downloading => Strings.StatusDownloading,
|
VersionRowState.Downloading => Strings.StatusDownloading,
|
||||||
|
VersionRowState.Verifying => Strings.StatusVerifying,
|
||||||
VersionRowState.Installing => Strings.StatusInstalling,
|
VersionRowState.Installing => Strings.StatusInstalling,
|
||||||
VersionRowState.Uninstalling => Strings.StatusUninstalling,
|
VersionRowState.Uninstalling => Strings.StatusUninstalling,
|
||||||
_ => string.Empty
|
_ => string.Empty
|
||||||
|
|||||||
263
src/PSLauncher.App/Views/HealthCheckEditorDialog.xaml
Normal file
263
src/PSLauncher.App/Views/HealthCheckEditorDialog.xaml
Normal file
@@ -0,0 +1,263 @@
|
|||||||
|
<Window x:Class="PSLauncher.App.Views.HealthCheckEditorDialog"
|
||||||
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:loc="clr-namespace:PSLauncher.Core.Localization;assembly=PSLauncher.Core"
|
||||||
|
Title="{x:Static loc:Strings.HealthEditorTitle}"
|
||||||
|
Icon="pack://application:,,,/Resources/favicon.ico"
|
||||||
|
Width="540" Height="640"
|
||||||
|
MinWidth="480" MinHeight="540"
|
||||||
|
WindowStartupLocation="CenterOwner"
|
||||||
|
Background="{StaticResource Brush.Bg.Window}"
|
||||||
|
ResizeMode="CanResize">
|
||||||
|
|
||||||
|
<Grid>
|
||||||
|
<Grid.RowDefinitions>
|
||||||
|
<RowDefinition Height="Auto" />
|
||||||
|
<RowDefinition Height="*" />
|
||||||
|
<RowDefinition Height="Auto" />
|
||||||
|
</Grid.RowDefinitions>
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<Border Grid.Row="0" Padding="24,20,24,12">
|
||||||
|
<TextBlock x:Name="TitleText"
|
||||||
|
Text="{x:Static loc:Strings.HealthEditorTitle}"
|
||||||
|
FontSize="20" FontWeight="SemiBold"
|
||||||
|
Foreground="{StaticResource Brush.Text.Primary}" />
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<ScrollViewer Grid.Row="1" VerticalScrollBarVisibility="Auto" Padding="24,0,24,12">
|
||||||
|
<StackPanel>
|
||||||
|
|
||||||
|
<!-- Nom -->
|
||||||
|
<TextBlock Text="{x:Static loc:Strings.HealthEditorName}"
|
||||||
|
FontSize="11" FontWeight="Bold"
|
||||||
|
Foreground="{StaticResource Brush.Text.Secondary}"
|
||||||
|
Margin="0,0,0,4" />
|
||||||
|
<TextBox x:Name="NameBox"
|
||||||
|
Background="#1A1A20"
|
||||||
|
Foreground="{StaticResource Brush.Text.Primary}"
|
||||||
|
BorderBrush="{StaticResource Brush.Border}"
|
||||||
|
BorderThickness="1" Padding="8" />
|
||||||
|
<TextBlock Text="{x:Static loc:Strings.HealthEditorNameHint}"
|
||||||
|
FontSize="11" FontStyle="Italic"
|
||||||
|
Foreground="{StaticResource Brush.Text.Secondary}"
|
||||||
|
Margin="0,4,0,12" />
|
||||||
|
|
||||||
|
<!-- Type de check -->
|
||||||
|
<TextBlock Text="{x:Static loc:Strings.HealthEditorKind}"
|
||||||
|
FontSize="11" FontWeight="Bold"
|
||||||
|
Foreground="{StaticResource Brush.Text.Secondary}"
|
||||||
|
Margin="0,0,0,4" />
|
||||||
|
<StackPanel Orientation="Horizontal" Margin="0,0,0,12">
|
||||||
|
<RadioButton x:Name="KindProcess"
|
||||||
|
GroupName="Kind"
|
||||||
|
Content="{x:Static loc:Strings.HealthEditorKindProcess}"
|
||||||
|
Foreground="{StaticResource Brush.Text.Primary}"
|
||||||
|
Margin="0,0,20,0"
|
||||||
|
Checked="OnKindChanged" />
|
||||||
|
<RadioButton x:Name="KindPing"
|
||||||
|
GroupName="Kind"
|
||||||
|
Content="{x:Static loc:Strings.HealthEditorKindPing}"
|
||||||
|
Foreground="{StaticResource Brush.Text.Primary}"
|
||||||
|
Margin="0,0,20,0"
|
||||||
|
Checked="OnKindChanged" />
|
||||||
|
<RadioButton x:Name="KindVrDevice"
|
||||||
|
GroupName="Kind"
|
||||||
|
Content="{x:Static loc:Strings.HealthEditorKindVrDevice}"
|
||||||
|
Foreground="{StaticResource Brush.Text.Primary}"
|
||||||
|
Checked="OnKindChanged" />
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<!-- Cible (process name, IP/host, ou alias VR) -->
|
||||||
|
<TextBlock x:Name="TargetLabel"
|
||||||
|
Text="{x:Static loc:Strings.HealthEditorTargetProcess}"
|
||||||
|
FontSize="11" FontWeight="Bold"
|
||||||
|
Foreground="{StaticResource Brush.Text.Secondary}"
|
||||||
|
Margin="0,0,0,4" />
|
||||||
|
<TextBox x:Name="TargetBox"
|
||||||
|
Background="#1A1A20"
|
||||||
|
Foreground="{StaticResource Brush.Text.Primary}"
|
||||||
|
BorderBrush="{StaticResource Brush.Border}"
|
||||||
|
BorderThickness="1" Padding="8"
|
||||||
|
FontFamily="Consolas" />
|
||||||
|
|
||||||
|
<!-- ComboBox d'alias VR (visible uniquement si Kind=VrDevice) -->
|
||||||
|
<ComboBox x:Name="VrDeviceCombo"
|
||||||
|
Background="#1A1A20"
|
||||||
|
Foreground="{StaticResource Brush.Text.Primary}"
|
||||||
|
BorderBrush="{StaticResource Brush.Border}"
|
||||||
|
BorderThickness="1" Padding="8"
|
||||||
|
DisplayMemberPath="Label"
|
||||||
|
SelectedValuePath="Alias"
|
||||||
|
Visibility="Collapsed" />
|
||||||
|
|
||||||
|
<TextBlock x:Name="TargetHint"
|
||||||
|
Text="{x:Static loc:Strings.HealthEditorTargetProcessHint}"
|
||||||
|
FontSize="11" FontStyle="Italic"
|
||||||
|
Foreground="{StaticResource Brush.Text.Secondary}"
|
||||||
|
Margin="0,4,0,12" TextWrapping="Wrap" />
|
||||||
|
|
||||||
|
<!-- Refresh interval -->
|
||||||
|
<TextBlock Text="{x:Static loc:Strings.HealthEditorRefresh}"
|
||||||
|
FontSize="11" FontWeight="Bold"
|
||||||
|
Foreground="{StaticResource Brush.Text.Secondary}"
|
||||||
|
Margin="0,0,0,4" />
|
||||||
|
<Grid Margin="0,0,0,4">
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="120" />
|
||||||
|
<ColumnDefinition Width="*" />
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<TextBox x:Name="RefreshMsBox"
|
||||||
|
Grid.Column="0"
|
||||||
|
Background="#1A1A20"
|
||||||
|
Foreground="{StaticResource Brush.Text.Primary}"
|
||||||
|
BorderBrush="{StaticResource Brush.Border}"
|
||||||
|
BorderThickness="1" Padding="8" />
|
||||||
|
<TextBlock Grid.Column="1" VerticalAlignment="Center"
|
||||||
|
Margin="8,0,0,0"
|
||||||
|
Text="ms"
|
||||||
|
Foreground="{StaticResource Brush.Text.Secondary}" />
|
||||||
|
</Grid>
|
||||||
|
<TextBlock Text="{x:Static loc:Strings.HealthEditorRefreshHint}"
|
||||||
|
FontSize="11" FontStyle="Italic"
|
||||||
|
Foreground="{StaticResource Brush.Text.Secondary}"
|
||||||
|
Margin="0,0,0,12" TextWrapping="Wrap" />
|
||||||
|
|
||||||
|
<!-- Picto picker (grille d'emojis) -->
|
||||||
|
<TextBlock Text="{x:Static loc:Strings.HealthEditorIcon}"
|
||||||
|
FontSize="11" FontWeight="Bold"
|
||||||
|
Foreground="{StaticResource Brush.Text.Secondary}"
|
||||||
|
Margin="0,0,0,4" />
|
||||||
|
<Border Background="#1A1A20"
|
||||||
|
BorderBrush="{StaticResource Brush.Border}"
|
||||||
|
BorderThickness="1" CornerRadius="4" Padding="6">
|
||||||
|
<ItemsControl x:Name="IconPicker">
|
||||||
|
<ItemsControl.ItemsPanel>
|
||||||
|
<ItemsPanelTemplate>
|
||||||
|
<WrapPanel Orientation="Horizontal" />
|
||||||
|
</ItemsPanelTemplate>
|
||||||
|
</ItemsControl.ItemsPanel>
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate>
|
||||||
|
<Button Click="OnIconClicked"
|
||||||
|
Tag="{Binding Emoji}"
|
||||||
|
Content="{Binding Emoji}"
|
||||||
|
FontSize="22"
|
||||||
|
Width="40" Height="40"
|
||||||
|
Margin="2"
|
||||||
|
BorderThickness="2"
|
||||||
|
Cursor="Hand">
|
||||||
|
<Button.Style>
|
||||||
|
<Style TargetType="Button">
|
||||||
|
<Setter Property="Background" Value="#0F1117" />
|
||||||
|
<Setter Property="BorderBrush" Value="#2A2F3A" />
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource Brush.Text.Primary}" />
|
||||||
|
<Setter Property="Template">
|
||||||
|
<Setter.Value>
|
||||||
|
<ControlTemplate TargetType="Button">
|
||||||
|
<Border CornerRadius="4"
|
||||||
|
Background="{TemplateBinding Background}"
|
||||||
|
BorderBrush="{TemplateBinding BorderBrush}"
|
||||||
|
BorderThickness="{TemplateBinding BorderThickness}">
|
||||||
|
<ContentPresenter HorizontalAlignment="Center"
|
||||||
|
VerticalAlignment="Center" />
|
||||||
|
</Border>
|
||||||
|
</ControlTemplate>
|
||||||
|
</Setter.Value>
|
||||||
|
</Setter>
|
||||||
|
<Style.Triggers>
|
||||||
|
<Trigger Property="IsMouseOver" Value="True">
|
||||||
|
<Setter Property="Background" Value="#1F2530" />
|
||||||
|
<Setter Property="BorderBrush" Value="#3B82F6" />
|
||||||
|
</Trigger>
|
||||||
|
<DataTrigger Binding="{Binding IsSelected}" Value="True">
|
||||||
|
<Setter Property="Background" Value="#1B2A40" />
|
||||||
|
<Setter Property="BorderBrush" Value="#3B82F6" />
|
||||||
|
</DataTrigger>
|
||||||
|
</Style.Triggers>
|
||||||
|
</Style>
|
||||||
|
</Button.Style>
|
||||||
|
</Button>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
</Border>
|
||||||
|
<TextBlock Text="{x:Static loc:Strings.HealthEditorIconHint}"
|
||||||
|
FontSize="11" FontStyle="Italic"
|
||||||
|
Foreground="{StaticResource Brush.Text.Secondary}"
|
||||||
|
Margin="0,4,0,12" TextWrapping="Wrap" />
|
||||||
|
|
||||||
|
<!-- Section Avancé : seuils Ping. Visible uniquement si Kind=Ping. -->
|
||||||
|
<Expander x:Name="PingAdvanced"
|
||||||
|
Header="{x:Static loc:Strings.HealthEditorPingAdvanced}"
|
||||||
|
Foreground="{StaticResource Brush.Text.Secondary}"
|
||||||
|
FontSize="11" FontWeight="Bold"
|
||||||
|
Background="Transparent" BorderThickness="0"
|
||||||
|
Margin="0,4,0,0" IsExpanded="False"
|
||||||
|
Visibility="Collapsed">
|
||||||
|
<Border Background="{StaticResource Brush.Bg.Card}"
|
||||||
|
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1"
|
||||||
|
CornerRadius="6" Padding="16" Margin="0,8,0,0">
|
||||||
|
<StackPanel>
|
||||||
|
<TextBlock Text="{x:Static loc:Strings.HealthEditorPingWarn}"
|
||||||
|
Foreground="{StaticResource Brush.Text.Secondary}"
|
||||||
|
FontSize="12" Margin="0,0,0,4" />
|
||||||
|
<TextBox x:Name="PingWarnBox"
|
||||||
|
Background="#1A1A20"
|
||||||
|
Foreground="{StaticResource Brush.Text.Primary}"
|
||||||
|
BorderBrush="{StaticResource Brush.Border}"
|
||||||
|
BorderThickness="1" Padding="8"
|
||||||
|
Width="120" HorizontalAlignment="Left" />
|
||||||
|
|
||||||
|
<TextBlock Text="{x:Static loc:Strings.HealthEditorPingError}"
|
||||||
|
Foreground="{StaticResource Brush.Text.Secondary}"
|
||||||
|
FontSize="12" Margin="0,12,0,4" />
|
||||||
|
<TextBox x:Name="PingErrorBox"
|
||||||
|
Background="#1A1A20"
|
||||||
|
Foreground="{StaticResource Brush.Text.Primary}"
|
||||||
|
BorderBrush="{StaticResource Brush.Border}"
|
||||||
|
BorderThickness="1" Padding="8"
|
||||||
|
Width="120" HorizontalAlignment="Left" />
|
||||||
|
|
||||||
|
<TextBlock Text="{x:Static loc:Strings.HealthEditorPingTimeout}"
|
||||||
|
Foreground="{StaticResource Brush.Text.Secondary}"
|
||||||
|
FontSize="12" Margin="0,12,0,4" />
|
||||||
|
<TextBox x:Name="PingTimeoutBox"
|
||||||
|
Background="#1A1A20"
|
||||||
|
Foreground="{StaticResource Brush.Text.Primary}"
|
||||||
|
BorderBrush="{StaticResource Brush.Border}"
|
||||||
|
BorderThickness="1" Padding="8"
|
||||||
|
Width="120" HorizontalAlignment="Left" />
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
</Expander>
|
||||||
|
|
||||||
|
<TextBlock x:Name="ErrorText"
|
||||||
|
Foreground="#F87171"
|
||||||
|
FontSize="12"
|
||||||
|
Margin="0,12,0,0"
|
||||||
|
Visibility="Collapsed"
|
||||||
|
TextWrapping="Wrap" />
|
||||||
|
|
||||||
|
</StackPanel>
|
||||||
|
</ScrollViewer>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<Border Grid.Row="2" Background="{StaticResource Brush.Bg.Footer}"
|
||||||
|
BorderBrush="{StaticResource Brush.Border}" BorderThickness="0,1,0,0"
|
||||||
|
Padding="20,12">
|
||||||
|
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right">
|
||||||
|
<Button Style="{StaticResource SecondaryButton}"
|
||||||
|
Content="{x:Static loc:Strings.ActionCancel}"
|
||||||
|
IsCancel="True"
|
||||||
|
Margin="0,0,12,0"
|
||||||
|
Click="OnCancel" />
|
||||||
|
<Button Style="{StaticResource AccentButton}"
|
||||||
|
Content="{x:Static loc:Strings.ActionSave}"
|
||||||
|
Padding="32,8"
|
||||||
|
IsDefault="True"
|
||||||
|
Click="OnSave" />
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
</Grid>
|
||||||
|
</Window>
|
||||||
227
src/PSLauncher.App/Views/HealthCheckEditorDialog.xaml.cs
Normal file
227
src/PSLauncher.App/Views/HealthCheckEditorDialog.xaml.cs
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
using System.Collections.ObjectModel;
|
||||||
|
using System.Windows;
|
||||||
|
using System.Windows.Controls;
|
||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
using PSLauncher.Core.Localization;
|
||||||
|
using PSLauncher.Models;
|
||||||
|
|
||||||
|
namespace PSLauncher.App.Views;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Modal d'édition d'une entrée du bandeau de santé. Présenté soit pour création
|
||||||
|
/// (constructeur sans argument), soit pour modification (constructeur avec entry
|
||||||
|
/// existante). À la sauvegarde, applique les valeurs sur l'objet d'origine pour
|
||||||
|
/// que la liste parente n'ait rien à reconstruire.
|
||||||
|
///
|
||||||
|
/// Choix UI : icon picker en grille avec 28 emojis curatés couvrant les usages
|
||||||
|
/// VR/streaming/réseau (les checks typiques de PROSERVE). On évite la saisie
|
||||||
|
/// libre d'un emoji pour ne pas se retrouver avec des caractères qui ne rendent
|
||||||
|
/// pas bien dans la pill du bandeau.
|
||||||
|
/// </summary>
|
||||||
|
public partial class HealthCheckEditorDialog : Window
|
||||||
|
{
|
||||||
|
/// <summary>Emojis proposés dans le picker. Listés du plus probable au moins probable.</summary>
|
||||||
|
private static readonly string[] CuratedIcons =
|
||||||
|
{
|
||||||
|
"🎮", "🥽", "📡", "🌐", "📶", "🔌", "💻", "🖥",
|
||||||
|
"📷", "🎧", "🛡", "🔥", "⚙", "🚀", "🔧", "📦",
|
||||||
|
"🎯", "🏃", "🚨", "🔋", "💾", "🔍", "📊", "🖱",
|
||||||
|
"⌨", "🎚", "🟢", "🔵",
|
||||||
|
};
|
||||||
|
|
||||||
|
public HealthCheckEntry Entry { get; }
|
||||||
|
public bool Saved { get; private set; }
|
||||||
|
|
||||||
|
private readonly ObservableCollection<IconOption> _icons = new();
|
||||||
|
|
||||||
|
public HealthCheckEditorDialog() : this(new HealthCheckEntry
|
||||||
|
{
|
||||||
|
Name = "",
|
||||||
|
Icon = "🔌",
|
||||||
|
Kind = "Process",
|
||||||
|
Target = "",
|
||||||
|
RefreshIntervalMs = 5000,
|
||||||
|
}, isNew: true)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Aliases acceptés par OpenVrService pour le picker VrDevice.</summary>
|
||||||
|
private static readonly VrAliasOption[] VrAliases =
|
||||||
|
{
|
||||||
|
new("hmd", "HMD"),
|
||||||
|
new("left_controller", "Manette gauche"),
|
||||||
|
new("right_controller", "Manette droite"),
|
||||||
|
new("tracker_1", "Tracker #1"),
|
||||||
|
new("tracker_2", "Tracker #2"),
|
||||||
|
new("tracker_3", "Tracker #3"),
|
||||||
|
new("base_station_1", "Base station #1"),
|
||||||
|
new("base_station_2", "Base station #2"),
|
||||||
|
};
|
||||||
|
|
||||||
|
public HealthCheckEditorDialog(HealthCheckEntry entry, bool isNew = false)
|
||||||
|
{
|
||||||
|
Entry = entry;
|
||||||
|
InitializeComponent();
|
||||||
|
|
||||||
|
TitleText.Text = isNew ? Strings.HealthEditorTitleNew : Strings.HealthEditorTitle;
|
||||||
|
|
||||||
|
// Charge les valeurs courantes
|
||||||
|
NameBox.Text = entry.Name ?? "";
|
||||||
|
TargetBox.Text = entry.Target ?? "";
|
||||||
|
RefreshMsBox.Text = entry.RefreshIntervalMs.ToString();
|
||||||
|
|
||||||
|
VrDeviceCombo.ItemsSource = VrAliases;
|
||||||
|
|
||||||
|
var kind = (entry.Kind ?? "Process").Trim().ToLowerInvariant();
|
||||||
|
var isPing = kind == "ping";
|
||||||
|
var isVrDevice = kind == "vrdevice";
|
||||||
|
var isProcess = !isPing && !isVrDevice;
|
||||||
|
KindProcess.IsChecked = isProcess;
|
||||||
|
KindPing.IsChecked = isPing;
|
||||||
|
KindVrDevice.IsChecked = isVrDevice;
|
||||||
|
|
||||||
|
if (isVrDevice)
|
||||||
|
{
|
||||||
|
// Sélectionne l'alias correspondant ; fallback "hmd" si Target inconnu
|
||||||
|
var alias = string.IsNullOrEmpty(entry.Target) ? "hmd" : entry.Target.Trim().ToLowerInvariant();
|
||||||
|
var match = VrAliases.FirstOrDefault(a => string.Equals(a.Alias, alias, StringComparison.OrdinalIgnoreCase));
|
||||||
|
VrDeviceCombo.SelectedValue = match?.Alias ?? "hmd";
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
VrDeviceCombo.SelectedValue = "hmd";
|
||||||
|
}
|
||||||
|
|
||||||
|
UpdateKindUi(kind);
|
||||||
|
|
||||||
|
PingWarnBox.Text = (entry.PingWarnMs ?? 50).ToString();
|
||||||
|
PingErrorBox.Text = (entry.PingErrorMs ?? 200).ToString();
|
||||||
|
PingTimeoutBox.Text = (entry.PingTimeoutMs ?? 1000).ToString();
|
||||||
|
|
||||||
|
// Construit le picker, marque l'icône courante comme sélectionnée
|
||||||
|
var current = string.IsNullOrEmpty(entry.Icon) ? "🔌" : entry.Icon;
|
||||||
|
var iconList = CuratedIcons.ToList();
|
||||||
|
if (!iconList.Contains(current)) iconList.Insert(0, current); // garde une icône custom si déjà en config
|
||||||
|
foreach (var ic in iconList)
|
||||||
|
_icons.Add(new IconOption(ic) { IsSelected = ic == current });
|
||||||
|
IconPicker.ItemsSource = _icons;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdateKindUi(string kind)
|
||||||
|
{
|
||||||
|
switch (kind)
|
||||||
|
{
|
||||||
|
case "ping":
|
||||||
|
TargetLabel.Text = Strings.HealthEditorTargetPing;
|
||||||
|
TargetHint.Text = Strings.HealthEditorTargetPingHint;
|
||||||
|
TargetBox.Visibility = Visibility.Visible;
|
||||||
|
VrDeviceCombo.Visibility = Visibility.Collapsed;
|
||||||
|
PingAdvanced.Visibility = Visibility.Visible;
|
||||||
|
break;
|
||||||
|
case "vrdevice":
|
||||||
|
TargetLabel.Text = Strings.HealthEditorTargetVrDevice;
|
||||||
|
TargetHint.Text = Strings.HealthEditorTargetVrDeviceHint;
|
||||||
|
TargetBox.Visibility = Visibility.Collapsed;
|
||||||
|
VrDeviceCombo.Visibility = Visibility.Visible;
|
||||||
|
PingAdvanced.Visibility = Visibility.Collapsed;
|
||||||
|
break;
|
||||||
|
default: // process
|
||||||
|
TargetLabel.Text = Strings.HealthEditorTargetProcess;
|
||||||
|
TargetHint.Text = Strings.HealthEditorTargetProcessHint;
|
||||||
|
TargetBox.Visibility = Visibility.Visible;
|
||||||
|
VrDeviceCombo.Visibility = Visibility.Collapsed;
|
||||||
|
PingAdvanced.Visibility = Visibility.Collapsed;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnKindChanged(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (TargetLabel is null) return; // pendant l'init avant que tout soit chargé
|
||||||
|
var kind = KindPing.IsChecked == true ? "ping"
|
||||||
|
: KindVrDevice.IsChecked == true ? "vrdevice"
|
||||||
|
: "process";
|
||||||
|
UpdateKindUi(kind);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnIconClicked(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (sender is not Button btn || btn.Tag is not string emoji) return;
|
||||||
|
foreach (var ic in _icons) ic.IsSelected = ic.Emoji == emoji;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnSave(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
var name = NameBox.Text?.Trim() ?? "";
|
||||||
|
if (string.IsNullOrEmpty(name))
|
||||||
|
{
|
||||||
|
ShowError(Strings.HealthEditorErrorName);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!int.TryParse(RefreshMsBox.Text?.Trim(), out var refreshMs) || refreshMs < 200)
|
||||||
|
{
|
||||||
|
ShowError(Strings.HealthEditorErrorRefresh);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var selectedIcon = _icons.FirstOrDefault(i => i.IsSelected)?.Emoji ?? Entry.Icon;
|
||||||
|
|
||||||
|
Entry.Name = name;
|
||||||
|
Entry.Icon = string.IsNullOrEmpty(selectedIcon) ? "🔌" : selectedIcon;
|
||||||
|
Entry.Kind = KindPing.IsChecked == true ? "Ping"
|
||||||
|
: KindVrDevice.IsChecked == true ? "VrDevice"
|
||||||
|
: "Process";
|
||||||
|
|
||||||
|
// Target dépend du Kind : pour VrDevice, on prend la valeur de la combo
|
||||||
|
// (alias ASCII stable comme "hmd" / "left_controller" — ne pas localiser).
|
||||||
|
Entry.Target = Entry.Kind == "VrDevice"
|
||||||
|
? (VrDeviceCombo.SelectedValue as string ?? "hmd")
|
||||||
|
: (TargetBox.Text?.Trim() ?? "");
|
||||||
|
|
||||||
|
Entry.RefreshIntervalMs = refreshMs;
|
||||||
|
|
||||||
|
if (Entry.Kind == "Ping")
|
||||||
|
{
|
||||||
|
Entry.PingWarnMs = TryParseNullableInt(PingWarnBox.Text);
|
||||||
|
Entry.PingErrorMs = TryParseNullableInt(PingErrorBox.Text);
|
||||||
|
Entry.PingTimeoutMs = TryParseNullableInt(PingTimeoutBox.Text);
|
||||||
|
}
|
||||||
|
|
||||||
|
Saved = true;
|
||||||
|
DialogResult = true;
|
||||||
|
Close();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnCancel(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
DialogResult = false;
|
||||||
|
Close();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ShowError(string msg)
|
||||||
|
{
|
||||||
|
ErrorText.Text = msg;
|
||||||
|
ErrorText.Visibility = Visibility.Visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int? TryParseNullableInt(string? s)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(s)) return null;
|
||||||
|
return int.TryParse(s.Trim(), out var v) ? v : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Élément du picker d'icônes — emoji + état sélectionné pour le data trigger.</summary>
|
||||||
|
public sealed partial class IconOption : ObservableObject
|
||||||
|
{
|
||||||
|
public string Emoji { get; }
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private bool _isSelected;
|
||||||
|
|
||||||
|
public IconOption(string emoji) { Emoji = emoji; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Item du ComboBox VrDevice — couple alias technique + libellé affiché.</summary>
|
||||||
|
public sealed record VrAliasOption(string Alias, string Label);
|
||||||
|
}
|
||||||
@@ -192,22 +192,21 @@
|
|||||||
La sidebar a RowSpan=2 → s'étend du sous-topbar jusqu'au bas
|
La sidebar a RowSpan=2 → s'étend du sous-topbar jusqu'au bas
|
||||||
de fenêtre. Le footer est confiné à la colonne content.
|
de fenêtre. Le footer est confiné à la colonne content.
|
||||||
-->
|
-->
|
||||||
<Grid Background="Black">
|
<Grid Background="{StaticResource Brush.Bg.Window}">
|
||||||
<Grid.ColumnDefinitions>
|
<Grid.ColumnDefinitions>
|
||||||
<ColumnDefinition Width="200" />
|
<ColumnDefinition Width="200" />
|
||||||
<ColumnDefinition Width="*" />
|
<ColumnDefinition Width="*" />
|
||||||
</Grid.ColumnDefinitions>
|
</Grid.ColumnDefinitions>
|
||||||
<Grid.RowDefinitions>
|
<Grid.RowDefinitions>
|
||||||
<RowDefinition Height="Auto" />
|
<RowDefinition Height="Auto" /> <!-- Top bar -->
|
||||||
<RowDefinition Height="*" />
|
<RowDefinition Height="Auto" /> <!-- Health banner -->
|
||||||
<RowDefinition Height="Auto" />
|
<RowDefinition Height="*" /> <!-- Body (sidebar + content) -->
|
||||||
|
<RowDefinition Height="Auto" /> <!-- Footer -->
|
||||||
</Grid.RowDefinitions>
|
</Grid.RowDefinitions>
|
||||||
|
|
||||||
<!-- Background image : limité à la zone content (Row 1, Col 1) pour ne pas
|
<!-- Background image : limité à la zone content (Row 2, Col 1) pour ne pas
|
||||||
passer derrière la sidebar ni le footer. ImageBrush avec AlignmentX=Right
|
passer derrière la sidebar, le banner ou le footer. -->
|
||||||
+ AlignmentY=Bottom : si le ratio fenêtre force un crop, on rogne en
|
<Rectangle Grid.Row="2" Grid.Column="1" IsHitTestVisible="False">
|
||||||
haut/à gauche — le logo ASTERION VR en bas-droite reste TOUJOURS visible. -->
|
|
||||||
<Rectangle Grid.Row="1" Grid.Column="1" IsHitTestVisible="False">
|
|
||||||
<Rectangle.Fill>
|
<Rectangle.Fill>
|
||||||
<ImageBrush ImageSource="pack://application:,,,/Resources/Background.png"
|
<ImageBrush ImageSource="pack://application:,,,/Resources/Background.png"
|
||||||
Stretch="UniformToFill"
|
Stretch="UniformToFill"
|
||||||
@@ -216,6 +215,9 @@
|
|||||||
Opacity="0.25" />
|
Opacity="0.25" />
|
||||||
</Rectangle.Fill>
|
</Rectangle.Fill>
|
||||||
</Rectangle>
|
</Rectangle>
|
||||||
|
<Rectangle Grid.Row="2" Grid.Column="1"
|
||||||
|
Fill="{StaticResource Brush.Bg.BlueTint}"
|
||||||
|
IsHitTestVisible="False" />
|
||||||
|
|
||||||
<!-- Top bar : 3 colonnes (brand / license center / chrome).
|
<!-- Top bar : 3 colonnes (brand / license center / chrome).
|
||||||
Grid.ColumnSpan=2 → s'étend sur sidebar + content. -->
|
Grid.ColumnSpan=2 → s'étend sur sidebar + content. -->
|
||||||
@@ -339,10 +341,56 @@
|
|||||||
</Grid>
|
</Grid>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
<!-- ============== Content area (Row 1, Col 1, swap par CurrentPage) ==============
|
<!-- ============== Health banner (Row 1, Col 1 uniquement) ==============
|
||||||
|
Pills colorées indiquant l'état des dépendances système (SteamVR,
|
||||||
|
Vive Streaming, ping casque…). Le banner ne couvre QUE la colonne
|
||||||
|
content — la sidebar à gauche s'étend du sous-topbar jusqu'en bas
|
||||||
|
en RowSpan=3 (banner + body + footer) pour rester visuellement le
|
||||||
|
chapeau de tout. Visible uniquement sur la page Library. -->
|
||||||
|
<Border Grid.Row="1" Grid.Column="1"
|
||||||
|
Background="{StaticResource Brush.Bg.Sidebar}"
|
||||||
|
BorderBrush="{StaticResource Brush.Border}"
|
||||||
|
BorderThickness="0,0,0,1"
|
||||||
|
Padding="16,8"
|
||||||
|
Visibility="{Binding ShowHealthBanner, Converter={StaticResource BoolToVisibility}}">
|
||||||
|
<ItemsControl ItemsSource="{Binding HealthIndicators}">
|
||||||
|
<ItemsControl.ItemsPanel>
|
||||||
|
<ItemsPanelTemplate>
|
||||||
|
<StackPanel Orientation="Horizontal" />
|
||||||
|
</ItemsPanelTemplate>
|
||||||
|
</ItemsControl.ItemsPanel>
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate>
|
||||||
|
<Border Background="{Binding StatusBrush}"
|
||||||
|
BorderBrush="{Binding BorderBrush}"
|
||||||
|
BorderThickness="1"
|
||||||
|
CornerRadius="12"
|
||||||
|
Padding="10,4"
|
||||||
|
Margin="0,0,8,0"
|
||||||
|
ToolTip="{Binding Tooltip}"
|
||||||
|
ToolTipService.InitialShowDelay="200"
|
||||||
|
ToolTipService.ShowDuration="20000">
|
||||||
|
<StackPanel Orientation="Horizontal">
|
||||||
|
<TextBlock Text="{Binding Icon}"
|
||||||
|
FontSize="14"
|
||||||
|
Foreground="{Binding IconForeground}"
|
||||||
|
VerticalAlignment="Center" />
|
||||||
|
<TextBlock Text="{Binding Name}"
|
||||||
|
Margin="6,0,0,0"
|
||||||
|
FontSize="12"
|
||||||
|
Foreground="{StaticResource Brush.Text.Primary}"
|
||||||
|
VerticalAlignment="Center" />
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<!-- ============== Content area (Row 2, Col 1, swap par CurrentPage) ==============
|
||||||
Superpose plusieurs blocs (Library / Report / Documentation) gérés par
|
Superpose plusieurs blocs (Library / Report / Documentation) gérés par
|
||||||
leur Visibility liée à CurrentPage. -->
|
leur Visibility liée à CurrentPage. -->
|
||||||
<Grid Grid.Row="1" Grid.Column="1">
|
<Grid Grid.Row="2" Grid.Column="1">
|
||||||
|
|
||||||
<!-- ====================== LIBRARY PAGE ====================== -->
|
<!-- ====================== LIBRARY PAGE ====================== -->
|
||||||
<Grid Visibility="{Binding IsLibrary, Converter={StaticResource BoolToVisibility}}">
|
<Grid Visibility="{Binding IsLibrary, Converter={StaticResource BoolToVisibility}}">
|
||||||
@@ -605,10 +653,11 @@
|
|||||||
<!-- ============== END Content area ============== -->
|
<!-- ============== END Content area ============== -->
|
||||||
|
|
||||||
<!-- ============== Left sidebar : nav buttons ==============
|
<!-- ============== Left sidebar : nav buttons ==============
|
||||||
Row 1 + Row 2 (RowSpan=2) → la sidebar s'étend depuis le bas du
|
Row 1 + RowSpan=3 → la sidebar s'étend du bas du top bar jusqu'au
|
||||||
top bar jusqu'en bas de la fenêtre, peu importe la visibilité du
|
bas de la fenêtre, couvrant les 3 rows (health banner, content,
|
||||||
footer ou de la taille de la zone content. -->
|
footer). Visuellement la sidebar reste le « chapeau de tout »
|
||||||
<Border Grid.Row="1" Grid.RowSpan="2" Grid.Column="0"
|
côté gauche, peu importe ce qui se passe à droite. -->
|
||||||
|
<Border Grid.Row="1" Grid.RowSpan="3" Grid.Column="0"
|
||||||
Background="#0A0A0E"
|
Background="#0A0A0E"
|
||||||
BorderBrush="{StaticResource Brush.Border}"
|
BorderBrush="{StaticResource Brush.Border}"
|
||||||
BorderThickness="0,0,1,0">
|
BorderThickness="0,0,1,0">
|
||||||
@@ -641,9 +690,10 @@
|
|||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
<!-- Footer : Row 2, Col 1 → cantonné à la colonne content, sous la zone
|
<!-- Footer : Row 3, Col 1 → cantonné à la colonne content, sous la zone
|
||||||
Library/Report/Doc. La sidebar (RowSpan=2) le contourne par sa colonne. -->
|
Library/Report/Doc. La sidebar (RowSpan=2 sur rows 2-3) le contourne
|
||||||
<Border Grid.Row="2" Grid.Column="1"
|
par sa colonne. -->
|
||||||
|
<Border Grid.Row="3" Grid.Column="1"
|
||||||
Background="{StaticResource Brush.Bg.Footer}"
|
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,10"
|
Padding="20,10"
|
||||||
|
|||||||
@@ -230,6 +230,19 @@
|
|||||||
Background="#1A1A20" Foreground="{StaticResource Brush.Text.Primary}"
|
Background="#1A1A20" Foreground="{StaticResource Brush.Text.Primary}"
|
||||||
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1" Padding="8"
|
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1" Padding="8"
|
||||||
FontFamily="Consolas" />
|
FontFamily="Consolas" />
|
||||||
|
|
||||||
|
<!-- Connexions parallèles : tuning de la queue de fin de DL -->
|
||||||
|
<TextBlock Text="{x:Static loc:Strings.SettingsParallelSegments}"
|
||||||
|
Foreground="{StaticResource Brush.Text.Secondary}"
|
||||||
|
FontSize="12" Margin="0,16,0,4" />
|
||||||
|
<TextBox Text="{Binding ParallelSegments, UpdateSourceTrigger=PropertyChanged}"
|
||||||
|
Background="#1A1A20" Foreground="{StaticResource Brush.Text.Primary}"
|
||||||
|
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1" Padding="8"
|
||||||
|
Width="80" HorizontalAlignment="Left" />
|
||||||
|
<TextBlock Text="{x:Static loc:Strings.SettingsParallelSegmentsHint}"
|
||||||
|
Foreground="{StaticResource Brush.Text.Secondary}"
|
||||||
|
FontSize="11" FontStyle="Italic" Margin="0,4,0,0"
|
||||||
|
TextWrapping="Wrap" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
@@ -525,6 +538,197 @@
|
|||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
|
<!-- Outil Doc (XAMPP htdocs deploy, mirror of Report) -->
|
||||||
|
<Border Background="{StaticResource Brush.Bg.Card}"
|
||||||
|
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1"
|
||||||
|
CornerRadius="6" Padding="20" Margin="0,0,0,16">
|
||||||
|
<StackPanel>
|
||||||
|
<TextBlock Text="{x:Static loc:Strings.SettingsDocTool}"
|
||||||
|
FontSize="11" FontWeight="Bold"
|
||||||
|
Foreground="{StaticResource Brush.Text.Secondary}"
|
||||||
|
Margin="0,0,0,8" />
|
||||||
|
|
||||||
|
<TextBlock Text="{x:Static loc:Strings.SettingsReportHtdocs}"
|
||||||
|
Foreground="{StaticResource Brush.Text.Secondary}"
|
||||||
|
FontSize="12" Margin="0,0,0,4" />
|
||||||
|
<TextBox Text="{Binding DocHtdocsRoot, UpdateSourceTrigger=PropertyChanged}"
|
||||||
|
Background="#1A1A20" Foreground="{StaticResource Brush.Text.Primary}"
|
||||||
|
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1" Padding="8"
|
||||||
|
FontFamily="Consolas" />
|
||||||
|
|
||||||
|
<TextBlock Text="{x:Static loc:Strings.SettingsReportFolder}"
|
||||||
|
Foreground="{StaticResource Brush.Text.Secondary}"
|
||||||
|
FontSize="12" Margin="0,12,0,4" />
|
||||||
|
<TextBox Text="{Binding DocFolderName, UpdateSourceTrigger=PropertyChanged}"
|
||||||
|
Background="#1A1A20" Foreground="{StaticResource Brush.Text.Primary}"
|
||||||
|
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1" Padding="8"
|
||||||
|
FontFamily="Consolas" />
|
||||||
|
|
||||||
|
<CheckBox IsChecked="{Binding DocAutoDeploy}"
|
||||||
|
Margin="0,12,0,0"
|
||||||
|
Foreground="{StaticResource Brush.Text.Primary}"
|
||||||
|
Content="{x:Static loc:Strings.SettingsDocAutoDeploy}" />
|
||||||
|
|
||||||
|
<Grid Margin="0,12,0,0">
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="*" />
|
||||||
|
<ColumnDefinition Width="80" />
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<TextBlock Grid.Column="0"
|
||||||
|
Text="{x:Static loc:Strings.SettingsDocMaxBackups}"
|
||||||
|
Foreground="{StaticResource Brush.Text.Secondary}"
|
||||||
|
FontSize="12" VerticalAlignment="Center" />
|
||||||
|
<TextBox Grid.Column="1"
|
||||||
|
Text="{Binding DocMaxBackups, UpdateSourceTrigger=PropertyChanged}"
|
||||||
|
Background="#1A1A20" Foreground="{StaticResource Brush.Text.Primary}"
|
||||||
|
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1" Padding="8" />
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid Margin="0,12,0,0">
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="*" />
|
||||||
|
<ColumnDefinition Width="Auto" />
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<TextBlock Grid.Column="0"
|
||||||
|
Text="{Binding DocDeployStatus}"
|
||||||
|
Foreground="{StaticResource Brush.Text.Secondary}"
|
||||||
|
FontSize="12" VerticalAlignment="Center"
|
||||||
|
TextWrapping="Wrap" />
|
||||||
|
<Button Grid.Column="1"
|
||||||
|
Style="{StaticResource SecondaryButton}"
|
||||||
|
Content="{x:Static loc:Strings.SettingsDocRedeploy}"
|
||||||
|
Command="{Binding RedeployDocCommand}"
|
||||||
|
IsEnabled="{Binding IsDeployingDoc, Converter={StaticResource InverseBoolToBool}}" />
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<TextBlock Text="{x:Static loc:Strings.SettingsDocBackups}"
|
||||||
|
FontWeight="Bold" FontSize="11"
|
||||||
|
Foreground="{StaticResource Brush.Text.Secondary}"
|
||||||
|
Margin="0,16,0,6" />
|
||||||
|
<TextBlock Text="{x:Static loc:Strings.SettingsReportNoBackups}"
|
||||||
|
Foreground="{StaticResource Brush.Text.Secondary}"
|
||||||
|
FontSize="12" FontStyle="Italic"
|
||||||
|
Visibility="{Binding HasDocBackups, Converter={StaticResource InverseBoolToVisibility}}" />
|
||||||
|
<ItemsControl ItemsSource="{Binding DocBackups}"
|
||||||
|
Visibility="{Binding HasDocBackups, Converter={StaticResource BoolToVisibility}}">
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate>
|
||||||
|
<Border Background="#1A1A20"
|
||||||
|
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1"
|
||||||
|
CornerRadius="4" Padding="10,8" Margin="0,0,0,4">
|
||||||
|
<Grid>
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="*" />
|
||||||
|
<ColumnDefinition Width="Auto" />
|
||||||
|
<ColumnDefinition Width="Auto" />
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<StackPanel Grid.Column="0" VerticalAlignment="Center">
|
||||||
|
<TextBlock Text="{Binding DateDisplay}"
|
||||||
|
Foreground="{StaticResource Brush.Text.Primary}"
|
||||||
|
FontSize="13" FontWeight="SemiBold" />
|
||||||
|
<TextBlock Text="{Binding SizeDisplay}"
|
||||||
|
Foreground="{StaticResource Brush.Text.Secondary}"
|
||||||
|
FontSize="11" />
|
||||||
|
</StackPanel>
|
||||||
|
<TextBlock Grid.Column="1"
|
||||||
|
Text="{Binding FolderName}"
|
||||||
|
FontFamily="Consolas" FontSize="10"
|
||||||
|
Foreground="{StaticResource Brush.Text.Secondary}"
|
||||||
|
VerticalAlignment="Center" Margin="0,0,12,0" />
|
||||||
|
<Button Grid.Column="2"
|
||||||
|
Style="{StaticResource SecondaryButton}"
|
||||||
|
Content="{x:Static loc:Strings.SettingsReportRevert}"
|
||||||
|
Command="{Binding RevertCommand}"
|
||||||
|
Padding="12,6" />
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<!-- Bandeau de santé système : liste éditable des checks -->
|
||||||
|
<Border Background="{StaticResource Brush.Bg.Card}"
|
||||||
|
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1"
|
||||||
|
CornerRadius="6" Padding="20" Margin="0,0,0,16">
|
||||||
|
<StackPanel>
|
||||||
|
<Grid>
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="*" />
|
||||||
|
<ColumnDefinition Width="Auto" />
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<TextBlock Grid.Column="0"
|
||||||
|
Text="{x:Static loc:Strings.SettingsHealthChecks}"
|
||||||
|
FontSize="11" FontWeight="Bold"
|
||||||
|
Foreground="{StaticResource Brush.Text.Secondary}"
|
||||||
|
VerticalAlignment="Center" />
|
||||||
|
<Button Grid.Column="1"
|
||||||
|
Style="{StaticResource SecondaryButton}"
|
||||||
|
Content="{x:Static loc:Strings.SettingsHealthAdd}"
|
||||||
|
Command="{Binding AddHealthCheckCommand}"
|
||||||
|
Padding="12,6" />
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<TextBlock Text="{x:Static loc:Strings.SettingsHealthChecksHint}"
|
||||||
|
FontSize="11" FontStyle="Italic"
|
||||||
|
Foreground="{StaticResource Brush.Text.Secondary}"
|
||||||
|
Margin="0,8,0,8" TextWrapping="Wrap" />
|
||||||
|
|
||||||
|
<TextBlock Text="{x:Static loc:Strings.SettingsHealthEmpty}"
|
||||||
|
FontSize="12" FontStyle="Italic"
|
||||||
|
Foreground="{StaticResource Brush.Text.Secondary}"
|
||||||
|
Margin="0,4,0,0" TextWrapping="Wrap"
|
||||||
|
Visibility="{Binding HasHealthChecks, Converter={StaticResource InverseBoolToVisibility}}" />
|
||||||
|
|
||||||
|
<ItemsControl ItemsSource="{Binding HealthChecks}"
|
||||||
|
Visibility="{Binding HasHealthChecks, Converter={StaticResource BoolToVisibility}}"
|
||||||
|
Margin="0,4,0,0">
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate>
|
||||||
|
<Border Background="#1A1A20"
|
||||||
|
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1"
|
||||||
|
CornerRadius="4" Padding="10,8" Margin="0,0,0,4">
|
||||||
|
<Grid>
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="Auto" />
|
||||||
|
<ColumnDefinition Width="*" />
|
||||||
|
<ColumnDefinition Width="Auto" />
|
||||||
|
<ColumnDefinition Width="Auto" />
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<TextBlock Grid.Column="0"
|
||||||
|
Text="{Binding DisplayIcon}"
|
||||||
|
FontSize="20"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
Margin="2,0,12,0" />
|
||||||
|
<StackPanel Grid.Column="1" VerticalAlignment="Center">
|
||||||
|
<TextBlock Text="{Binding DisplayName}"
|
||||||
|
Foreground="{StaticResource Brush.Text.Primary}"
|
||||||
|
FontSize="13" FontWeight="SemiBold" />
|
||||||
|
<TextBlock Text="{Binding DisplayDetail}"
|
||||||
|
Foreground="{StaticResource Brush.Text.Secondary}"
|
||||||
|
FontSize="11"
|
||||||
|
FontFamily="Consolas" />
|
||||||
|
</StackPanel>
|
||||||
|
<Button Grid.Column="2"
|
||||||
|
Style="{StaticResource SecondaryButton}"
|
||||||
|
Content="{x:Static loc:Strings.SettingsHealthEdit}"
|
||||||
|
Command="{Binding EditCommand}"
|
||||||
|
Margin="0,0,6,0"
|
||||||
|
Padding="12,6" />
|
||||||
|
<Button Grid.Column="3"
|
||||||
|
Style="{StaticResource SecondaryButton}"
|
||||||
|
Content="{x:Static loc:Strings.SettingsHealthDelete}"
|
||||||
|
Command="{Binding DeleteCommand}"
|
||||||
|
Padding="12,6" />
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
<!-- Logs (path + open button) -->
|
<!-- Logs (path + open button) -->
|
||||||
<Border Background="{StaticResource Brush.Bg.Card}"
|
<Border Background="{StaticResource Brush.Bg.Card}"
|
||||||
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1"
|
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1"
|
||||||
|
|||||||
211
src/PSLauncher.Core/DocTool/DocToolDeployer.cs
Normal file
211
src/PSLauncher.Core/DocTool/DocToolDeployer.cs
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using PSLauncher.Core.ReportTool;
|
||||||
|
using PSLauncher.Models;
|
||||||
|
|
||||||
|
namespace PSLauncher.Core.DocTool;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Implémentation : copy récursif + atomic rename + backups horodatés.
|
||||||
|
/// Strictement parallèle à <see cref="ReportToolDeployer"/> mais avec
|
||||||
|
/// <c>_doc/</c> comme source au lieu de <c>_report/</c>. Voir le commentaire
|
||||||
|
/// de cette dernière classe pour les détails du mécanisme.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class DocToolDeployer : IDocToolDeployer
|
||||||
|
{
|
||||||
|
private const string SourceSubdir = "_doc";
|
||||||
|
private const string BackupSuffix = ".backup-";
|
||||||
|
private const string TimestampFormat = "yyyyMMdd-HHmmss";
|
||||||
|
|
||||||
|
private readonly Func<DocToolConfig> _configProvider;
|
||||||
|
private readonly ILogger<DocToolDeployer> _logger;
|
||||||
|
|
||||||
|
public DocToolDeployer(
|
||||||
|
Func<DocToolConfig> configProvider,
|
||||||
|
ILogger<DocToolDeployer> logger)
|
||||||
|
{
|
||||||
|
_configProvider = configProvider;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<DeployResult> DeployAsync(
|
||||||
|
string installFolder,
|
||||||
|
IProgress<DeployProgress>? progress,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
var cfg = _configProvider();
|
||||||
|
var source = Path.Combine(installFolder, SourceSubdir);
|
||||||
|
var target = Path.Combine(cfg.HtdocsRoot, cfg.FolderName);
|
||||||
|
|
||||||
|
if (!Directory.Exists(source))
|
||||||
|
{
|
||||||
|
_logger.LogInformation("No {Sub}/ directory in {Folder}, skipping doc deploy", SourceSubdir, installFolder);
|
||||||
|
return new DeployResult(DeployStatus.SkippedNoSourceFolder, 0, 0, target);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Directory.Exists(cfg.HtdocsRoot))
|
||||||
|
{
|
||||||
|
_logger.LogWarning("HtdocsRoot {Root} not found — XAMPP not installed at expected location", cfg.HtdocsRoot);
|
||||||
|
return new DeployResult(DeployStatus.XamppNotFound, 0, 0, target,
|
||||||
|
$"Le dossier {cfg.HtdocsRoot} est introuvable. XAMPP est-il installé à cet emplacement ? (Settings → Avancés)");
|
||||||
|
}
|
||||||
|
|
||||||
|
var stagingTarget = target + ".new";
|
||||||
|
var backupTarget = target + BackupSuffix + DateTime.UtcNow.ToString(TimestampFormat, CultureInfo.InvariantCulture);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (Directory.Exists(stagingTarget)) Directory.Delete(stagingTarget, recursive: true);
|
||||||
|
|
||||||
|
var allFiles = Directory.EnumerateFiles(source, "*", SearchOption.AllDirectories).ToList();
|
||||||
|
int total = allFiles.Count;
|
||||||
|
long bytesTotal = 0;
|
||||||
|
|
||||||
|
await Task.Run(() =>
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(stagingTarget);
|
||||||
|
int done = 0;
|
||||||
|
foreach (var srcFile in allFiles)
|
||||||
|
{
|
||||||
|
ct.ThrowIfCancellationRequested();
|
||||||
|
var rel = Path.GetRelativePath(source, srcFile);
|
||||||
|
var dstFile = Path.Combine(stagingTarget, rel);
|
||||||
|
var dstDir = Path.GetDirectoryName(dstFile);
|
||||||
|
if (!string.IsNullOrEmpty(dstDir) && !Directory.Exists(dstDir))
|
||||||
|
Directory.CreateDirectory(dstDir);
|
||||||
|
File.Copy(srcFile, dstFile, overwrite: true);
|
||||||
|
bytesTotal += new FileInfo(srcFile).Length;
|
||||||
|
done++;
|
||||||
|
progress?.Report(new DeployProgress(done, total, rel));
|
||||||
|
}
|
||||||
|
}, ct).ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (Directory.Exists(target))
|
||||||
|
Directory.Move(target, backupTarget);
|
||||||
|
Directory.Move(stagingTarget, target);
|
||||||
|
|
||||||
|
PruneOldBackups(cfg);
|
||||||
|
|
||||||
|
_logger.LogInformation("Doc tool deployed to {Target} ({Files} files, {Bytes} bytes), backup at {Backup}",
|
||||||
|
target, total, bytesTotal, backupTarget);
|
||||||
|
return new DeployResult(DeployStatus.Deployed, total, bytesTotal, target);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to deploy doc tool to {Target}", target);
|
||||||
|
try { if (Directory.Exists(stagingTarget)) Directory.Delete(stagingTarget, recursive: true); }
|
||||||
|
catch { /* ignore */ }
|
||||||
|
return new DeployResult(DeployStatus.Failed, 0, 0, target, ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<IReadOnlyList<BackupInfo>> ListBackupsAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
var cfg = _configProvider();
|
||||||
|
var prefix = cfg.FolderName + BackupSuffix;
|
||||||
|
|
||||||
|
if (!Directory.Exists(cfg.HtdocsRoot))
|
||||||
|
return Task.FromResult<IReadOnlyList<BackupInfo>>(Array.Empty<BackupInfo>());
|
||||||
|
|
||||||
|
var list = new List<BackupInfo>();
|
||||||
|
foreach (var dir in Directory.EnumerateDirectories(cfg.HtdocsRoot, prefix + "*", SearchOption.TopDirectoryOnly))
|
||||||
|
{
|
||||||
|
ct.ThrowIfCancellationRequested();
|
||||||
|
var name = Path.GetFileName(dir);
|
||||||
|
var tsPart = name.Substring(prefix.Length);
|
||||||
|
if (!DateTime.TryParseExact(tsPart, TimestampFormat, CultureInfo.InvariantCulture,
|
||||||
|
DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var ts))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
long size = 0;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
foreach (var f in Directory.EnumerateFiles(dir, "*", SearchOption.AllDirectories))
|
||||||
|
size += new FileInfo(f).Length;
|
||||||
|
}
|
||||||
|
catch { /* size best-effort */ }
|
||||||
|
list.Add(new BackupInfo(name, ts, size));
|
||||||
|
}
|
||||||
|
list.Sort((a, b) => b.TimestampUtc.CompareTo(a.TimestampUtc));
|
||||||
|
return Task.FromResult<IReadOnlyList<BackupInfo>>(list);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<DeployResult> RevertAsync(string backupFolderName, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var cfg = _configProvider();
|
||||||
|
var backupPath = Path.Combine(cfg.HtdocsRoot, backupFolderName);
|
||||||
|
var target = Path.Combine(cfg.HtdocsRoot, cfg.FolderName);
|
||||||
|
|
||||||
|
if (!Directory.Exists(backupPath))
|
||||||
|
{
|
||||||
|
return new DeployResult(DeployStatus.BackupNotFound, 0, 0, target,
|
||||||
|
$"Le backup {backupFolderName} n'existe plus.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var newBackupForCurrent = target + BackupSuffix + DateTime.UtcNow.ToString(TimestampFormat, CultureInfo.InvariantCulture);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await Task.Run(() =>
|
||||||
|
{
|
||||||
|
ct.ThrowIfCancellationRequested();
|
||||||
|
if (Directory.Exists(target))
|
||||||
|
Directory.Move(target, newBackupForCurrent);
|
||||||
|
Directory.Move(backupPath, target);
|
||||||
|
}, ct).ConfigureAwait(false);
|
||||||
|
|
||||||
|
int files = 0;
|
||||||
|
long bytes = 0;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
foreach (var f in Directory.EnumerateFiles(target, "*", SearchOption.AllDirectories))
|
||||||
|
{
|
||||||
|
files++;
|
||||||
|
bytes += new FileInfo(f).Length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch { /* best-effort */ }
|
||||||
|
|
||||||
|
PruneOldBackups(cfg);
|
||||||
|
_logger.LogInformation("Reverted doc to backup {Backup} ({Files} files), previous current saved as {New}",
|
||||||
|
backupFolderName, files, newBackupForCurrent);
|
||||||
|
return new DeployResult(DeployStatus.Deployed, files, bytes, target);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Doc revert to {Backup} failed", backupFolderName);
|
||||||
|
return new DeployResult(DeployStatus.Failed, 0, 0, target, ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void PruneOldBackups(DocToolConfig cfg)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var prefix = cfg.FolderName + BackupSuffix;
|
||||||
|
var dirs = Directory.EnumerateDirectories(cfg.HtdocsRoot, prefix + "*", SearchOption.TopDirectoryOnly)
|
||||||
|
.Select(d => new { Path = d, Name = Path.GetFileName(d) })
|
||||||
|
.OrderByDescending(d => d.Name, StringComparer.Ordinal)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
int keep = Math.Max(0, cfg.MaxBackups);
|
||||||
|
for (int i = keep; i < dirs.Count; i++)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Directory.Delete(dirs[i].Path, recursive: true);
|
||||||
|
_logger.LogDebug("Pruned old doc backup {Dir}", dirs[i].Name);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogDebug(ex, "Could not prune doc backup {Dir} (will retry next deploy)", dirs[i].Name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogDebug(ex, "Doc backup pruning skipped");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
25
src/PSLauncher.Core/DocTool/IDocToolDeployer.cs
Normal file
25
src/PSLauncher.Core/DocTool/IDocToolDeployer.cs
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
using PSLauncher.Core.ReportTool;
|
||||||
|
|
||||||
|
namespace PSLauncher.Core.DocTool;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Déploie l'outil Documentation (page web hébergée dans XAMPP) depuis le
|
||||||
|
/// sous-dossier <c>_doc/</c> bundled dans chaque ZIP PROSERVE vers le htdocs
|
||||||
|
/// local. Mêmes garanties que <see cref="IReportToolDeployer"/> : copy + atomic
|
||||||
|
/// rename + backups horodatés + revert manuel.
|
||||||
|
///
|
||||||
|
/// On réutilise les types <see cref="DeployResult"/>, <see cref="DeployStatus"/>,
|
||||||
|
/// <see cref="DeployProgress"/> et <see cref="BackupInfo"/> du namespace ReportTool
|
||||||
|
/// pour ne pas dupliquer la modélisation — le mécanisme est strictement le même.
|
||||||
|
/// </summary>
|
||||||
|
public interface IDocToolDeployer
|
||||||
|
{
|
||||||
|
Task<DeployResult> DeployAsync(
|
||||||
|
string installFolder,
|
||||||
|
IProgress<DeployProgress>? progress,
|
||||||
|
CancellationToken ct);
|
||||||
|
|
||||||
|
Task<IReadOnlyList<BackupInfo>> ListBackupsAsync(CancellationToken ct);
|
||||||
|
|
||||||
|
Task<DeployResult> RevertAsync(string backupFolderName, CancellationToken ct);
|
||||||
|
}
|
||||||
@@ -176,7 +176,10 @@ public sealed class DownloadManager : IDownloadManager
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 2. Choix single vs multi-segment
|
// 2. Choix single vs multi-segment
|
||||||
var requestedSegments = Math.Clamp(_config.ParallelDownloadSegments, 1, 16);
|
// Hard-cap à 32 : au-delà OVH mutualisé risque le rate-limit per-IP et le
|
||||||
|
// gain marginal devient négatif. 16 = défaut, 24-32 OK pour les serveurs
|
||||||
|
// qui le supportent. 1 = comportement single-segment legacy.
|
||||||
|
var requestedSegments = Math.Clamp(_config.ParallelDownloadSegments, 1, 32);
|
||||||
var useMultiSegment = requestedSegments > 1
|
var useMultiSegment = requestedSegments > 1
|
||||||
&& job.ExpectedSize >= MultiSegmentMinSize
|
&& job.ExpectedSize >= MultiSegmentMinSize
|
||||||
&& (existing is null || existing.Segments.Count > 0);
|
&& (existing is null || existing.Segments.Count > 0);
|
||||||
|
|||||||
32
src/PSLauncher.Core/Health/ISystemHealthService.cs
Normal file
32
src/PSLauncher.Core/Health/ISystemHealthService.cs
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
using PSLauncher.Models;
|
||||||
|
|
||||||
|
namespace PSLauncher.Core.Health;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Effectue les vérifications de santé système configurées dans
|
||||||
|
/// <see cref="HealthChecksConfig"/> : ping IP, processus tournant, etc.
|
||||||
|
/// </summary>
|
||||||
|
public interface ISystemHealthService
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
Task<HealthResult> RunCheckAsync(HealthCheckEntry entry, CancellationToken ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record HealthResult(
|
||||||
|
HealthSeverity Severity,
|
||||||
|
string Detail);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sévérité d'un check. <see cref="Unknown"/> = check pas configuré
|
||||||
|
/// (Target vide), affiché en gris.
|
||||||
|
/// </summary>
|
||||||
|
public enum HealthSeverity
|
||||||
|
{
|
||||||
|
Unknown,
|
||||||
|
Ok,
|
||||||
|
Warning,
|
||||||
|
Error,
|
||||||
|
}
|
||||||
49
src/PSLauncher.Core/Health/OpenVR/IOpenVrService.cs
Normal file
49
src/PSLauncher.Core/Health/OpenVR/IOpenVrService.cs
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
namespace PSLauncher.Core.Health.OpenVRInterop;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Lecture en arrière-plan (sans démarrer SteamVR ni prendre le compositor)
|
||||||
|
/// de l'état des devices SteamVR : HMD, controllers, trackers, base stations.
|
||||||
|
/// Implémentation : <see cref="OpenVrService"/>.
|
||||||
|
/// </summary>
|
||||||
|
public interface IOpenVrService
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Interroge un device par alias :
|
||||||
|
/// <c>hmd</c>, <c>left_controller</c>, <c>right_controller</c>,
|
||||||
|
/// <c>tracker_1..N</c>, <c>base_station_1..N</c>.
|
||||||
|
/// </summary>
|
||||||
|
VrDeviceQueryResult Query(string deviceAlias);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// État de disponibilité de la runtime OpenVR. <c>RuntimeMissing</c> = openvr_api.dll
|
||||||
|
/// introuvable (SteamVR pas installé). <c>HmdAbsent</c> = SteamVR installé mais
|
||||||
|
/// vrserver pas lancé ou aucun HMD branché. <c>InitFailed</c> = API trouvée mais
|
||||||
|
/// VR_InitInternal2 a échoué (cas rare : permission denied, version mismatch).
|
||||||
|
/// <c>Ready</c> = OK, on peut lire les properties.
|
||||||
|
/// </summary>
|
||||||
|
public enum VrRuntimeState
|
||||||
|
{
|
||||||
|
Unknown,
|
||||||
|
RuntimeMissing,
|
||||||
|
HmdAbsent,
|
||||||
|
InitFailed,
|
||||||
|
Ready,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Résultat d'une requête sur un device. <see cref="Found"/> est false quand
|
||||||
|
/// l'alias résolu ne correspond à aucun device connecté (ex. tracker_3 absent).
|
||||||
|
/// </summary>
|
||||||
|
public sealed record VrDeviceQueryResult(
|
||||||
|
VrRuntimeState RuntimeState,
|
||||||
|
bool Found,
|
||||||
|
string DeviceLabel, // ex. "HMD", "Manette gauche"
|
||||||
|
string? Serial, // serial number ou null
|
||||||
|
OpenVRNative.ETrackedDeviceClass DeviceClass,
|
||||||
|
bool? IsConnected,
|
||||||
|
bool? IsCharging,
|
||||||
|
bool? IsWireless,
|
||||||
|
float? BatteryPercentage, // 0.0–1.0, null si propriété non exposée par le device
|
||||||
|
OpenVRNative.EDeviceActivityLevel? ActivityLevel,
|
||||||
|
string? ErrorDetail);
|
||||||
243
src/PSLauncher.Core/Health/OpenVR/OpenVRNative.cs
Normal file
243
src/PSLauncher.Core/Health/OpenVR/OpenVRNative.cs
Normal file
@@ -0,0 +1,243 @@
|
|||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace PSLauncher.Core.Health.OpenVRInterop;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// P/Invoke minimal vers <c>openvr_api.dll</c> (SDK Valve OpenVR, BSD-3).
|
||||||
|
/// On NE bundle PAS la DLL : on la résout dynamiquement via la copie installée
|
||||||
|
/// par SteamVR (chemin canonique <c>%LocalAppData%\openvr\openvrpaths.vrpath</c>),
|
||||||
|
/// donc :
|
||||||
|
/// - SteamVR pas installé → la DLL n'est pas trouvée et init renvoie une erreur claire,
|
||||||
|
/// - SteamVR mis à jour → on profite automatiquement de la dernière openvr_api.dll
|
||||||
|
/// sans avoir à respinner un build du launcher.
|
||||||
|
///
|
||||||
|
/// Le SDK OpenVR expose une "flat C API" : quelques fonctions globales pour
|
||||||
|
/// init/shutdown, et une <c>GetGenericInterface("FnTable:IVRSystem_022")</c>
|
||||||
|
/// qui renvoie un pointeur sur une struct C de pointeurs de fonction (vtable).
|
||||||
|
/// Cette classe expose juste ce qu'on consomme dans <see cref="OpenVrService"/> :
|
||||||
|
/// présence de la runtime, présence d'un HMD, et la vtable IVRSystem.
|
||||||
|
/// </summary>
|
||||||
|
public static class OpenVRNative
|
||||||
|
{
|
||||||
|
private const string DllName = "openvr_api";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// EVRApplicationType passé à VR_InitInternal2. <c>Background = 4</c> : on
|
||||||
|
/// veut LIRE l'état de SteamVR sans déclencher de rendu, sans devenir une
|
||||||
|
/// scene application, sans monopoliser le compositor.
|
||||||
|
/// </summary>
|
||||||
|
public const int VRApplicationBackground = 4;
|
||||||
|
|
||||||
|
/// <summary>Indices de slots dans la vtable IVRSystem_022. Stables depuis SteamVR 1.10 (2019).</summary>
|
||||||
|
public static class IVRSystemSlot
|
||||||
|
{
|
||||||
|
public const int GetTrackedDeviceActivityLevel = 16;
|
||||||
|
public const int GetTrackedDeviceIndexForControllerRole = 18;
|
||||||
|
public const int GetTrackedDeviceClass = 20;
|
||||||
|
public const int IsTrackedDeviceConnected = 21;
|
||||||
|
public const int GetBoolTrackedDeviceProperty = 22;
|
||||||
|
public const int GetFloatTrackedDeviceProperty = 23;
|
||||||
|
public const int GetStringTrackedDeviceProperty = 28;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Indices ETrackedDeviceProperty les plus utiles pour notre usage. Liste
|
||||||
|
/// complète dans <c>openvr.h</c> ; on en garde un sous-ensemble pour ne pas
|
||||||
|
/// dupliquer 800 enums dont 99% inutiles.
|
||||||
|
/// </summary>
|
||||||
|
public static class DeviceProperty
|
||||||
|
{
|
||||||
|
public const int Prop_DeviceIsWireless_Bool = 1003;
|
||||||
|
public const int Prop_DeviceIsCharging_Bool = 1004;
|
||||||
|
public const int Prop_DeviceProvidesBatteryStatus_Bool = 1026; // varie par version, fallback safe sur GetBool
|
||||||
|
public const int Prop_DeviceBatteryPercentage_Float = 1029;
|
||||||
|
public const int Prop_SerialNumber_String = 1002;
|
||||||
|
public const int Prop_TrackingSystemName_String = 1000;
|
||||||
|
public const int Prop_ModelNumber_String = 1001;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Classe OpenVR d'un device tracké. 1=HMD, 2=Controller, 3=GenericTracker, 4=TrackingReference.</summary>
|
||||||
|
public enum ETrackedDeviceClass
|
||||||
|
{
|
||||||
|
Invalid = 0,
|
||||||
|
HMD = 1,
|
||||||
|
Controller = 2,
|
||||||
|
GenericTracker = 3,
|
||||||
|
TrackingReference = 4,
|
||||||
|
DisplayRedirect = 5,
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum ETrackedControllerRole
|
||||||
|
{
|
||||||
|
Invalid = 0,
|
||||||
|
LeftHand = 1,
|
||||||
|
RightHand = 2,
|
||||||
|
OptOut = 3,
|
||||||
|
Treadmill = 4,
|
||||||
|
Stylus = 5,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Niveau d'activité du device. Idle = en veille, n'envoie plus de pose.
|
||||||
|
/// Standby = posé mais réveille rapidement. UserInteraction = en main.
|
||||||
|
/// </summary>
|
||||||
|
public enum EDeviceActivityLevel
|
||||||
|
{
|
||||||
|
Unknown = -1,
|
||||||
|
Idle = 0,
|
||||||
|
UserInteraction = 1,
|
||||||
|
UserInteraction_Timeout = 2,
|
||||||
|
Standby = 3,
|
||||||
|
Idle_Timeout = 4,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ATTENTION : OpenVR renvoie des bool C++ (1 octet). Sans MarshalAs(I1), le
|
||||||
|
// marshaller .NET lit 4 octets (BOOL Win32) et obtient des valeurs random ;
|
||||||
|
// c'est ce qui faisait passer VR_IsHmdPresent à "true" même casque débranché,
|
||||||
|
// déclenchant ensuite une init qui plante quand SteamVR n'est pas lancé.
|
||||||
|
[DllImport(DllName, EntryPoint = "VR_IsRuntimeInstalled", CallingConvention = CallingConvention.Cdecl)]
|
||||||
|
[return: MarshalAs(UnmanagedType.I1)]
|
||||||
|
public static extern bool VR_IsRuntimeInstalled();
|
||||||
|
|
||||||
|
[DllImport(DllName, EntryPoint = "VR_IsHmdPresent", CallingConvention = CallingConvention.Cdecl)]
|
||||||
|
[return: MarshalAs(UnmanagedType.I1)]
|
||||||
|
public static extern bool VR_IsHmdPresent();
|
||||||
|
|
||||||
|
[DllImport(DllName, EntryPoint = "VR_InitInternal2", CallingConvention = CallingConvention.Cdecl, CharSet = CharSet.Ansi)]
|
||||||
|
public static extern uint VR_InitInternal2(out int peError, int eApplicationType, [MarshalAs(UnmanagedType.LPStr)] string? pStartupInfo);
|
||||||
|
|
||||||
|
[DllImport(DllName, EntryPoint = "VR_ShutdownInternal", CallingConvention = CallingConvention.Cdecl)]
|
||||||
|
public static extern void VR_ShutdownInternal();
|
||||||
|
|
||||||
|
[DllImport(DllName, EntryPoint = "VR_GetGenericInterface", CallingConvention = CallingConvention.Cdecl, CharSet = CharSet.Ansi)]
|
||||||
|
public static extern IntPtr VR_GetGenericInterface([MarshalAs(UnmanagedType.LPStr)] string pchInterfaceVersion, out int peError);
|
||||||
|
|
||||||
|
[DllImport(DllName, EntryPoint = "VR_GetVRInitErrorAsEnglishDescription", CallingConvention = CallingConvention.Cdecl, CharSet = CharSet.Ansi)]
|
||||||
|
public static extern IntPtr VR_GetVRInitErrorAsEnglishDescription(int error);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Récupère un pointeur de fonction depuis la vtable IVRSystem (struct
|
||||||
|
/// contiguë de IntPtr à <paramref name="vtablePtr"/>) au slot demandé.
|
||||||
|
/// </summary>
|
||||||
|
public static IntPtr GetVTableSlot(IntPtr vtablePtr, int slotIndex)
|
||||||
|
{
|
||||||
|
unsafe
|
||||||
|
{
|
||||||
|
return ((IntPtr*)vtablePtr)[slotIndex];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string GetInitErrorDescription(int error)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var ptr = VR_GetVRInitErrorAsEnglishDescription(error);
|
||||||
|
return ptr == IntPtr.Zero ? $"unknown ({error})" : Marshal.PtrToStringAnsi(ptr) ?? $"unknown ({error})";
|
||||||
|
}
|
||||||
|
catch { return $"error code {error}"; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============= DLL RESOLUTION =============
|
||||||
|
|
||||||
|
private static int _resolverInstalled;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Installe (idempotent) un <see cref="DllImportResolver"/> qui, lorsque
|
||||||
|
/// .NET demande "openvr_api", le résout vers la DLL de l'install SteamVR
|
||||||
|
/// active. Pas d'effet de bord si SteamVR est absent — la DllImport
|
||||||
|
/// échouera ensuite comme d'habitude et le service catch.
|
||||||
|
/// </summary>
|
||||||
|
public static void EnsureResolverInstalled()
|
||||||
|
{
|
||||||
|
if (Interlocked.Exchange(ref _resolverInstalled, 1) == 1) return;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
NativeLibrary.SetDllImportResolver(typeof(OpenVRNative).Assembly, ResolveOpenVrDll);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Déjà installé ailleurs, ou plateforme qui ne supporte pas. On laisse
|
||||||
|
// le DllImport faire son travail (cherchera dans le PATH).
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IntPtr ResolveOpenVrDll(string libraryName, System.Reflection.Assembly assembly, DllImportSearchPath? searchPath)
|
||||||
|
{
|
||||||
|
if (!string.Equals(libraryName, DllName, StringComparison.OrdinalIgnoreCase))
|
||||||
|
return IntPtr.Zero;
|
||||||
|
|
||||||
|
var path = LocateOpenVrDll();
|
||||||
|
if (path is null) return IntPtr.Zero;
|
||||||
|
|
||||||
|
try { return NativeLibrary.Load(path); }
|
||||||
|
catch { return IntPtr.Zero; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Cherche openvr_api.dll dans cet ordre :
|
||||||
|
/// 1. <c>%LocalAppData%\openvr\openvrpaths.vrpath</c> (canon écrit par SteamVR au setup).
|
||||||
|
/// 2. Dossier Steam standard : <c>C:\Program Files (x86)\Steam\steamapps\common\SteamVR\bin\win64\</c>.
|
||||||
|
/// </summary>
|
||||||
|
private static string? LocateOpenVrDll()
|
||||||
|
{
|
||||||
|
// 1. Lire openvrpaths.vrpath (JSON)
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
|
||||||
|
var pathsFile = Path.Combine(localAppData, "openvr", "openvrpaths.vrpath");
|
||||||
|
if (File.Exists(pathsFile))
|
||||||
|
{
|
||||||
|
var json = File.ReadAllText(pathsFile, Encoding.UTF8);
|
||||||
|
using var doc = JsonDocument.Parse(json);
|
||||||
|
if (doc.RootElement.TryGetProperty("runtime", out var runtimes) && runtimes.ValueKind == JsonValueKind.Array)
|
||||||
|
{
|
||||||
|
foreach (var elt in runtimes.EnumerateArray())
|
||||||
|
{
|
||||||
|
var dir = elt.GetString();
|
||||||
|
if (string.IsNullOrEmpty(dir)) continue;
|
||||||
|
var dll = Path.Combine(dir, "bin", "win64", "openvr_api.dll");
|
||||||
|
if (File.Exists(dll)) return dll;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch { /* fallback */ }
|
||||||
|
|
||||||
|
// 2. Chemin Steam standard
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var steamPath = @"C:\Program Files (x86)\Steam\steamapps\common\SteamVR\bin\win64\openvr_api.dll";
|
||||||
|
if (File.Exists(steamPath)) return steamPath;
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============= DELEGATES POUR LES SLOTS UTILISÉS =============
|
||||||
|
|
||||||
|
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
|
||||||
|
public delegate EDeviceActivityLevel GetTrackedDeviceActivityLevelFn(uint deviceIndex);
|
||||||
|
|
||||||
|
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
|
||||||
|
public delegate uint GetTrackedDeviceIndexForControllerRoleFn(ETrackedControllerRole role);
|
||||||
|
|
||||||
|
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
|
||||||
|
public delegate ETrackedDeviceClass GetTrackedDeviceClassFn(uint deviceIndex);
|
||||||
|
|
||||||
|
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
|
||||||
|
[return: MarshalAs(UnmanagedType.I1)]
|
||||||
|
public delegate bool IsTrackedDeviceConnectedFn(uint deviceIndex);
|
||||||
|
|
||||||
|
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
|
||||||
|
[return: MarshalAs(UnmanagedType.I1)]
|
||||||
|
public delegate bool GetBoolTrackedDevicePropertyFn(uint deviceIndex, int property, out int peError);
|
||||||
|
|
||||||
|
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
|
||||||
|
public delegate float GetFloatTrackedDevicePropertyFn(uint deviceIndex, int property, out int peError);
|
||||||
|
|
||||||
|
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
|
||||||
|
public delegate uint GetStringTrackedDevicePropertyFn(uint deviceIndex, int property, IntPtr buffer, uint bufferSize, out int peError);
|
||||||
|
}
|
||||||
495
src/PSLauncher.Core/Health/OpenVR/OpenVrService.cs
Normal file
495
src/PSLauncher.Core/Health/OpenVR/OpenVrService.cs
Normal file
@@ -0,0 +1,495 @@
|
|||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using System.Text;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
// Alias pour éviter la collision avec le namespace PSLauncher.Core.Process
|
||||||
|
using SysProcess = System.Diagnostics.Process;
|
||||||
|
|
||||||
|
namespace PSLauncher.Core.Health.OpenVRInterop;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Wrapper haut niveau autour de <see cref="OpenVRNative"/>. Singleton
|
||||||
|
/// thread-safe : init paresseuse au premier appel <see cref="Query"/>, état
|
||||||
|
/// mémorisé pour les appels suivants. La vtable IVRSystem reste vivante toute
|
||||||
|
/// la durée de vie du process — coût CPU négligeable au repos, init-shutdown
|
||||||
|
/// répété coûte cher (centaines de ms).
|
||||||
|
///
|
||||||
|
/// Robustesse : tout va dans des try/catch. SteamVR crashé / fermé pendant
|
||||||
|
/// la session → le service détecte au prochain Query (vtable call lève) et
|
||||||
|
/// repasse en <see cref="VrRuntimeState.HmdAbsent"/> proprement, prêt à
|
||||||
|
/// re-tenter au tick suivant.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class OpenVrService : IOpenVrService, IDisposable
|
||||||
|
{
|
||||||
|
private const string IVRSystemInterfaceVersion = "FnTable:IVRSystem_022";
|
||||||
|
|
||||||
|
private readonly ILogger<OpenVrService> _logger;
|
||||||
|
private readonly object _lock = new();
|
||||||
|
|
||||||
|
private bool _resolverEnsured;
|
||||||
|
private bool _initAttempted;
|
||||||
|
private VrRuntimeState _lastLoggedState = (VrRuntimeState)(-99); // sentinelle « jamais loggé »
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Timestamp UTC de la PREMIÈRE détection de vrserver.exe lancé après une
|
||||||
|
/// période où il était absent. Remis à null si vrserver disparaît. Sert à
|
||||||
|
/// imposer un cooldown avant d'oser init OpenVR : entre l'apparition de
|
||||||
|
/// vrserver dans la liste process et le moment où ses drivers + ses shared
|
||||||
|
/// memories sont prêts à servir des requêtes vtable, il y a typiquement 5-10 s
|
||||||
|
/// de race window pendant laquelle <see cref="OpenVRNative.GetVTableSlot"/>
|
||||||
|
/// + premier appel = AccessViolation (lecture mémoire pas encore mappée).
|
||||||
|
/// </summary>
|
||||||
|
private DateTime? _vrServerFirstSeenUtc;
|
||||||
|
private const double VrServerSettleSeconds = 8.0;
|
||||||
|
private uint _initToken;
|
||||||
|
private IntPtr _vrSystemVTable = IntPtr.Zero;
|
||||||
|
|
||||||
|
// Delegates marshalés une seule fois après init pour éviter le coût
|
||||||
|
// GetDelegateForFunctionPointer à chaque tick.
|
||||||
|
private OpenVRNative.GetTrackedDeviceActivityLevelFn? _fnActivity;
|
||||||
|
private OpenVRNative.GetTrackedDeviceIndexForControllerRoleFn? _fnRoleToIndex;
|
||||||
|
private OpenVRNative.GetTrackedDeviceClassFn? _fnDeviceClass;
|
||||||
|
private OpenVRNative.IsTrackedDeviceConnectedFn? _fnIsConnected;
|
||||||
|
private OpenVRNative.GetBoolTrackedDevicePropertyFn? _fnGetBool;
|
||||||
|
private OpenVRNative.GetFloatTrackedDevicePropertyFn? _fnGetFloat;
|
||||||
|
private OpenVRNative.GetStringTrackedDevicePropertyFn? _fnGetString;
|
||||||
|
|
||||||
|
public OpenVrService(ILogger<OpenVrService> logger)
|
||||||
|
{
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public VrDeviceQueryResult Query(string deviceAlias)
|
||||||
|
{
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
// ============================================================
|
||||||
|
// MODE SAFE : on N'UTILISE PAS la vtable IVRSystem.
|
||||||
|
//
|
||||||
|
// Pourquoi : les fonctions de la vtable (IsTrackedDeviceConnected,
|
||||||
|
// GetBoolTrackedDeviceProperty, etc.) font du SHM/IPC avec vrserver
|
||||||
|
// qui peut être dans un état partiellement initialisé pendant et
|
||||||
|
// après le démarrage de SteamVR. Ces fonctions accèdent à de la
|
||||||
|
// mémoire pas encore mappée → AccessViolation natif → SEH passe
|
||||||
|
// au travers du CLR via Marshal.GetDelegateForFunctionPointer
|
||||||
|
// → process killed sans qu'aucun catch ne se déclenche.
|
||||||
|
//
|
||||||
|
// Repousser le délai de settle ne suffit pas (race window peut
|
||||||
|
// se rouvrir à n'importe quel moment de la session : (re)connexion
|
||||||
|
// d'un device, rechargement d'un driver…). On utilise donc UNIQUEMENT
|
||||||
|
// l'API flat C : VR_IsRuntimeInstalled + VR_IsHmdPresent qui ne
|
||||||
|
// touchent pas au SHM. Limitation : pas de batterie, pas de per-device
|
||||||
|
// info — juste "HMD détecté oui/non". La batterie reviendra en
|
||||||
|
// Phase 1.5 via un subprocess séparé (PSLauncher.VrProbe.exe) qui
|
||||||
|
// peut crasher sans tuer le launcher.
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
var label = ResolveLabel(deviceAlias);
|
||||||
|
var state = EnsureReadySafe();
|
||||||
|
LogStateTransition(state);
|
||||||
|
|
||||||
|
string detail;
|
||||||
|
switch (state)
|
||||||
|
{
|
||||||
|
case VrRuntimeState.RuntimeMissing:
|
||||||
|
return new VrDeviceQueryResult(state, false, label, null,
|
||||||
|
OpenVRNative.ETrackedDeviceClass.Invalid,
|
||||||
|
null, null, null, null, null,
|
||||||
|
"SteamVR n'est pas installé sur cette machine");
|
||||||
|
|
||||||
|
case VrRuntimeState.HmdAbsent:
|
||||||
|
return new VrDeviceQueryResult(state, false, label, null,
|
||||||
|
OpenVRNative.ETrackedDeviceClass.Invalid,
|
||||||
|
false, null, null, null, null,
|
||||||
|
"SteamVR n'est pas lancé ou aucun HMD n'est branché");
|
||||||
|
|
||||||
|
case VrRuntimeState.Ready:
|
||||||
|
detail = $"{label} : SteamVR actif, HMD détecté (info détaillée par appareil arrive en Phase 1.5)";
|
||||||
|
return new VrDeviceQueryResult(state, true, label, null,
|
||||||
|
OpenVRNative.ETrackedDeviceClass.HMD,
|
||||||
|
true, null, null, null, null,
|
||||||
|
detail);
|
||||||
|
|
||||||
|
default:
|
||||||
|
return new VrDeviceQueryResult(state, false, label, null,
|
||||||
|
OpenVRNative.ETrackedDeviceClass.Invalid,
|
||||||
|
null, null, null, null, null,
|
||||||
|
"Échec d'initialisation OpenVR");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Variante safe d'EnsureReady : ne fait JAMAIS de VR_InitInternal2 ni
|
||||||
|
/// de GetGenericInterface. Décide l'état uniquement depuis :
|
||||||
|
/// - <see cref="OpenVRNative.VR_IsRuntimeInstalled"/> (lookup statique
|
||||||
|
/// dans openvr_api.dll, aucun IPC, immédiat),
|
||||||
|
/// - vrserver.exe alive (process check),
|
||||||
|
/// - <see cref="OpenVRNative.VR_IsHmdPresent"/> (consulte un mutex/event
|
||||||
|
/// nommé partagé avec vrserver, mais pas de SHM).
|
||||||
|
/// </summary>
|
||||||
|
private VrRuntimeState EnsureReadySafe()
|
||||||
|
{
|
||||||
|
if (!_resolverEnsured)
|
||||||
|
{
|
||||||
|
OpenVRNative.EnsureResolverInstalled();
|
||||||
|
_resolverEnsured = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!OpenVRNative.VR_IsRuntimeInstalled())
|
||||||
|
return VrRuntimeState.RuntimeMissing;
|
||||||
|
}
|
||||||
|
catch (DllNotFoundException) { return VrRuntimeState.RuntimeMissing; }
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogDebug(ex, "VR_IsRuntimeInstalled threw");
|
||||||
|
return VrRuntimeState.RuntimeMissing;
|
||||||
|
}
|
||||||
|
|
||||||
|
// vrserver.exe en l'air ?
|
||||||
|
if (!IsVrServerRunning()) return VrRuntimeState.HmdAbsent;
|
||||||
|
|
||||||
|
// Cooldown de settle : on garde le délai même en mode safe parce que
|
||||||
|
// VR_IsHmdPresent consulte un objet de synchro qui peut être pas encore
|
||||||
|
// créé pendant les premières secondes du boot SteamVR.
|
||||||
|
if (!HasVrServerSettled()) return VrRuntimeState.HmdAbsent;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!OpenVRNative.VR_IsHmdPresent()) return VrRuntimeState.HmdAbsent;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogDebug(ex, "VR_IsHmdPresent threw");
|
||||||
|
return VrRuntimeState.HmdAbsent;
|
||||||
|
}
|
||||||
|
|
||||||
|
return VrRuntimeState.Ready;
|
||||||
|
}
|
||||||
|
|
||||||
|
private VrRuntimeState EnsureReady()
|
||||||
|
{
|
||||||
|
if (!_resolverEnsured)
|
||||||
|
{
|
||||||
|
OpenVRNative.EnsureResolverInstalled();
|
||||||
|
_resolverEnsured = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_vrSystemVTable != IntPtr.Zero) return VrRuntimeState.Ready;
|
||||||
|
|
||||||
|
// Probe runtime + HMD ; les deux sont des appels rapides qui ne déclenchent
|
||||||
|
// pas l'init du compositor même si SteamVR est éteint.
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!OpenVRNative.VR_IsRuntimeInstalled())
|
||||||
|
return VrRuntimeState.RuntimeMissing;
|
||||||
|
}
|
||||||
|
catch (DllNotFoundException) { return VrRuntimeState.RuntimeMissing; }
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogDebug(ex, "VR_IsRuntimeInstalled threw");
|
||||||
|
return VrRuntimeState.RuntimeMissing;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!OpenVRNative.VR_IsHmdPresent()) return VrRuntimeState.HmdAbsent;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogDebug(ex, "VR_IsHmdPresent threw");
|
||||||
|
return VrRuntimeState.HmdAbsent;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Garde 1 : vrserver.exe DOIT être actif. Sans lui, VR_InitInternal2 en
|
||||||
|
// mode Background peut soit hang, soit charger des drivers (lighthouse,
|
||||||
|
// oculus_link…) qui plantent en SEH.
|
||||||
|
if (!IsVrServerRunning())
|
||||||
|
{
|
||||||
|
return VrRuntimeState.HmdAbsent;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Garde 2 : on attend que vrserver soit en l'air depuis assez longtemps
|
||||||
|
// pour que ses drivers et ses shared memories soient initialisés.
|
||||||
|
// Sinon : init succeed → premier vtable call → AccessViolation natif
|
||||||
|
// (la shared memory n'est pas encore mappée). Le SEH passe au travers du
|
||||||
|
// CLR via Marshal.GetDelegateForFunctionPointer et tue le process.
|
||||||
|
// 8s = compromis entre réactivité (l'utilisateur a lancé SteamVR, on veut
|
||||||
|
// pas attendre 30 s) et fiabilité (sur SSD c'est ~3-5 s, sur HDD ça peut
|
||||||
|
// monter à 7 s). On garde HmdAbsent affiché pendant le warmup ; quand le
|
||||||
|
// ticker se redéclenche après le délai, on enchaînera proprement sur Ready.
|
||||||
|
if (!HasVrServerSettled())
|
||||||
|
{
|
||||||
|
return VrRuntimeState.HmdAbsent;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Init en mode Background : on lit les states sans devenir une scene app.
|
||||||
|
// L'init peut prendre 100-500 ms la première fois ; bloquer le caller est OK
|
||||||
|
// car le health loop tourne sur thread dédié.
|
||||||
|
if (_initAttempted && _initToken == 0) return VrRuntimeState.InitFailed;
|
||||||
|
_initAttempted = true;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_initToken = OpenVRNative.VR_InitInternal2(out var initError, OpenVRNative.VRApplicationBackground, null);
|
||||||
|
if (initError != 0 || _initToken == 0)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("VR_InitInternal2 failed: {Err}", OpenVRNative.GetInitErrorDescription(initError));
|
||||||
|
_initToken = 0;
|
||||||
|
return VrRuntimeState.InitFailed;
|
||||||
|
}
|
||||||
|
|
||||||
|
_vrSystemVTable = OpenVRNative.VR_GetGenericInterface(IVRSystemInterfaceVersion, out var ifaceError);
|
||||||
|
if (_vrSystemVTable == IntPtr.Zero || ifaceError != 0)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("VR_GetGenericInterface({Iface}) failed: {Err}",
|
||||||
|
IVRSystemInterfaceVersion, OpenVRNative.GetInitErrorDescription(ifaceError));
|
||||||
|
ShutdownInternal();
|
||||||
|
return VrRuntimeState.InitFailed;
|
||||||
|
}
|
||||||
|
|
||||||
|
BindVTableDelegates();
|
||||||
|
return VrRuntimeState.Ready;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "OpenVR init threw");
|
||||||
|
ShutdownInternal();
|
||||||
|
return VrRuntimeState.InitFailed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void BindVTableDelegates()
|
||||||
|
{
|
||||||
|
IntPtr P(int slot) => OpenVRNative.GetVTableSlot(_vrSystemVTable, slot);
|
||||||
|
|
||||||
|
_fnActivity = Marshal.GetDelegateForFunctionPointer<OpenVRNative.GetTrackedDeviceActivityLevelFn>(P(OpenVRNative.IVRSystemSlot.GetTrackedDeviceActivityLevel));
|
||||||
|
_fnRoleToIndex = Marshal.GetDelegateForFunctionPointer<OpenVRNative.GetTrackedDeviceIndexForControllerRoleFn>(P(OpenVRNative.IVRSystemSlot.GetTrackedDeviceIndexForControllerRole));
|
||||||
|
_fnDeviceClass = Marshal.GetDelegateForFunctionPointer<OpenVRNative.GetTrackedDeviceClassFn>(P(OpenVRNative.IVRSystemSlot.GetTrackedDeviceClass));
|
||||||
|
_fnIsConnected = Marshal.GetDelegateForFunctionPointer<OpenVRNative.IsTrackedDeviceConnectedFn>(P(OpenVRNative.IVRSystemSlot.IsTrackedDeviceConnected));
|
||||||
|
_fnGetBool = Marshal.GetDelegateForFunctionPointer<OpenVRNative.GetBoolTrackedDevicePropertyFn>(P(OpenVRNative.IVRSystemSlot.GetBoolTrackedDeviceProperty));
|
||||||
|
_fnGetFloat = Marshal.GetDelegateForFunctionPointer<OpenVRNative.GetFloatTrackedDevicePropertyFn>(P(OpenVRNative.IVRSystemSlot.GetFloatTrackedDeviceProperty));
|
||||||
|
_fnGetString = Marshal.GetDelegateForFunctionPointer<OpenVRNative.GetStringTrackedDevicePropertyFn>(P(OpenVRNative.IVRSystemSlot.GetStringTrackedDeviceProperty));
|
||||||
|
}
|
||||||
|
|
||||||
|
private uint? ResolveDeviceIndex(string alias)
|
||||||
|
{
|
||||||
|
if (_fnRoleToIndex is null || _fnDeviceClass is null) return null;
|
||||||
|
var a = alias.Trim().ToLowerInvariant();
|
||||||
|
const uint K_unMaxTrackedDeviceCount = 64;
|
||||||
|
const uint K_InvalidIndex = 0xFFFFFFFF;
|
||||||
|
|
||||||
|
switch (a)
|
||||||
|
{
|
||||||
|
case "hmd":
|
||||||
|
return 0; // index 0 = HMD principal par convention OpenVR
|
||||||
|
|
||||||
|
case "left_controller":
|
||||||
|
{
|
||||||
|
var idx = _fnRoleToIndex(OpenVRNative.ETrackedControllerRole.LeftHand);
|
||||||
|
return idx == K_InvalidIndex ? null : idx;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "right_controller":
|
||||||
|
{
|
||||||
|
var idx = _fnRoleToIndex(OpenVRNative.ETrackedControllerRole.RightHand);
|
||||||
|
return idx == K_InvalidIndex ? null : idx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// tracker_N / base_station_N : on énumère tous les devices et on prend le N-ième de la classe demandée
|
||||||
|
if (TryParseIndexed(a, "tracker_", out var trackerN))
|
||||||
|
return FindNthOfClass(OpenVRNative.ETrackedDeviceClass.GenericTracker, trackerN);
|
||||||
|
|
||||||
|
if (TryParseIndexed(a, "base_station_", out var stationN))
|
||||||
|
return FindNthOfClass(OpenVRNative.ETrackedDeviceClass.TrackingReference, stationN);
|
||||||
|
|
||||||
|
// Fallback : si l'utilisateur a tapé un index brut "0", "1", … on le respecte
|
||||||
|
if (uint.TryParse(a, out var raw) && raw < K_unMaxTrackedDeviceCount)
|
||||||
|
return raw;
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryParseIndexed(string alias, string prefix, out int n)
|
||||||
|
{
|
||||||
|
n = 0;
|
||||||
|
if (!alias.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) return false;
|
||||||
|
var tail = alias[prefix.Length..];
|
||||||
|
return int.TryParse(tail, out n) && n >= 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private uint? FindNthOfClass(OpenVRNative.ETrackedDeviceClass targetClass, int n)
|
||||||
|
{
|
||||||
|
if (_fnDeviceClass is null) return null;
|
||||||
|
const uint K_unMaxTrackedDeviceCount = 64;
|
||||||
|
var found = 0;
|
||||||
|
for (uint i = 0; i < K_unMaxTrackedDeviceCount; i++)
|
||||||
|
{
|
||||||
|
if (_fnDeviceClass(i) == targetClass)
|
||||||
|
{
|
||||||
|
found++;
|
||||||
|
if (found == n) return i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ResolveLabel(string alias) => alias.Trim().ToLowerInvariant() switch
|
||||||
|
{
|
||||||
|
"hmd" => "HMD",
|
||||||
|
"left_controller" => "Manette gauche",
|
||||||
|
"right_controller" => "Manette droite",
|
||||||
|
var a when a.StartsWith("tracker_") => $"Tracker {a[8..]}",
|
||||||
|
var a when a.StartsWith("base_station_") => $"Base station {a[13..]}",
|
||||||
|
_ => alias,
|
||||||
|
};
|
||||||
|
|
||||||
|
private bool? ReadBoolProperty(uint deviceIndex, int property)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var v = _fnGetBool!(deviceIndex, property, out var err);
|
||||||
|
return err == 0 ? v : null;
|
||||||
|
}
|
||||||
|
catch { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private float? ReadFloatProperty(uint deviceIndex, int property)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var v = _fnGetFloat!(deviceIndex, property, out var err);
|
||||||
|
return err == 0 ? v : null;
|
||||||
|
}
|
||||||
|
catch { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private string? ReadStringProperty(uint deviceIndex, int property)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var buf = Marshal.AllocHGlobal(256);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var written = _fnGetString!(deviceIndex, property, buf, 256, out var err);
|
||||||
|
if (err != 0 || written == 0) return null;
|
||||||
|
// OpenVR renvoie un C-string ANSI null-terminé
|
||||||
|
return Marshal.PtrToStringAnsi(buf, (int)Math.Min(written, 256u)).TrimEnd('\0');
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
Marshal.FreeHGlobal(buf);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Log au niveau INFO la première occurrence d'un état + chaque transition.
|
||||||
|
/// Évite de spammer (un check s'exécute toutes les 5 s, on aurait 700 messages
|
||||||
|
/// par heure si on loggait à chaque tick) tout en laissant une trace claire
|
||||||
|
/// dans le journal au cas où ça re-crasherait — la dernière ligne avant le
|
||||||
|
/// silence de Serilog dirait par exemple « OpenVR state: HmdAbsent ».
|
||||||
|
/// </summary>
|
||||||
|
private void LogStateTransition(VrRuntimeState newState)
|
||||||
|
{
|
||||||
|
if (newState == _lastLoggedState) return;
|
||||||
|
_lastLoggedState = newState;
|
||||||
|
var detail = newState switch
|
||||||
|
{
|
||||||
|
VrRuntimeState.RuntimeMissing => "openvr_api.dll introuvable (SteamVR pas installé)",
|
||||||
|
VrRuntimeState.HmdAbsent => "vrserver.exe pas lancé ou aucun HMD branché",
|
||||||
|
VrRuntimeState.InitFailed => "VR_InitInternal2 a échoué",
|
||||||
|
VrRuntimeState.Ready => "OK, vtable IVRSystem_022 bindée",
|
||||||
|
_ => "?",
|
||||||
|
};
|
||||||
|
_logger.LogInformation("OpenVR state: {State} ({Detail})", newState, detail);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// vrserver.exe est le process central de SteamVR ; sa présence garantit qu'on
|
||||||
|
/// peut init OpenVR sans déclencher de chargement de drivers risqué. Cache de
|
||||||
|
/// 2 s pour ne pas faire un GetProcessesByName à chaque tick.
|
||||||
|
/// </summary>
|
||||||
|
private DateTime _vrServerCacheUntil = DateTime.MinValue;
|
||||||
|
private bool _vrServerCacheValue;
|
||||||
|
|
||||||
|
private bool IsVrServerRunning()
|
||||||
|
{
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
if (now < _vrServerCacheUntil) return _vrServerCacheValue;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var procs = SysProcess.GetProcessesByName("vrserver");
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_vrServerCacheValue = procs.Length > 0;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
foreach (var p in procs) p.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogDebug(ex, "vrserver process probe failed, assuming absent");
|
||||||
|
_vrServerCacheValue = false;
|
||||||
|
}
|
||||||
|
_vrServerCacheUntil = now.AddSeconds(2);
|
||||||
|
|
||||||
|
// Update first-seen tracking : on note l'instant où vrserver apparaît,
|
||||||
|
// et on l'oublie quand il disparaît. C'est ce timestamp qu'on consulte
|
||||||
|
// dans EnsureReady pour décider si la fenêtre de race est passée.
|
||||||
|
if (_vrServerCacheValue && _vrServerFirstSeenUtc is null)
|
||||||
|
{
|
||||||
|
_vrServerFirstSeenUtc = now;
|
||||||
|
_logger.LogInformation("vrserver.exe just appeared — waiting {Secs}s for SteamVR drivers/IPC to settle before init", VrServerSettleSeconds);
|
||||||
|
}
|
||||||
|
else if (!_vrServerCacheValue && _vrServerFirstSeenUtc is not null)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("vrserver.exe disappeared — resetting OpenVR session");
|
||||||
|
_vrServerFirstSeenUtc = null;
|
||||||
|
// Si on était Ready, l'invalider : la prochaine session SteamVR exigera un nouvel init
|
||||||
|
if (_initToken != 0) ShutdownInternal();
|
||||||
|
_initAttempted = false;
|
||||||
|
}
|
||||||
|
return _vrServerCacheValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool HasVrServerSettled()
|
||||||
|
{
|
||||||
|
if (_vrServerFirstSeenUtc is null) return false;
|
||||||
|
return (DateTime.UtcNow - _vrServerFirstSeenUtc.Value).TotalSeconds >= VrServerSettleSeconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void MarkSessionBroken()
|
||||||
|
{
|
||||||
|
ShutdownInternal();
|
||||||
|
_initAttempted = false; // permettre une retente au prochain tick
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ShutdownInternal()
|
||||||
|
{
|
||||||
|
_vrSystemVTable = IntPtr.Zero;
|
||||||
|
_fnActivity = null;
|
||||||
|
_fnRoleToIndex = null;
|
||||||
|
_fnDeviceClass = null;
|
||||||
|
_fnIsConnected = null;
|
||||||
|
_fnGetBool = null;
|
||||||
|
_fnGetFloat = null;
|
||||||
|
_fnGetString = null;
|
||||||
|
|
||||||
|
if (_initToken != 0)
|
||||||
|
{
|
||||||
|
try { OpenVRNative.VR_ShutdownInternal(); } catch { /* ignore */ }
|
||||||
|
_initToken = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
lock (_lock) ShutdownInternal();
|
||||||
|
}
|
||||||
|
}
|
||||||
203
src/PSLauncher.Core/Health/SystemHealthService.cs
Normal file
203
src/PSLauncher.Core/Health/SystemHealthService.cs
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
using System.Net.NetworkInformation;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using PSLauncher.Core.Health.OpenVRInterop;
|
||||||
|
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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Implémentation : Ping ICMP via <see cref="Ping"/>, et listing de processus
|
||||||
|
/// via <see cref="Process.GetProcessesByName(string)"/>. Pas de privilèges
|
||||||
|
/// administrateur requis (le ping classique ICMP fonctionne en user-mode sur
|
||||||
|
/// Windows 10+ ; pas besoin du raw socket).
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SystemHealthService : ISystemHealthService
|
||||||
|
{
|
||||||
|
private readonly ILogger<SystemHealthService> _logger;
|
||||||
|
private readonly IOpenVrService _openVr;
|
||||||
|
|
||||||
|
public SystemHealthService(ILogger<SystemHealthService> logger, IOpenVrService openVr)
|
||||||
|
{
|
||||||
|
_logger = logger;
|
||||||
|
_openVr = openVr;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<HealthResult> RunCheckAsync(HealthCheckEntry entry, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var kind = entry.Kind?.Trim().ToLowerInvariant();
|
||||||
|
|
||||||
|
// VrDevice n'a PAS besoin d'un Target non vide (HMD = index 0 implicite),
|
||||||
|
// donc on le sort du gate Target-vide.
|
||||||
|
if (kind != "vrdevice" && string.IsNullOrWhiteSpace(entry.Target))
|
||||||
|
return new HealthResult(HealthSeverity.Unknown, "Non configuré (Target vide)");
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return kind switch
|
||||||
|
{
|
||||||
|
"ping" => await CheckPingAsync(entry, ct).ConfigureAwait(false),
|
||||||
|
"process" => CheckProcess(entry),
|
||||||
|
"vrdevice" => CheckVrDevice(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<HealthResult> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Lit l'état d'un device SteamVR via OpenVR :
|
||||||
|
/// - Runtime absent → Unknown + "SteamVR non installé"
|
||||||
|
/// - HMD absent → Unknown + "SteamVR non lancé / aucun HMD branché"
|
||||||
|
/// - Device cherché non trouvé → Error + "Appareil non détecté"
|
||||||
|
/// - Battery < 15% (et pas en charge) → Error
|
||||||
|
/// - Battery 15-30% (et pas en charge) → Warning
|
||||||
|
/// - Sinon → Ok avec le % batterie
|
||||||
|
///
|
||||||
|
/// Pour les <c>TrackingReference</c> (base stations) qui n'exposent pas de
|
||||||
|
/// batterie, on tombe sur l'état connecté/en veille uniquement.
|
||||||
|
/// </summary>
|
||||||
|
private HealthResult CheckVrDevice(HealthCheckEntry entry)
|
||||||
|
{
|
||||||
|
// Si Target vide pour VrDevice on assume "hmd" — c'est le cas le plus courant
|
||||||
|
var alias = string.IsNullOrWhiteSpace(entry.Target) ? "hmd" : entry.Target.Trim();
|
||||||
|
var result = _openVr.Query(alias);
|
||||||
|
|
||||||
|
if (result.RuntimeState == VrRuntimeState.RuntimeMissing)
|
||||||
|
return new HealthResult(HealthSeverity.Unknown, "SteamVR n'est pas installé sur cette machine");
|
||||||
|
|
||||||
|
if (result.RuntimeState == VrRuntimeState.HmdAbsent)
|
||||||
|
return new HealthResult(HealthSeverity.Error, "SteamVR n'est pas lancé ou aucun HMD n'est branché");
|
||||||
|
|
||||||
|
if (result.RuntimeState == VrRuntimeState.InitFailed)
|
||||||
|
return new HealthResult(HealthSeverity.Error,
|
||||||
|
$"Échec init OpenVR{(result.ErrorDetail is null ? "" : " : " + result.ErrorDetail)}");
|
||||||
|
|
||||||
|
if (!result.Found || result.IsConnected != true)
|
||||||
|
return new HealthResult(HealthSeverity.Error,
|
||||||
|
result.ErrorDetail ?? $"{result.DeviceLabel} non détecté");
|
||||||
|
|
||||||
|
// Mode safe (Phase 1) : OpenVR ne nous donne plus de batterie/activity/serial
|
||||||
|
// pour éviter le crash sur les vtable calls. Quand tous ces champs sont null
|
||||||
|
// mais qu'on est en Ready+Connected, on affiche le message court fourni par
|
||||||
|
// OpenVrService (ex. "HMD détecté — Phase 1.5 fournira la batterie").
|
||||||
|
if (result.BatteryPercentage is null
|
||||||
|
&& result.IsCharging is null
|
||||||
|
&& result.ActivityLevel is null
|
||||||
|
&& string.IsNullOrEmpty(result.Serial))
|
||||||
|
{
|
||||||
|
return new HealthResult(HealthSeverity.Ok,
|
||||||
|
result.ErrorDetail ?? $"{result.DeviceLabel} détecté");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Device présent : on assemble un résumé batterie + activité
|
||||||
|
var battery = result.BatteryPercentage;
|
||||||
|
var pctText = battery is null ? "—" : $"{Math.Round(battery.Value * 100)}%";
|
||||||
|
var chargeIcon = result.IsCharging == true ? " ⚡" : "";
|
||||||
|
var serial = string.IsNullOrEmpty(result.Serial) ? "" : $" · {result.Serial}";
|
||||||
|
var activityText = result.ActivityLevel switch
|
||||||
|
{
|
||||||
|
OpenVRNative.EDeviceActivityLevel.UserInteraction => "actif",
|
||||||
|
OpenVRNative.EDeviceActivityLevel.UserInteraction_Timeout => "actif (en pause)",
|
||||||
|
OpenVRNative.EDeviceActivityLevel.Standby => "en veille",
|
||||||
|
OpenVRNative.EDeviceActivityLevel.Idle => "inactif",
|
||||||
|
OpenVRNative.EDeviceActivityLevel.Idle_Timeout => "inactif",
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
|
var detail = $"{result.DeviceLabel} : {pctText}{chargeIcon}" +
|
||||||
|
(activityText is null ? "" : $" — {activityText}") +
|
||||||
|
serial;
|
||||||
|
|
||||||
|
// Severity : prioriser charging et class spéciale (base stations sans batterie)
|
||||||
|
if (result.DeviceClass == OpenVRNative.ETrackedDeviceClass.TrackingReference && battery is null)
|
||||||
|
{
|
||||||
|
// Base station : connectée = OK (même sans info batterie)
|
||||||
|
return new HealthResult(HealthSeverity.Ok, detail);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.IsCharging == true)
|
||||||
|
return new HealthResult(HealthSeverity.Ok, detail);
|
||||||
|
|
||||||
|
if (battery is null)
|
||||||
|
{
|
||||||
|
// Device sans batterie exposée (ex. base station filaire) : on rapporte juste "connecté"
|
||||||
|
return new HealthResult(HealthSeverity.Ok, detail);
|
||||||
|
}
|
||||||
|
|
||||||
|
var pct = battery.Value;
|
||||||
|
if (pct < 0.15f) return new HealthResult(HealthSeverity.Error, detail + " (seuil rouge < 15%)");
|
||||||
|
if (pct < 0.30f) return new HealthResult(HealthSeverity.Warning, detail + " (seuil orange < 30%)");
|
||||||
|
return new HealthResult(HealthSeverity.Ok, detail);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -95,6 +95,7 @@ public static class Strings
|
|||||||
public static string StatusDownloading => T("⬇ Téléchargement…", "⬇ Downloading…", "⬇ 下载中…", "⬇ กำลังดาวน์โหลด…", "⬇ جارٍ التنزيل…");
|
public static string StatusDownloading => T("⬇ Téléchargement…", "⬇ Downloading…", "⬇ 下载中…", "⬇ กำลังดาวน์โหลด…", "⬇ جارٍ التنزيل…");
|
||||||
public static string StatusInstalling => T("📦 Installation…", "📦 Installing…", "📦 安装中…", "📦 กำลังติดตั้ง…", "📦 جارٍ التثبيت…");
|
public static string StatusInstalling => T("📦 Installation…", "📦 Installing…", "📦 安装中…", "📦 กำลังติดตั้ง…", "📦 جارٍ التثبيت…");
|
||||||
public static string StatusUninstalling => T("🗑 Suppression…", "🗑 Uninstalling…", "🗑 卸载中…", "🗑 กำลังถอนการติดตั้ง…", "🗑 جارٍ الإزالة…");
|
public static string StatusUninstalling => T("🗑 Suppression…", "🗑 Uninstalling…", "🗑 卸载中…", "🗑 กำลังถอนการติดตั้ง…", "🗑 جارٍ الإزالة…");
|
||||||
|
public static string StatusVerifying => T("🔍 Vérification…", "🔍 Verifying…", "🔍 校验中…", "🔍 กำลังตรวจสอบ…", "🔍 جارٍ التحقق…");
|
||||||
|
|
||||||
// ==================== ACTIONS ====================
|
// ==================== ACTIONS ====================
|
||||||
public static string ActionLaunch => T("▶ Lancer", "▶ Launch", "▶ 启动", "▶ เปิด", "▶ تشغيل");
|
public static string ActionLaunch => T("▶ Lancer", "▶ Launch", "▶ 启动", "▶ เปิด", "▶ تشغيل");
|
||||||
@@ -177,6 +178,21 @@ public static class Strings
|
|||||||
$"تعذر تحميل {url}\n\n{detail}\n\nتأكد من تشغيل XAMPP وأن العنوان صحيح (الإعدادات → متقدم)."
|
$"تعذر تحميل {url}\n\n{detail}\n\nتأكد من تشغيل XAMPP وأن العنوان صحيح (الإعدادات → متقدم)."
|
||||||
);
|
);
|
||||||
|
|
||||||
|
public static string SettingsParallelSegments => T(
|
||||||
|
"Connexions parallèles pour les téléchargements (1-32, défaut 16)",
|
||||||
|
"Parallel connections for downloads (1-32, default 16)",
|
||||||
|
"下载的并行连接数 (1-32,默认 16)",
|
||||||
|
"การเชื่อมต่อแบบขนานสำหรับการดาวน์โหลด (1-32, ค่าเริ่มต้น 16)",
|
||||||
|
"اتصالات متوازية للتنزيلات (1-32، الافتراضي 16)"
|
||||||
|
);
|
||||||
|
public static string SettingsParallelSegmentsHint => T(
|
||||||
|
"Plus de connexions = débit pic plus élevé et queue de fin de DL plus courte. Au-delà de 16 le serveur peut throttler.",
|
||||||
|
"More connections = higher peak throughput and shorter end-of-DL tail. Above 16 the server may throttle.",
|
||||||
|
"更多连接 = 更高的峰值吞吐量和更短的下载尾部。超过 16 服务器可能会限速。",
|
||||||
|
"การเชื่อมต่อมากขึ้น = ปริมาณงานสูงสุดมากขึ้นและส่วนท้ายของการดาวน์โหลดสั้นลง สูงกว่า 16 เซิร์ฟเวอร์อาจจำกัด",
|
||||||
|
"اتصالات أكثر = إنتاجية ذروة أعلى وذيل تنزيل أقصر. فوق 16 قد يقيّد الخادم."
|
||||||
|
);
|
||||||
|
|
||||||
public static string SettingsAdvanced => T(
|
public static string SettingsAdvanced => T(
|
||||||
"▸ PARAMÈTRES AVANCÉS (serveur, installation, base de données, logs)",
|
"▸ PARAMÈTRES AVANCÉS (serveur, installation, base de données, logs)",
|
||||||
"▸ ADVANCED SETTINGS (server, installation, database, logs)",
|
"▸ ADVANCED SETTINGS (server, installation, database, logs)",
|
||||||
@@ -515,6 +531,42 @@ public static class Strings
|
|||||||
);
|
);
|
||||||
public static string SettingsReportRedeploy => T("📂 Re-déployer le Report maintenant", "📂 Re-deploy Report now", "📂 立即重新部署报告", "📂 ปรับใช้รายงานใหม่เดี๋ยวนี้", "📂 إعادة نشر التقرير الآن");
|
public static string SettingsReportRedeploy => T("📂 Re-déployer le Report maintenant", "📂 Re-deploy Report now", "📂 立即重新部署报告", "📂 ปรับใช้รายงานใหม่เดี๋ยวนี้", "📂 إعادة نشر التقرير الآن");
|
||||||
|
|
||||||
|
// ---- Doc tool (mêmes mécanique que Report, autres libellés) ----
|
||||||
|
public static string StatusDeployingDoc(string version) => T(
|
||||||
|
$"Déploiement de la Documentation v{version}…",
|
||||||
|
$"Deploying Documentation for v{version}…",
|
||||||
|
$"正在部署 v{version} 的文档…",
|
||||||
|
$"กำลังปรับใช้เอกสาร v{version}…",
|
||||||
|
$"جارٍ نشر التوثيق v{version}…"
|
||||||
|
);
|
||||||
|
public static string ProgressDocDeploy(int done, int total, string filename) => T(
|
||||||
|
$"📖 Déploiement Doc : {done}/{total} — {filename}",
|
||||||
|
$"📖 Deploying Doc: {done}/{total} — {filename}",
|
||||||
|
$"📖 部署文档:{done}/{total} — {filename}",
|
||||||
|
$"📖 ปรับใช้เอกสาร: {done}/{total} — {filename}",
|
||||||
|
$"📖 نشر التوثيق: {done}/{total} — {filename}"
|
||||||
|
);
|
||||||
|
public static string MsgBoxDocDeployFailed => T("Déploiement Doc échoué", "Doc deploy failed", "文档部署失败", "การปรับใช้เอกสารล้มเหลว", "فشل نشر التوثيق");
|
||||||
|
public static string MsgDocDeployFailed(string detail) => T(
|
||||||
|
$"Le déploiement de la Documentation a échoué :\n\n{detail}\n\nLa version PROSERVE est installée mais l'onglet Documentation pourrait être désynchronisé. Tu peux retenter via Paramètres → Avancés → Re-déployer la Doc.",
|
||||||
|
$"Documentation deploy failed:\n\n{detail}\n\nThe PROSERVE version is installed but the Documentation tab might be out of sync. You can retry via Settings → Advanced → Re-deploy Doc.",
|
||||||
|
$"文档部署失败:\n\n{detail}\n\nPROSERVE 版本已安装,但文档选项卡可能不同步。您可以通过 设置 → 高级 → 重新部署文档 重试。",
|
||||||
|
$"การปรับใช้เอกสารล้มเหลว:\n\n{detail}\n\nเวอร์ชัน PROSERVE ติดตั้งแล้วแต่แท็บเอกสารอาจไม่ซิงค์ คุณสามารถลองใหม่ผ่าน ตั้งค่า → ขั้นสูง → ปรับใช้เอกสารใหม่",
|
||||||
|
$"فشل نشر التوثيق:\n\n{detail}\n\nنسخة PROSERVE مثبتة لكن علامة التبويب التوثيق قد لا تكون متزامنة. يمكنك إعادة المحاولة عبر الإعدادات → متقدم → إعادة نشر التوثيق."
|
||||||
|
);
|
||||||
|
|
||||||
|
public static string SettingsDocTool => T("OUTIL DOCUMENTATION (XAMPP htdocs)", "DOCUMENTATION TOOL (XAMPP htdocs)", "文档工具 (XAMPP htdocs)", "เครื่องมือเอกสาร (XAMPP htdocs)", "أداة التوثيق (XAMPP htdocs)");
|
||||||
|
public static string SettingsDocAutoDeploy => T(
|
||||||
|
"Déployer automatiquement la Documentation à l'install d'une nouvelle version",
|
||||||
|
"Automatically deploy the Documentation when installing a new version",
|
||||||
|
"安装新版本时自动部署文档",
|
||||||
|
"ปรับใช้เอกสารโดยอัตโนมัติเมื่อติดตั้งเวอร์ชันใหม่",
|
||||||
|
"نشر التوثيق تلقائياً عند تثبيت نسخة جديدة"
|
||||||
|
);
|
||||||
|
public static string SettingsDocRedeploy => T("📖 Re-déployer la Doc maintenant", "📖 Re-deploy Doc now", "📖 立即重新部署文档", "📖 ปรับใช้เอกสารใหม่เดี๋ยวนี้", "📖 إعادة نشر التوثيق الآن");
|
||||||
|
public static string SettingsDocBackups => SettingsReportBackups;
|
||||||
|
public static string SettingsDocMaxBackups => SettingsReportMaxBackups;
|
||||||
|
|
||||||
// ---- Backups Report tool ----
|
// ---- Backups Report tool ----
|
||||||
public static string SettingsReportBackups => T("Backups disponibles", "Available backups", "可用备份", "สำรองข้อมูลที่มี", "النسخ الاحتياطية المتاحة");
|
public static string SettingsReportBackups => T("Backups disponibles", "Available backups", "可用备份", "สำรองข้อมูลที่มี", "النسخ الاحتياطية المتاحة");
|
||||||
public static string SettingsReportMaxBackups => T("Conserver N backups", "Keep N backups", "保留 N 个备份", "เก็บสำรอง N ชุด", "الاحتفاظ بـ N نسخة احتياطية");
|
public static string SettingsReportMaxBackups => T("Conserver N backups", "Keep N backups", "保留 N 个备份", "เก็บสำรอง N ชุด", "الاحتفاظ بـ N نسخة احتياطية");
|
||||||
@@ -894,6 +946,124 @@ public static class Strings
|
|||||||
$"تاريخ الإصدار: {date} • {size}"
|
$"تاريخ الإصدار: {date} • {size}"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ==================== HEALTH BANNER ====================
|
||||||
|
public static string SettingsHealthChecks => T(
|
||||||
|
"BANDEAU DE SANTÉ SYSTÈME",
|
||||||
|
"SYSTEM HEALTH BANNER",
|
||||||
|
"系统健康状态栏",
|
||||||
|
"แถบสถานะระบบ",
|
||||||
|
"شريط حالة النظام"
|
||||||
|
);
|
||||||
|
public static string SettingsHealthChecksHint => T(
|
||||||
|
"Pings et processus surveillés sous la top bar (Library uniquement). Édité ici, sauvegardé dans config.json — survit à un auto-update.",
|
||||||
|
"Pings and processes monitored below the top bar (Library only). Edited here, saved to config.json — survives auto-updates.",
|
||||||
|
"在顶部栏下监控的 Ping 和进程(仅库视图)。在此编辑,保存到 config.json — 在自动更新后保留。",
|
||||||
|
"Ping และโปรเซสที่ติดตามใต้แถบบนสุด (เฉพาะ Library) แก้ไขที่นี่ บันทึกใน config.json — คงอยู่หลังการอัปเดตอัตโนมัติ",
|
||||||
|
"Ping والعمليات المراقَبة أسفل الشريط العلوي (المكتبة فقط). يُحرَّر هنا، ويُحفَظ في config.json — يبقى بعد التحديث التلقائي."
|
||||||
|
);
|
||||||
|
public static string SettingsHealthAdd => T("➕ Ajouter", "➕ Add", "➕ 添加", "➕ เพิ่ม", "➕ إضافة");
|
||||||
|
public static string SettingsHealthEdit => T("✎ Éditer", "✎ Edit", "✎ 编辑", "✎ แก้ไข", "✎ تحرير");
|
||||||
|
public static string SettingsHealthDelete => T("🗑 Supprimer", "🗑 Delete", "🗑 删除", "🗑 ลบ", "🗑 حذف");
|
||||||
|
public static string SettingsHealthEmpty => T(
|
||||||
|
"Aucun check configuré. Le bandeau ne s'affichera pas tant qu'aucune entrée n'aura été ajoutée.",
|
||||||
|
"No checks configured. The banner stays hidden until at least one entry is added.",
|
||||||
|
"未配置任何检查。在添加至少一项之前,状态栏将保持隐藏。",
|
||||||
|
"ยังไม่มีการตรวจสอบที่ตั้งค่าไว้ แถบจะถูกซ่อนจนกว่าจะมีการเพิ่มอย่างน้อยหนึ่งรายการ",
|
||||||
|
"لم يُهيَّأ أي فحص. سيبقى الشريط مخفياً حتى تتم إضافة إدخال واحد على الأقل."
|
||||||
|
);
|
||||||
|
public static string SettingsHealthDeleteConfirm(string name) => T(
|
||||||
|
$"Supprimer le check « {name} » ?",
|
||||||
|
$"Delete check « {name} »?",
|
||||||
|
$"删除检查 \"{name}\"?",
|
||||||
|
$"ลบการตรวจสอบ « {name} »?",
|
||||||
|
$"حذف الفحص « {name} »؟"
|
||||||
|
);
|
||||||
|
|
||||||
|
public static string HealthEditorTitle => T("Éditer le check", "Edit check", "编辑检查", "แก้ไขการตรวจสอบ", "تحرير الفحص");
|
||||||
|
public static string HealthEditorTitleNew => T("Nouveau check", "New check", "新建检查", "การตรวจสอบใหม่", "فحص جديد");
|
||||||
|
public static string HealthEditorName => T("NOM", "NAME", "名称", "ชื่อ", "الاسم");
|
||||||
|
public static string HealthEditorNameHint => T(
|
||||||
|
"Libellé court affiché dans la pill du bandeau, ex. « SteamVR ».",
|
||||||
|
"Short label shown in the banner pill, e.g. \"SteamVR\".",
|
||||||
|
"状态栏胶囊中显示的简短标签,例如 \"SteamVR\"。",
|
||||||
|
"ชื่อย่อที่แสดงในป้ายของแถบ เช่น \"SteamVR\"",
|
||||||
|
"تسمية قصيرة تظهر في الشريط، مثل \"SteamVR\"."
|
||||||
|
);
|
||||||
|
public static string HealthEditorKind => T("TYPE DE VÉRIFICATION", "CHECK TYPE", "检查类型", "ประเภทการตรวจสอบ", "نوع الفحص");
|
||||||
|
public static string HealthEditorKindProcess => T("Processus (Windows)", "Process (Windows)", "进程 (Windows)", "โปรเซส (Windows)", "عملية (Windows)");
|
||||||
|
public static string HealthEditorKindPing => T("Ping (réseau ICMP)", "Ping (ICMP network)", "Ping (ICMP 网络)", "Ping (เครือข่าย ICMP)", "Ping (شبكة ICMP)");
|
||||||
|
public static string HealthEditorKindVrDevice => T(
|
||||||
|
"Appareil SteamVR",
|
||||||
|
"SteamVR device",
|
||||||
|
"SteamVR 设备",
|
||||||
|
"อุปกรณ์ SteamVR",
|
||||||
|
"جهاز SteamVR"
|
||||||
|
);
|
||||||
|
public static string HealthEditorTargetVrDevice => T("APPAREIL VR", "VR DEVICE", "VR 设备", "อุปกรณ์ VR", "جهاز VR");
|
||||||
|
public static string HealthEditorTargetVrDeviceHint => T(
|
||||||
|
"Phase 1 : détecte la présence de SteamVR et d'un HMD branché (vert si oui, rouge sinon). La batterie + l'info par-appareil arrive en Phase 1.5 via un sous-process séparé pour être robuste aux crashs natifs OpenVR.",
|
||||||
|
"Phase 1: detects SteamVR running with an HMD plugged in (green if yes, red otherwise). Battery + per-device info coming in Phase 1.5 via a separate child process to be robust against OpenVR native crashes.",
|
||||||
|
"第 1 阶段:检测 SteamVR 是否运行以及是否插入了 HMD(是则绿色,否则红色)。电池 + 每个设备的信息将在第 1.5 阶段通过单独的子进程提供,以增强对 OpenVR 原生崩溃的鲁棒性。",
|
||||||
|
"เฟส 1: ตรวจจับว่า SteamVR กำลังทำงานและมี HMD เชื่อมต่ออยู่หรือไม่ (เขียวถ้าใช่ แดงถ้าไม่) ข้อมูลแบตเตอรี่ + ต่ออุปกรณ์จะมาในเฟส 1.5 ผ่านโปรเซสลูกแยกเพื่อความทนทานต่อข้อผิดพลาดเนทีฟ OpenVR",
|
||||||
|
"المرحلة 1: تكتشف تشغيل SteamVR مع توصيل HMD (أخضر إذا نعم، أحمر خلاف ذلك). البطارية + المعلومات لكل جهاز ستأتي في المرحلة 1.5 عبر عملية فرعية منفصلة لتكون متينة ضد الأعطال الأصلية OpenVR."
|
||||||
|
);
|
||||||
|
public static string HealthEditorTargetProcess => T("CIBLE — NOM DE PROCESSUS", "TARGET — PROCESS NAME", "目标 — 进程名", "เป้าหมาย — ชื่อโปรเซส", "الهدف — اسم العملية");
|
||||||
|
public static string HealthEditorTargetPing => T("CIBLE — IP OU HOSTNAME", "TARGET — IP OR HOSTNAME", "目标 — IP 或主机名", "เป้าหมาย — IP หรือชื่อโฮสต์", "الهدف — IP أو اسم المضيف");
|
||||||
|
public static string HealthEditorTargetProcessHint => T(
|
||||||
|
"Nom de l'exécutable sans .exe, ex. « vrserver » ou « HtcConnectionUtility ». Insensible à la casse.",
|
||||||
|
"Executable name without .exe, e.g. \"vrserver\" or \"HtcConnectionUtility\". Case-insensitive.",
|
||||||
|
"可执行文件名(不含 .exe),例如 \"vrserver\" 或 \"HtcConnectionUtility\"。不区分大小写。",
|
||||||
|
"ชื่อไฟล์ปฏิบัติการโดยไม่มี .exe เช่น \"vrserver\" หรือ \"HtcConnectionUtility\" ไม่คำนึงถึงตัวพิมพ์",
|
||||||
|
"اسم الملف التنفيذي بدون .exe، مثل \"vrserver\" أو \"HtcConnectionUtility\". غير حساس لحالة الأحرف."
|
||||||
|
);
|
||||||
|
public static string HealthEditorTargetPingHint => T(
|
||||||
|
"Adresse IP (ex. 192.168.1.42) ou hostname résolvable. Vide = check ignoré (statut Inconnu).",
|
||||||
|
"IP address (e.g. 192.168.1.42) or resolvable hostname. Empty = check skipped (Unknown status).",
|
||||||
|
"IP 地址(例如 192.168.1.42)或可解析的主机名。空 = 跳过检查(未知状态)。",
|
||||||
|
"ที่อยู่ IP (เช่น 192.168.1.42) หรือชื่อโฮสต์ที่แก้ได้ ว่างเปล่า = ข้ามการตรวจสอบ (สถานะไม่ทราบ)",
|
||||||
|
"عنوان IP (مثل 192.168.1.42) أو اسم مضيف قابل للحل. فارغ = تخطّي الفحص (الحالة غير معروفة)."
|
||||||
|
);
|
||||||
|
public static string HealthEditorRefresh => T("INTERVALLE DE RAFRAÎCHISSEMENT", "REFRESH INTERVAL", "刷新间隔", "ช่วงเวลารีเฟรช", "فاصل التحديث");
|
||||||
|
public static string HealthEditorRefreshHint => T(
|
||||||
|
"Délai entre 2 vérifications, en millisecondes. Process ~1 ms (1000 OK) ; Ping = round-trip réseau (5000-10000 conseillé). Min 200 ms.",
|
||||||
|
"Delay between 2 checks, in milliseconds. Process ~1 ms (1000 OK); Ping = network round-trip (5000-10000 recommended). Min 200 ms.",
|
||||||
|
"两次检查之间的延迟(毫秒)。进程约 1 毫秒(1000 即可);Ping = 网络往返(建议 5000-10000)。最小 200 毫秒。",
|
||||||
|
"ระยะเวลาระหว่าง 2 การตรวจสอบเป็นมิลลิวินาที โปรเซส ~1 ms (1000 OK); Ping = round-trip เครือข่าย (แนะนำ 5000-10000). ขั้นต่ำ 200 ms.",
|
||||||
|
"التأخير بين فحصين بالمللي ثانية. عملية ~1 ms (1000 جيدة)؛ Ping = ذهاب وعودة الشبكة (يوصى 5000-10000). الحد الأدنى 200 ms."
|
||||||
|
);
|
||||||
|
public static string HealthEditorIcon => T("PICTOGRAMME", "ICON", "图标", "ไอคอน", "أيقونة");
|
||||||
|
public static string HealthEditorIconHint => T(
|
||||||
|
"Affiché à gauche du nom dans la pill. Choisis-en un dans la liste.",
|
||||||
|
"Displayed to the left of the name in the pill. Pick one from the list.",
|
||||||
|
"显示在胶囊中名称的左侧。从列表中选择一个。",
|
||||||
|
"แสดงทางซ้ายของชื่อในป้าย เลือกหนึ่งจากรายการ",
|
||||||
|
"تُعرض على يسار الاسم في الشارة. اختر واحدةً من القائمة."
|
||||||
|
);
|
||||||
|
public static string HealthEditorPingAdvanced => T(
|
||||||
|
"Avancé — seuils Ping",
|
||||||
|
"Advanced — Ping thresholds",
|
||||||
|
"高级 — Ping 阈值",
|
||||||
|
"ขั้นสูง — เกณฑ์ Ping",
|
||||||
|
"متقدم — حدود Ping"
|
||||||
|
);
|
||||||
|
public static string HealthEditorPingWarn => T("Warning ms — RTT au-dessus = orange", "Warning ms — RTT above = amber", "警告 ms — 高于此值 = 橙色", "Warning ms — RTT เกินค่านี้ = ส้ม", "تحذير ms — RTT فوق ذلك = برتقالي");
|
||||||
|
public static string HealthEditorPingError => T("Error ms — RTT au-dessus = rouge", "Error ms — RTT above = red", "错误 ms — 高于此值 = 红色", "Error ms — RTT เกินค่านี้ = แดง", "خطأ ms — RTT فوق ذلك = أحمر");
|
||||||
|
public static string HealthEditorPingTimeout => T("Timeout ms — abandon de la requête", "Timeout ms — abort the request", "超时 ms — 中止请求", "Timeout ms — ยกเลิกคำขอ", "مهلة ms — إلغاء الطلب");
|
||||||
|
public static string HealthEditorErrorName => T(
|
||||||
|
"Le nom est requis.",
|
||||||
|
"Name is required.",
|
||||||
|
"需要名称。",
|
||||||
|
"ต้องระบุชื่อ",
|
||||||
|
"الاسم مطلوب."
|
||||||
|
);
|
||||||
|
public static string HealthEditorErrorRefresh => T(
|
||||||
|
"Intervalle invalide. Saisis un entier ≥ 200 (millisecondes).",
|
||||||
|
"Invalid interval. Enter an integer ≥ 200 (milliseconds).",
|
||||||
|
"间隔无效。请输入 ≥ 200 的整数(毫秒)。",
|
||||||
|
"ช่วงเวลาไม่ถูกต้อง ใส่จำนวนเต็ม ≥ 200 (มิลลิวินาที)",
|
||||||
|
"فاصل غير صالح. أدخل عدداً صحيحاً ≥ 200 (مللي ثانية)."
|
||||||
|
);
|
||||||
|
|
||||||
// ==================== COPYRIGHT ====================
|
// ==================== COPYRIGHT ====================
|
||||||
public static string Copyright => T(
|
public static string Copyright => T(
|
||||||
"© 2026 ASTERION VR — Tous droits réservés",
|
"© 2026 ASTERION VR — Tous droits réservés",
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<LangVersion>latest</LangVersion>
|
<LangVersion>latest</LangVersion>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<!-- unsafe requis pour le déréférencement de la vtable OpenVR (Health/OpenVR/OpenVRNative.cs) -->
|
||||||
|
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -16,9 +16,13 @@ public sealed class LocalConfig
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Nombre de connexions HTTP parallèles utilisées pour télécharger un build.
|
/// Nombre de connexions HTTP parallèles utilisées pour télécharger un build.
|
||||||
/// 1 = comportement legacy single-connection. 4-8 contourne le throttling
|
/// 1 = comportement legacy single-connection. 4-8 contourne le throttling
|
||||||
/// per-connection d'Apache/OVH mutualisé. 8 est un bon défaut.
|
/// per-connection d'Apache/OVH mutualisé. 16 = défaut depuis v0.19 pour
|
||||||
|
/// raccourcir la « queue » en fin de DL : avec 8 segments sur 14 Go, le
|
||||||
|
/// dernier segment doit finir seul ~1.75 Go (visible comme un effondrement
|
||||||
|
/// du débit en fin de course) ; avec 16, c'est ~875 Mo. Push à 24/32 si
|
||||||
|
/// ton serveur l'accepte (OVH mutualisé tient bien jusqu'à 16-24).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public int ParallelDownloadSegments { get; set; } = 8;
|
public int ParallelDownloadSegments { get; set; } = 16;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Si false, ignore la vérification SHA-256 même si le manifest l'exige.
|
/// Si false, ignore la vérification SHA-256 même si le manifest l'exige.
|
||||||
@@ -36,12 +40,11 @@ public sealed class LocalConfig
|
|||||||
public string ReportUrl { get; set; } = "http://localhost/ProserveReport/";
|
public string ReportUrl { get; set; } = "http://localhost/ProserveReport/";
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// URL de la documentation locale (onglet "Documentation"). Vide par défaut :
|
/// URL de la documentation locale (onglet "Documentation"). Pointe par défaut
|
||||||
/// l'utilisateur peut pointer vers une URL locale ou web. Si vide, l'onglet
|
/// vers l'install standard ASTERION dans XAMPP. Surchargeable dans Settings →
|
||||||
/// affiche un placeholder avec les liens vers les fichiers .pptx du dossier
|
/// Avancés. Si vide, l'onglet affiche un placeholder.
|
||||||
/// d'install du launcher.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string DocumentationUrl { get; set; } = string.Empty;
|
public string DocumentationUrl { get; set; } = "http://localhost/ProserveDoc/";
|
||||||
|
|
||||||
public LicenseConfig License { get; set; } = new();
|
public LicenseConfig License { get; set; } = new();
|
||||||
|
|
||||||
@@ -53,6 +56,22 @@ public sealed class LocalConfig
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public ReportToolConfig ReportTool { get; set; } = new();
|
public ReportToolConfig ReportTool { get; set; } = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Configuration du déploiement de l'outil Documentation (page web locale
|
||||||
|
/// embarquée dans l'onglet "Documentation"). Même mécanisme que ReportTool :
|
||||||
|
/// copie de <c>_doc/</c> bundled dans le ZIP PROSERVE vers
|
||||||
|
/// <c>{HtdocsRoot}\{FolderName}</c> avec backups horodatés et revert.
|
||||||
|
/// </summary>
|
||||||
|
public DocToolConfig DocTool { get; set; } = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public HealthChecksConfig HealthChecks { get; set; } = new();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Connexion à la base MySQL locale (XAMPP) utilisée par PROSERVE pour ses
|
/// Connexion à la base MySQL locale (XAMPP) utilisée par PROSERVE pour ses
|
||||||
/// statistiques. Le launcher s'en sert pour appliquer les migrations bundled
|
/// statistiques. Le launcher s'en sert pour appliquer les migrations bundled
|
||||||
@@ -118,6 +137,99 @@ public sealed class ReportToolConfig
|
|||||||
public int MaxBackups { get; set; } = 3;
|
public int MaxBackups { get; set; } = 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Mêmes paramètres que <see cref="ReportToolConfig"/> mais pour la
|
||||||
|
/// documentation locale (sous-dossier <c>_doc/</c> du ZIP, déployé vers
|
||||||
|
/// <c>{HtdocsRoot}\ProserveDoc</c> par défaut).
|
||||||
|
/// </summary>
|
||||||
|
public sealed class DocToolConfig
|
||||||
|
{
|
||||||
|
public string HtdocsRoot { get; set; } = @"C:\xampp\htdocs";
|
||||||
|
public string FolderName { get; set; } = "ProserveDoc";
|
||||||
|
public bool AutoDeploy { get; set; } = true;
|
||||||
|
public int MaxBackups { get; set; } = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class HealthChecksConfig
|
||||||
|
{
|
||||||
|
/// <summary>Période de re-vérification, en secondes. 0 = désactivé.</summary>
|
||||||
|
public int RefreshIntervalSeconds { get; set; } = 10;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Liste des checks à effectuer. Defaults = exemples typiques setup VR
|
||||||
|
/// (SteamVR + Vive Business Streaming + ping casque). L'utilisateur édite
|
||||||
|
/// la liste via <c>%LocalAppData%\PSLauncher\config.json</c> pour ajuster
|
||||||
|
/// les noms de processus, IPs et seuils ; un éditeur Settings est prévu
|
||||||
|
/// en v2 si le besoin est récurrent.
|
||||||
|
/// </summary>
|
||||||
|
public List<HealthCheckEntry> Checks { get; set; } = new()
|
||||||
|
{
|
||||||
|
new HealthCheckEntry
|
||||||
|
{
|
||||||
|
Name = "SteamVR",
|
||||||
|
Icon = "🎮",
|
||||||
|
Kind = "Process",
|
||||||
|
Target = "vrserver",
|
||||||
|
},
|
||||||
|
new HealthCheckEntry
|
||||||
|
{
|
||||||
|
Name = "Vive Business Streaming",
|
||||||
|
Icon = "📡",
|
||||||
|
Kind = "Process",
|
||||||
|
Target = "HtcConnectionUtility",
|
||||||
|
},
|
||||||
|
// PAS de check « casque connecté » par défaut.
|
||||||
|
//
|
||||||
|
// Pourquoi : avec Vive Business Streaming dans la pile, le driver Vive
|
||||||
|
// ment à SteamVR — il prétend qu'un HMD est présent même quand la
|
||||||
|
// session Wi-Fi avec le casque physique est tombée. Donc tous les
|
||||||
|
// signaux qui passent par SteamVR (VR_IsHmdPresent, vtable IVRSystem,
|
||||||
|
// logs vrserver) sont contaminés par ce mensonge et ne reflètent pas
|
||||||
|
// l'état réel du casque.
|
||||||
|
//
|
||||||
|
// À traiter en Phase 2 via un signal authoritative qui ne passe PAS
|
||||||
|
// par SteamVR (pistes brainstormées : connexions TCP/UDP de
|
||||||
|
// HtcConnectionUtility via GetExtendedTcpTable, endpoint local de
|
||||||
|
// Vive Console, compteurs IO du process, ARP table avec OUIs HTC,
|
||||||
|
// logs Vive Streaming directs). Ajout d'un Kind dédié à ce moment-là.
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class HealthCheckEntry
|
||||||
|
{
|
||||||
|
/// <summary>Libellé affiché dans le bandeau, ex. "SteamVR".</summary>
|
||||||
|
public string Name { get; set; } = "";
|
||||||
|
|
||||||
|
/// <summary>Pictogramme emoji affiché à côté du nom, ex. "🎮", "📡", "🥽".</summary>
|
||||||
|
public string Icon { get; set; } = "🔌";
|
||||||
|
|
||||||
|
/// <summary>"Process" ou "Ping" (case-insensitive).</summary>
|
||||||
|
public string Kind { get; set; } = "Process";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Selon Kind : nom du processus (sans .exe) pour Process,
|
||||||
|
/// IP/hostname pour Ping. Vide = check ignoré (statut Unknown).
|
||||||
|
/// </summary>
|
||||||
|
public string Target { get; set; } = "";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Période entre 2 vérifications de CE check, en millisecondes. Indépendant
|
||||||
|
/// pour chaque entry car un Process check est rapide (~1ms) et peut tourner
|
||||||
|
/// toutes les secondes, alors qu'un Ping coûte une requête réseau et peut
|
||||||
|
/// tourner toutes les 5-10 s. Default 5000 ms.
|
||||||
|
/// </summary>
|
||||||
|
public int RefreshIntervalMs { get; set; } = 5000;
|
||||||
|
|
||||||
|
/// <summary>Pour Ping : RTT au-dessus duquel le statut passe en Warning (Orange). Default 50 ms.</summary>
|
||||||
|
public int? PingWarnMs { get; set; } = 50;
|
||||||
|
|
||||||
|
/// <summary>Pour Ping : RTT au-dessus duquel le statut passe en Error (Rouge). Default 200 ms.</summary>
|
||||||
|
public int? PingErrorMs { get; set; } = 200;
|
||||||
|
|
||||||
|
/// <summary>Pour Ping : timeout de la requête ICMP. Default 1 s.</summary>
|
||||||
|
public int? PingTimeoutMs { get; set; } = 1000;
|
||||||
|
}
|
||||||
|
|
||||||
public sealed class LicenseConfig
|
public sealed class LicenseConfig
|
||||||
{
|
{
|
||||||
public string? EncryptedKey { get; set; }
|
public string? EncryptedKey { get; set; }
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
<LangVersion>latest</LangVersion>
|
<LangVersion>latest</LangVersion>
|
||||||
<AssemblyName>PS_Launcher.Updater</AssemblyName>
|
<AssemblyName>PS_Launcher.Updater</AssemblyName>
|
||||||
<RootNamespace>PSLauncher.Updater</RootNamespace>
|
<RootNamespace>PSLauncher.Updater</RootNamespace>
|
||||||
<Version>0.16.0</Version>
|
<Version>0.21.0</Version>
|
||||||
<ApplicationIcon>..\PSLauncher.App\Resources\favicon.ico</ApplicationIcon>
|
<ApplicationIcon>..\PSLauncher.App\Resources\favicon.ico</ApplicationIcon>
|
||||||
<Company>ASTERION VR</Company>
|
<Company>ASTERION VR</Company>
|
||||||
<Copyright>© 2026 ASTERION VR — All rights reserved</Copyright>
|
<Copyright>© 2026 ASTERION VR — All rights reserved</Copyright>
|
||||||
|
|||||||
Reference in New Issue
Block a user