diff --git a/installer/PSLauncher.iss b/installer/PSLauncher.iss index 749ea10..c3bae56 100644 --- a/installer/PSLauncher.iss +++ b/installer/PSLauncher.iss @@ -11,7 +11,7 @@ #define MyAppName "PROSERVE Launcher" #define MyAppShortName "PS_Launcher" -#define MyAppVersion "0.22.0" +#define MyAppVersion "0.23.0" #define MyAppPublisher "ASTERION VR" #define MyAppURL "https://asterionvr.com" #define MyAppExeName "PS_Launcher.exe" diff --git a/src/PSLauncher.App/App.xaml.cs b/src/PSLauncher.App/App.xaml.cs index ca08d72..5924e2e 100644 --- a/src/PSLauncher.App/App.xaml.cs +++ b/src/PSLauncher.App/App.xaml.cs @@ -18,6 +18,7 @@ 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; @@ -191,6 +192,7 @@ public partial class App : Application configProvider: () => sp.GetRequiredService().DocTool, sp.GetRequiredService>())); + services.AddSingleton(); services.AddSingleton(); services.AddTransient(); diff --git a/src/PSLauncher.App/PSLauncher.App.csproj b/src/PSLauncher.App/PSLauncher.App.csproj index f68343e..da3942f 100644 --- a/src/PSLauncher.App/PSLauncher.App.csproj +++ b/src/PSLauncher.App/PSLauncher.App.csproj @@ -15,9 +15,9 @@ PROSERVE Launcher © 2026 ASTERION VR — All rights reserved PSLauncher.App - 0.22.0 - 0.22.0.0 - 0.22.0.0 + 0.23.0 + 0.23.0.0 + 0.23.0.0 true diff --git a/src/PSLauncher.App/Views/HealthCheckEditorDialog.xaml b/src/PSLauncher.App/Views/HealthCheckEditorDialog.xaml index 4a2b3d1..483178c 100644 --- a/src/PSLauncher.App/Views/HealthCheckEditorDialog.xaml +++ b/src/PSLauncher.App/Views/HealthCheckEditorDialog.xaml @@ -59,10 +59,16 @@ GroupName="Kind" Content="{x:Static loc:Strings.HealthEditorKindPing}" Foreground="{StaticResource Brush.Text.Primary}" + Margin="0,0,20,0" + Checked="OnKindChanged" /> + - + + + + + Aliases acceptés par OpenVrService pour le picker VrDevice. + 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; @@ -57,10 +70,29 @@ public partial class HealthCheckEditorDialog : Window TargetBox.Text = entry.Target ?? ""; RefreshMsBox.Text = entry.RefreshIntervalMs.ToString(); - var isPing = entry.Kind?.Equals("ping", System.StringComparison.OrdinalIgnoreCase) == true; - KindProcess.IsChecked = !isPing; - KindPing.IsChecked = isPing; - UpdateKindUi(isPing); + 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(); @@ -75,26 +107,41 @@ public partial class HealthCheckEditorDialog : Window IconPicker.ItemsSource = _icons; } - private void UpdateKindUi(bool isPing) + private void UpdateKindUi(string kind) { - if (isPing) + switch (kind) { - TargetLabel.Text = Strings.HealthEditorTargetPing; - TargetHint.Text = Strings.HealthEditorTargetPingHint; - PingAdvanced.Visibility = Visibility.Visible; - } - else - { - TargetLabel.Text = Strings.HealthEditorTargetProcess; - TargetHint.Text = Strings.HealthEditorTargetProcessHint; - PingAdvanced.Visibility = Visibility.Collapsed; + 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é - UpdateKindUi(KindPing.IsChecked == true); + var kind = KindPing.IsChecked == true ? "ping" + : KindVrDevice.IsChecked == true ? "vrdevice" + : "process"; + UpdateKindUi(kind); } private void OnIconClicked(object sender, RoutedEventArgs e) @@ -122,8 +169,16 @@ public partial class HealthCheckEditorDialog : Window Entry.Name = name; Entry.Icon = string.IsNullOrEmpty(selectedIcon) ? "🔌" : selectedIcon; - Entry.Kind = KindPing.IsChecked == true ? "Ping" : "Process"; - Entry.Target = TargetBox.Text?.Trim() ?? ""; + 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") @@ -166,4 +221,7 @@ public partial class HealthCheckEditorDialog : Window public IconOption(string emoji) { Emoji = emoji; } } + + /// Item du ComboBox VrDevice — couple alias technique + libellé affiché. + public sealed record VrAliasOption(string Alias, string Label); } diff --git a/src/PSLauncher.Core/Health/OpenVR/IOpenVrService.cs b/src/PSLauncher.Core/Health/OpenVR/IOpenVrService.cs new file mode 100644 index 0000000..9068285 --- /dev/null +++ b/src/PSLauncher.Core/Health/OpenVR/IOpenVrService.cs @@ -0,0 +1,49 @@ +namespace PSLauncher.Core.Health.OpenVRInterop; + +/// +/// 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 : . +/// +public interface IOpenVrService +{ + /// + /// Interroge un device par alias : + /// hmd, left_controller, right_controller, + /// tracker_1..N, base_station_1..N. + /// + VrDeviceQueryResult Query(string deviceAlias); +} + +/// +/// État de disponibilité de la runtime OpenVR. RuntimeMissing = openvr_api.dll +/// introuvable (SteamVR pas installé). HmdAbsent = SteamVR installé mais +/// vrserver pas lancé ou aucun HMD branché. InitFailed = API trouvée mais +/// VR_InitInternal2 a échoué (cas rare : permission denied, version mismatch). +/// Ready = OK, on peut lire les properties. +/// +public enum VrRuntimeState +{ + Unknown, + RuntimeMissing, + HmdAbsent, + InitFailed, + Ready, +} + +/// +/// Résultat d'une requête sur un device. est false quand +/// l'alias résolu ne correspond à aucun device connecté (ex. tracker_3 absent). +/// +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); diff --git a/src/PSLauncher.Core/Health/OpenVR/OpenVRNative.cs b/src/PSLauncher.Core/Health/OpenVR/OpenVRNative.cs new file mode 100644 index 0000000..1a2e356 --- /dev/null +++ b/src/PSLauncher.Core/Health/OpenVR/OpenVRNative.cs @@ -0,0 +1,237 @@ +using System.Runtime.InteropServices; +using System.Text; +using System.Text.Json; + +namespace PSLauncher.Core.Health.OpenVRInterop; + +/// +/// P/Invoke minimal vers openvr_api.dll (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 %LocalAppData%\openvr\openvrpaths.vrpath), +/// 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 GetGenericInterface("FnTable:IVRSystem_022") +/// qui renvoie un pointeur sur une struct C de pointeurs de fonction (vtable). +/// Cette classe expose juste ce qu'on consomme dans : +/// présence de la runtime, présence d'un HMD, et la vtable IVRSystem. +/// +public static class OpenVRNative +{ + private const string DllName = "openvr_api"; + + /// + /// EVRApplicationType passé à VR_InitInternal2. Background = 4 : on + /// veut LIRE l'état de SteamVR sans déclencher de rendu, sans devenir une + /// scene application, sans monopoliser le compositor. + /// + public const int VRApplicationBackground = 4; + + /// Indices de slots dans la vtable IVRSystem_022. Stables depuis SteamVR 1.10 (2019). + 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; + } + + /// + /// Indices ETrackedDeviceProperty les plus utiles pour notre usage. Liste + /// complète dans openvr.h ; on en garde un sous-ensemble pour ne pas + /// dupliquer 800 enums dont 99% inutiles. + /// + 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; + } + + /// Classe OpenVR d'un device tracké. 1=HMD, 2=Controller, 3=GenericTracker, 4=TrackingReference. + 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, + } + + /// + /// Niveau d'activité du device. Idle = en veille, n'envoie plus de pose. + /// Standby = posé mais réveille rapidement. UserInteraction = en main. + /// + public enum EDeviceActivityLevel + { + Unknown = -1, + Idle = 0, + UserInteraction = 1, + UserInteraction_Timeout = 2, + Standby = 3, + Idle_Timeout = 4, + } + + [DllImport(DllName, EntryPoint = "VR_IsRuntimeInstalled", CallingConvention = CallingConvention.Cdecl)] + public static extern bool VR_IsRuntimeInstalled(); + + [DllImport(DllName, EntryPoint = "VR_IsHmdPresent", CallingConvention = CallingConvention.Cdecl)] + 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); + + /// + /// Récupère un pointeur de fonction depuis la vtable IVRSystem (struct + /// contiguë de IntPtr à ) au slot demandé. + /// + 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; + + /// + /// Installe (idempotent) un 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. + /// + 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; } + } + + /// + /// Cherche openvr_api.dll dans cet ordre : + /// 1. %LocalAppData%\openvr\openvrpaths.vrpath (canon écrit par SteamVR au setup). + /// 2. Dossier Steam standard : C:\Program Files (x86)\Steam\steamapps\common\SteamVR\bin\win64\. + /// + 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); +} diff --git a/src/PSLauncher.Core/Health/OpenVR/OpenVrService.cs b/src/PSLauncher.Core/Health/OpenVR/OpenVrService.cs new file mode 100644 index 0000000..821058b --- /dev/null +++ b/src/PSLauncher.Core/Health/OpenVR/OpenVrService.cs @@ -0,0 +1,335 @@ +using System.Runtime.InteropServices; +using System.Text; +using Microsoft.Extensions.Logging; + +namespace PSLauncher.Core.Health.OpenVRInterop; + +/// +/// Wrapper haut niveau autour de . Singleton +/// thread-safe : init paresseuse au premier appel , é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 proprement, prêt à +/// re-tenter au tick suivant. +/// +public sealed class OpenVrService : IOpenVrService, IDisposable +{ + private const string IVRSystemInterfaceVersion = "FnTable:IVRSystem_022"; + + private readonly ILogger _logger; + private readonly object _lock = new(); + + private bool _resolverEnsured; + private bool _initAttempted; + 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 logger) + { + _logger = logger; + } + + public VrDeviceQueryResult Query(string deviceAlias) + { + lock (_lock) + { + var state = EnsureReady(); + var label = ResolveLabel(deviceAlias); + if (state != VrRuntimeState.Ready) + { + return new VrDeviceQueryResult(state, false, label, null, + OpenVRNative.ETrackedDeviceClass.Invalid, + null, null, null, null, null, + state switch + { + VrRuntimeState.RuntimeMissing => "SteamVR n'est pas installé sur cette machine", + VrRuntimeState.HmdAbsent => "SteamVR n'est pas lancé ou aucun HMD n'est branché", + VrRuntimeState.InitFailed => "Échec d'initialisation OpenVR", + _ => null, + }); + } + + try + { + var index = ResolveDeviceIndex(deviceAlias); + if (index is null) + { + return new VrDeviceQueryResult(state, false, label, null, + OpenVRNative.ETrackedDeviceClass.Invalid, + false, null, null, null, null, + $"Appareil « {deviceAlias} » non détecté"); + } + + var idx = index.Value; + var connected = _fnIsConnected!(idx); + if (!connected) + { + return new VrDeviceQueryResult(state, true, label, null, + _fnDeviceClass!(idx), + false, null, null, null, null, + "Appareil déconnecté"); + } + + var deviceClass = _fnDeviceClass!(idx); + var activity = _fnActivity!(idx); + var serial = ReadStringProperty(idx, OpenVRNative.DeviceProperty.Prop_SerialNumber_String); + + bool? wireless = ReadBoolProperty(idx, OpenVRNative.DeviceProperty.Prop_DeviceIsWireless_Bool); + bool? charging = ReadBoolProperty(idx, OpenVRNative.DeviceProperty.Prop_DeviceIsCharging_Bool); + float? battery = ReadFloatProperty(idx, OpenVRNative.DeviceProperty.Prop_DeviceBatteryPercentage_Float); + + return new VrDeviceQueryResult(state, true, label, serial, + deviceClass, true, charging, wireless, battery, activity, null); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "OpenVR query failed for {Alias}, marking session unhealthy", deviceAlias); + // Marquer l'état comme cassé pour qu'au prochain Query on retente l'init. + MarkSessionBroken(); + return new VrDeviceQueryResult(VrRuntimeState.HmdAbsent, false, label, null, + OpenVRNative.ETrackedDeviceClass.Invalid, + null, null, null, null, null, + $"Erreur OpenVR : {ex.Message}"); + } + } + } + + 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; + } + + // 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(P(OpenVRNative.IVRSystemSlot.GetTrackedDeviceActivityLevel)); + _fnRoleToIndex = Marshal.GetDelegateForFunctionPointer(P(OpenVRNative.IVRSystemSlot.GetTrackedDeviceIndexForControllerRole)); + _fnDeviceClass = Marshal.GetDelegateForFunctionPointer(P(OpenVRNative.IVRSystemSlot.GetTrackedDeviceClass)); + _fnIsConnected = Marshal.GetDelegateForFunctionPointer(P(OpenVRNative.IVRSystemSlot.IsTrackedDeviceConnected)); + _fnGetBool = Marshal.GetDelegateForFunctionPointer(P(OpenVRNative.IVRSystemSlot.GetBoolTrackedDeviceProperty)); + _fnGetFloat = Marshal.GetDelegateForFunctionPointer(P(OpenVRNative.IVRSystemSlot.GetFloatTrackedDeviceProperty)); + _fnGetString = Marshal.GetDelegateForFunctionPointer(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; } + } + + 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(); + } +} diff --git a/src/PSLauncher.Core/Health/SystemHealthService.cs b/src/PSLauncher.Core/Health/SystemHealthService.cs index 46f5b64..92033a0 100644 --- a/src/PSLauncher.Core/Health/SystemHealthService.cs +++ b/src/PSLauncher.Core/Health/SystemHealthService.cs @@ -1,5 +1,6 @@ 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. @@ -17,24 +18,31 @@ namespace PSLauncher.Core.Health; public sealed class SystemHealthService : ISystemHealthService { private readonly ILogger _logger; + private readonly IOpenVrService _openVr; - public SystemHealthService(ILogger logger) + public SystemHealthService(ILogger logger, IOpenVrService openVr) { _logger = logger; + _openVr = openVr; } public async Task RunCheckAsync(HealthCheckEntry entry, CancellationToken ct) { - if (string.IsNullOrWhiteSpace(entry.Target)) + 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 entry.Kind?.Trim().ToLowerInvariant() switch + return kind switch { - "ping" => await CheckPingAsync(entry, ct).ConfigureAwait(false), - "process" => CheckProcess(entry), - _ => new HealthResult(HealthSeverity.Unknown, $"Kind inconnu : '{entry.Kind}'"), + "ping" => await CheckPingAsync(entry, ct).ConfigureAwait(false), + "process" => CheckProcess(entry), + "vrdevice" => CheckVrDevice(entry), + _ => new HealthResult(HealthSeverity.Unknown, $"Kind inconnu : '{entry.Kind}'"), }; } catch (OperationCanceledException) { throw; } @@ -107,4 +115,76 @@ public sealed class SystemHealthService : ISystemHealthService foreach (var p in procs) p.Dispose(); } } + + /// + /// 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 TrackingReference (base stations) qui n'exposent pas de + /// batterie, on tombe sur l'état connecté/en veille uniquement. + /// + 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é"); + + // 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); + } } diff --git a/src/PSLauncher.Core/Localization/Strings.cs b/src/PSLauncher.Core/Localization/Strings.cs index 257bcee..77266bc 100644 --- a/src/PSLauncher.Core/Localization/Strings.cs +++ b/src/PSLauncher.Core/Localization/Strings.cs @@ -992,6 +992,21 @@ public static class Strings 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( + "Lit batterie + état de charge + tracking via OpenVR. SteamVR doit tourner. La runtime est trouvée via la copie installée par SteamVR — rien à bundler.", + "Reads battery + charging + tracking via OpenVR. SteamVR must be running. Runtime is found via SteamVR's installed copy — nothing to bundle.", + "通过 OpenVR 读取电池 + 充电 + 跟踪状态。SteamVR 必须在运行。运行时通过 SteamVR 的安装副本查找 — 无需打包。", + "อ่านแบตเตอรี่ + การชาร์จ + การติดตามผ่าน OpenVR ต้อง SteamVR ทำงาน รันไทม์ค้นหาผ่านสำเนาที่ติดตั้งของ SteamVR — ไม่ต้องบรรจุเอง", + "يقرأ البطارية + الشحن + التتبع عبر OpenVR. يجب تشغيل SteamVR. يتم العثور على وقت التشغيل من خلال نسخة SteamVR المثبتة — لا حاجة للتجميع." + ); 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( diff --git a/src/PSLauncher.Core/PSLauncher.Core.csproj b/src/PSLauncher.Core/PSLauncher.Core.csproj index 6c4f5b1..ce5231f 100644 --- a/src/PSLauncher.Core/PSLauncher.Core.csproj +++ b/src/PSLauncher.Core/PSLauncher.Core.csproj @@ -5,6 +5,8 @@ enable latest enable + + true