Skip to content

C# WPF .NET 10 port: replace PowerShell + .cmd launcher with self-contained WinTune.exe#1

Merged
danlinyu merged 34 commits into
mainfrom
csharp-port
May 10, 2026
Merged

C# WPF .NET 10 port: replace PowerShell + .cmd launcher with self-contained WinTune.exe#1
danlinyu merged 34 commits into
mainfrom
csharp-port

Conversation

@danlinyu

Copy link
Copy Markdown
Owner

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)

  • Two-project solution: WinTune.Core (services + DTOs, no WPF refs) and WinTune.App (WPF executable, MVVM via CommunityToolkit.Mvvm 8.x, DI via Microsoft.Extensions.Hosting, Serilog file logging)
  • LibraryImport-based P/Invoke for EmptyWorkingSet, SHEmptyRecycleBinW, DnsFlushResolverCache
  • app.manifest requests requireAdministrator, PerMonitorV2 DPI, longPathAware

Service layer ports (each PowerShell module ported with TDD)

  • MonitorService — CPU / RAM / disk snapshot + top-N processes
  • BoostService — EmptyWorkingSet + DNS flush + Explorer restart
  • CleanupService — preserves the safety invariants from the PowerShell incidents (reparse-point skip during recursion, finally-block service restart for wuauserv/bits, browser-running guard, env-var-derived paths)
  • StartupService — WMI + registry Run/RunOnce + Startup folders, deduped
  • DiagnoseService — 10 checks + 5 fixes with severity colour coding
  • DedupService — two-pass size + SHA1 dedup, RecycleBin send by default, scope-aware default scan roots

Tests — xUnit + FluentAssertions, 26 passing on every CI run

Dedupe UX improvements (ddb1c98)

  • Multi-select scan paths (Shift / Ctrl+click); Remove and Scan honour selection
  • Per-duplicate File name column + Reveal-in-Explorer button
  • Drag-resize on the rightmost column in both grids (fixed-width filler anchors the right edge)
  • Pixel-based vertical scrolling so a single tall expanded row can be scrolled within
  • Wheel handler with boundary fall-through so inner-grid wheel events bubble to outer grid at scroll limits

This commit (943058c)

  • Retires Launch-WinTune.cmd
  • README leads with the .exe install path; PowerShell version preserved as reference

Test plan

  • dotnet build -c Release — 0 warnings, 0 errors
  • dotnet test — 26 passed, 2 skipped (destructive smoke tests)
  • dotnet publish -c Release -r win-x64 — single 63 MB self-contained exe
  • CI green on csharp-port (run 25617851164)
  • Manual smoke: every tab exercised, Dashboard refresh ticks, Clean DnsCache, Boost Free RAM + Flush DNS + Open Task Manager Startup, Diagnose run + findings render, Dedupe scan + Reveal in Explorer + multi-select Remove + drag-resize + pixel-scroll

danlinyu added 30 commits May 8, 2026 23:49
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.
danlinyu added 4 commits May 9, 2026 13:39
- .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).
@danlinyu danlinyu merged commit dd2758c into main May 10, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant