From a6567221b702e5e53132f62ab00a1b7dbf76dcdb Mon Sep 17 00:00:00 2001 From: laurentiu021 Date: Wed, 13 May 2026 15:26:48 +0300 Subject: [PATCH] fix: address 18 medium-priority code review findings (BIND/MEM/BUG/SEC/PERF) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BIND-001..007: Add NotifyPropertyChangedFor on computed properties across 7 models MEM-006: DeepCleanupViewModel — named handler + unsubscribe on rescan/dispose MEM-007: ShortcutCleanerViewModel — named handler + unsubscribe on rescan/dispose BUG-002: MemoryTestService — ReverseDirection=true for correct cutoff break BUG-003: PerformanceService — embed description in script (AddParameter fix) BUG-004/005: Replace FlexVis misuse on IsEnabled with BoolInverterConverter SEC-006: ServiceManagerService — strict allowlist regex for sc.exe arguments PERF-002: LogsViewModel — CollectionView.Count instead of Cast().Count() PERF-003: NetworkSharedState — simplified buffer trimming PERF-004: MarkdownTextBlock — static compiled Regex PERF-006: DiskAnalyzerService — OrdinalIgnoreCase instead of ToLowerInvariant --- .gitignore | Bin 918 -> 996 bytes CHANGELOG.md | 43 ++++++++++++++++++ SysManager/SysManager/App.xaml | 1 + .../SysManager/Helpers/MarkdownTextBlock.cs | 6 ++- .../Helpers/OutputKindToBrushConverter.cs | 14 ++++++ SysManager/SysManager/Models/BatteryInfo.cs | 15 ++++-- .../SysManager/Models/DiskHealthReport.cs | 38 +++++++++++++--- .../SysManager/Models/DiskUsageEntry.cs | 4 +- .../SysManager/Models/FriendlyEventEntry.cs | 10 +++- SysManager/SysManager/Models/InstalledApp.cs | 4 +- .../SysManager/Models/PerformanceProfile.cs | 9 +++- SysManager/SysManager/Models/ProcessEntry.cs | 4 +- .../Services/DiskAnalyzerService.cs | 4 +- .../SysManager/Services/MemoryTestService.cs | 3 +- .../SysManager/Services/PerformanceService.cs | 8 ++-- .../Services/ServiceManagerService.cs | 7 ++- .../ViewModels/DeepCleanupViewModel.cs | 26 +++++++---- .../SysManager/ViewModels/LogsViewModel.cs | 4 +- .../ViewModels/NetworkSharedState.cs | 11 ++--- .../ViewModels/ShortcutCleanerViewModel.cs | 25 +++++++--- SysManager/SysManager/Views/AboutView.xaml | 4 +- SysManager/SysManager/Views/CleanupView.xaml | 4 +- .../SysManager/Views/DeepCleanupView.xaml | 6 +-- .../SysManager/Views/NetworkRepairView.xaml | 6 +-- .../SysManager/Views/SpeedTestView.xaml | 4 +- .../SysManager/Views/SystemHealthView.xaml | 4 +- .../SysManager/Views/TracerouteView.xaml | 2 +- 27 files changed, 202 insertions(+), 64 deletions(-) diff --git a/.gitignore b/.gitignore index cfa77f6ef6d66c839273350785921e0113bc12d4..e4ad7492297629208c789f0a58ac1bb43e86c989 100644 GIT binary patch delta 87 zcmbQn{)Bx(oSSogN~%IpYFTD#xq^nHr*3*`UTRTdNotCYLSBA}LRx;2LP2R#PG&Mt pQ9-^YFPB@Wk59a_zl&>pkZYKyYq(xkaem&$2y13mE(TsM1^_8A8-xG= delta 14 VcmaFDK8<}s+{RU=%*+g23;-y<1Q!4R diff --git a/CHANGELOG.md b/CHANGELOG.md index d7b88798..47d3aa2c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,49 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +## [0.49.0] - 2026-05-13 + +### Fixed +- **Binding: BatteryInfo** — add NotifyPropertyChangedFor on DesignCapacityMWh, + FullChargeCapacityMWh, EstimatedRuntimeMinutes for computed properties + HealthPercent, WearPercent, RuntimeDisplay (BIND-001). +- **Binding: DiskHealthReport** — add NotifyPropertyChangedFor on HealthStatus, + TemperatureC, WearPercent, PowerOnHours, ReadErrors, WriteErrors for 6+ + computed properties (BIND-002). +- **Binding: FriendlyEventEntry** — add NotifyPropertyChangedFor on Timestamp + and Severity for RelativeTime, FullTimestamp, SeverityIcon, SeverityColor + (BIND-003). +- **Binding: PerformanceProfile** — add NotifyPropertyChangedFor on + ActivePlanName and ActivePlanGuid for ProfileSummary (BIND-004). +- **Binding: ProcessEntry** — add NotifyPropertyChangedFor on MemoryBytes for + MemoryDisplay (BIND-005). +- **Binding: DiskUsageEntry** — add NotifyPropertyChangedFor on SizeBytes for + SizeDisplay (BIND-006). +- **Binding: InstalledApp** — add NotifyPropertyChangedFor on SizeBytes for + SizeDisplay (BIND-007). +- **Memory: DeepCleanupViewModel** — replace anonymous PropertyChanged lambda + with named handler, unsubscribe on rescan and Dispose (MEM-006). +- **Memory: ShortcutCleanerViewModel** — replace anonymous PropertyChanged + lambda with named handler, unsubscribe on rescan and Dispose (MEM-007). +- **Bug: MemoryTestService** — set ReverseDirection=true on EventLogQuery so + the cutoff break works correctly with newest-first ordering (BUG-002). +- **Bug: PerformanceService** — fix CreateRestorePointAsync by embedding + description directly in script instead of using AddParameter which doesn't + create script-scope variables (BUG-003). +- **Bug: SpeedTestView/CleanupView/DeepCleanupView/NetworkRepairView/ + SystemHealthView/TracerouteView/AboutView** — replace FlexVis converter + misuse on IsEnabled with dedicated BoolInverterConverter (BUG-004, BUG-005). +- **Security: ServiceManagerService** — replace weak quote-only validation with + strict allowlist regex for sc.exe service name arguments (SEC-006). +- **Performance: LogsViewModel** — use CollectionView.Count directly instead of + iterating entire filtered view via Cast/Count (PERF-002). +- **Performance: NetworkSharedState** — simplify buffer trimming to remove from + front sequentially (PERF-003). +- **Performance: MarkdownTextBlock** — use static compiled Regex instead of + creating new state machine on every parse call (PERF-004). +- **Performance: DiskAnalyzerService** — use StringComparison.OrdinalIgnoreCase + instead of allocating ToLowerInvariant copy on every path (PERF-006). + ## [0.48.0] - 2026-05-13 ### Fixed diff --git a/SysManager/SysManager/App.xaml b/SysManager/SysManager/App.xaml index 0769794b..8b3ab85a 100644 --- a/SysManager/SysManager/App.xaml +++ b/SysManager/SysManager/App.xaml @@ -17,6 +17,7 @@ + diff --git a/SysManager/SysManager/Helpers/MarkdownTextBlock.cs b/SysManager/SysManager/Helpers/MarkdownTextBlock.cs index 8fcb554a..4fd86c11 100644 --- a/SysManager/SysManager/Helpers/MarkdownTextBlock.cs +++ b/SysManager/SysManager/Helpers/MarkdownTextBlock.cs @@ -81,6 +81,10 @@ private static void OnMarkdownChanged(DependencyObject d, DependencyPropertyChan } } + // PERF-004: Pre-compiled regex avoids creating a new state machine on every call. + private static readonly Regex InlineFormattingRegex = new( + @"\*\*(.+?)\*\*|`([^`]+)`", RegexOptions.Compiled); + /// /// Parses a single line for **bold** and `code` inline formatting /// and appends the resulting Inlines to the TextBlock. @@ -88,7 +92,7 @@ private static void OnMarkdownChanged(DependencyObject d, DependencyPropertyChan private static void AddFormattedText(TextBlock tb, string text) { // Merge bold and code patterns, process left-to-right - var combined = Regex.Matches(text, @"\*\*(.+?)\*\*|`([^`]+)`"); + var combined = InlineFormattingRegex.Matches(text); var pos = 0; foreach (Match m in combined) diff --git a/SysManager/SysManager/Helpers/OutputKindToBrushConverter.cs b/SysManager/SysManager/Helpers/OutputKindToBrushConverter.cs index 0c0e264c..b3d3ac68 100644 --- a/SysManager/SysManager/Helpers/OutputKindToBrushConverter.cs +++ b/SysManager/SysManager/Helpers/OutputKindToBrushConverter.cs @@ -57,6 +57,20 @@ public object ConvertBack(object value, Type targetType, object parameter, Cultu => throw new NotSupportedException(); } +/// +/// Inverts a boolean value. Use for IsEnabled bindings where the source +/// property indicates a "busy" state and the target should be disabled. +/// +[ValueConversion(typeof(bool), typeof(bool))] +public sealed class BoolInverterConverter : IValueConverter +{ + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + => value is bool b ? !b : true; + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + => value is bool b ? !b : true; +} + public class BoolToElevationBadgeBrushConverter : IValueConverter { public object Convert(object value, Type targetType, object parameter, CultureInfo culture) diff --git a/SysManager/SysManager/Models/BatteryInfo.cs b/SysManager/SysManager/Models/BatteryInfo.cs index 9ed246a4..4ffac466 100644 --- a/SysManager/SysManager/Models/BatteryInfo.cs +++ b/SysManager/SysManager/Models/BatteryInfo.cs @@ -15,10 +15,19 @@ public partial class BatteryInfo : ObservableObject [ObservableProperty] private string _name = ""; [ObservableProperty] private string _status = ""; // Charging / Discharging / Full / AC (no battery) [ObservableProperty] private int _chargePercent; // 0-100 - [ObservableProperty] private uint _designCapacityMWh; // milliwatt-hours - [ObservableProperty] private uint _fullChargeCapacityMWh; + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(HealthPercent))] + [NotifyPropertyChangedFor(nameof(WearPercent))] + private uint _designCapacityMWh; // milliwatt-hours + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(HealthPercent))] + [NotifyPropertyChangedFor(nameof(WearPercent))] + private uint _fullChargeCapacityMWh; [ObservableProperty] private int _cycleCount; - [ObservableProperty] private int _estimatedRuntimeMinutes; // -1 = unlimited (AC) + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(RuntimeDisplay))] + private int _estimatedRuntimeMinutes; // -1 = unlimited (AC) [ObservableProperty] private string _chemistry = ""; // LiIon, NiMH, etc. [ObservableProperty] private string _manufacturer = ""; diff --git a/SysManager/SysManager/Models/DiskHealthReport.cs b/SysManager/SysManager/Models/DiskHealthReport.cs index 596699b1..2e384536 100644 --- a/SysManager/SysManager/Models/DiskHealthReport.cs +++ b/SysManager/SysManager/Models/DiskHealthReport.cs @@ -17,13 +17,39 @@ public partial class DiskHealthReport : ObservableObject [ObservableProperty] private string _mediaType = ""; // HDD / SSD / NVMe [ObservableProperty] private string _busType = ""; [ObservableProperty] private double _sizeGB; - [ObservableProperty] private string _healthStatus = ""; // Healthy / Warning / Unhealthy - [ObservableProperty] private double? _temperatureC; + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(HealthPercent))] + [NotifyPropertyChangedFor(nameof(HealthPercentColorHex))] + private string _healthStatus = ""; // Healthy / Warning / Unhealthy + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(HealthPercent))] + [NotifyPropertyChangedFor(nameof(HealthPercentColorHex))] + [NotifyPropertyChangedFor(nameof(TemperatureColorHex))] + [NotifyPropertyChangedFor(nameof(TemperatureGauge))] + private double? _temperatureC; + [ObservableProperty] private double? _temperatureMaxC; - [ObservableProperty] private int? _wearPercent; // 0 = new, 100 = worn out (SSD only) - [ObservableProperty] private long? _powerOnHours; - [ObservableProperty] private long? _readErrors; - [ObservableProperty] private long? _writeErrors; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(HealthPercent))] + [NotifyPropertyChangedFor(nameof(HealthPercentColorHex))] + [NotifyPropertyChangedFor(nameof(WearGauge))] + [NotifyPropertyChangedFor(nameof(WearColorHex))] + private int? _wearPercent; // 0 = new, 100 = worn out (SSD only) + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(PowerOnDisplay))] + private long? _powerOnHours; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(HealthPercent))] + [NotifyPropertyChangedFor(nameof(HealthPercentColorHex))] + private long? _readErrors; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(HealthPercent))] + [NotifyPropertyChangedFor(nameof(HealthPercentColorHex))] + private long? _writeErrors; [ObservableProperty] private long? _startStopCount; [ObservableProperty] private string _verdict = ""; // plain-English summary [ObservableProperty] private string _verdictColorHex = "#9AA0A6"; diff --git a/SysManager/SysManager/Models/DiskUsageEntry.cs b/SysManager/SysManager/Models/DiskUsageEntry.cs index f83fd728..5bba030a 100644 --- a/SysManager/SysManager/Models/DiskUsageEntry.cs +++ b/SysManager/SysManager/Models/DiskUsageEntry.cs @@ -14,7 +14,9 @@ public partial class DiskUsageEntry : ObservableObject { [ObservableProperty] private string _name = ""; [ObservableProperty] private string _fullPath = ""; - [ObservableProperty] private long _sizeBytes; + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(SizeDisplay))] + private long _sizeBytes; [ObservableProperty] private double _percentage; [ObservableProperty] private int _fileCount; [ObservableProperty] private int _folderCount; diff --git a/SysManager/SysManager/Models/FriendlyEventEntry.cs b/SysManager/SysManager/Models/FriendlyEventEntry.cs index e858947e..49ae7a29 100644 --- a/SysManager/SysManager/Models/FriendlyEventEntry.cs +++ b/SysManager/SysManager/Models/FriendlyEventEntry.cs @@ -13,11 +13,17 @@ namespace SysManager.Models; /// public partial class FriendlyEventEntry : ObservableObject { - [ObservableProperty] private DateTime _timestamp; + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(RelativeTime))] + [NotifyPropertyChangedFor(nameof(FullTimestamp))] + private DateTime _timestamp; [ObservableProperty] private string _logName = ""; // System / Application / Security / Setup [ObservableProperty] private string _providerName = ""; // source [ObservableProperty] private int _eventId; - [ObservableProperty] private EventSeverity _severity; + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(SeverityIcon))] + [NotifyPropertyChangedFor(nameof(SeverityColor))] + private EventSeverity _severity; [ObservableProperty] private string _severityLabel = ""; [ObservableProperty] private string _message = ""; // first line / summary [ObservableProperty] private string _fullMessage = ""; // full rendered text diff --git a/SysManager/SysManager/Models/InstalledApp.cs b/SysManager/SysManager/Models/InstalledApp.cs index e59d7753..de0e9af4 100644 --- a/SysManager/SysManager/Models/InstalledApp.cs +++ b/SysManager/SysManager/Models/InstalledApp.cs @@ -18,7 +18,9 @@ public partial class InstalledApp : ObservableObject [ObservableProperty] private string _version = ""; [ObservableProperty] private string _source = ""; [ObservableProperty] private string _status = ""; - [ObservableProperty] private long _sizeBytes; + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(SizeDisplay))] + private long _sizeBytes; [ObservableProperty] private string _publisher = ""; [ObservableProperty] private ImageSource? _icon; [ObservableProperty] private string _uninstallString = ""; diff --git a/SysManager/SysManager/Models/PerformanceProfile.cs b/SysManager/SysManager/Models/PerformanceProfile.cs index 343895b1..6f6e05be 100644 --- a/SysManager/SysManager/Models/PerformanceProfile.cs +++ b/SysManager/SysManager/Models/PerformanceProfile.cs @@ -14,8 +14,13 @@ namespace SysManager.Models; public partial class PerformanceProfile : ObservableObject { // ── Power plan ── - [ObservableProperty] private string _activePlanName = ""; - [ObservableProperty] private string _activePlanGuid = ""; + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(ProfileSummary))] + private string _activePlanName = ""; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(ProfileSummary))] + private string _activePlanGuid = ""; // ── Visual effects ── [ObservableProperty] private bool _visualEffectsReduced; diff --git a/SysManager/SysManager/Models/ProcessEntry.cs b/SysManager/SysManager/Models/ProcessEntry.cs index 34574815..0e4db262 100644 --- a/SysManager/SysManager/Models/ProcessEntry.cs +++ b/SysManager/SysManager/Models/ProcessEntry.cs @@ -16,7 +16,9 @@ public partial class ProcessEntry : ObservableObject [ObservableProperty] private string _name = ""; [ObservableProperty] private string _description = ""; [ObservableProperty] private double _cpuPercent; - [ObservableProperty] private long _memoryBytes; + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(MemoryDisplay))] + private long _memoryBytes; [ObservableProperty] private string _status = ""; [ObservableProperty] private string _userName = ""; [ObservableProperty] private DateTime _startTime; diff --git a/SysManager/SysManager/Services/DiskAnalyzerService.cs b/SysManager/SysManager/Services/DiskAnalyzerService.cs index 42238ee7..df5393d2 100644 --- a/SysManager/SysManager/Services/DiskAnalyzerService.cs +++ b/SysManager/SysManager/Services/DiskAnalyzerService.cs @@ -180,7 +180,7 @@ private static (long size, int files, int folders) MeasureFolder(string path, Ca private static bool ShouldSkip(string path) { - var lower = path.ToLowerInvariant(); - return SkipSegments.Any(seg => lower.Contains(seg)); + // PERF-006: Use OrdinalIgnoreCase instead of allocating a lowercase copy. + return SkipSegments.Any(seg => path.Contains(seg, StringComparison.OrdinalIgnoreCase)); } } diff --git a/SysManager/SysManager/Services/MemoryTestService.cs b/SysManager/SysManager/Services/MemoryTestService.cs index bb34d790..0dfd650f 100644 --- a/SysManager/SysManager/Services/MemoryTestService.cs +++ b/SysManager/SysManager/Services/MemoryTestService.cs @@ -35,7 +35,8 @@ public async Task CheckErrorLogsAsync(CancellationToken ct = using var reader = new System.Diagnostics.Eventing.Reader.EventLogReader( new System.Diagnostics.Eventing.Reader.EventLogQuery("System", System.Diagnostics.Eventing.Reader.PathType.LogName, - "*[System[Provider[@Name='Microsoft-Windows-WHEA-Logger' or @Name='Microsoft-Windows-MemoryDiagnostics-Results']]]")); + "*[System[Provider[@Name='Microsoft-Windows-WHEA-Logger' or @Name='Microsoft-Windows-MemoryDiagnostics-Results']]]") + { ReverseDirection = true }); var cutoff = DateTime.Now.AddDays(-30); System.Diagnostics.Eventing.Reader.EventRecord? rec; diff --git a/SysManager/SysManager/Services/PerformanceService.cs b/SysManager/SysManager/Services/PerformanceService.cs index 5fc8a2e7..7bc2d74a 100644 --- a/SysManager/SysManager/Services/PerformanceService.cs +++ b/SysManager/SysManager/Services/PerformanceService.cs @@ -511,9 +511,11 @@ await _ps.RunProcessAsync("powercfg.exe", /// public async Task CreateRestorePointAsync(string description, CancellationToken ct = default) { - var script = "Checkpoint-Computer -Description $desc -RestorePointType 'MODIFY_SETTINGS'"; - var parameters = new Dictionary { ["desc"] = description }; - var results = await _ps.RunAsync(script, parameters, ct).ConfigureAwait(false); + // BUG-003: Embed description directly in the script with single-quote + // escaping. AddParameter doesn't create script-scope variables. + var safeDesc = (description ?? "SysManager Restore Point").Replace("'", "''"); + var script = $"Checkpoint-Computer -Description '{safeDesc}' -RestorePointType 'MODIFY_SETTINGS'"; + var results = await _ps.RunAsync(script, null, ct).ConfigureAwait(false); return results != null; } diff --git a/SysManager/SysManager/Services/ServiceManagerService.cs b/SysManager/SysManager/Services/ServiceManagerService.cs index 1f81dd8d..7e2eb8f2 100644 --- a/SysManager/SysManager/Services/ServiceManagerService.cs +++ b/SysManager/SysManager/Services/ServiceManagerService.cs @@ -123,8 +123,11 @@ public static void StopService(string serviceName) /// Change the startup type of a service via sc.exe. Requires admin. public static async Task SetStartupTypeAsync(string serviceName, string startType, PowerShellRunner ps, CancellationToken ct = default) { - // Validate inputs to prevent command injection via sc.exe arguments - if (serviceName.Contains('"') || serviceName.Contains('\0')) + // SEC-006: Strict allowlist for service names — alphanumeric, spaces, + // hyphens, underscores, dots, and dollar signs only (covers all valid + // Windows service names including instance names like MSSQL$INSTANCE). + if (string.IsNullOrWhiteSpace(serviceName) || + !System.Text.RegularExpressions.Regex.IsMatch(serviceName, @"^[\w\s\-.$]+$")) throw new ArgumentException("Invalid service name.", nameof(serviceName)); var allowedTypes = new[] { "auto", "delayed-auto", "demand", "disabled" }; diff --git a/SysManager/SysManager/ViewModels/DeepCleanupViewModel.cs b/SysManager/SysManager/ViewModels/DeepCleanupViewModel.cs index ad4a5c51..4eb1bc1a 100644 --- a/SysManager/SysManager/ViewModels/DeepCleanupViewModel.cs +++ b/SysManager/SysManager/ViewModels/DeepCleanupViewModel.cs @@ -112,10 +112,21 @@ private void AddLocation(string label, string path) partial void OnIsCleaningChanged(bool value) => IsBusy = IsScanning || IsCleaning || IsLargeScanning; partial void OnIsLargeScanningChanged(bool value) => IsBusy = IsScanning || IsCleaning || IsLargeScanning; + private void OnCategoryPropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e) + { + if (e.PropertyName == nameof(CleanupCategory.IsSelected)) + { + OnPropertyChanged(nameof(TotalSelectedBytes)); + OnPropertyChanged(nameof(TotalSelectedDisplay)); + } + } + protected override void Dispose(bool disposing) { if (disposing) { + foreach (var c in Categories) + c.PropertyChanged -= OnCategoryPropertyChanged; _scanCts?.Dispose(); _cleanCts?.Dispose(); _largeCts?.Dispose(); @@ -154,19 +165,14 @@ private async Task ScanAsync() // Batch-update the observable collection to avoid per-item UI // re-renders that can stall the dispatcher on large lists. + // MEM-006: Unsubscribe from old categories before clearing to prevent + // PropertyChanged lambda leaks across rescans. + foreach (var old in Categories) + old.PropertyChanged -= OnCategoryPropertyChanged; Categories.Clear(); var catList = cats.ToList(); foreach (var c in catList) - { - c.PropertyChanged += (_, e) => - { - if (e.PropertyName == nameof(CleanupCategory.IsSelected)) - { - OnPropertyChanged(nameof(TotalSelectedBytes)); - OnPropertyChanged(nameof(TotalSelectedDisplay)); - } - }; - } + c.PropertyChanged += OnCategoryPropertyChanged; foreach (var c in catList) Categories.Add(c); var total = cats.Sum(c => c.TotalSizeBytes); ScanSummary = $"Found {CleanupCategory.HumanSize(total)} across {cats.Count} categories. Untick anything you want to keep."; diff --git a/SysManager/SysManager/ViewModels/LogsViewModel.cs b/SysManager/SysManager/ViewModels/LogsViewModel.cs index 242bbab1..d240a086 100644 --- a/SysManager/SysManager/ViewModels/LogsViewModel.cs +++ b/SysManager/SysManager/ViewModels/LogsViewModel.cs @@ -67,7 +67,9 @@ public LogsViewModel() private void UpdateVisibleCount() { - VisibleCount = EntriesView.Cast().Count(); + // PERF-002: Use CollectionView.Count directly instead of iterating + // the entire filtered collection via Cast().Count(). + VisibleCount = ((CollectionView)EntriesView).Count; HasNoResults = Entries.Count > 0 && VisibleCount == 0; } diff --git a/SysManager/SysManager/ViewModels/NetworkSharedState.cs b/SysManager/SysManager/ViewModels/NetworkSharedState.cs index dce82b9b..61b201ba 100644 --- a/SysManager/SysManager/ViewModels/NetworkSharedState.cs +++ b/SysManager/SysManager/ViewModels/NetworkSharedState.cs @@ -425,12 +425,11 @@ internal void TrimBuffer(ObservableCollection buffer) while (removeCount < buffer.Count && buffer[removeCount].DateTime < cutoff) removeCount++; - // Batch removal from end-to-start avoids O(n²) shifting - if (removeCount > 0) - { - for (int i = removeCount - 1; i >= 0; i--) - buffer.RemoveAt(i); - } + // PERF-003: Remove stale items from the front. Removing from index 0 + // repeatedly is O(n*removeCount) but unavoidable with ObservableCollection. + // We remove forward (always index 0) which is simpler and equivalent. + for (int i = 0; i < removeCount; i++) + buffer.RemoveAt(0); } internal void TrimAllBuffers() diff --git a/SysManager/SysManager/ViewModels/ShortcutCleanerViewModel.cs b/SysManager/SysManager/ViewModels/ShortcutCleanerViewModel.cs index 9f122fac..d7edb83a 100644 --- a/SysManager/SysManager/ViewModels/ShortcutCleanerViewModel.cs +++ b/SysManager/SysManager/ViewModels/ShortcutCleanerViewModel.cs @@ -46,7 +46,11 @@ private async Task ScanAsync() IsScanning = true; IsBusy = true; IsProgressIndeterminate = true; - BrokenShortcuts.Clear(); + // MEM-007: Unsubscribe from old items before clearing to prevent + // PropertyChanged lambda leaks across rescans. + foreach (var old in BrokenShortcuts) + old.PropertyChanged -= OnShortcutPropertyChanged; + BrokenShortcuts.Clear(); BrokenCount = 0; SelectedCount = 0; ScanStatus = "Scanning..."; @@ -59,11 +63,7 @@ private async Task ScanAsync() foreach (var s in results) { - s.PropertyChanged += (_, e) => - { - if (e.PropertyName == nameof(BrokenShortcut.IsSelected)) - SelectedCount = BrokenShortcuts.Count(x => x.IsSelected); - }; + s.PropertyChanged += OnShortcutPropertyChanged; BrokenShortcuts.Add(s); } @@ -132,9 +132,20 @@ private void DeselectAll() [RelayCommand] private void Cancel() => _cts?.Cancel(); + private void OnShortcutPropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e) + { + if (e.PropertyName == nameof(BrokenShortcut.IsSelected)) + SelectedCount = BrokenShortcuts.Count(x => x.IsSelected); + } + protected override void Dispose(bool disposing) { - if (disposing) _cts?.Dispose(); + if (disposing) + { + foreach (var s in BrokenShortcuts) + s.PropertyChanged -= OnShortcutPropertyChanged; + _cts?.Dispose(); + } base.Dispose(disposing); } } diff --git a/SysManager/SysManager/Views/AboutView.xaml b/SysManager/SysManager/Views/AboutView.xaml index 39e3994b..1c3572de 100644 --- a/SysManager/SysManager/Views/AboutView.xaml +++ b/SysManager/SysManager/Views/AboutView.xaml @@ -51,7 +51,7 @@