Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 15 additions & 4 deletions SysManager/SysManager/Helpers/OutputKindToBrushConverter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}

Expand All @@ -102,8 +106,15 @@ public object ConvertBack(object value, Type targetType, object parameter, Cultu
/// </summary>
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)
{
Expand Down
50 changes: 31 additions & 19 deletions SysManager/SysManager/Services/BatteryService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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 */ }
Expand All @@ -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 */ }
Expand All @@ -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 */ }
Expand All @@ -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 */ }
Expand Down
4 changes: 3 additions & 1 deletion SysManager/SysManager/Services/ShortcutCleanerService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
Expand Down
5 changes: 4 additions & 1 deletion SysManager/SysManager/ViewModels/UninstallerViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ namespace SysManager.ViewModels;
public partial class UninstallerViewModel : ViewModelBase
{
private readonly UninstallerService _service;
private readonly Action<PowerShellLine> _lineHandler;
private CancellationTokenSource? _cts;

public ObservableCollection<InstalledApp> AllApps { get; } = new();
Expand All @@ -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]
Expand Down Expand Up @@ -154,6 +156,7 @@ protected override void Dispose(bool disposing)
{
if (disposing)
{
_service.LineReceived -= _lineHandler;
_cts?.Dispose();
}
base.Dispose(disposing);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -146,6 +147,7 @@ private async Task ToggleFeatureAsync(WindowsFeature? feature)
finally
{
IsBusy = false;
ToggleFeatureCommand.NotifyCanExecuteChanged();
}
}

Expand Down
Loading