From 711fa7a62c788ba6fb26f2a1a782739dd6cbbd0b Mon Sep 17 00:00:00 2001 From: danlinyu <54904061+danlinyu@users.noreply.github.com> Date: Fri, 8 May 2026 23:49:05 -0400 Subject: [PATCH 01/34] fix(cleanup): guarantee wuauserv/bits restart even on delete failure Wrap Remove-PathContents for SoftwareDistribution\Download in try/finally so the previously-stopped services are restored even when the recursive delete throws. Previously a terminating error in the delete would jump to the outer catch and leave Windows Update offline until reboot. --- modules/Cleanup.psm1 | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/modules/Cleanup.psm1 b/modules/Cleanup.psm1 index c8dbfd7..6996621 100644 --- a/modules/Cleanup.psm1 +++ b/modules/Cleanup.psm1 @@ -106,13 +106,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 'C:\Windows\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' { From 2ffbc2ae37552b6015820252339eff63212bd419 Mon Sep 17 00:00:00 2001 From: danlinyu <54904061+danlinyu@users.noreply.github.com> Date: Fri, 8 May 2026 23:51:05 -0400 Subject: [PATCH 02/34] fix(cleanup): never follow reparse points during recursive delete A junction or symlink inside %TEMP% (or any other cleanup target) could previously redirect Remove-Item -Recurse -Force into e.g. C:\Windows\System32 because Remove-Item in PS 5.1 follows directory junctions. Replace Remove-Item -Recurse with Remove-DirectoryTreeSafe, which walks the tree manually and skips any reparse-point entry at every level. Top- level reparse points in Remove-PathContents are also skipped (link not deleted, target not followed). --- modules/Cleanup.psm1 | 38 +++++++++++++++++++++++++++++++++++--- 1 file changed, 35 insertions(+), 3 deletions(-) diff --git a/modules/Cleanup.psm1 b/modules/Cleanup.psm1 index 6996621..0616228 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 { From 4698564e3d6d9af6e35a64028816aca0d9ffebe7 Mon Sep 17 00:00:00 2001 From: danlinyu <54904061+danlinyu@users.noreply.github.com> Date: Fri, 8 May 2026 23:57:46 -0400 Subject: [PATCH 03/34] fix: replace hardcoded C:\Windows and C:\ProgramData with env vars Users with Windows installed off the C: drive (less common but legal) or a relocated ProgramData would have hit FileNotFoundException-style failures in SystemTemp / Prefetch / WindowsUpdate / search-index probes. Use $env:SystemRoot and $env:ProgramData via Join-Path. --- modules/Cleanup.psm1 | 6 +++--- modules/Diagnose.psm1 | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/modules/Cleanup.psm1 b/modules/Cleanup.psm1 index 0616228..abb67e6 100644 --- a/modules/Cleanup.psm1 +++ b/modules/Cleanup.psm1 @@ -104,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 @@ -140,7 +140,7 @@ function Invoke-SingleTarget { } # Restart in finally so services come back even if delete throws. try { - $r = Remove-PathContents -Path 'C:\Windows\SoftwareDistribution\Download' + $r = Remove-PathContents -Path (Join-Path $env:SystemRoot 'SoftwareDistribution\Download') $result.FilesRemoved = $r.FilesRemoved $result.BytesFreed = $r.BytesFreed $result.Errors += $r.Errors diff --git a/modules/Diagnose.psm1 b/modules/Diagnose.psm1 index 4185663..ef13a43 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 From 7bb90efc64dc2de10534e0bb4f0c7a02f410237a Mon Sep 17 00:00:00 2001 From: danlinyu <54904061+danlinyu@users.noreply.github.com> Date: Fri, 8 May 2026 23:58:53 -0400 Subject: [PATCH 04/34] refactor: drop Format-Bytes-Local duplicate, use exported Format-Bytes WinTune.ps1 was carrying a copy of the byte formatter under a different name for the Dedupe tab while the Cleanup tab used the exported one. Unify on Format-Bytes from Cleanup.psm1; all call sites now use the named -Bytes parameter. --- WinTune.ps1 | 18 +++++------------- 1 file changed, 5 insertions(+), 13 deletions(-) diff --git a/WinTune.ps1 b/WinTune.ps1 index 9252bb5..1101a2f 100644 --- a/WinTune.ps1 +++ b/WinTune.ps1 @@ -256,14 +256,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) } @@ -326,7 +318,7 @@ $ui.DedupeScanBtn.Add_Click({ foreach ($f in $g.Files) { [pscustomobject]@{ Group = $g.GroupId - Size = (Format-Bytes-Local $g.SizeBytes) + Size = (Format-Bytes -Bytes $g.SizeBytes) Path = $f.FullName Modified = $f.LastWriteTime.ToString('yyyy-MM-dd HH:mm') SizeBytes = [long]$g.SizeBytes @@ -337,7 +329,7 @@ $ui.DedupeScanBtn.Add_Click({ $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)." + $msg = "Found $($groups.Count) group(s), $((@($rows)).Count) duplicate file(s). Reclaimable: $(Format-Bytes -Bytes $totalWasted)." $ui.DedupeStatusLbl.Text = $msg Set-Status $msg } catch { @@ -367,7 +359,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 +395,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 +403,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 From 384ba0835b00249b1456a8fa98f53c077b80d858 Mon Sep 17 00:00:00 2001 From: danlinyu <54904061+danlinyu@users.noreply.github.com> Date: Fri, 8 May 2026 23:59:46 -0400 Subject: [PATCH 05/34] fix(diagnose): set AllowTelemetry policy so DiagTrack disable survives WU Windows Update cumulative updates routinely re-enable DiagTrack via service startup-type reset. Setting HKLM\SOFTWARE\Policies\Microsoft\ Windows\DataCollection\AllowTelemetry = 0 (DWord) is the GP-level control that survives those updates. Also correct the misleading 'Connected User Experiences -- silent half' comment on dmwappushservice -- that service is the WAP Push Message Routing Service and was removed in Win11 24H2. --- modules/Diagnose.psm1 | 24 +++++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/modules/Diagnose.psm1 b/modules/Diagnose.psm1 index ef13a43..cdb0cb6 100644 --- a/modules/Diagnose.psm1 +++ b/modules/Diagnose.psm1 @@ -213,15 +213,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 } } From 10b1f21d934250a41acb469008ce203704b6864d Mon Sep 17 00:00:00 2001 From: danlinyu <54904061+danlinyu@users.noreply.github.com> Date: Sat, 9 May 2026 00:00:39 -0400 Subject: [PATCH 06/34] fix(boost): dispose Process objects after EmptyWorkingSet Get-Process returns objects whose SafeHandle stays open until GC. With 300+ processes on a typical workstation, repeated invocations can pin hundreds of native handles transiently. Wrap each iteration in try/ finally that calls $_.Dispose() so handles release immediately. --- modules/Boost.psm1 | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) 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)) From d9c319f8e3eeee3b0a71999e761fd12f545bf750 Mon Sep 17 00:00:00 2001 From: danlinyu <54904061+danlinyu@users.noreply.github.com> Date: Sat, 9 May 2026 00:01:51 -0400 Subject: [PATCH 07/34] docs(readme): cover all 5 tabs and 6 modules; note safety guarantees The README still described the original three tabs (Dashboard, Clean, Boost) and four modules. Add Diagnose and Dedupe sections describing the checks, fixes, and safety stops. Update the project-layout block and dot-source examples to show all six modules. Also surface two of the safety guarantees added in this branch: - WindowsUpdate cleanup restarts services in finally - reparse points are skipped during cleanup deletion --- README.md | 54 ++++++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 48 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 60b3233..8c3b32b 100644 --- a/README.md +++ b/README.md @@ -20,21 +20,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 +50,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**: @@ -96,21 +128,31 @@ WinTune/ ├── Launch-WinTune.cmd double-clickable wrapper ├── 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 ``` ## License From 35b0fe8380736560eeecccbd0cadfc10977ce80e Mon Sep 17 00:00:00 2001 From: danlinyu <54904061+danlinyu@users.noreply.github.com> Date: Sat, 9 May 2026 00:05:13 -0400 Subject: [PATCH 08/34] feat(async): runspace-backed background ops with progress queues New helper module for moving long operations off the WPF dispatcher thread. Wraps [PowerShell]::Create() in a small set of cmdlets: Start-AsyncOp - launch script in background runspace with auto-injected $Progress closure Stop-AsyncOp - best-effort cancel Test-AsyncOpComplete - non-blocking completion check (poll from DispatcherTimer) Receive-AsyncProgress - drain progress queue Receive-AsyncOp - read final result, dispose runspace Progress payloads cross the thread boundary via System.Collections.Concurrent.ConcurrentQueue, so background producers and the UI consumer don't race. Verified end-to-end against Find-Duplicates with -OnProgress. --- modules/Async.psm1 | 113 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 113 insertions(+) create mode 100644 modules/Async.psm1 diff --git a/modules/Async.psm1 b/modules/Async.psm1 new file mode 100644 index 0000000..a517c9b --- /dev/null +++ b/modules/Async.psm1 @@ -0,0 +1,113 @@ +# 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). + 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 From cf19ce29ce9dfd10d251ccfa6cd248364ed62dee Mon Sep 17 00:00:00 2001 From: danlinyu <54904061+danlinyu@users.noreply.github.com> Date: Sat, 9 May 2026 00:07:25 -0400 Subject: [PATCH 09/34] feat(cleanup): add -OnProgress callback to Invoke-Cleanup Fires Start/TargetDone events around each target so the UI can show 'Cleaning Edge cache...' / 'Cleaning Firefox cache...' as the run proceeds, instead of going dark for the duration. Required for the upcoming runspace-based UI refactor that moves cleanup off the WPF dispatcher thread. --- modules/Cleanup.psm1 | 27 +++++++++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/modules/Cleanup.psm1 b/modules/Cleanup.psm1 index abb67e6..78c283f 100644 --- a/modules/Cleanup.psm1 +++ b/modules/Cleanup.psm1 @@ -233,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' From c2e943a8fd839c75b44a384c263d61fe9a76fc3d Mon Sep 17 00:00:00 2001 From: danlinyu <54904061+danlinyu@users.noreply.github.com> Date: Sat, 9 May 2026 00:09:12 -0400 Subject: [PATCH 10/34] feat(ui): run cleanup in background runspace; add Cancel button The Cleanup tab previously called Invoke-Cleanup synchronously on the WPF dispatcher thread. Browser-cache deletion on a profile with multi- gigabyte caches froze the window for the duration. Now: - CleanRunBtn launches the work via Start-AsyncOp into a background PowerShell runspace. - A 150ms DispatcherTimer polls the progress queue and surfaces '[i/N] Cleaning ...' status messages as each target starts. - New CleanCancelBtn calls Stop-AsyncOp; the in-flight target may still complete (PS Stop() is best-effort), but no further targets begin. Result-grid population happens on the dispatcher thread inside the poll callback, so WPF binding stays single-threaded as required. --- WinTune.ps1 | 108 ++++++++++++++++++++++++++++++++------------- ui/MainWindow.xaml | 1 + 2 files changed, 79 insertions(+), 30 deletions(-) diff --git a/WinTune.ps1 b/WinTune.ps1 index 1101a2f..3a044c7 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 @@ -105,45 +106,92 @@ $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 + + $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 = New-Object System.Windows.Threading.DispatcherTimer + $script:CleanupPoller.Interval = [TimeSpan]::FromMilliseconds(150) + $script:CleanupPoller.Add_Tick({ + try { + 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 { + Set-Status "Cleanup poller error: $($_.Exception.Message)" } - $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:CleanupPoller.Start() +}) + +$ui.CleanCancelBtn.Add_Click({ + if ($script:CleanupOp) { + Stop-AsyncOp $script:CleanupOp + $script:CleanupOp = $null } + if ($script:CleanupPoller) { $script:CleanupPoller.Stop(); $script:CleanupPoller = $null } + $ui.CleanRunBtn.IsEnabled = $true + $ui.CleanCancelBtn.IsEnabled = $false + Set-Status "Cleanup cancelled (in-flight target may still complete)." }) $ui.CleanOpenLogBtn.Add_Click({ diff --git a/ui/MainWindow.xaml b/ui/MainWindow.xaml index b36fc70..290b992 100644 --- a/ui/MainWindow.xaml +++ b/ui/MainWindow.xaml @@ -121,6 +121,7 @@