diff --git a/CHANGELOG.md b/CHANGELOG.md index 2701597f..dd9838c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,22 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +## [0.52.0] - 2026-05-13 + +### Fixed +- **Resource leak: BatteryService** — dispose WMI ManagementObject instances + in foreach loops to prevent COM RCW accumulation (LEAK-001, partial). +- **Resource leak: ShortcutCleanerService** — remove double ReleaseComObject + on same COM interface to prevent undefined behavior (LEAK-002). +- **Resource leak: UninstallerViewModel** — store LineReceived handler in field + and unsubscribe in Dispose to prevent memory leak (LEAK-004). +- **Bug: WindowsFeaturesViewModel** — call NotifyCanExecuteChanged on + ToggleFeatureCommand when IsBusy changes to prevent double-clicks (BUG-001). +- **Thread safety: ProcessStatusToBrushConverter** — freeze static brushes to + prevent cross-thread InvalidOperationException (THR-001, partial). +- **Performance: BoolToElevationBadgeBrushConverter** — pre-create static frozen + brush instances instead of allocating per Convert call (PERF-001, partial). + ## [0.51.0] - 2026-05-13 ### Fixed diff --git a/SysManager/SysManager/Helpers/OutputKindToBrushConverter.cs b/SysManager/SysManager/Helpers/OutputKindToBrushConverter.cs index b3d3ac68..c58cfe9f 100644 --- a/SysManager/SysManager/Helpers/OutputKindToBrushConverter.cs +++ b/SysManager/SysManager/Helpers/OutputKindToBrushConverter.cs @@ -73,9 +73,13 @@ public object ConvertBack(object value, Type targetType, object parameter, Cultu public class BoolToElevationBadgeBrushConverter : IValueConverter { + private static readonly Brush ElevatedBrush = Freeze(new SolidColorBrush(Color.FromRgb(0x4C, 0xAF, 0x50))); + private static readonly Brush NotElevatedBrush = Freeze(new SolidColorBrush(Color.FromRgb(0x9E, 0x9E, 0x9E))); + + private static SolidColorBrush Freeze(SolidColorBrush b) { b.Freeze(); return b; } + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) - => (value is bool b && b) ? (Brush)new SolidColorBrush(Color.FromRgb(0x4C, 0xAF, 0x50)) - : new SolidColorBrush(Color.FromRgb(0x9E, 0x9E, 0x9E)); + => (value is bool b && b) ? ElevatedBrush : NotElevatedBrush; public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) => throw new NotSupportedException(); } @@ -102,8 +106,15 @@ public object ConvertBack(object value, Type targetType, object parameter, Cultu /// public class ProcessStatusToBrushConverter : IValueConverter { - private static readonly Brush RunningBrush = new SolidColorBrush(Color.FromRgb(0x22, 0xC5, 0x5E)); - private static readonly Brush NotRespondingBrush = new SolidColorBrush(Color.FromRgb(0xEF, 0x44, 0x44)); + private static readonly Brush RunningBrush = CreateFrozen(Color.FromRgb(0x22, 0xC5, 0x5E)); + private static readonly Brush NotRespondingBrush = CreateFrozen(Color.FromRgb(0xEF, 0x44, 0x44)); + + private static SolidColorBrush CreateFrozen(Color c) + { + var b = new SolidColorBrush(c); + b.Freeze(); + return b; + } public object Convert(object value, Type targetType, object parameter, CultureInfo culture) { diff --git a/SysManager/SysManager/Services/BatteryService.cs b/SysManager/SysManager/Services/BatteryService.cs index 46e5fe40..6230a05a 100644 --- a/SysManager/SysManager/Services/BatteryService.cs +++ b/SysManager/SysManager/Services/BatteryService.cs @@ -28,19 +28,22 @@ internal static BatteryInfo GetBatteryInfo() foreach (ManagementObject obj in results) { - info.HasBattery = true; - info.Name = obj["Name"]?.ToString() ?? ""; - info.Manufacturer = obj["DeviceID"]?.ToString() ?? ""; - info.ChargePercent = Convert.ToInt32(obj["EstimatedChargeRemaining"] ?? 0); - info.Chemistry = MapChemistry(Convert.ToUInt16(obj["Chemistry"] ?? 0)); - - var statusCode = Convert.ToUInt16(obj["BatteryStatus"] ?? 0); - info.Status = MapBatteryStatus(statusCode); - - var runtime = Convert.ToInt64(obj["EstimatedRunTime"] ?? 0); - info.EstimatedRuntimeMinutes = runtime >= 71_582_788 ? -1 : (int)runtime; - - break; // first battery only + using (obj) + { + info.HasBattery = true; + info.Name = obj["Name"]?.ToString() ?? ""; + info.Manufacturer = obj["DeviceID"]?.ToString() ?? ""; + info.ChargePercent = Convert.ToInt32(obj["EstimatedChargeRemaining"] ?? 0); + info.Chemistry = MapChemistry(Convert.ToUInt16(obj["Chemistry"] ?? 0)); + + var statusCode = Convert.ToUInt16(obj["BatteryStatus"] ?? 0); + info.Status = MapBatteryStatus(statusCode); + + var runtime = Convert.ToInt64(obj["EstimatedRunTime"] ?? 0); + info.EstimatedRuntimeMinutes = runtime >= 71_582_788 ? -1 : (int)runtime; + + break; // first battery only + } } } catch (ManagementException) { /* WMI class not available */ } @@ -61,8 +64,11 @@ internal static BatteryInfo GetBatteryInfo() foreach (ManagementObject obj in results) { - info.DesignCapacityMWh = Convert.ToUInt32(obj["DesignedCapacity"] ?? 0); - break; + using (obj) + { + info.DesignCapacityMWh = Convert.ToUInt32(obj["DesignedCapacity"] ?? 0); + break; + } } } catch (ManagementException) { /* WMI class not present on this device */ } @@ -77,8 +83,11 @@ internal static BatteryInfo GetBatteryInfo() foreach (ManagementObject obj in results) { - info.FullChargeCapacityMWh = Convert.ToUInt32(obj["FullChargedCapacity"] ?? 0); - break; + using (obj) + { + info.FullChargeCapacityMWh = Convert.ToUInt32(obj["FullChargedCapacity"] ?? 0); + break; + } } } catch (ManagementException) { /* WMI class not present on this device */ } @@ -93,8 +102,11 @@ internal static BatteryInfo GetBatteryInfo() foreach (ManagementObject obj in results) { - info.CycleCount = Convert.ToInt32(obj["CycleCount"] ?? 0); - break; + using (obj) + { + info.CycleCount = Convert.ToInt32(obj["CycleCount"] ?? 0); + break; + } } } catch (ManagementException) { /* WMI class not present on this device */ } diff --git a/SysManager/SysManager/Services/ShortcutCleanerService.cs b/SysManager/SysManager/Services/ShortcutCleanerService.cs index 07c6a2de..8130ef49 100644 --- a/SysManager/SysManager/Services/ShortcutCleanerService.cs +++ b/SysManager/SysManager/Services/ShortcutCleanerService.cs @@ -150,7 +150,9 @@ private static string ResolveShortcutTarget(string lnkPath) } finally { - System.Runtime.InteropServices.Marshal.ReleaseComObject(file); + // LEAK-002: Only release the original COM object once. IPersistFile + // is the same underlying COM object (QueryInterface), so releasing + // both would double-decrement the ref count. System.Runtime.InteropServices.Marshal.ReleaseComObject(link); } } diff --git a/SysManager/SysManager/ViewModels/UninstallerViewModel.cs b/SysManager/SysManager/ViewModels/UninstallerViewModel.cs index 9180a563..6e3d1ab2 100644 --- a/SysManager/SysManager/ViewModels/UninstallerViewModel.cs +++ b/SysManager/SysManager/ViewModels/UninstallerViewModel.cs @@ -17,6 +17,7 @@ namespace SysManager.ViewModels; public partial class UninstallerViewModel : ViewModelBase { private readonly UninstallerService _service; + private readonly Action _lineHandler; private CancellationTokenSource? _cts; public ObservableCollection AllApps { get; } = new(); @@ -32,7 +33,8 @@ public partial class UninstallerViewModel : ViewModelBase public UninstallerViewModel(PowerShellRunner runner) { _service = new UninstallerService(runner); - _service.LineReceived += line => Console.Append(line); + _lineHandler = line => Console.Append(line); + _service.LineReceived += _lineHandler; } [RelayCommand] @@ -154,6 +156,7 @@ protected override void Dispose(bool disposing) { if (disposing) { + _service.LineReceived -= _lineHandler; _cts?.Dispose(); } base.Dispose(disposing); diff --git a/SysManager/SysManager/ViewModels/WindowsFeaturesViewModel.cs b/SysManager/SysManager/ViewModels/WindowsFeaturesViewModel.cs index 7cd5e581..a9bb6a92 100644 --- a/SysManager/SysManager/ViewModels/WindowsFeaturesViewModel.cs +++ b/SysManager/SysManager/ViewModels/WindowsFeaturesViewModel.cs @@ -94,6 +94,7 @@ private async Task ToggleFeatureAsync(WindowsFeature? feature) return; IsBusy = true; + ToggleFeatureCommand.NotifyCanExecuteChanged(); feature.Status = feature.IsEnabled ? "Disabling…" : "Enabling…"; StatusMessage = $"{(feature.IsEnabled ? "Disabling" : "Enabling")} {feature.DisplayName}…"; _cts?.Dispose(); @@ -146,6 +147,7 @@ private async Task ToggleFeatureAsync(WindowsFeature? feature) finally { IsBusy = false; + ToggleFeatureCommand.NotifyCanExecuteChanged(); } }