C# WPF .NET 10 port: replace PowerShell + .cmd launcher with self-contained WinTune.exe#1
Merged
Conversation
Wrap Remove-PathContents for SoftwareDistribution\Download in try/finally so the previously-stopped services are restored even when the recursive delete throws. Previously a terminating error in the delete would jump to the outer catch and leave Windows Update offline until reboot.
A junction or symlink inside %TEMP% (or any other cleanup target) could previously redirect Remove-Item -Recurse -Force into e.g. C:\Windows\System32 because Remove-Item in PS 5.1 follows directory junctions. Replace Remove-Item -Recurse with Remove-DirectoryTreeSafe, which walks the tree manually and skips any reparse-point entry at every level. Top- level reparse points in Remove-PathContents are also skipped (link not deleted, target not followed).
Users with Windows installed off the C: drive (less common but legal) or a relocated ProgramData would have hit FileNotFoundException-style failures in SystemTemp / Prefetch / WindowsUpdate / search-index probes. Use $env:SystemRoot and $env:ProgramData via Join-Path.
WinTune.ps1 was carrying a copy of the byte formatter under a different name for the Dedupe tab while the Cleanup tab used the exported one. Unify on Format-Bytes from Cleanup.psm1; all call sites now use the named -Bytes parameter.
…s WU Windows Update cumulative updates routinely re-enable DiagTrack via service startup-type reset. Setting HKLM\SOFTWARE\Policies\Microsoft\ Windows\DataCollection\AllowTelemetry = 0 (DWord) is the GP-level control that survives those updates. Also correct the misleading 'Connected User Experiences -- silent half' comment on dmwappushservice -- that service is the WAP Push Message Routing Service and was removed in Win11 24H2.
Get-Process returns objects whose SafeHandle stays open until GC. With 300+ processes on a typical workstation, repeated invocations can pin hundreds of native handles transiently. Wrap each iteration in try/ finally that calls $_.Dispose() so handles release immediately.
The README still described the original three tabs (Dashboard, Clean, Boost) and four modules. Add Diagnose and Dedupe sections describing the checks, fixes, and safety stops. Update the project-layout block and dot-source examples to show all six modules. Also surface two of the safety guarantees added in this branch: - WindowsUpdate cleanup restarts services in finally - reparse points are skipped during cleanup deletion
New helper module for moving long operations off the WPF dispatcher
thread. Wraps [PowerShell]::Create() in a small set of cmdlets:
Start-AsyncOp - launch script in background runspace with
auto-injected $Progress closure
Stop-AsyncOp - best-effort cancel
Test-AsyncOpComplete - non-blocking completion check (poll from
DispatcherTimer)
Receive-AsyncProgress - drain progress queue
Receive-AsyncOp - read final result, dispose runspace
Progress payloads cross the thread boundary via
System.Collections.Concurrent.ConcurrentQueue, so background producers
and the UI consumer don't race.
Verified end-to-end against Find-Duplicates with -OnProgress.
Fires Start/TargetDone events around each target so the UI can show 'Cleaning Edge cache...' / 'Cleaning Firefox cache...' as the run proceeds, instead of going dark for the duration. Required for the upcoming runspace-based UI refactor that moves cleanup off the WPF dispatcher thread.
The Cleanup tab previously called Invoke-Cleanup synchronously on the WPF dispatcher thread. Browser-cache deletion on a profile with multi- gigabyte caches froze the window for the duration. Now: - CleanRunBtn launches the work via Start-AsyncOp into a background PowerShell runspace. - A 150ms DispatcherTimer polls the progress queue and surfaces '[i/N] Cleaning <target>...' status messages as each target starts. - New CleanCancelBtn calls Stop-AsyncOp; the in-flight target may still complete (PS Stop() is best-effort), but no further targets begin. Result-grid population happens on the dispatcher thread inside the poll callback, so WPF binding stays single-threaded as required.
Find-Duplicates is the longest-running operation in WinTune -- a full scan of Documents+Pictures+Downloads can hash gigabytes and take minutes. Previously it ran on the WPF dispatcher thread, freezing the window despite a Render-priority repaint poke between phases. Now: - DedupeScanBtn launches Find-Duplicates in a background runspace via Start-AsyncOp. - DispatcherTimer poll drains the progress queue and updates DedupeStatusLbl with phase + counts (scan/hash/done). - New DedupeCancelBtn calls Stop-AsyncOp; window stays interactive throughout. - Result-grid population happens on the dispatcher thread inside the poll callback so no cross-thread WPF binding violation.
Invoke-Diagnostics issues several CIM queries (Win32_Processor, Win32_OperatingSystem, Win32_PageFileSetting, Win32_StartupCommand, Get-PhysicalDisk) plus filesystem walks for the Search index size estimate. Synchronously this took 1-2 seconds and visibly froze the window during startup (Add_Loaded called Run-Diagnose directly). Move to the Async helper. Same DispatcherTimer poll pattern as the Cleanup and Dedupe tabs. Add_Loaded continues to call Run-Diagnose, but now it returns immediately and the findings populate ~1-2s after the window appears.
Clear-WorkingSets iterates Get-Process (300+ processes typical) calling the EmptyWorkingSet P/Invoke on each plus a 600ms sleep before the post-trim measurement. That's ~1s of frozen window minimum. Move to the same Async pattern as the other tabs.
Without this, closing the window during a long dedup scan or cleanup left the background runspace running until the host process exited. The DispatcherTimer pollers also kept ticking against null state. Iterate over the four script-scoped op variables (CleanupOp, DedupeOp, DiagOp, BoostOp) and their pollers, stopping each defensively.
Without the unary-comma wrap on eturn ,@(), PowerShell's function- return unwrapping turned the empty array back into \. Callers such as 'foreach (\ in (Receive-AsyncProgress \))' worked by accident (foreach over \ is a no-op) but '(Receive-AsyncProgress).Count' would throw 'cannot call method on null'. Apply the same fix to the \ Op early-return.
Invoke-Diagnostics returned ,\.ToArray() -- the comma wrap prevented PowerShell from auto-unrolling the array into the pipeline. Result: '\ = @(Invoke-Diagnostics)' produced a 1-element array whose single element was the actual findings array, and WPF's DataGrid ItemsSource bound a one-row view with auto-generated columns from Object[] (Length, Rank, SyncRoot, ...). Return the array unwrapped. Callers that want array semantics for the empty case can wrap with @() defensively. Diagnostics in practice always emits at least 5+ findings, so the empty case is moot.
34 tests across:
- Async.Tests.ps1 (9) - runspace lifecycle, progress queue, cancellation, null defense
- Boost.Tests.ps1 (3) - exports, Clear-WorkingSets shape, DNS flush
- Cleanup.Tests.ps1 (5) - Format-Bytes thresholds, target list, OnProgress hook,
and the reparse-point safety property end-to-end with a
real junction in TEMP
- Dedup.Tests.ps1 (7) - empty/identical/small/excluded-ext groupings,
progress events, default scan roots, junction skip
- Diagnose.Tests.ps1 (3) - exports, finding shape, severity values
- Monitor.Tests.ps1 (5) - PerfSnapshot field set, CPU range, RAM consistency,
top-process count and ordering
- Startup.Tests.ps1 (2) - object shape, Source tag values
The Cleanup and Dedup junction tests use cmd /c mklink /J to create a real
NTFS junction in a per-test temp dir, exercising the Phase 1 safety code
against an actual reparse point rather than a mock.
Run-Tests.ps1 runs PSScriptAnalyzer with the project settings file then Pester from ./tests, exiting non-zero if either gate fails. Used locally and by CI -- the workflow is just 'install deps; .\Run-Tests.ps1' running on windows-latest under Windows PowerShell 5.1 (matching the app's runtime). PSScriptAnalyzerSettings.psd1 suppresses 6 rule families that don't fit a single-window interactive WPF tool: ShouldProcess, EmptyCatchBlock, SingularNouns (public API names are stable), BrokenHashAlgorithms (SHA1 is acceptable for personal-file dedup as documented in Dedup.psm1), ApprovedVerbs (Run-Diagnose is a local UI helper), and ReviewUnused- Parameter (the Async helper splats \ so PSSA can't see the bind). Readme gets a short Development section pointing at .\Run-Tests.ps1.
The Add_Tick scriptblocks for the four async pollers were defined inside
click handlers (e.g. inside CleanRunBtn.Add_Click). When the WPF
dispatcher fires those nested scriptblocks, they don't have the script's
session state -- so 'Set-Status' (and any other top-level function)
fails with 'not recognized'. The catch block then ALSO can't call
Set-Status to log it, the exception loops, and the user sees a flood
of 'Set-Status not recognized' errors in the powershell console.
Fix: define Update-CleanupPoll, Update-DedupePoll, Update-DiagPoll,
Update-BoostPoll at script scope, alongside Set-Status / Update-
Dashboard / Get-SelectedTargets. Add_Tick scriptblocks become trivial
delegates ({ Update-CleanupPoll }), exactly mirroring the dashboard
timer pattern that has always worked.
Also harden each catch: Write-Host the error so it's never lost, and
Stop() the poller so a recurring failure doesn't loop the dispatcher.
The deeper cause of the 'Update-CleanupPoll not recognized' error: the
Add_Tick scriptblock itself, even when reduced to '{ Update-CleanupPoll }',
was still CREATED inside a click handler at runtime. PowerShell's WPF
event dispatch doesn't propagate the script's session state to script-
blocks compiled at runtime inside other scriptblocks -- only to script-
blocks compiled at parse time at the script top level.
Dashboard timer works for exactly this reason: its Add_Tick scriptblock
is compiled when the script body runs, at top level.
Fix: create the four async pollers ONCE at script top level, alongside
the dashboard timer. Click handlers now just call .Start() on the pre-
wired timer; cancel handlers just .Stop(). The Add_Tick scriptblocks
are compiled at parse time and bind correctly to script session state.
… tests) Two-project solution preparing the standalone-executable rewrite: - WinTune.Core (class library, net10.0-windows) for services + DTOs - WinTune.App (WPF, net10.0-windows) for UI; PublishSingleFile + SelfContained configured under Release for single-file self-contained .exe output - WinTune.Core.Tests (xUnit + FluentAssertions + Moq) for Core test coverage - app.manifest with requireAdministrator, PerMonitorV2 DPI, longPathAware - global.json pins SDK 10.0.203 with rollForward=latestFeature - InternalsVisibleTo Core.Tests so tests can drive internal helpers - TreatWarningsAsErrors + AnalysisLevel=latest-recommended on every project PowerShell version remains intact on this branch as the reference implementation; csharp-port branch will hold the C# rewrite until parity is reached, then become main. Plan at docs/superpowers/plans/2026-05-09-csharp-wpf-port.md.
…Boost*, StartupEntry, Finding, DiagnoseResult, Duplicate*, DedupeProgress, RemovalResult)
…hell32 (SHEmptyRecycleBinW), dnsapi (DnsFlushResolverCache)
…ocesses - PerformanceCounter for CPU with WMI Win32_Processor.LoadPercentage fallback - WMI Win32_OperatingSystem for RAM totals - DriveInfo for system drive utilization - Process.GetProcesses() for top-N by working set, with disposal-in-finally - AllowUnsafeBlocks=true required by LibraryImport source generator - 4 xUnit tests passing (snapshot bounds, top-N ordering, cancellation x2)
…orer restart - ClearWorkingSetsAsync iterates all processes, calls psapi!EmptyWorkingSet via LibraryImport, disposes each Process in finally to avoid SafeHandle pile-up - RestartExplorerAsync kills explorer.exe and Start-s it back if Windows didn't respawn it - FlushDnsCacheAsync calls dnsapi!DnsFlushResolverCache via LibraryImport - 3 xUnit tests passing (working-set trim, DNS flush success, cancellation); RestartExplorer marked Skip because it kills the user's shell
…ck service restart
Faithful port of modules/Cleanup.psm1 preserving every safety guarantee:
- RemovePathContents skips reparse-point entries at top level (never follow,
never delete the link) — defends against malicious junction redirects
- RemoveDirectoryTreeSafe recursive deleter also skips reparse points and
leaves non-empty parent directories intact (the intentional signal that an
unexpected child lives there)
- WindowsUpdate target stops wuauserv + bits, clears SoftwareDistribution
Download, restarts services in a finally block so they ALWAYS come back
- Browser cache targets short-circuit when msedge/chrome/firefox is running
- All paths derived from %TEMP%, %SystemRoot%, %ProgramData%, %LOCALAPPDATA%
via Environment.GetEnvironmentVariable / SpecialFolder; no hardcoded
C:\Windows or C:\ProgramData
- RecycleBin via shell32!SHEmptyRecycleBinW (treats E_UNEXPECTED 0x8000FFFF
as "already empty", matching the PowerShell silently-accept behavior)
- DnsCache via dnsapi!DnsFlushResolverCache
- Per-run log written to %LOCALAPPDATA%\WinTune\logs\cleanup-{stamp}.log
- IProgress<CleanupProgress> emits Start + TargetDone events per target
6 xUnit tests passing including the reparse-point invariant test that
creates a sentinel directory + junction and verifies the sentinel survives.
TempDirectory test helper added under tests/.../TestHelpers/.
- Win32_StartupCommand via System.Management - Run/RunOnce keys under HKLM and HKCU via Microsoft.Win32.RegistryKey, always opened in Registry64 view to avoid WOW6432Node redirection - Per-user + AllUsers Startup folders via Environment.SpecialFolder - DistinctBy (User, Name) then OrderBy User+Name to mirror PowerShell sort - 4 xUnit tests: shape, required fields, distinct invariant, cancellation
Checks - Disk health via MSFT_PhysicalDisk in ROOT\Microsoft\Windows\Storage namespace - Free space per fixed drive: Red <10%, Yellow <15%, Green otherwise - Cloud shell extensions loaded into explorer.exe (OneDrive / FileSyncShell / drivefsext / GoogleDrive / Dropbox / Box) via Process.Modules - Quick Access bloat: AutomaticDestinations + Recent file count - Search index size at %ProgramData%\Microsoft\Search\...\CiFiles - DiagTrack telemetry service status - Win11 right-click overlay vs classic registered CLSID - Pagefile placement vs drive free space - Startup app count via Win32_StartupCommand - RAM pressure from Win32_OperatingSystem Fixes - ResetQuickAccessAsync deletes file entries in Recent / AutomaticDestinations / CustomDestinations - DisableTelemetryAsync stops + sc-config-disabled DiagTrack, sets HKLM Policies AllowTelemetry=0 (so Windows Update cumulative updates can't silently re-enable), best-effort dmwappushservice handling for older builds - EnableClassicRightClickAsync writes the CLSID + InprocServer32 default "" - DisableClassicRightClickAsync removes the CLSID subtree - StartSearchIndexRebuildAsync stops WSearch, flips SetupCompletedSuccessfully=0, restarts service 3 unit tests + 1 destructive smoke skipped. Live PowerShell suite already covers the fix paths against the same logic.
- Pass 1: enumerate files via DirectoryInfo.EnumerateFiles with IgnoreInaccessible, group by exact byte size - Pass 2: SHA-256 fingerprint each candidate (size buckets >=2 files); switched from PowerShell's SHA1 to SHA-256 as content fingerprint to satisfy CA5350 and reduce collision risk to negligible - Skip reparse points (junctions / OneDrive cloud-only that would trigger download), Offline files, System files, and (by default) Hidden files - Default exclude extensions match the PowerShell list: .lnk .url .tmp .crdownload .partial - IProgress<DedupeProgress> reports Enumerate (every 500 files), HashStart, Hash (every 25 files), Done with totals - RemoveDuplicateFilesAsync uses Microsoft.VisualBasic.FileIO for recycle-bin delete (default) or File.Delete for permanent delete; per-file errors collected without aborting - GetDefaultScanRoots returns Desktop / Documents / Pictures / Videos / Music / Downloads, filtered to existing directories 6 unit tests covering grouping, minSize cutoff, exclude extensions, cancellation, permanent delete, default roots. All 26 cross-service tests still pass.
- App.xaml.cs hosts Microsoft.Extensions.Hosting + Serilog file sink under
%LOCALAPPDATA%\WinTune\logs (daily rolling, 7-day retention, invariant
culture); registers all 6 Core services + 5 tab ViewModels + MainWindow
as DI singletons / transient
- MainWindowViewModel composes the 5 tab VMs, exposes them via DataContext
per TabItem so each tab gets its own VM root
- DashboardViewModel uses DispatcherTimer 2s tick to refresh CPU / RAM /
Disk + Top-10 processes
- CleanViewModel binds 10 checkboxes via [ObservableProperty], drives
IProgress<CleanupProgress>, exposes Run/Cancel/SelectAll/ClearAll/OpenLog
via [RelayCommand], opens last log via notepad.exe
- BoostViewModel wires Free RAM / Restart Explorer / Flush DNS / Open
Task Manager Startup, lists startup apps via IStartupService
- DiagnoseViewModel runs diagnostics + 5 fixes; re-runs diagnostics after
state-changing fixes so the findings list refreshes
- DedupeViewModel manages scan paths (Add via OpenFolderDialog, Defaults,
Remove), min-size selector, scan with progress, three auto-select
helpers (oldest/newest/shortest path), Clear, and Delete-to-Recycle-Bin
with safety stop that refuses to delete every file in any group
- SeverityToBrushConverter renders red/yellow/green badges in Diagnose
findings grid; BytesToDisplayConverter formats bytes in cleanup +
dedupe grids
- MainWindow.xaml ports the original 5-tab layout from ui/MainWindow.xaml
with all event handlers replaced by {Binding Command=...}; status bar
binds to MainWindowViewModel.StatusBar
- app.manifest already configured for requireAdministrator + PerMonitorV2;
Release publish properties already set up for single-file self-contained
Build succeeds with 0 warnings under TreatWarningsAsErrors +
latest-recommended analyzers.
- .github/workflows/dotnet.yml: setup-dotnet@v4 with .NET 10, restore, build Release, test with trx logging, publish single-file self-contained win-x64 .exe, upload as artifact "WinTune-win-x64" - Runs on push + PR to main and csharp-port branches, plus manual dispatch - Test results always uploaded (even on test failure) for debugging - README documents both flavors: PowerShell (main) and self-contained .exe (csharp-port), with the dotnet publish command verified to produce a 63 MB single-file PE32+ GUI executable
…ag-resize, pixel scroll - Multi-select scan paths (Extended SelectionMode); Remove and Scan buttons accept SelectedItems via CommandParameter so Shift/Ctrl-click works and Scan respects selection (falls back to all paths when none selected). - Per-duplicate investigation UI: FileName computed property on DuplicateFile model; new File name column and Reveal-in-Explorer button column in the inner duplicates grid (shells explorer.exe /select,"<path>"). - Rightmost column drag-resizable in both Groups and inner files grids: switched from star-sized filler (which collapsed and fought the target column) to a fixed 20 px filler that always anchors the right edge. HorizontalScrollBarVisibility=Auto absorbs overflow when columns grow past visible width. - Pixel-based vertical scrolling on both grids (ScrollViewer.CanContentScroll=False + EnableRowVirtualization=False) so a single tall row with row details can be scrolled within instead of being treated as a single scroll unit. - PreviewMouseWheel handler on both grids — only marks Handled when scroll actually moved, so boundary-hits bubble up from inner grid to outer grid for seamless wheel scrolling.
… assets CI failed with NETSDK1047: 'project.assets.json doesn't have a target for net10.0-windows/win-x64'. Cause: the Release-conditional sets RuntimeIdentifier=win-x64, but `dotnet restore` runs config-agnostic and didn't see the conditional, so the assets file lacked the win-x64 target. `dotnet build -c Release` then activated the conditional and demanded RID-specific assets that weren't there. Solution-level `dotnet build -r win-x64` is rejected by the SDK (NETSDK1134), so we can't fix this in the workflow. Adding RuntimeIdentifiers (plural) at the App project level lets restore generate the win-x64 target unconditionally, and the conditional RuntimeIdentifier (singular) still drives publish.
…stall The .cmd shim only existed because the PowerShell version needed a double-clickable wrapper to invoke `powershell.exe -STA -ExecutionPolicy Bypass`. With the C# port now shipping as a self-contained .exe (single file, requireAdministrator manifest, no PowerShell host needed), the shim is obsolete. Updates: - Delete Launch-WinTune.cmd. - README: lead with the .exe install path, demote PowerShell version to "reference implementation" with direct script invocation. - README: refresh project layout to show src/ + tests/ tree alongside the preserved PowerShell tree. - README: drop the "launcher pins PowerShell 5.1" note (no launcher).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Replaces the original PowerShell + WPF + Launch-WinTune.cmd toolchain with a self-contained C# / WPF / .NET 10 single-file executable. Feature parity with the PowerShell version across all five tabs (Dashboard, Clean, Boost, Diagnose, Dedupe), plus several Dedupe UX improvements that didn't exist in the PowerShell original.
What's in the merge
Architecture (caea31e onward)
WinTune.Core(services + DTOs, no WPF refs) andWinTune.App(WPF executable, MVVM via CommunityToolkit.Mvvm 8.x, DI via Microsoft.Extensions.Hosting, Serilog file logging)EmptyWorkingSet,SHEmptyRecycleBinW,DnsFlushResolverCacheapp.manifestrequestsrequireAdministrator, PerMonitorV2 DPI, longPathAwareService layer ports (each PowerShell module ported with TDD)
finally-block service restart for wuauserv/bits, browser-running guard, env-var-derived paths)Tests — xUnit + FluentAssertions, 26 passing on every CI run
Dedupe UX improvements (ddb1c98)
This commit (943058c)
Test plan
dotnet build -c Release— 0 warnings, 0 errorsdotnet test— 26 passed, 2 skipped (destructive smoke tests)dotnet publish -c Release -r win-x64— single 63 MB self-contained exe