Fixes applied (14 of 15 findings — see note on R5)
Concurrency crashes - H1 HardwareMonitorService.RebuildTree now builds the tree under _updateLock, so it can't enumerate a hardware's _active HashSet while the update loop mutates it. - A1 AppSettings now guards every dictionary read/write (and snapshots in Save) with a lock — safe under concurrent access from the parallel discovery threads. - R2 Plumbed that same lock (HardwareMonitorService.SensorReadLock) into RemoteWebServer and wrapped the Prometheus sensor.Values enumeration with it. - L1 Computer — refactored Add into AddCore, which performs the cancellation/enabled re-check and the _groups insertion atomically under _lock. A deferred task can no longer add (and leak) a group after Close() drained the list; if it loses the race it closes the group instead. - M1 UpdateTimer_Tick now bails before/after the await when _isShuttingDown is set in MainWindow_Closed, so an in-flight tick won't touch the disposed view-model/Computer. Broken behavior - T1 Tray callback now decodes NOTIFYICON_VERSION_4 correctly (message = LOWORD(lParam), icon id = HIWORD(lParam)) — right-click menu and double-click work again. - M2 A transient update exception no longer calls _timer.Stop(); the loop keeps running. - H2 Newly discovered (deferred) storage devices get the current ForceDriveWakeup setting applied in HardwareChanged. - V2 Sensor items carry a parent reference; toggling IsVisible recomputes the parent group's visibility, so no empty group headers. (Strengthened the existing test that had skipped this assertion.) - H3 Tree-rebuild coalescing now uses a dirty flag with a re-check, so a change arriving during a rebuild isn't lost. - R4 Web routing matches endpoints exactly on the query-stripped path (Url.AbsolutePath), so static assets like metrics.html aren't hijacked. - L8 IntelCpu.Update skips the bus/core-clock math while TimeStampCounterFrequency is still 0 (deferred-TSC window), so clocks keep their prior value instead of reporting 0 MHz. - V1 Existing plot series keep their assigned color; only an explicit user pen color updates them (no per-tick color shifting). - M3 Runtime errors write to a dedicated runtime.log (once), instead of overwriting the shared startup.log. - T2 CreateSensorIcon returns IntPtr.Zero on DIB failure instead of the shared main-icon handle (which callers DestroyIcon). I also set _isOpen = false in HardwareMonitorService.Dispose so the rebuild guard actually holds during shutdown (the latent after-close-rebuild issue adjacent to H3
This commit is contained in:
@@ -70,6 +70,8 @@ public sealed class MainWindow : Window
|
||||
private bool _isUpdating;
|
||||
private bool _initialWindowStateApplied;
|
||||
private bool _isClosingForExit;
|
||||
private bool _isShuttingDown;
|
||||
private bool _runtimeErrorLogged;
|
||||
private bool _isMainWindowHidden;
|
||||
private bool _isMonitoringStarted;
|
||||
private bool _startupCompletionRequested;
|
||||
@@ -635,13 +637,18 @@ public sealed class MainWindow : Window
|
||||
|
||||
private async void UpdateTimer_Tick(DispatcherQueueTimer sender, object args)
|
||||
{
|
||||
if (_isUpdating)
|
||||
if (_isUpdating || _isShuttingDown)
|
||||
return;
|
||||
|
||||
_isUpdating = true;
|
||||
try
|
||||
{
|
||||
await ViewModel.UpdateAsync();
|
||||
|
||||
// The window may have been closed (and the view model/computer disposed) while we awaited the update.
|
||||
if (_isShuttingDown)
|
||||
return;
|
||||
|
||||
UpdateSensorColumnWidths();
|
||||
_trayIconService.Update();
|
||||
_gadgetWindow?.UpdateSensors(ViewModel.GetGadgetSensorItems());
|
||||
@@ -649,7 +656,7 @@ public sealed class MainWindow : Window
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_timer.Stop();
|
||||
// A transient update failure must not permanently freeze the UI: keep the timer running and just record it.
|
||||
RecordRuntimeError("Sensor update failed", ex);
|
||||
}
|
||||
finally
|
||||
@@ -662,8 +669,24 @@ public sealed class MainWindow : Window
|
||||
{
|
||||
_startupTrace?.Mark("MainWindow.RuntimeError", $"{message}: {exception.GetType().FullName}: {exception.Message}");
|
||||
_startupTrace?.Flush();
|
||||
string logPath = IOPath.Combine(AppContext.BaseDirectory, "LibreHardwareMonitor.Windows.WinUI.startup.log");
|
||||
File.WriteAllText(logPath, exception.ToString());
|
||||
|
||||
// Use a dedicated runtime log (not the startup log that App.xaml.cs appends to) and write it only once, so a
|
||||
// recurring per-tick failure neither clobbers the startup diagnostics nor grows the file without bound.
|
||||
string logPath = IOPath.Combine(AppContext.BaseDirectory, "LibreHardwareMonitor.Windows.WinUI.runtime.log");
|
||||
if (!_runtimeErrorLogged)
|
||||
{
|
||||
try
|
||||
{
|
||||
File.WriteAllText(logPath, exception.ToString());
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Never let diagnostic logging throw out of the update loop.
|
||||
}
|
||||
|
||||
_runtimeErrorLogged = true;
|
||||
}
|
||||
|
||||
ViewModel.SetStatusText($"{message}. See {IOPath.GetFileName(logPath)}.");
|
||||
}
|
||||
|
||||
@@ -682,6 +705,7 @@ public sealed class MainWindow : Window
|
||||
return;
|
||||
}
|
||||
|
||||
_isShuttingDown = true;
|
||||
_timer.Stop();
|
||||
_deviceColumnWidthSettleTimer.Stop();
|
||||
_trayIconService.Dispose();
|
||||
|
||||
@@ -15,7 +15,10 @@ namespace LibreHardwareMonitor.Windows.WinUI.Services;
|
||||
|
||||
public sealed class AppSettings : ISettings
|
||||
{
|
||||
private readonly IDictionary<string, string> _settings = new Dictionary<string, string>();
|
||||
// ISettings is read and written concurrently from background hardware-discovery threads (deferred group/sensor
|
||||
// construction) as well as the UI thread, so every access to the backing dictionary is guarded by _lock.
|
||||
private readonly Dictionary<string, string> _settings = new();
|
||||
private readonly object _lock = new();
|
||||
|
||||
private AppSettings(string fileName)
|
||||
{
|
||||
@@ -35,91 +38,107 @@ public sealed class AppSettings : ISettings
|
||||
|
||||
public bool Contains(string name)
|
||||
{
|
||||
return _settings.ContainsKey(name);
|
||||
lock (_lock)
|
||||
return _settings.ContainsKey(name);
|
||||
}
|
||||
|
||||
public string GetValue(string name, string value)
|
||||
{
|
||||
return _settings.TryGetValue(name, out string? result) ? result : value;
|
||||
lock (_lock)
|
||||
return _settings.TryGetValue(name, out string? result) ? result : value;
|
||||
}
|
||||
|
||||
public int GetValue(string name, int value)
|
||||
{
|
||||
return _settings.TryGetValue(name, out string? result) && int.TryParse(result, out int parsedValue)
|
||||
? parsedValue
|
||||
: value;
|
||||
lock (_lock)
|
||||
return _settings.TryGetValue(name, out string? result) && int.TryParse(result, out int parsedValue)
|
||||
? parsedValue
|
||||
: value;
|
||||
}
|
||||
|
||||
public float GetValue(string name, float value)
|
||||
{
|
||||
return _settings.TryGetValue(name, out string? result)
|
||||
&& float.TryParse(result, NumberStyles.Float, CultureInfo.InvariantCulture, out float parsedValue)
|
||||
? parsedValue
|
||||
: value;
|
||||
lock (_lock)
|
||||
return _settings.TryGetValue(name, out string? result)
|
||||
&& float.TryParse(result, NumberStyles.Float, CultureInfo.InvariantCulture, out float parsedValue)
|
||||
? parsedValue
|
||||
: value;
|
||||
}
|
||||
|
||||
public double GetValue(string name, double value)
|
||||
{
|
||||
return _settings.TryGetValue(name, out string? result)
|
||||
&& double.TryParse(result, NumberStyles.Float, CultureInfo.InvariantCulture, out double parsedValue)
|
||||
? parsedValue
|
||||
: value;
|
||||
lock (_lock)
|
||||
return _settings.TryGetValue(name, out string? result)
|
||||
&& double.TryParse(result, NumberStyles.Float, CultureInfo.InvariantCulture, out double parsedValue)
|
||||
? parsedValue
|
||||
: value;
|
||||
}
|
||||
|
||||
public bool GetValue(string name, bool value)
|
||||
{
|
||||
return _settings.TryGetValue(name, out string? result) ? result == "true" : value;
|
||||
lock (_lock)
|
||||
return _settings.TryGetValue(name, out string? result) ? result == "true" : value;
|
||||
}
|
||||
|
||||
public Color GetValue(string name, Color value)
|
||||
{
|
||||
if (_settings.TryGetValue(name, out string? result)
|
||||
&& uint.TryParse(result, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out uint parsedValue))
|
||||
lock (_lock)
|
||||
{
|
||||
return Color.FromArgb(
|
||||
(byte)((parsedValue >> 24) & 0xff),
|
||||
(byte)((parsedValue >> 16) & 0xff),
|
||||
(byte)((parsedValue >> 8) & 0xff),
|
||||
(byte)(parsedValue & 0xff));
|
||||
}
|
||||
if (_settings.TryGetValue(name, out string? result)
|
||||
&& uint.TryParse(result, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out uint parsedValue))
|
||||
{
|
||||
return Color.FromArgb(
|
||||
(byte)((parsedValue >> 24) & 0xff),
|
||||
(byte)((parsedValue >> 16) & 0xff),
|
||||
(byte)((parsedValue >> 8) & 0xff),
|
||||
(byte)(parsedValue & 0xff));
|
||||
}
|
||||
|
||||
return value;
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
public void SetValue(string name, string value)
|
||||
{
|
||||
_settings[name] = value;
|
||||
lock (_lock)
|
||||
_settings[name] = value;
|
||||
}
|
||||
|
||||
public void SetValue(string name, int value)
|
||||
{
|
||||
_settings[name] = value.ToString(CultureInfo.InvariantCulture);
|
||||
lock (_lock)
|
||||
_settings[name] = value.ToString(CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
public void SetValue(string name, float value)
|
||||
{
|
||||
_settings[name] = value.ToString(CultureInfo.InvariantCulture);
|
||||
lock (_lock)
|
||||
_settings[name] = value.ToString(CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
public void SetValue(string name, double value)
|
||||
{
|
||||
_settings[name] = value.ToString(CultureInfo.InvariantCulture);
|
||||
lock (_lock)
|
||||
_settings[name] = value.ToString(CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
public void SetValue(string name, bool value)
|
||||
{
|
||||
_settings[name] = value ? "true" : "false";
|
||||
lock (_lock)
|
||||
_settings[name] = value ? "true" : "false";
|
||||
}
|
||||
|
||||
public void SetValue(string name, Color value)
|
||||
{
|
||||
uint argb = (uint)((value.A << 24) | (value.R << 16) | (value.G << 8) | value.B);
|
||||
_settings[name] = argb.ToString("X8", CultureInfo.InvariantCulture);
|
||||
lock (_lock)
|
||||
_settings[name] = argb.ToString("X8", CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
public void Remove(string name)
|
||||
{
|
||||
_settings.Remove(name);
|
||||
lock (_lock)
|
||||
_settings.Remove(name);
|
||||
}
|
||||
|
||||
public void Load()
|
||||
@@ -149,7 +168,10 @@ public sealed class AppSettings : ISettings
|
||||
XmlAttribute? keyAttribute = child.Attributes?["key"];
|
||||
XmlAttribute? valueAttribute = child.Attributes?["value"];
|
||||
if (keyAttribute?.Value != null && valueAttribute?.Value != null)
|
||||
_settings[keyAttribute.Value] = valueAttribute.Value;
|
||||
{
|
||||
lock (_lock)
|
||||
_settings[keyAttribute.Value] = valueAttribute.Value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -165,7 +187,11 @@ public sealed class AppSettings : ISettings
|
||||
XmlElement appSettings = doc.CreateElement("appSettings");
|
||||
configuration.AppendChild(appSettings);
|
||||
|
||||
foreach (KeyValuePair<string, string> setting in _settings)
|
||||
List<KeyValuePair<string, string>> snapshot;
|
||||
lock (_lock)
|
||||
snapshot = new List<KeyValuePair<string, string>>(_settings);
|
||||
|
||||
foreach (KeyValuePair<string, string> setting in snapshot)
|
||||
{
|
||||
XmlElement add = doc.CreateElement("add");
|
||||
add.SetAttribute("key", setting.Key);
|
||||
|
||||
@@ -29,6 +29,7 @@ public sealed class HardwareMonitorService : IDisposable
|
||||
private readonly UpdateVisitor _updateVisitor = new();
|
||||
private bool _isOpen;
|
||||
private int _treeRebuildQueued;
|
||||
private volatile bool _treeRebuildDirty;
|
||||
|
||||
public HardwareMonitorService(AppSettings settings)
|
||||
{
|
||||
@@ -44,6 +45,12 @@ public sealed class HardwareMonitorService : IDisposable
|
||||
|
||||
public Computer Computer { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Lock guarding access to live sensor collections (values and active-sensor sets). Held by <see cref="UpdateAsync" />
|
||||
/// while sensors are updated; other readers (tree rebuild, the remote web server) take it to read consistently.
|
||||
/// </summary>
|
||||
public object SensorReadLock => _updateLock;
|
||||
|
||||
public AppSettings Settings { get; }
|
||||
|
||||
public SensorTreeItemViewModel Root { get; private set; } = SensorTreeItemViewModel.CreateRoot(Environment.MachineName);
|
||||
@@ -177,8 +184,14 @@ public sealed class HardwareMonitorService : IDisposable
|
||||
public void RebuildTree(bool raiseTreeRebuilt = true)
|
||||
{
|
||||
SensorTreeItemViewModel root = SensorTreeItemViewModel.CreateRoot(Environment.MachineName);
|
||||
foreach (IHardware hardware in Computer.Hardware.OrderBy(hardware => hardware.HardwareType).ThenBy(hardware => hardware.Name, StringComparer.OrdinalIgnoreCase))
|
||||
root.Children.Add(SensorTreeItemViewModel.FromHardware(hardware, Settings));
|
||||
|
||||
// Building the tree reads each hardware's live sensor set (a HashSet). Hold the update lock so it cannot run
|
||||
// concurrently with UpdateAsync()'s Computer.Accept, which mutates that set via ActivateSensor on another thread.
|
||||
lock (_updateLock)
|
||||
{
|
||||
foreach (IHardware hardware in Computer.Hardware.OrderBy(hardware => hardware.HardwareType).ThenBy(hardware => hardware.Name, StringComparer.OrdinalIgnoreCase))
|
||||
root.Children.Add(SensorTreeItemViewModel.FromHardware(hardware, Settings));
|
||||
}
|
||||
|
||||
Root = root;
|
||||
if (raiseTreeRebuilt)
|
||||
@@ -187,6 +200,9 @@ public sealed class HardwareMonitorService : IDisposable
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
// Clear before closing so any tree-rebuild task still pending its delay bails instead of rebuilding from a
|
||||
// half-closed Computer.
|
||||
_isOpen = false;
|
||||
Computer.HardwareAdded -= HardwareChanged;
|
||||
Computer.HardwareRemoved -= HardwareChanged;
|
||||
Computer.Close();
|
||||
@@ -247,12 +263,18 @@ public sealed class HardwareMonitorService : IDisposable
|
||||
|
||||
private void HardwareChanged(IHardware hardware)
|
||||
{
|
||||
// Storage discovery is deferred, so drives usually appear after ForceDriveWakeup was applied at startup (against
|
||||
// an empty hardware list). Apply the current setting to each newly discovered drive so it actually takes effect.
|
||||
if (hardware is StorageDevice storageDevice)
|
||||
storageDevice.ForceWakeup = Settings.GetValue("forceDriveWakeupItem", false);
|
||||
|
||||
if (_isOpen)
|
||||
QueueTreeRebuild();
|
||||
}
|
||||
|
||||
private void QueueTreeRebuild()
|
||||
{
|
||||
_treeRebuildDirty = true;
|
||||
if (Interlocked.Exchange(ref _treeRebuildQueued, 1) == 1)
|
||||
return;
|
||||
|
||||
@@ -262,8 +284,14 @@ public sealed class HardwareMonitorService : IDisposable
|
||||
{
|
||||
await Task.Delay(100).ConfigureAwait(false);
|
||||
|
||||
if (_isOpen)
|
||||
RebuildTree();
|
||||
// Rebuild until no change has arrived since the last rebuild began, so a HardwareAdded/Removed that lands
|
||||
// while a rebuild is in flight is not coalesced away and lost.
|
||||
while (_treeRebuildDirty)
|
||||
{
|
||||
_treeRebuildDirty = false;
|
||||
if (_isOpen)
|
||||
RebuildTree();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -272,6 +300,10 @@ public sealed class HardwareMonitorService : IDisposable
|
||||
finally
|
||||
{
|
||||
Interlocked.Exchange(ref _treeRebuildQueued, 0);
|
||||
|
||||
// A change may have slipped in between the loop's exit and clearing the flag; re-queue so it is honored.
|
||||
if (_treeRebuildDirty)
|
||||
QueueTreeRebuild();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ namespace LibreHardwareMonitor.Windows.WinUI.Services;
|
||||
public sealed class RemoteWebServer : IDisposable
|
||||
{
|
||||
private readonly IComputer _computer;
|
||||
private readonly object _sensorReadLock;
|
||||
private readonly Func<SensorTreeItemViewModel?> _rootProvider;
|
||||
private readonly Version _version = typeof(RemoteWebServer).Assembly.GetName().Version ?? new Version(0, 0);
|
||||
private CancellationTokenSource? _cts;
|
||||
@@ -34,6 +35,7 @@ public sealed class RemoteWebServer : IDisposable
|
||||
public RemoteWebServer(
|
||||
Func<SensorTreeItemViewModel?> rootProvider,
|
||||
IComputer computer,
|
||||
object sensorReadLock,
|
||||
string listenerIp,
|
||||
int listenerPort,
|
||||
bool authEnabled,
|
||||
@@ -42,6 +44,7 @@ public sealed class RemoteWebServer : IDisposable
|
||||
{
|
||||
_rootProvider = rootProvider;
|
||||
_computer = computer;
|
||||
_sensorReadLock = sensorReadLock;
|
||||
ListenerIp = listenerIp;
|
||||
ListenerPort = listenerPort;
|
||||
AuthEnabled = authEnabled;
|
||||
@@ -196,7 +199,10 @@ public sealed class RemoteWebServer : IDisposable
|
||||
return;
|
||||
}
|
||||
|
||||
string requestedFile = request.RawUrl?.TrimStart('/') ?? "";
|
||||
// Match on the path component only (AbsolutePath excludes the query string) and compare endpoints exactly.
|
||||
// Prefix matching previously let any static asset whose name starts with an endpoint name (e.g.
|
||||
// "metrics.html", "sensor-icons.css") be hijacked by the API handlers.
|
||||
string requestedFile = (request.Url?.AbsolutePath ?? request.RawUrl ?? "").TrimStart('/');
|
||||
if (requestedFile.Length == 0)
|
||||
requestedFile = "index.html";
|
||||
|
||||
@@ -206,13 +212,13 @@ public sealed class RemoteWebServer : IDisposable
|
||||
return;
|
||||
}
|
||||
|
||||
if (requestedFile.StartsWith("metrics", StringComparison.OrdinalIgnoreCase))
|
||||
if (requestedFile.Equals("metrics", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
await SendPrometheusAsync(context.Response, request);
|
||||
return;
|
||||
}
|
||||
|
||||
if (requestedFile.StartsWith("Sensor", StringComparison.OrdinalIgnoreCase))
|
||||
if (requestedFile.Equals("Sensor", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
Dictionary<string, object?> result = [];
|
||||
HandleSensorRequest(request, result);
|
||||
@@ -220,7 +226,7 @@ public sealed class RemoteWebServer : IDisposable
|
||||
return;
|
||||
}
|
||||
|
||||
if (requestedFile.StartsWith("ResetAllMinMax", StringComparison.OrdinalIgnoreCase))
|
||||
if (requestedFile.Equals("ResetAllMinMax", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_computer.Accept(new SensorVisitor(sensor =>
|
||||
{
|
||||
@@ -427,7 +433,12 @@ public sealed class RemoteWebServer : IDisposable
|
||||
private async Task SendPrometheusAsync(HttpListenerResponse response, HttpListenerRequest request)
|
||||
{
|
||||
Dictionary<string, int> settings = ParsePrometheusSettings(request);
|
||||
string content = GeneratePrometheusResponse(_rootProvider(), settings);
|
||||
|
||||
// Serialize against the sensor update loop: GeneratePrometheusResponse enumerates each sensor's live Values ring
|
||||
// buffer, which the update thread mutates concurrently (an unsynchronized enumeration would throw).
|
||||
string content;
|
||||
lock (_sensorReadLock)
|
||||
content = GeneratePrometheusResponse(_rootProvider(), settings);
|
||||
|
||||
response.AddHeader("Cache-Control", "no-cache");
|
||||
response.AddHeader("Access-Control-Allow-Origin", "*");
|
||||
|
||||
@@ -202,7 +202,14 @@ public sealed class TrayIconService : IDisposable
|
||||
private IntPtr WindowSubclassProc(IntPtr hWnd, uint message, IntPtr wParam, IntPtr lParam, nuint subclassId, nuint refData)
|
||||
{
|
||||
if (message == CallbackMessage)
|
||||
HandleTrayCallback((uint)wParam.ToInt64(), (uint)lParam.ToInt64());
|
||||
{
|
||||
// With NOTIFYICON_VERSION_4 the event is packed into lParam: LOWORD = the mouse/keyboard message,
|
||||
// HIWORD = the icon id. (wParam carries the cursor coordinates, which we don't use.)
|
||||
uint packed = unchecked((uint)lParam.ToInt64());
|
||||
uint mouseMessage = packed & 0xFFFF;
|
||||
uint iconId = (packed >> 16) & 0xFFFF;
|
||||
HandleTrayCallback(iconId, mouseMessage);
|
||||
}
|
||||
|
||||
return DefSubclassProc(hWnd, message, wParam, lParam);
|
||||
}
|
||||
@@ -360,7 +367,7 @@ public sealed class TrayIconService : IDisposable
|
||||
BitmapInfo bitmapInfo = BitmapInfo.Create(IconSize, IconSize);
|
||||
bitmap = CreateDIBSection(hdc, ref bitmapInfo, 0, out _, IntPtr.Zero, 0);
|
||||
if (bitmap == IntPtr.Zero)
|
||||
return _mainIconHandle;
|
||||
return IntPtr.Zero; // never return the shared main icon: callers DestroyIcon() the returned handle
|
||||
|
||||
oldBitmap = SelectObject(memoryDc, bitmap);
|
||||
WinUIColor color = GetSensorTrayColor(sensor);
|
||||
|
||||
@@ -116,6 +116,7 @@ public sealed class MainWindowViewModel : ViewModelBase, IDisposable
|
||||
_remoteWebServer = new RemoteWebServer(
|
||||
() => RootItems.FirstOrDefault(),
|
||||
_hardwareMonitor.Computer,
|
||||
_hardwareMonitor.SensorReadLock,
|
||||
settings.GetValue("listenerIp", "?"),
|
||||
settings.GetValue("listenerPort", 8085),
|
||||
settings.GetValue("authenticationEnabled", false),
|
||||
@@ -958,9 +959,11 @@ public sealed class MainWindowViewModel : ViewModelBase, IDisposable
|
||||
_plotSeriesByIdentifier[identifier] = series;
|
||||
PlotSeries.Add(series);
|
||||
}
|
||||
else
|
||||
else if (sensorItem.PenColor.HasValue)
|
||||
{
|
||||
series.Color = GetPlotColor(sensorItem);
|
||||
// Only honor an explicit user pen color for an existing series; keep the auto-assigned color stable.
|
||||
// (Recomputing it from the live series count made existing lines shift/collide colors every tick.)
|
||||
series.Color = sensorItem.PenColor.Value;
|
||||
}
|
||||
|
||||
series.Points.Add(new PlotPointViewModel(now, value.Value));
|
||||
|
||||
@@ -18,6 +18,7 @@ namespace LibreHardwareMonitor.Windows.WinUI.ViewModels;
|
||||
public sealed class SensorTreeItemViewModel : ViewModelBase
|
||||
{
|
||||
private readonly AppSettings? _settings;
|
||||
private SensorTreeItemViewModel? _parent;
|
||||
private string? _expandedSettingName;
|
||||
private bool _isExpanded = true;
|
||||
private bool _isVisible = true;
|
||||
@@ -68,6 +69,10 @@ public sealed class SensorTreeItemViewModel : ViewModelBase
|
||||
_settings?.SetValue(new Identifier(Sensor.Identifier, "hidden").ToString(), !value);
|
||||
|
||||
UpdateVisibilityState();
|
||||
|
||||
// A SensorType group's visibility depends on whether any child is visible, so recompute the parent too;
|
||||
// otherwise hiding the last visible sensor leaves an empty group header (and the reverse on show).
|
||||
_parent?.UpdateVisibilityState();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -320,7 +325,11 @@ public sealed class SensorTreeItemViewModel : ViewModelBase
|
||||
};
|
||||
|
||||
foreach (ISensor sensor in sensors)
|
||||
item.Children.Add(FromSensor(sensor, settings));
|
||||
{
|
||||
SensorTreeItemViewModel child = FromSensor(sensor, settings);
|
||||
child._parent = item;
|
||||
item.Children.Add(child);
|
||||
}
|
||||
|
||||
item.UpdateVisibilityState();
|
||||
return item;
|
||||
|
||||
Reference in New Issue
Block a user