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
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,12 @@ publish/
*.pidb
*.booproj

# Local developer notes (never tracked)
.kiro/
.session-notes/
notes-local.md
scratch.md

# NuGet
*.nupkg
*.snupkg
Expand Down
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,26 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

## [1.18.1] - 2026-06-03

### Fixed
- **Privacy Toggles no longer write to the registry on every click.** Toggling a switch now updates local state only; the user must press **Apply** to write pending changes. A live counter shows how many changes are pending, and **Discard** reverts them without touching the registry. Prevents accidental system changes when scrolling through the toggle list.
- **Dashboard no longer freezes on first load.** Static system info (CPU, OS, RAM modules) is now loaded asynchronously instead of blocking the UI thread on a synchronous WMI capture. The Dashboard tab is responsive immediately on startup.
- **DNS / Hosts tab loads asynchronously.** Reading the hosts file is now async (`File.ReadAllLinesAsync`); Refresh no longer freezes the UI on slow disks.
- **Icon cache eviction is now true FIFO.** The icon cache previously evicted random entries because `ConcurrentDictionary.Keys` has no insertion-order guarantee. Frequently-used icons could be dropped while stale ones survived. The cache now tracks insertion order via a queue and evicts the oldest entries first when the size limit is reached.
- **SpeedTest output read** — replaced `Task.Result` access after `Task.WhenAll` with proper `await` to remove the deadlock-prone pattern (the awaited tasks were already complete, but the style is now safe under all call paths).
- **Silent exception swallowing** — empty `catch { }` blocks now log at Debug level so failures are diagnosable: ThemePopup custom-color parser, TemperatureService LibreHardwareMonitor close, WindowsUpdateService COM/RuntimeBinder catches in `ExtractKbIds` and `ClassifyCategory`.
- **Deep Cleanup** — file/directory cleanup errors are now logged in addition to being added to the per-run error list, so unexpected I/O issues surface in the SysManager log.
- **Admin relaunch** — `RelaunchAsAdmin` now distinguishes the user's UAC decline (Win32 error 1223 → Information) from real Win32 failures (Warning) and logs `InvalidOperationException` instead of swallowing it silently.
- **`SHGetFileInfo` P/Invoke** — added `SetLastError = true` so callers can inspect the Win32 error code on failure.

### Changed
- **PrivacyView toolbar** — the **Apply All** button is replaced by **Apply** (writes only pending changes) and **Discard** (reverts to last-applied state). Both are disabled when no changes are pending. The Apply button uses the primary style to highlight the action.
- **PerformanceService.TakeSnapshotAsync** XML doc now warns callers that the method must run before any state-modifying call; the recommended lazy-initialization pattern is documented inline.
- **PerformanceService.CreateRestorePointAsync** comment reworded — the previous `// BUG-003:` marker was a design note, not an open bug; replaced with an explanation of why PowerShell `AddParameter` cannot be used here.
- **App.xaml.cs unhandled-exception dialog** — added inline note explaining why `MessageBox.Show` is used instead of `DialogService` (the dispatcher exception may originate from DialogService itself).
- **`.gitignore`** — added `.kiro/`, `.session-notes/`, `notes-local.md`, `scratch.md` so local developer notes can never be tracked accidentally.

## [1.18.0] - 2026-06-03

### Fixed
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -235,7 +235,9 @@ deleting anything (uses the standard `LegacyDisable` registry mechanism):
- **Telemetry**: disable diagnostic data, activity history, advertising ID, feedback prompts
- **UI Declutter**: disable Start suggestions, tips, lock screen tips, Spotlight ads
- **Features**: disable Copilot, Cortana, web search in Start, widgets
- Instant apply — toggles take effect immediately via registry writes
- Explicit apply — flip toggles to stage changes, press **Apply** to write to the
registry, or **Discard** to revert pending changes. A live counter shows how
many changes are queued, so accidental clicks never modify the system silently.
- Category filter and search
- Requires admin for HKLM-backed toggles
- Fully reversible — re-enable any toggle with one click
Expand Down
72 changes: 59 additions & 13 deletions SysManager/SysManager.Tests/PrivacyViewModelTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,13 @@

using SysManager.Services;
using SysManager.ViewModels;
using SysManager.Helpers;

namespace SysManager.Tests;

/// <summary>
/// Tests for <see cref="PrivacyViewModel"/>. Verifies toggle population,
/// category filtering, and reset behavior without writing to the registry.
/// category filtering, pending-change tracking, and discard behavior
/// without writing to the registry.
/// </summary>
public class PrivacyViewModelTests
{
Expand Down Expand Up @@ -63,30 +63,76 @@ public void FilterByAll_ShowsAllToggles()
}

[Fact]
public void ResetAll_SetsAllIsEnabledToFalse()
public void FilteredToggles_InitiallyMatchesAll()
{
var vm = CreateVm();
// Some toggles may be enabled from registry reads — force some on
foreach (var toggle in vm.Toggles)
toggle.IsEnabled = true;
Assert.Equal(vm.Toggles.Count, vm.FilteredToggles.Count);
}

vm.ResetAllCommand.Execute(null);
[Fact]
public void Constructor_NoPendingChanges_AfterLoad()
{
var vm = CreateVm();
Assert.Equal(0, vm.PendingChangeCount);
Assert.False(vm.HasPendingChanges);
}

Assert.All(vm.Toggles, t => Assert.False(t.IsEnabled));
[Fact]
public void TogglingValue_IncrementsPendingChangeCount()
{
var vm = CreateVm();
var first = vm.Toggles[0];
first.IsEnabled = !first.IsEnabled;

Assert.Equal(1, vm.PendingChangeCount);
Assert.True(vm.HasPendingChanges);
}

[Fact]
public void FilteredToggles_InitiallyMatchesAll()
public void TogglingValueBackToBaseline_ResetsPendingCount()
{
var vm = CreateVm();
Assert.Equal(vm.Toggles.Count, vm.FilteredToggles.Count);
var first = vm.Toggles[0];
var original = first.IsEnabled;

first.IsEnabled = !original;
first.IsEnabled = original;

Assert.Equal(0, vm.PendingChangeCount);
}

[Fact]
public void StatusMessage_UpdatesAfterReset()
public void DiscardChanges_RestoresAllTogglesToBaseline()
{
var vm = CreateVm();
vm.ResetAllCommand.Execute(null);
Assert.Contains("reset", vm.StatusMessage, StringComparison.OrdinalIgnoreCase);
var baseline = vm.Toggles.Select(t => t.IsEnabled).ToList();

// Flip every toggle.
foreach (var t in vm.Toggles)
t.IsEnabled = !t.IsEnabled;

vm.DiscardChangesCommand.Execute(null);

for (int i = 0; i < vm.Toggles.Count; i++)
Assert.Equal(baseline[i], vm.Toggles[i].IsEnabled);
Assert.Equal(0, vm.PendingChangeCount);
}

[Fact]
public void StatusMessage_MentionsPending_WhenChangesQueued()
{
var vm = CreateVm();
vm.Toggles[0].IsEnabled = !vm.Toggles[0].IsEnabled;

Assert.Contains("pending", vm.StatusMessage, StringComparison.OrdinalIgnoreCase);
}

[Fact]
public void ApplyChanges_WithNoPending_SetsNoChangesMessage()
{
var vm = CreateVm();
vm.ApplyChangesCommand.Execute(null);

Assert.Contains("no changes", vm.StatusMessage, StringComparison.OrdinalIgnoreCase);
}
}
4 changes: 4 additions & 0 deletions SysManager/SysManager/App.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,10 @@ private static void OnUi(object s, DispatcherUnhandledExceptionEventArgs e)

try
{
// MessageBox is the safe last-resort dialog here: the unhandled
// dispatcher exception may itself originate from DialogService or
// any of its dependencies, so we cannot rely on the app's own
// dialog stack at this point. Direct WPF MessageBox always works.
MessageBox.Show(e.Exception.Message, "SysManager error",
MessageBoxButton.OK, MessageBoxImage.Error);
}
Expand Down
14 changes: 10 additions & 4 deletions SysManager/SysManager/Helpers/AdminHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

using System.Diagnostics;
using System.Security.Principal;
using Serilog;

namespace SysManager.Helpers;

Expand Down Expand Up @@ -42,14 +43,19 @@ public static bool RelaunchAsAdmin(string? argumentHint = null)
Process.Start(psi);
return true;
}
catch (InvalidOperationException)
catch (InvalidOperationException ex)
{
// User declined UAC or process path unavailable
// Process path unavailable or app shutting down.
Log.Debug(ex, "RelaunchAsAdmin: process path unavailable");
return false;
}
catch (System.ComponentModel.Win32Exception)
catch (System.ComponentModel.Win32Exception ex)
{
// UAC declined or another Win32 error
// 1223 = ERROR_CANCELLED (user declined UAC); other codes = real Win32 error.
if (ex.NativeErrorCode == 1223)
Log.Information("RelaunchAsAdmin: user declined UAC prompt");
else
Log.Warning(ex, "RelaunchAsAdmin: Win32 error {Code}", ex.NativeErrorCode);
return false;
}
}
Expand Down
13 changes: 11 additions & 2 deletions SysManager/SysManager/Services/DeepCleanupService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
// License: MIT

using System.IO;
using Serilog;
using SysManager.Models;

namespace SysManager.Services;
Expand Down Expand Up @@ -352,14 +353,22 @@
freed += len;
filesDeleted++;
}
catch (Exception ex) { errors.Add($"{file}: {ex.Message}"); }
catch (Exception ex)
{
errors.Add($"{file}: {ex.Message}");
Log.Debug(ex, "Deep cleanup: failed to delete file {File}", file);
}

Check notice

Code scanning / CodeQL

Generic catch clause Note

Generic catch clause.
Comment on lines +356 to +360
}
foreach (var dir in EnumerateDirectoriesDepthFirst(path, ct))
{
try { Directory.Delete(dir, recursive: false); } catch (IOException) { } catch (UnauthorizedAccessException) { }
}
}
catch (Exception ex) { errors.Add($"{path}: {ex.Message}"); }
catch (Exception ex)
{
errors.Add($"{path}: {ex.Message}");
Log.Debug(ex, "Deep cleanup: failed to enumerate path {Path}", path);
}

Check notice

Code scanning / CodeQL

Generic catch clause Note

Generic catch clause.
Comment on lines +367 to +371
}
}

Expand Down
5 changes: 3 additions & 2 deletions SysManager/SysManager/Services/HostsFileService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,50 +27,51 @@
/// Reads and parses the hosts file. Skips pure comment lines (starting with #
/// that don't have an IP pattern). Detects commented-out entries as disabled.
/// </summary>
public List<HostsEntry> ReadHosts()
public async Task<List<HostsEntry>> ReadHostsAsync(CancellationToken ct = default)
{
List<HostsEntry> entries = [];
if (!File.Exists(HostsPath)) return entries;

foreach (string rawLine in File.ReadAllLines(HostsPath))
var lines = await File.ReadAllLinesAsync(HostsPath, ct).ConfigureAwait(false);
Comment on lines +30 to +35
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Don’t preflight with File.Exists here.

Line 33 can silently collapse read failures into “missing file.” DnsHostsViewModel.LoadHostsAsync explicitly handles UnauthorizedAccessException/IOException, but File.Exists(HostsPath) returns false for some access/path errors, so the UI can incorrectly report Loaded 0 entries. instead of surfacing the failure.

Treat only FileNotFoundException/DirectoryNotFoundException as the empty-list case and let real read errors propagate.

Suggested fix
 public async Task<List<HostsEntry>> ReadHostsAsync(CancellationToken ct = default)
 {
     List<HostsEntry> entries = [];
-    if (!File.Exists(HostsPath)) return entries;
-
-    var lines = await File.ReadAllLinesAsync(HostsPath, ct).ConfigureAwait(false);
+    string[] lines;
+    try
+    {
+        lines = await File.ReadAllLinesAsync(HostsPath, ct).ConfigureAwait(false);
+    }
+    catch (FileNotFoundException)
+    {
+        return entries;
+    }
+    catch (DirectoryNotFoundException)
+    {
+        return entries;
+    }
+
     foreach (string rawLine in lines)
     {
         string line = rawLine.Trim();
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
public async Task<List<HostsEntry>> ReadHostsAsync(CancellationToken ct = default)
{
List<HostsEntry> entries = [];
if (!File.Exists(HostsPath)) return entries;
foreach (string rawLine in File.ReadAllLines(HostsPath))
var lines = await File.ReadAllLinesAsync(HostsPath, ct).ConfigureAwait(false);
public async Task<List<HostsEntry>> ReadHostsAsync(CancellationToken ct = default)
{
List<HostsEntry> entries = [];
string[] lines;
try
{
lines = await File.ReadAllLinesAsync(HostsPath, ct).ConfigureAwait(false);
}
catch (FileNotFoundException)
{
return entries;
}
catch (DirectoryNotFoundException)
{
return entries;
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@SysManager/SysManager/Services/HostsFileService.cs` around lines 30 - 35, The
current ReadHostsAsync method uses File.Exists(HostsPath) which can hide real
IO/permission errors; instead remove the preflight check and wrap the
File.ReadAllLinesAsync(HostsPath, ct) call in a try/catch that only catches
FileNotFoundException and DirectoryNotFoundException and returns an empty
List<HostsEntry> for those cases, while allowing other exceptions (e.g.,
UnauthorizedAccessException/IOException) to propagate so
DnsHostsViewModel.LoadHostsAsync can handle them; keep using the existing
HostsPath and ct parameters and ConfigureAwait(false) as before.

foreach (string rawLine in lines)
{
string line = rawLine.Trim();
if (string.IsNullOrEmpty(line)) continue;

bool isDisabled = false;
string workLine = line;

// Check if this is a commented-out entry (# followed by IP pattern)
if (line.StartsWith('#'))
{
workLine = line[1..].TrimStart();
// If the remainder doesn't start with something that looks like an IP, skip it
if (!LooksLikeIpStart(workLine)) continue;
isDisabled = true;
}

// Parse: IP hostname [# comment]
string[] parts = workLine.Split(['#'], 2);
string entryPart = parts[0].Trim();
string comment = parts.Length > 1 ? parts[1].Trim() : "";

string[] tokens = entryPart.Split([' ', '\t'], StringSplitOptions.RemoveEmptyEntries);
if (tokens.Length < 2) continue;

string ip = tokens[0];
string hostname = tokens[1];

// Validate IP
if (!IPAddress.TryParse(ip, out _)) continue;

entries.Add(new HostsEntry
{
IpAddress = ip,
Hostname = hostname,
Comment = comment,
IsEnabled = !isDisabled
});
}

Check notice

Code scanning / CodeQL

Missed opportunity to use Select Note

This foreach loop immediately
maps its iteration variable to another variable
- consider mapping the sequence explicitly using '.Select(...)'.

return entries;
}
Expand Down
29 changes: 14 additions & 15 deletions SysManager/SysManager/Services/IconExtractorService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ namespace SysManager.Services;
public sealed partial class IconExtractorService
{
private static readonly ConcurrentDictionary<string, ImageSource?> _cache = new(StringComparer.OrdinalIgnoreCase);
private static readonly Queue<string> _insertionOrder = new();
private static readonly object _evictionLock = new();
Comment on lines +26 to 27
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Clear cache must also reset FIFO queue state.

After adding _insertionOrder, ClearCache() (Line 157) only clears _cache. That leaves stale keys in the queue, which can grow unbounded across clears and force extra stale dequeues during later evictions.

🔧 Proposed fix
-    public static void ClearCache() => _cache.Clear();
+    public static void ClearCache()
+    {
+        lock (_evictionLock)
+        {
+            _cache.Clear();
+            _insertionOrder.Clear();
+        }
+    }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@SysManager/SysManager/Services/IconExtractorService.cs` around lines 26 - 27,
ClearCache() currently only clears the _cache but leaves the FIFO
_insertionOrder queue populated, causing stale entries and growth; update
ClearCache() to also clear _insertionOrder in the same critical section (use the
existing _evictionLock) so both _cache.Clear() and _insertionOrder.Clear() are
done under lock to maintain thread-safety and consistent state for future
evictions.


/// <summary>Maximum number of cached icons before eviction kicks in.</summary>
Expand All @@ -46,23 +47,21 @@ public sealed partial class IconExtractorService
if (string.IsNullOrEmpty(normalized))
return _appFallback.Value;

// Evict oldest entries when cache exceeds the limit.
// ConcurrentDictionary has no insertion order, so we clear half
// the cache to amortize the cost of eviction.
if (_cache.Count >= MaxCacheSize)
if (_cache.TryGetValue(normalized, out var cached))
return cached;

var icon = ExtractIcon(normalized);

lock (_evictionLock)
{
lock (_evictionLock)
{
// Double-check inside lock to avoid redundant eviction.
if (_cache.Count >= MaxCacheSize)
{
var keys = _cache.Keys.Take(_cache.Count / 2).ToList();
foreach (var k in keys) _cache.TryRemove(k, out _);
}
}
if (_cache.TryAdd(normalized, icon))
_insertionOrder.Enqueue(normalized);

while (_cache.Count > MaxCacheSize && _insertionOrder.TryDequeue(out var oldest))
_cache.TryRemove(oldest, out _);
}

return _cache.GetOrAdd(normalized, static path => ExtractIcon(path));
return icon;
}

/// <summary>
Expand Down Expand Up @@ -470,7 +469,7 @@ private struct SHFILEINFO
public string szTypeName;
}

[DllImport("shell32.dll", CharSet = CharSet.Unicode)]
[DllImport("shell32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
private static extern IntPtr SHGetFileInfo(
string pszPath, uint dwFileAttributes,
ref SHFILEINFO psfi, uint cbSizeFileInfo, uint uFlags);
Expand Down
15 changes: 12 additions & 3 deletions SysManager/SysManager/Services/PerformanceService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,14 @@ public sealed record OriginalSnapshot(
int ProcessorMinPercentAc,
string? NvidiaSubKey); // null = no NVIDIA GPU

/// <summary>Take a snapshot of the current system state.</summary>
/// <summary>
/// Take a snapshot of the current system state.
/// IMPORTANT: callers MUST invoke this BEFORE applying any change, otherwise
/// the snapshot will capture already-modified state and Restore will not be
/// able to revert to the original baseline. The recommended pattern is to
/// guard every Apply through a wrapper that lazy-initializes the snapshot
/// (see PerformanceViewModel.EnsureSnapshotAsync).
/// </summary>
public async Task<OriginalSnapshot> TakeSnapshotAsync(CancellationToken ct = default)
{
var (name, guid) = await GetActivePlanAsync(ct).ConfigureAwait(false);
Expand Down Expand Up @@ -517,8 +524,10 @@ await _ps.RunProcessAsync("powercfg.exe",
/// </summary>
public async Task<bool> CreateRestorePointAsync(string description, CancellationToken ct = default)
{
// BUG-003: Embed description directly in the script with single-quote
// escaping. AddParameter doesn't create script-scope variables.
// NOTE: PowerShell AddParameter binds runtime arguments but does NOT
// create script-scope variables, so the description has to be embedded
// directly in the script body — with single-quote escaping to avoid
// injection via the user-supplied string.
var safeDesc = (description ?? "SysManager Restore Point").Replace("'", "''");
var script = $"Checkpoint-Computer -Description '{safeDesc}' -RestorePointType 'MODIFY_SETTINGS'";
await _ps.RunAsync(script, null, ct).ConfigureAwait(false);
Expand Down
4 changes: 2 additions & 2 deletions SysManager/SysManager/Services/SpeedTestService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -219,8 +219,8 @@ public async Task<SpeedTestResult> RunOoklaAsync(
var stdoutTask = proc.StandardOutput.ReadToEndAsync(linked);
var stderrTask = proc.StandardError.ReadToEndAsync(linked);
await Task.WhenAll(stdoutTask, stderrTask).ConfigureAwait(false);
var stdout = stdoutTask.Result;
var stderr = stderrTask.Result;
var stdout = await stdoutTask.ConfigureAwait(false);
var stderr = await stderrTask.ConfigureAwait(false);

try
{
Expand Down
2 changes: 1 addition & 1 deletion SysManager/SysManager/Services/TemperatureService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,7 @@
finally
{
try { computer?.Close(); }
catch { /* dispose errors — ignore */ }
catch (Exception ex) { Log.Debug(ex, "LibreHardwareMonitor close failed"); }

Check notice

Code scanning / CodeQL

Generic catch clause Note

Generic catch clause.
}
}

Expand Down
8 changes: 4 additions & 4 deletions SysManager/SysManager/Services/WindowsUpdateService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -250,8 +250,8 @@ private static List<string> ExtractKbIds(dynamic u)
if (!string.IsNullOrWhiteSpace(id)) list.Add(id);
}
}
catch (COMException) { }
catch (Microsoft.CSharp.RuntimeBinder.RuntimeBinderException) { }
catch (COMException ex) { Serilog.Log.Debug(ex, "ExtractKbIds: COM error"); }
catch (Microsoft.CSharp.RuntimeBinder.RuntimeBinderException ex) { Serilog.Log.Debug(ex, "ExtractKbIds: dynamic binding error"); }
return list;
}

Expand Down Expand Up @@ -286,8 +286,8 @@ internal static string ClassifyCategory(string title, dynamic? u = null)
}
Marshal.FinalReleaseComObject(cats);
}
catch (COMException) { }
catch (Microsoft.CSharp.RuntimeBinder.RuntimeBinderException) { }
catch (COMException ex) { Serilog.Log.Debug(ex, "ClassifyCategory: COM error reading Categories"); }
catch (Microsoft.CSharp.RuntimeBinder.RuntimeBinderException ex) { Serilog.Log.Debug(ex, "ClassifyCategory: dynamic binding error"); }
}

if (title.Contains("Driver", StringComparison.OrdinalIgnoreCase) ||
Expand Down
6 changes: 3 additions & 3 deletions SysManager/SysManager/SysManager.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,9 @@
<RootNamespace>SysManager</RootNamespace>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<NoWarn>NU1603;NU1701</NoWarn>
<Version>1.18.0</Version>
<FileVersion>1.18.0.0</FileVersion>
<AssemblyVersion>1.18.0.0</AssemblyVersion>
<Version>1.18.1</Version>
<FileVersion>1.18.1.0</FileVersion>
<AssemblyVersion>1.18.1.0</AssemblyVersion>
<Product>SysManager</Product>
<Description>SysManager — Windows system monitoring toolkit by laurentiu021. Network, updates, health, logs, safe deep cleanup.</Description>
<PackageProjectUrl>https://github.com/laurentiu021/SystemManager</PackageProjectUrl>
Expand Down
Loading
Loading