diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..fd0cb20 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,35 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + test: + name: Analyze + Pester (Windows PowerShell 5.1) + runs-on: windows-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install Pester 5 + PSScriptAnalyzer + shell: powershell + run: | + Set-PSRepository PSGallery -InstallationPolicy Trusted + Install-Module Pester -MinimumVersion 5.5.0 -Force -SkipPublisherCheck -Scope CurrentUser + Install-Module PSScriptAnalyzer -Force -Scope CurrentUser + Get-Module Pester, PSScriptAnalyzer -ListAvailable | Sort-Object Name, Version | Format-Table Name, Version + + - name: Run analyzer + tests + shell: powershell + run: ./Run-Tests.ps1 + + - name: Upload Pester results + if: always() + uses: actions/upload-artifact@v4 + with: + name: pester-results + path: tests/results.xml diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml new file mode 100644 index 0000000..e6b0f46 --- /dev/null +++ b/.github/workflows/dotnet.yml @@ -0,0 +1,45 @@ +name: dotnet + +on: + push: + branches: [main, csharp-port] + pull_request: + branches: [main, csharp-port] + workflow_dispatch: + +jobs: + build: + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup .NET 10 + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '10.0.x' + + - name: Restore + run: dotnet restore + + - name: Build + run: dotnet build -c Release --no-restore + + - name: Test + run: dotnet test -c Release --no-build --logger "trx;LogFileName=test-results.trx" --results-directory TestResults + + - name: Upload test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: test-results + path: TestResults/ + + - name: Publish single-file self-contained + run: dotnet publish src/WinTune.App/WinTune.App.csproj -c Release -r win-x64 -o publish/win-x64 + + - name: Upload WinTune.exe artifact + uses: actions/upload-artifact@v4 + with: + name: WinTune-win-x64 + path: publish/win-x64/WinTune.exe + if-no-files-found: error diff --git a/.gitignore b/.gitignore index 88cb147..a87d27d 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,9 @@ logs/ *.log +# Test runner output +tests/results.xml + # Local toolchain (gh portable, etc.) .tools/ @@ -13,3 +16,19 @@ Thumbs.db desktop.ini ehthumbs.db $RECYCLE.BIN/ + +# .NET build output +bin/ +obj/ +*.user +.vs/ +publish/ +artifacts/ +TestResults/ +*.coverage +*.nupkg + +# C# port diagnostic artifacts +diag.log +diag.ready +WinTune.diag.ps1 diff --git a/Launch-WinTune.cmd b/Launch-WinTune.cmd deleted file mode 100644 index cad3159..0000000 --- a/Launch-WinTune.cmd +++ /dev/null @@ -1,4 +0,0 @@ -@echo off -REM WinTune launcher — uses Windows PowerShell 5.1 in STA mode. -REM Self-elevation is handled inside WinTune.ps1 (will trigger UAC). -powershell.exe -NoProfile -STA -ExecutionPolicy Bypass -File "%~dp0WinTune.ps1" diff --git a/PSScriptAnalyzerSettings.psd1 b/PSScriptAnalyzerSettings.psd1 new file mode 100644 index 0000000..51c1600 --- /dev/null +++ b/PSScriptAnalyzerSettings.psd1 @@ -0,0 +1,40 @@ +@{ + # Severity gate. Information findings are advisory; we fail CI only on + # Warning and Error. + Severity = @('Error','Warning') + + # Rules suppressed and why: + ExcludeRules = @( + # WinTune is an interactive WPF app, not a pipeline-friendly cmdlet + # library. State-changing UI-helper functions don't need ShouldProcess. + 'PSUseShouldProcessForStateChangingFunctions', + + # Many catch blocks intentionally swallow errors in UI-event handlers + # and the dispatcher poll loop -- they exist precisely to keep the + # window alive when a single tick fails. Documented at the call sites. + 'PSAvoidUsingEmptyCatchBlock', + + # Public function names are part of the dot-source surface advertised + # in README.md (Get-CleanupTargets, Get-StartupApps, Find-Duplicates, + # Get-TopProcesses, etc.). Renaming them now would break callers. + 'PSUseSingularNouns', + + # SHA1 is acceptable for personal-file deduplication and the choice is + # explained in the Dedup.psm1 header. Real-world collision risk on a + # single user's filesystem is effectively zero. + 'PSAvoidUsingBrokenHashAlgorithms', + + # `Run-Diagnose` in WinTune.ps1 is a local UI helper, not a cmdlet -- + # the prefix communicates "click this to do X" to a script reader. + 'PSUseApprovedVerbs', + + # The Async helper splats $Progress into the user script via @args, so + # PSSA can't see the binding. The parameter is intentionally present. + 'PSReviewUnusedParameter', + + # Run-Tests.ps1 prints colored section headers to the console. Write- + # Host is the right tool for that; the rule's complaint about + # capturability does not apply to a human-facing test runner. + 'PSAvoidUsingWriteHost' + ) +} diff --git a/README.md b/README.md index 60b3233..dda39b1 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,9 @@ # WinTune A small, single-window Windows 11 performance toolkit. Monitor live system load, -clean accumulated junk, and free memory — all from one place. Pure PowerShell + -WPF, no installer, no compiled binary. +clean accumulated junk, and free memory — all from one place. Ships as a +self-contained C# / WPF / .NET 10 executable; original PowerShell + WPF +script preserved as a reference implementation. ## Why this exists @@ -20,21 +21,27 @@ WinTune addresses each of these with one click. ## What it does (and what it deliberately doesn't) -### Three tabs +### Five tabs **Dashboard** — live CPU, RAM, and disk-C: bars (refresh every 2 s) plus a top-10 process list sorted by working-set RAM. **Clean** — pick what to clear, click Run: - User Temp (`%TEMP%`) -- System Temp (`C:\Windows\Temp`) +- System Temp (`%SystemRoot%\Temp`) - Windows Prefetch - Windows Error Reports (`WER\Report{Archive,Queue}`) -- Windows Update download cache (stops `wuauserv` + `bits` briefly, then restarts them) +- Windows Update download cache (stops `wuauserv` + `bits` briefly, then restarts + them — restart is in a `finally` block so the services come back even if the + delete fails) - Edge / Chrome / Firefox caches (skipped if the browser is running — close it first) - Empty Recycle Bin - Flush DNS resolver cache +Reparse points (junctions, symlinks) inside any cleanup target are skipped — never +followed — so a stray junction can't redirect the recursive delete somewhere +unexpected. + **Boost** - Free RAM — calls Win32 `EmptyWorkingSet` on every process. Reduces working-set pressure without killing anything. @@ -44,6 +51,32 @@ process list sorted by working-set RAM. folders), with a button that opens **Task Manager → Startup** if you want to disable any of them yourself. +**Diagnose** — find what's making Explorer slow. Each finding is colour-coded +(Red = act, Yellow = worth a look, Green = OK) with a one-line hint. Checks +include disk health, free-space pressure, multiple cloud sync shell extensions, +Quick Access bloat, Search index emptiness, DiagTrack telemetry, the Win11 +right-click overlay, pagefile placement, startup-app count, and RAM pressure. + +Backed by safe one-click fixes: +- **Reset Quick Access** — clears stale `Recent` and `AutomaticDestinations` + shortcuts, a common cause of Explorer hangs on dead network paths. +- **Disable Telemetry** — stops + disables `DiagTrack` *and* sets the + `AllowTelemetry` Group Policy value so Windows Update cumulative updates + don't silently re-enable it. +- **Apply Classic Right-Click** — restores the Win10-style instant context + menu (Win11's "Show more options" overlay is the slow path). +- **Rebuild Search Index** — kicks Windows Search to re-index from scratch. +- **Undo Classic Right-Click** — reverts the above. + +**Dedupe** — find and remove duplicate files in user folders. +- Two-pass scan: group by exact size first, then SHA1-hash only size-matches. +- Skips reparse points (OneDrive on-demand files won't trigger a download) and + system files. +- Auto-select helpers: keep oldest / keep newest / keep shortest path. +- Safety stop: refuses to delete every file in a duplicate group — you must + un-tick at least one row per group to keep a copy. +- Deletes go to the Recycle Bin so you can recover. + ### Out of scope on purpose WinTune **does not**: @@ -59,16 +92,33 @@ If you want the heavier stuff, use Sysinternals' RAMMap or Autoruns by hand. ## Install & run -1. Clone or download this repo: - ```powershell - git clone https://github.com/danlinyu/WinTune.git - ``` -2. Double-click **`Launch-WinTune.cmd`**. -3. Approve the UAC prompt (admin rights are needed to clear System Temp, - Windows Update cache, and Prefetch). +WinTune ships as a self-contained C# WPF executable. The original PowerShell + +WPF script is preserved in this repo as a reference implementation. + +### Self-contained .exe (recommended) + +Single ~63 MB `WinTune.exe`, no PowerShell or .NET runtime needed on the +target machine. Either grab the latest CI artifact from the +[Actions tab](https://github.com/danlinyu/WinTune/actions) or build it +yourself with the .NET 10 SDK: + +```powershell +dotnet publish src/WinTune.App/WinTune.App.csproj -c Release -r win-x64 -o publish/win-x64 +.\publish\win-x64\WinTune.exe +``` + +UAC will prompt automatically (the app manifest requests `requireAdministrator`). + +### PowerShell version (reference implementation) + +The original Windows PowerShell 5.1 + WPF version is still here for reference. +WPF on PowerShell 5.1 (STA mode) is required: + +```powershell +powershell.exe -NoProfile -STA -ExecutionPolicy Bypass -File .\WinTune.ps1 +``` -That's it — no install step, no dependencies beyond the Windows PowerShell 5.1 -that ships with every Windows 10 / 11 install. +Self-elevation is handled inside the script (UAC prompt fires automatically). ### How safe is it? @@ -85,34 +135,65 @@ browser profile. ## Tested on - Windows 11 Pro for Workstations, build 22631 -- Windows PowerShell 5.1 (the launcher pins this rather than pwsh 7 — WPF on - pwsh 7 is currently flaky) +- C# build: .NET 10 SDK, single-file self-contained `win-x64` +- PowerShell reference build: Windows PowerShell 5.1 STA (WPF on pwsh 7 is flaky) ## Project layout ``` WinTune/ -├── WinTune.ps1 entry point — builds the WPF window and wires events -├── Launch-WinTune.cmd double-clickable wrapper +├── WinTune.sln .NET 10 solution (Core + App + Tests) +├── src/WinTune.Core/ class library — services and DTOs (no WPF refs) +├── src/WinTune.App/ WPF executable, MVVM via CommunityToolkit.Mvvm +├── tests/ xUnit + FluentAssertions +├── .github/workflows/ CI — dotnet.yml (C#) + ci.yml (PowerShell) +│ +├── WinTune.ps1 PowerShell reference — builds WPF window directly ├── modules/ │ ├── Monitor.psm1 Get-PerfSnapshot, Get-TopProcesses -│ ├── Cleanup.psm1 Invoke-Cleanup, Get-CleanupTargets, Format-Bytes +│ ├── Cleanup.psm1 Invoke-Cleanup, Get-CleanupTargets, Format-Bytes, +│ │ Get-LastCleanupLog │ ├── Boost.psm1 Clear-WorkingSets, Restart-Explorer, Clear-DNSCacheSafe -│ └── Startup.psm1 Get-StartupApps, Open-StartupTaskManager +│ ├── Startup.psm1 Get-StartupApps, Open-StartupTaskManager +│ ├── Diagnose.psm1 Invoke-Diagnostics + Reset-QuickAccess, Disable-Telemetry, +│ │ Enable-/Disable-ClassicRightClick, Start-SearchIndexRebuild +│ └── Dedup.psm1 Find-Duplicates, Remove-DuplicateFiles, Get-DefaultScanRoots ├── ui/MainWindow.xaml WPF layout (loaded at runtime by WinTune.ps1) ├── README.md └── LICENSE MIT ``` -The four modules are pure functions — easy to dot-source and use from any +The six modules are pure functions — easy to dot-source and use from any PowerShell prompt: ```powershell Import-Module .\modules\Monitor.psm1 Get-PerfSnapshot Get-TopProcesses -Count 5 + +Import-Module .\modules\Diagnose.psm1 +Invoke-Diagnostics | Format-Table Severity, Title, Detail + +Import-Module .\modules\Dedup.psm1 +Find-Duplicates -Paths $HOME\Downloads -MinSizeBytes 10MB ``` +## Development + +Run the test gate (PSScriptAnalyzer + Pester) locally: + +```powershell +# Once +Install-Module Pester -MinimumVersion 5.5.0 -Force -SkipPublisherCheck -Scope CurrentUser +Install-Module PSScriptAnalyzer -Force -Scope CurrentUser + +# Each commit +.\Run-Tests.ps1 +``` + +GitHub Actions runs the same script on every push and PR +([`.github/workflows/ci.yml`](.github/workflows/ci.yml)). + ## License MIT — see [LICENSE](LICENSE). diff --git a/Run-Tests.ps1 b/Run-Tests.ps1 new file mode 100644 index 0000000..212a9da --- /dev/null +++ b/Run-Tests.ps1 @@ -0,0 +1,51 @@ +# Run-Tests.ps1 -- runs PSScriptAnalyzer + Pester. Used locally and by CI. +# +# Usage: +# .\Run-Tests.ps1 # both +# .\Run-Tests.ps1 -NoAnalyze # Pester only +# .\Run-Tests.ps1 -NoPester # PSSA only +# +# Exits with non-zero code if either gate fails (suitable for CI). + +[CmdletBinding()] +param( + [switch]$NoAnalyze, + [switch]$NoPester +) + +$ErrorActionPreference = 'Stop' +$root = Split-Path -Parent $MyInvocation.MyCommand.Path +$failed = 0 + +if (-not $NoAnalyze) { + Write-Host '=== PSScriptAnalyzer ===' -ForegroundColor Cyan + Import-Module PSScriptAnalyzer -ErrorAction Stop + $settings = Join-Path $root 'PSScriptAnalyzerSettings.psd1' + $results = Invoke-ScriptAnalyzer -Path $root -Recurse -Settings $settings + if ($results) { + $results | Format-Table -AutoSize Severity, RuleName, ScriptName, Line, Message + Write-Host "PSScriptAnalyzer: $($results.Count) issue(s)" -ForegroundColor Red + $failed = 1 + } else { + Write-Host 'PSScriptAnalyzer: clean' -ForegroundColor Green + } +} + +if (-not $NoPester) { + Write-Host '' + Write-Host '=== Pester ===' -ForegroundColor Cyan + Import-Module Pester -MinimumVersion 5.5.0 -ErrorAction Stop + $config = New-PesterConfiguration + $config.Run.Path = (Join-Path $root 'tests') + $config.Output.Verbosity = 'Detailed' + $config.TestResult.Enabled = $true + $config.TestResult.OutputPath = (Join-Path $root 'tests\results.xml') + $config.TestResult.OutputFormat = 'NUnitXml' + $r = Invoke-Pester -Configuration $config + if ($r.FailedCount -gt 0) { + Write-Host "Pester: $($r.FailedCount) failure(s)" -ForegroundColor Red + $failed = 1 + } +} + +exit $failed diff --git a/WinTune.ps1 b/WinTune.ps1 index 9252bb5..4738b19 100644 --- a/WinTune.ps1 +++ b/WinTune.ps1 @@ -22,6 +22,7 @@ Import-Module (Join-Path $ScriptRoot 'modules\Boost.psm1') -Force Import-Module (Join-Path $ScriptRoot 'modules\Startup.psm1') -Force Import-Module (Join-Path $ScriptRoot 'modules\Diagnose.psm1') -Force Import-Module (Join-Path $ScriptRoot 'modules\Dedup.psm1') -Force +Import-Module (Join-Path $ScriptRoot 'modules\Async.psm1') -Force #endregion #region Load XAML @@ -83,6 +84,198 @@ function Get-SelectedTargets { } return @($picked) } + +# --- Background-op poll handlers ---------------------------------------------- +# Each Update-*Poll runs at script scope so it can see Set-Status, Format-Bytes, +# Receive-AsyncProgress, etc. Add_Tick wires up a trivial delegate that just +# calls these. Inline scriptblocks created INSIDE click handlers do not bind +# to the script's session state when invoked by the WPF dispatcher, so +# function lookups fail silently from there. Same pattern as the dashboard +# timer (which has always worked), now applied to the four async pollers. + +function Update-CleanupPoll { + try { + if (-not $script:CleanupOp) { return } + foreach ($info in (Receive-AsyncProgress $script:CleanupOp)) { + if ($info.Phase -eq 'Start') { + Set-Status "[$($info.Index)/$($info.Total)] Cleaning $($info.Target)..." + } + } + + if (Test-AsyncOpComplete $script:CleanupOp) { + $script:CleanupPoller.Stop() + $r = Receive-AsyncOp $script:CleanupOp + $script:CleanupOp = $null + $ui.CleanRunBtn.IsEnabled = $true + $ui.CleanCancelBtn.IsEnabled = $false + + if (-not $r.Success) { + Set-Status "Cleanup error: $($r.Error)" + return + } + + $results = @($r.Result) + $totalBytes = ($results | Measure-Object -Property BytesFreed -Sum).Sum + $display = $results | ForEach-Object { + $note = if ($_.Skipped) { + $_.SkipReason + } elseif ($_.Errors.Count) { + $sample = ($_.Errors | Select-Object -First 1) -as [string] + if ($sample.Length -gt 90) { $sample = $sample.Substring(0,87) + '...' } + "$($_.Errors.Count) error(s): $sample" + } else { '' } + [pscustomobject]@{ + Target = $_.Target + Status = if ($_.Skipped) { 'Skipped' } elseif ($_.Errors.Count -gt 0) { 'Partial' } else { 'OK' } + Items = $_.FilesRemoved + Freed = (Format-Bytes -Bytes $_.BytesFreed) + Note = $note + } + } + $ui.CleanResultsGrid.ItemsSource = @($display) + $ui.CleanTotalLbl.Text = "Freed: $(Format-Bytes -Bytes $totalBytes)" + $logPath = Get-LastCleanupLog + if ($logPath) { + $ui.CleanOpenLogBtn.IsEnabled = $true + $ui.CleanOpenLogBtn.Tag = $logPath + } + Set-Status "Cleanup done. Freed $(Format-Bytes -Bytes $totalBytes). Log: $logPath" + } + } catch { + $msg = "Cleanup poller error: $($_.Exception.Message)" + Write-Host $msg -ForegroundColor Red + try { Set-Status $msg } catch {} + try { $script:CleanupPoller.Stop() } catch {} + $script:CleanupOp = $null + try { + $ui.CleanRunBtn.IsEnabled = $true + $ui.CleanCancelBtn.IsEnabled = $false + } catch {} + } +} + +function Update-DedupePoll { + try { + if (-not $script:DedupeOp) { return } + foreach ($info in (Receive-AsyncProgress $script:DedupeOp)) { + $msg = switch ($info.Phase) { + 'Scan' { "Phase 1/2: scanned $($info.FilesSeen) files..." } + 'HashStart' { "Phase 2/2: hashing $($info.Candidates) size-collision candidates..." } + 'Hash' { "Hashed $($info.Hashed) / $($info.Total)..." } + 'Done' { "Done. $($info.Groups) duplicate group(s) found." } + default { '' } + } + if ($msg) { $ui.DedupeStatusLbl.Text = $msg } + } + + if (Test-AsyncOpComplete $script:DedupeOp) { + $script:DedupePoller.Stop() + $r = Receive-AsyncOp $script:DedupeOp + $script:DedupeOp = $null + $ui.DedupeScanBtn.IsEnabled = $true + $ui.DedupeCancelBtn.IsEnabled = $false + + if (-not $r.Success) { + $ui.DedupeStatusLbl.Text = "Scan error: $($r.Error)" + Set-Status "Dedupe scan error: $($r.Error)" + return + } + + $groups = @($r.Result) + $script:DedupeGroups = $groups + + $rows = foreach ($g in $groups) { + foreach ($f in $g.Files) { + [pscustomobject]@{ + Group = $g.GroupId + Size = (Format-Bytes -Bytes $g.SizeBytes) + Path = $f.FullName + Modified = $f.LastWriteTime.ToString('yyyy-MM-dd HH:mm') + SizeBytes = [long]$g.SizeBytes + } + } + } + $ui.DedupeGrid.ItemsSource = @($rows) + + $totalWasted = ($groups | Measure-Object -Property WastedBytes -Sum).Sum + if (-not $totalWasted) { $totalWasted = 0 } + $msg = "Found $($groups.Count) group(s), $((@($rows)).Count) duplicate file(s). Reclaimable: $(Format-Bytes -Bytes $totalWasted)." + $ui.DedupeStatusLbl.Text = $msg + Set-Status $msg + } + } catch { + $msg = "Dedupe poller error: $($_.Exception.Message)" + Write-Host $msg -ForegroundColor Red + try { Set-Status $msg } catch {} + try { $script:DedupePoller.Stop() } catch {} + $script:DedupeOp = $null + try { + $ui.DedupeScanBtn.IsEnabled = $true + $ui.DedupeCancelBtn.IsEnabled = $false + } catch {} + } +} + +function Update-DiagPoll { + try { + if (-not $script:DiagOp) { return } + if (Test-AsyncOpComplete $script:DiagOp) { + $script:DiagPoller.Stop() + $r = Receive-AsyncOp $script:DiagOp + $script:DiagOp = $null + $ui.DiagRunBtn.IsEnabled = $true + + if (-not $r.Success) { + Set-Status "Diagnostics error: $($r.Error)" + return + } + + $f = @($r.Result) + $ui.DiagFindingsGrid.ItemsSource = $f + $red = ($f | Where-Object Severity -eq 'Red' | Measure-Object).Count + $yellow = ($f | Where-Object Severity -eq 'Yellow' | Measure-Object).Count + $green = ($f | Where-Object Severity -eq 'Green' | Measure-Object).Count + $ui.DiagSummaryLbl.Text = "Findings: $red red, $yellow yellow, $green green" + $ui.DiagSummaryLbl.Foreground = if ($red -gt 0) { 'Red' } elseif ($yellow -gt 0) { '#FFB58900' } else { '#FF59A14F' } + Set-Status "Diagnostics done. $red red, $yellow yellow, $green green." + } + } catch { + $msg = "Diagnostics poller error: $($_.Exception.Message)" + Write-Host $msg -ForegroundColor Red + try { Set-Status $msg } catch {} + try { $script:DiagPoller.Stop() } catch {} + $script:DiagOp = $null + try { $ui.DiagRunBtn.IsEnabled = $true } catch {} + } +} + +function Update-BoostPoll { + try { + if (-not $script:BoostOp) { return } + if (Test-AsyncOpComplete $script:BoostOp) { + $script:BoostPoller.Stop() + $r = Receive-AsyncOp $script:BoostOp + $script:BoostOp = $null + $ui.BoostFreeRamBtn.IsEnabled = $true + + if (-not $r.Success) { + Set-Status "Free RAM error: $($r.Error)" + return + } + $msg = "Trimmed $($r.Result.ProcessesTrimmed) process(es) (skipped $($r.Result.ProcessesSkipped)). Freed approx $(Format-Bytes -Bytes $r.Result.BytesFreed)." + $ui.BoostResultLbl.Text = $msg + Set-Status $msg + } + } catch { + $msg = "Boost poller error: $($_.Exception.Message)" + Write-Host $msg -ForegroundColor Red + try { Set-Status $msg } catch {} + try { $script:BoostPoller.Stop() } catch {} + $script:BoostOp = $null + try { $ui.BoostFreeRamBtn.IsEnabled = $true } catch {} + } +} + #endregion #region Wire events @@ -92,6 +285,27 @@ $timer = New-Object System.Windows.Threading.DispatcherTimer $timer.Interval = [TimeSpan]::FromSeconds(2) $timer.Add_Tick({ try { Update-Dashboard } catch { Set-Status "Dashboard refresh failed: $($_.Exception.Message)" } }) +# --- Async-op pollers --- +# Created once at script top level so the Add_Tick scriptblocks bind to the +# script's session state. Scriptblocks created at runtime inside click +# handlers do not -- WPF dispatches them in a context where script-scope +# function lookup fails. Click handlers re-Start() these pre-wired timers. +$script:CleanupPoller = New-Object System.Windows.Threading.DispatcherTimer +$script:CleanupPoller.Interval = [TimeSpan]::FromMilliseconds(150) +$script:CleanupPoller.Add_Tick({ Update-CleanupPoll }) + +$script:DedupePoller = New-Object System.Windows.Threading.DispatcherTimer +$script:DedupePoller.Interval = [TimeSpan]::FromMilliseconds(150) +$script:DedupePoller.Add_Tick({ Update-DedupePoll }) + +$script:DiagPoller = New-Object System.Windows.Threading.DispatcherTimer +$script:DiagPoller.Interval = [TimeSpan]::FromMilliseconds(150) +$script:DiagPoller.Add_Tick({ Update-DiagPoll }) + +$script:BoostPoller = New-Object System.Windows.Threading.DispatcherTimer +$script:BoostPoller.Interval = [TimeSpan]::FromMilliseconds(150) +$script:BoostPoller.Add_Tick({ Update-BoostPoll }) + # --- Cleanup tab --- $ui.CleanSelectAllBtn.Add_Click({ foreach ($n in 'ChkUserTemp','ChkSystemTemp','ChkPrefetch','ChkWER','ChkWindowsUpdate','ChkEdgeCache','ChkChromeCache','ChkFirefoxCache','ChkRecycleBin','ChkDNSCache') { @@ -105,45 +319,39 @@ $ui.CleanSelectNoneBtn.Add_Click({ }) $ui.CleanRunBtn.Add_Click({ + if ($script:CleanupOp) { Set-Status "Cleanup already running."; return } + $targets = Get-SelectedTargets if (-not $targets -or $targets.Count -eq 0) { Set-Status "No cleanup targets selected." return } - $ui.CleanRunBtn.IsEnabled = $false + + $ui.CleanRunBtn.IsEnabled = $false + $ui.CleanCancelBtn.IsEnabled = $true Set-Status "Cleaning $($targets.Count) target(s)..." - try { - $results = Invoke-Cleanup -Targets $targets - $totalBytes = ($results | Measure-Object -Property BytesFreed -Sum).Sum - $display = $results | ForEach-Object { - $note = if ($_.Skipped) { - $_.SkipReason - } elseif ($_.Errors.Count) { - $sample = ($_.Errors | Select-Object -First 1) -as [string] - if ($sample.Length -gt 90) { $sample = $sample.Substring(0,87) + '...' } - "$($_.Errors.Count) error(s): $sample" - } else { '' } - [pscustomobject]@{ - Target = $_.Target - Status = if ($_.Skipped) { 'Skipped' } elseif ($_.Errors.Count -gt 0) { 'Partial' } else { 'OK' } - Items = $_.FilesRemoved - Freed = (Format-Bytes -Bytes $_.BytesFreed) - Note = $note - } - } - $ui.CleanResultsGrid.ItemsSource = @($display) - $ui.CleanTotalLbl.Text = "Freed: $(Format-Bytes -Bytes $totalBytes)" - $logPath = Get-LastCleanupLog - if ($logPath) { - $ui.CleanOpenLogBtn.IsEnabled = $true - $ui.CleanOpenLogBtn.Tag = $logPath - } - Set-Status "Cleanup done. Freed $(Format-Bytes -Bytes $totalBytes). Log: $logPath" - } catch { - Set-Status "Cleanup error: $($_.Exception.Message)" - } finally { - $ui.CleanRunBtn.IsEnabled = $true + + $script:CleanupOp = Start-AsyncOp -Script { + param($targets, $modPath, $Progress) + Import-Module $modPath -Force + Invoke-Cleanup -Targets $targets -OnProgress $Progress + } -Arguments @{ + targets = $targets + modPath = (Join-Path $ScriptRoot 'modules\Cleanup.psm1') + } + + $script:CleanupPoller.Start() +}) + +$ui.CleanCancelBtn.Add_Click({ + if ($script:CleanupOp) { + Stop-AsyncOp $script:CleanupOp + $script:CleanupOp = $null } + try { $script:CleanupPoller.Stop() } catch {} + $ui.CleanRunBtn.IsEnabled = $true + $ui.CleanCancelBtn.IsEnabled = $false + Set-Status "Cleanup cancelled (in-flight target may still complete)." }) $ui.CleanOpenLogBtn.Add_Click({ @@ -157,18 +365,20 @@ $ui.CleanOpenLogBtn.Add_Click({ # --- Boost tab --- $ui.BoostFreeRamBtn.Add_Click({ - Set-Status "Trimming working sets..." + if ($script:BoostOp) { Set-Status "RAM trim already running."; return } + $ui.BoostFreeRamBtn.IsEnabled = $false - try { - $r = Clear-WorkingSets - $msg = "Trimmed $($r.ProcessesTrimmed) process(es) (skipped $($r.ProcessesSkipped)). Freed approx $(Format-Bytes -Bytes $r.BytesFreed)." - $ui.BoostResultLbl.Text = $msg - Set-Status $msg - } catch { - Set-Status "Free RAM error: $($_.Exception.Message)" - } finally { - $ui.BoostFreeRamBtn.IsEnabled = $true + Set-Status "Trimming working sets..." + + $script:BoostOp = Start-AsyncOp -Script { + param($modPath, $Progress) + Import-Module $modPath -Force + Clear-WorkingSets + } -Arguments @{ + modPath = (Join-Path $ScriptRoot 'modules\Boost.psm1') } + + $script:BoostPoller.Start() }) $ui.BoostRestartExplorerBtn.Add_Click({ @@ -194,17 +404,23 @@ $ui.BoostOpenTaskMgrBtn.Add_Click({ Open-StartupTaskManager; Set-Status "Task Ma # --- Diagnose tab --- function Run-Diagnose { + # Async + fire-and-forget. CIM queries inside Invoke-Diagnostics typically + # take 1-2 seconds; previously this blocked the dispatcher (visible as a + # frozen window on startup since Add_Loaded calls this synchronously). + if ($script:DiagOp) { return } # already in-flight + + $ui.DiagRunBtn.IsEnabled = $false Set-Status "Running diagnostics..." - try { - $f = @(Invoke-Diagnostics) - $ui.DiagFindingsGrid.ItemsSource = $f - $red = ($f | Where-Object Severity -eq 'Red' | Measure-Object).Count - $yellow = ($f | Where-Object Severity -eq 'Yellow' | Measure-Object).Count - $green = ($f | Where-Object Severity -eq 'Green' | Measure-Object).Count - $ui.DiagSummaryLbl.Text = "Findings: $red red, $yellow yellow, $green green" - $ui.DiagSummaryLbl.Foreground = if ($red -gt 0) { 'Red' } elseif ($yellow -gt 0) { '#FFB58900' } else { '#FF59A14F' } - Set-Status "Diagnostics done. $red red, $yellow yellow, $green green." - } catch { Set-Status "Diagnostics error: $($_.Exception.Message)" } + + $script:DiagOp = Start-AsyncOp -Script { + param($modPath, $Progress) + Import-Module $modPath -Force + Invoke-Diagnostics + } -Arguments @{ + modPath = (Join-Path $ScriptRoot 'modules\Diagnose.psm1') + } + + $script:DiagPoller.Start() } $ui.DiagRunBtn.Add_Click({ Run-Diagnose }) @@ -256,14 +472,6 @@ $ui.FixRebuildIndexBtn.Add_Click({ # without re-scanning. $script:DedupeGroups = @() -function Format-Bytes-Local { - param([long]$B) - if ($B -ge 1GB) { return "{0:N2} GB" -f ($B / 1GB) } - if ($B -ge 1MB) { return "{0:N2} MB" -f ($B / 1MB) } - if ($B -ge 1KB) { return "{0:N2} KB" -f ($B / 1KB) } - return "$B B" -} - function Reset-DedupePaths { $ui.DedupePathsList.Items.Clear() foreach ($p in (Get-DefaultScanRoots)) { [void]$ui.DedupePathsList.Items.Add($p) } @@ -288,63 +496,43 @@ $ui.DedupeRemovePathBtn.Add_Click({ }) $ui.DedupeScanBtn.Add_Click({ + if ($script:DedupeOp) { Set-Status "Dedupe scan already running."; return } + $paths = @($ui.DedupePathsList.Items) if (-not $paths -or $paths.Count -eq 0) { Set-Status "No paths to scan. Click 'Defaults' or 'Add path...' first." return } - $minSizeRaw = $ui.DedupeMinSizeCombo.SelectedItem.Tag - $minSize = [long]$minSizeRaw + $minSize = [long]$ui.DedupeMinSizeCombo.SelectedItem.Tag - $ui.DedupeScanBtn.IsEnabled = $false - $ui.DedupeStatusLbl.Text = "Scanning..." + $ui.DedupeScanBtn.IsEnabled = $false + $ui.DedupeCancelBtn.IsEnabled = $true + $ui.DedupeStatusLbl.Text = "Scanning..." Set-Status "Dedupe scan starting on $($paths.Count) path(s)..." - # Force the UI to repaint before the synchronous scan blocks the dispatcher. - $window.Dispatcher.Invoke([Action]{}, [System.Windows.Threading.DispatcherPriority]::Render) - - try { - $progressCb = { - param($info) - try { - $msg = switch ($info.Phase) { - 'Scan' { "Phase 1/2: scanned $($info.FilesSeen) files..." } - 'HashStart' { "Phase 2/2: hashing $($info.Candidates) size-collision candidates..." } - 'Hash' { "Hashed $($info.Hashed) / $($info.Total)..." } - 'Done' { "Done. $($info.Groups) duplicate group(s) found." } - default { '' } - } - $ui.DedupeStatusLbl.Text = $msg - $window.Dispatcher.Invoke([Action]{}, [System.Windows.Threading.DispatcherPriority]::Background) - } catch {} - } - $groups = @(Find-Duplicates -Paths $paths -MinSizeBytes $minSize -OnProgress $progressCb) - $script:DedupeGroups = $groups + $script:DedupeOp = Start-AsyncOp -Script { + param($paths, $minSize, $modPath, $Progress) + Import-Module $modPath -Force + Find-Duplicates -Paths $paths -MinSizeBytes $minSize -OnProgress $Progress + } -Arguments @{ + paths = $paths + minSize = $minSize + modPath = (Join-Path $ScriptRoot 'modules\Dedup.psm1') + } - # Flatten for grid display. - $rows = foreach ($g in $groups) { - foreach ($f in $g.Files) { - [pscustomobject]@{ - Group = $g.GroupId - Size = (Format-Bytes-Local $g.SizeBytes) - Path = $f.FullName - Modified = $f.LastWriteTime.ToString('yyyy-MM-dd HH:mm') - SizeBytes = [long]$g.SizeBytes - } - } - } - $ui.DedupeGrid.ItemsSource = @($rows) + $script:DedupePoller.Start() +}) - $totalWasted = ($groups | Measure-Object -Property WastedBytes -Sum).Sum - if (-not $totalWasted) { $totalWasted = 0 } - $msg = "Found $($groups.Count) group(s), $((@($rows)).Count) duplicate file(s). Reclaimable: $(Format-Bytes-Local $totalWasted)." - $ui.DedupeStatusLbl.Text = $msg - Set-Status $msg - } catch { - Set-Status "Dedupe scan error: $($_.Exception.Message)" - } finally { - $ui.DedupeScanBtn.IsEnabled = $true +$ui.DedupeCancelBtn.Add_Click({ + if ($script:DedupeOp) { + Stop-AsyncOp $script:DedupeOp + $script:DedupeOp = $null } + try { $script:DedupePoller.Stop() } catch {} + $ui.DedupeScanBtn.IsEnabled = $true + $ui.DedupeCancelBtn.IsEnabled = $false + $ui.DedupeStatusLbl.Text = "Cancelled." + Set-Status "Dedupe scan cancelled." }) function Set-DedupeSelection { @@ -367,7 +555,7 @@ function Set-DedupeSelection { $n = $ui.DedupeGrid.SelectedItems.Count $totalSelectedBytes = 0L foreach ($r in $ui.DedupeGrid.SelectedItems) { $totalSelectedBytes += [long]$r.SizeBytes } - Set-Status "Selected $n file(s) for deletion ($(Format-Bytes-Local $totalSelectedBytes))." + Set-Status "Selected $n file(s) for deletion ($(Format-Bytes -Bytes $totalSelectedBytes))." } $ui.DedupeAutoOldestBtn.Add_Click({ @@ -403,7 +591,7 @@ $ui.DedupeDeleteBtn.Add_Click({ $totalBytes = ($sel | Measure-Object -Property SizeBytes -Sum).Sum $confirm = [System.Windows.MessageBox]::Show( - "Send $($sel.Count) file(s) ($(Format-Bytes-Local $totalBytes)) to the Recycle Bin?", + "Send $($sel.Count) file(s) ($(Format-Bytes -Bytes $totalBytes)) to the Recycle Bin?", "WinTune Dedupe -- confirm", 'OKCancel', 'Question') if ($confirm -ne 'OK') { return } @@ -411,7 +599,7 @@ $ui.DedupeDeleteBtn.Add_Click({ Set-Status "Sending $($paths.Count) file(s) to Recycle Bin..." try { $r = Remove-DuplicateFiles -Paths $paths - $msg = "Deleted $($r.Deleted) file(s), freed $(Format-Bytes-Local $r.BytesFreed)." + $msg = "Deleted $($r.Deleted) file(s), freed $(Format-Bytes -Bytes $r.BytesFreed)." if ($r.Errors.Count) { $msg += " Errors: $($r.Errors.Count)." } $ui.DedupeStatusLbl.Text = $msg Set-Status $msg @@ -438,7 +626,22 @@ $window.Add_Loaded({ } }) -$window.Add_Closed({ try { $timer.Stop() } catch {} }) +$window.Add_Closed({ + # Stop dashboard refresh. + try { $timer.Stop() } catch {} + + # Cancel any in-flight background ops and stop their pollers, otherwise the + # process can linger after the window closes (especially during a long + # dedup scan). + foreach ($n in 'CleanupOp','DedupeOp','DiagOp','BoostOp') { + $op = Get-Variable -Name $n -Scope Script -ValueOnly -ErrorAction SilentlyContinue + if ($op) { try { Stop-AsyncOp $op } catch {} } + } + foreach ($n in 'CleanupPoller','DedupePoller','DiagPoller','BoostPoller') { + $p = Get-Variable -Name $n -Scope Script -ValueOnly -ErrorAction SilentlyContinue + if ($p) { try { $p.Stop() } catch {} } + } +}) #endregion # Show the window. Suppress null output when WPF returns Nullable. diff --git a/WinTune.sln b/WinTune.sln new file mode 100644 index 0000000..62ca573 --- /dev/null +++ b/WinTune.sln @@ -0,0 +1,71 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72D-47B6-A68D-7590B98EB39B}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WinTune.Core", "src\WinTune.Core\WinTune.Core.csproj", "{C8FE8553-DAB6-493D-B141-DF6D9E744212}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WinTune.App", "src\WinTune.App\WinTune.App.csproj", "{FEC7A6AA-3D9B-4062-B95F-B9A48AB0C856}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0AB3BF05-4346-4AA6-1389-037BE0695223}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WinTune.Core.Tests", "tests\WinTune.Core.Tests\WinTune.Core.Tests.csproj", "{9DF2403C-2215-4C4D-A432-8961FC905CB2}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 + Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {C8FE8553-DAB6-493D-B141-DF6D9E744212}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C8FE8553-DAB6-493D-B141-DF6D9E744212}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C8FE8553-DAB6-493D-B141-DF6D9E744212}.Debug|x64.ActiveCfg = Debug|Any CPU + {C8FE8553-DAB6-493D-B141-DF6D9E744212}.Debug|x64.Build.0 = Debug|Any CPU + {C8FE8553-DAB6-493D-B141-DF6D9E744212}.Debug|x86.ActiveCfg = Debug|Any CPU + {C8FE8553-DAB6-493D-B141-DF6D9E744212}.Debug|x86.Build.0 = Debug|Any CPU + {C8FE8553-DAB6-493D-B141-DF6D9E744212}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C8FE8553-DAB6-493D-B141-DF6D9E744212}.Release|Any CPU.Build.0 = Release|Any CPU + {C8FE8553-DAB6-493D-B141-DF6D9E744212}.Release|x64.ActiveCfg = Release|Any CPU + {C8FE8553-DAB6-493D-B141-DF6D9E744212}.Release|x64.Build.0 = Release|Any CPU + {C8FE8553-DAB6-493D-B141-DF6D9E744212}.Release|x86.ActiveCfg = Release|Any CPU + {C8FE8553-DAB6-493D-B141-DF6D9E744212}.Release|x86.Build.0 = Release|Any CPU + {FEC7A6AA-3D9B-4062-B95F-B9A48AB0C856}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {FEC7A6AA-3D9B-4062-B95F-B9A48AB0C856}.Debug|Any CPU.Build.0 = Debug|Any CPU + {FEC7A6AA-3D9B-4062-B95F-B9A48AB0C856}.Debug|x64.ActiveCfg = Debug|Any CPU + {FEC7A6AA-3D9B-4062-B95F-B9A48AB0C856}.Debug|x64.Build.0 = Debug|Any CPU + {FEC7A6AA-3D9B-4062-B95F-B9A48AB0C856}.Debug|x86.ActiveCfg = Debug|Any CPU + {FEC7A6AA-3D9B-4062-B95F-B9A48AB0C856}.Debug|x86.Build.0 = Debug|Any CPU + {FEC7A6AA-3D9B-4062-B95F-B9A48AB0C856}.Release|Any CPU.ActiveCfg = Release|Any CPU + {FEC7A6AA-3D9B-4062-B95F-B9A48AB0C856}.Release|Any CPU.Build.0 = Release|Any CPU + {FEC7A6AA-3D9B-4062-B95F-B9A48AB0C856}.Release|x64.ActiveCfg = Release|Any CPU + {FEC7A6AA-3D9B-4062-B95F-B9A48AB0C856}.Release|x64.Build.0 = Release|Any CPU + {FEC7A6AA-3D9B-4062-B95F-B9A48AB0C856}.Release|x86.ActiveCfg = Release|Any CPU + {FEC7A6AA-3D9B-4062-B95F-B9A48AB0C856}.Release|x86.Build.0 = Release|Any CPU + {9DF2403C-2215-4C4D-A432-8961FC905CB2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9DF2403C-2215-4C4D-A432-8961FC905CB2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9DF2403C-2215-4C4D-A432-8961FC905CB2}.Debug|x64.ActiveCfg = Debug|Any CPU + {9DF2403C-2215-4C4D-A432-8961FC905CB2}.Debug|x64.Build.0 = Debug|Any CPU + {9DF2403C-2215-4C4D-A432-8961FC905CB2}.Debug|x86.ActiveCfg = Debug|Any CPU + {9DF2403C-2215-4C4D-A432-8961FC905CB2}.Debug|x86.Build.0 = Debug|Any CPU + {9DF2403C-2215-4C4D-A432-8961FC905CB2}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9DF2403C-2215-4C4D-A432-8961FC905CB2}.Release|Any CPU.Build.0 = Release|Any CPU + {9DF2403C-2215-4C4D-A432-8961FC905CB2}.Release|x64.ActiveCfg = Release|Any CPU + {9DF2403C-2215-4C4D-A432-8961FC905CB2}.Release|x64.Build.0 = Release|Any CPU + {9DF2403C-2215-4C4D-A432-8961FC905CB2}.Release|x86.ActiveCfg = Release|Any CPU + {9DF2403C-2215-4C4D-A432-8961FC905CB2}.Release|x86.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {C8FE8553-DAB6-493D-B141-DF6D9E744212} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {FEC7A6AA-3D9B-4062-B95F-B9A48AB0C856} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {9DF2403C-2215-4C4D-A432-8961FC905CB2} = {0AB3BF05-4346-4AA6-1389-037BE0695223} + EndGlobalSection +EndGlobal diff --git a/docs/superpowers/plans/2026-05-09-csharp-wpf-port.md b/docs/superpowers/plans/2026-05-09-csharp-wpf-port.md new file mode 100644 index 0000000..2b54fa9 --- /dev/null +++ b/docs/superpowers/plans/2026-05-09-csharp-wpf-port.md @@ -0,0 +1,1520 @@ +# WinTune C# WPF .NET 10 Port — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the PowerShell + WPF WinTune (`WinTune.ps1` + 7 `.psm1` modules + `MainWindow.xaml`) with a self-contained .NET 10 WPF executable that ships as a single `.exe`, requires no PowerShell on the target machine, and preserves every safety guarantee called out in the existing README. + +**Architecture:** Two-project solution. `WinTune.Core` is a .NET 10 class library holding all services and DTOs (no WPF references). `WinTune.App` is the WPF executable, MVVM via CommunityToolkit.Mvvm, DI via Microsoft.Extensions.DependencyInjection. Native interop via `[LibraryImport]` source-generated P/Invoke (no `DllImport`). Tests in xUnit + FluentAssertions. + +**Tech Stack:** +- .NET 10 SDK (latest LTS as of 2026-05; supported through 2028-11) +- WPF (Windows.Desktop) +- CommunityToolkit.Mvvm 8.x +- Microsoft.Extensions.DependencyInjection / Microsoft.Extensions.Hosting +- Serilog (file + console sink) +- xUnit + FluentAssertions + Moq +- Microsoft.VisualBasic.FileIO (built into .NET Windows desktop) for Recycle-Bin delete +- System.Management for WMI/CIM +- System.ServiceProcess.ServiceController for service control +- System.Diagnostics.PerformanceCounter for CPU +- LibraryImport for `EmptyWorkingSet`, `SHEmptyRecycleBin`, `DnsFlushResolverCache` + +**Branch:** `csharp-port` (created 2026-05-09 from `main`). PowerShell version stays on `main` until C# port reaches feature parity, then `main` flips. + +**Repo layout (new):** +``` +WinTune/ +├── WinTune.sln NEW — solution file +├── src/ +│ ├── WinTune.Core/ NEW — class library +│ │ ├── WinTune.Core.csproj +│ │ ├── Models/ +│ │ │ ├── PerfSnapshot.cs +│ │ │ ├── ProcessSnapshot.cs +│ │ │ ├── CleanupTarget.cs +│ │ │ ├── CleanupResult.cs +│ │ │ ├── CleanupProgress.cs +│ │ │ ├── BoostResult.cs +│ │ │ ├── StartupEntry.cs +│ │ │ ├── Finding.cs +│ │ │ ├── DiagnoseResult.cs +│ │ │ ├── DuplicateGroup.cs +│ │ │ ├── DuplicateFile.cs +│ │ │ ├── DedupeProgress.cs +│ │ │ └── RemovalResult.cs +│ │ ├── Services/ +│ │ │ ├── IMonitorService.cs / MonitorService.cs +│ │ │ ├── IBoostService.cs / BoostService.cs +│ │ │ ├── ICleanupService.cs / CleanupService.cs +│ │ │ ├── IStartupService.cs / StartupService.cs +│ │ │ ├── IDiagnoseService.cs / DiagnoseService.cs +│ │ │ └── IDedupService.cs / DedupService.cs +│ │ └── NativeInterop/ +│ │ ├── PsApi.cs EmptyWorkingSet +│ │ ├── Shell32.cs SHEmptyRecycleBin, SHFileOperation +│ │ └── DnsApi.cs DnsFlushResolverCache +│ └── WinTune.App/ NEW — WPF executable +│ ├── WinTune.App.csproj +│ ├── App.xaml + App.xaml.cs +│ ├── MainWindow.xaml + MainWindow.xaml.cs +│ ├── app.manifest +│ ├── Views/ +│ │ ├── DashboardView.xaml + .cs +│ │ ├── CleanView.xaml + .cs +│ │ ├── BoostView.xaml + .cs +│ │ ├── DiagnoseView.xaml + .cs +│ │ └── DedupeView.xaml + .cs +│ ├── ViewModels/ +│ │ ├── MainWindowViewModel.cs +│ │ ├── DashboardViewModel.cs +│ │ ├── CleanViewModel.cs +│ │ ├── BoostViewModel.cs +│ │ ├── DiagnoseViewModel.cs +│ │ └── DedupeViewModel.cs +│ ├── Converters/ +│ │ ├── BytesToDisplayConverter.cs +│ │ └── SeverityToBrushConverter.cs +│ └── Resources/ +│ └── Styles.xaml +├── tests/ +│ ├── WinTune.Core.Tests/ NEW — xUnit +│ │ ├── WinTune.Core.Tests.csproj +│ │ ├── MonitorServiceTests.cs +│ │ ├── BoostServiceTests.cs +│ │ ├── CleanupServiceTests.cs +│ │ ├── StartupServiceTests.cs +│ │ ├── DiagnoseServiceTests.cs +│ │ ├── DedupServiceTests.cs +│ │ └── TestHelpers/ +│ │ └── TempDirectory.cs +│ └── WinTune.App.Tests/ NEW — UI smoke tests via FlaUI (optional) +└── .github/workflows/dotnet.yml NEW — alongside existing ci.yml +``` + +PowerShell tree (`WinTune.ps1`, `modules/`, `ui/MainWindow.xaml`, `Launch-WinTune.cmd`, `Run-Tests.ps1`, `tests/` Pester suite, `.github/workflows/ci.yml`) stays untouched on this branch — it's the reference implementation we're porting. + +--- + +## Phase 0 — Scaffold + +### Task 0.1: Verify .NET 10 SDK is on PATH + +**Files:** None. + +- [ ] **Step 1: Verify dotnet installed** + +```powershell +dotnet --list-sdks +``` + +Expected: at least one line starting with `10.0.` (e.g., `10.0.203 [C:\Program Files\dotnet\sdk]`). If not, install via `winget install --id Microsoft.DotNet.SDK.10 --silent`. + +- [ ] **Step 2: Set the SDK pin** + +```powershell +cd C:\Users\danli\repos\WinTune +dotnet new globaljson --sdk-version 10.0.203 --roll-forward latestFeature --force +``` + +Expected: creates `global.json` at repo root pinning the major.feature version. + +- [ ] **Step 3: Commit** + +```powershell +git add global.json +git commit -m "chore: pin .NET 10 SDK via global.json" +``` + +### Task 0.2: Create solution and project skeletons + +**Files:** +- Create: `WinTune.sln` +- Create: `src/WinTune.Core/WinTune.Core.csproj` +- Create: `src/WinTune.App/WinTune.App.csproj` +- Create: `tests/WinTune.Core.Tests/WinTune.Core.Tests.csproj` + +- [ ] **Step 1: Create solution + projects** + +```powershell +dotnet new sln -n WinTune +dotnet new classlib -n WinTune.Core -o src/WinTune.Core -f net10.0-windows +dotnet new wpf -n WinTune.App -o src/WinTune.App -f net10.0-windows +dotnet new xunit -n WinTune.Core.Tests -o tests/WinTune.Core.Tests -f net10.0-windows +dotnet sln add src/WinTune.Core/WinTune.Core.csproj +dotnet sln add src/WinTune.App/WinTune.App.csproj +dotnet sln add tests/WinTune.Core.Tests/WinTune.Core.Tests.csproj +dotnet add src/WinTune.App/WinTune.App.csproj reference src/WinTune.Core/WinTune.Core.csproj +dotnet add tests/WinTune.Core.Tests/WinTune.Core.Tests.csproj reference src/WinTune.Core/WinTune.Core.csproj +``` + +Delete the auto-generated `Class1.cs`, `MainWindow.xaml*`, `App.xaml*`, `UnitTest1.cs` placeholders — we'll write our own. + +- [ ] **Step 2: Configure WinTune.Core.csproj** + +Replace the auto-generated content with: + +```xml + + + net10.0-windows + false + false + enable + enable + latest + true + latest-recommended + + + + + + + + + +``` + +(Use `9.0.0` packages until 10.0 GA versions confirmed — the 9.0 line works on .NET 10 runtime via roll-forward.) + +- [ ] **Step 3: Configure WinTune.App.csproj** + +Replace auto-generated content with: + +```xml + + + WinExe + net10.0-windows + true + enable + enable + latest + true + latest-recommended + app.manifest + + WinTune + WinTune.App + + + + true + true + win-x64 + true + true + embedded + + + + + + + + + + + + +``` + +- [ ] **Step 4: Add app.manifest with requireAdministrator** + +Create `src/WinTune.App/app.manifest`: + +```xml + + + + + + + + + + + + + + + + + + + true/pm + PerMonitorV2 + true + + + +``` + +- [ ] **Step 5: Configure WinTune.Core.Tests.csproj** + +```xml + + + net10.0-windows + enable + enable + false + true + + + + + + + all + + + + + all + + + + + + + +``` + +- [ ] **Step 6: Verify build** + +```powershell +dotnet restore +dotnet build -c Debug +``` + +Expected: 0 warnings, 0 errors. + +- [ ] **Step 7: Verify tests run (empty suite)** + +```powershell +dotnet test +``` + +Expected: "Passed: 0" (no tests yet, but the runner finds the project). + +- [ ] **Step 8: Add .gitignore entries** + +Append to `.gitignore`: + +``` +# .NET build output +bin/ +obj/ +*.user +.vs/ +publish/ +artifacts/ +TestResults/ +*.coverage +*.nupkg +``` + +- [ ] **Step 9: Commit** + +```powershell +git add . +git commit -m "feat(csharp): scaffold .NET 10 WPF solution with Core + App + Tests projects" +``` + +--- + +## Phase 1 — Models + MonitorService + BoostService + +### Task 1.1: Create the model DTOs + +**Files:** Create one file per model under `src/WinTune.Core/Models/`. All records use init-only properties. + +- [ ] **Step 1: PerfSnapshot.cs** + +```csharp +namespace WinTune.Core.Models; + +public sealed record PerfSnapshot( + int CpuPct, + double RamUsedGB, + double RamTotalGB, + double RamPct, + double DiskUsedGB, + double DiskFreeGB, + double DiskTotalGB, + double DiskPct, + DateTime Timestamp); +``` + +- [ ] **Step 2: ProcessSnapshot.cs** + +```csharp +namespace WinTune.Core.Models; + +public sealed record ProcessSnapshot( + string Name, + int Id, + double RamMB, + int Threads, + string StartTime); // "HH:mm:ss" or "-" +``` + +- [ ] **Step 3: CleanupTarget.cs** + +```csharp +namespace WinTune.Core.Models; + +public enum CleanupTarget +{ + UserTemp, + SystemTemp, + Prefetch, + WindowsErrorReports, + WindowsUpdate, + EdgeCache, + ChromeCache, + FirefoxCache, + RecycleBin, + DnsCache +} +``` + +- [ ] **Step 4: CleanupResult.cs and CleanupProgress.cs** + +```csharp +// CleanupResult.cs +namespace WinTune.Core.Models; + +public sealed record CleanupResult( + CleanupTarget Target, + int FilesRemoved, + long BytesFreed, + IReadOnlyList Errors, + bool Skipped, + string? SkipReason); + +// CleanupProgress.cs +namespace WinTune.Core.Models; + +public sealed record CleanupProgress( + string Phase, // "Start" | "TargetDone" + int Index, + int Total, + CleanupTarget Target, + int FilesRemoved = 0, + long BytesFreed = 0, + bool Skipped = false, + int ErrorCount = 0); +``` + +- [ ] **Step 5: BoostResult.cs** + +```csharp +namespace WinTune.Core.Models; + +public sealed record WorkingSetResult( + int ProcessesTrimmed, + int ProcessesSkipped, + long BytesFreedEstimate); + +public sealed record ExplorerRestartResult(int ProcessesRestarted); + +public sealed record DnsFlushResult(bool Success, string? Error); +``` + +- [ ] **Step 6: StartupEntry.cs** + +```csharp +namespace WinTune.Core.Models; + +public sealed record StartupEntry( + string Name, + string Command, + string Location, // friendly path + string User, // "AllUsers" or username + StartupSource Source); + +public enum StartupSource { Wmi, Registry, StartupFolder } +``` + +- [ ] **Step 7: Finding.cs and DiagnoseResult.cs** + +```csharp +// Finding.cs +namespace WinTune.Core.Models; + +public enum Severity { Red, Yellow, Green } + +public sealed record Finding( + Severity Severity, + string Title, + string Detail, + string? Hint); + +// DiagnoseResult.cs +namespace WinTune.Core.Models; + +public sealed record DiagnoseResult( + bool Success, + string Note, + int FilesRemoved = 0, + IReadOnlyList? Errors = null); +``` + +- [ ] **Step 8: DuplicateGroup.cs, DuplicateFile.cs, DedupeProgress.cs, RemovalResult.cs** + +```csharp +// DuplicateFile.cs +namespace WinTune.Core.Models; + +public sealed record DuplicateFile(string FullPath, long SizeBytes, DateTime LastWriteTime); + +// DuplicateGroup.cs +namespace WinTune.Core.Models; + +public sealed record DuplicateGroup( + int GroupId, + string Hash, + long SizeBytes, + long WastedBytes, + IReadOnlyList Files); + +// DedupeProgress.cs +namespace WinTune.Core.Models; + +public sealed record DedupeProgress( + string Phase, // "Enumerate" | "Hash" | "Group" | "Done" + int FilesScanned, + int FilesHashed, + int GroupsFound, + long WastedBytes); + +// RemovalResult.cs +namespace WinTune.Core.Models; + +public sealed record RemovalResult( + int Deleted, + long BytesFreed, + IReadOnlyList Errors, + bool Permanent); +``` + +- [ ] **Step 9: Commit** + +```powershell +git add src/WinTune.Core/Models/ +git commit -m "feat(core): add Model DTOs for all services" +``` + +### Task 1.2: NativeInterop scaffolding + +**Files:** Create `src/WinTune.Core/NativeInterop/` files. + +- [ ] **Step 1: PsApi.cs** + +```csharp +using System.Runtime.InteropServices; + +namespace WinTune.Core.NativeInterop; + +internal static partial class PsApi +{ + [LibraryImport("psapi.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static partial bool EmptyWorkingSet(IntPtr hProcess); +} +``` + +- [ ] **Step 2: Shell32.cs** + +```csharp +using System.Runtime.InteropServices; + +namespace WinTune.Core.NativeInterop; + +internal static partial class Shell32 +{ + [Flags] + internal enum SHERB : uint + { + NoConfirmation = 0x00000001, + NoProgressUI = 0x00000002, + NoSound = 0x00000004 + } + + [LibraryImport("shell32.dll", SetLastError = true)] + internal static partial int SHEmptyRecycleBinW( + IntPtr hwnd, + [MarshalAs(UnmanagedType.LPWStr)] string? pszRootPath, + SHERB dwFlags); +} +``` + +- [ ] **Step 3: DnsApi.cs** + +```csharp +using System.Runtime.InteropServices; + +namespace WinTune.Core.NativeInterop; + +internal static partial class DnsApi +{ + [LibraryImport("dnsapi.dll", EntryPoint = "DnsFlushResolverCache")] + [return: MarshalAs(UnmanagedType.Bool)] + internal static partial bool DnsFlushResolverCache(); +} +``` + +- [ ] **Step 4: Commit** + +```powershell +git add src/WinTune.Core/NativeInterop/ +git commit -m "feat(core): add LibraryImport bindings for psapi, shell32, dnsapi" +``` + +### Task 1.3: MonitorService — TDD + +**Files:** +- Create: `src/WinTune.Core/Services/IMonitorService.cs` +- Create: `src/WinTune.Core/Services/MonitorService.cs` +- Create: `tests/WinTune.Core.Tests/MonitorServiceTests.cs` + +- [ ] **Step 1: Define the interface** + +```csharp +// IMonitorService.cs +using WinTune.Core.Models; + +namespace WinTune.Core.Services; + +public interface IMonitorService +{ + Task GetPerfSnapshotAsync(CancellationToken ct = default); + Task> GetTopProcessesAsync(int count = 10, CancellationToken ct = default); +} +``` + +- [ ] **Step 2: Write the failing tests** + +```csharp +// MonitorServiceTests.cs +using FluentAssertions; +using WinTune.Core.Services; +using Xunit; + +namespace WinTune.Core.Tests; + +public class MonitorServiceTests +{ + [Fact] + public async Task GetPerfSnapshotAsync_returns_plausible_values() + { + IMonitorService sut = new MonitorService(); + + var snap = await sut.GetPerfSnapshotAsync(); + + snap.CpuPct.Should().BeInRange(0, 100); + snap.RamPct.Should().BeInRange(0, 100); + snap.DiskPct.Should().BeInRange(0, 100); + snap.RamTotalGB.Should().BeGreaterThan(0); + snap.DiskTotalGB.Should().BeGreaterThan(0); + snap.Timestamp.Should().BeCloseTo(DateTime.Now, TimeSpan.FromSeconds(2)); + } + + [Fact] + public async Task GetTopProcessesAsync_returns_at_most_count_entries_sorted_desc_by_ram() + { + IMonitorService sut = new MonitorService(); + + var top = await sut.GetTopProcessesAsync(5); + + top.Should().NotBeEmpty(); + top.Count.Should().BeLessThanOrEqualTo(5); + top.Should().BeInDescendingOrder(p => p.RamMB); + top.Should().OnlyContain(p => p.Id > 0); + top.Should().OnlyContain(p => !string.IsNullOrEmpty(p.Name)); + } + + [Fact] + public async Task GetTopProcessesAsync_respects_cancellation() + { + IMonitorService sut = new MonitorService(); + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + var act = async () => await sut.GetTopProcessesAsync(10, cts.Token); + + await act.Should().ThrowAsync(); + } +} +``` + +- [ ] **Step 3: Run tests and verify failure** + +```powershell +dotnet test --filter "FullyQualifiedName~MonitorServiceTests" +``` + +Expected: FAIL — MonitorService does not exist. + +- [ ] **Step 4: Implement MonitorService** + +```csharp +// MonitorService.cs +using System.Diagnostics; +using System.IO; +using System.Management; +using WinTune.Core.Models; + +namespace WinTune.Core.Services; + +public sealed class MonitorService : IMonitorService +{ + private static readonly Lazy CpuCounter = new(() => + { + var pc = new PerformanceCounter("Processor", "% Processor Time", "_Total", readOnly: true); + pc.NextValue(); // first call always returns 0 + Thread.Sleep(100); + return pc; + }); + + public Task GetPerfSnapshotAsync(CancellationToken ct = default) => + Task.Run(() => + { + ct.ThrowIfCancellationRequested(); + int cpuPct = ReadCpuPct(); + (double ramUsed, double ramTotal, double ramPct) = ReadMemory(); + (double diskUsed, double diskFree, double diskTotal, double diskPct) = ReadDisk(); + return new PerfSnapshot( + cpuPct, ramUsed, ramTotal, ramPct, + diskUsed, diskFree, diskTotal, diskPct, + DateTime.Now); + }, ct); + + public Task> GetTopProcessesAsync(int count = 10, CancellationToken ct = default) => + Task.Run>(() => + { + ct.ThrowIfCancellationRequested(); + var snapshots = new List(); + foreach (var p in Process.GetProcesses()) + { + ct.ThrowIfCancellationRequested(); + try + { + long ws = p.WorkingSet64; + if (ws <= 0) continue; + string startStr = "-"; + try { startStr = p.StartTime.ToString("HH:mm:ss"); } catch { } + snapshots.Add(new ProcessSnapshot( + Name: p.ProcessName, + Id: p.Id, + RamMB: Math.Round(ws / 1024.0 / 1024.0, 1), + Threads: p.Threads.Count, + StartTime: startStr)); + } + catch { /* protected/system processes throw on access */ } + finally { p.Dispose(); } + } + return snapshots + .OrderByDescending(p => p.RamMB) + .Take(count) + .ToList(); + }, ct); + + private static int ReadCpuPct() + { + try + { + float v = CpuCounter.Value.NextValue(); + return (int)Math.Round(v, MidpointRounding.AwayFromZero); + } + catch + { + // WMI fallback + using var mc = new ManagementClass("Win32_Processor"); + using var moc = mc.GetInstances(); + int sum = 0, n = 0; + foreach (ManagementObject mo in moc) + { + using (mo) + { + if (mo["LoadPercentage"] is ushort load) { sum += load; n++; } + } + } + return n == 0 ? 0 : sum / n; + } + } + + private static (double used, double total, double pct) ReadMemory() + { + using var search = new ManagementObjectSearcher("SELECT TotalVisibleMemorySize, FreePhysicalMemory FROM Win32_OperatingSystem"); + foreach (ManagementObject mo in search.Get()) + { + using (mo) + { + ulong totalKB = (ulong)mo["TotalVisibleMemorySize"]; + ulong freeKB = (ulong)mo["FreePhysicalMemory"]; + double totalGB = totalKB / 1024.0 / 1024.0; + double usedGB = (totalKB - freeKB) / 1024.0 / 1024.0; + double pct = totalKB == 0 ? 0 : Math.Round((totalKB - freeKB) * 100.0 / totalKB, 1); + return (Math.Round(usedGB, 2), Math.Round(totalGB, 2), pct); + } + } + return (0, 0, 0); + } + + private static (double used, double free, double total, double pct) ReadDisk() + { + var sysDrive = Path.GetPathRoot(Environment.SystemDirectory) ?? "C:\\"; + var di = new DriveInfo(sysDrive); + if (!di.IsReady) return (0, 0, 0, 0); + double totalGB = di.TotalSize / 1024.0 / 1024.0 / 1024.0; + double freeGB = di.AvailableFreeSpace / 1024.0 / 1024.0 / 1024.0; + double usedGB = totalGB - freeGB; + double pct = totalGB == 0 ? 0 : Math.Round(usedGB * 100.0 / totalGB, 1); + return (Math.Round(usedGB, 2), Math.Round(freeGB, 2), Math.Round(totalGB, 2), pct); + } +} +``` + +- [ ] **Step 5: Run tests and verify pass** + +```powershell +dotnet test --filter "FullyQualifiedName~MonitorServiceTests" +``` + +Expected: PASS — all 3 tests. + +- [ ] **Step 6: Commit** + +```powershell +git add src/WinTune.Core/Services/IMonitorService.cs src/WinTune.Core/Services/MonitorService.cs tests/WinTune.Core.Tests/MonitorServiceTests.cs +git commit -m "feat(core): port MonitorService with CPU/RAM/Disk snapshot + top-N processes" +``` + +### Task 1.4: BoostService — TDD + +**Files:** +- Create: `src/WinTune.Core/Services/IBoostService.cs` +- Create: `src/WinTune.Core/Services/BoostService.cs` +- Create: `tests/WinTune.Core.Tests/BoostServiceTests.cs` + +- [ ] **Step 1: Define the interface** + +```csharp +// IBoostService.cs +using WinTune.Core.Models; + +namespace WinTune.Core.Services; + +public interface IBoostService +{ + Task ClearWorkingSetsAsync(CancellationToken ct = default); + Task RestartExplorerAsync(CancellationToken ct = default); + Task FlushDnsCacheAsync(CancellationToken ct = default); +} +``` + +- [ ] **Step 2: Tests** + +```csharp +// BoostServiceTests.cs +using FluentAssertions; +using WinTune.Core.Services; +using Xunit; + +namespace WinTune.Core.Tests; + +public class BoostServiceTests +{ + [Fact] + public async Task ClearWorkingSetsAsync_trims_at_least_some_processes_and_does_not_throw() + { + IBoostService sut = new BoostService(); + + var result = await sut.ClearWorkingSetsAsync(); + + result.ProcessesTrimmed.Should().BeGreaterThan(0); + // We don't assert BytesFreedEstimate strictly because OS reschedules pages quickly + } + + [Fact] + public async Task FlushDnsCacheAsync_returns_success_true() + { + IBoostService sut = new BoostService(); + + var result = await sut.FlushDnsCacheAsync(); + + result.Success.Should().BeTrue(); + result.Error.Should().BeNull(); + } + + // RestartExplorerAsync is destructive (kills explorer.exe); not run in CI + // Local-only smoke test marked with explicit Trait. + [Fact(Skip = "destructive: kills explorer.exe — run manually")] + public async Task RestartExplorerAsync_smoke() + { + IBoostService sut = new BoostService(); + var result = await sut.RestartExplorerAsync(); + result.ProcessesRestarted.Should().BeGreaterThanOrEqualTo(0); + } +} +``` + +- [ ] **Step 3: Run tests, verify failure** + +```powershell +dotnet test --filter "FullyQualifiedName~BoostServiceTests" +``` + +Expected: FAIL. + +- [ ] **Step 4: Implement BoostService** + +```csharp +// BoostService.cs +using System.Diagnostics; +using WinTune.Core.Models; +using WinTune.Core.NativeInterop; + +namespace WinTune.Core.Services; + +public sealed class BoostService : IBoostService +{ + public Task ClearWorkingSetsAsync(CancellationToken ct = default) => + Task.Run(() => + { + ct.ThrowIfCancellationRequested(); + int trimmed = 0, skipped = 0; + long beforeTotal = 0, afterTotal = 0; + + foreach (var p in Process.GetProcesses()) + { + ct.ThrowIfCancellationRequested(); + try + { + beforeTotal += p.WorkingSet64; + if (PsApi.EmptyWorkingSet(p.Handle)) trimmed++; + else skipped++; + } + catch { skipped++; } + finally { p.Dispose(); } + } + + Thread.Sleep(600); + + foreach (var p in Process.GetProcesses()) + { + try { afterTotal += p.WorkingSet64; } + catch { } + finally { p.Dispose(); } + } + + long freed = Math.Max(0, beforeTotal - afterTotal); + return new WorkingSetResult(trimmed, skipped, freed); + }, ct); + + public Task RestartExplorerAsync(CancellationToken ct = default) => + Task.Run(() => + { + ct.ThrowIfCancellationRequested(); + int killed = 0; + foreach (var p in Process.GetProcessesByName("explorer")) + { + try { p.Kill(); killed++; } catch { } + finally { p.Dispose(); } + } + Thread.Sleep(1000); + if (Process.GetProcessesByName("explorer").Length == 0) + { + Process.Start(new ProcessStartInfo("explorer.exe") { UseShellExecute = true }); + } + return new ExplorerRestartResult(killed); + }, ct); + + public Task FlushDnsCacheAsync(CancellationToken ct = default) => + Task.Run(() => + { + ct.ThrowIfCancellationRequested(); + try + { + bool ok = DnsApi.DnsFlushResolverCache(); + return ok + ? new DnsFlushResult(true, null) + : new DnsFlushResult(false, "DnsFlushResolverCache returned false"); + } + catch (Exception ex) + { + return new DnsFlushResult(false, ex.Message); + } + }, ct); +} +``` + +- [ ] **Step 5: Run tests, verify pass** + +```powershell +dotnet test --filter "FullyQualifiedName~BoostServiceTests" +``` + +Expected: PASS (2 ran, 1 skipped). + +- [ ] **Step 6: Commit** + +```powershell +git add src/WinTune.Core/Services/IBoostService.cs src/WinTune.Core/Services/BoostService.cs tests/WinTune.Core.Tests/BoostServiceTests.cs +git commit -m "feat(core): port BoostService with EmptyWorkingSet P/Invoke and DNS flush" +``` + +--- + +## Phase 2 — CleanupService + StartupService + +### Task 2.1: TempDirectory test helper + +**Files:** Create `tests/WinTune.Core.Tests/TestHelpers/TempDirectory.cs`. + +- [ ] **Step 1: Implementation** + +```csharp +namespace WinTune.Core.Tests.TestHelpers; + +public sealed class TempDirectory : IDisposable +{ + public string Path { get; } + + public TempDirectory() + { + Path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), $"wintune-test-{Guid.NewGuid():N}"); + Directory.CreateDirectory(Path); + } + + public string CreateFile(string name, byte[] content) + { + var full = System.IO.Path.Combine(Path, name); + File.WriteAllBytes(full, content); + return full; + } + + public string CreateFile(string name, string content) => CreateFile(name, System.Text.Encoding.UTF8.GetBytes(content)); + + public void CreateJunction(string linkName, string targetDir) + { + var linkPath = System.IO.Path.Combine(Path, linkName); + Directory.CreateSymbolicLink(linkPath, targetDir); // .NET 6+; junction-equivalent + } + + public void Dispose() + { + try { Directory.Delete(Path, recursive: true); } catch { } + } +} +``` + +- [ ] **Step 2: Commit** + +```powershell +git add tests/WinTune.Core.Tests/TestHelpers/ +git commit -m "test: add TempDirectory helper for filesystem tests" +``` + +### Task 2.2: CleanupService — TDD with safety invariants + +**Files:** +- Create: `src/WinTune.Core/Services/ICleanupService.cs` +- Create: `src/WinTune.Core/Services/CleanupService.cs` +- Create: `tests/WinTune.Core.Tests/CleanupServiceTests.cs` + +> NOTE TO IMPLEMENTER: Cleanup is the highest-risk service. The PowerShell version (`modules/Cleanup.psm1`) has commits explicitly documenting bugs that cost real work — read it before writing C# (`git show 711fa7a`, `2ffbc2a`, `4698564`). The C# port MUST preserve: +> 1. **Reparse-point skip during recursion** — never follow junctions/symlinks. (See `git show 2ffbc2a`.) +> 2. **`finally` block service restart** — `wuauserv` and `bits` MUST come back even if delete throws. (See `git show 711fa7a`.) +> 3. **Browser-running guard** — refuse to clear cache if browser is open. +> 4. **Path constants from env vars, not hardcoded `C:\Windows`** — (See `git show 4698564`.) + +- [ ] **Step 1: Interface** + +```csharp +// ICleanupService.cs +using WinTune.Core.Models; + +namespace WinTune.Core.Services; + +public interface ICleanupService +{ + IReadOnlyList GetAvailableTargets(); + + Task> InvokeCleanupAsync( + IReadOnlyCollection targets, + IProgress? progress = null, + CancellationToken ct = default); + + string? GetLastCleanupLogPath(); + string FormatBytes(long bytes); +} +``` + +- [ ] **Step 2: Tests for safety invariants** — write enough tests that a regression in the four bugs above is caught: + +```csharp +// CleanupServiceTests.cs +using FluentAssertions; +using WinTune.Core.Models; +using WinTune.Core.Services; +using WinTune.Core.Tests.TestHelpers; +using Xunit; + +namespace WinTune.Core.Tests; + +public class CleanupServiceTests +{ + [Fact] + public void Reparse_points_inside_target_are_NOT_followed() + { + // Arrange: create a temp dir, make it look like a "cleanup target" + // by overriding the target's path resolver. Inside it, place a junction + // pointing to ANOTHER temp dir that contains a sentinel file. After + // running cleanup on the target, the sentinel must still exist. + using var sentinelDir = new TempDirectory(); + var sentinelPath = sentinelDir.CreateFile("sentinel.txt", "DO NOT DELETE"); + using var targetDir = new TempDirectory(); + targetDir.CreateFile("trash.txt", "ok to delete"); + targetDir.CreateJunction("link-to-sentinel", sentinelDir.Path); + + // Act: drive the recursive deleter against targetDir.Path directly, + // bypassing the env-var lookup. This requires CleanupService to expose + // an internal `RemoveDirectoryContentsSafe(string path)` method. + var sut = new CleanupService(); + var (filesRemoved, bytesFreed, errors) = sut.RemoveDirectoryContentsSafeForTest(targetDir.Path); + + // Assert: sentinel survived + File.Exists(sentinelPath).Should().BeTrue("reparse-point junction must NOT be followed during recursive delete"); + filesRemoved.Should().BeGreaterThan(0); + } + + [Fact] + public void FormatBytes_is_human_readable() + { + var sut = new CleanupService(); + sut.FormatBytes(0).Should().Be("0 B"); + sut.FormatBytes(1023).Should().Be("1023 B"); + sut.FormatBytes(1024).Should().Be("1.0 KB"); + sut.FormatBytes(1024L * 1024).Should().Be("1.0 MB"); + sut.FormatBytes(1024L * 1024 * 1024).Should().Be("1.0 GB"); + } + + [Fact] + public async Task InvokeCleanupAsync_emits_progress_for_each_target() + { + var sut = new CleanupService(); + var progressEvents = new List(); + var progress = new Progress(p => progressEvents.Add(p)); + + // UserTemp is the safest real target to test against + var results = await sut.InvokeCleanupAsync( + new[] { CleanupTarget.UserTemp }, + progress); + + results.Should().HaveCount(1); + progressEvents.Should().ContainSingle(p => p.Phase == "Start"); + progressEvents.Should().ContainSingle(p => p.Phase == "TargetDone"); + } +} +``` + +(Test for finally-block service restart requires admin + an integration setting; document but skip in unit suite — covered by manual smoke or an integration test fixture.) + +- [ ] **Step 3: Verify failure** + +```powershell +dotnet test --filter "FullyQualifiedName~CleanupServiceTests" +``` + +Expected: FAIL — type does not exist. + +- [ ] **Step 4: Implement CleanupService** + +> Implementation guidance follows the spec from the Explore mappers. Key safety methods: +> +> - `RemoveDirectoryContentsSafe(string path, CancellationToken ct)` — enumerates `DirectoryInfo.EnumerateFileSystemInfos(...)`, for each entry checks `(info.Attributes & FileAttributes.ReparsePoint) != 0` and skips if true, otherwise recurses (for dirs) or deletes (for files). Catches `IOException` / `UnauthorizedAccessException` per entry into an errors list, never rethrows. +> - `WindowsUpdate` target wraps the delete in `try { StopServices(["wuauserv","bits"]); DeleteContents(...); } finally { StartServices(["wuauserv","bits"]); }`. +> - `EdgeCache` / `ChromeCache` / `FirefoxCache` check `Process.GetProcessesByName("msedge"|"chrome"|"firefox").Length > 0` and short-circuit with `Skipped = true, SkipReason = "Browser is running"`. +> - `RecycleBin` calls `Shell32.SHEmptyRecycleBinW(IntPtr.Zero, null, NoConfirmation|NoProgressUI|NoSound)`. +> - `DnsCache` calls `DnsApi.DnsFlushResolverCache()`. +> - All paths derived from `Environment.GetFolderPath(SpecialFolder.LocalApplicationData)`, `Environment.GetEnvironmentVariable("TEMP")`, `Environment.GetEnvironmentVariable("SystemRoot")`, etc. NEVER hardcoded `C:\Windows` etc. +> +> `RemoveDirectoryContentsSafeForTest(string path)` is an `internal` method exposed via `[InternalsVisibleTo("WinTune.Core.Tests")]` in `WinTune.Core.csproj`. +> +> Log file: `%LOCALAPPDATA%\WinTune\logs\cleanup-yyyyMMdd-HHmmss.log`. Service writes Serilog-style entries on errors; `GetLastCleanupLogPath()` returns the most recent file. + +(The full implementation is ~250 lines. Implementer reads `modules/Cleanup.psm1` line-by-line and ports each helper.) + +- [ ] **Step 5: Verify tests pass** + +```powershell +dotnet test --filter "FullyQualifiedName~CleanupServiceTests" +``` + +Expected: PASS. + +- [ ] **Step 6: Commit** + +```powershell +git add src/WinTune.Core/Services/ICleanupService.cs src/WinTune.Core/Services/CleanupService.cs tests/WinTune.Core.Tests/CleanupServiceTests.cs +git commit -m "feat(core): port CleanupService with reparse-point skip + finally-block service restart" +``` + +### Task 2.3: StartupService — TDD + +**Files:** +- Create: `src/WinTune.Core/Services/IStartupService.cs` +- Create: `src/WinTune.Core/Services/StartupService.cs` +- Create: `tests/WinTune.Core.Tests/StartupServiceTests.cs` + +- [ ] **Step 1: Interface** + +```csharp +public interface IStartupService +{ + Task> GetStartupAppsAsync(CancellationToken ct = default); + void OpenStartupTaskManager(); +} +``` + +- [ ] **Step 2: Tests** — assert non-empty list on a real Windows box, distinct entries, every entry has Name+Location. + +- [ ] **Step 3: Implementation** — three sources merged + `.DistinctBy(e => (e.User, e.Name))`: + 1. WMI `Win32_StartupCommand` via `ManagementObjectSearcher`. + 2. Registry HKLM + HKCU `Run` and `RunOnce` keys via `Microsoft.Win32.RegistryKey`. + 3. Common + per-user Startup folder via `Environment.GetFolderPath(SpecialFolder.Startup)` and `.CommonStartup`. + +- [ ] **Step 4: Verify pass + commit** + +```powershell +dotnet test --filter "FullyQualifiedName~StartupServiceTests" +git add src/WinTune.Core/Services/IStartupService.cs src/WinTune.Core/Services/StartupService.cs tests/WinTune.Core.Tests/StartupServiceTests.cs +git commit -m "feat(core): port StartupService (WMI + registry + Startup folders)" +``` + +--- + +## Phase 3 — DiagnoseService + DedupService + +### Task 3.1: DiagnoseService — 11 checks + 5 fixes + +**Files:** standard service trio. + +The 11 checks (from `Diagnose.psm1`): +1. Disk health — WMI `MSStorageDriver_FailurePredictStatus` if available, else free-space heuristic via `DriveInfo`. +2. Free-space pressure — `DriveInfo.AvailableFreeSpace / TotalSize < 0.10`. +3. Multiple cloud sync shell extensions — enumerate `explorer.exe` modules; flag if ≥2 of OneDrive/Dropbox/GoogleDrive/Box/iCloud DLLs loaded. +4. Quick Access bloat — count files in `%APPDATA%\Microsoft\Windows\Recent\AutomaticDestinations` > 50. +5. Search index empty — directory size of `%PROGRAMDATA%\Microsoft\Search\Data\Applications\Windows\Projects\SystemIndex\Indexer\CiFiles` < 10 MB. +6. DiagTrack telemetry — `ServiceController.GetServices().FirstOrDefault(s => s.ServiceName == "DiagTrack")?.Status == Running`. +7. Win11 right-click overlay enabled — registry presence of `HKCU\Software\Classes\CLSID\{86ca1aa0-...}` is *missing* (default Win11) → flag yellow. +8. Pagefile placement — `Win32_PageFileSetting`; flag if on system drive AND another fixed drive has more free space. +9. Startup app count — WMI `Win32_StartupCommand`; flag if > 15. +10. RAM pressure — `(used/total) > 0.85`. +11. Disk free percentage per drive — already covered by #2; per-drive list. + +The 5 fixes: +- ResetQuickAccess — `File.Delete` recursively in Recent + AutomaticDestinations + CustomDestinations. +- DisableTelemetry — `ServiceController.Stop("DiagTrack")` + `WaitForStatus(Stopped)` + `ChangeServiceConfig` to Disabled (PInvoke or use sc.exe via Process.Start) + `RegistryKey.SetValue("HKLM\Software\Policies\Microsoft\Windows\DataCollection", "AllowTelemetry", 0)`. +- EnableClassicRightClick — create `HKCU\Software\Classes\CLSID\{86ca1aa0-34aa-4e8b-a509-50c905bae2a2}\InprocServer32` with default value `""`. +- DisableClassicRightClick — delete that key. +- RebuildSearchIndex — `ServiceController.Stop("WSearch")` + delete index data dir + restart service. + +Each check returns a `Finding`; each fix returns a `DiagnoseResult`. + +Tasks 3.1.1 through 3.1.16 follow the pattern: +1. Add interface method +2. Add test (fixture-mocked where possible; live-only for the rest with `[Trait("Category","Live")]`) +3. Run, verify failure +4. Implement +5. Run, verify pass +6. Commit per group of 3-4 checks (`feat(core): port DiagnoseService checks 1-3`, etc.) + +### Task 3.2: DedupService — two-pass + +**Files:** standard service trio. + +- [ ] **Step 1: Interface** + +```csharp +public interface IDedupService +{ + Task> FindDuplicatesAsync( + IReadOnlyCollection roots, + long minSizeBytes = 1L * 1024 * 1024, + bool includeHidden = false, + IReadOnlySet? excludeExtensions = null, + IProgress? progress = null, + CancellationToken ct = default); + + Task RemoveDuplicateFilesAsync( + IReadOnlyCollection paths, + bool permanent = false, + CancellationToken ct = default); + + IReadOnlyList GetDefaultScanRoots(); +} +``` + +- [ ] **Step 2: Tests** — temp dir with 3 identical files + 1 unique → 1 group of 3, 0 unique. Reparse-point file skipped. Files below `minSizeBytes` skipped. + +- [ ] **Step 3: Implementation** — two-pass: + 1. Enumerate via `DirectoryInfo.EnumerateFiles("*", new EnumerationOptions { RecurseSubdirectories = true, IgnoreInaccessible = true })`. Filter by `.Length >= minSizeBytes`, `(Attributes & (ReparsePoint|Offline|System)) == 0`, extension not in excludes. Group by `Length`. + 2. For each size-group with ≥2 files, hash with `SHA1.HashData(File.OpenRead(path))` (chunked 64KB read). Group by hash. Yield groups with ≥2. + + `RemoveDuplicateFilesAsync(paths, permanent=false)`: use `Microsoft.VisualBasic.FileIO.FileSystem.DeleteFile(p, UIOption.OnlyErrorDialogs, RecycleOption.SendToRecycleBin)`. Permanent path uses `File.Delete`. + +- [ ] **Step 4: Pass + commit.** + +--- + +## Phase 4 — WPF UI (MVVM) + +> **Approach:** Port `ui/MainWindow.xaml` largely unchanged. Replace inline event handlers with `{Binding Command}`. Wire each tab's content to a tab-specific ViewModel via DataContext. Use `CommunityToolkit.Mvvm` for `[ObservableProperty]` and `[RelayCommand]`. Inject services via DI. + +### Task 4.1: App.xaml + DI bootstrap + +**Files:** +- Create: `src/WinTune.App/App.xaml` +- Create: `src/WinTune.App/App.xaml.cs` + +- [ ] **Step 1: App.xaml** + +```xml + + + + + + + + + +``` + +- [ ] **Step 2: App.xaml.cs** + +```csharp +using System.Windows; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Serilog; +using WinTune.App.ViewModels; +using WinTune.Core.Services; + +namespace WinTune.App; + +public partial class App : Application +{ + public static IHost? Host { get; private set; } + + protected override void OnStartup(StartupEventArgs e) + { + Log.Logger = new LoggerConfiguration() + .WriteTo.File( + System.IO.Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "WinTune", "logs", "wintune-.log"), + rollingInterval: RollingInterval.Day, + retainedFileCountLimit: 7) + .CreateLogger(); + + Host = Microsoft.Extensions.Hosting.Host.CreateDefaultBuilder() + .UseSerilog() + .ConfigureServices(services => + { + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddTransient(); + }) + .Build(); + + var window = Host.Services.GetRequiredService(); + window.DataContext = Host.Services.GetRequiredService(); + window.Show(); + base.OnStartup(e); + } + + protected override void OnExit(ExitEventArgs e) + { + Host?.Dispose(); + Log.CloseAndFlush(); + base.OnExit(e); + } +} +``` + +- [ ] **Step 3: Commit** + +### Task 4.2: ViewModels per tab + +For each ViewModel, the pattern is: + +```csharp +public partial class DashboardViewModel : ObservableObject, IDisposable +{ + private readonly IMonitorService _monitor; + private readonly DispatcherTimer _timer; + + [ObservableProperty] private int cpuPct; + [ObservableProperty] private int ramPct; + [ObservableProperty] private int diskPct; + [ObservableProperty] private string ramDisplay = ""; + [ObservableProperty] private string diskDisplay = ""; + + public ObservableCollection TopProcesses { get; } = new(); + + public DashboardViewModel(IMonitorService monitor) + { + _monitor = monitor; + _timer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(2) }; + _timer.Tick += async (_, _) => await RefreshAsync(); + _timer.Start(); + _ = RefreshAsync(); + } + + private async Task RefreshAsync() + { + try + { + var snap = await _monitor.GetPerfSnapshotAsync(); + CpuPct = snap.CpuPct; + RamPct = (int)Math.Round(snap.RamPct); + DiskPct = (int)Math.Round(snap.DiskPct); + RamDisplay = $"{snap.RamUsedGB:F1} / {snap.RamTotalGB:F1} GB"; + DiskDisplay = $"{snap.DiskUsedGB:F1} / {snap.DiskTotalGB:F1} GB"; + + var top = await _monitor.GetTopProcessesAsync(10); + TopProcesses.Clear(); + foreach (var p in top) TopProcesses.Add(p); + } + catch { /* don't crash dashboard on transient failure */ } + } + + public void Dispose() => _timer.Stop(); +} +``` + +CleanViewModel, BoostViewModel, DiagnoseViewModel, DedupeViewModel follow the same pattern with `[RelayCommand]` methods that bind to buttons. Cancellation is per-VM `CancellationTokenSource`, cancelled in `Dispose()`. + +(Tasks 4.2.1–4.2.5 are one VM per task, ~30-50 lines each.) + +### Task 4.3: MainWindow.xaml port + +Migrate `ui/MainWindow.xaml` directly. Changes: +- Set `x:Class="WinTune.App.MainWindow"` on root Window. +- Each tab's content moves into a UserControl (`Views/DashboardView.xaml`, etc.) with its own DataContext. +- Replace any `x:Name`-based code-behind manipulation with `{Binding}` to ViewModel properties. +- Buttons: replace `Click=...` (none in current XAML — good) with `Command="{Binding RunCleanupCommand}"`. + +Each Button → Command mapping is documented in `docs/superpowers/plans/2026-05-09-csharp-wpf-port-bindings.md` (generated by the explorer agent and committed alongside the plan). + +### Task 4.4: Manual smoke test + +- [ ] Run from VS or `dotnet run --project src/WinTune.App` — UAC prompt fires, window opens, dashboard updates every 2s, every tab loads without exception. + +--- + +## Phase 5 — Publish + CI + +### Task 5.1: Single-file publish + +- [ ] **Step 1: Publish locally** + +```powershell +dotnet publish src/WinTune.App/WinTune.App.csproj -c Release -r win-x64 -o publish/win-x64 +``` + +Expected: `publish/win-x64/WinTune.exe` ~30-50 MB, no other files (or just the .pdb). + +- [ ] **Step 2: Smoke-test the exe** + +Copy `WinTune.exe` to a clean directory (no .NET runtime adjacent), double-click. Expected: UAC prompt → window opens. + +- [ ] **Step 3: Document** + +Add to `README.md`: + +```markdown +## Build the C# version + +Requires .NET 10 SDK. + +```powershell +dotnet publish src/WinTune.App/WinTune.App.csproj -c Release -r win-x64 -o publish/win-x64 +``` + +The output `WinTune.exe` is a single self-contained file — no .NET runtime needed on the target machine. +``` + +- [ ] **Step 4: Commit** + +### Task 5.2: GitHub Actions + +**Files:** Create `.github/workflows/dotnet.yml`. + +```yaml +name: dotnet + +on: + push: + branches: [main, csharp-port] + pull_request: + branches: [main] + +jobs: + build: + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: '10.0.x' + - run: dotnet restore + - run: dotnet build -c Release --no-restore + - run: dotnet test -c Release --no-build --logger "trx;LogFileName=test-results.trx" + - run: dotnet publish src/WinTune.App/WinTune.App.csproj -c Release -r win-x64 -o publish/win-x64 + - uses: actions/upload-artifact@v4 + with: + name: WinTune-win-x64 + path: publish/win-x64/WinTune.exe +``` + +- [ ] **Commit + push** — verify CI green. + +--- + +## Self-review checklist + +- [x] Every Phase 0/1 step has complete, runnable code or an exact command. +- [x] Tests precede implementation in every TDD task. +- [x] Phases 2/3/4 use detailed-pattern + brief-task style because the patterns repeat — implementer reads the corresponding `.psm1` to fill in details, with explicit invariants called out. +- [x] No "TBD" / "implement later" placeholders in P0/P1. +- [x] File paths are absolute or repo-relative. +- [x] Safety invariants (reparse-point skip, finally-block service restart, browser guard) are tested where unit-testable. +- [x] Commits are frequent and atomic — one per task. +- [x] Branch strategy stated (`csharp-port` until parity, then flip). + +--- + +## Execution mode + +**Subagent-Driven** (recommended) — fresh subagent per task, parent reviews each commit before dispatching the next. Use `superpowers:subagent-driven-development` skill. + +Multi-session: this plan covers ~1-2 weeks of work. The plan file is the durable handoff between sessions. Each session starts by reading this plan and the most recent git log to figure out where to resume. diff --git a/global.json b/global.json new file mode 100644 index 0000000..ab84b81 --- /dev/null +++ b/global.json @@ -0,0 +1,6 @@ +{ + "sdk": { + "version": "10.0.203", + "rollForward": "latestFeature" + } +} diff --git a/modules/Async.psm1 b/modules/Async.psm1 new file mode 100644 index 0000000..7d9a431 --- /dev/null +++ b/modules/Async.psm1 @@ -0,0 +1,115 @@ +# Async.psm1 -- runspace-backed background ops with progress queues. +# +# Why this exists: long operations (dedup scan, cache cleanup, diagnostics) +# previously ran synchronously on the WPF dispatcher thread, freezing the +# window for seconds-to-minutes. This module wraps them in a background +# PowerShell runspace and exposes a thread-safe progress queue the UI thread +# polls via DispatcherTimer. +# +# Pattern (from a WPF event handler): +# +# $script:CleanupOp = Start-AsyncOp ` +# -Script { +# param($targets, $modulePath, $Progress) +# Import-Module $modulePath -Force +# & $Progress @{ msg = "starting"; pct = 0 } +# Invoke-Cleanup -Targets $targets +# } ` +# -Arguments @{ targets = $targets; modulePath = $modPath } +# +# $poller = New-Object System.Windows.Threading.DispatcherTimer +# $poller.Interval = [TimeSpan]::FromMilliseconds(200) +# $poller.Add_Tick({ +# foreach ($p in Receive-AsyncProgress $script:CleanupOp) { +# $ui.StatusLabel.Text = $p.msg +# } +# if (Test-AsyncOpComplete $script:CleanupOp) { +# $poller.Stop() +# $r = Receive-AsyncOp $script:CleanupOp +# # ... handle $r.Success / $r.Result / $r.Error +# } +# }) +# $poller.Start() +# +# A user-supplied $Progress closure is injected automatically and enqueues +# onto a System.Collections.Concurrent.ConcurrentQueue safe for cross-thread +# producer/consumer. The user script invokes & $Progress with whatever +# payload it wants. + +function Start-AsyncOp { + [CmdletBinding()] + param( + [Parameter(Mandatory)][scriptblock]$Script, + [hashtable]$Arguments = @{} + ) + + $progressQueue = [System.Collections.Concurrent.ConcurrentQueue[object]]::new() + + # Wrapper passes through the user's args plus an injected $Progress + # closure that enqueues onto the shared queue. + $wrapper = { + param($UserScript, $UserArgs, $Queue) + $argsWithProgress = @{} + foreach ($k in $UserArgs.Keys) { $argsWithProgress[$k] = $UserArgs[$k] } + $argsWithProgress['Progress'] = { param($info) $Queue.Enqueue($info) }.GetNewClosure() + & $UserScript @argsWithProgress + } + + $ps = [PowerShell]::Create() + [void]$ps.AddScript($wrapper). + AddArgument($Script). + AddArgument($Arguments). + AddArgument($progressQueue) + + $async = $ps.BeginInvoke() + + [pscustomobject]@{ + PowerShell = $ps + Async = $async + ProgressQueue = $progressQueue + } +} + +function Stop-AsyncOp { + # Best-effort cancellation. Stops the runspace pipeline; in-flight cmdlets + # may need to reach a checkpoint before noticing. + param($Op) + if (-not $Op) { return } + try { $Op.PowerShell.Stop() } catch {} + try { $Op.PowerShell.Dispose() } catch {} +} + +function Test-AsyncOpComplete { + param($Op) + if (-not $Op) { return $true } + [bool]$Op.Async.IsCompleted +} + +function Receive-AsyncProgress { + # Drains all queued progress payloads. Returns array (possibly empty). + # Unary-comma wraps so PowerShell's return-value unwrapping doesn't turn + # an empty array back into $null. + param($Op) + if (-not $Op) { return ,@() } + $items = New-Object System.Collections.ArrayList + $msg = $null + while ($Op.ProgressQueue.TryDequeue([ref]$msg)) { [void]$items.Add($msg) } + return ,$items.ToArray() +} + +function Receive-AsyncOp { + # Call exactly once after Test-AsyncOpComplete returns true. Disposes the + # underlying PowerShell instance. + param($Op) + if (-not $Op) { return [pscustomobject]@{ Success = $false; Result = $null; Error = 'No op' } } + try { + $r = $Op.PowerShell.EndInvoke($Op.Async) + return [pscustomobject]@{ Success = $true; Result = $r; Error = $null } + } catch { + return [pscustomobject]@{ Success = $false; Result = $null; Error = $_.Exception.Message } + } finally { + try { $Op.PowerShell.Dispose() } catch {} + } +} + +Export-ModuleMember -Function Start-AsyncOp, Stop-AsyncOp, Test-AsyncOpComplete, Receive-AsyncProgress, Receive-AsyncOp diff --git a/modules/Boost.psm1 b/modules/Boost.psm1 index 5bc9d00..43f5764 100644 --- a/modules/Boost.psm1 +++ b/modules/Boost.psm1 @@ -16,19 +16,26 @@ function Clear-WorkingSets { $totalBefore = 0L $totalAfter = 0L + # Dispose each Process object after we read its native handle. Without this + # the SafeHandles linger until GC, which can pin hundreds of process handles + # transiently on systems with many processes. Get-Process | ForEach-Object { - $totalBefore += $_.WorkingSet64 try { - $h = $_.Handle - $ok = [WinTune.Native]::EmptyWorkingSet($h) + $totalBefore += $_.WorkingSet64 + $ok = [WinTune.Native]::EmptyWorkingSet($_.Handle) if ($ok) { $trimmed++ } else { $skipped++ } } catch { $skipped++ + } finally { + $_.Dispose() } } Start-Sleep -Milliseconds 600 - Get-Process | ForEach-Object { $totalAfter += $_.WorkingSet64 } + + Get-Process | ForEach-Object { + try { $totalAfter += $_.WorkingSet64 } finally { $_.Dispose() } + } $freedBytes = [math]::Max([long]0, [long]($totalBefore - $totalAfter)) diff --git a/modules/Cleanup.psm1 b/modules/Cleanup.psm1 index c8dbfd7..78c283f 100644 --- a/modules/Cleanup.psm1 +++ b/modules/Cleanup.psm1 @@ -11,6 +11,31 @@ $script:ValidTargets = @( function Get-CleanupTargets { $script:ValidTargets } +function Test-IsReparsePoint { + param([System.IO.FileSystemInfo]$Item) + return [bool]($Item.Attributes -band [System.IO.FileAttributes]::ReparsePoint) +} + +function Remove-DirectoryTreeSafe { + # Recursive delete that NEVER follows reparse points (junctions, symlinks). + # Defends against a malicious junction inside a cleanup target redirecting + # the recursive delete to e.g. C:\Windows\System32. A parent that holds a + # skipped reparse-point child will fail with "directory not empty" -- that + # is the intended signal that something unexpected lives there. + param([string]$Path) + + Get-ChildItem -LiteralPath $Path -Force -ErrorAction SilentlyContinue | + ForEach-Object { + if (Test-IsReparsePoint $_) { return } # skip link, do not recurse + if ($_.PSIsContainer) { + Remove-DirectoryTreeSafe -Path $_.FullName + } else { + [System.IO.File]::Delete($_.FullName) + } + } + [System.IO.Directory]::Delete($Path, $false) +} + function Remove-PathContents { param([string]$Path, [switch]$Recurse) @@ -25,16 +50,23 @@ function Remove-PathContents { Get-ChildItem -LiteralPath $Path -Force -ErrorAction SilentlyContinue | ForEach-Object { try { + # Skip reparse-point entries at top level: do not delete the + # link, do not follow it. Same defense as Remove-DirectoryTreeSafe. + if (Test-IsReparsePoint $_) { return } + if ($_.PSIsContainer) { $sizeBefore = (Get-ChildItem -LiteralPath $_.FullName -Recurse -Force -ErrorAction SilentlyContinue | - Where-Object { -not $_.PSIsContainer } | + Where-Object { + -not $_.PSIsContainer -and + -not (Test-IsReparsePoint $_) + } | Measure-Object -Property Length -Sum).Sum - Remove-Item -LiteralPath $_.FullName -Recurse -Force -ErrorAction Stop + Remove-DirectoryTreeSafe -Path $_.FullName $bytesFreed += [long]($sizeBefore | ForEach-Object { if ($_) { $_ } else { 0 } }) $filesRemoved += 1 } else { $bytesFreed += [long]$_.Length - Remove-Item -LiteralPath $_.FullName -Force -ErrorAction Stop + [System.IO.File]::Delete($_.FullName) $filesRemoved += 1 } } catch { @@ -72,13 +104,13 @@ function Invoke-SingleTarget { $result.Errors = $r.Errors } 'SystemTemp' { - $r = Remove-PathContents -Path 'C:\Windows\Temp' + $r = Remove-PathContents -Path (Join-Path $env:SystemRoot 'Temp') $result.FilesRemoved = $r.FilesRemoved $result.BytesFreed = $r.BytesFreed $result.Errors = $r.Errors } 'Prefetch' { - $r = Remove-PathContents -Path 'C:\Windows\Prefetch' + $r = Remove-PathContents -Path (Join-Path $env:SystemRoot 'Prefetch') $result.FilesRemoved = $r.FilesRemoved $result.BytesFreed = $r.BytesFreed $result.Errors = $r.Errors @@ -106,13 +138,17 @@ function Invoke-SingleTarget { } } catch { $result.Errors += "stop ${svc}: $($_.Exception.Message)" } } - $r = Remove-PathContents -Path 'C:\Windows\SoftwareDistribution\Download' - $result.FilesRemoved = $r.FilesRemoved - $result.BytesFreed = $r.BytesFreed - $result.Errors += $r.Errors - foreach ($svc in $stoppedSvcs) { - try { Start-Service -Name $svc -ErrorAction Stop } - catch { $result.Errors += "start ${svc}: $($_.Exception.Message)" } + # Restart in finally so services come back even if delete throws. + try { + $r = Remove-PathContents -Path (Join-Path $env:SystemRoot 'SoftwareDistribution\Download') + $result.FilesRemoved = $r.FilesRemoved + $result.BytesFreed = $r.BytesFreed + $result.Errors += $r.Errors + } finally { + foreach ($svc in $stoppedSvcs) { + try { Start-Service -Name $svc -ErrorAction Stop } + catch { $result.Errors += "start ${svc}: $($_.Exception.Message)" } + } } } 'EdgeCache' { @@ -197,10 +233,33 @@ function Invoke-SingleTarget { function Invoke-Cleanup { [CmdletBinding()] param( - [string[]]$Targets = $script:ValidTargets + [string[]]$Targets = $script:ValidTargets, + [scriptblock]$OnProgress ) - $results = foreach ($t in $Targets) { Invoke-SingleTarget -Target $t } + $results = New-Object System.Collections.ArrayList + $i = 0 + foreach ($t in $Targets) { + $i++ + if ($OnProgress) { + & $OnProgress @{ Phase = 'Start'; Index = $i; Total = $Targets.Count; Target = $t } + } + $r = Invoke-SingleTarget -Target $t + [void]$results.Add($r) + if ($OnProgress) { + & $OnProgress @{ + Phase = 'TargetDone' + Index = $i + Total = $Targets.Count + Target = $t + FilesRemoved = $r.FilesRemoved + BytesFreed = $r.BytesFreed + Skipped = $r.Skipped + ErrorCount = $r.Errors.Count + } + } + } + $results = $results.ToArray() # Always write a per-run log so users can inspect the actual error messages. $logDir = Join-Path $env:LOCALAPPDATA 'WinTune\logs' diff --git a/modules/Diagnose.psm1 b/modules/Diagnose.psm1 index 4185663..ba8b02b 100644 --- a/modules/Diagnose.psm1 +++ b/modules/Diagnose.psm1 @@ -88,7 +88,7 @@ function Invoke-Diagnostics { } # --- Windows Search index - $idxBase = 'C:\ProgramData\Microsoft\Search\Data\Applications\Windows\Projects\SystemIndex\Indexer\CiFiles' + $idxBase = Join-Path $env:ProgramData 'Microsoft\Search\Data\Applications\Windows\Projects\SystemIndex\Indexer\CiFiles' if (Test-Path $idxBase) { $idxBytes = (Get-ChildItem $idxBase -Recurse -ErrorAction SilentlyContinue | Measure-Object -Property Length -Sum).Sum @@ -166,7 +166,11 @@ function Invoke-Diagnostics { 'Use Boost tab -> "Free RAM (Empty Working Sets)".') ) } - return ,$findings.ToArray() + # Emit each finding as its own pipeline item. The earlier `return ,$findings.ToArray()` + # form prevented unrolling, so `@(Invoke-Diagnostics)` wrapped the whole + # array as a single element and WPF ItemsSource showed one row whose + # auto-generated columns were Length/Rank/SyncRoot of an Object[]. + return $findings.ToArray() } # ===================================================================== @@ -213,15 +217,29 @@ function Disable-Telemetry { try { if ($svc.Status -eq 'Running') { Stop-Service DiagTrack -Force -ErrorAction Stop } Set-Service DiagTrack -StartupType Disabled -ErrorAction Stop - # Also Connected User Experiences -- the silent half - $cuat = Get-Service dmwappushservice -ErrorAction SilentlyContinue - if ($cuat) { + + # Set the Group Policy registry value too. Without this, Windows Update + # cumulative updates routinely re-enable DiagTrack -- the policy key is + # what makes the disable stick across updates. AllowTelemetry=0 = Security. + $polRoot = 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\DataCollection' + if (-not (Test-Path $polRoot)) { New-Item -Path $polRoot -Force | Out-Null } + Set-ItemProperty -Path $polRoot -Name 'AllowTelemetry' -Type DWord -Value 0 -ErrorAction Stop + + # Also stop dmwappushservice (WAP Push Message Routing Service) where it + # still exists. Removed from Win11 24H2; harmless to attempt on systems + # without it. + $wap = Get-Service dmwappushservice -ErrorAction SilentlyContinue + if ($wap) { try { - if ($cuat.Status -eq 'Running') { Stop-Service dmwappushservice -Force -ErrorAction SilentlyContinue } + if ($wap.Status -eq 'Running') { Stop-Service dmwappushservice -Force -ErrorAction SilentlyContinue } Set-Service dmwappushservice -StartupType Disabled -ErrorAction SilentlyContinue } catch {} } - [pscustomobject]@{ Success = $true; Note = 'DiagTrack stopped + disabled. Reversible: Set-Service DiagTrack -StartupType Automatic; Start-Service DiagTrack' } + + [pscustomobject]@{ + Success = $true + Note = 'DiagTrack stopped + disabled, AllowTelemetry policy = 0. Reversible: Remove-ItemProperty -Path HKLM:\SOFTWARE\Policies\Microsoft\Windows\DataCollection -Name AllowTelemetry; Set-Service DiagTrack -StartupType Automatic; Start-Service DiagTrack' + } } catch { [pscustomobject]@{ Success = $false; Note = $_.Exception.Message } } diff --git a/src/WinTune.App/App.xaml b/src/WinTune.App/App.xaml new file mode 100644 index 0000000..cf69731 --- /dev/null +++ b/src/WinTune.App/App.xaml @@ -0,0 +1,28 @@ + + + + + + + + + + + + diff --git a/src/WinTune.App/App.xaml.cs b/src/WinTune.App/App.xaml.cs new file mode 100644 index 0000000..dd289ff --- /dev/null +++ b/src/WinTune.App/App.xaml.cs @@ -0,0 +1,74 @@ +using System.Globalization; +using System.IO; +using System.Windows; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Serilog; +using WinTune.App.ViewModels; +using WinTune.Core.Services; + +namespace WinTune.App; + +public partial class App : Application +{ + public static IHost? Host { get; private set; } + + protected override void OnStartup(StartupEventArgs e) + { + var logsDir = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "WinTune", "logs"); + Directory.CreateDirectory(logsDir); + + Log.Logger = new LoggerConfiguration() + .MinimumLevel.Information() + .WriteTo.File( + Path.Combine(logsDir, "wintune-.log"), + rollingInterval: RollingInterval.Day, + retainedFileCountLimit: 7, + shared: true, + formatProvider: CultureInfo.InvariantCulture) + .CreateLogger(); + + Host = Microsoft.Extensions.Hosting.Host.CreateDefaultBuilder() + .UseSerilog() + .ConfigureServices(services => + { + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddTransient(); + }) + .Build(); + + var window = Host.Services.GetRequiredService(); + window.DataContext = Host.Services.GetRequiredService(); + MainWindow = window; + window.Show(); + + base.OnStartup(e); + } + + protected override void OnExit(ExitEventArgs e) + { + if (Host is not null) + { + if (Host.Services.GetService(typeof(MainWindowViewModel)) is IDisposable vm) + { + vm.Dispose(); + } + Host.Dispose(); + } + Log.CloseAndFlush(); + base.OnExit(e); + } +} diff --git a/src/WinTune.App/AssemblyInfo.cs b/src/WinTune.App/AssemblyInfo.cs new file mode 100644 index 0000000..cc29e7f --- /dev/null +++ b/src/WinTune.App/AssemblyInfo.cs @@ -0,0 +1,10 @@ +using System.Windows; + +[assembly:ThemeInfo( + ResourceDictionaryLocation.None, //where theme specific resource dictionaries are located + //(used if a resource is not found in the page, + // or application resource dictionaries) + ResourceDictionaryLocation.SourceAssembly //where the generic resource dictionary is located + //(used if a resource is not found in the page, + // app, or any theme specific resource dictionaries) +)] diff --git a/src/WinTune.App/Converters/BytesToDisplayConverter.cs b/src/WinTune.App/Converters/BytesToDisplayConverter.cs new file mode 100644 index 0000000..5346558 --- /dev/null +++ b/src/WinTune.App/Converters/BytesToDisplayConverter.cs @@ -0,0 +1,23 @@ +using System.Globalization; +using System.Windows.Data; + +namespace WinTune.App.Converters; + +public sealed class BytesToDisplayConverter : IValueConverter +{ + public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + if (value is null) return ""; + long bytes = System.Convert.ToInt64(value, CultureInfo.InvariantCulture); + const long KB = 1024L; + const long MB = KB * 1024; + const long GB = MB * 1024; + if (bytes >= GB) return string.Format(CultureInfo.InvariantCulture, "{0:N2} GB", bytes / (double)GB); + if (bytes >= MB) return string.Format(CultureInfo.InvariantCulture, "{0:N2} MB", bytes / (double)MB); + if (bytes >= KB) return string.Format(CultureInfo.InvariantCulture, "{0:N2} KB", bytes / (double)KB); + return string.Format(CultureInfo.InvariantCulture, "{0} B", bytes); + } + + public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) => + throw new NotSupportedException(); +} diff --git a/src/WinTune.App/Converters/SeverityToBrushConverter.cs b/src/WinTune.App/Converters/SeverityToBrushConverter.cs new file mode 100644 index 0000000..c20198d --- /dev/null +++ b/src/WinTune.App/Converters/SeverityToBrushConverter.cs @@ -0,0 +1,31 @@ +using System.Globalization; +using System.Windows.Data; +using System.Windows.Media; +using WinTune.Core.Models; + +namespace WinTune.App.Converters; + +public sealed class SeverityToBrushConverter : IValueConverter +{ + private static readonly SolidColorBrush Red = new(Color.FromRgb(0xE1, 0x57, 0x59)); + private static readonly SolidColorBrush Yellow = new(Color.FromRgb(0xE6, 0xC2, 0x29)); + private static readonly SolidColorBrush Green = new(Color.FromRgb(0x59, 0xA1, 0x4F)); + private static readonly SolidColorBrush Default = new(Color.FromRgb(0x1F, 0x1F, 0x2E)); + + static SeverityToBrushConverter() + { + Red.Freeze(); Yellow.Freeze(); Green.Freeze(); Default.Freeze(); + } + + public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) => + value switch + { + Severity.Red => Red, + Severity.Yellow => Yellow, + Severity.Green => Green, + _ => Default + }; + + public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) => + throw new NotSupportedException(); +} diff --git a/src/WinTune.App/MainWindow.xaml b/src/WinTune.App/MainWindow.xaml new file mode 100644 index 0000000..2c69e5f --- /dev/null +++ b/src/WinTune.App/MainWindow.xaml @@ -0,0 +1,319 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +