diff --git a/installer/PSLauncher.iss b/installer/PSLauncher.iss
index 006c22a..749ea10 100644
--- a/installer/PSLauncher.iss
+++ b/installer/PSLauncher.iss
@@ -11,7 +11,7 @@
#define MyAppName "PROSERVE Launcher"
#define MyAppShortName "PS_Launcher"
-#define MyAppVersion "0.21.0"
+#define MyAppVersion "0.22.0"
#define MyAppPublisher "ASTERION VR"
#define MyAppURL "https://asterionvr.com"
#define MyAppExeName "PS_Launcher.exe"
diff --git a/src/PSLauncher.App/PSLauncher.App.csproj b/src/PSLauncher.App/PSLauncher.App.csproj
index c378d1c..f68343e 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.21.0
- 0.21.0.0
- 0.21.0.0
+ 0.22.0
+ 0.22.0.0
+ 0.22.0.0
true
diff --git a/src/PSLauncher.App/ViewModels/MainViewModel.cs b/src/PSLauncher.App/ViewModels/MainViewModel.cs
index 2a4695b..04d1c8a 100644
--- a/src/PSLauncher.App/ViewModels/MainViewModel.cs
+++ b/src/PSLauncher.App/ViewModels/MainViewModel.cs
@@ -74,6 +74,14 @@ public sealed partial class MainViewModel : ObservableObject
public ObservableCollection HealthIndicators { get; } = new();
public bool HasHealthIndicators => HealthIndicators.Count > 0;
+ ///
+ /// Cancelle les boucles de polling per-check actuellement en vol. Recréée à
+ /// chaque appel à pour permettre un redémarrage
+ /// propre après édition des checks dans Settings (hot-reload sans relancer
+ /// l'app).
+ ///
+ private CancellationTokenSource? _healthLoopCts;
+
///
/// Le bandeau santé n'a de sens que sur la page Library — Reports et
/// Documentation sont des WebView pleine-page où afficher des pills
@@ -319,41 +327,53 @@ public sealed partial class MainViewModel : ObservableObject
}
///
- /// Lance une tâche en arrière-plan qui exécute tous les checks en parallèle,
- /// applique les résultats sur les VM via le dispatcher UI, puis attend
- /// RefreshIntervalSeconds avant de recommencer. La tâche tourne tant que
- /// l'app vit ; pas de cancel explicite (l'arrêt suit la fermeture du process).
+ /// Lance une boucle indépendante par check, chacune cadencée sur son propre
+ /// . 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).
///
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;
- var interval = TimeSpan.FromSeconds(Math.Max(2, _config.HealthChecks.RefreshIntervalSeconds));
- _ = Task.Run(async () =>
+
+ _healthLoopCts = new CancellationTokenSource();
+ var ct = _healthLoopCts.Token;
+
+ foreach (var vm in HealthIndicators.ToList())
{
- while (true)
+ 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 () =>
{
- try
+ while (!ct.IsCancellationRequested)
{
- var snapshot = HealthIndicators.ToList();
- var tasks = snapshot.Select(async vm =>
+ try
{
- try
- {
- var result = await _healthService.RunCheckAsync(vm.Entry, CancellationToken.None);
- await Application.Current.Dispatcher.InvokeAsync(() => vm.Apply(result));
- }
- catch (Exception ex)
- {
- _logger.LogDebug(ex, "Health check {Name} threw", vm.Entry.Name);
- }
- });
- await Task.WhenAll(tasks).ConfigureAwait(false);
+ 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; }
}
- catch (Exception ex) { _logger.LogDebug(ex, "Health loop tick"); }
- try { await Task.Delay(interval).ConfigureAwait(false); }
- catch { break; }
- }
- });
+ }, ct);
+ }
}
///
@@ -546,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));
}
}
diff --git a/src/PSLauncher.App/ViewModels/SettingsViewModel.cs b/src/PSLauncher.App/ViewModels/SettingsViewModel.cs
index e8ee19e..c05c0f6 100644
--- a/src/PSLauncher.App/ViewModels/SettingsViewModel.cs
+++ b/src/PSLauncher.App/ViewModels/SettingsViewModel.cs
@@ -85,6 +85,17 @@ public sealed partial class SettingsViewModel : ObservableObject
= new();
public bool HasDocBackups => DocBackups.Count > 0;
+ // ----- Health checks (bandeau de santé système) -----
+ ///
+ /// 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
+ /// _config.HealthChecks.Checks n'est écrasé qu'à ),
+ /// pour que Cancel ne laisse pas de modifs partielles dans la config.
+ ///
+ public System.Collections.ObjectModel.ObservableCollection HealthChecks { get; }
+ = new();
+ public bool HasHealthChecks => HealthChecks.Count > 0;
+
public IReadOnlyList AvailableLanguages { get; } =
PSLauncher.Core.Localization.Strings.Available
.Select(t => new LanguageOption(t.Code, t.Name))
@@ -193,6 +204,11 @@ public sealed partial class SettingsViewModel : ObservableObject
_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();
@@ -327,6 +343,11 @@ public sealed partial class SettingsViewModel : ObservableObject
_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);
@@ -633,6 +654,53 @@ public sealed partial class SettingsViewModel : ObservableObject
}
}
+ // ===================== 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()
{
@@ -697,6 +765,66 @@ public sealed partial class ReportBackupViewModel : ObservableObject
private Task Revert() => _revertHandler(Info);
}
+///
+/// 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 config.HealthChecks.Checks n'a lieu qu'au Save.
+///
+public sealed partial class HealthCheckRowViewModel : ObservableObject
+{
+ public HealthCheckEntry Entry { get; private set; }
+
+ /// Re-publié à chaque édition pour rafraîchir le label dans la liste.
+ [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 _onDelete;
+
+ public HealthCheckRowViewModel(HealthCheckEntry entry, Action 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);
+}
+
/// Variante doc — même structure que .
public sealed partial class DocBackupViewModel : ObservableObject
{
diff --git a/src/PSLauncher.App/Views/HealthCheckEditorDialog.xaml b/src/PSLauncher.App/Views/HealthCheckEditorDialog.xaml
new file mode 100644
index 0000000..4a2b3d1
--- /dev/null
+++ b/src/PSLauncher.App/Views/HealthCheckEditorDialog.xaml
@@ -0,0 +1,246 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/PSLauncher.App/Views/HealthCheckEditorDialog.xaml.cs b/src/PSLauncher.App/Views/HealthCheckEditorDialog.xaml.cs
new file mode 100644
index 0000000..bec216f
--- /dev/null
+++ b/src/PSLauncher.App/Views/HealthCheckEditorDialog.xaml.cs
@@ -0,0 +1,169 @@
+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;
+
+///
+/// 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.
+///
+public partial class HealthCheckEditorDialog : Window
+{
+ /// Emojis proposés dans le picker. Listés du plus probable au moins probable.
+ private static readonly string[] CuratedIcons =
+ {
+ "🎮", "🥽", "📡", "🌐", "📶", "🔌", "💻", "🖥",
+ "📷", "🎧", "🛡", "🔥", "⚙", "🚀", "🔧", "📦",
+ "🎯", "🏃", "🚨", "🔋", "💾", "🔍", "📊", "🖱",
+ "⌨", "🎚", "🟢", "🔵",
+ };
+
+ public HealthCheckEntry Entry { get; }
+ public bool Saved { get; private set; }
+
+ private readonly ObservableCollection _icons = new();
+
+ public HealthCheckEditorDialog() : this(new HealthCheckEntry
+ {
+ Name = "",
+ Icon = "🔌",
+ Kind = "Process",
+ Target = "",
+ RefreshIntervalMs = 5000,
+ }, isNew: true)
+ {
+ }
+
+ 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();
+
+ var isPing = entry.Kind?.Equals("ping", System.StringComparison.OrdinalIgnoreCase) == true;
+ KindProcess.IsChecked = !isPing;
+ KindPing.IsChecked = isPing;
+ UpdateKindUi(isPing);
+
+ 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(bool isPing)
+ {
+ if (isPing)
+ {
+ 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;
+ }
+ }
+
+ private void OnKindChanged(object sender, RoutedEventArgs e)
+ {
+ if (TargetLabel is null) return; // pendant l'init avant que tout soit chargé
+ UpdateKindUi(KindPing.IsChecked == true);
+ }
+
+ 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" : "Process";
+ Entry.Target = 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;
+ }
+
+ /// Élément du picker d'icônes — emoji + état sélectionné pour le data trigger.
+ public sealed partial class IconOption : ObservableObject
+ {
+ public string Emoji { get; }
+
+ [ObservableProperty]
+ private bool _isSelected;
+
+ public IconOption(string emoji) { Emoji = emoji; }
+ }
+}
diff --git a/src/PSLauncher.App/Views/SettingsDialog.xaml b/src/PSLauncher.App/Views/SettingsDialog.xaml
index 250cc22..561842c 100644
--- a/src/PSLauncher.App/Views/SettingsDialog.xaml
+++ b/src/PSLauncher.App/Views/SettingsDialog.xaml
@@ -648,6 +648,87 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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 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",
diff --git a/src/PSLauncher.Models/LocalConfig.cs b/src/PSLauncher.Models/LocalConfig.cs
index df9b503..b528108 100644
--- a/src/PSLauncher.Models/LocalConfig.cs
+++ b/src/PSLauncher.Models/LocalConfig.cs
@@ -208,6 +208,14 @@ public sealed class HealthCheckEntry
///
public string Target { get; set; } = "";
+ ///
+ /// 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.
+ ///
+ public int RefreshIntervalMs { get; set; } = 5000;
+
/// Pour Ping : RTT au-dessus duquel le statut passe en Warning (Orange). Default 50 ms.
public int? PingWarnMs { get; set; } = 50;