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 MyAppShortName "PS_Launcher"
|
||||
#define MyAppVersion "0.16.0"
|
||||
#define MyAppVersion "0.23.5"
|
||||
#define MyAppPublisher "ASTERION VR"
|
||||
#define MyAppURL "https://asterionvr.com"
|
||||
#define MyAppExeName "PS_Launcher.exe"
|
||||
|
||||
@@ -16,6 +16,9 @@ using PSLauncher.Core.Integrity;
|
||||
using PSLauncher.Core.Licensing;
|
||||
using PSLauncher.Core.Localization;
|
||||
using PSLauncher.Core.Manifests;
|
||||
using PSLauncher.Core.DocTool;
|
||||
using PSLauncher.Core.Health;
|
||||
using PSLauncher.Core.Health.OpenVRInterop;
|
||||
using PSLauncher.Core.Migrations;
|
||||
using PSLauncher.Core.Process;
|
||||
using PSLauncher.Core.ReportTool;
|
||||
@@ -94,7 +97,19 @@ public partial class App : Application
|
||||
Log.Information("PSLauncher starting (logs in {Path})", LogsDirectory);
|
||||
|
||||
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) =>
|
||||
{
|
||||
Log.Error(args.Exception, "Unhandled UI exception");
|
||||
@@ -111,16 +126,16 @@ public partial class App : Application
|
||||
services.AddSingleton(sp =>
|
||||
{
|
||||
// Handler tuné pour les gros téléchargements parallèles :
|
||||
// - MaxConnectionsPerServer=16 pour autoriser le multi-segment (8 par défaut).
|
||||
// - AutomaticDecompression sur l'API JSON uniquement (None ici car les builds
|
||||
// sont des ZIPs déjà compressés ; éviter la CPU+RAM gaspillée par un éventuel
|
||||
// wrapping gzip côté serveur).
|
||||
// - MaxConnectionsPerServer=32 pour aligner avec le clamp DownloadManager (8-32).
|
||||
// Default WPF = 8 → trop bas pour notre multi-segment.
|
||||
// - AutomaticDecompression=None : les builds sont des ZIPs déjà compressés ;
|
||||
// é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.
|
||||
var handler = new SocketsHttpHandler
|
||||
{
|
||||
PooledConnectionLifetime = TimeSpan.FromMinutes(5),
|
||||
PooledConnectionIdleTimeout = TimeSpan.FromSeconds(60),
|
||||
MaxConnectionsPerServer = 16,
|
||||
MaxConnectionsPerServer = 32,
|
||||
EnableMultipleHttp2Connections = true,
|
||||
AutomaticDecompression = System.Net.DecompressionMethods.None,
|
||||
};
|
||||
@@ -182,6 +197,16 @@ public partial class App : Application
|
||||
configProvider: () => sp.GetRequiredService<LocalConfig>().ReportTool,
|
||||
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.AddSingleton<MainViewModel>();
|
||||
services.AddSingleton<MainWindow>();
|
||||
|
||||
@@ -15,9 +15,9 @@
|
||||
<Product>PROSERVE Launcher</Product>
|
||||
<Copyright>© 2026 ASTERION VR — All rights reserved</Copyright>
|
||||
<RootNamespace>PSLauncher.App</RootNamespace>
|
||||
<Version>0.16.0</Version>
|
||||
<AssemblyVersion>0.16.0.0</AssemblyVersion>
|
||||
<FileVersion>0.16.0.0</FileVersion>
|
||||
<Version>0.23.5</Version>
|
||||
<AssemblyVersion>0.23.5.0</AssemblyVersion>
|
||||
<FileVersion>0.23.5.0</FileVersion>
|
||||
|
||||
<!-- Single-file self-contained publish profile (used by `dotnet publish`) -->
|
||||
<PublishSingleFile>true</PublishSingleFile>
|
||||
|
||||
@@ -11,12 +11,18 @@
|
||||
<!-- Inverse bool : utilisé pour griser un bouton pendant qu'une opération est en cours -->
|
||||
<res:InverseBoolConverter x:Key="InverseBoolToBool" />
|
||||
|
||||
<!-- Fond fenêtre : noir pur pour ne pas délaver le bitmap overlay -->
|
||||
<SolidColorBrush x:Key="Brush.Bg.Window" Color="#000000" />
|
||||
<!-- Cards & sidebar : très légère teinte bleutée pour le contraste -->
|
||||
<SolidColorBrush x:Key="Brush.Bg.Sidebar" Color="#0E1218" />
|
||||
<SolidColorBrush x:Key="Brush.Bg.Card" Color="#161B23" />
|
||||
<SolidColorBrush x:Key="Brush.Bg.Footer" Color="#050709" />
|
||||
<!-- Fond fenêtre : presque noir avec un soupçon de bleu. v0.18 a tenté
|
||||
un navy plus prononcé (#0A1220) mais c'était trop bleu — on revient
|
||||
à un noir-bleuté très subtil pour rester sombre tout en gardant
|
||||
l'identité ASTERION VR. -->
|
||||
<SolidColorBrush x:Key="Brush.Bg.Window" Color="#050A14" />
|
||||
<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.Text.Primary" Color="#F2F2F2" />
|
||||
<SolidColorBrush x:Key="Brush.Text.Secondary" Color="#A0A0A8" />
|
||||
@@ -281,6 +287,47 @@
|
||||
</Setter>
|
||||
</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.
|
||||
Re-template minimal pour avoir le contenu, l'arrow et le popup tous en
|
||||
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.Localization;
|
||||
using PSLauncher.Core.Manifests;
|
||||
using PSLauncher.Core.DocTool;
|
||||
using PSLauncher.Core.Health;
|
||||
using PSLauncher.Core.Migrations;
|
||||
using PSLauncher.Core.Process;
|
||||
using PSLauncher.Core.ReportTool;
|
||||
@@ -43,6 +45,8 @@ public sealed partial class MainViewModel : ObservableObject
|
||||
private readonly IToastService _toastService;
|
||||
private readonly IDatabaseMigrationService _migrationService;
|
||||
private readonly IReportToolDeployer _reportDeployer;
|
||||
private readonly IDocToolDeployer _docDeployer;
|
||||
private readonly ISystemHealthService _healthService;
|
||||
private readonly IServiceProvider _serviceProvider;
|
||||
private readonly ILogger<MainViewModel> _logger;
|
||||
|
||||
@@ -51,9 +55,40 @@ public sealed partial class MainViewModel : ObservableObject
|
||||
private RemoteManifest? _lastManifest;
|
||||
private CancellationTokenSource? _activeDownloadCts;
|
||||
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();
|
||||
|
||||
/// <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]
|
||||
[NotifyPropertyChangedFor(nameof(HasFeaturedVersion))]
|
||||
[NotifyPropertyChangedFor(nameof(HasOtherVersions))]
|
||||
@@ -90,6 +125,7 @@ public sealed partial class MainViewModel : ObservableObject
|
||||
[NotifyPropertyChangedFor(nameof(IsDocumentation))]
|
||||
[NotifyPropertyChangedFor(nameof(FooterVisibility))]
|
||||
[NotifyPropertyChangedFor(nameof(FooterText))]
|
||||
[NotifyPropertyChangedFor(nameof(ShowHealthBanner))]
|
||||
private LauncherPage _currentPage = LauncherPage.Library;
|
||||
|
||||
public bool IsLibrary => CurrentPage == LauncherPage.Library;
|
||||
@@ -226,6 +262,8 @@ public sealed partial class MainViewModel : ObservableObject
|
||||
IToastService toastService,
|
||||
IDatabaseMigrationService migrationService,
|
||||
IReportToolDeployer reportDeployer,
|
||||
IDocToolDeployer docDeployer,
|
||||
ISystemHealthService healthService,
|
||||
IServiceProvider serviceProvider,
|
||||
ILogger<MainViewModel> logger)
|
||||
{
|
||||
@@ -242,6 +280,8 @@ public sealed partial class MainViewModel : ObservableObject
|
||||
_toastService = toastService;
|
||||
_migrationService = migrationService;
|
||||
_reportDeployer = reportDeployer;
|
||||
_docDeployer = docDeployer;
|
||||
_healthService = healthService;
|
||||
_serviceProvider = serviceProvider;
|
||||
_logger = logger;
|
||||
|
||||
@@ -250,6 +290,7 @@ public sealed partial class MainViewModel : ObservableObject
|
||||
_license = _licenseService.GetCached();
|
||||
|
||||
RebuildList();
|
||||
InitHealthIndicators();
|
||||
|
||||
// Vérification automatique des MAJ au démarrage (silencieuse et non bloquante)
|
||||
_ = Task.Run(async () =>
|
||||
@@ -264,6 +305,75 @@ public sealed partial class MainViewModel : ObservableObject
|
||||
}
|
||||
catch (Exception ex) { _logger.LogWarning(ex, "Auto check at startup failed"); }
|
||||
});
|
||||
|
||||
// Boucle périodique des health checks. Ne tourne que si la config en
|
||||
// contient au moins un. Premier passage immédiat puis re-check tous les
|
||||
// RefreshIntervalSeconds.
|
||||
StartHealthLoop();
|
||||
}
|
||||
|
||||
/// <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>
|
||||
@@ -329,7 +439,7 @@ public sealed partial class MainViewModel : ObservableObject
|
||||
private void WireRowHandlers(VersionRowViewModel row)
|
||||
{
|
||||
row.LaunchHandler = LaunchVersion;
|
||||
row.InstallHandler = r => _ = InstallVersionAsync(r);
|
||||
row.InstallHandler = r => _activeInstallTask = InstallVersionAsync(r);
|
||||
row.UninstallHandler = r => _ = UninstallVersionAsync(r);
|
||||
row.ShowReleaseNotesHandler = r => _ = ShowReleaseNotesAsync(r);
|
||||
row.OpenFolderHandler = OpenVersionFolder;
|
||||
@@ -456,6 +566,15 @@ public sealed partial class MainViewModel : ObservableObject
|
||||
_license = _licenseService.GetCached();
|
||||
NotifyLicenseChanged();
|
||||
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);
|
||||
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)
|
||||
{
|
||||
_activeDownloadCts.Cancel();
|
||||
// Attend la fin de la tâche en cours pour libérer les handles fichier
|
||||
try { await Task.Delay(200); } catch { }
|
||||
// Attend que InstallVersionAsync termine son catch OperationCanceledException
|
||||
// — 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);
|
||||
@@ -678,9 +811,13 @@ public sealed partial class MainViewModel : ObservableObject
|
||||
row.ProgressPercent = 0;
|
||||
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).
|
||||
// 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 =>
|
||||
{
|
||||
if (row.State == VersionRowState.Downloading)
|
||||
row.State = VersionRowState.Verifying;
|
||||
var percent = (int)Math.Round(pct * 100.0);
|
||||
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);
|
||||
ProgressDetail = null;
|
||||
ProgressPercent = 0;
|
||||
@@ -832,6 +1012,7 @@ public sealed partial class MainViewModel : ObservableObject
|
||||
_activeDownloadCts?.Dispose();
|
||||
_activeDownloadCts = null;
|
||||
_activeRow = null;
|
||||
_activeInstallTask = null;
|
||||
IsBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ using CommunityToolkit.Mvvm.Input;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using PSLauncher.App.Views;
|
||||
using PSLauncher.Core.Configuration;
|
||||
using PSLauncher.Core.DocTool;
|
||||
using PSLauncher.Core.Downloads;
|
||||
using PSLauncher.Core.Installations;
|
||||
using PSLauncher.Core.Licensing;
|
||||
@@ -33,6 +34,7 @@ public sealed partial class SettingsViewModel : ObservableObject
|
||||
private readonly IDownloadStateStore _downloadStore;
|
||||
private readonly IDatabaseMigrationService _migrationService;
|
||||
private readonly IReportToolDeployer _reportDeployer;
|
||||
private readonly IDocToolDeployer _docDeployer;
|
||||
private readonly IInstallationRegistry _registry;
|
||||
private readonly HttpClient _http;
|
||||
private readonly ILogger<SettingsViewModel> _logger;
|
||||
@@ -47,6 +49,7 @@ public sealed partial class SettingsViewModel : ObservableObject
|
||||
[ObservableProperty] private string _language;
|
||||
[ObservableProperty] private string _reportUrl;
|
||||
[ObservableProperty] private string _documentationUrl;
|
||||
[ObservableProperty] private int _parallelSegments;
|
||||
|
||||
// ----- DB Migration settings -----
|
||||
[ObservableProperty] private string _dbHost;
|
||||
@@ -71,6 +74,28 @@ public sealed partial class SettingsViewModel : ObservableObject
|
||||
= new();
|
||||
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; } =
|
||||
PSLauncher.Core.Localization.Strings.Available
|
||||
.Select(t => new LanguageOption(t.Code, t.Name))
|
||||
@@ -139,6 +164,7 @@ public sealed partial class SettingsViewModel : ObservableObject
|
||||
IDownloadStateStore downloadStore,
|
||||
IDatabaseMigrationService migrationService,
|
||||
IReportToolDeployer reportDeployer,
|
||||
IDocToolDeployer docDeployer,
|
||||
IInstallationRegistry registry,
|
||||
HttpClient http,
|
||||
ILogger<SettingsViewModel> logger)
|
||||
@@ -149,6 +175,7 @@ public sealed partial class SettingsViewModel : ObservableObject
|
||||
_downloadStore = downloadStore;
|
||||
_migrationService = migrationService;
|
||||
_reportDeployer = reportDeployer;
|
||||
_docDeployer = docDeployer;
|
||||
_registry = registry;
|
||||
_http = http;
|
||||
_logger = logger;
|
||||
@@ -158,6 +185,7 @@ public sealed partial class SettingsViewModel : ObservableObject
|
||||
_language = string.IsNullOrWhiteSpace(config.Language) ? "auto" : config.Language;
|
||||
_reportUrl = config.ReportUrl;
|
||||
_documentationUrl = config.DocumentationUrl;
|
||||
_parallelSegments = config.ParallelDownloadSegments;
|
||||
|
||||
_dbHost = config.Database.Host;
|
||||
_dbPort = config.Database.Port;
|
||||
@@ -170,8 +198,20 @@ public sealed partial class SettingsViewModel : ObservableObject
|
||||
_reportFolderName = config.ReportTool.FolderName;
|
||||
_reportAutoDeploy = config.ReportTool.AutoDeploy;
|
||||
_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();
|
||||
_ = LoadDocBackupsAsync();
|
||||
|
||||
LauncherVersion = Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? "0.0.0";
|
||||
MachineId = licenseService.GetMachineId();
|
||||
@@ -281,6 +321,7 @@ public sealed partial class SettingsViewModel : ObservableObject
|
||||
_config.Language = Language ?? "auto";
|
||||
_config.ReportUrl = ReportUrl?.Trim() ?? "";
|
||||
_config.DocumentationUrl = DocumentationUrl?.Trim() ?? "";
|
||||
_config.ParallelDownloadSegments = Math.Clamp(ParallelSegments, 1, 32);
|
||||
|
||||
// DB settings
|
||||
_config.Database.Host = DbHost.Trim();
|
||||
@@ -297,6 +338,16 @@ public sealed partial class SettingsViewModel : ObservableObject
|
||||
_config.ReportTool.AutoDeploy = ReportAutoDeploy;
|
||||
_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);
|
||||
_logger.LogInformation("Settings saved (language={Lang}, changed={Changed})", _config.Language, languageChanged);
|
||||
|
||||
@@ -553,6 +604,143 @@ public sealed partial class SettingsViewModel : ObservableObject
|
||||
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>
|
||||
@@ -576,3 +764,82 @@ public sealed partial class ReportBackupViewModel : ObservableObject
|
||||
[RelayCommand]
|
||||
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
|
||||
AvailableIdle, // Disponible côté serveur, pas installée
|
||||
Downloading,
|
||||
Verifying, // SHA-256 du ZIP téléchargé en cours
|
||||
Installing,
|
||||
Uninstalling,
|
||||
}
|
||||
@@ -77,13 +78,17 @@ public sealed partial class VersionRowViewModel : ObservableObject
|
||||
|
||||
public bool IsInstalled => State is VersionRowState.InstalledIdle or VersionRowState.Uninstalling;
|
||||
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
|
||||
{
|
||||
VersionRowState.InstalledIdle => Strings.StatusInstalled,
|
||||
VersionRowState.AvailableIdle => Strings.StatusAvailable,
|
||||
VersionRowState.Downloading => Strings.StatusDownloading,
|
||||
VersionRowState.Verifying => Strings.StatusVerifying,
|
||||
VersionRowState.Installing => Strings.StatusInstalling,
|
||||
VersionRowState.Uninstalling => Strings.StatusUninstalling,
|
||||
_ => 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
|
||||
de fenêtre. Le footer est confiné à la colonne content.
|
||||
-->
|
||||
<Grid Background="Black">
|
||||
<Grid Background="{StaticResource Brush.Bg.Window}">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="200" />
|
||||
<ColumnDefinition Width="*" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="*" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" /> <!-- Top bar -->
|
||||
<RowDefinition Height="Auto" /> <!-- Health banner -->
|
||||
<RowDefinition Height="*" /> <!-- Body (sidebar + content) -->
|
||||
<RowDefinition Height="Auto" /> <!-- Footer -->
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<!-- Background image : limité à la zone content (Row 1, Col 1) pour ne pas
|
||||
passer derrière la sidebar ni le footer. ImageBrush avec AlignmentX=Right
|
||||
+ AlignmentY=Bottom : si le ratio fenêtre force un crop, on rogne en
|
||||
haut/à gauche — le logo ASTERION VR en bas-droite reste TOUJOURS visible. -->
|
||||
<Rectangle Grid.Row="1" Grid.Column="1" IsHitTestVisible="False">
|
||||
<!-- Background image : limité à la zone content (Row 2, Col 1) pour ne pas
|
||||
passer derrière la sidebar, le banner ou le footer. -->
|
||||
<Rectangle Grid.Row="2" Grid.Column="1" IsHitTestVisible="False">
|
||||
<Rectangle.Fill>
|
||||
<ImageBrush ImageSource="pack://application:,,,/Resources/Background.png"
|
||||
Stretch="UniformToFill"
|
||||
@@ -216,6 +215,9 @@
|
||||
Opacity="0.25" />
|
||||
</Rectangle.Fill>
|
||||
</Rectangle>
|
||||
<Rectangle Grid.Row="2" Grid.Column="1"
|
||||
Fill="{StaticResource Brush.Bg.BlueTint}"
|
||||
IsHitTestVisible="False" />
|
||||
|
||||
<!-- Top bar : 3 colonnes (brand / license center / chrome).
|
||||
Grid.ColumnSpan=2 → s'étend sur sidebar + content. -->
|
||||
@@ -339,10 +341,56 @@
|
||||
</Grid>
|
||||
</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
|
||||
leur Visibility liée à CurrentPage. -->
|
||||
<Grid Grid.Row="1" Grid.Column="1">
|
||||
<Grid Grid.Row="2" Grid.Column="1">
|
||||
|
||||
<!-- ====================== LIBRARY PAGE ====================== -->
|
||||
<Grid Visibility="{Binding IsLibrary, Converter={StaticResource BoolToVisibility}}">
|
||||
@@ -605,10 +653,11 @@
|
||||
<!-- ============== END Content area ============== -->
|
||||
|
||||
<!-- ============== Left sidebar : nav buttons ==============
|
||||
Row 1 + Row 2 (RowSpan=2) → la sidebar s'étend depuis le bas du
|
||||
top bar jusqu'en bas de la fenêtre, peu importe la visibilité du
|
||||
footer ou de la taille de la zone content. -->
|
||||
<Border Grid.Row="1" Grid.RowSpan="2" Grid.Column="0"
|
||||
Row 1 + RowSpan=3 → la sidebar s'étend du bas du top bar jusqu'au
|
||||
bas de la fenêtre, couvrant les 3 rows (health banner, content,
|
||||
footer). Visuellement la sidebar reste le « chapeau de tout »
|
||||
côté gauche, peu importe ce qui se passe à droite. -->
|
||||
<Border Grid.Row="1" Grid.RowSpan="3" Grid.Column="0"
|
||||
Background="#0A0A0E"
|
||||
BorderBrush="{StaticResource Brush.Border}"
|
||||
BorderThickness="0,0,1,0">
|
||||
@@ -641,9 +690,10 @@
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Footer : Row 2, Col 1 → cantonné à la colonne content, sous la zone
|
||||
Library/Report/Doc. La sidebar (RowSpan=2) le contourne par sa colonne. -->
|
||||
<Border Grid.Row="2" Grid.Column="1"
|
||||
<!-- Footer : Row 3, Col 1 → cantonné à la colonne content, sous la zone
|
||||
Library/Report/Doc. La sidebar (RowSpan=2 sur rows 2-3) le contourne
|
||||
par sa colonne. -->
|
||||
<Border Grid.Row="3" Grid.Column="1"
|
||||
Background="{StaticResource Brush.Bg.Footer}"
|
||||
BorderBrush="{StaticResource Brush.Border}" BorderThickness="0,1,0,0"
|
||||
Padding="20,10"
|
||||
|
||||
@@ -230,6 +230,19 @@
|
||||
Background="#1A1A20" Foreground="{StaticResource Brush.Text.Primary}"
|
||||
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1" Padding="8"
|
||||
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>
|
||||
</Border>
|
||||
|
||||
@@ -525,6 +538,197 @@
|
||||
</StackPanel>
|
||||
</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) -->
|
||||
<Border Background="{StaticResource Brush.Bg.Card}"
|
||||
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
|
||||
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
|
||||
&& job.ExpectedSize >= MultiSegmentMinSize
|
||||
&& (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 StatusInstalling => T("📦 Installation…", "📦 Installing…", "📦 安装中…", "📦 กำลังติดตั้ง…", "📦 جارٍ التثبيت…");
|
||||
public static string StatusUninstalling => T("🗑 Suppression…", "🗑 Uninstalling…", "🗑 卸载中…", "🗑 กำลังถอนการติดตั้ง…", "🗑 جارٍ الإزالة…");
|
||||
public static string StatusVerifying => T("🔍 Vérification…", "🔍 Verifying…", "🔍 校验中…", "🔍 กำลังตรวจสอบ…", "🔍 جارٍ التحقق…");
|
||||
|
||||
// ==================== ACTIONS ====================
|
||||
public static string ActionLaunch => T("▶ Lancer", "▶ Launch", "▶ 启动", "▶ เปิด", "▶ تشغيل");
|
||||
@@ -177,6 +178,21 @@ public static class Strings
|
||||
$"تعذر تحميل {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(
|
||||
"▸ PARAMÈTRES AVANCÉS (serveur, installation, base de données, 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", "📂 立即重新部署报告", "📂 ปรับใช้รายงานใหม่เดี๋ยวนี้", "📂 إعادة نشر التقرير الآن");
|
||||
|
||||
// ---- 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 ----
|
||||
public static string SettingsReportBackups => T("Backups disponibles", "Available backups", "可用备份", "สำรองข้อมูลที่มี", "النسخ الاحتياطية المتاحة");
|
||||
public static string SettingsReportMaxBackups => T("Conserver N backups", "Keep N backups", "保留 N 个备份", "เก็บสำรอง N ชุด", "الاحتفاظ بـ N نسخة احتياطية");
|
||||
@@ -894,6 +946,124 @@ public static class Strings
|
||||
$"تاريخ الإصدار: {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 ====================
|
||||
public static string Copyright => T(
|
||||
"© 2026 ASTERION VR — Tous droits réservés",
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<!-- unsafe requis pour le déréférencement de la vtable OpenVR (Health/OpenVR/OpenVRNative.cs) -->
|
||||
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -16,9 +16,13 @@ public sealed class LocalConfig
|
||||
/// <summary>
|
||||
/// Nombre de connexions HTTP parallèles utilisées pour télécharger un build.
|
||||
/// 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>
|
||||
public int ParallelDownloadSegments { get; set; } = 8;
|
||||
public int ParallelDownloadSegments { get; set; } = 16;
|
||||
|
||||
/// <summary>
|
||||
/// 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/";
|
||||
|
||||
/// <summary>
|
||||
/// URL de la documentation locale (onglet "Documentation"). Vide par défaut :
|
||||
/// l'utilisateur peut pointer vers une URL locale ou web. Si vide, l'onglet
|
||||
/// affiche un placeholder avec les liens vers les fichiers .pptx du dossier
|
||||
/// d'install du launcher.
|
||||
/// URL de la documentation locale (onglet "Documentation"). Pointe par défaut
|
||||
/// vers l'install standard ASTERION dans XAMPP. Surchargeable dans Settings →
|
||||
/// Avancés. Si vide, l'onglet affiche un placeholder.
|
||||
/// </summary>
|
||||
public string DocumentationUrl { get; set; } = string.Empty;
|
||||
public string DocumentationUrl { get; set; } = "http://localhost/ProserveDoc/";
|
||||
|
||||
public LicenseConfig License { get; set; } = new();
|
||||
|
||||
@@ -53,6 +56,22 @@ public sealed class LocalConfig
|
||||
/// </summary>
|
||||
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>
|
||||
/// Connexion à la base MySQL locale (XAMPP) utilisée par PROSERVE pour ses
|
||||
/// 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;
|
||||
}
|
||||
|
||||
/// <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 string? EncryptedKey { get; set; }
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<LangVersion>latest</LangVersion>
|
||||
<AssemblyName>PS_Launcher.Updater</AssemblyName>
|
||||
<RootNamespace>PSLauncher.Updater</RootNamespace>
|
||||
<Version>0.16.0</Version>
|
||||
<Version>0.21.0</Version>
|
||||
<ApplicationIcon>..\PSLauncher.App\Resources\favicon.ico</ApplicationIcon>
|
||||
<Company>ASTERION VR</Company>
|
||||
<Copyright>© 2026 ASTERION VR — All rights reserved</Copyright>
|
||||
|
||||
Reference in New Issue
Block a user