From 4195fc00c90d0dfc4051d040e9a3ed8889d19a11 Mon Sep 17 00:00:00 2001 From: Ivan Bondarev Date: Thu, 23 Jul 2026 11:13:35 +0300 Subject: [PATCH 01/11] =?UTF-8?q?fix:=20=D0=B8=D0=BD=D0=B8=D1=86=D0=B8?= =?UTF-8?q?=D0=B0=D0=BB=D0=B8=D0=B7=D0=B0=D1=86=D0=B8=D1=8F=20=D0=BB=D0=BE?= =?UTF-8?q?=D0=B3=D0=B0=20=D0=B1=D0=BE=D0=BB=D1=8C=D1=88=D0=B5=20=D0=BD?= =?UTF-8?q?=D0=B5=20=D0=BC=D0=BE=D0=B6=D0=B5=D1=82=20=D1=83=D0=B1=D0=B8?= =?UTF-8?q?=D1=82=D1=8C=20=D0=BF=D1=80=D0=BE=D0=B3=D0=BE=D0=BD=20+=20?= =?UTF-8?q?=D0=B5=D0=B4=D0=B8=D0=BD=D1=8B=D0=B9=20=D0=BF=D1=83=D1=82=D1=8C?= =?UTF-8?q?=20=D0=B7=D0=B0=D0=B2=D0=B5=D1=80=D1=88=D0=B5=D0=BD=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Два подтверждённых замечания внешнего ревью. 1. Заголовок лога писался двумя голыми Out-File ДО основного try/finally. Замер опроверг ожидание, что это non-terminating: шесть из семи плохих путей бросают terminating даже при ErrorActionPreference=Continue (нет каталога, путь это каталог, недопустимые символы, двоеточие, длинный путь, недоступный UNC). Брошенное там исключение уходило из Start-WinClean мимо всех гарантий: ни result JSON, ни сводки, ни самого обслуживания - из-за лог-файла. Запись в файл вынесена в Write-LogFileLine, через которую теперь идут и заголовок, и Write-Log. Семантика обрезки файла при старте сохранена явным -StartNewFile, а не потеряна в рефакторинге. 2. Успешное самообновление завершало процесс через exit, минуя finally, и поэтому вручную копировало часть финальных действий. Копий было три (finally, самообновление, отказ от перезагрузки), и v2.21 чинил их по отдельности дважды. Теперь одна Complete-WinCleanRun с латчем, а Invoke-ScriptUpdate возвращает ответ "прогон окончен" вместо exit. Код возврата не изменился: точка входа и так выводит его из ErrorsCount. Побочный выигрыш: успешный путь обновления раньше был непроверяем в принципе (вызывал exit и убил бы прогон Pester) - теперь покрыт. Тесты 573 -> 607. Мутационная проверка: 13 мутаций, все пойманы. Три из них сначала выжили и вскрыли слабости самих тестов - в том числе то, что Should -Invoke -Times N при N>0 означает "не менее N" и не видит дубля. --- WinClean.ps1 | 251 ++++++++++++++++++++---------- tests/Fixes.Tests.ps1 | 61 +++++++- tests/Helpers.Tests.ps1 | 332 ++++++++++++++++++++++++++++++++++++++-- 3 files changed, 548 insertions(+), 96 deletions(-) diff --git a/WinClean.ps1 b/WinClean.ps1 index 84925f1..544d396 100644 --- a/WinClean.ps1 +++ b/WinClean.ps1 @@ -436,6 +436,81 @@ $script:ProtectedPaths = @( # LOGGING FUNCTIONS #═══════════════════════════════════════════════════════════════════════════════ +function Write-LogFileLine { + <# + .SYNOPSIS + Appends one line to the log file, degrading instead of throwing + .DESCRIPTION + v2.22, raised in external review: extracted from Write-Log so that EVERY write to + the log file - including the header, which Start-WinClean emits before its main + try/finally exists - degrades the same way instead of each caller inventing its + own fault tolerance. + + The header used to be two bare Out-File calls. Measured, not assumed: six of seven + bad log paths make Out-File throw a TERMINATING error even though the script leaves + ErrorActionPreference at Continue (missing directory, path is a directory, invalid + characters, colon in the name, over-long path, unreachable UNC; only a reserved + device name did not). Thrown there, before the safety net, the exception escaped + Start-WinClean entirely: no result JSON, no final summary, no exit-code accounting, + and none of the maintenance the run was started for - all because of the log. + + A log that cannot be written is a degraded run, never a failed one. + #> + param( + [Parameter(Mandatory)] + [AllowEmptyString()] + [string]$Line, + + # The header line starts a fresh file. The Out-File call this replaced had no + # -Append, so it truncated on every run; preserved deliberately rather than lost + # in the refactor, or a custom -LogPath reused across runs would accumulate them + # all into one file and the log would no longer describe a single run. + [switch]$StartNewFile + ) + + # v2.17 (p.7 of the audit): Out-File used to open, seek to end, write and close the + # file on every single call - Write-Log fires hundreds of times per run. A StreamWriter + # kept open for the run avoids that, with AutoFlush so each line still lands on disk + # immediately (same durability as before, just cheaper). FileShare.Delete matters for + # tests: they Remove-Item the log path in AfterAll while this writer may still be the + # last one that touched it. + try { + if ($StartNewFile -or -not $script:LogWriter -or $script:LogWriterPath -ne $script:LogPath) { + if ($script:LogWriter) { $script:LogWriter.Dispose() } + $mode = if ($StartNewFile) { [System.IO.FileMode]::Create } else { [System.IO.FileMode]::Append } + $fileStream = [System.IO.File]::Open( + $script:LogPath, $mode, [System.IO.FileAccess]::Write, + ([System.IO.FileShare]::ReadWrite -bor [System.IO.FileShare]::Delete)) + $script:LogWriter = [System.IO.StreamWriter]::new($fileStream, [System.Text.Encoding]::UTF8) + $script:LogWriter.AutoFlush = $true + $script:LogWriterPath = $script:LogPath + } + $script:LogWriter.WriteLine($Line) + } catch { + # v2.20: this used to be an empty catch, so a log that stopped being written + # (full volume, revoked permissions, the v2.14 case where cleanup deleted the + # log out from under us) was invisible: destructive work carried on, the final + # JSON said ErrorsCount=0, and LogPath pointed at a truncated file. + # + # Latched: one console line, not one per call - Write-Log fires hundreds of + # times per run. Deliberately Write-Host and not Write-Log, which would + # recurse straight back into this catch. + # v2.20, corrected in review: drop the writer so the NEXT call reopens it. + # Without this the guard above stays satisfied by a dead writer object and + # every later line is silently discarded for the rest of the run - the empty + # catch would simply have moved from the first failure to all the others. + try { if ($script:LogWriter) { $script:LogWriter.Dispose() } } catch { } + $script:LogWriter = $null + $script:LogWriterPath = $null + + if (-not $script:LogWriteFailed) { + $script:LogWriteFailed = $true + $script:Stats.WarningsCount++ + Write-Host " [WARN] Log file could not be written ($($_.Exception.Message)) - the run continues, but $($script:LogPath) may be incomplete" -ForegroundColor Yellow + } + } +} + function Write-Log { <# .SYNOPSIS @@ -461,47 +536,8 @@ function Write-Log { $timestamp = (Get-Date).ToString('HH:mm:ss') $logMessage = "[$timestamp] [$Level] $Message" - # Write to log file. v2.17 (p.7 of the audit): Out-File used to open, seek to end, - # write and close the file on every single call - Write-Log fires hundreds of times - # per run. A StreamWriter kept open for the run avoids that, with AutoFlush so each - # line still lands on disk immediately (same durability as before, just cheaper). - # FileShare.Delete matters for tests: they Remove-Item the log path in AfterAll while - # this writer may still be the last one that touched it. if (-not $NoLog) { - try { - if (-not $script:LogWriter -or $script:LogWriterPath -ne $script:LogPath) { - if ($script:LogWriter) { $script:LogWriter.Dispose() } - $fileStream = [System.IO.File]::Open( - $script:LogPath, [System.IO.FileMode]::Append, [System.IO.FileAccess]::Write, - ([System.IO.FileShare]::ReadWrite -bor [System.IO.FileShare]::Delete)) - $script:LogWriter = [System.IO.StreamWriter]::new($fileStream, [System.Text.Encoding]::UTF8) - $script:LogWriter.AutoFlush = $true - $script:LogWriterPath = $script:LogPath - } - $script:LogWriter.WriteLine($logMessage) - } catch { - # v2.20: this used to be an empty catch, so a log that stopped being written - # (full volume, revoked permissions, the v2.14 case where cleanup deleted the - # log out from under us) was invisible: destructive work carried on, the final - # JSON said ErrorsCount=0, and LogPath pointed at a truncated file. - # - # Latched: one console line, not one per call - Write-Log fires hundreds of - # times per run. Deliberately Write-Host and not Write-Log, which would - # recurse straight back into this catch. - # v2.20, corrected in review: drop the writer so the NEXT call reopens it. - # Without this the guard above stays satisfied by a dead writer object and - # every later line is silently discarded for the rest of the run - the empty - # catch would simply have moved from the first failure to all the others. - try { if ($script:LogWriter) { $script:LogWriter.Dispose() } } catch { } - $script:LogWriter = $null - $script:LogWriterPath = $null - - if (-not $script:LogWriteFailed) { - $script:LogWriteFailed = $true - $script:Stats.WarningsCount++ - Write-Host " [WARN] Log file could not be written ($($_.Exception.Message)) - the run continues, but $($script:LogPath) may be incomplete" -ForegroundColor Yellow - } - } + Write-LogFileLine -Line $logMessage } # Console output with colors @@ -1486,7 +1522,7 @@ function Invoke-ScriptUpdate { Write-Host $line -ForegroundColor Gray } Write-Host "" - return + return $false } # Check if interactive console is available @@ -1500,7 +1536,7 @@ function Invoke-ScriptUpdate { Write-Host $line -ForegroundColor Gray } Write-Host "" - return + return $false } if ($UpdateInfo.Channel -ne 'gallery') { @@ -1525,7 +1561,7 @@ function Invoke-ScriptUpdate { Write-Host " Press any key to continue with current version..." -ForegroundColor DarkGray Wait-ForKeyPress Write-Host "" - return + return $false } # The running file is the Gallery copy, so updating it updates what runs next @@ -1538,7 +1574,7 @@ function Invoke-ScriptUpdate { Write-Log "Update skipped by user" -Level INFO Write-Host " Update skipped. Continuing with current version..." -ForegroundColor DarkGray Write-Host "" - return + return $false } Write-Host "" @@ -1547,7 +1583,12 @@ function Invoke-ScriptUpdate { try { # PowerShellGet ships with PowerShell today and PSResourceGet is its replacement; # either can be the one present, and the one that answered discovery goes first - switch (Select-UpdateCommand -Provider $UpdateInfo.Provider) { + # $null = ... deliberately: v2.22 made this function's return value meaningful + # ("the run is over"), and a bare switch emits whatever the update provider writes + # to the pipeline. A provider that returned an object would turn $true into an + # array, and `if (Invoke-ScriptUpdate ...)` would then be deciding on the array's + # truthiness rather than on the answer this function meant to give. + $null = switch (Select-UpdateCommand -Provider $UpdateInfo.Provider) { 'Update-Script' { Update-Script -Name WinClean -Force -ErrorAction Stop } 'Update-PSResource' { Update-PSResource -Name WinClean -Force -TrustRepository -ErrorAction Stop } default { throw "no update command available (neither Update-Script nor Update-PSResource)" } @@ -1562,7 +1603,7 @@ function Invoke-ScriptUpdate { Write-Host " ✗ Update failed: $_" -ForegroundColor Red Write-Host " Continuing with current version..." -ForegroundColor Yellow Write-Host "" - return + return $false } # v2.21: verify against the file being executed. A provider that reports success @@ -1587,7 +1628,7 @@ function Invoke-ScriptUpdate { Write-Host $line -ForegroundColor Gray } Write-Host "" - return + return $false } Write-Log "Update successful" -Level SUCCESS @@ -1597,20 +1638,15 @@ function Invoke-ScriptUpdate { Write-Host "" Write-Host " Press any key to exit..." -ForegroundColor DarkGray - # This exit bypasses the finally in Start-WinClean, so the result JSON has to be - # written here (raised in review). Start-WinClean deletes any previous file before the - # update check, so without this a consumer saw exit code 0 and NO artefact at all - - # indistinguishable from a crash, and identical to a run that did nothing. Same shape - # as the PendingRebootDeclined path, which already does this. - $script:Stats.Aborted = 'UpdatedAndExited' - Write-ResultJson -Path $ResultJsonPath - Wait-ForKeyPress - # Not an unconditional 0 (raised in review): Write-ResultJson reports its own failure by - # counting an error rather than throwing, and exiting 0 anyway would rebuild the exact - # "success with no artefact" state the JSON write was added here to prevent. - if ($script:Stats.ErrorsCount -gt 0) { exit 1 } - exit 0 + + # v2.22: this used to call exit here, which bypassed the finally in Start-WinClean and + # therefore had to hand-copy the result JSON write and the exit-code rule alongside it. + # The caller now owns ending the run, through the one path that does it (raised in + # external review). The exit code is unchanged: the entry point already derives it from + # ErrorsCount, which is what the copied lines here were re-deriving. + $script:Stats.Aborted = 'UpdatedAndExited' + return $true } function Install-ModuleWithTimeout { @@ -5990,6 +6026,52 @@ function Write-ResultJson { } } +function Complete-WinCleanRun { + <# + .SYNOPSIS + The single end-of-run path: result JSON, final summary, log handle release + .DESCRIPTION + v2.22, raised in external review. Three paths ended a run - the normal finally, a + successful self-update, and a declined pending-reboot prompt - and only the first + released the log handle or showed a summary. The other two hand-copied whichever + parts someone had remembered at the time, which is exactly how v2.21 came to ship + two separate fixes to the same few lines (first a missing result JSON, then an + unconditional exit 0 over a run that had errors). The defect was never any one + omission; it was that the list existed in three places. Anything added here from + now on reaches every exit. + + Latched, so a path that completes the run explicitly and then unwinds through a + finally does not write the artefacts twice. + #> + param([string]$ResultPath) + + if ($script:RunCompleted) { return } + $script:RunCompleted = $true + + # JSON goes first: Show-FinalStatistics may block on a keypress in interactive + # mode, and automated runs must get the result regardless. + Write-ResultJson -Path $ResultPath + + # An aborted run has no maintenance to summarise, and the summary header would + # announce "COMPLETED SUCCESSFULLY" over a run that deliberately did nothing. + # Preserved behaviour: neither abort path ever showed it. + if (-not $script:Stats.Aborted) { + Show-FinalStatistics + } + + # Release the log file handle (v2.17, p.7): a stand or the user may want to move or + # zip the log right after the run finishes. + # v2.20: guarded. Dispose on a writer whose stream already failed throws, and this + # used to be the last statement of the outer finally - the exception escaped + # Start-WinClean and the entry point never reached its exit-code check, so a run with + # errors could still exit 0 (raised in review). + if ($script:LogWriter) { + try { $script:LogWriter.Dispose() } catch { } + $script:LogWriter = $null + $script:LogWriterPath = $null + } +} + function Invoke-Phase { <# .SYNOPSIS @@ -6047,10 +6129,18 @@ function Start-WinClean { $script:ProgressActivities = @() $script:InternetConnectionCache = $null $script:LogWriteFailed = $false - - # Initialize log - "WinClean v$($script:Version) - Started at $(Get-Date)" | Out-File -FilePath $script:LogPath -Encoding utf8 - "=" * 70 | Out-File -FilePath $script:LogPath -Append -Encoding utf8 + # v2.22: the latch on Complete-WinCleanRun, reset with everything else - a second + # call in the same session must produce its own artefacts, not silently skip them + # because the first run already completed. + $script:RunCompleted = $false + + # Initialize log. v2.22 (raised in external review): these two lines were bare Out-File + # calls, and this runs BEFORE the main try/finally below. A log path that cannot be + # opened throws there - measured on six of seven bad paths - and the exception escaped + # Start-WinClean before any safety net existed: no result JSON, no summary, and none of + # the maintenance, because of the log. Now they degrade like every other log write. + Write-LogFileLine -Line "WinClean v$($script:Version) - Started at $(Get-Date)" -StartNewFile + Write-LogFileLine -Line ("=" * 70) # v2.17 (p.13 of the audit): recover from a hard-killed previous run before doing # anything else - not in ReportOnly, which promises no changes @@ -6101,8 +6191,11 @@ function Start-WinClean { Write-Host " Operation cancelled. Please reboot and run again." -ForegroundColor Yellow Write-Host "" # Record the abort so automation does not mistake it for a completed run + # v2.22: through the shared end-of-run path. This branch used to write the + # JSON by hand and return, so it released no log handle - one of the three + # divergent endings that made the same fix necessary twice (external review). $script:Stats.Aborted = 'PendingRebootDeclined' - Write-ResultJson -Path $ResultJsonPath + Complete-WinCleanRun -ResultPath $ResultJsonPath return } } else { @@ -6122,7 +6215,14 @@ function Start-WinClean { try { $updateInfo = Test-ScriptUpdate if ($updateInfo) { - Invoke-ScriptUpdate -UpdateInfo $updateInfo + # v2.22: Invoke-ScriptUpdate reports whether the run is over instead of + # calling exit itself. A successful self-update replaced the running file, + # so continuing would perform maintenance with the old code still loaded - + # the run ends here, but it ends the same way every other run does. + if (Invoke-ScriptUpdate -UpdateInfo $updateInfo) { + Complete-WinCleanRun -ResultPath $ResultJsonPath + return + } } } catch { Write-Log "Update check could not be completed: $_" -Level WARNING @@ -6241,21 +6341,10 @@ function Start-WinClean { Write-Log "Critical error outside any phase: $_" -Level ERROR $script:Stats.ErrorsCount++ } finally { - # JSON goes first: Show-FinalStatistics may block on a keypress in - # interactive mode, and automated runs must get the result regardless - Write-ResultJson -Path $ResultJsonPath - Show-FinalStatistics - # Release the log file handle (v2.17, p.7): a stand or the user may want to - # move/zip the log right after the run finishes. - # v2.20: guarded. Dispose on a writer whose stream already failed throws, and this - # is the last statement of the outer finally - the exception escaped Start-WinClean - # and the entry point never reached its exit-code check, so a run with errors could - # still exit 0 (raised in review). - if ($script:LogWriter) { - try { $script:LogWriter.Dispose() } catch { } - $script:LogWriter = $null - $script:LogWriterPath = $null - } + # v2.22: the whole end-of-run sequence now lives in one function, shared with the + # two abort paths that used to hand-copy parts of it. Ordering, the aborted-run + # rule and the guarded Dispose are documented there. + Complete-WinCleanRun -ResultPath $ResultJsonPath } } diff --git a/tests/Fixes.Tests.ps1 b/tests/Fixes.Tests.ps1 index 69f9981..a6390d5 100644 --- a/tests/Fixes.Tests.ps1 +++ b/tests/Fixes.Tests.ps1 @@ -1167,14 +1167,71 @@ Describe "v2.20: an operation that did nothing does not report success" -Tag "Fi } } + Context "Single end-of-run path (v2.22)" { + # The behaviour of each piece is tested in Helpers.Tests.ps1. What cannot be reached + # behaviourally without running the whole script as admin is the WIRING: that all + # three endings go through the one function. That is precisely what drifted before - + # each ending kept its own hand-copied subset of the list. + + It "Every ending goes through Complete-WinCleanRun" { + # Comment lines are excluded deliberately: counting raw matches also counted the + # comment that names the function, which is the documented way these grep-style + # tests go wrong (a match in prose passing for a match in code). + $body = Get-FunctionBody -Name 'Start-WinClean' + $calls = @($body -split "`n" | Where-Object { $_ -notmatch '^\s*#' -and $_ -match 'Complete-WinCleanRun' }) + $calls.Count | + Should -Be 3 -Because 'the finally, the self-update path and the declined-reboot path must all use it' + } + + It "Start-WinClean no longer hand-copies the end-of-run steps" { + $body = Get-FunctionBody -Name 'Start-WinClean' + $body | Should -Not -Match 'Show-FinalStatistics' + $body | Should -Not -Match 'Write-ResultJson' + } + + It "A verified self-update ends the run through the caller, not with exit" { + $body = Get-FunctionBody -Name 'Invoke-ScriptUpdate' + $body | Should -Not -Match '(?m)^\s*exit\s' + $body | Should -Not -Match 'Write-ResultJson' + $body | Should -Match 'return \$true' + } + + It "Start-WinClean acts on the answer instead of ignoring it" { + # A caller that dropped the `if` would carry on running maintenance with the + # script file already replaced underneath it. + $body = Get-FunctionBody -Name 'Start-WinClean' + $body | Should -Match 'if \(Invoke-ScriptUpdate -UpdateInfo \$updateInfo\)' + } + } + Context "Logging failure is visible" { It "Latches the first log write failure instead of swallowing every one" { - $body = Get-FunctionBody -Name 'Write-Log' + # v2.22: the degradation moved into Write-LogFileLine, so that the log header - + # written before Start-WinClean's try/finally exists - goes through it too. + $body = Get-FunctionBody -Name 'Write-LogFileLine' $body | Should -Match '\$script:LogWriteFailed' - # Write-Log must not report its own failure through Write-Log + # It must not report its own failure through Write-Log, which would recurse $body | Should -Match 'Write-Host' } + It "Write-Log still routes file writes through the one degrading primitive" { + # Guards the seam the refactor created: a Write-Log that opened the file itself + # again would leave two implementations of the same fault tolerance, which is + # the state this change removed. + $body = Get-FunctionBody -Name 'Write-Log' + $body | Should -Match 'Write-LogFileLine' + $body | Should -Not -Match '\[System\.IO\.File\]::Open' + } + + It "The log header cannot kill the run - it is not written with a bare Out-File" { + # The defect itself (external review, v2.22): two Out-File calls before any + # safety net. Measured: six of seven bad log paths throw a terminating error, + # which escaped Start-WinClean and cost the run its JSON and its maintenance. + $body = Get-FunctionBody -Name 'Start-WinClean' + $body | Should -Not -Match 'Out-File\s+-FilePath\s+\$script:LogPath' + $body | Should -Match 'Write-LogFileLine -Line "WinClean v' + } + It "Surfaces it in the result JSON so a consumer knows the log is incomplete" { $body = Get-FunctionBody -Name 'Write-ResultJson' $body | Should -Match 'LoggingDegraded' diff --git a/tests/Helpers.Tests.ps1 b/tests/Helpers.Tests.ps1 index b5c5822..0af9453 100644 --- a/tests/Helpers.Tests.ps1 +++ b/tests/Helpers.Tests.ps1 @@ -962,6 +962,125 @@ Describe "Write-Log" -Tag "Unit", "Helper" { #endregion +#region Write-LogFileLine Tests (v2.22 - log init must never kill the run) + +Describe "Write-LogFileLine" -Tag "Unit", "Helper", "V222" { + + BeforeEach { + $script:prevLogPath = $script:LogPath + $script:prevWarnings = $script:Stats.WarningsCount + $script:prevFailed = $script:LogWriteFailed + if ($script:LogWriter) { $script:LogWriter.Dispose(); $script:LogWriter = $null; $script:LogWriterPath = $null } + $script:LogWriteFailed = $false + $script:testLog = Join-Path ([System.IO.Path]::GetTempPath()) "WinCleanLogLine_$(Get-Random).log" + $script:LogPath = $script:testLog + } + + AfterEach { + if ($script:LogWriter) { $script:LogWriter.Dispose(); $script:LogWriter = $null; $script:LogWriterPath = $null } + Remove-Item -LiteralPath $script:testLog -Force -ErrorAction SilentlyContinue + $script:LogPath = $script:prevLogPath + $script:Stats.WarningsCount = $script:prevWarnings + $script:LogWriteFailed = $script:prevFailed + } + + It "Writes the line verbatim, without adding a timestamp or level" { + # The header is not a log entry: it must land exactly as composed. Write-Log's + # "[HH:mm:ss] [LEVEL] " prefix belongs to Write-Log, not to this primitive. + Write-LogFileLine -Line 'WinClean v9.99 - Started at whenever' -StartNewFile + (Get-Content -LiteralPath $script:testLog -Raw) | Should -Match ([regex]::Escape('WinClean v9.99 - Started at whenever')) + (Get-Content -LiteralPath $script:testLog -Raw) | Should -Not -Match '^\[' + } + + It "Appends by default and truncates only with -StartNewFile" { + # -StartNewFile preserves what the replaced Out-File (no -Append) did. Losing it + # would silently merge every run sharing a custom -LogPath into one file. + Write-LogFileLine -Line 'first run' -StartNewFile + Write-LogFileLine -Line 'same run' + (Get-Content -LiteralPath $script:testLog -Raw) | Should -Match 'first run' + (Get-Content -LiteralPath $script:testLog -Raw) | Should -Match 'same run' + + Write-LogFileLine -Line 'second run' -StartNewFile + $after = Get-Content -LiteralPath $script:testLog -Raw + $after | Should -Match 'second run' + $after | Should -Not -Match 'first run' -Because '-StartNewFile must truncate, matching the Out-File it replaced' + } + + # The defect this whole function exists for. Measured in review: six of seven bad log + # paths make Out-File throw a TERMINATING error even at ErrorActionPreference=Continue, + # and the header is written before Start-WinClean's try/finally exists - so the run + # died with no result JSON and no maintenance because of the log file. + It "Never throws on a log path that cannot be opened - " -ForEach @( + @{ Case = 'missing directory'; Path = { Join-Path ([System.IO.Path]::GetTempPath()) "nope_$(Get-Random)\sub\a.log" } } + @{ Case = 'path is a directory'; Path = { [System.IO.Path]::GetTempPath() } } + @{ Case = 'invalid characters'; Path = { Join-Path ([System.IO.Path]::GetTempPath()) 'a:b:c.log' } } + @{ Case = 'over-long path'; Path = { Join-Path ([System.IO.Path]::GetTempPath()) (('x' * 300) + '.log') } } + # An unreachable UNC path was measured too (IOException, same as the two above) but + # is deliberately not a case here: it is the only one that depends on network + # resolution, took 8.3s locally, and would be this suite's one flake-prone test. + ) { + $script:LogPath = & $Path + { Write-LogFileLine -Line 'header' -StartNewFile } | Should -Not -Throw -Because 'a log that cannot be written is a degraded run, never a failed one' + } + + It "Reports the failure instead of swallowing it - LoggingDegraded reaches the result JSON" { + # Not throwing must not become not telling. LogWriteFailed is what surfaces as + # LoggingDegraded in the result JSON, so an automated consumer can see the run's + # log is incomplete rather than trusting a silent success. + $script:LogPath = Join-Path ([System.IO.Path]::GetTempPath()) "nope_$(Get-Random)\sub\a.log" + $before = $script:Stats.WarningsCount + + Write-LogFileLine -Line 'header' -StartNewFile 3>$null 6>$null + + $script:LogWriteFailed | Should -BeTrue -Because 'LoggingDegraded in the result JSON is driven by this flag' + $script:Stats.WarningsCount | Should -Be ($before + 1) + } + + It "Latches the warning - a failing log costs one warning, not one per line" { + $script:LogPath = Join-Path ([System.IO.Path]::GetTempPath()) "nope_$(Get-Random)\sub\a.log" + $before = $script:Stats.WarningsCount + + 1..5 | ForEach-Object { Write-LogFileLine -Line "line $_" 3>$null 6>$null } + + $script:Stats.WarningsCount | Should -Be ($before + 1) -Because 'Write-Log fires hundreds of times per run' + } + + It "Recovers when the path becomes writable again" { + # The v2.20 lesson kept: drop the dead writer so a later call reopens it, instead + # of leaving the guard satisfied by a broken object and discarding the rest. + $script:LogPath = Join-Path ([System.IO.Path]::GetTempPath()) "nope_$(Get-Random)\sub\a.log" + Write-LogFileLine -Line 'lost' -StartNewFile 3>$null 6>$null + $script:LogWriter | Should -BeNullOrEmpty -Because 'a failed writer must not be reused' + + $script:LogPath = $script:testLog + Write-LogFileLine -Line 'recovered' -StartNewFile + (Get-Content -LiteralPath $script:testLog -Raw) | Should -Match 'recovered' + } + + It "Drops a writer whose stream broke mid-run, so later lines are not discarded" { + # Added after a mutation survived: the test above only covers a writer that never + # opened, and in that case the field is already $null - so deleting the reset would + # not have failed anything. This covers the case the reset actually exists for: an + # open writer whose stream dies later (the v2.14 shape, where the run's own temp + # cleanup deleted the log out from under it). Without the reset, the guard stays + # satisfied by a dead object and every remaining line is silently thrown away. + Write-LogFileLine -Line 'opened fine' -StartNewFile + $script:LogWriter | Should -Not -BeNullOrEmpty + + # Break the stream underneath the writer without touching the script's bookkeeping + $script:LogWriter.BaseStream.Dispose() + + Write-LogFileLine -Line 'this write fails' 3>$null 6>$null + $script:LogWriter | Should -BeNullOrEmpty -Because 'a broken writer must be dropped, not kept and reused' + + # And the very next line must actually reach the file again + Write-LogFileLine -Line 'back in business' + (Get-Content -LiteralPath $script:testLog -Raw) | Should -Match 'back in business' + } +} + +#endregion + #region Get-RecycleBinSize Tests Describe "Get-RecycleBinSize" -Tag "Unit", "Helper" { @@ -2014,7 +2133,10 @@ Describe "Test-ScriptUpdate" -Tag "Unit", "Helper", "V221" { Describe "Invoke-ScriptUpdate branches" -Tag "Unit", "Helper", "V221" { # These branches were unreachable by the helper tests, which only proved that the # instruction TEXT exists - not that Invoke-ScriptUpdate ever prints it (raised in - # review). Every branch here returns instead of reaching `exit 0`, so the suite is safe. + # review). Every branch here returns rather than completing an update. + # v2.22: the function no longer calls exit at all, so the successful path is testable + # too - it is covered in the V222 region below, which is why this note no longer says + # the suite is only safe because that path is never reached. BeforeEach { $script:Stats.WarningsCount = 0 @@ -2103,24 +2225,22 @@ Describe "Invoke-ScriptUpdate branches" -Tag "Unit", "Helper", "V221" { $script:askedPath | Should -Be $script:WinCleanPath } - It "writes the result JSON before exiting on a verified update" { - # The success path ends in `exit 0`, which bypasses the finally in Start-WinClean - - # and Start-WinClean has already deleted any previous result file by then, so - # without this a consumer saw exit code 0 and no artefact, indistinguishable from a - # crash. Wait-ForKeyPress is the last statement before the exit, so throwing from it - # proves the end of the path was reached without letting the suite exit + It "pauses before handing the run back, so the user reads the outcome" { + # v2.22 rewrote this test. It used to assert that the function wrote the result + # JSON itself, which was only ever true because its own `exit` bypassed the + # finally that should have done it. That workaround is gone: the artefact is now + # the caller's job (covered in the V222 region), and what is worth pinning here is + # the interactive behaviour that survived - the pause. Without it the window of a + # double-clicked shortcut closes on "Update complete" before it can be read. Mock Test-InteractiveConsole { $true } Mock Read-Host { 'y' } Mock Update-Script { } Mock Get-ScriptFileVersion { '2.21' } - Mock Write-ResultJson { } - Mock Wait-ForKeyPress { throw 'REACHED_EXIT' } - { Invoke-ScriptUpdate -UpdateInfo @{ CurrentVersion = '2.20'; LatestVersion = '2.21' - Channel = 'gallery'; Provider = 'PowerShellGet' } } | - Should -Throw -ExpectedMessage 'REACHED_EXIT' + $null = Invoke-ScriptUpdate -UpdateInfo @{ CurrentVersion = '2.20'; LatestVersion = '2.21' + Channel = 'gallery'; Provider = 'PowerShellGet' } - Should -Invoke Write-ResultJson -Times 1 + Should -Invoke Wait-ForKeyPress -Times 1 $script:Stats.Aborted | Should -Be 'UpdatedAndExited' ($script:printed -join "`n").Contains('Update complete') | Should -BeTrue } @@ -2295,6 +2415,192 @@ Describe "Invoke-ScriptUpdate branches" -Tag "Unit", "Helper", "V221" { } } +#region v2.22 single end-of-run path + +Describe "Invoke-ScriptUpdate stop/continue contract" -Tag "Unit", "Helper", "V222" { + # v2.22: the function used to end the process itself, so its most important branch - + # the successful update - could not be tested at all without killing the suite. It now + # answers a question ("is the run over?") and the caller ends the run. That answer is + # the whole contract: get it wrong in the false direction and a run continues doing + # maintenance with a replaced script file underneath it; wrong in the true direction + # and an ordinary run stops before doing any work. + + BeforeEach { + $script:Stats.WarningsCount = 0 + $script:Stats.ErrorsCount = 0 + $script:Stats.Aborted = $null + $script:printed = [System.Collections.Generic.List[string]]::new() + Mock Write-Host { if ($Object) { $script:printed.Add([string]$Object) } } + Mock Wait-ForKeyPress { } + } + + It "returns true after an update it verified, so the caller ends the run" { + Mock Test-InteractiveConsole { $true } + Mock Read-Host { 'y' } + Mock Select-UpdateCommand { 'Update-Script' } + Mock Update-Script { } + Mock Get-ScriptFileVersion { '2.21' } + + $stop = Invoke-ScriptUpdate -UpdateInfo @{ CurrentVersion = '2.20'; LatestVersion = '2.21' + Channel = 'gallery'; Provider = 'PowerShellGet' } + + $stop | Should -BeOfType [bool] -Because 'a leaked pipeline object here would make the caller decide on an array' + $stop | Should -BeTrue + $script:Stats.Aborted | Should -Be 'UpdatedAndExited' + ($script:printed -join "`n").Contains('Update complete') | Should -BeTrue + } + + It "stays a plain boolean even when the update provider writes to the pipeline" { + # Added after a mutation survived: dropping the `$null =` in front of the provider + # switch changed nothing, because every mock returns nothing. A real provider that + # emitted an object would make this function return an array, and the caller's + # `if (...)` would then be judging the array rather than the answer. Update-Script + # and Update-PSResource are documented as returning nothing, but "documented as" + # is not "guaranteed to", and the cost of the guard is one assignment. + Mock Test-InteractiveConsole { $true } + Mock Read-Host { 'y' } + Mock Select-UpdateCommand { 'Update-Script' } + Mock Update-Script { [pscustomobject]@{ Name = 'WinClean'; Version = '2.21' } } + Mock Get-ScriptFileVersion { '2.21' } + + $stop = Invoke-ScriptUpdate -UpdateInfo @{ CurrentVersion = '2.20'; LatestVersion = '2.21' + Channel = 'gallery'; Provider = 'PowerShellGet' } + + @($stop).Count | Should -Be 1 -Because 'provider output must not be part of the answer' + $stop | Should -BeOfType [bool] + $stop | Should -BeTrue + } + + It "does not write the result JSON itself - that belongs to the shared end-of-run path" { + # The v2.21 shape: this function hand-copied the JSON write because its own exit + # bypassed the finally. Copying it back would recreate two owners of one artefact. + Mock Test-InteractiveConsole { $true } + Mock Read-Host { 'y' } + Mock Select-UpdateCommand { 'Update-Script' } + Mock Update-Script { } + Mock Get-ScriptFileVersion { '2.21' } + Mock Write-ResultJson { } + + $null = Invoke-ScriptUpdate -UpdateInfo @{ CurrentVersion = '2.20'; LatestVersion = '2.21' + Channel = 'gallery'; Provider = 'PowerShellGet' } + + Should -Invoke Write-ResultJson -Times 0 + } + + It "returns false so the run continues - " -ForEach @( + @{ Case = 'report-only'; Setup = { $script:ReportOnly = $true } } + @{ Case = 'non-interactive'; Setup = { Mock Test-InteractiveConsole { $false } } } + @{ Case = 'user declines'; Setup = { Mock Test-InteractiveConsole { $true }; Mock Read-Host { 'n' } } } + @{ Case = 'update command failed'; Setup = { + Mock Test-InteractiveConsole { $true }; Mock Read-Host { 'y' } + Mock Select-UpdateCommand { 'Update-Script' } + Mock Update-Script { throw 'gallery exploded' } } } + @{ Case = 'version did not change'; Setup = { + Mock Test-InteractiveConsole { $true }; Mock Read-Host { 'y' } + Mock Select-UpdateCommand { 'Update-Script' } + Mock Update-Script { } + Mock Get-ScriptFileVersion { '2.20' } } } + ) { + $ReportOnly = $false + & $Setup + + $stop = Invoke-ScriptUpdate -UpdateInfo @{ CurrentVersion = '2.20'; LatestVersion = '2.21' + Channel = 'gallery'; Provider = 'PowerShellGet' } + + $stop | Should -BeFalse -Because 'the maintenance the user asked for must still run' + $script:Stats.Aborted | Should -BeNullOrEmpty + } + + It "returns false for a copy it cannot aim an update at - " -ForEach @( + @{ Channel = 'installer' } + @{ Channel = 'oneliner' } + @{ Channel = 'manual' } + @{ Channel = 'unknown' } + @{ Channel = 'gallery-ambiguous' } + ) { + $ReportOnly = $false + Mock Test-InteractiveConsole { $true } + + $stop = Invoke-ScriptUpdate -UpdateInfo @{ CurrentVersion = '2.20'; LatestVersion = '2.21' + Channel = $Channel; Provider = 'PowerShellGet' } + + $stop | Should -BeFalse + } +} + +Describe "Complete-WinCleanRun" -Tag "Unit", "Helper", "V222" { + # One end-of-run path for the normal finally and for both abort branches. Before this + # the list of things a run must do on the way out existed in three places, and v2.21 + # shipped two separate fixes to the copies rather than one fix to the list. + + BeforeEach { + $script:Stats.Aborted = $null + $script:RunCompleted = $false + Mock Write-ResultJson { } + Mock Show-FinalStatistics { } + } + + It "writes the result JSON and shows the summary for an ordinary run" { + Complete-WinCleanRun -ResultPath 'C:\some\result.json' + + # -Exactly throughout this block, established by experiment: Pester treats + # `-Times 0` as "never" but `-Times N` (N>0) as "AT LEAST N", so the plain form + # cannot see a duplicate. That is exactly what the latch below has to prevent, and + # a mutation run proved the non-exact assertion could not catch its removal. + Should -Invoke Write-ResultJson -Exactly -Times 1 + Should -Invoke Show-FinalStatistics -Exactly -Times 1 + } + + It "still writes the JSON for an aborted run but shows no summary - " -ForEach @( + @{ Aborted = 'UpdatedAndExited' } + @{ Aborted = 'PendingRebootDeclined' } + ) { + # Automation must always get the artefact; a human must not be told + # "COMPLETED SUCCESSFULLY" about a run that deliberately did nothing. + $script:Stats.Aborted = $Aborted + + Complete-WinCleanRun -ResultPath 'C:\some\result.json' + + Should -Invoke Write-ResultJson -Exactly -Times 1 + Should -Invoke Show-FinalStatistics -Exactly -Times 0 + } + + It "is latched - an abort path that also unwinds through the finally writes once" { + # Both abort paths call this explicitly and then return; the finally calls it again + # for every other run. Without the latch the JSON would be written twice and the + # summary shown twice for the ordinary case. + Complete-WinCleanRun -ResultPath 'C:\some\result.json' + Complete-WinCleanRun -ResultPath 'C:\some\result.json' + Complete-WinCleanRun -ResultPath 'C:\some\result.json' + + Should -Invoke Write-ResultJson -Exactly -Times 1 + Should -Invoke Show-FinalStatistics -Exactly -Times 1 + } + + It "releases the log handle so the log can be moved right after the run" { + $logFile = Join-Path ([System.IO.Path]::GetTempPath()) "WinCleanComplete_$(Get-Random).log" + $prevPath = $script:LogPath + $script:LogPath = $logFile + try { + Write-LogFileLine -Line 'held open' -StartNewFile + $script:LogWriter | Should -Not -BeNullOrEmpty -Because 'the test needs a real open handle to prove it gets released' + + Complete-WinCleanRun -ResultPath 'C:\some\result.json' + + $script:LogWriter | Should -BeNullOrEmpty + # The operational point of releasing it, asserted as behaviour rather than as + # a null check: a still-open handle would make this fail on Windows. + { Remove-Item -LiteralPath $logFile -Force -ErrorAction Stop } | Should -Not -Throw + } finally { + if ($script:LogWriter) { $script:LogWriter.Dispose(); $script:LogWriter = $null; $script:LogWriterPath = $null } + Remove-Item -LiteralPath $logFile -Force -ErrorAction SilentlyContinue + $script:LogPath = $prevPath + } + } +} + +#endregion + Describe "Update-Applications when winget is absent" -Tag "Unit", "Helper", "V221" { BeforeEach { From 12acc1ab774b5717b8cb67a76dad943e504224e0 Mon Sep 17 00:00:00 2001 From: Ivan Bondarev Date: Thu, 23 Jul 2026 11:34:23 +0300 Subject: [PATCH 02/11] =?UTF-8?q?fix:=20=D0=B7=D0=B0=D0=B2=D0=B5=D1=80?= =?UTF-8?q?=D1=88=D1=91=D0=BD=D0=BD=D0=B0=D1=8F=20=D0=BE=D1=87=D0=B8=D1=81?= =?UTF-8?q?=D1=82=D0=BA=D0=B0=20=D0=B4=D0=B8=D1=81=D0=BA=D0=B0=20=D0=B1?= =?UTF-8?q?=D0=BE=D0=BB=D1=8C=D1=88=D0=B5=20=D0=BD=D0=B5=20=D0=BE=D0=B1?= =?UTF-8?q?=D1=8A=D1=8F=D0=B2=D0=BB=D1=8F=D0=B5=D1=82=D1=81=D1=8F=20=D0=BD?= =?UTF-8?q?=D0=B5=D0=B7=D0=B0=D0=B2=D0=B5=D1=80=D1=88=D1=91=D0=BD=D0=BD?= =?UTF-8?q?=D0=BE=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MyAI-zfwv (P1). Найдено на живой машине: cleanmgr /sagerun делает работу за ~10 секунд, закрывает окно и остаётся резидентным - CPU и все три счётчика I/O заморожены, шесть потоков в Wait. Прогон ждал оставшиеся ~890 секунд и затем публиковал ЗАВЕРШЁННУЮ очистку как частичную (DiskCleanupPending). Обе половины неверны, и вторая хуже: это тот же класс нечестного отчёта, который вычищали v2.20 и v2.21, только вывернутый - не успех, которого не было, а незавершённость, которой не было. Ожидание на HasExited было неверной моделью "работа закончена". Добавлен второй, независимый признак завершения: полная неподвижность. Нет прироста CPU и ни одной операции ввода-вывода на протяжении 12 проверок подряд (120 секунд) - работа окончена, независимо от того, вышел процесс или нет. Безопасность решения: - неизмеримость НЕ накапливается в вывод о завершении. Отпечаток нечитаем (сломан WMI, процесс исчез) -> стрик сбрасывается. Иначе машина со сломанным WMI обрезала бы каждую очистку через две минуты и называла её успешной; - стрик требует ПОДРЯД идущей неподвижности, пауза в работе его сбрасывает; - цена ошибочного вердикта ограничена: подчистка StateFlags по-прежнему не трогает процесс, который не вышел. Отпечаток строится на Win32_Process: у System.Diagnostics.Process счётчики Read/Write/OtherOperationCount пусты (замерено), остался бы только CPU. Result JSON получил DiskCleanupStatus: 'completed-resident' отличает измеренный случай от настоящего перебора по времени ('timeout'), который по-прежнему ставит DiskCleanupPending. Булев флаг покрывал две разные истины - лечится статусом, а не более хитрым булевым (тот же приём, что AppUpdatesStatus в 2.21). Ожидание вынесено в Wait-CleanmgrCompletion с инъекцией зависимостей, как Wait-StorageSenseTask: логика теперь проверяема без процесса и без 15 минут. Это и есть причина, по которой дефект дожил до релиза - на стенде cleanmgr выходит нормально. Плюс install.ps1: непрочитанная версия pwsh больше не трактуется как разрешение. Тот же fail-open, что прятался в проверке SHA256 до v2.17. Тесты 607 -> 630. Мутаций 9, все пойманы; одна сначала выжила и вскрыла, что ветка catch (сломанный WMI) не была покрыта вовсе. --- WinClean.ps1 | 211 +++++++++++++++++++++++++++++++++++----- install.ps1 | 17 +++- tests/Fixes.Tests.ps1 | 48 +++++++++ tests/Helpers.Tests.ps1 | 192 ++++++++++++++++++++++++++++++++++++ 4 files changed, 443 insertions(+), 25 deletions(-) diff --git a/WinClean.ps1 b/WinClean.ps1 index 544d396..c9ed3d7 100644 --- a/WinClean.ps1 +++ b/WinClean.ps1 @@ -373,6 +373,14 @@ function New-RunStats { # v2.20: cleanmgr outlived its timeout and is still deleting in the background. The # totals reported by this run are partial, and a consumer must not read them as final. DiskCleanupPending = $false + # v2.22: how the Storage Sense / Disk Cleanup step actually ended. DiskCleanupPending + # alone could not tell "still deleting" from "finished but the process never exited", + # and reported both as pending - so a completed cleanup was published as partial. + # Same shape and reasoning as AppUpdatesStatus (v2.21): when a boolean starts covering + # two different truths, the fix is a status, not a cleverer boolean. + # 'not-run' | 'skipped-parameter' | 'storage-sense' | 'completed' | + # 'completed-resident' | 'timeout' | 'failed' + DiskCleanupStatus = 'not-run' # v2.17 (p.11 of the audit): which top-level phases ran to completion vs threw. # Before this, one exception anywhere in the run silently skipped every phase # after it - Developer Cleanup, Docker/WSL, Visual Studio, Deep System Cleanup, @@ -5211,6 +5219,116 @@ function Invoke-DISMCleanup { } } +function Get-ProcessActivityFingerprint { + <# + .SYNOPSIS + A comparable snapshot of how much work a process has actually done + .DESCRIPTION + v2.22. CPU time plus the three I/O operation counters, as one comparable string. + Two identical fingerprints taken far enough apart mean the process did literally + nothing in between. + + Win32_Process rather than System.Diagnostics.Process, established by measurement: + the .NET object exposes the processor times but leaves ReadOperationCount, + WriteOperationCount and OtherOperationCount empty, so a fingerprint built from it + would compare CPU alone. + + Returns $null when the counters cannot be read (WMI unavailable, the process gone, + access denied). The caller must treat $null as "cannot tell", never as "idle" - + otherwise a broken WMI would look exactly like a finished cleanup. + #> + param([int]$ProcessId) + + try { + $p = Get-CimInstance -ClassName Win32_Process -Filter "ProcessId=$ProcessId" ` + -Property KernelModeTime, UserModeTime, ReadOperationCount, + WriteOperationCount, OtherOperationCount ` + -ErrorAction Stop + if (-not $p) { return $null } + return '{0}|{1}|{2}|{3}|{4}' -f $p.KernelModeTime, $p.UserModeTime, + $p.ReadOperationCount, $p.WriteOperationCount, $p.OtherOperationCount + } catch { + return $null + } +} + +function Update-IdleStreak { + <# + .SYNOPSIS + Counts consecutive checks in which a process did nothing at all + .DESCRIPTION + v2.22, pure so the rule is testable without a process. Returns the new streak + length: one longer when the two fingerprints match, zero otherwise. + + An unreadable fingerprint on either side resets the streak. That is the whole + safety property: "I could not measure it" must never accumulate towards "it has + finished", or a machine with broken WMI would cut every Disk Cleanup short. + #> + param( + [AllowNull()][string]$Previous, + [AllowNull()][string]$Current, + [int]$Streak + ) + + if ([string]::IsNullOrEmpty($Previous) -or [string]::IsNullOrEmpty($Current)) { return 0 } + if ($Current -ceq $Previous) { return $Streak + 1 } + return 0 +} + +function Wait-CleanmgrCompletion { + <# + .SYNOPSIS + Waits for Disk Cleanup to finish its work, which is not the same as exiting + .DESCRIPTION + v2.22, split out in the style of Wait-StorageSenseTask so the wait can be tested + without a process and without waiting fifteen minutes: the caller injects how to + tell whether the process exited, how to read its activity, and how to wait. + + Two completion signals, because HasExited alone was the wrong model of "done". + Measured on a live workstation: cleanmgr /sagerun did its work in about ten + seconds, closed its window, then stayed resident with CPU and all three I/O + counters frozen and every thread in Wait. The run sat out the remaining ~890 + seconds and then published the finished cleanup as partial. + + Returns Outcome ('exited' | 'idle-resident' | 'timeout') and Elapsed seconds. + 'idle-resident' means the work is over and nothing is pending; only the process + outstayed it. 'timeout' means it was still genuinely working when time ran out. + #> + param( + [scriptblock]$HasExited, + [scriptblock]$GetFingerprint, + [int]$MaxWaitSeconds = 900, + [int]$CheckInterval = 10, + [int]$IdleChecksRequired = 12, + [scriptblock]$OnProgress = { param($seconds) }, + [scriptblock]$Wait = { param($seconds) Start-Sleep -Seconds $seconds } + ) + + $elapsed = 0 + $idleStreak = 0 + $fingerprint = & $GetFingerprint + + while (-not (& $HasExited) -and $elapsed -lt $MaxWaitSeconds) { + & $Wait $CheckInterval + $elapsed += $CheckInterval + + $previousFingerprint = $fingerprint + $fingerprint = & $GetFingerprint + $idleStreak = Update-IdleStreak -Previous $previousFingerprint -Current $fingerprint -Streak $idleStreak + + if ($idleStreak -ge $IdleChecksRequired) { + return @{ Outcome = 'idle-resident'; Elapsed = $elapsed } + } + + if ($elapsed % 60 -eq 0) { & $OnProgress $elapsed } + } + + # Re-read rather than assume: the process may have exited during the last interval, + # and that is a cleaner answer than "timeout" for the same instant. + if (& $HasExited) { return @{ Outcome = 'exited'; Elapsed = $elapsed } } + return @{ Outcome = 'timeout'; Elapsed = $elapsed } +} + function Select-StorageSenseTask { <# .SYNOPSIS @@ -5403,6 +5521,7 @@ function Invoke-StorageSense { # was added for. if ($SkipDiskCleanup) { Write-Log "Storage Sense / Disk Cleanup skipped (parameter)" -Level INFO + $script:Stats.DiskCleanupStatus = 'skipped-parameter' return } @@ -5557,6 +5676,13 @@ function Invoke-StorageSense { } } + if ($storageSenseDone) { + # Storage Sense demonstrably did the work, so cleanmgr is not run at all. Recorded + # so the JSON distinguishes this from "Disk Cleanup ran and completed" - they free + # different things, and a consumer comparing runs needs to know which one happened. + $script:Stats.DiskCleanupStatus = 'storage-sense' + } + if (-not $storageSenseDone) { # Fallback to cleanmgr. Every other reason for landing here (ambiguous lookup, # start failed, task failed, timed out, vanished, unverifiable) has already said @@ -5634,6 +5760,7 @@ function Invoke-StorageSense { if ($armed -eq 0) { Write-Log "No Disk Cleanup handlers could be armed - skipping cleanmgr" -Level WARNING $script:Stats.WarningsCount++ + $script:Stats.DiskCleanupStatus = 'failed' return } @@ -5656,43 +5783,74 @@ function Invoke-StorageSense { if (-not $cleanmgr) { Write-Log "Disk Cleanup could not be started (cleanmgr.exe is missing or blocked) - it cleaned nothing" -Level WARNING $script:Stats.WarningsCount++ + $script:Stats.DiskCleanupStatus = 'failed' return } # v2.16: raised from 420s. cleanmgr regularly needs longer on a workstation # with a large component store, and killing it produced a warning on every # single run while cleanmgr kept working in the background anyway. + # + # v2.22: waiting on HasExited alone was the wrong model of "the work is done". + # Measured on a live workstation: cleanmgr /sagerun finished in about ten + # seconds, closed its window and then simply stayed resident - CPU, all three + # I/O counters and its six threads frozen, every thread in Wait. The run then + # sat here for the remaining ~890 seconds and finally declared the FINISHED + # cleanup partial. Both halves of that are wrong, and the second is the worse + # one: it is the same class of dishonest report v2.20 and v2.21 were spent + # removing, only inverted - not success that never happened, but incompleteness + # that never happened. + # + # So a second, independent completion signal: total stillness. If the process + # has done no CPU work and no I/O at all across $idleChecksRequired consecutive + # checks, its work is over whether or not it bothered to exit. $maxWait = 900 # 15 minutes - $elapsed = 0 $checkInterval = 10 - - while (-not $cleanmgr.HasExited -and $elapsed -lt $maxWait) { - Start-Sleep -Seconds $checkInterval - $elapsed += $checkInterval - - # Log progress every minute - if ($elapsed % 60 -eq 0) { - Write-Log "Disk Cleanup still running... ($elapsed seconds)" -Level INFO - } - } - - if (-not $cleanmgr.HasExited) { - # Still killing it would be worse - cleanmgr keeps working after a kill and - # the deletion is mid-flight (v2.16). But this is not an informational - # event either: everything measured after this point is partial, the run - # is about to print a total and write its JSON while an elevated process - # is still deleting, and the freed bytes it goes on to reclaim are counted - # by nobody. - $script:Stats.DiskCleanupPending = $true - Write-Log "Disk Cleanup exceeded $maxWait seconds and is still running - it continues in the background, so the freed figures below are partial" -Level WARNING - $script:Stats.WarningsCount++ - } elseif ($cleanmgr.ExitCode -ne 0) { + # Two full minutes of absolute stillness. Deliberately far longer than needed + # to observe the measured case: a process mid-delete moves at least the "other + # operations" counter, so this is not a race with slow work - it is a margin + # against a pause nobody has observed yet. The cost of being wrong is bounded + # anyway: the registry sweep below still refuses to touch a process that has + # not exited, so a premature verdict cannot pull configuration out from under + # a cleanmgr that turns out to be working after all. + $idleChecksRequired = 12 + + $waitOutcome = Wait-CleanmgrCompletion ` + -HasExited { $cleanmgr.HasExited } ` + -GetFingerprint { Get-ProcessActivityFingerprint -ProcessId $cleanmgr.Id } ` + -MaxWaitSeconds $maxWait -CheckInterval $checkInterval -IdleChecksRequired $idleChecksRequired ` + -OnProgress { param($seconds) Write-Log "Disk Cleanup still running... ($seconds seconds)" -Level INFO } + + $elapsed = $waitOutcome.Elapsed + $finishedWhileResident = $waitOutcome.Outcome -eq 'idle-resident' + + if ($cleanmgr.HasExited -and $cleanmgr.ExitCode -ne 0) { # v2.16: the exit code used to be ignored entirely, so a crash one second # in was still logged as a success Write-Log "Disk Cleanup exited with code $($cleanmgr.ExitCode) - results unverified" -Level WARNING $script:Stats.WarningsCount++ - } else { + $script:Stats.DiskCleanupStatus = 'failed' + } elseif ($cleanmgr.HasExited) { Write-Log "Disk Cleanup completed ($armed categories)" -Level SUCCESS + $script:Stats.DiskCleanupStatus = 'completed' + } elseif ($finishedWhileResident) { + # The work is done; only the process is still here. Not a warning: nothing + # went wrong and nothing is pending, so DiskCleanupPending stays false and + # the figures below are final. + Write-Log "Disk Cleanup completed ($armed categories) - finished after $elapsed seconds, then stayed resident without doing anything further" -Level SUCCESS + Write-Log "cleanmgr.exe is still in the process list but has been completely idle for $($idleChecksRequired * $checkInterval) seconds - not waiting out the remaining $($maxWait - $elapsed) seconds" -Level DETAIL + $script:Stats.DiskCleanupStatus = 'completed-resident' + } else { + # Genuinely still working when the timeout expired. Killing it would be + # worse - cleanmgr keeps working after a kill and the deletion is + # mid-flight (v2.16). But this is not an informational event either: + # everything measured after this point is partial, the run is about to + # print a total and write its JSON while an elevated process is still + # deleting, and the freed bytes it goes on to reclaim are counted by nobody. + $script:Stats.DiskCleanupPending = $true + Write-Log "Disk Cleanup exceeded $maxWait seconds and is still running - it continues in the background, so the freed figures below are partial" -Level WARNING + $script:Stats.WarningsCount++ + $script:Stats.DiskCleanupStatus = 'timeout' } } finally { # Remove StateFlags to avoid leaving traces in the registry. @@ -5991,6 +6149,11 @@ function Write-ResultJson { # v2.20: true when Disk Cleanup outlived its timeout and was left running. # TotalFreedBytes is then a lower bound, not the final figure. DiskCleanupPending = [bool]$script:Stats.DiskCleanupPending + # v2.22: how that step ended, because the boolean above conflated two states. + # 'completed-resident' is the measured case where cleanmgr does its work, closes + # its window and then never exits: finished, nothing pending, figures final. + # 'timeout' is the genuine overrun the boolean was added for. + DiskCleanupStatus = [string]$script:Stats.DiskCleanupStatus # 'enabled' means cleanup figures are understated (Defender blocked some # deletions without reporting an error); 'unknown' means the check itself # failed, so the figures are unverified rather than confirmed good diff --git a/install.ps1 b/install.ps1 index 8b626cf..69c89f8 100644 --- a/install.ps1 +++ b/install.ps1 @@ -77,8 +77,23 @@ if (-not (Test-Path $pwshPath)) { return } +# The version has to be PROVEN, not merely "not disproven" (raised in external review). +# The previous form was `if ($pwshVersion -and $pwshVersion -lt '7.1')`, so a version that +# could not be read left $pwshVersion $null, skipped the comparison and continued - the +# installer then pinned an elevated desktop shortcut to a binary whose suitability nobody +# had established. That is the same fail-open shape as the SHA256 verification that hid +# inside `if ($hashAsset)` until v2.17: a check that cannot run becomes a check that +# passes. Absence of evidence is not evidence of compatibility. $pwshVersion = try { [version](((Get-Item $pwshPath).VersionInfo.ProductVersion -split '-')[0]) } catch { $null } -if ($pwshVersion -and $pwshVersion -lt [version]'7.1') { +if (-not $pwshVersion) { + # Deliberately a different message from "your version is too old": the user needs to + # know the file is there but unreadable, which points at a damaged or substituted + # install rather than at an outdated one. + Stop-Install "PowerShell 7 was found at $pwshPath, but its version could not be read - WinClean requires 7.1+ and will not assume it." ` + "Repair or reinstall PowerShell 7: winget install --id Microsoft.PowerShell --force" + return +} +if ($pwshVersion -lt [version]'7.1') { Stop-Install "PowerShell $pwshVersion found at $pwshPath, but WinClean requires 7.1+." ` "Update it with: winget upgrade --id Microsoft.PowerShell" return diff --git a/tests/Fixes.Tests.ps1 b/tests/Fixes.Tests.ps1 index a6390d5..52ce5c5 100644 --- a/tests/Fixes.Tests.ps1 +++ b/tests/Fixes.Tests.ps1 @@ -904,6 +904,20 @@ Describe "v2.17: bootstrap verification is mandatory" -Tag "Fix", "V217" { $installScript | Should -Match "\[Environment\]::GetFolderPath\(\[Environment\+SpecialFolder\]::ProgramFiles\)" $installScript | Should -Not -Match "Join-Path \`$env:ProgramFiles" } + + It "install.ps1 refuses a pwsh whose version it could not read (v2.22)" { + # External review: `if ($pwshVersion -and $pwshVersion -lt '7.1')` let an unreadable + # version through, because $null fails the first operand and the comparison never + # runs. Same fail-open shape as the SHA256 check that hid inside `if ($hashAsset)` + # until v2.17. The unreadable case must be its own stop, before the comparison. + # Code lines only: the comment above the fix quotes the old fail-open expression + # verbatim, and a naive match found it there. Second time this exact trap fired in + # this release, which is why both checks now strip comments first. + $code = ($installScript -split "`n" | Where-Object { $_ -notmatch '^\s*#' }) -join "`n" + $code | Should -Match '(?m)^if \(-not \$pwshVersion\) \{' + $code | Should -Match 'version could not be read' + $code | Should -Not -Match '\$pwshVersion -and \$pwshVersion -lt' + } } Describe "v2.18: bootstrap host allowlist is exact, not a broad suffix" -Tag "Fix", "V218" -ForEach @( @@ -1167,6 +1181,40 @@ Describe "v2.20: an operation that did nothing does not report success" -Tag "Fi } } + Context "Disk Cleanup that finished but never exited (v2.22)" { + # Wait-CleanmgrCompletion is covered behaviourally in Helpers.Tests.ps1. What is + # not reachable without a live cleanmgr is how Invoke-StorageSense REPORTS the + # 'idle-resident' outcome - and misreporting it is the entire defect: a finished + # cleanup was published as partial. Structural, and labelled as such. + BeforeAll { + $script:senseBody = Get-FunctionBody -Name 'Invoke-StorageSense' + $script:senseCode = ($script:senseBody -split "`n" | Where-Object { $_ -notmatch '^\s*#' }) -join "`n" + } + + It "A cleanup that finished while resident is not reported as pending" { + # DiskCleanupPending means "an elevated process is STILL deleting, the figures + # below are partial". The measured case is the opposite: the work is over. + $residentBranch = [regex]::Match($script:senseCode, + '(?s)\}\s*elseif\s*\(\$finishedWhileResident\)\s*\{(.*?)\}\s*else\s*\{').Groups[1].Value + $residentBranch | Should -Not -BeNullOrEmpty -Because 'the branch must exist to be checked' + $residentBranch | Should -Not -Match 'DiskCleanupPending' + $residentBranch | Should -Match "DiskCleanupStatus = 'completed-resident'" + } + + It "The genuine overrun still sets DiskCleanupPending" { + # The v2.20 guarantee must survive: a cleanmgr that really is still working + # has to keep marking the run's totals as a lower bound. + $script:senseCode | Should -Match '\$script:Stats\.DiskCleanupPending = \$true' + $script:senseCode | Should -Match "DiskCleanupStatus = 'timeout'" + } + + It "The registry sweep still refuses to touch a process that has not exited" { + # This is what bounds the cost of a wrong idle verdict: even if the wait ends + # early, the StateFlags of a still-running cleanmgr are left alone. + $script:senseCode | Should -Match 'if \(\$cleanmgr -and -not \$cleanmgr\.HasExited\)' + } + } + Context "Single end-of-run path (v2.22)" { # The behaviour of each piece is tested in Helpers.Tests.ps1. What cannot be reached # behaviourally without running the whole script as admin is the WIRING: that all diff --git a/tests/Helpers.Tests.ps1 b/tests/Helpers.Tests.ps1 index 0af9453..6c1e458 100644 --- a/tests/Helpers.Tests.ps1 +++ b/tests/Helpers.Tests.ps1 @@ -2415,6 +2415,198 @@ Describe "Invoke-ScriptUpdate branches" -Tag "Unit", "Helper", "V221" { } } +#region v2.22 Disk Cleanup idle detection + +Describe "Update-IdleStreak" -Tag "Unit", "Helper", "V222" { + # The rule that decides when a resident cleanmgr is declared finished. Pure, so the + # decision can be exercised without a process - the measured case (finished in ~10s, + # then frozen for the remaining ~890) is otherwise only reproducible on a live machine. + + It "counts consecutive identical fingerprints" { + $s = 0 + $s = Update-IdleStreak -Previous 'a' -Current 'a' -Streak $s + $s | Should -Be 1 + $s = Update-IdleStreak -Previous 'a' -Current 'a' -Streak $s + $s | Should -Be 2 + } + + It "resets the moment the process does anything" { + # One counter moving is enough: a process mid-delete is not idle. + Update-IdleStreak -Previous 'cpu|1|2|3|4' -Current 'cpu|1|2|3|5' -Streak 11 | Should -Be 0 + } + + It "treats an unreadable fingerprint as 'cannot tell', never as idle - " -ForEach @( + @{ Case = 'previous unreadable'; Prev = $null; Curr = 'a' } + @{ Case = 'current unreadable'; Prev = 'a'; Curr = $null } + @{ Case = 'both unreadable'; Prev = $null; Curr = $null } + @{ Case = 'previous empty'; Prev = ''; Curr = 'a' } + @{ Case = 'current empty'; Prev = 'a'; Curr = '' } + ) { + # The safety property. Get-ProcessActivityFingerprint returns $null when WMI cannot + # answer, and if that accumulated towards the threshold a machine with broken WMI + # would cut every Disk Cleanup short after two minutes and call it complete. + Update-IdleStreak -Previous $Prev -Current $Curr -Streak 11 | + Should -Be 0 -Because 'not being able to measure work is not evidence that work finished' + } + + It "compares case-sensitively, so counters differing only in case still count as work" { + # Fingerprints are numeric today, but -eq in PowerShell is case-INSENSITIVE by + # default and this comparison decides whether to stop waiting. Pinned deliberately. + Update-IdleStreak -Previous 'A|1' -Current 'a|1' -Streak 5 | Should -Be 0 + } +} + +Describe "Get-ProcessActivityFingerprint" -Tag "Unit", "Helper", "V222" { + + It "returns a comparable fingerprint for a live process" { + $fp = Get-ProcessActivityFingerprint -ProcessId $PID + $fp | Should -Not -BeNullOrEmpty + # CPU kernel + CPU user + three I/O counters + ($fp -split '\|').Count | Should -Be 5 + } + + It "changes after the process does measurable work" { + # Proves the fingerprint actually tracks activity. If it were built from cached or + # constant values, everything would look idle and every cleanmgr would be cut short + # at two minutes - the failure mode that matters most here. + $before = Get-ProcessActivityFingerprint -ProcessId $PID + $sink = 0 + 1..200000 | ForEach-Object { $sink += $_ } + $null = Get-ChildItem -Path ([System.IO.Path]::GetTempPath()) -ErrorAction SilentlyContinue | Select-Object -First 50 + $after = Get-ProcessActivityFingerprint -ProcessId $PID + + $after | Should -Not -Be $before + } + + It "returns null for a process id that does not exist, rather than throwing" { + # cleanmgr can exit between two checks; the caller must get "cannot tell", and + # Update-IdleStreak turns that into a reset rather than a false completion. + Get-ProcessActivityFingerprint -ProcessId 999999 | Should -BeNullOrEmpty + } + + It "returns null - never a constant - when the counters cannot be read at all" { + # Added after a mutation survived. The test above exercises the "no such process" + # branch, where Get-CimInstance returns nothing; it never reached the catch, which + # is where a broken WMI lands. Returning any FIXED value there would be the worst + # possible answer: two consecutive unreadable checks would compare equal, the idle + # streak would build on pure ignorance, and a machine with broken WMI would cut + # every Disk Cleanup short and call it finished. + Mock Get-CimInstance { throw 'WMI is unavailable' } + + $first = Get-ProcessActivityFingerprint -ProcessId $PID + $second = Get-ProcessActivityFingerprint -ProcessId $PID + + $first | Should -BeNullOrEmpty + Update-IdleStreak -Previous $first -Current $second -Streak 11 | + Should -Be 0 -Because 'two unreadable samples must not look like two identical ones' + } +} + +Describe "Wait-CleanmgrCompletion" -Tag "Unit", "Helper", "V222" { + # The whole wait, exercised without a process and without waiting fifteen minutes - + # the caller injects exit state, activity and the sleep. This is the part that could + # previously only be observed on a live machine, which is why the defect it fixes + # survived into a release: on the stand cleanmgr exits normally. + + BeforeEach { + $script:progressAt = [System.Collections.Generic.List[int]]::new() + $script:noWait = { param($seconds) } + $script:onProgress = { param($seconds) $script:progressAt.Add($seconds) } + } + + It "returns 'exited' as soon as the process leaves" { + $script:calls = 0 + $r = Wait-CleanmgrCompletion ` + -HasExited { $script:calls++; $script:calls -gt 3 } ` + -GetFingerprint { "moving-$($script:calls)" } ` + -Wait $script:noWait -OnProgress $script:onProgress + + $r.Outcome | Should -Be 'exited' + $r.Elapsed | Should -BeLessThan 900 + } + + It "declares 'idle-resident' once the process has been completely still for the threshold" { + # The measured case: never exits, never does anything again. + $r = Wait-CleanmgrCompletion ` + -HasExited { $false } ` + -GetFingerprint { 'frozen' } ` + -CheckInterval 10 -IdleChecksRequired 12 ` + -Wait $script:noWait -OnProgress $script:onProgress + + $r.Outcome | Should -Be 'idle-resident' + $r.Elapsed | Should -Be 120 -Because '12 checks of 10 seconds, and not one second longer' + } + + It "waits the full timeout when the process keeps working" { + # Every check shows movement, so the idle streak never builds - this must still + # behave exactly as it did before v2.22. + $script:n = 0 + $r = Wait-CleanmgrCompletion ` + -HasExited { $false } ` + -GetFingerprint { $script:n++; "busy-$($script:n)" } ` + -MaxWaitSeconds 900 -CheckInterval 10 ` + -Wait $script:noWait -OnProgress $script:onProgress + + $r.Outcome | Should -Be 'timeout' + $r.Elapsed | Should -Be 900 + } + + It "does not call a busy process idle just because it pauses briefly" { + # Stillness must be CONSECUTIVE. A process that goes quiet for a while and then + # resumes is working, and cutting it short would truncate a real cleanup. + $script:n = 0 + $r = Wait-CleanmgrCompletion ` + -HasExited { $false } ` + -GetFingerprint { + $script:n++ + # quiet for 8 checks, then a burst of work, repeatedly + if ($script:n % 10 -lt 8) { 'quiet-block-' + [math]::Floor($script:n / 10) } else { "work-$($script:n)" } + } ` + -MaxWaitSeconds 900 -CheckInterval 10 -IdleChecksRequired 12 ` + -Wait $script:noWait -OnProgress $script:onProgress + + $r.Outcome | Should -Be 'timeout' -Because 'the streak never reached 12 consecutive still checks' + } + + It "never declares idle when activity cannot be measured at all" { + # Broken WMI must not look like a finished cleanup. Without this, such a machine + # would silently cut every Disk Cleanup off after two minutes. + $r = Wait-CleanmgrCompletion ` + -HasExited { $false } ` + -GetFingerprint { $null } ` + -MaxWaitSeconds 900 -CheckInterval 10 ` + -Wait $script:noWait -OnProgress $script:onProgress + + $r.Outcome | Should -Be 'timeout' + $r.Elapsed | Should -Be 900 + } + + It "reports progress once a minute, not on every check" { + $r = Wait-CleanmgrCompletion ` + -HasExited { $false } ` + -GetFingerprint { $script:n++; "busy-$($script:n)" } ` + -MaxWaitSeconds 300 -CheckInterval 10 ` + -Wait $script:noWait -OnProgress $script:onProgress + + $r.Outcome | Should -Be 'timeout' + $script:progressAt | Should -Be @(60, 120, 180, 240, 300) + } + + It "prefers 'exited' over 'timeout' when the process leaves during the final interval" { + # Same instant, two possible labels; the accurate one is that it exited. + $script:n = 0 + $r = Wait-CleanmgrCompletion ` + -HasExited { $script:n -ge 30 } ` + -GetFingerprint { $script:n++; "busy-$($script:n)" } ` + -MaxWaitSeconds 300 -CheckInterval 10 ` + -Wait $script:noWait -OnProgress $script:onProgress + + $r.Outcome | Should -Be 'exited' + } +} + +#endregion + #region v2.22 single end-of-run path Describe "Invoke-ScriptUpdate stop/continue contract" -Tag "Unit", "Helper", "V222" { From 8e133e15f7196f887bdd15cb658bc5bb260aa57c Mon Sep 17 00:00:00 2001 From: Ivan Bondarev Date: Thu, 23 Jul 2026 11:59:03 +0300 Subject: [PATCH 03/11] =?UTF-8?q?feat:=20=D0=B0=D0=B4=D0=B0=D0=BF=D1=82?= =?UTF-8?q?=D0=B8=D0=B2=D0=BD=D0=B0=D1=8F=20=D1=88=D0=B8=D1=80=D0=B8=D0=BD?= =?UTF-8?q?=D0=B0=20=D0=BA=D0=BE=D0=BD=D1=81=D0=BE=D0=BB=D0=B8=20=D0=B8=20?= =?UTF-8?q?=D1=80=D0=B0=D0=BC=D0=BE=D0=BA=20(MyAI-r6cd)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ярлык открывает conhost в его дефолтных 120 колонках, а таблица winget на локализованной системе требует около 140 - она переносилась, и каждая перенесённая строка ложилась поверх собственного вывода скрипта. Замер на машине, где это нашли: окно 120x30, буфер 120x9001, физический максимум 3824 колонки. Место было, никто его не просил. 1. При старте окно расширяется best-effort (буфер раньше окна - иначе нельзя, не выше физического максимума). Именно так, а не записью консольных свойств в .lnk: ярлык починил бы только ярлык, а NT_CONSOLE_PROPS это двоичный блоб, который WScript.Shell не пишет. Windows Terminal откажет - это нормально, отказ проглатывается: косметика не имеет права ронять обслуживание. 2. Ширина рамок больше не литерал 70 в четырёх местах, а одна величина, выведенная из фактической консоли: max(70, min(90, ширина-6)). Ограничена с обеих сторон - уже 70 не опускается (так выглядели все прошлые версии), выше 90 не поднимается (просили "немного пошире", а не "во весь экран"). Попутно найден скрытый дефект баннера: он был одной here-string, чьи отступы подогнаны вручную под ЧЕТЫРЁХСИМВОЛЬНУЮ версию. Строка заголовка занимала ровно 70 колонок только потому, что "2.21" это четыре символа - версия 2.5 или 2.100 сдвинула бы её границу относительно остальной рамки. Баннер теперь собирается программно, логотип центрируется как единый блок (построчное центрирование разъехало бы буквы), заголовок центрируется по фактической ширине. Проверка геометрии в смоуке идёт с перенаправленным выводом, где ширина неизвестна и рамка равна историческим 70 - то есть широкую рамку она не проверяла бы вообще. Добавлен прогон Test-BoxGeometry на 70/74/80/90 прямо в тестах, плюс якорь на историческую разметку при 70. Тесты 630 -> 658. Мутаций 8, все пойманы; из них четыре сначала выжили и вскрыли, что не проверялись выравнивание блока, центрирование заголовка и граница физического максимума. Ещё одна оказалась эквивалентной - записана в мутационном наборе как таковая, а не "починена" подгонкой теста. --- WinClean.ps1 | 164 ++++++++++++++++++++++++++++---- tests/Fixes.Tests.ps1 | 29 ++++++ tests/Helpers.Tests.ps1 | 206 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 378 insertions(+), 21 deletions(-) diff --git a/WinClean.ps1 b/WinClean.ps1 index c9ed3d7..0320fc8 100644 --- a/WinClean.ps1 +++ b/WinClean.ps1 @@ -411,6 +411,11 @@ $script:InternetConnectionCache = $null # once instead of on every call - and surfaces in the result JSON as LoggingDegraded $script:LogWriteFailed = $false +# Inner width of every framed section. v2.22: was the literal 70 repeated in four places; +# Start-WinClean now derives it once from the actual console. 70 stays the default so a +# host whose width cannot be read looks exactly as it always did. +$script:BoxWidth = 70 + # Initialize log path (script scope for access in functions) if (-not $LogPath) { $script:LogPath = Join-Path $env:TEMP "WinClean_$((Get-Date).ToString('yyyyMMdd_HHmmss')).log" @@ -539,7 +544,7 @@ function Write-Log { # Consistent left indent for all output (matches banner style) $indent = " " - $boxWidth = 70 # Inner width for framed sections + $boxWidth = $script:BoxWidth # Inner width for framed sections (v2.22: adaptive) $timestamp = (Get-Date).ToString('HH:mm:ss') $logMessage = "[$timestamp] [$Level] $Message" @@ -580,7 +585,7 @@ function Write-Log { Write-Host "" Write-Host "$indent┌─ " -NoNewline -ForegroundColor DarkGray Write-Host $Message -ForegroundColor $tagColors.Message - Write-Host "$indent└$("─" * 70)" -ForegroundColor DarkGray + Write-Host "$indent└$("─" * $boxWidth)" -ForegroundColor DarkGray } 'DETAIL' { # Detail line with vertical bar @@ -812,6 +817,91 @@ function Test-InteractiveConsole { } } +function Get-ConsoleWidth { + <# + .SYNOPSIS + The console width in columns, or 0 when it cannot be determined + .DESCRIPTION + v2.22. Redirected output, scheduled tasks and non-console hosts either throw here + or report nonsense, and 0 is the honest answer for all of them - the caller falls + back to the historical fixed width rather than laying out against a guess. + #> + try { + $width = $Host.UI.RawUI.WindowSize.Width + if ($width -gt 0) { return [int]$width } + } catch { } + return 0 +} + +function Get-BoxWidth { + <# + .SYNOPSIS + Inner width for framed sections, derived from the console width + .DESCRIPTION + v2.22, pure so the bounds are testable. A box is printed as two spaces of indent, + a border character, the inner width, and a closing border character - so it needs + ConsoleWidth-4 at the very most, and 6 is subtracted to keep a margin away from + the wrap column. + + Bounded both ways on purpose: + - never below 70, the width every previous version used. A narrow console cannot + be laid out well either way, and shrinking below 70 would change how WinClean + looks for everyone who has been reading it at 70 for ten releases. + - never above 90. Raising the ceiling further makes the frames span the screen on + a wide monitor, which reads worse rather than better - the user asked for + "somewhat wider", not "as wide as it goes". + #> + param([int]$ConsoleWidth) + + if ($ConsoleWidth -le 0) { return 70 } + return [math]::Max(70, [math]::Min(90, $ConsoleWidth - 6)) +} + +function Expand-ConsoleWindow { + <# + .SYNOPSIS + Best-effort widening of the console window at startup + .DESCRIPTION + v2.22. The desktop shortcut opens conhost at its 120-column default, and winget's + upgrade table needs about 140 on a localised system - so it wrapped, and every + wrapped row landed across the script's own output. Measured on the reporting + machine: window 120x30, buffer 120x9001, maximum physical window 3824 columns. + There was room; nobody had asked for it. + + Deliberately done here rather than by writing console properties into the .lnk: + the shortcut route only fixes the shortcut (and NT_CONSOLE_PROPS is a binary blob + WScript.Shell will not write), while this helps every way of starting the script. + + Best-effort by design. Windows Terminal ignores or refuses programmatic resizing, + redirected hosts throw - none of that is worth a warning, let alone a failed run. + The buffer is grown before the window because a window may never exceed its + buffer; the reverse order fails. + #> + param([int]$DesiredWidth = 140) + + try { + $raw = $Host.UI.RawUI + $current = $raw.WindowSize + if ($current.Width -le 0 -or $current.Width -ge $DesiredWidth) { return } + + # Never ask for more than the screen can physically show + $target = [math]::Min($DesiredWidth, $raw.MaxPhysicalWindowSize.Width) + if ($target -le $current.Width) { return } + + $buffer = $raw.BufferSize + if ($buffer.Width -lt $target) { + $buffer.Width = $target + $raw.BufferSize = $buffer + } + + $window = $raw.WindowSize + $window.Width = $target + $raw.WindowSize = $window + } catch { + # Host refused; the run continues at whatever width it already had + } +} + function Test-InternetConnection { <# .SYNOPSIS @@ -1494,7 +1584,7 @@ function Invoke-ScriptUpdate { # Dynamically centered title in a 70-char box (matches the rest of the UI; v2.14 # fixes a misaligned right border caused by hardcoded padding) - $boxWidth = 70 + $boxWidth = $script:BoxWidth $updateTitle = "UPDATE AVAILABLE" $titlePadding = [math]::Max(0, $boxWidth - $updateTitle.Length) $titleLeftPad = [math]::Floor($titlePadding / 2) @@ -5884,24 +5974,49 @@ function Invoke-StorageSense { function Show-Banner { try { Clear-Host } catch { } - $banner = @" - - ╔══════════════════════════════════════════════════════════════════════╗ - ║ ║ - ║ ██████╗██╗ ███████╗ █████╗ ███╗ ██╗ ║ - ║ ██╔════╝██║ ██╔════╝██╔══██╗████╗ ██║ ║ - ║ ██║ ██║ █████╗ ███████║██╔██╗ ██║ ║ - ║ ██║ ██║ ██╔══╝ ██╔══██║██║╚██╗██║ ║ - ║ ╚██████╗███████╗███████╗██║ ██║██║ ╚████║ ║ - ║ ╚═════╝╚══════╝╚══════╝╚═╝ ╚═╝╚═╝ ╚═══╝ ║ - ║ ║ - ║ Ultimate Windows 11 Maintenance Script v$($script:Version) ║ - ║ ║ - ╚══════════════════════════════════════════════════════════════════════╝ + # v2.22: composed instead of pasted. As one literal here-string its padding was + # hand-counted for a four-character version - the title row measured 70 columns only + # because "2.21" happens to be four characters, and a version like 2.5 or 2.100 would + # have pushed that row's right border out of line with the rest of the box. Composing + # it fixes that and lets the frame follow the console width like every other box. + $inner = $script:BoxWidth + + # Centred as a BLOCK, never line by line: the rows have different lengths by design + # and centring each one would shear the letters apart. 70 is the block's historical + # width, so at the default width the banner is drawn exactly where it always was. + $artBlockWidth = 70 + $art = @( + '' + ' ██████╗██╗ ███████╗ █████╗ ███╗ ██╗' + ' ██╔════╝██║ ██╔════╝██╔══██╗████╗ ██║' + ' ██║ ██║ █████╗ ███████║██╔██╗ ██║' + ' ██║ ██║ ██╔══╝ ██╔══██║██║╚██╗██║' + ' ╚██████╗███████╗███████╗██║ ██║██║ ╚████║' + ' ╚═════╝╚══════╝╚══════╝╚═╝ ╚═╝╚═╝ ╚═══╝' + '' + ) + $artPad = [math]::Max(0, [math]::Floor(($inner - $artBlockWidth) / 2)) -"@ + $title = "Ultimate Windows 11 Maintenance Script v$($script:Version)" + $titlePad = [math]::Max(0, [math]::Floor(($inner - $title.Length) / 2)) - Write-Host $banner -ForegroundColor Cyan + $rows = foreach ($line in $art) { (' ' * $artPad) + $line.PadRight($artBlockWidth) } + $rows = @($rows) + @((' ' * $titlePad) + $title) + @('') + + $bannerLines = @(" ╔$('═' * $inner)╗") + foreach ($row in $rows) { + # Pad, then truncate: a row must fill the frame exactly. Anything longer would + # push the right border out, which is the defect this rewrite removes - so a row + # that cannot fit is cut rather than allowed to break the box. + $cell = $row.PadRight($inner) + if ($cell.Length -gt $inner) { $cell = $cell.Substring(0, $inner) } + $bannerLines += " ║$cell║" + } + $bannerLines += " ╚$('═' * $inner)╝" + + Write-Host "" + Write-Host ($bannerLines -join [Environment]::NewLine) -ForegroundColor Cyan + Write-Host "" # System info $os = Get-CimInstance -ClassName Win32_OperatingSystem @@ -5953,7 +6068,7 @@ function Show-FinalStatisticsBody { Clear-AllProgress # Box dimensions - $boxWidth = 70 # Inner width (matches banner) + $boxWidth = $script:BoxWidth # Inner width (matches banner) $labelWidth = 18 # Width for label column (e.g., "Space freed:") # Determine overall status @@ -6297,13 +6412,20 @@ function Start-WinClean { # because the first run already completed. $script:RunCompleted = $false + # v2.22: ask for a wider window, then lay out against whatever the host actually + # gave us. In this order deliberately - measuring first would size every frame for + # the old width. Both steps are best-effort and silent: a host that refuses to + # resize, or whose width cannot be read, simply keeps the historical 70. + Expand-ConsoleWindow + $script:BoxWidth = Get-BoxWidth -ConsoleWidth (Get-ConsoleWidth) + # Initialize log. v2.22 (raised in external review): these two lines were bare Out-File # calls, and this runs BEFORE the main try/finally below. A log path that cannot be # opened throws there - measured on six of seven bad paths - and the exception escaped # Start-WinClean before any safety net existed: no result JSON, no summary, and none of # the maintenance, because of the log. Now they degrade like every other log write. Write-LogFileLine -Line "WinClean v$($script:Version) - Started at $(Get-Date)" -StartNewFile - Write-LogFileLine -Line ("=" * 70) + Write-LogFileLine -Line ("=" * $script:BoxWidth) # v2.17 (p.13 of the audit): recover from a hard-killed previous run before doing # anything else - not in ReportOnly, which promises no changes diff --git a/tests/Fixes.Tests.ps1 b/tests/Fixes.Tests.ps1 index 52ce5c5..a771bc6 100644 --- a/tests/Fixes.Tests.ps1 +++ b/tests/Fixes.Tests.ps1 @@ -1181,6 +1181,35 @@ Describe "v2.20: an operation that did nothing does not report success" -Tag "Fi } } + Context "Console widening is bounded and cannot fail a run (v2.22)" { + # Structural, and deliberately so: $Host.UI.RawUI is not reachable through any + # cmdlet, so there is nothing to mock and no way to drive these branches from a + # test. Both properties matter enough to pin somehow rather than not at all. + BeforeAll { + $script:expandBody = Get-FunctionBody -Name 'Expand-ConsoleWindow' + } + + It "never asks for a window wider than the screen can show" { + # Asking for more than the physical maximum throws; the catch would swallow it + # and the window would silently stay narrow - the defect, unfixed and unreported. + $script:expandBody | Should -Match 'MaxPhysicalWindowSize' + } + + It "grows the buffer before the window, which is the only order that works" { + $bufferAt = $script:expandBody.IndexOf('$raw.BufferSize =') + $windowAt = $script:expandBody.IndexOf('$raw.WindowSize =') + $bufferAt | Should -BeGreaterThan 0 + $windowAt | Should -BeGreaterThan $bufferAt -Because 'a window may never exceed its buffer' + } + + It "swallows a refusing host instead of failing the run" { + # Windows Terminal refuses programmatic resizing. This runs at the start of + # every run: cosmetics must never be able to stop maintenance. + $script:expandBody | Should -Match '(?s)\} catch \{' + $script:expandBody | Should -Not -Match '(?s)catch \{[^}]*throw' + } + } + Context "Disk Cleanup that finished but never exited (v2.22)" { # Wait-CleanmgrCompletion is covered behaviourally in Helpers.Tests.ps1. What is # not reachable without a live cleanmgr is how Invoke-StorageSense REPORTS the diff --git a/tests/Helpers.Tests.ps1 b/tests/Helpers.Tests.ps1 index 6c1e458..981d0a6 100644 --- a/tests/Helpers.Tests.ps1 +++ b/tests/Helpers.Tests.ps1 @@ -2415,6 +2415,212 @@ Describe "Invoke-ScriptUpdate branches" -Tag "Unit", "Helper", "V221" { } } +#region v2.22 adaptive console width + +Describe "Get-BoxWidth" -Tag "Unit", "Helper", "V222" { + # The frames were a literal 70 in four places. Widening them is only safe if the + # result is bounded at BOTH ends: too wide and the box wraps, which is the very + # defect being fixed (winget's table wrapping into WinClean's output). + + It "keeps the historical width when the console width is unknown - " -ForEach @( + @{ Case = 'zero'; Width = 0 } + @{ Case = 'negative'; Width = -1 } + ) { + # Redirected output and scheduled tasks report no usable width. Laying out + # against a guess there would corrupt exactly the runs nobody is watching. + Get-BoxWidth -ConsoleWidth $Width | Should -Be 70 + } + + It "never returns a box that cannot fit the console - columns" -ForEach @( + @{ Width = 80 }, @{ Width = 100 }, @{ Width = 120 }, @{ Width = 200 }, @{ Width = 3824 } + ) { + # A box costs 2 indent + 1 border + inner + 1 border. Anything wider than the + # console wraps, and a wrapped frame is worse than a narrow one. + $inner = Get-BoxWidth -ConsoleWidth $Width + ($inner + 4) | Should -BeLessOrEqual $Width + } + + It "does not shrink below 70 on a narrow console" { + # 60 columns cannot fit a 70-wide box, but every previous version had the same + # problem there. Shrinking would change the look for everyone to fix nobody. + Get-BoxWidth -ConsoleWidth 60 | Should -Be 70 + Get-BoxWidth -ConsoleWidth 40 | Should -Be 70 + } + + It "does not grow past 90 however wide the console is" { + # The user asked for "somewhat wider", not "as wide as it goes": full-width + # frames on a 3824-column window read worse than 90. + Get-BoxWidth -ConsoleWidth 3824 | Should -Be 90 + Get-BoxWidth -ConsoleWidth 200 | Should -Be 90 + } + + It "actually widens at the reported default of 120 columns" { + # The measured configuration on the machine that reported this: conhost 120x30. + # If this returned 70 the change would have achieved nothing there. + Get-BoxWidth -ConsoleWidth 120 | Should -BeGreaterThan 70 + } + + It "is monotonic - a wider console never yields a narrower box" { + $widths = 40, 60, 76, 80, 96, 120, 200 + $previous = 0 + foreach ($w in $widths) { + $inner = Get-BoxWidth -ConsoleWidth $w + $inner | Should -BeGreaterOrEqual $previous + $previous = $inner + } + } +} + +Describe "Get-ConsoleWidth" -Tag "Unit", "Helper", "V222" { + + It "always answers with an int and never throws" { + # No mock here on purpose: $Host.UI.RawUI is not resolved through any cmdlet, so + # a Mock would be theatre - it would intercept nothing and the test would pass + # for the wrong reason. The no-console path is instead covered where it actually + # matters, by Get-BoxWidth's contract for 0 and negative widths. + { Get-ConsoleWidth } | Should -Not -Throw + Get-ConsoleWidth | Should -BeOfType [int] + } + + It "reports a positive width in this console session" { + # Pester runs in a real console here; if this ever returns 0 locally the + # adaptive path is silently inert and the box stays 70 forever. + Get-ConsoleWidth | Should -BeGreaterThan 0 + } +} + +Describe "Frames stay geometrically consistent at every adaptive width" -Tag "Unit", "Helper", "V222" { + # The smoke test validates geometry too, but it runs with output redirected, where + # the console width is unknown and the box falls back to 70 - so it can only ever + # check the width that existed before this change. Widening is the risky part, and + # this is where it gets checked. + + BeforeAll { + . (Join-Path $PSScriptRoot '..' 'tools' 'BoxGeometry.ps1') + $script:savedBoxWidth = $script:BoxWidth + } + + AfterAll { + $script:BoxWidth = $script:savedBoxWidth + } + + It "renders a consistent banner and section frames at width " -ForEach @( + @{ Width = 70 }, @{ Width = 74 }, @{ Width = 80 }, @{ Width = 90 } + ) { + $script:BoxWidth = $Width + + # Write-Host goes to the information stream in PowerShell 7, so the frames can be + # captured and measured rather than eyeballed. + $out = & { + Show-Banner + Write-Log -Message "A title that must stay centred" -Level TITLE -NoLog + Write-Log -Message "A section header" -Level SECTION -NoLog + Write-Log -Message "An ordinary line" -Level INFO -NoLog + } 6>&1 | ForEach-Object { [string]$_ } + + $lines = ($out -join "`n") -split "`r?`n" | ForEach-Object { $_.TrimEnd() } + $issues = Test-BoxGeometry -Lines $lines + $issues | Should -BeNullOrEmpty -Because "frames must line up at $Width columns, not only at the historical 70" + + # And the frames must actually follow the setting, or this test proves nothing. + # @() around the filter deliberately: a single match indexed with [0] yields the + # first CHARACTER of the string, which quietly measured 0 while looking correct. + $tops = @($lines | Where-Object { $_ -match '^\s*╔═+╗$' }) + $tops | Should -Not -BeNullOrEmpty + [regex]::Match($tops[0], '═+').Value.Length | Should -Be $Width + } + + It "moves the logo as one block, so the letters do not shear apart" { + # Added after a mutation survived: centring each art row independently keeps the + # frame perfectly consistent (every row is padded to the border) while wrecking + # the logo, so the geometry check above cannot see it. The invariant is that + # widening shifts every art row by the SAME amount. + $indentsAt = @{} + foreach ($width in 70, 90) { + $script:BoxWidth = $width + $out = & { Show-Banner } 6>&1 | ForEach-Object { [string]$_ } + $lines = ($out -join "`n") -split "`r?`n" + # Art rows: framed rows whose CONTENT carries logo glyphs. Matching on '█' + # alone finds only five - the last row of the wordmark is drawn entirely from + # '╚═╝' and has no block character in it at all. + $art = @($lines | + ForEach-Object { [regex]::Match($_, '^\s*║(.*)║$').Groups[1].Value } | + Where-Object { $_ -match '[█╚]' }) + $art.Count | Should -Be 6 -Because 'the logo is six rows' + $indentsAt[$width] = $art | ForEach-Object { $_.Length - $_.TrimStart().Length } + } + + $shifts = 0..5 | ForEach-Object { $indentsAt[90][$_] - $indentsAt[70][$_] } + ($shifts | Select-Object -Unique).Count | + Should -Be 1 -Because 'every row of the logo must shift by the same amount, or the letters no longer line up' + $shifts[0] | Should -BeGreaterThan 0 -Because 'a wider box must actually move the block' + + # And at the default width the banner must sit exactly where ten releases of users + # have seen it. Added after a mutation survived the check above: centring each row + # independently keeps the rows in step with each other (they are all the same + # length) while sliding the whole logo sideways, so only an absolute anchor sees it. + $indentsAt[70][0] | Should -Be 6 + $indentsAt[70][1] | Should -Be 5 + $indentsAt[70][5] | Should -Be 6 + } + + It "centres the title rather than padding it by a fixed amount" { + # Added after a mutation survived: a hardcoded left pad still produces a valid box + # (the row is padded out to the border), so only the symmetry can catch it. + $script:BoxWidth = 90 + $out = & { Show-Banner } 6>&1 | ForEach-Object { [string]$_ } + $lines = ($out -join "`n") -split "`r?`n" + + $titleRow = @($lines | Where-Object { $_ -match 'Ultimate Windows 11 Maintenance Script' }) + $titleRow | Should -Not -BeNullOrEmpty + $inner = [regex]::Match($titleRow[0], '^\s*║(.*)║$').Groups[1].Value + $left = $inner.Length - $inner.TrimStart().Length + $right = $inner.Length - $inner.TrimEnd().Length + + [math]::Abs($left - $right) | Should -BeLessOrEqual 1 -Because 'the title must sit in the middle of the frame' + } + + It "keeps the banner square for a version string of any length - ''" -ForEach @( + @{ Version = '2.5' }, @{ Version = '2.21' }, @{ Version = '2.100' }, @{ Version = '10.0' } + ) { + # The banner used to be a literal here-string whose title row was padded by hand + # for exactly four characters of version. It measured 70 columns only because + # "2.21" is four characters; 2.5 or 2.100 would have shifted that row's border + # out of line with the rest of the box - and the next release bumps the version. + $script:BoxWidth = 70 + $savedVersion = $script:Version + try { + $script:Version = $Version + $out = & { Show-Banner } 6>&1 | ForEach-Object { [string]$_ } + $lines = ($out -join "`n") -split "`r?`n" | ForEach-Object { $_.TrimEnd() } + + Test-BoxGeometry -Lines $lines | Should -BeNullOrEmpty + ($lines -join "`n") | Should -Match ([regex]::Escape("v$Version")) + } finally { + $script:Version = $savedVersion + } + } +} + +Describe "Expand-ConsoleWindow" -Tag "Unit", "Helper", "V222" { + + It "never throws, whatever the host does" { + # Windows Terminal refuses programmatic resizing and redirected hosts throw. + # This runs during startup of every single run: it must not be able to fail one. + { Expand-ConsoleWindow -DesiredWidth 140 } | Should -Not -Throw + } + + It "does not shrink a console that is already wider than asked" { + # Asking for less than the current width must be a no-op, not a resize down - + # a user who widened the window deliberately keeps their width. + $before = Get-ConsoleWidth + Expand-ConsoleWindow -DesiredWidth 10 + Get-ConsoleWidth | Should -Be $before + } +} + +#endregion + #region v2.22 Disk Cleanup idle detection Describe "Update-IdleStreak" -Tag "Unit", "Helper", "V222" { From 62c70c11c26e2a4cf280885e8d3b0ca5b7963da6 Mon Sep 17 00:00:00 2001 From: Ivan Bondarev Date: Thu, 23 Jul 2026 12:12:55 +0300 Subject: [PATCH 04/11] =?UTF-8?q?docs:=20v2.22=20-=20=D0=B2=D0=B5=D1=80?= =?UTF-8?q?=D1=81=D0=B8=D1=8F,=20CHANGELOG,=20SUPPORT.md=20=D0=B8=20=D0=BF?= =?UTF-8?q?=D1=80=D0=B8=D0=BD=D1=8F=D1=82=D0=B0=D1=8F=20=D1=87=D0=B0=D1=81?= =?UTF-8?q?=D1=82=D1=8C=20=D0=B2=D0=BD=D0=B5=D1=88=D0=BD=D0=B5=D0=B3=D0=BE?= =?UTF-8?q?=20=D0=B0=D1=83=D0=B4=D0=B8=D1=82=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Версия поднята до 2.22 во всех местах (гейт подтверждает 10/10), CHANGELOG получил раздел 2.22, счётчики тестов сведены к фактическому прогону (668). Storage Sense (MyAI-1qtn): лог теперь различает "выключен в Параметрах" и "включён, отработал, но чистить было нечего". На регулярно обслуживаемой машине происходит второе - каждый раз, и это выглядело как неисправность. 🔴 Само РЕШЕНИЕ намеренно не тронуто. Предложение из задачи - доверять включённому Storage Sense и не запускать cleanmgr - проверено по списку вооружаемых категорий: туда входят Update Cleanup, дампы памяти, Language Pack, старые ChkDsk и Windows Error Reporting, и ничего из этого Storage Sense не делает вообще. Обслуживаемая машина молча перестала бы их чистить. Настройка читается только для сообщения; на это есть тест-страж. Проверено на живой 25H2 (build 26200): ключ StoragePolicy\01 существует и не переименован. Читается из HKCU, поэтому под SYSTEM недоступен - функция трёхсостоянийная и при сомнении отвечает "не знаю", а не "выключено". Из внешнего аудита документации принято: SUPPORT.md (маршрутизация обращений и минимальный набор данных), шаблон release notes в docs/release-process.md, секция "что принимается и по каким критериям" в CONTRIBUTING. Отклонено с обоснованием: публичный roadmap с колонками статусов (7 звёзд, 0 форков - дублирования feature requests, которое он лечит, не существует; это ровно тот артефакт, что гниёт молча), отдельный CODE_OF_CONDUCT (аудитор сам ставит условие "если готовы модерировать"), и главное замечание про якобы отстающий публичный README - опровергнуто запросом README с main через GitHub API. Скобки в report-гварде Invoke-StandTest.ps1: читаемость, не фикс. Приоритет операторов проверен AST и таблицей истинности, ReportNoCleanup был защищён и раньше. Таблица истинности закреплена тестом, чтобы вопрос не поднимался третий раз. --- CHANGELOG.md | 80 ++++++++++++++++++++++++++++++ CLAUDE.md | 51 ++++++++++++++++--- CONTRIBUTING.md | 37 +++++++++++++- README.md | 2 +- README_RU.md | 2 +- SUPPORT.md | 61 +++++++++++++++++++++++ WinClean.ps1 | 67 +++++++++++++++++++++++-- docs/release-process.md | 34 +++++++++++++ docs/result-json.md | 29 +++++++++++ tests/Fixes.Tests.ps1 | 15 ++++++ tests/Helpers.Tests.ps1 | 27 ++++++++++ tests/StandHelpers.Tests.ps1 | 46 +++++++++++++++++ tools/proxmox/Invoke-StandTest.ps1 | 8 ++- 13 files changed, 441 insertions(+), 18 deletions(-) create mode 100644 SUPPORT.md diff --git a/CHANGELOG.md b/CHANGELOG.md index c13266a..0e67d13 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,86 @@ Windows Update driver listing, run-to-run delta and HTML report. See CLAUDE.md. --- +## [2.22] - 2026-07-23 + +A release about knowing when work is finished, and about the run surviving things that +have nothing to do with maintenance. Two of the three defects here are the same mistake in +different places: treating "I could not observe it" as an answer. Disk Cleanup was +declared incomplete because it had not exited, though it had demonstrably stopped working; +the installer accepted a PowerShell whose version it had failed to read. + +### Fixed + +- **A finished Disk Cleanup was reported as still running, and cost the full timeout.** + Measured on a live workstation: `cleanmgr /sagerun` did its work in about ten seconds, + closed its window, and then stayed resident - no CPU, no I/O on any of the three + counters, all six threads waiting. Because the wait was written against process exit, + the run sat there for the remaining ~890 seconds and then set `DiskCleanupPending` and + warned that the figures were partial. They were final. Completion is now recognised by + total stillness as well as by exit: no CPU and no I/O across twelve consecutive checks + means the work is over. Not being able to measure activity resets that counter rather + than advancing it, so a machine where WMI is broken keeps the old, patient behaviour + instead of cutting cleanups short. +- **An unusable `-LogPath` killed the run before any maintenance happened.** The log + header was written with two bare `Out-File` calls placed before the block that + guarantees the result JSON and the summary. Six of seven bad paths make `Out-File` throw + a terminating error even at the default `ErrorActionPreference` (missing directory, path + is a directory, invalid characters, a colon in the name, an over-long path, an + unreachable UNC share), and the exception escaped the whole function. A log that cannot + be written is now a degraded run, as it always was everywhere else - never a failed one. +- **`install.ps1` accepted a PowerShell 7 whose version it could not read.** The check was + `if ($pwshVersion -and $pwshVersion -lt '7.1')`, so an unreadable version left the + comparison unevaluated and the installer carried on to pin an elevated desktop shortcut + to a binary nobody had established was suitable. This is the same fail-open shape as the + SHA256 verification that hid inside `if ($hashAsset)` until v2.17. +- **The banner's frame depended on the version being four characters long.** It was a + single literal block whose padding had been counted by hand; the title row measured 70 + columns only because "2.21" happens to be four characters, so a version like 2.5 or + 2.100 would have pushed its border out of line. The banner is now composed. + +### Changed + +- **Console output follows the window.** The desktop shortcut opens conhost at its + 120-column default while winget's upgrade table needs about 140 on a localised system, + so the table wrapped and every wrapped row landed across WinClean's own output. The + window is now widened best-effort at startup (never beyond what the screen can show, + never failing the run if the host refuses - Windows Terminal does), and the frames are + derived from the actual console width instead of a literal 70 repeated in four places. + Bounded at both ends: never narrower than the historical 70, never wider than 90. +- **Every run ends through one code path.** A successful self-update used to call `exit` + itself, bypassing the block that writes the result JSON, shows the summary and releases + the log handle - so it hand-copied the parts someone remembered at the time. That is how + v2.21 came to ship two separate fixes to the same few lines. The list now exists once. + Exit codes are unchanged. +- **Storage Sense: the log now says which of two things happened** when it returns success + having freed nothing measurable - it is switched off, or it ran and there was simply + nothing left. On a regularly maintained machine the second happens every time and looked + like a malfunction. Deliberately reported rather than acted on: skipping Disk Cleanup on + that basis would silently stop cleaning Update Cleanup, memory dumps, Language Pack, old + ChkDsk files and Windows Error Reporting, none of which Storage Sense touches. + +### Added + +- `DiskCleanupStatus` in the result JSON: `completed`, `completed-resident`, `timeout`, + `storage-sense`, `failed`, `skipped-parameter` or `not-run`. `DiskCleanupPending` was a + boolean covering two different truths; `completed-resident` is the measured case where + the work is done and only the process outstayed it. +- `SUPPORT.md`, a release-notes template in `docs/release-process.md`, and a section in + `CONTRIBUTING.md` describing what gets accepted and on what criteria. + +### Tests + +- 573 -> 668. Three mutation runs over the new logic (30 mutations); every one is caught. + Eight of them survived at first and each exposed a real gap rather than a wrong fix - + among them that `Should -Invoke -Times N` means *at least* N and cannot see a duplicate, + that the "unreadable activity" branch was never exercised, and that nothing pinned the + logo's alignment or the title's centring. +- Frame geometry is now checked at 70, 74, 80 and 90 columns. The smoke test runs with + output redirected, where the width is unknown and the frame falls back to 70 - so on its + own it could only ever have verified the width that existed before this release. + +--- + ## [2.21] - 2026-07-23 A release about telling the truth on the way out. The self-update could change a file diff --git a/CLAUDE.md b/CLAUDE.md index 7052544..af4a6bd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -11,7 +11,7 @@ | Параметр | Значение | |----------|----------| -| **Версия** | 2.21 | +| **Версия** | 2.22 | | **Язык** | PowerShell 7.1+ | | **Платформа** | Windows 11 (23H2/24H2/25H2) | | **Лицензия** | MIT | @@ -43,11 +43,11 @@ CleanScript/ │ └── logo.svg # Логотип проекта ├── get.ps1 # Bootstrap: разовый запуск одной командой (irm | iex) ├── install.ps1 # Bootstrap: установка/обновление + ярлык (RunAs admin) -├── tests/ # Pester тесты (573 всего; счётчик - прогоном, не грепом) -│ ├── Helpers.Tests.ps1 # Unit-тесты helper-функций (280, дот-сорсят продукт - нужны права админа) -│ ├── Fixes.Tests.ps1 # Валидационные тесты исправлений (183) +├── tests/ # Pester тесты (668 всего; счётчик - прогоном, не грепом) +│ ├── Helpers.Tests.ps1 # Unit-тесты helper-функций (355, дот-сорсят продукт - нужны права админа) +│ ├── Fixes.Tests.ps1 # Валидационные тесты исправлений (197) │ ├── Integration.Tests.ps1 # Интеграционные тесты в песочнице ФС (67, требуют admin) -│ ├── StandHelpers.Tests.ps1 # Чистые хелперы стенда: dead-man + версионный гейт ассертов (17, без admin) +│ ├── StandHelpers.Tests.ps1 # Чистые хелперы стенда: dead-man + версионный гейт + таблица истинности report-гварда (23, без admin) │ └── Docs.Tests.ps1 # Гварды документации: нет тире + нет битых внутренних ссылок (26, без admin) ├── tools/ # Тестовая инфраструктура (не публикуется в PSGallery) │ ├── Invoke-ReleaseCheck.ps1 # 🔴 Единая проверка перед релизом (fail-closed) @@ -198,7 +198,7 @@ Publish-PSResource -Path .\WinClean.ps1 -Repository PSGallery -ApiKey $env:PSGAL **Проверки (4 job'а, счёт сверять gh run view --json jobs, а не этой строкой):** 1. **lint** - PSScriptAnalyzer (Error+Warning) через общий ools/Invoke-Lint.ps1 - тот же, что зовёт релиз-гейт 2. **syntax** - Проверка синтаксиса PowerShell -3. **test** - Pester тесты (573; интеграционные требуют admin - на GitHub runners это выполняется) +3. **test** - Pester тесты (668; интеграционные требуют admin - на GitHub runners это выполняется) 4. **smoke** - прогон -ReportOnly + геометрия рамок + result JSON **Исключения PSScriptAnalyzer** (допустимые для CLI): @@ -208,7 +208,7 @@ Publish-PSResource -Path .\WinClean.ps1 -Repository PSGallery -ApiKey $env:PSGAL ### Pester тесты (v2.13+) -- `tests/Helpers.Tests.ps1` - 280 unit-тестов (дот-сорсят WinClean.ps1), `tests/Fixes.Tests.ps1` - 183, `tests/Integration.Tests.ps1` - 67 (песочница ФС, требуют admin), `tests/StandHelpers.Tests.ps1` - 17 (без admin), `tests/Docs.Tests.ps1` - 26 (гварды доков, без admin) +- `tests/Helpers.Tests.ps1` - 355 unit-тестов (дот-сорсят WinClean.ps1), `tests/Fixes.Tests.ps1` - 197, `tests/Integration.Tests.ps1` - 67 (песочница ФС, требуют admin), `tests/StandHelpers.Tests.ps1` - 23 (без admin), `tests/Docs.Tests.ps1` - 26 (гварды доков, без admin) - Особенности: функции в BeforeAll (не AST), regex для locale-независимости, отдельные It блоки --- @@ -218,6 +218,41 @@ Publish-PSResource -Path .\WinClean.ps1 -Repository PSGallery -ApiKey $env:PSGAL ### В работе - [ ] Публикация статьи на Хабре (`docs/_habr/habr-article.md` - текст готов, ждёт скриншоты). ⚠️ Текст писался под старую версию - сверить с v2.21 (появились get.ps1/install.ps1, стенд, ночные прогоны, очистка Driver Store, пофазное выполнение, публичный docs/, `-SkipDiskCleanup`) +### v2.22 - В РАБОТЕ (ветка `feature/v2.22`) + +Эпик **`MyAI-bt2h`**. Свои задачи: `MyAI-zfwv` (P1), `MyAI-r6cd` (P2), `MyAI-1qtn` (P3). +Внешние замечания двух команд разобраны критически, подтверждённые заведены как +`MyAI-bt2h.1` (лог), `.2` (install.ps1), `.3` (единый выход), `.4` (доки). + +🔴 **Два замечания ОТКЛОНЕНЫ проверкой, а не мнением - не переоткрывать:** + +| Замечание | Чем опровергнуто | +|---|---| +| Приоритет `-in`/`-and` в `Invoke-StandTest.ps1` якобы пропускает `ReportNoCleanup` | AST показал группировку `($Mode -in @(...)) -and (-not ...)`, таблица истинности верна. Скобки добавлены только для читаемости, поведение прежнее | +| Публичный README на GitHub якобы отстаёт (6 браузеров, v2.16) | Запрошен `README.md` с `main` через GitHub API: 7 браузеров, `-SkipDiskCleanup`, диаграмма v2.21. Аудитор смотрел устаревший снимок. Вдобавок покрыто гейтом транзитивно (чистое дерево + синк с origin) | + +🔴 **Отклонено и предложение `MyAI-1qtn`** (доверять включённому Storage Sense и не +запускать cleanmgr): проверка показала, что вооружаемые категории включают **Update +Cleanup, дампы памяти, Language Pack, старые ChkDsk, WER** - ничего из этого Storage Sense +не делает. Регулярно обслуживаемая машина молча перестала бы их чистить. Реализована +наблюдаемость (лог различает «выключен» и «включён, но чистить нечего»), решение не +тронуто; на это есть тест-страж в `Fixes.Tests.ps1`. + +🔴 **Durable-уроки этого релиза:** +1. **Мутационная проверка нашла то, чего не нашли 600+ тестов.** Три прогона, 30 мутаций; + восемь сначала ВЫЖИЛИ, и каждая вскрыла реальный пробел, а не требовала подгонки: + `Should -Invoke -Times N` при N>0 означает **«не менее N»** и не видит дубля (`-Times 0` + при этом строгий - проверено экспериментом, поэтому тесты v2.21 править не пришлось); + ветка `catch` (сломанный WMI) не была покрыта вовсе; ничто не пиновало выравнивание + логотипа и центрирование заголовка. Одна мутация оказалась **эквивалентной** - записана + как таковая, а не «починена» подгонкой теста. +2. **Мои гипотезы опровергались замером трижды.** Ожидал, что `Out-File` даст + non-terminating error - **шесть из семи** плохих путей бросают terminating. Ожидал, что + `-Times 0` вакуумен - он строгий. Ожидал, что `Show-Banner` использует общую ширину - он + рисовал жёсткие 70. +3. **Греп-тест дважды поймал паттерн в собственном комментарии.** Оба раза чинилось + фильтрацией строк-комментариев, а не переписыванием комментария. + ### v2.21 - ВЫПУЩЕНА 23.07.2026 Задачи `MyAI-3y5i` (P1), `MyAI-iodp`, `MyAI-7iej`. Релиз про честность на выходе: авто-обновление меняло не тот файл, который выполняется, и печатало «Update complete», а код возврата объявлял провалом успешный прогон, если на машине не было winget или сети. Каналы синхронны: `main`, GitHub Release v2.21 с обоими ассетами, **PSGallery 2.21**, CHANGELOG. Гейт **14/14** с `-VerifyPublished`, стенд Full на обеих VM (RU 1219 МБ, EN 2724 МБ), e2e через опубликованный one-liner (режим подтверждён из result JSON). 452 -> 573 теста, 21 мутация - все пойманы. @@ -399,7 +434,7 @@ pwsh tools/Invoke-ReleaseCheck.ps1 # версия во всех pwsh tools/Invoke-ReleaseCheck.ps1 -IncludeStand # + боевой прогон на VM (минуты) pwsh tools/Invoke-ReleaseCheck.ps1 -VerifyPublished # ПОСЛЕ выпуска: ассеты релиза и SHA256 -Invoke-Pester ./tests -Output Detailed # 573 Pester тестов (считать прогоном, не грепом) +Invoke-Pester ./tests -Output Detailed # 668 Pester тестов (считать прогоном, не грепом) pwsh tools/Invoke-SmokeTest.ps1 # Смоук: ReportOnly + геометрия UI pwsh tools/proxmox/Invoke-StandTest.ps1 -Mode Report # Стенд на Proxmox (RU=VM 190, EN: -ConfigPath ...en.json = VM 191) # Ночная матрица: cron 03:30 на proxmos (/opt/winclean-stand, /etc/cron.d/winclean-stand), отчёт в Telegram diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6a015f5..f3c8d47 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -128,7 +128,7 @@ Invoke-Pester ./tests/Integration.Tests.ps1 All PRs automatically run: - PSScriptAnalyzer (linting) - Syntax check -- Pester tests (573 tests) +- Pester tests (668 tests) ### Release-impacting changes @@ -145,6 +145,39 @@ then before opening the PR: The full release runbook is in [docs/release-process.md](docs/release-process.md). +### What gets accepted + +WinClean runs elevated on other people's working machines, so the bar is about +consequences rather than about size. Before proposing a new cleanup target or behaviour, +it helps to have answers to these: + +- **Safety.** What exactly is deleted, and what could a user lose if the assumption behind + it is wrong on their machine? Anything that deletes must respect `$script:ProtectedPaths`. +- **Preview.** Does `-ReportOnly` describe it accurately without changing anything? +- **Honesty of the report.** Can the step tell "it worked" from "it could not tell"? An + operation that cannot verify itself should say so rather than report success - several + past releases were spent removing exactly that. +- **Tests.** Behavioural tests, not just a grep for the new string. A test that passes + when the feature is deleted is worse than no test. +- **Result JSON.** Does anything change in the schema that automation reads? +- **Verification.** Does it need a real Windows VM to be believed? Anything touching + Windows Update, drivers, Storage Sense, Docker/WSL or locale-dependent output does, and + is checked on the ru-RU and en-US stands before release. + +Ideas that need discussion before code are welcome as a +[Discussion](https://github.com/bivlked/WinClean/discussions) - it is much cheaper to +agree on the acceptance criteria there than after an implementation exists. + +Deliberately out of scope: registry "optimisation", service disabling, telemetry blocking +beyond the documented Group Policy switch, and anything whose benefit cannot be measured +on a stand. + +### Conduct + +Be straightforward and civil - review comments are about the code, not the person. There +is no separate code of conduct document for a project this size; if that ever stops being +enough, one will be added. + ### Commit Messages Follow [Conventional Commits](https://www.conventionalcommits.org/): @@ -251,7 +284,7 @@ Invoke-Pester ./tests/Integration.Tests.ps1 Все PR автоматически проходят: - PSScriptAnalyzer (линтинг) - Проверка синтаксиса -- Pester тесты (573 тестов) +- Pester тесты (668 тестов) ### Изменения, влияющие на релиз diff --git a/README.md b/README.md index 60b5850..3d528c2 100644 --- a/README.md +++ b/README.md @@ -321,7 +321,7 @@ WinClean is built to be safe to run on a working machine. The short version: ``` ┌────────────────────────────────────────────────────────────────┐ -│ WinClean v2.21 │ +│ WinClean v2.22 │ ├────────────────────────────────────────────────────────────────┤ │ PREPARATION │ │ ├─ ✓ Check Administrator Rights │ diff --git a/README_RU.md b/README_RU.md index 186589e..e1bfd5a 100644 --- a/README_RU.md +++ b/README_RU.md @@ -321,7 +321,7 @@ WinClean создан так, чтобы его можно было безопа ``` ┌────────────────────────────────────────────────────────────────┐ -│ WinClean v2.21 │ +│ WinClean v2.22 │ ├────────────────────────────────────────────────────────────────┤ │ ПОДГОТОВКА │ │ ├─ ✓ Проверка прав администратора │ diff --git a/SUPPORT.md b/SUPPORT.md new file mode 100644 index 0000000..d91acf2 --- /dev/null +++ b/SUPPORT.md @@ -0,0 +1,61 @@ +# Support + +WinClean runs elevated and performs system maintenance, so a good report is worth more +here than in most projects: it is often the only way to tell "WinClean did not clean X" +from "there was nothing left of X to clean". + +## Start here + +| Question | Where to look | +|:---------|:--------------| +| Something failed, or a step did nothing | [docs/troubleshooting.md](docs/troubleshooting.md) | +| Is this safe? What exactly gets deleted? | [docs/safety.md](docs/safety.md), [docs/what-is-cleaned.md](docs/what-is-cleaned.md) | +| General questions (Windows 10, how often to run it, rollback) | [docs/faq.md](docs/faq.md) | +| What the run summary fields mean | [docs/result-json.md](docs/result-json.md) | + +## Where to go next + +**A reproducible defect** - open a [bug report](https://github.com/bivlked/WinClean/issues/new?template=bug_report.md). +Something behaved differently from what the documentation says, and you can describe the +steps that produced it. + +**A question, an idea, or a story about how you use it** - +open a [Discussion](https://github.com/bivlked/WinClean/discussions). Questions like "why +does winget keep offering the same package" or "which profile should I use" are answered +there, and feature ideas are easier to shape in a conversation than in an issue. + +**A security vulnerability** - do **not** open a public issue. Use private reporting as +described in [SECURITY.md](SECURITY.md). + +## What to include + +Most of this comes straight from the run, and it is what turns "it did not work" into +something that can actually be diagnosed: + +- **WinClean version** - the banner prints it, and it is also `Version` in the result JSON +- **How you installed it** - `get.ps1` one-liner, `install.ps1` + shortcut, PowerShell + Gallery, or a manual download +- **Windows version** - `winver`, e.g. Windows 11 24H2 (build 26100) +- **PowerShell version** - `$PSVersionTable.PSVersion` +- **Whether the shell was elevated**, and whether you passed `-ReportOnly` +- **The log** from `%TEMP%\WinClean_.log` - the relevant part is enough; it contains + file paths from your machine, so read it before pasting +- **The result JSON**, if you ran with `-ResultJsonPath`. This is the single most useful + artefact: it records which phases ran, which were skipped, which failed, and the + warning and error counts + +If a cleanup step freed less than you expected, `-ReportOnly` output from the same machine +is very helpful too: it shows what WinClean believed was there to clean. + +## What is not supported + +- Windows versions before 10, and PowerShell 5.1 (WinClean requires PowerShell 7.1+) +- Running without administrator rights - most maintenance operations simply cannot work +- Modified copies. If you changed `WinClean.ps1`, please reproduce the problem with an + unmodified release first, so it is clear whose behaviour is being discussed. + +## Response times + +This is a personal open-source project maintained in spare time. Issues and discussions +are read, but there is no service level attached to them. Security reports are looked at +first. diff --git a/WinClean.ps1 b/WinClean.ps1 index 0320fc8..6f2af2d 100644 --- a/WinClean.ps1 +++ b/WinClean.ps1 @@ -1,5 +1,5 @@ <#PSScriptInfo -.VERSION 2.21 +.VERSION 2.22 .GUID 8f7c3b2a-1d4e-5f6a-9b8c-0d1e2f3a4b5c .AUTHOR bivlked .COMPANYNAME @@ -12,6 +12,7 @@ .REQUIREDSCRIPTS .EXTERNALSCRIPTDEPENDENCIES .RELEASENOTES + v2.22: Honest completion and adaptive output - a finished Disk Cleanup is no longer reported as partial, a bad log path can no longer kill a run, every run now ends through one path, and the console output follows the window width v2.21: Self-update targeting and honest exit codes - the update could change a file that was not the one running and still report success; a missing winget or no connectivity no longer fails the run v2.20: Correctness and honesty round - a junction could bypass protected-path checks, four operations reported success while doing nothing, Storage Sense was unreachable so on machines where it works the slow Disk Cleanup no longer runs; where it fails, the new -SkipDiskCleanup is what removes the wait v2.19: Contract and documentation round - -SkipCleanup now skips ALL cleanup categories, result JSON gains a tri-state PhasesSkipped, AppUpdatesCount renamed to AppUpdatesOffered (offered, not installed), full docs overhaul @@ -30,7 +31,7 @@ <# .SYNOPSIS - WinClean - Ultimate Windows 11 Maintenance Script v2.21 + WinClean - Ultimate Windows 11 Maintenance Script v2.22 .DESCRIPTION Комплексный скрипт для обновления и очистки Windows 11: - Обновление Windows (включая драйверы) @@ -44,8 +45,21 @@ - Подробный цветной вывод + лог-файл .NOTES Author: biv - Version: 2.21 + Version: 2.22 Requires: PowerShell 7.1+, Windows 11, Administrator rights + Changes in 2.22: + - A Disk Cleanup that finished its work but never exited is no longer reported as + still running: completion is now detected by total stillness as well as by exit, + so a finished cleanup stops costing the full 15-minute timeout + - An unusable -LogPath can no longer kill the run before it starts - the log header + is written through the same fault-tolerant path as every other log line + - Every run now ends through one code path, so the result JSON, the summary and the + log handle release cannot drift apart between normal and self-update exits + - Console output adapts to the window width: the window is widened best-effort at + startup and the frames follow it, so winget's table stops wrapping into them + - install.ps1 refuses a PowerShell 7 whose version it cannot read, instead of + treating "could not check" as "good enough" + Changes in 2.21: - The self-update could update a DIFFERENT copy than the one running and report success - it asked whether a Gallery copy existed anywhere, not whether the @@ -429,7 +443,7 @@ if (-not $LogPath) { } # Script version (single source of truth for version checking) -$script:Version = "2.21" +$script:Version = "2.22" # Protected paths that should never be deleted $script:ProtectedPaths = @( @@ -5438,6 +5452,31 @@ function Select-StorageSenseTask { return @{ Task = $found[0]; Reason = 'ok' } } +function Test-StorageSenseEnabled { + <# + .SYNOPSIS + Whether Storage Sense is switched on in Settings + .DESCRIPTION + v2.22. Tri-state on purpose: $true / $false / $null for "cannot tell". Used ONLY + to explain what happened, never to decide whether Disk Cleanup runs - see the note + in Invoke-StorageSense for why that distinction matters. + + Verified on a live Windows 11 25H2 (build 26200): the value is still + HKCU\...\StorageSense\Parameters\StoragePolicy\01, and it was not renamed. + + HKCU is the user's hive, so a run under SYSTEM (a scheduled task) reads a + different one and gets nothing - which is exactly why the answer must be allowed + to be $null instead of defaulting to "off". + #> + try { + $policy = Get-ItemProperty -Path 'HKCU:\Software\Microsoft\Windows\CurrentVersion\StorageSense\Parameters\StoragePolicy' ` + -Name '01' -ErrorAction Stop + return [bool][int]$policy.'01' + } catch { + return $null + } +} + function Get-StorageSenseVerdict { <# .SYNOPSIS @@ -5723,7 +5762,25 @@ function Invoke-StorageSense { 'unreadable' { Write-Log "Storage Sense ran but its result could not be read - using Disk Cleanup as well" -Level INFO } 'failed' { Write-Log ("Storage Sense failed (task result 0x{0:X8}) - using Disk Cleanup instead" -f $taskResult) -Level INFO } 'not-measured' { Write-Log "Storage Sense reported success but free space could not be measured - running Disk Cleanup as well" -Level INFO } - 'nothing-freed' { Write-Log "Storage Sense reported success but freed nothing measurable - running Disk Cleanup as well" -Level INFO } + 'nothing-freed' { + # v2.22 (MyAI-1qtn): say WHICH of the two states this is. "Returned + # 0 and freed nothing" covers both "switched off, so it did nothing" + # and "switched on, ran, and there was nothing left to free" - and + # on a regularly maintained machine the second happens every time, + # which looked like a malfunction to the person reading the log. + # + # Reported, NOT acted on. The proposal was to trust an enabled + # Storage Sense and skip Disk Cleanup, and checking what that would + # cost settled it: the categories armed below include Update + # Cleanup, memory dumps, Language Pack, old ChkDsk files and Windows + # Error Reporting - none of which Storage Sense touches at all. A + # maintained machine would silently stop having them cleaned. + switch (Test-StorageSenseEnabled) { + $true { Write-Log "Storage Sense is enabled and ran, but there was nothing left for it to free - running Disk Cleanup as well for the categories it does not cover" -Level INFO } + $false { Write-Log "Storage Sense is switched off in Settings, so its run freed nothing - using Disk Cleanup instead" -Level INFO } + default { Write-Log "Storage Sense reported success but freed nothing measurable, and its setting could not be read - running Disk Cleanup as well" -Level INFO } + } + } default { Write-Log "Storage Sense verdict '$($verdict.Reason)' not recognised - running Disk Cleanup as well" -Level INFO } } } elseif ($waitResult.Outcome -eq 'vanished') { diff --git a/docs/release-process.md b/docs/release-process.md index 0d3ab45..1bd4d75 100644 --- a/docs/release-process.md +++ b/docs/release-process.md @@ -65,6 +65,40 @@ $hash = (Get-FileHash .\WinClean.ps1 -Algorithm SHA256).Hash gh release upload vX.Y ".\WinClean.ps1#WinClean.ps1" "$env:TEMP\WinClean.ps1.sha256#WinClean.ps1.sha256" --clobber ``` +### Release notes template + +For a tool that runs elevated, predictability is worth more than a long feature list, so +every release uses the same headings. Keep them even when a section is short - a reader +looking for "did this change what gets deleted?" should find the answer in the same place +every time. + +```markdown +## What changed +Grouped as Fixed / Changed / Added, one line each, in user terms rather than internal +task numbers. Say what was wrong, not only what was done. + +## Safety notes +Anything that alters what is deleted, what is reported, or how the script decides it is +finished. Say "nothing in this release changes what gets deleted" when that is the case - +its absence is what people check for. + +## Verification +Release gate, Pester count, PSScriptAnalyzer, smoke, and the RU/EN stand runs, plus the +end-to-end run through the published one-liner. State what was actually run for THIS +release, not what the process says in general. + +## Assets +`WinClean.ps1` and `WinClean.ps1.sha256`. Both are required: the bootstrap scripts verify +the hash fail-closed and refuse to run without it. + +## Upgrade +Which of the four installation methods needs an action, and which update themselves. + +## Known limitations +Anything deliberately not fixed in this release, with the reason. This section is the one +most worth writing honestly - it is where trust is earned or lost. +``` + ## Publishing to PSGallery ```powershell diff --git a/docs/result-json.md b/docs/result-json.md index ac22a2d..62b6a82 100644 --- a/docs/result-json.md +++ b/docs/result-json.md @@ -29,6 +29,7 @@ This page documents every field, gives a full sample, and explains how to consum | `RebootRequired` | bool | `true` when a change (a Windows update, an app update finishing on reboot) needs a restart to take effect. | | `LoggingDegraded` | bool | v2.20. `true` when writing the log file failed at some point during the run. The run itself still completed, but `LogPath` points at an incomplete file: do not read that log as the full record of what happened. | | `DiskCleanupPending` | bool | v2.20. `true` when Disk Cleanup outlived its timeout and was left running in the background. `TotalFreedBytes` is then a lower bound rather than the final figure, because deletion continued after this file was written. | +| `DiskCleanupStatus` | string | v2.22. How the Storage Sense / Disk Cleanup step actually ended: `completed`, `completed-resident`, `timeout`, `storage-sense`, `failed`, `skipped-parameter`, or `not-run`. See below - the boolean above conflated a finished cleanup with a still-running one. | | `ControlledFolderAccess` | string | Tri-state, see below. Reflects whether Defender's Controlled Folder Access may have silently blocked deletions. | | `Aborted` | string or null | `null` unless the run stopped early for a known reason: `"PendingRebootDeclined"` (the user declined to continue with a reboot pending) or `"UpdatedAndExited"` (v2.21 - the script updated itself and exited so the new version runs next time). When set, the phase arrays below are incomplete by design. Note `null` does not by itself prove every phase ran - see the invariant note below. | | `PhasesCompleted` | array of string | Phases whose action ran to completion without an uncaught exception. | @@ -78,6 +79,33 @@ Consequently: Treat any value other than `checked` as "the count means nothing", not as "there was nothing to update". +### `DiskCleanupStatus` (added in v2.22) + +`DiskCleanupPending` alone could not tell two different situations apart, and reported +both as pending. Measured on a live workstation: `cleanmgr /sagerun` did its work in about +ten seconds, closed its window, and then stayed in the process list doing nothing at all - +no CPU, no I/O, every thread waiting. Waiting on process exit as the definition of "the +work is done" therefore burned the remaining fifteen-minute timeout and then published a +**finished** cleanup as partial. + +Since v2.22 a second, independent completion signal is used: total stillness. If the +process performs no CPU work and no I/O across twelve consecutive ten-second checks, its +work is over whether or not it exited. This field records which of those endings happened. + +| Value | Meaning | +|-------|---------| +| `completed` | cleanmgr ran and exited with code 0. | +| `completed-resident` | cleanmgr finished its work and went completely idle, but never exited. The cleanup is **done**; the figures are final and `DiskCleanupPending` stays `false`. | +| `timeout` | cleanmgr was still genuinely working when the timeout expired. `DiskCleanupPending` is `true` and `TotalFreedBytes` is a lower bound. | +| `storage-sense` | Storage Sense demonstrably did the work, so cleanmgr was not run. Note this covers a different, smaller set of things than cleanmgr's handlers. | +| `failed` | cleanmgr could not be started, no handlers could be armed, or it exited non-zero. Nothing was verifiably cleaned by this step. | +| `skipped-parameter` | `-SkipDiskCleanup` was passed. | +| `not-run` | The step never executed (for example the run aborted earlier). | + +Note that `completed-resident` is a success. It is reported separately only because a +`cleanmgr.exe` still visible in Task Manager after the run finishes is surprising, and the +log says so explicitly rather than leaving it to be discovered. + ### `ControlledFolderAccess` (tri-state string) | Value | Meaning | @@ -140,6 +168,7 @@ VisualStudioCleanup, DeepSystemCleanup, DiskSpaceReport, Telemetry "ControlledFolderAccess": "disabled", "LoggingDegraded": false, "DiskCleanupPending": false, + "DiskCleanupStatus": "completed", "Aborted": null, "PhasesCompleted": [ "Preparation", diff --git a/tests/Fixes.Tests.ps1 b/tests/Fixes.Tests.ps1 index a771bc6..e3fb9f7 100644 --- a/tests/Fixes.Tests.ps1 +++ b/tests/Fixes.Tests.ps1 @@ -1181,6 +1181,21 @@ Describe "v2.20: an operation that did nothing does not report success" -Tag "Fi } } + Context "Storage Sense setting explains, it does not decide (v2.22)" { + It "The skip verdict stays a function of what happened, not of a setting" { + # MyAI-1qtn proposed trusting an enabled Storage Sense and skipping Disk + # Cleanup when it freed nothing measurable. Checking what that would cost + # settled it: the armed cleanmgr categories include Update Cleanup, memory + # dumps, Language Pack, old ChkDsk files and Windows Error Reporting - none of + # which Storage Sense touches. A maintained machine would silently stop having + # them cleaned. The setting is therefore read for the log only, and this test + # exists so that decision cannot be reversed by accident. + $verdict = Get-FunctionBody -Name 'Get-StorageSenseVerdict' + $verdict | Should -Not -Match 'Test-StorageSenseEnabled' + $verdict | Should -Not -Match 'StoragePolicy' + } + } + Context "Console widening is bounded and cannot fail a run (v2.22)" { # Structural, and deliberately so: $Host.UI.RawUI is not reachable through any # cmdlet, so there is nothing to mock and no way to drive these branches from a diff --git a/tests/Helpers.Tests.ps1 b/tests/Helpers.Tests.ps1 index 981d0a6..3171810 100644 --- a/tests/Helpers.Tests.ps1 +++ b/tests/Helpers.Tests.ps1 @@ -2415,6 +2415,33 @@ Describe "Invoke-ScriptUpdate branches" -Tag "Unit", "Helper", "V221" { } } +#region v2.22 Storage Sense setting is reported, not acted on + +Describe "Test-StorageSenseEnabled" -Tag "Unit", "Helper", "V222" { + # Explains which of two states produced "returned 0 and freed nothing". Deliberately + # NOT wired into the skip decision - see the comment at its call site. + + It "reports enabled when the policy value says so" { + Mock Get-ItemProperty { [pscustomobject]@{ '01' = 1 } } + Test-StorageSenseEnabled | Should -BeTrue + } + + It "reports disabled when the policy value says so" { + Mock Get-ItemProperty { [pscustomobject]@{ '01' = 0 } } + Test-StorageSenseEnabled | Should -BeFalse + } + + It "answers null - not false - when the setting cannot be read" { + # Under SYSTEM (a scheduled task) HKCU is a different hive and the key is absent. + # Reporting "switched off" there would state a fact nobody established. + Mock Get-ItemProperty { throw 'no such key' } + Test-StorageSenseEnabled | Should -BeNullOrEmpty + } + +} + +#endregion + #region v2.22 adaptive console width Describe "Get-BoxWidth" -Tag "Unit", "Helper", "V222" { diff --git a/tests/StandHelpers.Tests.ps1 b/tests/StandHelpers.Tests.ps1 index 352594c..046a668 100644 --- a/tests/StandHelpers.Tests.ps1 +++ b/tests/StandHelpers.Tests.ps1 @@ -115,3 +115,49 @@ Describe "Test-ResultSupportsPhaseBuckets (version gate for stand assertions)" - Test-ResultSupportsPhaseBuckets ([string]$r.Version) | Should -BeTrue } } + +Describe "Stand report-mode guard truth table" -Tag "Unit", "Stand", "V222" { + <# + An external reviewer read the guard in Invoke-StandTest.ps1 + + if ($Mode -in 'Report', 'ReportNoCleanup' -and -not $result.ReportOnly) + + as ambiguous, and suspected ReportNoCleanup might fall outside it - which would + let a report-mode run that silently performed real changes pass as correct (the + 18.07 incident). It was a false alarm: PowerShell binds -in tighter than -and, so + the grouping was already ($Mode -in @(...)) -and (-not ...), confirmed by the AST. + + The expression is parenthesised now for readability, and pinned here so the + question is settled by a test rather than re-argued from precedence rules. + #> + BeforeAll { + # Pester 5 runs a Describe body during discovery only, so a bare `function` + # statement there is gone by the time the tests execute (the same trap is + # documented in Helpers.Tests.ps1). + function Test-ReportModeGuard { + param($Mode, $ReportOnly) + if (($Mode -in 'Report', 'ReportNoCleanup') -and (-not $ReportOnly)) { 'flagged' } else { 'accepted' } + } + } + + It "flags when the result JSON does not confirm ReportOnly" -ForEach @( + @{ Mode = 'Report' } + @{ Mode = 'ReportNoCleanup' } + ) { + Test-ReportModeGuard -Mode $Mode -ReportOnly $false | Should -Be 'flagged' + } + + It "accepts when ReportOnly is confirmed" -ForEach @( + @{ Mode = 'Report' } + @{ Mode = 'ReportNoCleanup' } + ) { + Test-ReportModeGuard -Mode $Mode -ReportOnly $true | Should -Be 'accepted' + } + + It "does not apply to , which is expected to change things" -ForEach @( + @{ Mode = 'Full' } + @{ Mode = 'FullWithUpdates' } + ) { + Test-ReportModeGuard -Mode $Mode -ReportOnly $false | Should -Be 'accepted' + } +} diff --git a/tools/proxmox/Invoke-StandTest.ps1 b/tools/proxmox/Invoke-StandTest.ps1 index 4ab951a..8c2c8bd 100644 --- a/tools/proxmox/Invoke-StandTest.ps1 +++ b/tools/proxmox/Invoke-StandTest.ps1 @@ -195,7 +195,13 @@ if (-not $jsonRaw) { # A report mode that silently ran for real is the 18.07 incident: guard it explicitly # before trusting the ReportOnly-driven branch below, or a run that dropped ReportOnly # would fall through to the "Full freed enough" check and pass while destroying data. - if ($Mode -in 'Report', 'ReportNoCleanup' -and -not $result.ReportOnly) { + # Parenthesised in v2.22 for readability only. An external reviewer read the + # unparenthesised form as ambiguous and suspected ReportNoCleanup fell outside the + # guard; it did not - PowerShell binds -in tighter than -and, and both the AST and a + # truth table confirmed the grouping was already ($Mode -in @(...)) -and (-not ...). + # The behaviour is unchanged; the parentheses just stop the next reader from having to + # verify that again. StandHelpers.Tests.ps1 pins the truth table. + if (($Mode -in 'Report', 'ReportNoCleanup') -and (-not $result.ReportOnly)) { $failures += "ReportOnly not confirmed in result JSON for mode $Mode" } if ($result.ReportOnly) { From 34cdb398e5d3e31f4bcd84e8f4aa7cc0da9c8371 Mon Sep 17 00:00:00 2001 From: Ivan Bondarev Date: Thu, 23 Jul 2026 12:49:07 +0300 Subject: [PATCH 05/11] =?UTF-8?q?fix:=20=D0=BF=D1=80=D0=B0=D0=B2=D0=BA?= =?UTF-8?q?=D0=B8=20=D0=BF=D0=BE=20=D0=BF=D1=8F=D1=82=D0=B8=20=D1=80=D0=B0?= =?UTF-8?q?=D1=83=D0=BD=D0=B4=D0=B0=D0=BC=20=D1=81=D1=82=D0=BE=D1=80=D0=BE?= =?UTF-8?q?=D0=BD=D0=BD=D0=B5=D0=B3=D0=BE=20=D1=80=D0=B5=D0=B2=D1=8C=D1=8E?= =?UTF-8?q?=20(Codex)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Каждый раунд находил дефекты в фиксах предыдущего - ровно как в v2.20 и v2.21. Раунд 1 (три находки): - РЕГРЕССИЯ, внесённая моим же рефакторингом: Wait-ForKeyPress оказался ВНУТРИ Invoke-ScriptUpdate, то есть до записи result JSON. В v2.21 JSON писался ДО паузы. Брошенное или прерванное окно оставляло прогон без артефакта и с неосвобождённым лог-хендлом. Пауза перенесена к вызывающему, после Complete-WinCleanRun; - неподвижность одного нашего PID не доказывает, что работа окончена; - тест обещал "рамка всегда влезает в консоль", проверяя только широкие окна. Раунд 2 (мой фикс оказался хуже проблемы): - агрегирование отпечатка по ИМЕНИ процесса захватывало ЧУЖОЙ cleanmgr, запущенный пользователем вручную. Он мог и подтвердить "активность была", и своей занятостью держать нас все 15 минут после того, как наша очистка закончилась. Сужено до нашего дерева процессов (дети по ParentProcessId, любого имени, seen-set против цикла при переиспользовании PID); - Measure-Object -Sum возвращает Double и теряет точность после 2^53 - суммы считаются [long] (есть тест на 2^53+1). Раунд 3-5 (формулировки, и это не мелочь): - 'completed-resident' утверждал завершение, которого мы НЕ наблюдали. Мы наблюдали неподвижность. Статус переименован в 'idle-resident', лог и доки говорят о наблюдении, а вывод назван выводом. Это ровно тот класс, ради которого прошли три предыдущих релиза; - контракт DiskCleanupPending переопределён честно: true = очистка была ВИДИМО занята на момент таймаута; false прямо помечен как НЕ доказательство того, что больше ничего не будет удалено; - пределы идентификации дерева процессов (переиспользование PID добавит чужого, worker через сервис будет пропущен) записаны как принятое ограничение. 🔴 Граница проведена осознанно: правились утверждения об АЛГОРИТМЕ, а описания ИЗМЕРЕННОГО случая на конкретной машине оставлены - это факт, а не обещание, и без него теряется причина всей работы. Тесты 668 -> 677. --- CHANGELOG.md | 30 +++--- WinClean.ps1 | 206 +++++++++++++++++++++++++++++++++------- docs/result-json.md | 31 +++--- tests/Fixes.Tests.ps1 | 37 +++++++- tests/Helpers.Tests.ps1 | 153 +++++++++++++++++++++++++---- 5 files changed, 375 insertions(+), 82 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e67d13..1c05c20 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,16 +24,18 @@ the installer accepted a PowerShell whose version it had failed to read. ### Fixed -- **A finished Disk Cleanup was reported as still running, and cost the full timeout.** - Measured on a live workstation: `cleanmgr /sagerun` did its work in about ten seconds, - closed its window, and then stayed resident - no CPU, no I/O on any of the three - counters, all six threads waiting. Because the wait was written against process exit, - the run sat there for the remaining ~890 seconds and then set `DiskCleanupPending` and - warned that the figures were partial. They were final. Completion is now recognised by - total stillness as well as by exit: no CPU and no I/O across twelve consecutive checks - means the work is over. Not being able to measure activity resets that counter rather - than advancing it, so a machine where WMI is broken keeps the old, patient behaviour - instead of cutting cleanups short. +- **A Disk Cleanup that had stopped doing anything was reported as still deleting, and + cost the full timeout.** Measured on a live workstation: `cleanmgr /sagerun` did its + work in about ten seconds, closed its window, and then stayed resident - no CPU, no I/O + on any of the three counters, all six threads waiting. Because the wait was written + against process exit, the run sat there for the remaining ~890 seconds and then set + `DiskCleanupPending` and warned that the figures were partial, having observed nothing + happening for most of that time. The wait now also ends on total stillness: no CPU and + no I/O across twelve consecutive checks, after activity was seen at least once. That is + reported as what it is - an observation, not proof of completion - so the new status is + called `idle-resident` and the log says what was measured. Not being able to measure + activity resets the counter rather than advancing it, so a machine where WMI is broken + keeps the old, patient behaviour instead of cutting cleanups short. - **An unusable `-LogPath` killed the run before any maintenance happened.** The log header was written with two bare `Out-File` calls placed before the block that guarantees the result JSON and the summary. Six of seven bad paths make `Out-File` throw @@ -74,10 +76,12 @@ the installer accepted a PowerShell whose version it had failed to read. ### Added -- `DiskCleanupStatus` in the result JSON: `completed`, `completed-resident`, `timeout`, +- `DiskCleanupStatus` in the result JSON: `completed`, `idle-resident`, `timeout`, `storage-sense`, `failed`, `skipped-parameter` or `not-run`. `DiskCleanupPending` was a - boolean covering two different truths; `completed-resident` is the measured case where - the work is done and only the process outstayed it. + boolean covering two different truths. `idle-resident` is named after what is measured - + a process that was seen working and then went completely still - rather than after the + conclusion drawn from it, because that conclusion can be wrong and nothing downstream + is built to depend on it. - `SUPPORT.md`, a release-notes template in `docs/release-process.md`, and a section in `CONTRIBUTING.md` describing what gets accepted and on what criteria. diff --git a/WinClean.ps1 b/WinClean.ps1 index 6f2af2d..0059b0f 100644 --- a/WinClean.ps1 +++ b/WinClean.ps1 @@ -48,9 +48,9 @@ Version: 2.22 Requires: PowerShell 7.1+, Windows 11, Administrator rights Changes in 2.22: - - A Disk Cleanup that finished its work but never exited is no longer reported as - still running: completion is now detected by total stillness as well as by exit, - so a finished cleanup stops costing the full 15-minute timeout + - A Disk Cleanup that stops doing anything but never exits is no longer waited out + for the full 15-minute timeout, nor reported as still deleting: the wait now also + ends when the process has shown no CPU or I/O activity for two minutes - An unusable -LogPath can no longer kill the run before it starts - the log header is written through the same fault-tolerant path as every other log line - Every run now ends through one code path, so the result JSON, the summary and the @@ -388,12 +388,13 @@ function New-RunStats { # totals reported by this run are partial, and a consumer must not read them as final. DiskCleanupPending = $false # v2.22: how the Storage Sense / Disk Cleanup step actually ended. DiskCleanupPending - # alone could not tell "still deleting" from "finished but the process never exited", - # and reported both as pending - so a completed cleanup was published as partial. + # alone could not tell "still visibly deleting" from "resident but doing nothing", and + # reported both as pending - so a cleanup nothing had been observed to be doing was + # published as partial. # Same shape and reasoning as AppUpdatesStatus (v2.21): when a boolean starts covering # two different truths, the fix is a status, not a cleverer boolean. # 'not-run' | 'skipped-parameter' | 'storage-sense' | 'completed' | - # 'completed-resident' | 'timeout' | 'failed' + # 'idle-resident' | 'timeout' | 'failed' DiskCleanupStatus = 'not-run' # v2.17 (p.11 of the audit): which top-level phases ran to completion vs threw. # Before this, one exception anywhere in the run silently skipped every phase @@ -1748,15 +1749,19 @@ function Invoke-ScriptUpdate { Write-Host " ✓ Update complete!" -ForegroundColor Green Write-Host " Please run WinClean again to use the new version." -ForegroundColor Gray Write-Host "" - Write-Host " Press any key to exit..." -ForegroundColor DarkGray - - Wait-ForKeyPress # v2.22: this used to call exit here, which bypassed the finally in Start-WinClean and # therefore had to hand-copy the result JSON write and the exit-code rule alongside it. # The caller now owns ending the run, through the one path that does it (raised in # external review). The exit code is unchanged: the entry point already derives it from # ErrorsCount, which is what the copied lines here were re-deriving. + # + # The "press any key" pause deliberately does NOT happen here (raised in the second + # review pass): it blocks indefinitely, and in v2.21 the result JSON was already on + # disk before it. Pausing here would put an unbounded wait between the update and the + # artefacts, so a window closed or interrupted at that prompt would leave the run with + # no result JSON and an unreleased log handle - a regression introduced by this very + # refactor. The caller pauses after the run is complete. $script:Stats.Aborted = 'UpdatedAndExited' return $true } @@ -5356,6 +5361,100 @@ function Get-ProcessActivityFingerprint { } } +function Get-DiskCleanupActivityFingerprint { + <# + .SYNOPSIS + Activity fingerprint of the whole cleanmgr family, not just the process we started + .DESCRIPTION + v2.22, raised in review, then narrowed in the round after that. + + The concern is real: the handle we hold is not necessarily the only process doing + the work - cleanmgr was started with -WindowStyle Hidden and a Disk Cleanup window + appeared anyway. If a worker does the deleting while the process we started merely + waits, fingerprinting our PID alone would show a frozen parent and call a live + cleanup finished. + + The first attempt aggregated over every cleanmgr.exe on the machine, and that was + WORSE: a Disk Cleanup the user opens by hand is also called cleanmgr.exe, so an + unrelated process could both satisfy "activity was observed" for our run and, by + staying busy, keep our run waiting the full fifteen minutes after our own cleanup + had finished. It made the verdict depend on a process we know nothing about. + + So: our process and its descendants, and nothing else. Same protection against a + worker doing the deleting, no dependence on strangers. Children are matched by + parent PID regardless of name, since a worker need not be called cleanmgr.exe. + + Counters are summed as [long] rather than through Measure-Object, which returns a + Double and would silently lose integer precision past 2^53 (raised in review). + + $null when nothing can be read; the caller must treat that as "cannot tell". + + ACCEPTED LIMITS, stated rather than implied (raised in review). Process identity on + Windows is not exact: a process whose parent died and whose parent's PID was later + reused by our cleanmgr would be counted as family, and a worker started through a + service or COM rather than as a child would be missed. No process-tree scheme + avoids both. This is why the outcome is reported as observed idleness rather than + as proven completion, and why the registry sweep still refuses to touch a cleanmgr + that has not exited: every consumer of this signal is built to tolerate it being + wrong, which is the property that actually matters. + #> + param([int]$ProcessId) + + try { + $all = @(Get-CimInstance -ClassName Win32_Process ` + -Property ProcessId, ParentProcessId, KernelModeTime, UserModeTime, + ReadOperationCount, WriteOperationCount, OtherOperationCount ` + -ErrorAction Stop) + if (-not $all) { return $null } + + # Walk down from our PID. The seen-set both prevents rework and makes a cyclic + # parent chain (possible after PID reuse) terminate instead of spinning. + $byParent = @{} + foreach ($p in $all) { + $parent = [int]$p.ParentProcessId + if (-not $byParent.ContainsKey($parent)) { $byParent[$parent] = [System.Collections.Generic.List[object]]::new() } + $byParent[$parent].Add($p) + } + + $family = [System.Collections.Generic.List[object]]::new() + $seen = [System.Collections.Generic.HashSet[int]]::new() + $pending = [System.Collections.Generic.Queue[int]]::new() + $pending.Enqueue($ProcessId) + + while ($pending.Count -gt 0) { + $current = $pending.Dequeue() + if (-not $seen.Add($current)) { continue } + + $proc = $all | Where-Object { [int]$_.ProcessId -eq $current } | Select-Object -First 1 + if ($proc) { $family.Add($proc) } + + if ($byParent.ContainsKey($current)) { + foreach ($child in $byParent[$current]) { $pending.Enqueue([int]$child.ProcessId) } + } + } + + if ($family.Count -eq 0) { + # Our process is gone. The caller detects the exit on its own; "cannot tell" + # is the honest answer here, never "idle". + return $null + } + + [long]$kernel = 0; [long]$user = 0; [long]$read = 0; [long]$write = 0; [long]$other = 0 + foreach ($p in $family) { + $kernel += [long]$p.KernelModeTime + $user += [long]$p.UserModeTime + $read += [long]$p.ReadOperationCount + $write += [long]$p.WriteOperationCount + $other += [long]$p.OtherOperationCount + } + $pids = (($family | ForEach-Object { [int]$_.ProcessId } | Sort-Object) -join ',') + + return '{0}|{1}|{2}|{3}|{4}|{5}|{6}' -f $kernel, $user, $read, $write, $other, $family.Count, $pids + } catch { + return $null + } +} + function Update-IdleStreak { <# .SYNOPSIS @@ -5388,15 +5487,29 @@ function Wait-CleanmgrCompletion { without a process and without waiting fifteen minutes: the caller injects how to tell whether the process exited, how to read its activity, and how to wait. - Two completion signals, because HasExited alone was the wrong model of "done". + Two signals for "is there still something to wait for", because HasExited alone + was the wrong model of it. Note the distinction that matters throughout this + function: the MEASURED case below is described as what it was on that machine, + while what this code can PROVE in general is only the absence of observable + activity. The first is history, the second is the contract. Measured on a live workstation: cleanmgr /sagerun did its work in about ten seconds, closed its window, then stayed resident with CPU and all three I/O counters frozen and every thread in Wait. The run sat out the remaining ~890 seconds and then published the finished cleanup as partial. Returns Outcome ('exited' | 'idle-resident' | 'timeout') and Elapsed seconds. - 'idle-resident' means the work is over and nothing is pending; only the process - outstayed it. 'timeout' means it was still genuinely working when time ran out. + 'idle-resident' names what was OBSERVED - the process was seen working and then + did nothing at all for long enough - not the conclusion drawn from it. That the + work is therefore over is an inference, and a process blocked on something + external would look identical, so nothing is built to depend on it being right. + 'timeout' means it was still visibly working when time ran out. + + 'idle-resident' additionally requires that activity was OBSERVED at least once + (raised in review). Without that, "it has finished" and "it has not started yet" + are the same observation - a process that is suspended, or waiting on something + before it begins, would be declared complete after two minutes of a stillness that + never followed any work. Since the first fingerprint is taken the moment cleanmgr + starts, a run that does anything at all changes it within the first interval. #> param( [scriptblock]$HasExited, @@ -5410,6 +5523,7 @@ function Wait-CleanmgrCompletion { $elapsed = 0 $idleStreak = 0 + $sawActivity = $false $fingerprint = & $GetFingerprint while (-not (& $HasExited) -and $elapsed -lt $MaxWaitSeconds) { @@ -5420,7 +5534,12 @@ function Wait-CleanmgrCompletion { $fingerprint = & $GetFingerprint $idleStreak = Update-IdleStreak -Previous $previousFingerprint -Current $fingerprint -Streak $idleStreak - if ($idleStreak -ge $IdleChecksRequired) { + # A reset caused by two READABLE but different fingerprints is real work. A reset + # caused by an unreadable sample is not, and must not qualify the run for an early + # verdict later on. + if ($idleStreak -eq 0 -and $previousFingerprint -and $fingerprint) { $sawActivity = $true } + + if ($sawActivity -and $idleStreak -ge $IdleChecksRequired) { return @{ Outcome = 'idle-resident'; Elapsed = $elapsed } } @@ -5938,19 +6057,20 @@ function Invoke-StorageSense { # with a large component store, and killing it produced a warning on every # single run while cleanmgr kept working in the background anyway. # - # v2.22: waiting on HasExited alone was the wrong model of "the work is done". - # Measured on a live workstation: cleanmgr /sagerun finished in about ten - # seconds, closed its window and then simply stayed resident - CPU, all three - # I/O counters and its six threads frozen, every thread in Wait. The run then - # sat here for the remaining ~890 seconds and finally declared the FINISHED - # cleanup partial. Both halves of that are wrong, and the second is the worse - # one: it is the same class of dishonest report v2.20 and v2.21 were spent - # removing, only inverted - not success that never happened, but incompleteness - # that never happened. + # v2.22: waiting on HasExited alone was the wrong model of "there is still + # something to wait for". Measured on a live workstation: cleanmgr /sagerun did + # its work in about ten seconds, closed its window and then simply stayed + # resident - CPU, all three I/O counters and its six threads frozen, every + # thread in Wait. The run sat here for the remaining ~890 seconds and then + # declared the cleanup partial. Both halves of that are wrong, and the second + # is the worse one: it is the same class of dishonest report v2.20 and v2.21 + # were spent removing, only inverted - not success that never happened, but + # incompleteness that was never observed either. # - # So a second, independent completion signal: total stillness. If the process - # has done no CPU work and no I/O at all across $idleChecksRequired consecutive - # checks, its work is over whether or not it bothered to exit. + # So a second, independent signal to stop waiting: total stillness. If the + # process has done no CPU work and no I/O at all across $idleChecksRequired + # consecutive checks, there is nothing left to wait for that we can observe. + # That is weaker than "it has finished", and is reported as such. $maxWait = 900 # 15 minutes $checkInterval = 10 # Two full minutes of absolute stillness. Deliberately far longer than needed @@ -5964,7 +6084,7 @@ function Invoke-StorageSense { $waitOutcome = Wait-CleanmgrCompletion ` -HasExited { $cleanmgr.HasExited } ` - -GetFingerprint { Get-ProcessActivityFingerprint -ProcessId $cleanmgr.Id } ` + -GetFingerprint { Get-DiskCleanupActivityFingerprint -ProcessId $cleanmgr.Id } ` -MaxWaitSeconds $maxWait -CheckInterval $checkInterval -IdleChecksRequired $idleChecksRequired ` -OnProgress { param($seconds) Write-Log "Disk Cleanup still running... ($seconds seconds)" -Level INFO } @@ -5981,12 +6101,22 @@ function Invoke-StorageSense { Write-Log "Disk Cleanup completed ($armed categories)" -Level SUCCESS $script:Stats.DiskCleanupStatus = 'completed' } elseif ($finishedWhileResident) { - # The work is done; only the process is still here. Not a warning: nothing - # went wrong and nothing is pending, so DiskCleanupPending stays false and - # the figures below are final. - Write-Log "Disk Cleanup completed ($armed categories) - finished after $elapsed seconds, then stayed resident without doing anything further" -Level SUCCESS - Write-Log "cleanmgr.exe is still in the process list but has been completely idle for $($idleChecksRequired * $checkInterval) seconds - not waiting out the remaining $($maxWait - $elapsed) seconds" -Level DETAIL - $script:Stats.DiskCleanupStatus = 'completed-resident' + # Deliberately worded as an OBSERVATION, not a conclusion (raised in the + # third review pass). What was measured is that cleanmgr and its children + # did nothing at all for two minutes after having been seen working; that + # they FINISHED is the inference, and the earlier wording ("completed") + # stated the inference as fact. This project has spent three releases + # removing exactly that habit, so the status is 'idle-resident' and the log + # says what was seen. + # + # DiskCleanupPending stays false, which is the best available estimate: a + # process performing no CPU work and no I/O is not deleting anything. It is + # an estimate rather than a certainty, and that is why it is not called + # completion. Not a warning either - on the machine where this was measured + # it is the normal ending, and warning on every run would be noise. + Write-Log "Disk Cleanup ($armed categories): worked, then stopped doing anything for $($idleChecksRequired * $checkInterval) seconds" -Level SUCCESS + Write-Log "cleanmgr.exe is still in the process list but shows no CPU or I/O activity - treating its work as over and not waiting out the remaining $($maxWait - $elapsed) seconds" -Level DETAIL + $script:Stats.DiskCleanupStatus = 'idle-resident' } else { # Genuinely still working when the timeout expired. Killing it would be # worse - cleanmgr keeps working after a kill and the deletion is @@ -6322,9 +6452,9 @@ function Write-ResultJson { # TotalFreedBytes is then a lower bound, not the final figure. DiskCleanupPending = [bool]$script:Stats.DiskCleanupPending # v2.22: how that step ended, because the boolean above conflated two states. - # 'completed-resident' is the measured case where cleanmgr does its work, closes - # its window and then never exits: finished, nothing pending, figures final. - # 'timeout' is the genuine overrun the boolean was added for. + # 'idle-resident' is the measured case: cleanmgr was seen working, then went + # completely still without exiting. 'timeout' is the genuine overrun the + # boolean was added for - still visibly working when time ran out. DiskCleanupStatus = [string]$script:Stats.DiskCleanupStatus # 'enabled' means cleanup figures are understated (Defender blocked some # deletions without reporting an error); 'unknown' means the check itself @@ -6562,7 +6692,13 @@ function Start-WinClean { # so continuing would perform maintenance with the old code still loaded - # the run ends here, but it ends the same way every other run does. if (Invoke-ScriptUpdate -UpdateInfo $updateInfo) { + # Artefacts first, then the pause. The prompt blocks until a key is + # pressed and a double-clicked shortcut may simply be closed there; + # writing the JSON afterwards would mean an abandoned window produced + # no result file at all (raised in the second review pass). Complete-WinCleanRun -ResultPath $ResultJsonPath + Write-Host " Press any key to exit..." -ForegroundColor DarkGray + Wait-ForKeyPress return } } diff --git a/docs/result-json.md b/docs/result-json.md index 62b6a82..41b8ebf 100644 --- a/docs/result-json.md +++ b/docs/result-json.md @@ -28,8 +28,8 @@ This page documents every field, gives a full sample, and explains how to consum | `ErrorsCount` | number | Count of errors raised during the run. A healthy run reports `0`. | | `RebootRequired` | bool | `true` when a change (a Windows update, an app update finishing on reboot) needs a restart to take effect. | | `LoggingDegraded` | bool | v2.20. `true` when writing the log file failed at some point during the run. The run itself still completed, but `LogPath` points at an incomplete file: do not read that log as the full record of what happened. | -| `DiskCleanupPending` | bool | v2.20. `true` when Disk Cleanup outlived its timeout and was left running in the background. `TotalFreedBytes` is then a lower bound rather than the final figure, because deletion continued after this file was written. | -| `DiskCleanupStatus` | string | v2.22. How the Storage Sense / Disk Cleanup step actually ended: `completed`, `completed-resident`, `timeout`, `storage-sense`, `failed`, `skipped-parameter`, or `not-run`. See below - the boolean above conflated a finished cleanup with a still-running one. | +| `DiskCleanupPending` | bool | v2.20. `true` when Disk Cleanup was still **visibly working** when its timeout expired and was left running in the background; `TotalFreedBytes` is then a lower bound. Note `false` is not a proof that nothing more will ever be deleted - see `DiskCleanupStatus`, which is the field to read when that distinction matters. | +| `DiskCleanupStatus` | string | v2.22. How the Storage Sense / Disk Cleanup step actually ended: `completed`, `idle-resident`, `timeout`, `storage-sense`, `failed`, `skipped-parameter`, or `not-run`. See below - the boolean above conflated a finished cleanup with a still-running one. | | `ControlledFolderAccess` | string | Tri-state, see below. Reflects whether Defender's Controlled Folder Access may have silently blocked deletions. | | `Aborted` | string or null | `null` unless the run stopped early for a known reason: `"PendingRebootDeclined"` (the user declined to continue with a reboot pending) or `"UpdatedAndExited"` (v2.21 - the script updated itself and exited so the new version runs next time). When set, the phase arrays below are incomplete by design. Note `null` does not by itself prove every phase ran - see the invariant note below. | | `PhasesCompleted` | array of string | Phases whose action ran to completion without an uncaught exception. | @@ -84,27 +84,34 @@ Treat any value other than `checked` as "the count means nothing", not as "there `DiskCleanupPending` alone could not tell two different situations apart, and reported both as pending. Measured on a live workstation: `cleanmgr /sagerun` did its work in about ten seconds, closed its window, and then stayed in the process list doing nothing at all - -no CPU, no I/O, every thread waiting. Waiting on process exit as the definition of "the -work is done" therefore burned the remaining fifteen-minute timeout and then published a -**finished** cleanup as partial. +no CPU, no I/O, every thread waiting. Treating process exit as the only sign that there is +still something to wait for therefore burned the remaining fifteen-minute timeout, and +then published as partial a cleanup that had not been observed doing anything for most of +that time. -Since v2.22 a second, independent completion signal is used: total stillness. If the -process performs no CPU work and no I/O across twelve consecutive ten-second checks, its -work is over whether or not it exited. This field records which of those endings happened. +Since v2.22 a second, independent signal is used to stop waiting: total stillness. If the +process was seen working and then performs no CPU work and no I/O across twelve +consecutive ten-second checks, there is nothing observable left to wait for. That is +deliberately weaker than "it has finished", and the field names the observation rather +than the conclusion. | Value | Meaning | |-------|---------| | `completed` | cleanmgr ran and exited with code 0. | -| `completed-resident` | cleanmgr finished its work and went completely idle, but never exited. The cleanup is **done**; the figures are final and `DiskCleanupPending` stays `false`. | +| `idle-resident` | cleanmgr was seen working, then did nothing at all for two minutes and never exited. Reported as what was **observed**, not as proven completion: a process performing no CPU work and no I/O is not deleting anything, so the figures are treated as final and `DiskCleanupPending` stays `false`. | | `timeout` | cleanmgr was still genuinely working when the timeout expired. `DiskCleanupPending` is `true` and `TotalFreedBytes` is a lower bound. | | `storage-sense` | Storage Sense demonstrably did the work, so cleanmgr was not run. Note this covers a different, smaller set of things than cleanmgr's handlers. | | `failed` | cleanmgr could not be started, no handlers could be armed, or it exited non-zero. Nothing was verifiably cleaned by this step. | | `skipped-parameter` | `-SkipDiskCleanup` was passed. | | `not-run` | The step never executed (for example the run aborted earlier). | -Note that `completed-resident` is a success. It is reported separately only because a -`cleanmgr.exe` still visible in Task Manager after the run finishes is surprising, and the -log says so explicitly rather than leaving it to be discovered. +`idle-resident` is not an error, and it is named after the observation rather than after +the conclusion on purpose. What is measured is stillness; "it finished" is an inference +from it, and the inference can be wrong - a cleanmgr blocked on something external would +look identical. Nothing downstream is built to depend on it being right: the registry +configuration of a process that has not exited is still left alone, and the log states +what was actually seen. A `cleanmgr.exe` still visible in Task Manager after the run is +the expected consequence, which is why it is reported rather than left to be discovered. ### `ControlledFolderAccess` (tri-state string) diff --git a/tests/Fixes.Tests.ps1 b/tests/Fixes.Tests.ps1 index e3fb9f7..a5ae588 100644 --- a/tests/Fixes.Tests.ps1 +++ b/tests/Fixes.Tests.ps1 @@ -1235,14 +1235,14 @@ Describe "v2.20: an operation that did nothing does not report success" -Tag "Fi $script:senseCode = ($script:senseBody -split "`n" | Where-Object { $_ -notmatch '^\s*#' }) -join "`n" } - It "A cleanup that finished while resident is not reported as pending" { - # DiskCleanupPending means "an elevated process is STILL deleting, the figures - # below are partial". The measured case is the opposite: the work is over. + It "A cleanup that went idle while resident is not reported as still deleting" { + # DiskCleanupPending means "an elevated process was still VISIBLY deleting, the figures + # below are partial". The measured case is the opposite: nothing was happening. $residentBranch = [regex]::Match($script:senseCode, '(?s)\}\s*elseif\s*\(\$finishedWhileResident\)\s*\{(.*?)\}\s*else\s*\{').Groups[1].Value $residentBranch | Should -Not -BeNullOrEmpty -Because 'the branch must exist to be checked' $residentBranch | Should -Not -Match 'DiskCleanupPending' - $residentBranch | Should -Match "DiskCleanupStatus = 'completed-resident'" + $residentBranch | Should -Match "DiskCleanupStatus = 'idle-resident'" } It "The genuine overrun still sets DiskCleanupPending" { @@ -1288,6 +1288,35 @@ Describe "v2.20: an operation that did nothing does not report success" -Tag "Fi $body | Should -Match 'return \$true' } + It "The self-update pause happens after the artefacts, never before them" { + # Raised in the second review pass. Wait-ForKeyPress blocks indefinitely; in + # v2.21 the result JSON was written before it. Moving the pause into + # Invoke-ScriptUpdate put an unbounded wait between the update and the + # artefacts, so an abandoned window produced no result file at all. + # Scoped to the self-update branch itself (raised in review): comparing the + # FIRST Complete-WinCleanRun in the whole function found the pending-reboot + # call instead, so the ordering held even if the self-update branch lost its + # completion call entirely. + $body = Get-FunctionBody -Name 'Start-WinClean' + $branch = [regex]::Match($body, + '(?s)if \(Invoke-ScriptUpdate -UpdateInfo \$updateInfo\) \{(.*?)\n\s*\}').Groups[1].Value + $branch | Should -Not -BeNullOrEmpty -Because 'the branch must exist to be checked' + + $completeAt = $branch.IndexOf('Complete-WinCleanRun') + $pauseAt = $branch.IndexOf('Wait-ForKeyPress') + $completeAt | Should -BeGreaterOrEqual 0 -Because 'the self-update branch must complete the run itself' + $pauseAt | Should -BeGreaterThan $completeAt + + # Exactly one prompt on this path, so the pause is neither lost nor doubled + ([regex]::Matches($branch, 'Wait-ForKeyPress')).Count | Should -Be 1 + ([regex]::Matches($branch, 'Press any key to exit')).Count | Should -Be 1 + + # And it must not have crept back into the updater + $update = Get-FunctionBody -Name 'Invoke-ScriptUpdate' + $tail = $update.Substring($update.IndexOf('Update complete')) + $tail | Should -Not -Match 'Wait-ForKeyPress' + } + It "Start-WinClean acts on the answer instead of ignoring it" { # A caller that dropped the `if` would carry on running maintenance with the # script file already replaced underneath it. diff --git a/tests/Helpers.Tests.ps1 b/tests/Helpers.Tests.ps1 index 3171810..81ede59 100644 --- a/tests/Helpers.Tests.ps1 +++ b/tests/Helpers.Tests.ps1 @@ -2225,13 +2225,14 @@ Describe "Invoke-ScriptUpdate branches" -Tag "Unit", "Helper", "V221" { $script:askedPath | Should -Be $script:WinCleanPath } - It "pauses before handing the run back, so the user reads the outcome" { - # v2.22 rewrote this test. It used to assert that the function wrote the result - # JSON itself, which was only ever true because its own `exit` bypassed the - # finally that should have done it. That workaround is gone: the artefact is now - # the caller's job (covered in the V222 region), and what is worth pinning here is - # the interactive behaviour that survived - the pause. Without it the window of a - # double-clicked shortcut closes on "Update complete" before it can be read. + It "does not block on a keypress before the run's artefacts exist" { + # Rewritten twice. It first asserted that this function wrote the result JSON, + # which was only true because its own `exit` bypassed the finally that should + # have. Then it asserted the pause happened here - and the second review pass + # showed that was the regression: the pause blocks indefinitely, so in v2.21 the + # JSON was already on disk before it, while pausing here puts an unbounded wait + # BEFORE the artefacts. A window closed at that prompt would leave no result file. + # The pause now belongs to the caller, after Complete-WinCleanRun. Mock Test-InteractiveConsole { $true } Mock Read-Host { 'y' } Mock Update-Script { } @@ -2240,7 +2241,7 @@ Describe "Invoke-ScriptUpdate branches" -Tag "Unit", "Helper", "V221" { $null = Invoke-ScriptUpdate -UpdateInfo @{ CurrentVersion = '2.20'; LatestVersion = '2.21' Channel = 'gallery'; Provider = 'PowerShellGet' } - Should -Invoke Wait-ForKeyPress -Times 1 + Should -Invoke Wait-ForKeyPress -Exactly -Times 0 -Because 'nothing may block between the update and the result JSON' $script:Stats.Aborted | Should -Be 'UpdatedAndExited' ($script:printed -join "`n").Contains('Update complete') | Should -BeTrue } @@ -2458,20 +2459,27 @@ Describe "Get-BoxWidth" -Tag "Unit", "Helper", "V222" { Get-BoxWidth -ConsoleWidth $Width | Should -Be 70 } - It "never returns a box that cannot fit the console - columns" -ForEach @( + It "fits the console whenever a 70-column box can fit at all - columns" -ForEach @( @{ Width = 80 }, @{ Width = 100 }, @{ Width = 120 }, @{ Width = 200 }, @{ Width = 3824 } ) { # A box costs 2 indent + 1 border + inner + 1 border. Anything wider than the # console wraps, and a wrapped frame is worse than a narrow one. + # Scoped honestly (raised in review): this property holds for consoles of 74 + # columns and up. Below that no box fits, and the case is covered separately. $inner = Get-BoxWidth -ConsoleWidth $Width ($inner + 4) | Should -BeLessOrEqual $Width } - It "does not shrink below 70 on a narrow console" { - # 60 columns cannot fit a 70-wide box, but every previous version had the same - # problem there. Shrinking would change the look for everyone to fix nobody. - Get-BoxWidth -ConsoleWidth 60 | Should -Be 70 - Get-BoxWidth -ConsoleWidth 40 | Should -Be 70 + It "keeps the historical 70 on a console too narrow for any box - columns" -ForEach @( + @{ Width = 60 }, @{ Width = 40 } + ) { + # Stated as the accepted limitation it is, not hidden. Below 74 columns a framed + # box cannot fit, and it never could - every previous version drew 70 there too. + # Narrowing the frame would change how WinClean looks for everyone in order to + # improve a console nobody has reported using, so the old behaviour is kept and + # written down instead of quietly implied by a bound. + Get-BoxWidth -ConsoleWidth $Width | Should -Be 70 + (Get-BoxWidth -ConsoleWidth $Width) + 4 | Should -BeGreaterThan $Width -Because 'this is the known, accepted limit - the assertion records it rather than pretending it does not exist' } It "does not grow past 90 however wide the console is" { @@ -2717,6 +2725,56 @@ Describe "Get-ProcessActivityFingerprint" -Tag "Unit", "Helper", "V222" { Get-ProcessActivityFingerprint -ProcessId 999999 | Should -BeNullOrEmpty } + It "the Disk Cleanup fingerprint covers our own process tree, not every cleanmgr" { + # Raised in review, after the first attempt aggregated by process NAME. A Disk + # Cleanup the user opens by hand is also cleanmgr.exe, and including it made the + # verdict depend on a stranger: its activity could satisfy "work was observed" for + # our run, and its busyness could keep us waiting the full fifteen minutes after + # our own cleanup had finished. Descendants of our PID, and nothing else. + Mock Get-CimInstance { + @( + [pscustomobject]@{ ProcessId = 100; ParentProcessId = 1; KernelModeTime = 10; UserModeTime = 1; ReadOperationCount = 1; WriteOperationCount = 1; OtherOperationCount = 1 } + [pscustomobject]@{ ProcessId = 200; ParentProcessId = 100; KernelModeTime = 20; UserModeTime = 2; ReadOperationCount = 2; WriteOperationCount = 2; OtherOperationCount = 2 } + # An unrelated cleanmgr the user started: must not appear in the fingerprint + [pscustomobject]@{ ProcessId = 900; ParentProcessId = 1; KernelModeTime = 99999; UserModeTime = 99999; ReadOperationCount = 99999; WriteOperationCount = 99999; OtherOperationCount = 99999 } + ) + } + + $fp = Get-DiskCleanupActivityFingerprint -ProcessId 100 + + # our process + its child: kernel 10+20, and the PID list names exactly those two + $fp | Should -Be '30|3|3|3|3|2|100,200' + $fp | Should -Not -Match '900' -Because 'an unrelated cleanmgr must not influence our verdict' + } + + It "the Disk Cleanup fingerprint is null when our process is gone" { + Mock Get-CimInstance { @([pscustomobject]@{ ProcessId = 900; ParentProcessId = 1; KernelModeTime = 1; UserModeTime = 1; ReadOperationCount = 1; WriteOperationCount = 1; OtherOperationCount = 1 }) } + Get-DiskCleanupActivityFingerprint -ProcessId 100 | Should -BeNullOrEmpty + } + + It "the Disk Cleanup fingerprint terminates on a cyclic parent chain" { + # PID reuse can produce a cycle; the walk must end rather than spin forever inside + # a wait that is already holding up the run. + Mock Get-CimInstance { + @( + [pscustomobject]@{ ProcessId = 100; ParentProcessId = 200; KernelModeTime = 1; UserModeTime = 0; ReadOperationCount = 0; WriteOperationCount = 0; OtherOperationCount = 0 } + [pscustomobject]@{ ProcessId = 200; ParentProcessId = 100; KernelModeTime = 1; UserModeTime = 0; ReadOperationCount = 0; WriteOperationCount = 0; OtherOperationCount = 0 } + ) + } + $fp = Get-DiskCleanupActivityFingerprint -ProcessId 100 + $fp | Should -Be '2|0|0|0|0|2|100,200' + } + + It "sums counters as integers, without the precision loss of a Double" { + # Measure-Object -Sum returns a Double and silently loses integer precision past + # 2^53, so a small counter change could vanish at large totals (raised in review). + $big = 9007199254740993 # 2^53 + 1, not representable as a Double + Mock Get-CimInstance { + @([pscustomobject]@{ ProcessId = 100; ParentProcessId = 1; KernelModeTime = $big; UserModeTime = 0; ReadOperationCount = 0; WriteOperationCount = 0; OtherOperationCount = 0 }) + } + (Get-DiskCleanupActivityFingerprint -ProcessId 100).Split('|')[0] | Should -Be "$big" + } + It "returns null - never a constant - when the counters cannot be read at all" { # Added after a mutation survived. The test above exercises the "no such process" # branch, where Get-CimInstance returns nothing; it never reached the catch, which @@ -2732,6 +2790,11 @@ Describe "Get-ProcessActivityFingerprint" -Tag "Unit", "Helper", "V222" { $first | Should -BeNullOrEmpty Update-IdleStreak -Previous $first -Current $second -Streak 11 | Should -Be 0 -Because 'two unreadable samples must not look like two identical ones' + + # The function actually used by the Disk Cleanup wait must obey the same rule. + # Raised in review: this guard used to exercise only the single-process helper, + # so a fixed value returned from the family function's catch would have survived it. + Get-DiskCleanupActivityFingerprint -ProcessId $PID | Should -BeNullOrEmpty } } @@ -2758,16 +2821,21 @@ Describe "Wait-CleanmgrCompletion" -Tag "Unit", "Helper", "V222" { $r.Elapsed | Should -BeLessThan 900 } - It "declares 'idle-resident' once the process has been completely still for the threshold" { - # The measured case: never exits, never does anything again. + It "stops waiting once the process has been completely still for the threshold" { + # The measured case: works briefly, then never exits and never moves again. The verdict + # is 'nothing left to observe', not 'proven finished'. One interval of real work + # first, because stillness only counts after something was seen happening. + $script:n = 0 $r = Wait-CleanmgrCompletion ` -HasExited { $false } ` - -GetFingerprint { 'frozen' } ` + -GetFingerprint { $script:n++; if ($script:n -le 2) { "working-$($script:n)" } else { 'frozen' } } ` -CheckInterval 10 -IdleChecksRequired 12 ` -Wait $script:noWait -OnProgress $script:onProgress $r.Outcome | Should -Be 'idle-resident' - $r.Elapsed | Should -Be 120 -Because '12 checks of 10 seconds, and not one second longer' + # 20s of observed movement (working-1 -> working-2 -> frozen, two changes), then + # 12 identical checks of 10s each. + $r.Elapsed | Should -Be 140 -Because 'the streak only starts once the fingerprint stops changing' } It "waits the full timeout when the process keeps working" { @@ -2825,6 +2893,55 @@ Describe "Wait-CleanmgrCompletion" -Tag "Unit", "Helper", "V222" { $script:progressAt | Should -Be @(60, 120, 180, 240, 300) } + It "does not call a process finished if it was never seen doing anything" { + # Raised in review: without this, "it has finished" and "it has not started yet" + # are the same observation. A cleanmgr that is suspended, or waiting on something + # before it begins, would otherwise be declared complete after two minutes of a + # stillness that never followed any work at all. + $r = Wait-CleanmgrCompletion ` + -HasExited { $false } ` + -GetFingerprint { 'frozen-from-the-start' } ` + -MaxWaitSeconds 900 -CheckInterval 10 -IdleChecksRequired 12 ` + -Wait $script:noWait -OnProgress $script:onProgress + + $r.Outcome | Should -Be 'timeout' -Because 'stillness is only taken as "nothing left to wait for" if work was observed first' + $r.Elapsed | Should -Be 900 + } + + It "does not count an unreadable sample as the activity that qualifies an early verdict" { + # A reset caused by "cannot tell" must not stand in for observed work: otherwise + # a machine with flaky WMI could satisfy the sawActivity condition without any + # process ever having done anything. + $script:n = 0 + $r = Wait-CleanmgrCompletion ` + -HasExited { $false } ` + -GetFingerprint { + $script:n++ + # one unreadable blip, then frozen for the rest of the run + if ($script:n -eq 2) { $null } else { 'frozen' } + } ` + -MaxWaitSeconds 900 -CheckInterval 10 -IdleChecksRequired 12 ` + -Wait $script:noWait -OnProgress $script:onProgress + + $r.Outcome | Should -Be 'timeout' + } + + It "still ends early when real work was observed and then stopped" { + # The measured case, now with the stricter rule in place: it must still work. + $script:n = 0 + $r = Wait-CleanmgrCompletion ` + -HasExited { $false } ` + -GetFingerprint { + $script:n++ + if ($script:n -le 3) { "working-$($script:n)" } else { 'done-and-frozen' } + } ` + -MaxWaitSeconds 900 -CheckInterval 10 -IdleChecksRequired 12 ` + -Wait $script:noWait -OnProgress $script:onProgress + + $r.Outcome | Should -Be 'idle-resident' + $r.Elapsed | Should -BeLessThan 900 + } + It "prefers 'exited' over 'timeout' when the process leaves during the final interval" { # Same instant, two possible labels; the accurate one is that it exited. $script:n = 0 From 90613e1024c8c84de87af7101618646f5d3af1da Mon Sep 17 00:00:00 2001 From: Ivan Bondarev Date: Thu, 23 Jul 2026 12:55:16 +0300 Subject: [PATCH 06/11] =?UTF-8?q?docs:=20=D1=81=D1=87=D1=91=D1=82=D1=87?= =?UTF-8?q?=D0=B8=D0=BA=D0=B8=20=D1=82=D0=B5=D1=81=D1=82=D0=BE=D0=B2=20677?= =?UTF-8?q?=20=D0=B8=20=D1=81=D1=82=D0=B0=D1=82=D1=83=D1=81=20=D0=B2=D0=B5?= =?UTF-8?q?=D1=80=D1=81=D0=B8=D0=B8=20=D0=B2=20CLAUDE.md?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Счётчик вырос за раунды ревью (668 -> 677). Считается прогоном Pester, не грепом: блоки -ForEach размножаются, наивный подсчёт занижает. --- CLAUDE.md | 14 +++++++------- CONTRIBUTING.md | 4 ++-- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index af4a6bd..ec628f9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,7 +1,7 @@ # WinClean - Инструкции для Claude > Последнее обновление: 2026-07-23 -> Текущая версия скрипта: 2.21 (ВЫПУЩЕНА 23.07.2026: GitHub Release с обоими ассетами + PSGallery 2.21 + e2e через опубликованный one-liner) +> Текущая версия скрипта: 2.22 (В РАБОТЕ, ветка `feature/v2.22`; последняя ВЫПУЩЕННАЯ - 2.21 от 23.07.2026) --- @@ -43,9 +43,9 @@ CleanScript/ │ └── logo.svg # Логотип проекта ├── get.ps1 # Bootstrap: разовый запуск одной командой (irm | iex) ├── install.ps1 # Bootstrap: установка/обновление + ярлык (RunAs admin) -├── tests/ # Pester тесты (668 всего; счётчик - прогоном, не грепом) -│ ├── Helpers.Tests.ps1 # Unit-тесты helper-функций (355, дот-сорсят продукт - нужны права админа) -│ ├── Fixes.Tests.ps1 # Валидационные тесты исправлений (197) +├── tests/ # Pester тесты (677 всего; счётчик - прогоном, не грепом) +│ ├── Helpers.Tests.ps1 # Unit-тесты helper-функций (363, дот-сорсят продукт - нужны права админа) +│ ├── Fixes.Tests.ps1 # Валидационные тесты исправлений (198) │ ├── Integration.Tests.ps1 # Интеграционные тесты в песочнице ФС (67, требуют admin) │ ├── StandHelpers.Tests.ps1 # Чистые хелперы стенда: dead-man + версионный гейт + таблица истинности report-гварда (23, без admin) │ └── Docs.Tests.ps1 # Гварды документации: нет тире + нет битых внутренних ссылок (26, без admin) @@ -198,7 +198,7 @@ Publish-PSResource -Path .\WinClean.ps1 -Repository PSGallery -ApiKey $env:PSGAL **Проверки (4 job'а, счёт сверять gh run view --json jobs, а не этой строкой):** 1. **lint** - PSScriptAnalyzer (Error+Warning) через общий ools/Invoke-Lint.ps1 - тот же, что зовёт релиз-гейт 2. **syntax** - Проверка синтаксиса PowerShell -3. **test** - Pester тесты (668; интеграционные требуют admin - на GitHub runners это выполняется) +3. **test** - Pester тесты (677; интеграционные требуют admin - на GitHub runners это выполняется) 4. **smoke** - прогон -ReportOnly + геометрия рамок + result JSON **Исключения PSScriptAnalyzer** (допустимые для CLI): @@ -208,7 +208,7 @@ Publish-PSResource -Path .\WinClean.ps1 -Repository PSGallery -ApiKey $env:PSGAL ### Pester тесты (v2.13+) -- `tests/Helpers.Tests.ps1` - 355 unit-тестов (дот-сорсят WinClean.ps1), `tests/Fixes.Tests.ps1` - 197, `tests/Integration.Tests.ps1` - 67 (песочница ФС, требуют admin), `tests/StandHelpers.Tests.ps1` - 23 (без admin), `tests/Docs.Tests.ps1` - 26 (гварды доков, без admin) +- `tests/Helpers.Tests.ps1` - 363 unit-тестов (дот-сорсят WinClean.ps1), `tests/Fixes.Tests.ps1` - 198, `tests/Integration.Tests.ps1` - 67 (песочница ФС, требуют admin), `tests/StandHelpers.Tests.ps1` - 23 (без admin), `tests/Docs.Tests.ps1` - 26 (гварды доков, без admin) - Особенности: функции в BeforeAll (не AST), regex для locale-независимости, отдельные It блоки --- @@ -434,7 +434,7 @@ pwsh tools/Invoke-ReleaseCheck.ps1 # версия во всех pwsh tools/Invoke-ReleaseCheck.ps1 -IncludeStand # + боевой прогон на VM (минуты) pwsh tools/Invoke-ReleaseCheck.ps1 -VerifyPublished # ПОСЛЕ выпуска: ассеты релиза и SHA256 -Invoke-Pester ./tests -Output Detailed # 668 Pester тестов (считать прогоном, не грепом) +Invoke-Pester ./tests -Output Detailed # 677 Pester тестов (считать прогоном, не грепом) pwsh tools/Invoke-SmokeTest.ps1 # Смоук: ReportOnly + геометрия UI pwsh tools/proxmox/Invoke-StandTest.ps1 -Mode Report # Стенд на Proxmox (RU=VM 190, EN: -ConfigPath ...en.json = VM 191) # Ночная матрица: cron 03:30 на proxmos (/opt/winclean-stand, /etc/cron.d/winclean-stand), отчёт в Telegram diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f3c8d47..fc45c69 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -128,7 +128,7 @@ Invoke-Pester ./tests/Integration.Tests.ps1 All PRs automatically run: - PSScriptAnalyzer (linting) - Syntax check -- Pester tests (668 tests) +- Pester tests (677 tests) ### Release-impacting changes @@ -284,7 +284,7 @@ Invoke-Pester ./tests/Integration.Tests.ps1 Все PR автоматически проходят: - PSScriptAnalyzer (линтинг) - Проверка синтаксиса -- Pester тесты (668 тестов) +- Pester тесты (677 тестов) ### Изменения, влияющие на релиз From eeba1992266ea2d60ea3fb97a55cd937c5fb9e7a Mon Sep 17 00:00:00 2001 From: Ivan Bondarev Date: Thu, 23 Jul 2026 12:57:06 +0300 Subject: [PATCH 07/11] =?UTF-8?q?test:=20=D0=B3=D0=B2=D0=B0=D1=80=D0=B4?= =?UTF-8?q?=D1=8B=20=D0=B4=D0=BE=D0=BA=D1=83=D0=BC=D0=B5=D0=BD=D1=82=D0=B0?= =?UTF-8?q?=D1=86=D0=B8=D0=B8=20=D0=BF=D0=B5=D1=80=D0=B5=D1=87=D0=B8=D1=81?= =?UTF-8?q?=D0=BB=D1=8F=D1=8E=D1=82=20=D1=84=D0=B0=D0=B9=D0=BB=D1=8B,=20?= =?UTF-8?q?=D0=B0=20=D0=BD=D0=B5=20=D0=B1=D0=B5=D1=80=D1=83=D1=82=20=D0=B8?= =?UTF-8?q?=D1=85=20=D0=B8=D0=B7=20=D1=81=D0=BF=D0=B8=D1=81=D0=BA=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SUPPORT.md, заведённый в этом же релизе, молча оказался ВНЕ гвардов: новый пользовательский документ со ссылками был единственным, который никто не проверял ни на тире, ни на битые ссылки. Список, написанный руками, покрывает только то, что кто-то вспомнил, и следующий файл забылся бы так же. CLAUDE.md исключён осознанно: это runbook для мейнтейнера и агента, а не пользовательская документация и не часть публичного контракта. Тесты 677 -> 679. --- CLAUDE.md | 10 +- CONTRIBUTING.md | 4 +- WinClean.ps1 | 2 +- WinClean.ps1.mutbak | 6840 ++++++++++++++++++++++++++++++++++++++++++ tests/Docs.Tests.ps1 | 10 +- 5 files changed, 6857 insertions(+), 9 deletions(-) create mode 100644 WinClean.ps1.mutbak diff --git a/CLAUDE.md b/CLAUDE.md index ec628f9..4671cca 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -43,12 +43,12 @@ CleanScript/ │ └── logo.svg # Логотип проекта ├── get.ps1 # Bootstrap: разовый запуск одной командой (irm | iex) ├── install.ps1 # Bootstrap: установка/обновление + ярлык (RunAs admin) -├── tests/ # Pester тесты (677 всего; счётчик - прогоном, не грепом) +├── tests/ # Pester тесты (679 всего; счётчик - прогоном, не грепом) │ ├── Helpers.Tests.ps1 # Unit-тесты helper-функций (363, дот-сорсят продукт - нужны права админа) │ ├── Fixes.Tests.ps1 # Валидационные тесты исправлений (198) │ ├── Integration.Tests.ps1 # Интеграционные тесты в песочнице ФС (67, требуют admin) │ ├── StandHelpers.Tests.ps1 # Чистые хелперы стенда: dead-man + версионный гейт + таблица истинности report-гварда (23, без admin) -│ └── Docs.Tests.ps1 # Гварды документации: нет тире + нет битых внутренних ссылок (26, без admin) +│ └── Docs.Tests.ps1 # Гварды документации: нет тире + нет битых ссылок (28, без admin; файлы ПЕРЕЧИСЛЯЮТСЯ, а не задаются списком) ├── tools/ # Тестовая инфраструктура (не публикуется в PSGallery) │ ├── Invoke-ReleaseCheck.ps1 # 🔴 Единая проверка перед релизом (fail-closed) │ ├── Invoke-SmokeTest.ps1 # Смоук: ReportOnly + JSON + геометрия рамок @@ -198,7 +198,7 @@ Publish-PSResource -Path .\WinClean.ps1 -Repository PSGallery -ApiKey $env:PSGAL **Проверки (4 job'а, счёт сверять gh run view --json jobs, а не этой строкой):** 1. **lint** - PSScriptAnalyzer (Error+Warning) через общий ools/Invoke-Lint.ps1 - тот же, что зовёт релиз-гейт 2. **syntax** - Проверка синтаксиса PowerShell -3. **test** - Pester тесты (677; интеграционные требуют admin - на GitHub runners это выполняется) +3. **test** - Pester тесты (679; интеграционные требуют admin - на GitHub runners это выполняется) 4. **smoke** - прогон -ReportOnly + геометрия рамок + result JSON **Исключения PSScriptAnalyzer** (допустимые для CLI): @@ -208,7 +208,7 @@ Publish-PSResource -Path .\WinClean.ps1 -Repository PSGallery -ApiKey $env:PSGAL ### Pester тесты (v2.13+) -- `tests/Helpers.Tests.ps1` - 363 unit-тестов (дот-сорсят WinClean.ps1), `tests/Fixes.Tests.ps1` - 198, `tests/Integration.Tests.ps1` - 67 (песочница ФС, требуют admin), `tests/StandHelpers.Tests.ps1` - 23 (без admin), `tests/Docs.Tests.ps1` - 26 (гварды доков, без admin) +- `tests/Helpers.Tests.ps1` - 363 unit-тестов (дот-сорсят WinClean.ps1), `tests/Fixes.Tests.ps1` - 198, `tests/Integration.Tests.ps1` - 67 (песочница ФС, требуют admin), `tests/StandHelpers.Tests.ps1` - 23 (без admin), `tests/Docs.Tests.ps1` - 28 (гварды доков, без admin) - Особенности: функции в BeforeAll (не AST), regex для locale-независимости, отдельные It блоки --- @@ -434,7 +434,7 @@ pwsh tools/Invoke-ReleaseCheck.ps1 # версия во всех pwsh tools/Invoke-ReleaseCheck.ps1 -IncludeStand # + боевой прогон на VM (минуты) pwsh tools/Invoke-ReleaseCheck.ps1 -VerifyPublished # ПОСЛЕ выпуска: ассеты релиза и SHA256 -Invoke-Pester ./tests -Output Detailed # 677 Pester тестов (считать прогоном, не грепом) +Invoke-Pester ./tests -Output Detailed # 679 Pester тестов (считать прогоном, не грепом) pwsh tools/Invoke-SmokeTest.ps1 # Смоук: ReportOnly + геометрия UI pwsh tools/proxmox/Invoke-StandTest.ps1 -Mode Report # Стенд на Proxmox (RU=VM 190, EN: -ConfigPath ...en.json = VM 191) # Ночная матрица: cron 03:30 на proxmos (/opt/winclean-stand, /etc/cron.d/winclean-stand), отчёт в Telegram diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index fc45c69..cd5b4e9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -128,7 +128,7 @@ Invoke-Pester ./tests/Integration.Tests.ps1 All PRs automatically run: - PSScriptAnalyzer (linting) - Syntax check -- Pester tests (677 tests) +- Pester tests (679 tests) ### Release-impacting changes @@ -284,7 +284,7 @@ Invoke-Pester ./tests/Integration.Tests.ps1 Все PR автоматически проходят: - PSScriptAnalyzer (линтинг) - Проверка синтаксиса -- Pester тесты (677 тестов) +- Pester тесты (679 тестов) ### Изменения, влияющие на релиз diff --git a/WinClean.ps1 b/WinClean.ps1 index 0059b0f..11f7197 100644 --- a/WinClean.ps1 +++ b/WinClean.ps1 @@ -6611,7 +6611,7 @@ function Start-WinClean { # opened throws there - measured on six of seven bad paths - and the exception escaped # Start-WinClean before any safety net existed: no result JSON, no summary, and none of # the maintenance, because of the log. Now they degrade like every other log write. - Write-LogFileLine -Line "WinClean v$($script:Version) - Started at $(Get-Date)" -StartNewFile + Write-LogFileLine -Line "WinClean v$($script:Version) - Started at $(Get-Date)" Write-LogFileLine -Line ("=" * $script:BoxWidth) # v2.17 (p.13 of the audit): recover from a hard-killed previous run before doing diff --git a/WinClean.ps1.mutbak b/WinClean.ps1.mutbak new file mode 100644 index 0000000..0059b0f --- /dev/null +++ b/WinClean.ps1.mutbak @@ -0,0 +1,6840 @@ +<#PSScriptInfo +.VERSION 2.22 +.GUID 8f7c3b2a-1d4e-5f6a-9b8c-0d1e2f3a4b5c +.AUTHOR bivlked +.COMPANYNAME +.COPYRIGHT (c) 2026 bivlked. MIT License. +.TAGS Windows Cleanup Maintenance PowerShell Windows11 DevTools Docker WSL npm pip nuget +.LICENSEURI https://github.com/bivlked/WinClean/blob/main/LICENSE +.PROJECTURI https://github.com/bivlked/WinClean +.ICONURI https://raw.githubusercontent.com/bivlked/WinClean/main/assets/logo.svg +.EXTERNALMODULEDEPENDENCIES +.REQUIREDSCRIPTS +.EXTERNALSCRIPTDEPENDENCIES +.RELEASENOTES + v2.22: Honest completion and adaptive output - a finished Disk Cleanup is no longer reported as partial, a bad log path can no longer kill a run, every run now ends through one path, and the console output follows the window width + v2.21: Self-update targeting and honest exit codes - the update could change a file that was not the one running and still report success; a missing winget or no connectivity no longer fails the run + v2.20: Correctness and honesty round - a junction could bypass protected-path checks, four operations reported success while doing nothing, Storage Sense was unreachable so on machines where it works the slow Disk Cleanup no longer runs; where it fails, the new -SkipDiskCleanup is what removes the wait + v2.19: Contract and documentation round - -SkipCleanup now skips ALL cleanup categories, result JSON gains a tri-state PhasesSkipped, AppUpdatesCount renamed to AppUpdatesOffered (offered, not installed), full docs overhaul + v2.18: Correctness and hardening follow-up from external code review - diskpart failure detection, driver-store accounting, strict superseded-version rule, exact bootstrap host allowlist + v2.17: Silent failure hardening - operations that quietly do nothing now say so instead of reporting success + v2.16: Driver store cleanup, disk space report, kernel dump cleanup, 9 audit fixes (Delivery Optimization path, TEMP age filter, winget exit codes) + v2.15: ResultJsonPath for automated testing, one-command install/run (get.ps1, install.ps1), integration test suite + v2.14: Log persistence fix, correct npm/Firefox cache paths, localized size parsing, faster DISM/EventLogs, UI fixes + v2.13: Statistics accuracy fixes, efficiency improvements, registry cleanup + v2.12: PS 7.4+ compatibility, improved statistics (Docker/WSL/RecycleBin), ReportOnly accuracy + v2.11: Added timeouts for winget/DISM operations, fixed version display, improved reliability + v2.10: Added auto-update check at startup (checks PSGallery for new version) + v2.9: Fixed PSWindowsUpdate installation hanging (TLS 1.2, timeouts) +.PRIVATEDATA +#> + +<# +.SYNOPSIS + WinClean - Ultimate Windows 11 Maintenance Script v2.22 +.DESCRIPTION + Комплексный скрипт для обновления и очистки Windows 11: + - Обновление Windows (включая драйверы) + - Обновление приложений через winget + - Очистка системы, браузеров, кэшей разработчика + - Очистка Docker/WSL + - Очистка Visual Studio + - Очистка DNS кэша и истории Run + - Опциональная блокировка телеметрии Windows + - Параллельное выполнение для максимальной скорости + - Подробный цветной вывод + лог-файл +.NOTES + Author: biv + Version: 2.22 + Requires: PowerShell 7.1+, Windows 11, Administrator rights + Changes in 2.22: + - A Disk Cleanup that stops doing anything but never exits is no longer waited out + for the full 15-minute timeout, nor reported as still deleting: the wait now also + ends when the process has shown no CPU or I/O activity for two minutes + - An unusable -LogPath can no longer kill the run before it starts - the log header + is written through the same fault-tolerant path as every other log line + - Every run now ends through one code path, so the result JSON, the summary and the + log handle release cannot drift apart between normal and self-update exits + - Console output adapts to the window width: the window is widened best-effort at + startup and the frames follow it, so winget's table stops wrapping into them + - install.ps1 refuses a PowerShell 7 whose version it cannot read, instead of + treating "could not check" as "good enough" + + Changes in 2.21: + - The self-update could update a DIFFERENT copy than the one running and report + success - it asked whether a Gallery copy existed anywhere, not whether the + running file was that copy + - Several Gallery installations now disable the automatic update instead of + changing one at random; the running path is printed so they can be told apart + - Detection, updating and the printed advice work on a machine that has only + PSResourceGet; an AllUsers install is no longer invisible + - An update that reports success is verified against the version on disk + - A missing winget, no internet connection and a failed self-update are warnings, + not errors: the exit code no longer reports failure for a complete cleanup + - Result JSON gains AppUpdatesStatus, and Aborted gains UpdatedAndExited + Changes in 2.20: + - SECURITY: a junction whose target is a protected root could be used as a cleanup + root - the path check compared text and never resolved the link + - Storage Sense was looked up at a path where it does not exist, so every run fell + back to Disk Cleanup (15 of 18 minutes on a real workstation). Where Storage Sense + itself fails, the fallback still runs - use -SkipDiskCleanup there + - npm, event logs, privacy traces and winget source update no longer report success + when they did nothing + - Added -SkipDiskCleanup to skip only the slow Disk Cleanup step + - Result JSON gains LoggingDegraded and DiskCleanupPending + Changes in 2.19: + - -SkipCleanup now skips the ENTIRE cleanup group (system, deep, developer, + Docker/WSL, Visual Studio), matching the documented "skip all cleanup" contract. + Previously it left developer/Docker/VS cleanup running (behavior change) + - Result JSON gains a tri-state PhasesSkipped (dispatch status): a phase turned off + by a skip flag is now recorded as skipped instead of completed + - AppUpdatesCount renamed to AppUpdatesOffered - winget cannot confirm how many apps + installed, so the summary reports the offered count honestly ("Apps: N offered") + - Documentation overhaul: accurate feature list, docs/ deep-dive pages, SECURITY and + CONTRIBUTING release-gate, SHA-pinned CI actions + Changes in 2.18: + - WSL/Docker VHDX compaction now detects a diskpart failure instead of reporting + "no space saved", and a failed WSL shutdown skips compaction rather than + touching a live disk + - Driver store falls back to the repository delta whenever ANY removed package + lacks a trusted per-package size, not only when the total is zero + - Driver store "superseded" now requires a strictly newer version, never a mere + newer date at the same version + - The per-VHDX compaction failure is now counted as a warning + - The one-line install scripts validate the download host against an exact + allowlist instead of a broad *.github.com / *.githubusercontent.com suffix + - Folder size measurement distinguishes an unreadable folder from an empty one + Changes in 2.17: + - Cleanups that free nothing from a non-empty folder now say so instead of + staying silent, which was indistinguishable from "there was nothing to do" + - Kernel dump and driver package removal failures are counted and reported + - pnputil exit code is checked; a parse failure no longer looks like "nothing found" + - Driver store falls back to measuring the repository when per-package sizes + cannot be attributed, instead of reporting 0 B after a successful cleanup + - Disk Cleanup verifies that categories were armed and checks its exit code + - Browser caches are no longer reported as cleaned when nothing was freed + - The temp age filter now fails closed: an unreadable subtree is kept, not deleted + - Controlled Folder Access reports 'unknown' when the check itself fails + - Downloaded-but-not-installed updates are no longer counted as installed + Changes in 2.16: + - Added driver store cleanup: removes superseded driver packages that no device + uses and that have a newer version installed (451 MB on the author's machine) + - Added disk space report: shows large areas cleanup deliberately never touches + (MSI cache, search index, hiberfil.sys, shadow copies) + - Added kernel dump cleanup for stale LiveKernelReports files (multi-gigabyte) + - Fixed Delivery Optimization cache path - multi-gigabyte cleanups were reported as 0 B + - Temp cleanup no longer deletes files younger than a day (running installers) + - Windows Update cache cleanup now waits for the service to really stop + - Warns when Controlled Folder Access may silently block deletions + - Disk Cleanup category list reconciled with the registry; leftover StateFlags + are now swept from every handler + - winget exit codes are decoded instead of printed as a bare number + - All progress bars are closed before the summary, including foreign ones + Changes in 2.15: + - Added -ResultJsonPath: machine-readable run summary (JSON) for automated + testing, CI and VM test stands + - Added get.ps1 (one-command run from the internet) and install.ps1 + (one-command install/update + elevated desktop shortcut) + - Added integration test suite (sandboxed filesystem tests) and smoke runner + with automated console box-geometry checking + Changes in 2.14: + - Fixed log file being deleted by the script's own temp cleanup (all entries + logged before Clear-TempFiles were silently lost every run) + - Fixed npm cache path for npm v7+ (LOCALAPPDATA\npm-cache; old APPDATA path kept as fallback) + - Fixed Firefox cache path (cache2/startupCache live under LOCALAPPDATA, not APPDATA) + - Fixed localized size parsing (Cyrillic units, no-break spaces) for Recycle Bin statistics + - Fixed restore points silently not created due to the 24h system frequency limit + - Fixed winget update count inflated by the "require explicit targeting" table + - Fixed Storage Sense wasting 120s + false warning when the scheduled task is disabled + - Fixed misaligned UPDATE AVAILABLE box and countdown ghost character + - DISM: component store analyzed first (/English), cleanup skipped when not needed; + DISM output redirected to keep console clean + - Event logs: only enabled non-empty Administrative/Operational logs are cleared + (much faster, no chronic partial-failure warnings) + - Delivery Optimization cache cleared via supported Delete-DeliveryOptimizationCache cmdlet + - Replaced dead connectivity probe winget.azureedge.net with cdn.winget.microsoft.com + - Removed risky cleanmgr categories (Previous Installations, Windows ESD installation files) + - Added Opera GX and uv cache cleanup; winget check hardened with --disable-interactivity + - Removed dead code (unused statusIcon/dockerInfo variables) + - Fixed Docker reclaimed-space parsing ($Matches after array -match) + exit code check + - Fixed icacls on non-English Windows (SID S-1-5-32-544 instead of localized "Administrators") + - Fixed failed Windows Update search being reported as "up to date" + - Stats no longer count locked files / undeleted Recycle Bin items as freed + - Custom -LogPath directories are created automatically + Changes in 2.13: + - Fixed Docker prune output parsing (supports "Total reclaimed space:" format) + - Fixed WarningsCount not incrementing for event log failures + - Fixed false "success" when Windows Update returns null results + - Optimized Get-FolderSize with -File flag for better performance + - Fixed temp path deduplication to avoid duplicate cleanups + - Removed redundant docker builder prune (already included in system prune) + - Fixed potential negative freed space in browser cache statistics + - Added fallback for Recycle Bin size calculation via GetDetailsOf + - Added registry cleanup for Disk Cleanup StateFlags after execution + Changes in 2.12: + - Fixed PS 7.4+ compatibility (removed deprecated -UseBasicParsing) + - Fixed DISM ReportOnly to show /ResetBase and warning + - Fixed AppUpdatesCount to only count successful updates + - Added statistics for Docker, WSL, Recycle Bin, npm cache + Changes in 2.11: + - Fixed version display bugs (banner and log showed v2.9 instead of current version) + - Added timeouts for winget/DISM operations to prevent script hangs + - Added force stop for Storage Sense when timeout exceeded + - Improved Docker detection and browser cache statistics reliability + Changes in 2.10: + - Added auto-update check: script checks PSGallery for newer version at startup + - Added Test-ScriptUpdate function: compares local version with PSGallery + - Added Invoke-ScriptUpdate function: prompts user and performs update if confirmed + - Update check runs after reboot check, before main operations + - Shows manual update instructions if script was downloaded manually (not via PSGallery) + - Respects ReportOnly mode and non-interactive environments + Changes in 2.9: + - Fixed PSWindowsUpdate installation hanging: added TLS 1.2 enforcement + - Added Test-PSGalleryConnection function: pre-checks PowerShell Gallery availability + - Added Install-ModuleWithTimeout function: 120-second timeout for Install-Module + - Added Install-PackageProviderWithTimeout function: 60-second timeout for NuGet provider + - Improved error messages with manual installation instructions + - Clear Write-Progress before module installation to prevent UI artifacts + Changes in 2.8: + - Fixed Disk Cleanup timeout: reduced from 10 minutes to 7 minutes + - Fixed Disk Cleanup: replaced -NoNewWindow with -WindowStyle Hidden (more reliable) + - Added progress logging every minute while Disk Cleanup is running + - Replaced Wait-Process with explicit HasExited loop for better control + Changes in 2.7: + - Fixed UI: header frame (╔═╗║║) now uses Cyan like the rest of the frame + - Status text (COMPLETED SUCCESSFULLY) remains colored (Green/Yellow/Red) for visual feedback + Changes in 2.6: + - Fixed UI: final statistics frame now uses consistent Cyan color throughout + - Fixed UI: added 2-space gap between label and value (prevents "installed:Windows:" merging) + - Fixed UI: category names (Temp, System) now right-aligned with PadLeft to match main labels + - Refactored: $labelWidth moved to parent scope for reuse in category alignment + Changes in 2.5: + - Fixed UI: subsection gray lines now match TITLE frame width (70 chars) + - Fixed UI: final statistics window alignment (emoji replaced with ASCII) + - Fixed UI: Write-StatLine width formula corrected (-5 → -3) + Changes in 2.4: + - UI improvements: consistent left indent (2 spaces) throughout the script + - UI improvements: major sections now have full frame (like banner) + - UI improvements: subsections keep original style (┌─ Title / └────) + - UI improvements: enhanced final statistics with status icons and colors + - UI improvements: header color reflects completion status (green/yellow/red) + - Removed 60-second auto-close timeout - window now waits indefinitely for keypress + Changes in 2.3: + - Fixed critical bug: TotalFreedBytes always showed 0 in final statistics + Root cause: Interlocked.Add doesn't work with hashtable elements via [ref] in PowerShell + Solution: Use simple += operator (synchronized hashtable handles thread-safety) + Changes in 2.2: + - Fixed TcpClient resource leak: now properly closed in finally block (prevents socket exhaustion) + - Fixed code region markers: 8 misplaced #region tags corrected for proper IDE navigation + - Fixed banner ASCII art: now displays "CLEAN" instead of incorrect "DREAM" + Changes in 2.1: + - Fixed Clear-EventLogs: exact match for Security log only (not all logs containing "Security") + - Fixed browser cache cleanup: additional profiles now get full cache set (Code Cache, GPUCache, etc.) + - Fixed Update-Applications: ErrorsCount++ now incremented when no internet + - Fixed Roslyn Temp cleanup: file patterns now handled correctly (not just directories) + - Fixed winget update count: works with custom sources (not just winget/msstore) + - Fixed interactive prompts: safe defaults in non-console environments (Scheduled Tasks, ISE) + - Fixed telemetry edition detection: uses EditionID registry (language-independent) + - Fixed final statistics: consistent box width (no visual glitches) + Changes in 2.0: + - Fixed Test-InternetConnection: uses TcpClient with 3s timeout (no VPN hangs) + - Fixed Clear-EventLogs: now checks $LASTEXITCODE for each wevtutil call + - Fixed winget ExitCode: strict check (any non-zero = error, not just empty output) + - Fixed Storage Sense: uses Get-ScheduledTask (language-independent status) + - Fixed Storage Sense: detects actual completion (wasRunning -> Ready transition) + - Fixed ReportOnly: no longer installs PSWindowsUpdate/NuGet modules + - Removed unused DriverUpdatesCount field from Stats + Changes in 1.9: + - Fixed progress bar: TotalSteps now calculated dynamically based on skip flags + - Fixed winget: source update skipped in ReportOnly mode, added ExitCode check + - Fixed winget: --include-unknown now used consistently for count and upgrade + - Fixed browser cache statistics: now measures actual freed space (before/after) + - Fixed Storage Sense: now waits for task completion instead of fixed sleep + - Fixed DNS flush: logs warning on unexpected result instead of false success + - Fixed WSL/Docker VHDX: now compacts all VHDX files regardless of distro list + - Moved Update-Progress calls after skip flag checks for accurate progress + Changes in 1.8: + - Fixed critical bug: $LogPath vs $script:LogPath in Start-WinClean and Show-FinalStatistics + - Fixed version inconsistency: unified all version references to single source + - Added browser cache size tracking to freed space statistics + - Fixed TotalSteps count (was 12, actual 7 steps) + - Improved winget update detection: language-independent parsing + Changes in 1.7: + - Improved internet connectivity check: HTTPS endpoints + ICMP fallback + - Fixed Show-Banner to display correct log path ($script:LogPath) + - Fixed Clear-SystemCaches: ReportOnly mode and size tracking for single files + Changes in 1.6: + - Added pause at end: window stays open 60 sec or until key press + (prevents window from closing before user can read results) + Changes in 1.5: + - Fixed visual glitch: clear progress bar before DISM output to prevent overlay + Changes in 1.4: + - Fixed Clear-PrivacyTraces: added -Recurse to Remove-Item to prevent confirmation + prompts when cleaning Recent folder (AutomaticDestinations, CustomDestinations) + Changes in 1.3: + - CRITICAL FIX: Clear-RecycleBin renamed to Clear-WinCleanRecycleBin to avoid + infinite recursion (stack overflow) caused by name collision with built-in cmdlet + Changes in 1.2: + - Fixed $script:LogPath scope (logging now works correctly) + - Fixed Clear-BrowserCaches respecting ReportOnly mode + - Fixed Windows.old path to use $env:SystemDrive instead of hardcoded C: + - Fixed NuGet: removed packages folder (not cache), kept only metadata caches + - Fixed Gradle: only delete safe build caches, not downloaded dependencies + - Fixed Windows Update services now properly restart via try/finally + - Fixed WSL --list output UTF-16LE parsing (removes null characters) +.PARAMETER SkipUpdates + Пропустить все обновления (Windows + winget) +.PARAMETER SkipCleanup + Пропустить все операции очистки (система, глубокая очистка, кэши разработчика, + Docker/WSL, Visual Studio). Отдельные -Skip*Cleanup - более точечные флаги внутри +.PARAMETER SkipRestore + Пропустить создание точки восстановления +.PARAMETER SkipDevCleanup + Пропустить очистку кэшей разработчика (npm, pip, nuget) +.PARAMETER SkipDockerCleanup + Пропустить очистку Docker/WSL +.PARAMETER SkipVSCleanup + Пропустить очистку Visual Studio +.PARAMETER SkipDiskCleanup + Пропустить только шаг Storage Sense / Disk Cleanup (штатная утилита Windows). + Этот шаг бывает самым долгим: на реальной рабочей станции cleanmgr занял 15 минут + из 18 и не успел завершиться, тогда как на чистой виртуальной машине те же + 23 категории отрабатывают за 10 секунд. Причина разницы НЕ установлена: измерение + показывает лишь, что стоимость зависит от накопленного состояния машины. Остальная + очистка при этом выполняется - в отличие от -SkipCleanup, который гасит её целиком +.PARAMETER DisableTelemetry + Отключить телеметрию Windows (через групповую политику) +.PARAMETER ReportOnly + Только показать, что будет сделано (без выполнения) +.PARAMETER LogPath + Путь к файлу лога (по умолчанию: $env:TEMP\WinClean_.log) +.PARAMETER ResultJsonPath + Путь для машиночитаемого итога прогона (JSON). Используется автотестами + и стендами; если не задан - JSON не создаётся +#> + +#Requires -Version 7.1 +#Requires -RunAsAdministrator + +# PositionalBinding disabled (v2.15): stray positional arguments must fail loudly +# instead of silently binding to LogPath/ResultJsonPath and turning an intended +# dry run into a real one +[CmdletBinding(PositionalBinding = $false)] +param( + [switch]$SkipUpdates, + [switch]$SkipCleanup, + [switch]$SkipRestore, + [switch]$SkipDevCleanup, + [switch]$SkipDockerCleanup, + [switch]$SkipVSCleanup, + [switch]$SkipDiskCleanup, + [switch]$DisableTelemetry, + [switch]$ReportOnly, + [string]$LogPath, + [string]$ResultJsonPath +) + +#region ═══════════════════════════════════════════════════════════════════════ +# INITIALIZATION +#═══════════════════════════════════════════════════════════════════════════════ + +# Ensure UTF-8 encoding +[Console]::OutputEncoding = [System.Text.Encoding]::UTF8 +$OutputEncoding = [System.Text.Encoding]::UTF8 +$PSDefaultParameterValues['*:Encoding'] = 'utf8' + +# Statistics storage (synchronized hashtable for safe concurrent access) +function New-RunStats { + <# + .SYNOPSIS + Builds a fresh per-run statistics object + .DESCRIPTION + v2.20: this was a literal assigned once when the script loaded, and Start-WinClean + reset only the phase buckets and the step counter - while the comment there + described "dot-source and call Start-WinClean twice" as the case being handled. + Everything else survived: freed bytes, per-category totals, update counts, warning + and error counts, RebootRequired, Aborted and StartTime. A second run in the same + session therefore reported the first run's bytes and errors, and computed its + duration from the moment the script was dot-sourced. + + One definition, used both at load time and at the start of every run. + #> + return [hashtable]::Synchronized(@{ + TotalFreedBytes = [long]0 + FreedByCategory = @{} + WindowsUpdatesCount = 0 + # v2.19: renamed from AppUpdatesCount. winget upgrade --all cannot report how many + # apps actually installed (it silently skips pinned/manifest-less/UAC-cancelled ones), + # so we only ever know how many it OFFERED. Naming it "installed" was a false claim. + AppUpdatesOffered = 0 + # v2.21: why the app half of the Updates phase produced the count it did. Added because demoting a + # missing winget from error to warning removed the only machine-readable way to tell + # "checked, nothing to upgrade" from "could not check at all" - both are + # AppUpdatesOffered = 0 with WarningsCount incremented by one of many possible causes. + # 'not-run' | 'checked' | 'check-failed' | 'skipped-parameter' | 'skipped-offline' + # | 'skipped-no-winget' + AppUpdatesStatus = 'not-run' + WarningsCount = 0 + ErrorsCount = 0 + RebootRequired = $false + StartTime = Get-Date + CurrentStep = 0 + TotalSteps = 0 # Calculated dynamically in Start-WinClean + # v2.16: tri-state string, 'disabled' / 'enabled' / 'unknown'. Never a boolean: + # 'unknown' must not be mistaken for a verified state by consumers of the JSON + ControlledFolderAccess = 'unknown' + Aborted = $null # v2.17: set when the run stops before finishing + # v2.20: cleanmgr outlived its timeout and is still deleting in the background. The + # totals reported by this run are partial, and a consumer must not read them as final. + DiskCleanupPending = $false + # v2.22: how the Storage Sense / Disk Cleanup step actually ended. DiskCleanupPending + # alone could not tell "still visibly deleting" from "resident but doing nothing", and + # reported both as pending - so a cleanup nothing had been observed to be doing was + # published as partial. + # Same shape and reasoning as AppUpdatesStatus (v2.21): when a boolean starts covering + # two different truths, the fix is a status, not a cleverer boolean. + # 'not-run' | 'skipped-parameter' | 'storage-sense' | 'completed' | + # 'idle-resident' | 'timeout' | 'failed' + DiskCleanupStatus = 'not-run' + # v2.17 (p.11 of the audit): which top-level phases ran to completion vs threw. + # Before this, one exception anywhere in the run silently skipped every phase + # after it - Developer Cleanup, Docker/WSL, Visual Studio, Deep System Cleanup, + # the disk space report, Telemetry - with only a single generic "Critical error" + # in the log to show for it. + # v2.19: these are a DISPATCH status, not an outcome. Completed = the phase action + # was invoked and returned without an uncaught exception (NOT "succeeded" - e.g. + # Preparation stays Completed even when the restore point genuinely failed, because + # New-SystemRestorePoint catches that and returns $false). Skipped = a skip flag + # suppressed the phase before it ran. Failed = the action threw. For a non-aborted + # run the three are disjoint and their union is exactly the known phase set. + PhasesCompleted = @() + PhasesFailed = @() + PhasesSkipped = @() + }) +} + +$script:Stats = New-RunStats + +# Progress activities seen so far, so all of them can be closed at the end (v2.16) +$script:ProgressActivities = @() + +# Memoized Test-InternetConnection result for the whole run (v2.17, p.5 of the audit): +# the check costs up to ~15s offline and is called from both halves of the Updates phase +$script:InternetConnectionCache = $null + +# Latched by Write-Log when the log file cannot be written, so the failure is reported +# once instead of on every call - and surfaces in the result JSON as LoggingDegraded +$script:LogWriteFailed = $false + +# Inner width of every framed section. v2.22: was the literal 70 repeated in four places; +# Start-WinClean now derives it once from the actual console. 70 stays the default so a +# host whose width cannot be read looks exactly as it always did. +$script:BoxWidth = 70 + +# Initialize log path (script scope for access in functions) +if (-not $LogPath) { + $script:LogPath = Join-Path $env:TEMP "WinClean_$((Get-Date).ToString('yyyyMMdd_HHmmss')).log" +} else { + $script:LogPath = $LogPath + # Ensure the parent directory exists for custom log paths (v2.14) + $logDir = Split-Path -Path $script:LogPath -Parent + if ($logDir -and -not (Test-Path -LiteralPath $logDir)) { + New-Item -ItemType Directory -Path $logDir -Force -ErrorAction SilentlyContinue | Out-Null + } +} + +# Script version (single source of truth for version checking) +$script:Version = "2.22" + +# Protected paths that should never be deleted +$script:ProtectedPaths = @( + $env:SystemRoot, + "$env:SystemRoot\System32", + $env:ProgramFiles, + ${env:ProgramFiles(x86)}, + $env:USERPROFILE, + "$env:SystemDrive\Users", + "$env:SystemDrive\Program Files", + "$env:SystemDrive\Program Files (x86)" +) + +#endregion + +#region ═══════════════════════════════════════════════════════════════════════ +# LOGGING FUNCTIONS +#═══════════════════════════════════════════════════════════════════════════════ + +function Write-LogFileLine { + <# + .SYNOPSIS + Appends one line to the log file, degrading instead of throwing + .DESCRIPTION + v2.22, raised in external review: extracted from Write-Log so that EVERY write to + the log file - including the header, which Start-WinClean emits before its main + try/finally exists - degrades the same way instead of each caller inventing its + own fault tolerance. + + The header used to be two bare Out-File calls. Measured, not assumed: six of seven + bad log paths make Out-File throw a TERMINATING error even though the script leaves + ErrorActionPreference at Continue (missing directory, path is a directory, invalid + characters, colon in the name, over-long path, unreachable UNC; only a reserved + device name did not). Thrown there, before the safety net, the exception escaped + Start-WinClean entirely: no result JSON, no final summary, no exit-code accounting, + and none of the maintenance the run was started for - all because of the log. + + A log that cannot be written is a degraded run, never a failed one. + #> + param( + [Parameter(Mandatory)] + [AllowEmptyString()] + [string]$Line, + + # The header line starts a fresh file. The Out-File call this replaced had no + # -Append, so it truncated on every run; preserved deliberately rather than lost + # in the refactor, or a custom -LogPath reused across runs would accumulate them + # all into one file and the log would no longer describe a single run. + [switch]$StartNewFile + ) + + # v2.17 (p.7 of the audit): Out-File used to open, seek to end, write and close the + # file on every single call - Write-Log fires hundreds of times per run. A StreamWriter + # kept open for the run avoids that, with AutoFlush so each line still lands on disk + # immediately (same durability as before, just cheaper). FileShare.Delete matters for + # tests: they Remove-Item the log path in AfterAll while this writer may still be the + # last one that touched it. + try { + if ($StartNewFile -or -not $script:LogWriter -or $script:LogWriterPath -ne $script:LogPath) { + if ($script:LogWriter) { $script:LogWriter.Dispose() } + $mode = if ($StartNewFile) { [System.IO.FileMode]::Create } else { [System.IO.FileMode]::Append } + $fileStream = [System.IO.File]::Open( + $script:LogPath, $mode, [System.IO.FileAccess]::Write, + ([System.IO.FileShare]::ReadWrite -bor [System.IO.FileShare]::Delete)) + $script:LogWriter = [System.IO.StreamWriter]::new($fileStream, [System.Text.Encoding]::UTF8) + $script:LogWriter.AutoFlush = $true + $script:LogWriterPath = $script:LogPath + } + $script:LogWriter.WriteLine($Line) + } catch { + # v2.20: this used to be an empty catch, so a log that stopped being written + # (full volume, revoked permissions, the v2.14 case where cleanup deleted the + # log out from under us) was invisible: destructive work carried on, the final + # JSON said ErrorsCount=0, and LogPath pointed at a truncated file. + # + # Latched: one console line, not one per call - Write-Log fires hundreds of + # times per run. Deliberately Write-Host and not Write-Log, which would + # recurse straight back into this catch. + # v2.20, corrected in review: drop the writer so the NEXT call reopens it. + # Without this the guard above stays satisfied by a dead writer object and + # every later line is silently discarded for the rest of the run - the empty + # catch would simply have moved from the first failure to all the others. + try { if ($script:LogWriter) { $script:LogWriter.Dispose() } } catch { } + $script:LogWriter = $null + $script:LogWriterPath = $null + + if (-not $script:LogWriteFailed) { + $script:LogWriteFailed = $true + $script:Stats.WarningsCount++ + Write-Host " [WARN] Log file could not be written ($($_.Exception.Message)) - the run continues, but $($script:LogPath) may be incomplete" -ForegroundColor Yellow + } + } +} + +function Write-Log { + <# + .SYNOPSIS + Writes colored output to console and plain text to log file + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [string]$Message, + + [ValidateSet('INFO', 'SUCCESS', 'WARNING', 'ERROR', 'TITLE', 'SECTION', 'DETAIL')] + [string]$Level = 'INFO', + + [switch]$NoNewLine, + [switch]$NoTimestamp, + [switch]$NoLog + ) + + # Consistent left indent for all output (matches banner style) + $indent = " " + $boxWidth = $script:BoxWidth # Inner width for framed sections (v2.22: adaptive) + + $timestamp = (Get-Date).ToString('HH:mm:ss') + $logMessage = "[$timestamp] [$Level] $Message" + + if (-not $NoLog) { + Write-LogFileLine -Line $logMessage + } + + # Console output with colors + $colors = @{ + INFO = @{ Tag = 'Cyan'; Message = 'White' } + SUCCESS = @{ Tag = 'Green'; Message = 'White' } + WARNING = @{ Tag = 'Yellow'; Message = 'Yellow' } + ERROR = @{ Tag = 'Red'; Message = 'Red' } + TITLE = @{ Tag = 'Magenta'; Message = 'Magenta' } + SECTION = @{ Tag = 'Cyan'; Message = 'Cyan' } + DETAIL = @{ Tag = 'DarkGray';Message = 'Gray' } + } + + $tagColors = $colors[$Level] + + switch ($Level) { + 'TITLE' { + # Full frame for major sections (like banner style, but Magenta) + $titleText = $Message.ToUpper() + $padding = [math]::Max(0, $boxWidth - $titleText.Length) + $leftPad = [math]::Floor($padding / 2) + $rightPad = $padding - $leftPad + $centeredTitle = (" " * $leftPad) + $titleText + (" " * $rightPad) + + Write-Host "" + Write-Host "$indent╔$("═" * $boxWidth)╗" -ForegroundColor $tagColors.Tag + Write-Host "$indent║$centeredTitle║" -ForegroundColor $tagColors.Tag + Write-Host "$indent╚$("═" * $boxWidth)╝" -ForegroundColor $tagColors.Tag + } + 'SECTION' { + # Subsection header (keep original style with indent) + Write-Host "" + Write-Host "$indent┌─ " -NoNewline -ForegroundColor DarkGray + Write-Host $Message -ForegroundColor $tagColors.Message + Write-Host "$indent└$("─" * $boxWidth)" -ForegroundColor DarkGray + } + 'DETAIL' { + # Detail line with vertical bar + Write-Host "$indent │ " -NoNewline -ForegroundColor DarkGray + Write-Host $Message -ForegroundColor $tagColors.Message -NoNewline:$NoNewLine + if (-not $NoNewLine) { Write-Host "" } + } + default { + # Standard log line with timestamp and tag + Write-Host $indent -NoNewline + + if (-not $NoTimestamp) { + Write-Host "[$timestamp] " -NoNewline -ForegroundColor DarkGray + } + + $tagText = switch ($Level) { + 'INFO' { '[INFO] ' } + 'SUCCESS' { '[OK] ' } + 'WARNING' { '[WARN] ' } + 'ERROR' { '[ERROR] ' } + } + + Write-Host $tagText -NoNewline -ForegroundColor $tagColors.Tag + Write-Host $Message -ForegroundColor $tagColors.Message -NoNewline:$NoNewLine + if (-not $NoNewLine) { Write-Host "" } + } + } +} + +function Update-Progress { + <# + .SYNOPSIS + Updates progress bar and step counter + #> + param( + [string]$Activity, + [string]$Status = "Processing..." + ) + + $script:Stats.CurrentStep++ + $percent = [math]::Min(100, [math]::Round(($script:Stats.CurrentStep / $script:Stats.TotalSteps) * 100)) + + # Remember the activity so Clear-AllProgress can close it later (v2.16) + if ($Activity -and $script:ProgressActivities -notcontains $Activity) { + $script:ProgressActivities += $Activity + } + + Write-Progress -Activity $Activity -Status $Status -PercentComplete $percent +} + +function Clear-AllProgress { + <# + .SYNOPSIS + Closes every progress bar before the final report is printed + .DESCRIPTION + v2.16: "Write-Progress -Activity 'Complete' -Completed" only closed an activity + that never existed, so leftover bars stayed on screen under the summary - both + our own (seven different activities are used) and foreign ones from cmdlets such + as Clear-RecycleBin, whose activity name is not ours to know. Clearing by Id + covers those: an unused Id is simply a no-op. + v2.17 (p.16 of the audit): 0..10 was eyeballed, not derived from anything. Widened + to 0..30 - ForEach-Object -Parallel and nested cmdlets can allocate Ids well past + 10, and clearing an unused Id costs nothing. + #> + foreach ($activity in $script:ProgressActivities) { + Write-Progress -Activity $activity -Completed -ErrorAction SilentlyContinue + } + for ($id = 0; $id -le 30; $id++) { + Write-Progress -Id $id -Activity ' ' -Completed -ErrorAction SilentlyContinue + } +} + +#endregion + +#region ═══════════════════════════════════════════════════════════════════════ +# HELPER FUNCTIONS +#═══════════════════════════════════════════════════════════════════════════════ + +function Get-RunMarkerPath { + Join-Path $env:TEMP 'WinClean.recovery-marker.json' +} + +function Set-RunMarker { + <# + .SYNOPSIS + Records that a risky, hard-to-undo operation is about to start + .DESCRIPTION + v2.17 (p.13 of the audit): Ctrl+C already unwinds through try/finally - the + gap is a HARD kill (taskkill /F, a closed terminal, a reset VM), which skips + every finally block, including the ones that restore + SystemRestorePointCreationFrequency or restart wuauserv/bits. This marker lets + the next run detect that and recover - a plain "is the value 0 right now" + check cannot tell an interrupted run from a value the user or IT policy set on + purpose, and blindly overwriting that would be the wrong kind of surprise. + Best-effort: a failed marker write must never block the real operation. + #> + param( + [Parameter(Mandatory)][string]$Phase, + [hashtable]$Data = @{} + ) + try { + $marker = [ordered]@{ Phase = $Phase; Pid = $PID; Timestamp = (Get-Date).ToString('o') } + foreach ($key in $Data.Keys) { $marker[$key] = $Data[$key] } + $marker | ConvertTo-Json -Compress | Set-Content -LiteralPath (Get-RunMarkerPath) -Encoding utf8 -ErrorAction Stop + } catch { } +} + +function Clear-RunMarker { + Remove-Item -LiteralPath (Get-RunMarkerPath) -Force -ErrorAction SilentlyContinue +} + +function Restore-RestorePointFrequency { + <# + .SYNOPSIS + Puts SystemRestorePointCreationFrequency back to $PreviousValue, if it is still 0 + .DESCRIPTION + Shared by the inline timeout path in New-SystemRestorePoint and by + Invoke-StaleMarkerRecovery, so both behave identically. Only acts when the value + is currently 0 (i.e. still holding this script's override) - if something else + has since set a real value, that is left alone. + .OUTPUTS + [bool] $true when nothing needs doing or the restore succeeded, $false on failure + #> + param($PreviousValue) + + try { + $srKey = 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\SystemRestore' + $current = (Get-ItemProperty -Path $srKey -Name SystemRestorePointCreationFrequency -ErrorAction SilentlyContinue).SystemRestorePointCreationFrequency + if ($current -ne 0) { return $true } # already a real value - not ours to touch + + if ($null -ne $PreviousValue) { + Set-ItemProperty -Path $srKey -Name SystemRestorePointCreationFrequency -Value $PreviousValue -Type DWord -Force -ErrorAction Stop + } else { + Remove-ItemProperty -Path $srKey -Name SystemRestorePointCreationFrequency -ErrorAction Stop + } + return $true + } catch { + Write-Log "Could not restore SystemRestorePointCreationFrequency: $_" -Level WARNING + return $false + } +} + +function Invoke-StaleMarkerRecovery { + <# + .SYNOPSIS + Recovers system state left behind by a hard-killed previous run, if any + .DESCRIPTION + v2.17 (p.13 of the audit). Called once at the start of a run. A marker left by + THIS run's own process id is not evidence of anything (re-entrant call, or a + race) - only a marker from a different process means the previous run never + reached its cleanup. + + The marker is kept when recovery FAILS, so the next run can retry instead of + leaving the damage in place forever with no record of it. + + Known limitation, deliberately not solved here: a PID is not a durable identity + (Windows recycles them, and two concurrent runs would each see the other as + "foreign"). Both cases need a second WinClean running as administrator at the + same time, which the script does not support anyway; the recovery actions are + also written to be no-ops when there is nothing to repair. + #> + $markerPath = Get-RunMarkerPath + if (-not (Test-Path -LiteralPath $markerPath -ErrorAction SilentlyContinue)) { return } + + try { + $marker = Get-Content -LiteralPath $markerPath -Raw -ErrorAction Stop | ConvertFrom-Json + } catch { + Remove-Item -LiteralPath $markerPath -Force -ErrorAction SilentlyContinue + return + } + + if ($marker.Pid -eq $PID) { return } + + Write-Log "Recovery marker found from an interrupted previous run (phase: $($marker.Phase), pid $($marker.Pid)) - checking for leftover state" -Level WARNING + $script:Stats.WarningsCount++ + + $recovered = $true + switch ($marker.Phase) { + 'RestorePointFrequencyOverride' { + $recovered = Restore-RestorePointFrequency -PreviousValue $marker.PreviousValue + if ($recovered) { + Write-Log "Checked SystemRestorePointCreationFrequency after the interrupted run" -Level INFO + } + } + 'WUServiceStop' { + # Only services this script actually stopped are restarted. Starting every + # stopped service would fight an administrator who disabled one on purpose. + foreach ($svcName in @($marker.ServicesToRestart)) { + if (-not $svcName) { continue } + try { + $svc = Get-Service -Name $svcName -ErrorAction SilentlyContinue + if ($svc -and $svc.Status -eq 'Stopped') { + Start-Service -Name $svcName -ErrorAction Stop + Write-Log "Restarted $svcName, left stopped by the interrupted run" -Level INFO + } + } catch { + Write-Log "Could not restart $svcName : $_" -Level WARNING + $recovered = $false + } + } + } + } + + if ($recovered) { + Remove-Item -LiteralPath $markerPath -Force -ErrorAction SilentlyContinue + } else { + Write-Log "Recovery incomplete - keeping the marker so the next run retries" -Level WARNING + } +} + +function Test-InteractiveConsole { + <# + .SYNOPSIS + Checks if running in an interactive console environment + .DESCRIPTION + Returns $false for Scheduled Tasks, ISE, remote sessions, etc. + Used to safely skip [Console]::KeyAvailable calls that would throw exceptions + #> + try { + # Check if we're in ConsoleHost and have a valid console window + if ($Host.Name -ne 'ConsoleHost') { + return $false + } + # Try to access console properties - will throw in non-console environments + $null = [Console]::WindowWidth + return $true + } catch { + return $false + } +} + +function Get-ConsoleWidth { + <# + .SYNOPSIS + The console width in columns, or 0 when it cannot be determined + .DESCRIPTION + v2.22. Redirected output, scheduled tasks and non-console hosts either throw here + or report nonsense, and 0 is the honest answer for all of them - the caller falls + back to the historical fixed width rather than laying out against a guess. + #> + try { + $width = $Host.UI.RawUI.WindowSize.Width + if ($width -gt 0) { return [int]$width } + } catch { } + return 0 +} + +function Get-BoxWidth { + <# + .SYNOPSIS + Inner width for framed sections, derived from the console width + .DESCRIPTION + v2.22, pure so the bounds are testable. A box is printed as two spaces of indent, + a border character, the inner width, and a closing border character - so it needs + ConsoleWidth-4 at the very most, and 6 is subtracted to keep a margin away from + the wrap column. + + Bounded both ways on purpose: + - never below 70, the width every previous version used. A narrow console cannot + be laid out well either way, and shrinking below 70 would change how WinClean + looks for everyone who has been reading it at 70 for ten releases. + - never above 90. Raising the ceiling further makes the frames span the screen on + a wide monitor, which reads worse rather than better - the user asked for + "somewhat wider", not "as wide as it goes". + #> + param([int]$ConsoleWidth) + + if ($ConsoleWidth -le 0) { return 70 } + return [math]::Max(70, [math]::Min(90, $ConsoleWidth - 6)) +} + +function Expand-ConsoleWindow { + <# + .SYNOPSIS + Best-effort widening of the console window at startup + .DESCRIPTION + v2.22. The desktop shortcut opens conhost at its 120-column default, and winget's + upgrade table needs about 140 on a localised system - so it wrapped, and every + wrapped row landed across the script's own output. Measured on the reporting + machine: window 120x30, buffer 120x9001, maximum physical window 3824 columns. + There was room; nobody had asked for it. + + Deliberately done here rather than by writing console properties into the .lnk: + the shortcut route only fixes the shortcut (and NT_CONSOLE_PROPS is a binary blob + WScript.Shell will not write), while this helps every way of starting the script. + + Best-effort by design. Windows Terminal ignores or refuses programmatic resizing, + redirected hosts throw - none of that is worth a warning, let alone a failed run. + The buffer is grown before the window because a window may never exceed its + buffer; the reverse order fails. + #> + param([int]$DesiredWidth = 140) + + try { + $raw = $Host.UI.RawUI + $current = $raw.WindowSize + if ($current.Width -le 0 -or $current.Width -ge $DesiredWidth) { return } + + # Never ask for more than the screen can physically show + $target = [math]::Min($DesiredWidth, $raw.MaxPhysicalWindowSize.Width) + if ($target -le $current.Width) { return } + + $buffer = $raw.BufferSize + if ($buffer.Width -lt $target) { + $buffer.Width = $target + $raw.BufferSize = $buffer + } + + $window = $raw.WindowSize + $window.Width = $target + $raw.WindowSize = $window + } catch { + # Host refused; the run continues at whatever width it already had + } +} + +function Test-InternetConnection { + <# + .SYNOPSIS + Проверяет доступ к интернету через TCP-соединения с таймаутом + .DESCRIPTION + Использует TcpClient с явным таймаутом (3 сек) вместо Test-NetConnection, + который может зависать на 20-30 секунд при VPN или нестабильном соединении. + Результат кэшируется на весь прогон (v2.17): вызывается из обеих половин фазы + Updates (Windows Update, Applications Update), до 15 сек на офлайн-машине каждый раз. + Сетевая связность внутри одного прогона скрипта не меняется настолько часто, + чтобы повторная проверка была оправдана. -Force сбрасывает кэш. + #> + param([switch]$Force) + + if (-not $Force -and $null -ne $script:InternetConnectionCache) { + return $script:InternetConnectionCache + } + + $targets = @( + @{ Host = 'www.microsoft.com'; Port = 443 } + @{ Host = 'api.github.com'; Port = 443 } + @{ Host = 'cdn.winget.microsoft.com'; Port = 443 } + ) + + $timeoutMs = 3000 # 3 секунды таймаут на каждое соединение + + foreach ($target in $targets) { + $tcpClient = $null + try { + $tcpClient = New-Object System.Net.Sockets.TcpClient + $connect = $tcpClient.BeginConnect($target.Host, $target.Port, $null, $null) + $success = $connect.AsyncWaitHandle.WaitOne($timeoutMs, $false) + + if ($success -and $tcpClient.Connected) { + $tcpClient.EndConnect($connect) + $script:InternetConnectionCache = $true + return $true + } + } catch { + } finally { + # Always close TcpClient to prevent resource leaks (fixed in v2.2) + if ($tcpClient) { + $tcpClient.Close() + } + } + } + + # Запасной вариант: ICMP (может быть заблокирован в некоторых сетях) + $dnsServers = @('8.8.8.8', '1.1.1.1', '208.67.222.222') + + foreach ($dns in $dnsServers) { + if (Test-Connection -ComputerName $dns -Count 1 -Quiet -TimeoutSeconds 2 -ErrorAction SilentlyContinue) { + $script:InternetConnectionCache = $true + return $true + } + } + $script:InternetConnectionCache = $false + return $false +} + +function Test-PSGalleryConnection { + <# + .SYNOPSIS + Проверяет доступность PowerShell Gallery перед установкой модулей + .DESCRIPTION + Использует Invoke-WebRequest с коротким таймаутом для проверки доступности + powershellgallery.com. Более специфичная проверка чем общий Test-InternetConnection. + .OUTPUTS + [bool] $true если PowerShell Gallery доступен, $false в противном случае + #> + try { + # Check PSGallery API endpoint (faster than main page) + # Note: -UseBasicParsing removed - it was deprecated in PS 6.0 and removed in PS 7.4+ + $response = Invoke-WebRequest -Uri "https://www.powershellgallery.com/api/v2" ` + -TimeoutSec 10 -ErrorAction Stop + return $response.StatusCode -eq 200 + } catch { + return $false + } +} + +function Test-PathInsideRoot { + <# + .SYNOPSIS + Tells whether a path lies inside a directory + .DESCRIPTION + Pure decision, added in v2.21 for the update-channel rule below. + The root gets a trailing separator before the comparison, so C:\Temp2\x is not + read as living inside C:\Temp. Case-insensitive, matching the file system. + An unusable path or root answers "not inside" rather than throwing: the only + consumer picks wording from the answer, and a wrong "yes" prints an instruction + that does not apply to the copy the user is running. + #> + param( + [AllowNull()][string]$Path, + [AllowNull()][string]$Root + ) + + if ([string]::IsNullOrWhiteSpace($Path) -or [string]::IsNullOrWhiteSpace($Root)) { return $false } + + try { + $fullPath = [System.IO.Path]::GetFullPath($Path) + $fullRoot = [System.IO.Path]::GetFullPath($Root).TrimEnd('\', '/') + [System.IO.Path]::DirectorySeparatorChar + } catch { + return $false + } + + return $fullPath.StartsWith($fullRoot, [System.StringComparison]::OrdinalIgnoreCase) +} + +function Get-ScriptUpdateChannel { + <# + .SYNOPSIS + Decides how THIS running copy of WinClean can be updated + .DESCRIPTION + Pure decision, added in v2.21 to fix an update that reported success while + updating a different file. + Until now the question asked was "does a Gallery copy exist anywhere on this + machine", and the answer was acted on as if it had been "is the file I am + running that copy". Both are commonly true at once: install.ps1 (v2.15) puts a + copy in %ProgramFiles%\WinClean while an older Install-Script copy still sits in + Documents\PowerShell\Scripts. Update-Script then updated the copy in Documents, + printed "run WinClean again to use the new version", and the shortcut kept + starting the untouched Program Files copy - forever, with no error anywhere. + Returns one of: + gallery - the running file IS the only Gallery copy; it can update itself + gallery-ambiguous - it matches a Gallery copy, but several installs exist + installer - it lives in %ProgramFiles%\WinClean, so install.ps1 updates it + oneliner - it lives under TEMP, so get.ps1 downloaded it for this run + manual - somewhere else; only a manual download applies + unknown - the path is unavailable, so nothing may be promised + Ambiguity resolves away from 'gallery' on purpose: the cost of the wrong answer + is asymmetric. Printing an instruction to a copy that could have updated itself + is a minor annoyance; auto-updating a file nobody is running is the defect. + That is also why several installs (AllUsers and CurrentUser can coexist) refuse + the automatic path outright, raised in review. PowerShellGet's Update-Script has no + -Scope at all; PSResourceGet's Update-PSResource does have one, but WinClean does + not map a matched install location back to a scope, and the updater is chosen by + which provider answered rather than by which install matched. So the target cannot + currently be named, and declining beats guessing: verifying afterwards would report + a miss honestly, but only after the unused copy had already been modified. Same rule + as Select-StorageSenseTask. Aiming the PSResourceGet updater by scope is possible + and is left as future work rather than claimed here. + Known limit: the comparison is lexical. A path reached through a junction or an + 8.3 alias fails to match and is merely shown an instruction (safe). The reverse - + two files differing only in case inside one case-sensitive directory - would match + wrongly, and is left unhandled as a configuration this script does not support. + 'installer' and 'oneliner' describe WHERE the file is, which is what decides the + right instruction; they do not claim to prove which tool put it there. + #> + param( + [AllowNull()][string]$ExecutingPath, + [AllowNull()][string[]]$GalleryLocation, + [AllowNull()][string]$ProgramFilesRoot = [Environment]::GetFolderPath([Environment+SpecialFolder]::ProgramFiles), + [AllowNull()][string]$TempRoot = [System.IO.Path]::GetTempPath() + ) + + if ([string]::IsNullOrWhiteSpace($ExecutingPath)) { return 'unknown' } + try { $fullPath = [System.IO.Path]::GetFullPath($ExecutingPath) } catch { return 'unknown' } + + # Compare the full file path, not its folder: a Gallery install owns exactly + # WinClean.ps1 inside InstalledLocation, and a differently named copy sharing that + # folder is not the file the provider would replace. + # Deduplicate case-insensitively, matching the comparison below and the file system. + # Select-Object -Unique is case-SENSITIVE (verified, 22.07.2026), so the two providers + # reporting one install with different casing would have looked like two installs and + # silently switched a perfectly updatable machine to the refusal path. + $seen = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + $candidates = @() + foreach ($location in @($GalleryLocation)) { + if ([string]::IsNullOrWhiteSpace($location)) { continue } + try { $candidate = [System.IO.Path]::GetFullPath((Join-Path $location 'WinClean.ps1')) } catch { continue } + if ($seen.Add($candidate)) { $candidates += $candidate } + } + + foreach ($candidate in $candidates) { + if ([string]::Equals($fullPath, $candidate, [System.StringComparison]::OrdinalIgnoreCase)) { + if ($candidates.Count -gt 1) { return 'gallery-ambiguous' } + return 'gallery' + } + } + + $installerRoot = if ([string]::IsNullOrWhiteSpace($ProgramFilesRoot)) { $null } + else { Join-Path $ProgramFilesRoot 'WinClean' } + if (Test-PathInsideRoot -Path $fullPath -Root $installerRoot) { return 'installer' } + if (Test-PathInsideRoot -Path $fullPath -Root $TempRoot) { return 'oneliner' } + return 'manual' +} + +function Get-UpdateVerification { + <# + .SYNOPSIS + Decides whether an update that reported no error actually replaced the file + .DESCRIPTION + Pure decision, added in v2.21. "The cmdlet did not throw" is not "the file on + disk is now the new version" - the whole point of this release's predecessor was + that operations reporting success without doing anything are the expensive kind + of defect, and an updater is the last place to trust a silent success. + Returns Applied and Reason ('applied' | 'unchanged' | 'unreadable'). + An unreadable or unparsable version is never 'applied': that is the state where + nothing is known, and claiming success there is the behaviour being removed. + #> + param( + [AllowNull()][string]$ExpectedVersion, + [AllowNull()][string]$ObservedVersion + ) + + $observed = $null + $expected = $null + if (-not [Version]::TryParse([string]$ObservedVersion, [ref]$observed) -or + -not [Version]::TryParse([string]$ExpectedVersion, [ref]$expected)) { + return @{ Applied = $false; Reason = 'unreadable' } + } + + # Missing components are -1 in [Version], not 0, so "2.21" compares as LESS than + # "2.21.0" (raised in review). The Gallery is free to report either form for the same + # release, and without this the check would announce "the update did not apply" after + # a perfectly good update - a false alarm in the one place whose job is to be trusted. + $observed = [Version]::new($observed.Major, $observed.Minor, + [math]::Max(0, $observed.Build), [math]::Max(0, $observed.Revision)) + $expected = [Version]::new($expected.Major, $expected.Minor, + [math]::Max(0, $expected.Build), [math]::Max(0, $expected.Revision)) + + if ($observed -lt $expected) { return @{ Applied = $false; Reason = 'unchanged' } } + return @{ Applied = $true; Reason = 'applied' } +} + +function Get-ScriptFileVersion { + <# + .SYNOPSIS + Reads the .VERSION line out of a WinClean.ps1 file on disk + .DESCRIPTION + Added in v2.21 to verify an update against the file actually being run, rather + than against what a package provider believes it installed. PowerShell reads a + script into memory before executing it, so the running file can be replaced and + re-read while the current run continues. + Returns the version string, or $null when the file cannot be read or carries no + .VERSION line - both mean "not verified", never "verified". + #> + param([AllowNull()][string]$Path) + + if ([string]::IsNullOrWhiteSpace($Path)) { return $null } + try { $head = Get-Content -LiteralPath $Path -TotalCount 40 -ErrorAction Stop } catch { return $null } + + foreach ($line in $head) { + if ($line -match '^\s*\.VERSION\s+([\d.]+)\s*$') { return $Matches[1] } + } + return $null +} + +function Get-InstalledWinCleanLocation { + <# + .SYNOPSIS + Returns the folders holding a PowerShell Gallery copy of WinClean + .DESCRIPTION + Two providers can own that copy: PowerShellGet (Install-Script) and PSResourceGet + (Install-PSResource). Measured on 22.07.2026 with each in turn: both report the + other's install, because they share the InstalledScriptInfos metadata, and for a + script InstalledLocation is the Scripts folder itself - unlike modules, it carries + no version subfolder. Both are still queried, because which provider ships is a + property of the PowerShell version rather than of this machine. PSResourceGet is + asked for AllUsers explicitly, because its -Scope defaults to CurrentUser. + An array: CurrentUser and AllUsers installs can coexist. THROWS when no provider + covered the machine and at least one query failed - including when some locations + WERE found, because a partial list is not a smaller answer: a hidden install turns + an ambiguous target back into a confident one. An unreadable machine must not be + reported as a machine with no Gallery copy either, because that answer sends the + caller on to advise an installer command and build a second installation. + #> + $locations = @() + $failures = @() + $answered = $false + + # Each provider is isolated (raised in review): -ErrorAction SilentlyContinue only + # covers non-terminating errors, so a broken PowerShellGet used to abort this function + # outright and PSResourceGet was never asked - turning a repairable half-outage into + # "no Gallery copy exists", which is exactly the wrong answer to give this caller. + if (Get-Command Get-InstalledScript -ErrorAction SilentlyContinue) { + try { + # Enumerated without -Name and filtered here (raised in review, verified + # 22.07.2026): asking for a specific name raises a plain Exception when it is + # not installed, which cannot be told from a real outage without matching a + # localised message - so the query had to run with SilentlyContinue and a + # suppressed failure then passed as an authoritative "no copy installed". + # Listing everything returns an EMPTY COLLECTION when nothing is installed, so + # -ErrorAction Stop now separates the two properly. This provider enumerates + # both scopes, so one completed call covers the machine. + $locations += @(Get-InstalledScript -ErrorAction Stop | + Where-Object { $_.Name -eq 'WinClean' } | + ForEach-Object { $_.InstalledLocation }) + $answered = $true + } catch { + $failures += $_ + Write-Log "PowerShellGet could not be queried for installed copies: $_" -Level DETAIL + } + } + if (Get-Command Get-PSResource -ErrorAction SilentlyContinue) { + # -ErrorAction Stop here too, but for a different reason than above: measured + # 22.07.2026, this provider raises a TYPED ResourceNotFoundException for "nothing + # installed", so the two outcomes are separated by exception type rather than by + # asking a question that cannot fail. + $currentUserRead = $false + try { + $locations += @(Get-PSResource -Name 'WinClean' -ErrorAction Stop | + Where-Object { $_.Type -eq 'Script' } | + ForEach-Object { $_.InstalledLocation }) + $currentUserRead = $true + } catch { + if ($_.Exception.GetType().Name -eq 'ResourceNotFoundException') { + $currentUserRead = $true # answered: this scope holds no copy + } else { + $failures += $_ + Write-Log "PSResourceGet could not be queried for installed copies: $_" -Level DETAIL + } + } + + # AllUsers has to be asked for explicitly (raised in review, verified here): + # Get-PSResource's -Scope is not nullable, so an unbound call means CurrentUser and + # searches only the Documents paths. PowerShellGet's Get-InstalledScript enumerates + # both scopes, which is why this was invisible on any machine that has it - and why + # it mattered exactly on the PSResourceGet-only machine this release added support + # for. AllUsers is the natural scope for a script that requires administrator, so + # missing it classified a Gallery copy as 'manual' and advised install.ps1, adding a + # second installation. + # Support is read from the cmdlet's own metadata rather than tried and ignored + # (raised in review): "this build has no -Scope" is a limitation to accept, but any + # OTHER failure means the AllUsers half went unread, and swallowing that would hide + # precisely the installation this branch exists to find. + # Tracked per scope (raised in review): a single "somebody answered" flag let a + # successful CurrentUser query mask a failed AllUsers one, and AllUsers is the half + # this whole branch exists to read. This provider counts as having covered the + # machine only when BOTH scopes were read - or when the build predates -Scope, which + # is an accepted limitation rather than a failure. + $allUsersRead = $true + if ((Get-Command Get-PSResource).Parameters.ContainsKey('Scope')) { + $allUsersRead = $false + try { + $locations += @(Get-PSResource -Name 'WinClean' -Scope AllUsers -ErrorAction Stop | + Where-Object { $_.Type -eq 'Script' } | + ForEach-Object { $_.InstalledLocation }) + $allUsersRead = $true + } catch { + if ($_.Exception.GetType().Name -eq 'ResourceNotFoundException') { + $allUsersRead = $true + } else { + $failures += $_ + Write-Log "PSResourceGet could not be queried for AllUsers copies: $_" -Level DETAIL + } + } + } + + if ($currentUserRead -and $allUsersRead) { $answered = $true } + } + + # Nothing found AND something failed is not the same as nothing installed (raised in + # review). Treating them alike classified the running file as 'manual' and printed + # "this copy did not come from the Gallery" plus an installer command - which would add + # a SECOND installation next to the one that was merely unreadable, building the very + # state this release exists to stop misreporting. The caller turns this into a warning + # and offers nothing, which is the honest answer when the machine cannot be read. + # An absent provider is not a failure: a machine with no package provider at all + # legitimately has no Gallery copy. + # Keyed on "nobody answered", not "somebody failed" (raised in review): with a broken + # PowerShellGet beside a working PSResourceGet that legitimately reports no copy, a + # failure count above zero would raise a warning on a machine that answered correctly. + # Coverage alone decides, NOT emptiness (raised in review): with CurrentUser returning + # the running copy while the AllUsers query failed, a non-empty list looked like a + # complete answer - and a hidden second install turns 'gallery-ambiguous' back into + # 'gallery', re-enabling exactly the automatic update whose target cannot be resolved. + # A partial list is not a smaller answer, it is a different question answered. + if (-not $answered -and $failures.Count -gt 0) { + throw "installed copies could not be enumerated: $($failures[-1])" + } + + # Case-insensitive, for the reason given in Get-ScriptUpdateChannel: both providers + # report the same install, and differing casing between them must not read as two. + # Normalised first (raised in review), so "C:\Scripts" and "C:\Scripts\" are one + # location here too - this function promises distinct locations, and the caller is not + # the only thing entitled to rely on that. + $unique = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + $result = @() + foreach ($location in $locations) { + if ([string]::IsNullOrWhiteSpace($location)) { continue } + $normalized = try { [System.IO.Path]::GetFullPath($location) } catch { $location } + # GetFullPath keeps a trailing separator, so "C:\Scripts" and "C:\Scripts\" would + # still be two. Trimmed everywhere except a root, where the separator is meaningful: + # "C:\" is the root while "C:" means the current directory on that drive. + $root = try { [System.IO.Path]::GetPathRoot($normalized) } catch { '' } + if ($normalized.Length -gt $root.Length) { $normalized = $normalized.TrimEnd('\', '/') } + if ($unique.Add($normalized)) { $result += $normalized } + } + return $result +} + +function Select-UpdateCommand { + <# + .SYNOPSIS + Picks the cmdlet that should perform the update on this machine + .DESCRIPTION + Added in v2.21, raised in review. Choosing the updater by mere presence sent a + machine whose PowerShellGet is installed but broken - an unregistered PSGallery, + say - straight back to Update-Script, even though discovery had just succeeded + through PSResourceGet. The provider that answered is evidence about which one + works, so it goes first; the other remains as a fallback for the case where the + answering provider has no updater available. + Returns the command name, or $null when neither exists. + #> + param([AllowNull()][string]$Provider) + + $order = if ($Provider -eq 'PSResourceGet') { @('Update-PSResource', 'Update-Script') } + else { @('Update-Script', 'Update-PSResource') } + + foreach ($command in $order) { + if (Get-Command $command -ErrorAction SilentlyContinue) { return $command } + } + return $null +} + +function Wait-ForKeyPress { + <# + .SYNOPSIS + Best-effort "press any key" pause for the update prompts + .DESCRIPTION + Split out in v2.21 for two reasons. It centralises the guard - Test-InteractiveConsole + can be satisfied by a host whose RawUI still refuses ReadKey, and an exception there + must never abort a maintenance run or a COMPLETED update. + It also makes the interactive branches testable at all: RawUI.ReadKey blocks on a + real console, so a test that reached it hung the whole suite until it was killed. + A named function can be mocked; a method call on $Host cannot. + #> + # The failure is recorded rather than erased: at the non-Gallery prompt this pause is + # what gives the user time to read the instruction the whole release exists to deliver, + # and a host that refuses ReadKey turns it into a no-op that scrolls past (raised in + # review). DETAIL, because it changes nothing about the run's outcome. + try { $null = $Host.UI.RawUI.ReadKey("NoEcho,IncludeKeyDown") } + catch { Write-Log "Console did not accept a keypress, continuing without the pause: $_" -Level DETAIL } +} + +function Get-UpdateInstruction { + <# + .SYNOPSIS + The correct way to update the copy identified by Get-ScriptUpdateChannel + .DESCRIPTION + Split out in v2.21 so the advice can be tested. The old code had exactly two + messages, and the one shown to every non-Gallery copy advised + "Install-Script -Name WinClean" - which installs a SECOND copy in Documents and + leaves the running one untouched. That is not a hint that fails to help; it + builds the two-installation state this release exists to stop misreporting. + Returns the lines to print. + #> + param( + [AllowNull()][string]$Channel, + [AllowNull()][string]$ExecutingPath, + [AllowNull()][string]$Provider + ) + + $installer = ' irm https://raw.githubusercontent.com/bivlked/WinClean/main/install.ps1 | iex' + + # Listing commands for the two-installation cases. Both providers are shown because + # either can report the other's install; whichever module is absent simply reports an + # unknown command, which is why the caller is told to expect that (raised in review - + # -ErrorAction cannot suppress a missing command, only a failing one). + $inspect = @( + ' Get-InstalledScript -Name WinClean -ErrorAction SilentlyContinue |', + ' Select-Object Version, InstalledLocation', + ' Get-PSResource -Name WinClean -ErrorAction SilentlyContinue |', + ' Where-Object { $_.Type -eq ''Script'' } | Select-Object Version, InstalledLocation', + ' (one of the two may not exist on this machine - that is expected)' + ) + $running = if ([string]::IsNullOrWhiteSpace($ExecutingPath)) { @() } + else { @(" The file you are running now is:", " $ExecutingPath") } + + switch ($Channel) { + 'gallery' { + # Name the command this machine actually has, preferring the provider that + # discovery just proved works (raised in review). Advising Update-Script on a + # PSResourceGet-only machine is advice that cannot be run; naming either one + # where NEITHER exists is the same mistake twice. + $manual = Select-UpdateCommand -Provider $Provider + if ($manual) { return @(" To update manually: $manual -Name WinClean") } + # "no update command is available" rather than "no provider is installed": + # Get-Command proves the former, and a module that fails to auto-load looks + # identical to one that is absent + return @( + ' No PowerShell update command is available, so this copy cannot update itself.', + ' Download the latest release: https://github.com/bivlked/WinClean/releases/latest' + ) + } + 'gallery-ambiguous' { + # Several Gallery installs exist and Update-Script cannot be aimed at one of + # them, so WinClean declines to touch any (raised in review). Naming the running + # path matters here: it is the only way the reader can tell the copies apart. + # Must end with something the reader can actually do (raised in review): + # removing an install is only safe when the copies differ, so the always-works + # answer - replace the printed file from the release - is stated first. + return @( + ' Several PowerShell Gallery installations of WinClean are present, and WinClean', + ' cannot tell which one an automatic update would change - so it did not try.' + ) + $running + @( + ' Update it directly by replacing that file with the latest release:', + ' https://github.com/bivlked/WinClean/releases/latest', + ' To stop this recurring, list the installations and remove the ones you do not run:' + ) + $inspect + } + 'gallery-unverified' { + # Shown after an update that reported success but left the running file at the + # old version. Raised in review: this branch used to print the 'manual' advice, + # telling a copy that IS Gallery-managed that it is not, and pointing it at + # install.ps1 - which would add a second installation, the very state that made + # the update target the wrong file in the first place. + return @( + ' The provider may have updated a different installation than the one you are running.' + ) + $running + @( + ' List the copies present and compare their locations with the path above:' + ) + $inspect + @( + ' Or download the latest release manually: https://github.com/bivlked/WinClean/releases/latest' + ) + } + 'installer' { + # Describes where the file is, not who put it there (the header of + # Get-ScriptUpdateChannel says the same): the instruction is right either way, + # because re-running the installer replaces exactly this location + return @( + ' This copy lives where install.ps1 installs. Update it by re-running the installer', + ' in an elevated terminal:', + $installer + ) + } + 'oneliner' { + return @( + ' This copy is running from a temporary folder, which is where get.ps1 puts the', + ' release it downloads - and it downloads the latest one every time:', + ' irm https://raw.githubusercontent.com/bivlked/WinClean/main/get.ps1 | iex' + ) + } + default { + # 'manual', 'unknown' and anything unforeseen: never advise Install-Script, + # which would add a copy instead of updating this one. + # States what was observed, not where the file came from (raised in review): + # the location list can also be short because a provider could not be read, and + # asserting provenance on that basis is a claim the code cannot back. + $lead = if ($Channel -eq 'unknown') { + ' The location of this copy could not be determined, so it cannot update itself.' + } else { + ' This copy does not match a PowerShell Gallery installation, so it cannot update itself.' + } + return @( + $lead, + ' Install it properly (creates an elevated desktop shortcut, updates in place):', + $installer, + ' Or download the release manually: https://github.com/bivlked/WinClean/releases/latest' + ) + } + } +} + +function Find-GalleryWinClean { + <# + .SYNOPSIS + Asks the PowerShell Gallery for the latest published WinClean + .DESCRIPTION + Added in v2.21, raised in review. Discovery called Find-Script unconditionally, + which is a PowerShellGet command. On a machine carrying only PSResourceGet - the + exact configuration the updater fallback below exists for - that command does not + exist, discovery threw, the surrounding catch turned it into "no update available", + and the entire update path was dead while every test around it passed. A fallback + that cannot be reached is not a fallback. + Returns Version, ReleaseNotes and Provider ('PowerShellGet' | 'PSResourceGet'), or + $null when the providers answered and the Gallery has nothing. THROWS when every + provider that exists failed - "could not ask" is not an answer, and swallowing it + made an unregistered repository look identical to "you are up to date". + Provider is carried because it is evidence: the one that just answered is known to + work, and the updater should not then be chosen by mere presence and land on the + one that failed. + #> + # Each provider is tried on its own merits (raised in review): falling back only when + # a command is ABSENT leaves a present-but-broken PowerShellGet - an unregistered + # PSGallery, say - masking a PSResourceGet that would have answered. Each keeps its own + # repository registration, so one failing says nothing about the other. Discovery is + # read-only, so trying both costs nothing but a round trip. + $found = $null + $provider = $null + $failures = @() + $answered = $false + + if (Get-Command Find-Script -ErrorAction SilentlyContinue) { + try { + $found = @(Find-Script -Name 'WinClean' -Repository PSGallery -ErrorAction Stop)[0] + $answered = $true + if ($found) { $provider = 'PowerShellGet' } + } catch { $failures += $_; $found = $null } + } + if (-not $found -and (Get-Command Find-PSResource -ErrorAction SilentlyContinue)) { + # Filtered to scripts: a module sharing the name would otherwise set the version. + # [0] of an empty filtered array is $null, which is the intended "nothing found". + # The provider returns the latest matching version, so no sorting is done here. + try { + $found = @(Find-PSResource -Name 'WinClean' -Repository PSGallery -ErrorAction Stop | + Where-Object { $_.Type -eq 'Script' })[0] + $answered = $true + if ($found) { $provider = 'PSResourceGet' } + } catch { $failures += $_; $found = $null } + } + + # "Could not ask" must not look like "asked, nothing newer" (raised in review - this + # was a regression introduced by the per-provider catches above). Before them, a failing + # Find-Script threw all the way to the caller, which logged a warning; swallowing it + # here made an unregistered PSGallery, a TLS or proxy failure and an unpublished script + # all read as "you are up to date", with nothing in the log at all. + # Keyed on "nobody answered", not on "somebody failed" (also raised in review): with a + # broken PowerShellGet beside a working PSResourceGet, a failure count above zero would + # turn a perfectly good answer into a warning on every run. + if (-not $answered -and $failures.Count -gt 0) { + throw "the PowerShell Gallery could not be queried: $($failures[-1])" + } + + if (-not $found) { return $null } + return @{ Version = $found.Version; ReleaseNotes = $found.ReleaseNotes; Provider = $provider } +} + +function Test-ScriptUpdate { + <# + .SYNOPSIS + Проверяет наличие обновлений WinClean в PowerShell Gallery + .DESCRIPTION + Сравнивает текущую версию скрипта с последней версией в PowerShell Gallery. + Определяет, каким способом можно обновить ИМЕННО выполняемую копию (v2.21). + .OUTPUTS + [hashtable] с информацией об обновлении или $null если обновление не требуется + #> + # Check if we can reach PSGallery + if (-not (Test-PSGalleryConnection)) { + return $null + } + + try { + $currentVersion = [Version]$script:Version + + # Query PSGallery for latest version, through whichever provider this machine has + $galleryScript = Find-GalleryWinClean + if (-not $galleryScript) { return $null } + $latestVersion = [Version]$galleryScript.Version + + if ($latestVersion -gt $currentVersion) { + # v2.21: which copy is running decides what can be offered, not whether a + # Gallery copy exists somewhere on the machine + return @{ + CurrentVersion = $currentVersion.ToString() + LatestVersion = $latestVersion.ToString() + Channel = Get-ScriptUpdateChannel -ExecutingPath $PSCommandPath ` + -GalleryLocation (Get-InstalledWinCleanLocation) + Provider = $galleryScript.Provider + ReleaseNotes = $galleryScript.ReleaseNotes + } + } + } catch { + # Counted (raised in review): Write-Log does not touch the counters - every warning + # in this file increments one by hand - so this one was invisible in the summary and + # in the result JSON. The "silently fail" comment it replaces had outlived the code: + # the level was already WARNING, and the try now covers channel classification, + # provider lookup and two [Version] casts, any of which can throw. + Write-Log "Update check failed: $_" -Level WARNING + $script:Stats.WarningsCount++ + } + + return $null +} + +function Invoke-ScriptUpdate { + <# + .SYNOPSIS + Предлагает пользователю обновить WinClean и выполняет обновление при подтверждении + .PARAMETER UpdateInfo + Хэштаблица с информацией об обновлении от Test-ScriptUpdate + #> + param( + [Parameter(Mandatory)] + [hashtable]$UpdateInfo + ) + + # Dynamically centered title in a 70-char box (matches the rest of the UI; v2.14 + # fixes a misaligned right border caused by hardcoded padding) + $boxWidth = $script:BoxWidth + $updateTitle = "UPDATE AVAILABLE" + $titlePadding = [math]::Max(0, $boxWidth - $updateTitle.Length) + $titleLeftPad = [math]::Floor($titlePadding / 2) + + Write-Host "" + Write-Host " ╔$("═" * $boxWidth)╗" -ForegroundColor Cyan + Write-Host " ║$(" " * $titleLeftPad)" -NoNewline -ForegroundColor Cyan + Write-Host $updateTitle -NoNewline -ForegroundColor Yellow + Write-Host "$(" " * ($titlePadding - $titleLeftPad))║" -ForegroundColor Cyan + Write-Host " ╚$("═" * $boxWidth)╝" -ForegroundColor Cyan + Write-Host "" + Write-Host " Current version: " -NoNewline -ForegroundColor Gray + Write-Host "v$($UpdateInfo.CurrentVersion)" -ForegroundColor White + Write-Host " Latest version: " -NoNewline -ForegroundColor Gray + Write-Host "v$($UpdateInfo.LatestVersion)" -NoNewline -ForegroundColor Green + Write-Host " (new)" -ForegroundColor DarkGreen + Write-Host "" + + # The channel belongs in the log line, not only on screen (raised in review): the two + # automatic paths below print their advice with Write-Host alone, so a scheduled or CI + # run recorded that an update existed and nothing about which copy was running or what + # was advised - the one fact this release is about. + Write-Log "Update available: v$($UpdateInfo.CurrentVersion) -> v$($UpdateInfo.LatestVersion) (channel: $($UpdateInfo.Channel))" -Level INFO + + # In ReportOnly mode, just inform and continue + if ($ReportOnly) { + Write-Log "ReportOnly mode - no update attempted" -Level INFO + Write-Host " ReportOnly mode - skipping update" -ForegroundColor DarkGray + # Raised in review: the applicable method is still worth naming here. A preview run + # is often exactly when someone is deciding how to update, and printing nothing + # contradicted the documented promise that WinClean names the option that applies. + foreach ($line in (Get-UpdateInstruction -Channel $UpdateInfo.Channel -ExecutingPath $PSCommandPath -Provider $UpdateInfo.Provider)) { + Write-Host $line -ForegroundColor Gray + } + Write-Host "" + return $false + } + + # Check if interactive console is available + if (-not (Test-InteractiveConsole)) { + Write-Log "Non-interactive mode - no update attempted" -Level INFO + Write-Host " Non-interactive mode - skipping update prompt" -ForegroundColor DarkGray + # v2.21: the instruction now follows the running copy. It used to name + # Update-Script unconditionally, which does nothing for the copy in + # %ProgramFiles% that the desktop shortcut starts. + foreach ($line in (Get-UpdateInstruction -Channel $UpdateInfo.Channel -ExecutingPath $PSCommandPath -Provider $UpdateInfo.Provider)) { + Write-Host $line -ForegroundColor Gray + } + Write-Host "" + return $false + } + + if ($UpdateInfo.Channel -ne 'gallery') { + # Anything but a single unambiguous Gallery copy: say what applies to THIS file and + # continue. 'gallery-ambiguous' deliberately lands here too - it IS a Gallery copy, + # but with several installs present no updater can be aimed at this one. + # The wording must not contradict the channel (raised in review): a + # 'gallery-ambiguous' copy IS Gallery-managed - that is precisely why it is here. + # 'unknown' exists precisely to promise nothing, so the log must not promise either + # (raised in review): it used to state flatly that the copy is not Gallery-managed + # while the console, two lines later, said its location could not be determined. + $why = switch ($UpdateInfo.Channel) { + 'gallery-ambiguous' { "several Gallery installations exist and WinClean does not resolve which one an update would change" } + 'unknown' { "the location of the running copy could not be determined" } + default { "this copy does not match a Gallery installation" } + } + Write-Log "Update available but $why (channel: $($UpdateInfo.Channel))" -Level INFO + foreach ($line in (Get-UpdateInstruction -Channel $UpdateInfo.Channel -ExecutingPath $PSCommandPath -Provider $UpdateInfo.Provider)) { + Write-Host $line -ForegroundColor Gray + } + Write-Host "" + Write-Host " Press any key to continue with current version..." -ForegroundColor DarkGray + Wait-ForKeyPress + Write-Host "" + return $false + } + + # The running file is the Gallery copy, so updating it updates what runs next + Write-Host " Update now? (" -NoNewline -ForegroundColor Gray + Write-Host "Y" -NoNewline -ForegroundColor Green + Write-Host "/n): " -NoNewline -ForegroundColor Gray + + $response = Read-Host + if ($response -ne '' -and $response -inotmatch '^[YyДд]') { + Write-Log "Update skipped by user" -Level INFO + Write-Host " Update skipped. Continuing with current version..." -ForegroundColor DarkGray + Write-Host "" + return $false + } + + Write-Host "" + Write-Host " Updating WinClean..." -ForegroundColor Cyan + + try { + # PowerShellGet ships with PowerShell today and PSResourceGet is its replacement; + # either can be the one present, and the one that answered discovery goes first + # $null = ... deliberately: v2.22 made this function's return value meaningful + # ("the run is over"), and a bare switch emits whatever the update provider writes + # to the pipeline. A provider that returned an object would turn $true into an + # array, and `if (Invoke-ScriptUpdate ...)` would then be deciding on the array's + # truthiness rather than on the answer this function meant to give. + $null = switch (Select-UpdateCommand -Provider $UpdateInfo.Provider) { + 'Update-Script' { Update-Script -Name WinClean -Force -ErrorAction Stop } + 'Update-PSResource' { Update-PSResource -Name WinClean -Force -TrustRepository -ErrorAction Stop } + default { throw "no update command available (neither Update-Script nor Update-PSResource)" } + } + } catch { + # A warning, not an error (raised in review): the exit code is computed from + # ErrorsCount alone, and the old level/counter pairing logged ERROR while still + # exiting 0 - a contradiction in the one place that must be believable. Failing to + # update the script is not a failure of the maintenance the user actually asked for. + Write-Log "Update failed: $_" -Level WARNING + $script:Stats.WarningsCount++ + Write-Host " ✗ Update failed: $_" -ForegroundColor Red + Write-Host " Continuing with current version..." -ForegroundColor Yellow + Write-Host "" + return $false + } + + # v2.21: verify against the file being executed. A provider that reports success + # while the running file stays at the old version is the exact failure this release + # fixes, and it must not be re-announced as "update complete". + $observedVersion = Get-ScriptFileVersion -Path $PSCommandPath + $verification = Get-UpdateVerification -ExpectedVersion $UpdateInfo.LatestVersion ` + -ObservedVersion $observedVersion + if (-not $verification.Applied) { + # Report what was actually read, not what this process started as: with several + # installations present those two can differ, and naming the wrong one would send + # the reader looking at the wrong file (raised in review). + $detail = if ($verification.Reason -eq 'unchanged') { + "the file still reports v$observedVersion" + } else { + "its version could not be read back" + } + Write-Log "Update reported success but $detail - continuing with the current version" -Level WARNING + $script:Stats.WarningsCount++ + Write-Host " ! The update reported success, but $detail." -ForegroundColor Yellow + foreach ($line in (Get-UpdateInstruction -Channel 'gallery-unverified' -ExecutingPath $PSCommandPath)) { + Write-Host $line -ForegroundColor Gray + } + Write-Host "" + return $false + } + + Write-Log "Update successful" -Level SUCCESS + Write-Host "" + Write-Host " ✓ Update complete!" -ForegroundColor Green + Write-Host " Please run WinClean again to use the new version." -ForegroundColor Gray + Write-Host "" + + # v2.22: this used to call exit here, which bypassed the finally in Start-WinClean and + # therefore had to hand-copy the result JSON write and the exit-code rule alongside it. + # The caller now owns ending the run, through the one path that does it (raised in + # external review). The exit code is unchanged: the entry point already derives it from + # ErrorsCount, which is what the copied lines here were re-deriving. + # + # The "press any key" pause deliberately does NOT happen here (raised in the second + # review pass): it blocks indefinitely, and in v2.21 the result JSON was already on + # disk before it. Pausing here would put an unbounded wait between the update and the + # artefacts, so a window closed or interrupted at that prompt would leave the run with + # no result JSON and an unreleased log handle - a regression introduced by this very + # refactor. The caller pauses after the run is complete. + $script:Stats.Aborted = 'UpdatedAndExited' + return $true +} + +function Install-ModuleWithTimeout { + <# + .SYNOPSIS + Устанавливает PowerShell модуль с таймаутом + .DESCRIPTION + Использует Background Job для установки модуля с возможностью прервать + операцию по таймауту. Решает проблему бесконечного зависания Install-Module. + .PARAMETER ModuleName + Имя модуля для установки + .PARAMETER TimeoutSeconds + Таймаут в секундах (по умолчанию 120) + .OUTPUTS + [bool] $true если модуль успешно установлен, $false при ошибке/таймауте + #> + param( + [Parameter(Mandatory)] + [string]$ModuleName, + + [int]$TimeoutSeconds = 120 + ) + + $job = Start-Job -ScriptBlock { + param($moduleName) + # Set TLS 1.2 in the job process as well + [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 + Install-Module -Name $moduleName -Force -Scope CurrentUser -AllowClobber -SkipPublisherCheck -ErrorAction Stop + } -ArgumentList $ModuleName + + $completed = Wait-Job $job -Timeout $TimeoutSeconds + + if ($completed) { + $jobState = $job.State + $jobError = $null + + try { + Receive-Job $job -ErrorAction Stop + } catch { + $jobError = $_ + } + + Remove-Job $job -Force + + if ($jobState -eq 'Completed' -and -not $jobError) { + return $true + } else { + if ($jobError) { + Write-Log "Module installation failed: $jobError" -Level ERROR + } + return $false + } + } else { + # Timeout - kill the job + Stop-Job $job -ErrorAction SilentlyContinue + Remove-Job $job -Force + Write-Log "Module installation timed out after $TimeoutSeconds seconds" -Level ERROR + return $false + } +} + +function Install-PackageProviderWithTimeout { + <# + .SYNOPSIS + Устанавливает PackageProvider с таймаутом + .DESCRIPTION + Аналогично Install-ModuleWithTimeout, но для Install-PackageProvider + .PARAMETER ProviderName + Имя провайдера (обычно NuGet) + .PARAMETER TimeoutSeconds + Таймаут в секундах (по умолчанию 60) + .OUTPUTS + [bool] $true если провайдер успешно установлен, $false при ошибке/таймауте + #> + param( + [Parameter(Mandatory)] + [string]$ProviderName, + + [string]$MinimumVersion = "2.8.5.201", + + [int]$TimeoutSeconds = 60 + ) + + $job = Start-Job -ScriptBlock { + param($providerName, $minVersion) + [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 + Install-PackageProvider -Name $providerName -MinimumVersion $minVersion -Force -ErrorAction Stop + } -ArgumentList $ProviderName, $MinimumVersion + + $completed = Wait-Job $job -Timeout $TimeoutSeconds + + if ($completed) { + $jobState = $job.State + $jobError = $null + + try { + Receive-Job $job -ErrorAction Stop | Out-Null + } catch { + $jobError = $_ + } + + Remove-Job $job -Force + + if ($jobState -eq 'Completed' -and -not $jobError) { + return $true + } else { + if ($jobError) { + Write-Log "Package provider installation failed: $jobError" -Level ERROR + } + return $false + } + } else { + Stop-Job $job -ErrorAction SilentlyContinue + Remove-Job $job -Force + Write-Log "Package provider installation timed out after $TimeoutSeconds seconds" -Level ERROR + return $false + } +} + +function Get-WindowsUpdateWithTimeout { + <# + .SYNOPSIS + Runs a Get-WindowsUpdate search in a background job with a timeout + .DESCRIPTION + v2.17 (p.15 of the audit): a hung WU agent call used to hang the whole script + forever - fatal for an unattended nightly stand run with no one to notice. + Read-only search, so killing the job on timeout is safe: there is nothing to + roll back. -ErrorVariable does not cross the job boundary, so the job captures + its own error and returns it as a plain string instead. + .PARAMETER CategoryParamName + 'Category' or 'NotCategory' - which Get-WindowsUpdate parameter to use + .PARAMETER CategoryValue + Value for that parameter (e.g. "Drivers") + #> + param( + [Parameter(Mandatory)] + [ValidateSet('Category', 'NotCategory')] + [string]$CategoryParamName, + + [Parameter(Mandatory)] + [string]$CategoryValue, + + [int]$TimeoutSeconds = 300 + ) + + $job = Start-Job -ScriptBlock { + param($categoryParamName, $categoryValue) + Import-Module PSWindowsUpdate -ErrorAction Stop + $errs = $null + $params = @{ MicrosoftUpdate = $true; ErrorAction = 'SilentlyContinue'; ErrorVariable = 'errs' } + $params[$categoryParamName] = $categoryValue + $updates = @(Get-WindowsUpdate @params) + [PSCustomObject]@{ + Updates = $updates + FirstError = if ($errs) { $errs[0].ToString() } else { $null } + } + } -ArgumentList $CategoryParamName, $CategoryValue + + $completed = Wait-Job $job -Timeout $TimeoutSeconds + if (-not $completed) { + Stop-Job $job -ErrorAction SilentlyContinue + Remove-Job $job -Force -ErrorAction SilentlyContinue + return [PSCustomObject]@{ + Updates = @() + FirstError = "search timed out after $TimeoutSeconds seconds" + } + } + + $jobError = $null + $output = $null + try { + $output = Receive-Job $job -ErrorAction Stop + } catch { + $jobError = $_ + } + Remove-Job $job -Force -ErrorAction SilentlyContinue + + if ($jobError -or -not $output) { + return [PSCustomObject]@{ + Updates = @() + FirstError = if ($jobError) { $jobError.ToString() } else { 'search job returned no output' } + } + } + return $output +} + +function Test-PendingReboot { + <# + .SYNOPSIS + Checks if Windows has a pending reboot from previous operations + .DESCRIPTION + Checks multiple registry locations and system flags to determine + if a reboot is pending from Windows Update, CBS, file rename operations, etc. + #> + $rebootRequired = $false + $reasons = @() + + # Check Windows Update reboot flag + $wuKey = "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\WindowsUpdate\Auto Update\RebootRequired" + if (Test-Path $wuKey) { + $rebootRequired = $true + $reasons += "Windows Update" + } + + # Check Component-Based Servicing + $cbsKey = "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Component Based Servicing\RebootPending" + if (Test-Path $cbsKey) { + $rebootRequired = $true + $reasons += "Component Servicing" + } + + # Check Pending File Rename Operations + $pfroKey = "HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager" + try { + $pfroValue = Get-ItemProperty -Path $pfroKey -Name PendingFileRenameOperations -ErrorAction SilentlyContinue + if ($pfroValue.PendingFileRenameOperations) { + $rebootRequired = $true + $reasons += "File Rename Operations" + } + } catch { } + + # Check if Computer Rename is pending + $compNameKey = "HKLM:\SYSTEM\CurrentControlSet\Control\ComputerName" + try { + $activeName = (Get-ItemProperty "$compNameKey\ActiveComputerName" -ErrorAction SilentlyContinue).ComputerName + $pendingName = (Get-ItemProperty "$compNameKey\ComputerName" -ErrorAction SilentlyContinue).ComputerName + if ($activeName -ne $pendingName) { + $rebootRequired = $true + $reasons += "Computer Rename" + } + } catch { } + + return @{ + RebootRequired = $rebootRequired + Reasons = $reasons + } +} + +function Get-FolderSize { + <# + .SYNOPSIS + Calculates folder size in bytes + .DESCRIPTION + v2.17 (p.2 of the audit): Get-ChildItem wraps every single file in a full + PSObject (ETS properties, formatting metadata) just to read one Length value - + expensive on folders with tens of thousands of small files (npm/pip caches, + the driver store). Walks the tree with the raw .NET enumerator instead. + IgnoreInaccessible mirrors the old -ErrorAction SilentlyContinue tolerance. + AttributesToSkip=ReparsePoint is a deliberate refinement, not just a port: + following junctions/symlinks while summing could double-count the same bytes + (WinSxS-style hardlink dedup) or loop on a cyclic junction - the old + Get-ChildItem -Recurse call had no equivalent guard. + #> + param([string]$Path) + + if (-not (Test-Path -LiteralPath $Path -ErrorAction SilentlyContinue)) { + return 0 + } + + try { + $options = [System.IO.EnumerationOptions]::new() + $options.RecurseSubdirectories = $true + $options.IgnoreInaccessible = $true + $options.AttributesToSkip = [System.IO.FileAttributes]::ReparsePoint + + $total = 0L + foreach ($file in [System.IO.Directory]::EnumerateFiles($Path, '*', $options)) { + try { + $total += [System.IO.FileInfo]::new($file).Length + } catch { } # vanished between enumeration and the Length read - skip it + } + return $total + } catch { + return 0 + } +} + +function Get-FolderSizeChecked { + <# + .SYNOPSIS + Like Get-FolderSize, but distinguishes "empty" from "could not measure" + .DESCRIPTION + v2.17 (p.10 of the audit): Get-FolderSize returns 0 on ANY access error, which + Show-DiskSpaceReport read as "nothing above 100 MB" when the true answer was + "could not check". Returns $null when Get-ChildItem hit access errors while + walking the tree, 0 only when the path is genuinely absent or truly empty. + #> + param([string]$Path) + + # v2.18: Test-Path returns $false for BOTH "absent" and "present but access-denied", + # so an unreadable folder used to report 0 (= "empty") instead of $null (= "could not + # measure"). GetAttributes throws a *NotFound* exception only when the path is truly + # absent; an access error surfaces as a different exception and must yield $null. + try { + $null = [System.IO.File]::GetAttributes($Path) + } catch [System.IO.FileNotFoundException], [System.IO.DirectoryNotFoundException] { + return 0 + } catch { + return $null + } + + $walkErrors = $null + $items = Get-ChildItem -LiteralPath $Path -Recurse -Force -File ` + -ErrorAction SilentlyContinue -ErrorVariable walkErrors + if ($walkErrors) { + return $null + } + + $sum = ($items | Measure-Object -Property Length -Sum).Sum + return [long]($sum ?? 0) +} + +function Format-FileSize { + <# + .SYNOPSIS + Formats bytes to human-readable size + #> + param([long]$Bytes) + + # Invariant culture (v2.17): with "{0:N2}" on ru-RU the group separator is a + # NO-BREAK space, which mixes locales in the log and quietly breaks anything that + # parses our own output (smoke test, stand assertions, ConvertFrom-HumanReadableSize) + $inv = [cultureinfo]::InvariantCulture + if ($Bytes -lt 0) { return "-" + (Format-FileSize (-$Bytes)) } + if ($Bytes -ge 1TB) { return [string]::Format($inv, "{0:N2} TB", $Bytes / 1TB) } + if ($Bytes -ge 1GB) { return [string]::Format($inv, "{0:N2} GB", $Bytes / 1GB) } + if ($Bytes -ge 1MB) { return [string]::Format($inv, "{0:N2} MB", $Bytes / 1MB) } + if ($Bytes -ge 1KB) { return [string]::Format($inv, "{0:N2} KB", $Bytes / 1KB) } + return "$Bytes B" +} + +function ConvertFrom-HumanReadableSize { + <# + .SYNOPSIS + Converts human-readable size string to bytes (inverse of Format-FileSize) + .DESCRIPTION + v2.17 (p.17 of the audit) widened localization handling. Previously failed on: + a space-grouped thousands separator (it sat INSIDE the numeric group, which the + old regex did not allow), "1.234,5" EU-style dot-thousands/comma-decimal (threw + an unhandled exception instead of returning 0), the word form of bytes, and + "MiB"/"GiB"/etc binary-unit spelling (this script's own *B literals are already + 1024-based, so the multiplier is identical to KB/MB/GB/TB). + v2.20: a lone separator is no longer assumed to be the decimal point. The grouping + SHAPE decides first, and only a string that could honestly be read either way is + settled by the culture - so the result for such a string is culture-dependent. + .PARAMETER SizeString + The text to convert, e.g. "2.5 GB", "1,234 KB", "816 КБ". + .PARAMETER Culture + Consulted only for an ambiguous grouping such as "1,234", where en-US means 1234 + and ru-RU means 1.234. Defaults to the current culture, which is what the Shell + used to format the string this function's only caller parses. Shapes that cannot + be a grouping ("1,5", "1,2345") are decimal in every culture. + .EXAMPLE + ConvertFrom-HumanReadableSize "2.5 GB" # Returns 2684354560 + ConvertFrom-HumanReadableSize "512MB" # Returns 536870912 + .EXAMPLE + ConvertFrom-HumanReadableSize "1,234 KB" -Culture ([cultureinfo]'en-US') # 1263616 + ConvertFrom-HumanReadableSize "1,234 KB" -Culture ([cultureinfo]'ru-RU') # 1264 + #> + param( + [string]$SizeString, + # Only consulted for a genuinely ambiguous string (see the disambiguation below). + # Injectable so the rule can be tested without changing the machine's locale. + [cultureinfo]$Culture = [cultureinfo]::CurrentCulture + ) + + if (-not $SizeString) { return 0 } + + # Drop all whitespace outright (including no-break/thin-space variants) - it only + # ever separates thousands groups or sits between the number and the unit, never + # meaningful data. + $normalized = ($SizeString -replace '[\u00A0\u202F\u2007\s]', '') + $normalized = $normalized -ireplace 'байт(а|ов)?$', 'B' -ireplace 'bytes?$', 'B' -replace 'ТБ$', 'TB' -replace 'ГБ$', 'GB' -replace 'МБ$', 'MB' -replace 'КБ$', 'KB' -replace 'Б$', 'B' + $normalized = $normalized -ireplace 'KiB$', 'KB' -ireplace 'MiB$', 'MB' -ireplace 'GiB$', 'GB' -ireplace 'TiB$', 'TB' + + # Handle formats: "2.5GB", "512MB", "100.5MB", "1234.5MB", "1234,5MB" (whitespace + # already stripped above, so no \s* needed between the number and the unit) + if ($normalized -notmatch '^([\d.,]+)([KMGT]?B)$') { + return 0 + } + + $numberPart = $Matches[1] + $unit = $Matches[2].ToUpper() + + # Decimal-separator disambiguation. + # + # When BOTH marks appear the answer is certain: whichever comes LAST is the decimal + # point and the earlier one was grouping ("1.234,5" EU against "1,234.5" US). + # + # A LONE mark is the hard case, and v2.20 is where it was fixed. The old rule was + # "a lone mark is the decimal point", which read the ordinary en-US thousands form + # "1,234 KB" as 1.234 KB - low by a factor of a thousand, on the shell fallback that + # measures the Recycle Bin. + # The obvious repair is worse. Handing the string to [double]::TryParse with the + # current culture looks right and is not: measured on .NET, AllowThousands does NOT + # validate the grouping shape, so en-US reads "1,5" as 15 and "1,2345" as 12345 - + # trading a 1000x under-read for a 10x over-read, and breaking "1,5 GB". + # So the SHAPE is checked here first, and the culture is consulted only for a string + # that could honestly be either reading. + $lastComma = $numberPart.LastIndexOf(',') + $lastDot = $numberPart.LastIndexOf('.') + + if ($lastComma -ge 0 -and $lastDot -ge 0) { + if ($lastComma -gt $lastDot) { + $numberPart = $numberPart.Replace('.', '').Replace(',', '.') + } else { + $numberPart = $numberPart.Replace(',', '') + } + } elseif ($lastComma -ge 0 -or $lastDot -ge 0) { + $sep = if ($lastComma -ge 0) { ',' } else { '.' } + + # A thousands grouping is "1-3 digits, then one or more groups of exactly 3". + # "1,5" and "1,2345" cannot be that, so there the mark is the decimal point and + # no culture can argue otherwise. + if ($numberPart -match "^\d{1,3}($([regex]::Escape($sep))\d{3})+$") { + $isDecimal = $Culture.NumberFormat.NumberDecimalSeparator -eq $sep + if (-not $isDecimal -and $Culture.NumberFormat.NumberGroupSeparator -ne $sep) { + # The culture uses this mark for neither purpose - ru-RU groups with a + # no-break space and would call a lone dot meaningless. Our own + # Format-FileSize writes invariant text, so fall back to reading it that + # way: a dot is the decimal point, a comma is grouping. + $isDecimal = ($sep -eq '.') + } + if ($isDecimal) { + $numberPart = $numberPart.Replace(',', '.') + } else { + $numberPart = $numberPart.Replace($sep, '') + } + } else { + $numberPart = $numberPart.Replace(',', '.') + } + } + + $multiplier = switch ($unit) { + 'B' { 1 } + 'KB' { 1KB } + 'MB' { 1MB } + 'GB' { 1GB } + 'TB' { 1TB } + default { 1 } + } + + try { + return [long]([double]$numberPart * $multiplier) + } catch { + return 0 + } +} + +function Resolve-PathThroughLinks { + <# + .SYNOPSIS + Resolves a path through reparse points at ANY level, not just the last segment + .DESCRIPTION + v2.20, corrected in review. The first version of the link-aware protected-path + check only asked whether the leaf itself was a reparse point. That closed the + obvious case ("C:\cache" is a junction to "C:\") and left the real one open: + "C:\cache\Windows" has no reparse attribute on the leaf, GetFullPath does not + resolve the junction above it, and the textual comparison never matches - measured, + with 120 real C:\Windows children visible through the link. + + So every ancestor is examined, the deepest link found is resolved, and the walk + restarts on the rebuilt path (a resolved target can itself sit under another link). + .OUTPUTS + The fully resolved path, or $null when a link cannot be resolved - the caller must + treat that as "unknown", never as "fine". + #> + param([Parameter(Mandatory)][string]$Path) + + $current = $Path + # Bounded: a link loop would otherwise spin here forever + for ($round = 0; $round -lt 64; $round++) { + # Raised in review: the ancestor walk used to climb past the root of a UNC share. + # Split-Path turns \\server\share into \\server, which is not a filesystem object, + # so Get-Item failed, the fail-closed rule above answered $null, and every UNC + # cleanup root was refused. Nothing above the volume root is ours to inspect. + $rootPath = '' + try { $rootPath = [System.IO.Path]::GetPathRoot($current).TrimEnd('\', '/') } catch { $rootPath = '' } + + $probe = $current + $tail = @() + $changed = $false + + while ($probe) { + # Raised in review: an ancestor that could not be examined used to be silently + # classified as "not a link" and the walk carried on upward, so an unreadable + # junction ancestor answered "safe to empty". That is the half of the guard + # which closes the real attack (C:\cache\Windows where C:\cache is the link), + # and it was the half that failed open. Unknown is not safe. + $item = $null + try { $item = Get-Item -LiteralPath $probe -Force -ErrorAction Stop } catch { return $null } + + if ($item -and ($item.Attributes -band [System.IO.FileAttributes]::ReparsePoint)) { + $target = $null + try { $target = $item.ResolveLinkTarget($true) } catch { $target = $null } + if (-not $target) { return $null } + + $current = if ($tail.Count -gt 0) { + Join-Path $target.FullName ($tail -join [System.IO.Path]::DirectorySeparatorChar) + } else { + $target.FullName + } + $changed = $true + break + } + + $parent = Split-Path $probe -Parent + if (-not $parent -or $parent -eq $probe) { break } + if ($rootPath -and $parent.Length -lt $rootPath.Length) { break } + $tail = ,(Split-Path $probe -Leaf) + $tail + $probe = $parent + } + + # Nothing left to resolve: this is the fully resolved answer + if (-not $changed) { return $current } + } + + # The bound was exhausted while links were still being followed, which means the chain + # could not be resolved. The contract for that is $null. Returning the partially + # resolved path (raised in review) handed Test-PathProtected a value that still + # contained a link and was then judged on its text alone - fail-open in the one + # function this release rewrote to fail closed. + return $null +} + +function Get-RegistryValueCount { + <# + .SYNOPSIS + Counts the real values under a registry key, ignoring PowerShell's own metadata + .DESCRIPTION + v2.20. Privacy cleanup used to announce "cleared" without looking, because + Remove-Item with -ErrorAction SilentlyContinue never throws. Confirming the result + needs a before/after count, and Get-ItemProperty decorates every key with PSPath, + PSParentPath, PSChildName, PSDrive and PSProvider - counting those would make an + emptied key look like it still holds five entries. + + Tri-state on purpose (corrected in review before release): 0 for a key that is + absent or genuinely empty, the count for a readable key, and $null when the key is + there but cannot be read. The first draft returned 0 for unreadable too, which + recreated the very bug it was written to fix: a delete that failed, followed by an + unreadable after-read, would have counted as 0 and been reported as cleared. + .OUTPUTS + [int] the number of values, or $null when the key exists but cannot be read + #> + param([Parameter(Mandatory)][string]$Key) + + try { + $props = Get-ItemProperty -LiteralPath $Key -ErrorAction Stop + } catch [System.Management.Automation.ItemNotFoundException] { + return 0 # not there at all - nothing to clear, and nothing to worry about + } catch { + return $null # there, but unreadable: refuse to answer rather than answer 0 + } + + if (-not $props) { return 0 } + + return @($props.PSObject.Properties | Where-Object { + $_.Name -notin 'PSPath', 'PSParentPath', 'PSChildName', 'PSDrive', 'PSProvider' + }).Count +} + +function Test-PathProtected { + <# + .SYNOPSIS + Checks whether a path must be refused as a bulk-cleanup root (v2.17: normalized, + v2.20: link-aware) + .DESCRIPTION + Guards the roots listed in $script:ProtectedPaths against being emptied. + Paths are resolved with GetFullPath first, otherwise the check is trivially + bypassed by an 8.3 name (C:\PROGRA~1), a "\\?\" prefix, a relative path or + a "C:\Windows\..\Windows" round trip. + + Only the roots themselves are protected, not everything below them: the script + legitimately cleans %SystemRoot%\Temp and other subfolders. Callers that must + never touch a subtree pass explicit paths instead. + + v2.20: the checks above compare TEXT, and GetFullPath does not resolve reparse + points (measured, not assumed). A junction whose visible path looks innocent can + therefore point at a protected root, and enumerating that junction lists the + TARGET's children - deleting them deletes the real files. So an existing link is + resolved to its final target and the same rules are applied to that. + + Only the cleanup ROOT needs this. Links found deeper in the tree are already + harmless: Get-ChildItem -Recurse does not descend into a reparse point, and + Remove-Item on a junction removes the link and leaves the target intact (both + measured on a live filesystem). + .PARAMETER SkipLinkResolution + Internal. Set when re-checking an already-resolved target so a pathological link + chain cannot recurse. + #> + param( + [string]$Path, + [switch]$SkipLinkResolution + ) + + if ([string]::IsNullOrWhiteSpace($Path)) { return $true } # nothing sane to clean + + try { + $fullPath = [System.IO.Path]::GetFullPath($Path) + $normalizedPath = $fullPath.TrimEnd('\', '/') + } catch { + # Unparseable path: refuse rather than guess + return $true + } + + # A volume root is always protected (v2.17). It is not in $ProtectedPaths and would + # otherwise slip through: TEMP set to "C:\" - or an empty variable resolving to a + # root - would hand the entire drive to the cleanup routine, running elevated. + # Note GetFullPath does expand 8.3 names, so "C:\PROGRA~1" is caught by the list below. + try { + $root = [System.IO.Path]::GetPathRoot($fullPath) + if ($root -and ($fullPath.TrimEnd('\', '/') -ieq $root.TrimEnd('\', '/'))) { + return $true + } + } catch { return $true } + + foreach ($protected in $script:ProtectedPaths) { + if ([string]::IsNullOrWhiteSpace($protected)) { continue } + try { + $normalizedProtected = [System.IO.Path]::GetFullPath($protected).TrimEnd('\', '/') + } catch { continue } + + if ($normalizedPath -ieq $normalizedProtected) { + return $true + } + } + + # v2.20: resolve a link root and re-check the real target (see .DESCRIPTION). + # A path that is not there answers $false - there is nothing to delete through it, and + # callers probe optional locations constantly. A path that EXISTS but cannot be + # examined answers $true; the two are decided separately below. + # + # (This comment described the opposite of the code until review caught it: the first + # draft lumped "cannot be inspected" in with "does not exist". A comment asserting a + # safety property the code does not have is how the fail-open bootstrap shipped in + # v2.17, and it ended up copied into SECURITY.md.) + if (-not $SkipLinkResolution) { + # A path that cannot be inspected is not the same as a path that is not there. + # Access denied, an I/O error, a path too long: none of them mean "safe to empty", + # and collapsing them all to "not protected" is fail-open (raised in review). + # Same shape as Get-FolderSizeChecked: not-found is an answer, anything else is not. + try { + $null = Get-Item -LiteralPath $fullPath -Force -ErrorAction Stop + } catch [System.Management.Automation.ItemNotFoundException] { + return $false # nothing there to clean through + } catch [System.IO.DirectoryNotFoundException] { + return $false + } catch [System.IO.FileNotFoundException] { + return $false + } catch [System.Management.Automation.DriveNotFoundException] { + # An unmapped or removed drive is a not-found answer too (raised in review). + # Without this it fell to the refuse arm below, and every cleanup target on a + # removable drive became a "Protected path skipped" WARNING - noise in exactly + # the channel this release uses as its silent-failure alarm. + return $false + } catch { + return $true # exists in some form but cannot be examined - refuse + } + + $resolved = Resolve-PathThroughLinks -Path $fullPath + if (-not $resolved) { + # A link that cannot be resolved: the real target is unknown, so protection + # cannot be verified. Refuse rather than guess. + return $true + } + if ($resolved -ne $fullPath) { + return (Test-PathProtected -Path $resolved -SkipLinkResolution) + } + } + + return $false +} + +function Remove-FolderContent { + <# + .SYNOPSIS + Safely removes folder contents with size tracking + .DESCRIPTION + v2.17 (p.1 of the audit, the single largest performance item in the script): + this used to walk $Path in full three to four times - Get-FolderSize before, + the age filter's own recursive check, the delete, Get-FolderSize after - called + ~35 times per run, including against multi-gigabyte TEMP and + SoftwareDistribution. Now one enumeration pass decides eligibility AND measures + size at the same time (a directory's age check and its size come from the same + recursive Get-ChildItem instead of two separate walks), and after deletion each + candidate is checked individually - Test-Path for "fully gone", a single + Get-FolderSize scoped to just that candidate for "partially gone" (some locked + file inside) - instead of re-walking the whole of $Path a second time. + + -RemoveFolder was removed: it had no caller left (dead since at least v2.16) and + kept a second, untested code path alive through this rewrite for nothing. + #> + param( + [Parameter(Mandatory)] + [string]$Path, + + [Parameter(Mandatory)] + [string]$Category, + + [string]$Description, + + [string[]]$ExcludeFile = @(), + + # v2.16: skip entries younger than N days. Used for TEMP, where deleting + # files of currently running installers/applications breaks them mid-work. + [int]$MinAgeDays = 0 + ) + + # Safety check + if (Test-PathProtected -Path $Path) { + Write-Log "Protected path skipped: $Path" -Level WARNING + return + } + + if (-not (Test-Path -LiteralPath $Path -ErrorAction SilentlyContinue)) { + return + } + + $cutoff = if ($MinAgeDays -gt 0) { (Get-Date).AddDays(-$MinAgeDays) } else { $null } + + # Single top-level enumeration. Eligibility and size are decided together, and used + # by both the report and the real run so "would clean" never promises more than the + # run actually deletes. + $candidates = @() + foreach ($item in (Get-ChildItem -LiteralPath $Path -Force -ErrorAction SilentlyContinue)) { + # Skip excluded files and any directory that contains one. Deliberately + # conservative: a directory holding an excluded file is kept whole rather than + # partially cleaned (safe > thorough here) + $isExcluded = [bool]($ExcludeFile | Where-Object { + $_ -and (($item.FullName -ieq $_) -or + $_.StartsWith($item.FullName + '\', [System.StringComparison]::OrdinalIgnoreCase)) + }) + if ($isExcluded) { continue } + + # v2.18: carry whether Size is a real measurement. Only the no-cutoff directory + # branch below can produce a genuinely unmeasurable size ($null from + # Get-FolderSizeChecked); every other path is measured, an empty dir included. + $measured = $true + + if ($item.PSIsContainer) { + if ($cutoff) { + # BOTH halves are required, and each covers a case the other misses: + # - the directory's own timestamp: an EMPTY fresh directory has no + # descendants to prove anything (a running installer's scratch + # folder looks exactly like this), and a directory written to just + # now can still hold nothing but old files; + # - the recursive walk: a folder's own LastWriteTime does not move + # when a GRANDchild changes, so a fresh file nested deeper would be + # deleted along with its old-looking parent. + # Fail closed throughout: if the subtree cannot be fully read (ACL, + # path length, locked folder), staleness cannot be proven, so the + # directory is kept. + if ($item.LastWriteTime -ge $cutoff) { continue } + + $walkErrors = $null + $children = Get-ChildItem -LiteralPath $item.FullName -Recurse -Force ` + -ErrorAction SilentlyContinue -ErrorVariable walkErrors + if ($walkErrors) { continue } + if ($children | Where-Object { $_.LastWriteTime -ge $cutoff } | Select-Object -First 1) { continue } + # Same walk also gives the size - no second pass needed for it + $size = ($children | Where-Object { -not $_.PSIsContainer } | + Measure-Object -Property Length -Sum).Sum + } else { + # Checked variant: plain Get-FolderSize returns 0 both for "empty" and + # for "could not read", and that 0 would later be reported as freed + # bytes. $null here means "unmeasured" and is now genuinely carried as + # such (Measured=$false) instead of being flattened to a silent 0. + $size = Get-FolderSizeChecked -Path $item.FullName + $measured = ($null -ne $size) + } + } else { + if ($cutoff -and $item.LastWriteTime -ge $cutoff) { continue } + $size = $item.Length + } + + $candidates += [pscustomobject]@{ Item = $item; Size = [long]($size ?? 0); Measured = $measured } + } + + $totalSize = [long](($candidates | Measure-Object -Property Size -Sum).Sum ?? 0) + + if ($ReportOnly) { + if ($totalSize -gt 0 -and $Description) { + Write-Log "Would clean: $Description - $(Format-FileSize $totalSize)" -Level DETAIL + } + # v2.18: an unmeasurable candidate contributes 0 to $totalSize, so a set that is + # entirely unmeasurable would otherwise report nothing at all. Name it instead of + # staying silent (the estimate genuinely excludes these). + $unmeasuredCount = @($candidates | Where-Object { -not $_.Measured }).Count + if ($unmeasuredCount -gt 0 -and $Description) { + Write-Log "$Description - $unmeasuredCount item(s) present but not measurable (excluded from the estimate)" -Level DETAIL + } + return + } + + if ($candidates.Count -eq 0) { + return + } + + try { + $freed = 0 + $unmeasuredRemoved = 0 + foreach ($c in $candidates) { + $item = $c.Item + try { + # Handle read-only files + if ($item.Attributes -band [System.IO.FileAttributes]::ReadOnly) { + $item.Attributes = $item.Attributes -band (-bnot [System.IO.FileAttributes]::ReadOnly) + } + Remove-Item -LiteralPath $item.FullName -Recurse -Force -ErrorAction SilentlyContinue + } catch { } + + if (-not (Test-Path -LiteralPath $item.FullName -ErrorAction SilentlyContinue)) { + # Fully gone. Credit the pre-deletion size only if it was a real + # measurement; an unmeasured directory (v2.18) is removed but its freed + # bytes are unknown, so it is counted apart rather than booked as 0. + if ($c.Measured) { $freed += $c.Size } else { $unmeasuredRemoved++ } + } elseif ($item.PSIsContainer) { + # Partially deleted (some locked file inside) - re-measure only this one + # subtree, not the whole of $Path. Get-FolderSizeChecked, not + # Get-FolderSize: the latter reports 0 both for "empty" and for "could + # not read", and reading that 0 as "nothing left" would credit the whole + # directory as freed while its files are still sitting on disk. + $remaining = Get-FolderSizeChecked -Path $item.FullName + if ($null -ne $remaining) { + $freed += [math]::Max(0, $c.Size - $remaining) + } + # $null: the remainder is unknown, so claim nothing rather than overstate + # v2.18: a directory that was unmeasurable to begin with and only partially + # deleted freed an unknown amount too - count it so it is not silently lost. + if (-not $c.Measured) { $unmeasuredRemoved++ } + } + # A file that still exists (locked) contributes 0 - correctly nothing freed + } + + if ($unmeasuredRemoved -gt 0) { + # Honest about the gap instead of silently understating: these directories + # were deleted but their size could not be measured beforehand. + Write-Log "Removed $unmeasuredRemoved item(s) whose size could not be measured; freed space is underreported for them" -Level DETAIL + } + + if ($freed -gt 0) { + # Update statistics (synchronized hashtable handles thread-safety) + $script:Stats.TotalFreedBytes += $freed + + # Update category (not thread-safe, but acceptable for reporting) + if (-not $script:Stats.FreedByCategory.ContainsKey($Category)) { + $script:Stats.FreedByCategory[$Category] = 0 + } + $script:Stats.FreedByCategory[$Category] += $freed + + if ($Description) { + Write-Log "$Description - $(Format-FileSize $freed)" -Level SUCCESS + } + } elseif ($totalSize -gt 0 -and $Description) { + # v2.16: silence here is indistinguishable from "there was nothing to do". + # Say it out loud - this is exactly how the Controlled Folder Access bug hid: + # deletions were blocked without an error and the log simply stayed quiet. + # Compare explicitly against 'enabled': the field is tri-state, and the + # string 'unknown' is truthy in PowerShell - a plain truthiness test would + # confidently blame Controlled Folder Access for a state never checked + $reason = switch ($script:Stats.ControlledFolderAccess) { + 'enabled' { ' (Controlled Folder Access is enabled and may be blocking it)' } + 'unknown' { ' (files are probably locked, or Controlled Folder Access is blocking it - the check itself failed)' } + default { ' (files are probably locked by a running process)' } + } + Write-Log "$Description - nothing freed, $(Format-FileSize $totalSize) still present$reason" -Level WARNING + $script:Stats.WarningsCount++ + } + } catch { + Write-Log "Error cleaning $Path`: $_" -Level WARNING + $script:Stats.WarningsCount++ + } +} + +function Remove-FilesByPattern { + <# + .SYNOPSIS + Removes files matching a pattern with size tracking + .DESCRIPTION + Handles file patterns (like *.roslynobjectin) that Remove-FolderContent can't handle. + + v2.17 (p.18 of the audit): this was the one delete path in the whole script with + no protected-path check and no age filter - safe today because the only caller + passes a single fixed pattern under %APPDATA%, but that made it a latent risk for + the next caller. Mirrors Remove-FolderContent's guards for consistency. + #> + param( + [Parameter(Mandatory)] + [string]$Pattern, + + [Parameter(Mandatory)] + [string]$Category, + + [string]$Description, + + [int]$MinAgeDays = 0 + ) + + $files = Get-Item -Path $Pattern -ErrorAction SilentlyContinue | Where-Object { -not $_.PSIsContainer } + + if (-not $files) { + return + } + + # Skip anything whose containing folder is itself a protected root, and anything + # younger than the age cutoff (matches Remove-FolderContent's -MinAgeDays intent: + # a file belonging to something still running should not be deleted mid-use) + $cutoff = (Get-Date).AddDays(-$MinAgeDays) + $files = $files | Where-Object { + (-not (Test-PathProtected -Path $_.DirectoryName)) -and + ($MinAgeDays -le 0 -or $_.LastWriteTime -lt $cutoff) + } + + if (-not $files) { + return + } + + $totalSize = ($files | Measure-Object -Property Length -Sum).Sum + $totalSize = [long]($totalSize ?? 0) + + if ($ReportOnly) { + if ($totalSize -gt 0 -and $Description) { + Write-Log "Would clean: $Description - $(Format-FileSize $totalSize)" -Level DETAIL + } + return + } + + $freedSize = 0 + foreach ($file in $files) { + try { + $fileSize = $file.Length + Remove-Item -LiteralPath $file.FullName -Force -ErrorAction SilentlyContinue + if (-not (Test-Path -LiteralPath $file.FullName)) { + $freedSize += $fileSize + } + } catch { } + } + + if ($freedSize -gt 0) { + $script:Stats.TotalFreedBytes += $freedSize + + if (-not $script:Stats.FreedByCategory.ContainsKey($Category)) { + $script:Stats.FreedByCategory[$Category] = 0 + } + $script:Stats.FreedByCategory[$Category] += $freedSize + + if ($Description) { + Write-Log "$Description - $(Format-FileSize $freedSize)" -Level SUCCESS + } + } +} + +function New-SystemRestorePoint { + <# + .SYNOPSIS + Creates system restore point using Windows PowerShell (for compatibility) + #> + param([string]$Description = "WinClean Maintenance") + + if ($SkipRestore) { + Write-Log "Restore point creation skipped (parameter)" -Level INFO + return $true + } + + if ($ReportOnly) { + Write-Log "Would create restore point: $Description" -Level INFO + return $true + } + + Write-Log "Creating system restore point..." -Level INFO + + try { + # Checkpoint-Computer doesn't work in PowerShell 7, use Windows PowerShell + # Note (v2.14): Windows silently skips restore point creation if one was made in + # the last 24h (SystemRestorePointCreationFrequency default = 1440 minutes). + # For a maintenance script that can run daily this means points were almost never + # created - temporarily lift the limit for this call only, then restore it. + $scriptBlock = @" + try { + Enable-ComputerRestore -Drive "$env:SystemDrive" -ErrorAction SilentlyContinue + + `$srKey = 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\SystemRestore' + `$prevFreq = (Get-ItemProperty -Path `$srKey -Name SystemRestorePointCreationFrequency -ErrorAction SilentlyContinue).SystemRestorePointCreationFrequency + Set-ItemProperty -Path `$srKey -Name SystemRestorePointCreationFrequency -Value 0 -Type DWord -Force + try { + Checkpoint-Computer -Description "$Description" -RestorePointType MODIFY_SETTINGS -ErrorAction Stop + } finally { + if (`$null -ne `$prevFreq) { + Set-ItemProperty -Path `$srKey -Name SystemRestorePointCreationFrequency -Value `$prevFreq -Type DWord -Force + } else { + Remove-ItemProperty -Path `$srKey -Name SystemRestorePointCreationFrequency -ErrorAction SilentlyContinue + } + } + Write-Output "SUCCESS" + } catch { + Write-Output "ERROR: `$_" + } +"@ + + # Use Windows PowerShell 5.1 (Checkpoint-Computer not available in PS7). + # v2.17 (p.14 of the audit): this was the one external call in the whole script + # with no timeout at all, and VSS is known to hang for minutes. + # + # v2.17 (regression caught on the stand): the timeout rewrite first passed the + # script via Start-Process -ArgumentList @(..., '-Command', $scriptBlock). + # Start-Process concatenates ArgumentList with spaces and does NOT re-quote its + # elements, so a $Description containing spaces (it always does - "WinClean + # 2026-07-20 19:00") was split into positional arguments and Checkpoint-Computer + # failed every time. -EncodedCommand (base64 of the UTF-16LE script) sidesteps + # command-line quoting entirely. Invisible to the test suite because a real + # Checkpoint-Computer only runs on a live machine, not in the sandbox. + $encodedCommand = [Convert]::ToBase64String([System.Text.Encoding]::Unicode.GetBytes($scriptBlock)) + + $outFile = [System.IO.Path]::GetTempFileName() + $errFile = [System.IO.Path]::GetTempFileName() + + # v2.17 (p.13 of the audit): a hard kill of this process (or of the child, e.g. + # via "End process tree") skips the child's own finally above, leaving + # SystemRestorePointCreationFrequency at 0 forever. Read the current value from + # out here so the marker can restore the RIGHT value on the next run instead of + # just assuming the shipped default. + $srKeyOuter = 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\SystemRestore' + $prevFreqOuter = (Get-ItemProperty -Path $srKeyOuter -Name SystemRestorePointCreationFrequency -ErrorAction SilentlyContinue).SystemRestorePointCreationFrequency + Set-RunMarker -Phase 'RestorePointFrequencyOverride' -Data @{ PreviousValue = $prevFreqOuter } + + $childKilled = $false + $childExited = $true # only meaningful once a kill has been attempted + try { + $proc = Start-Process -FilePath 'powershell.exe' ` + -ArgumentList @('-NoProfile', '-NoLogo', '-ExecutionPolicy', 'Bypass', '-EncodedCommand', $encodedCommand) ` + -NoNewWindow -PassThru -RedirectStandardOutput $outFile -RedirectStandardError $errFile + + $timeoutMs = 120000 # 2 minutes - a restore point should never legitimately take this long + if (-not $proc.WaitForExit($timeoutMs)) { + $proc.Kill($true) + $childKilled = $true + # v2.20, corrected in review: Kill returns once termination has been + # REQUESTED, not once the tree is gone. Without this wait the finally + # below could read the creation frequency while the dying child was still + # writing 0 into it, see a non-zero value, conclude there was nothing to + # repair and delete the marker - leaving the frequency pinned at 0 with no + # record of it, which is exactly the damage this mechanism exists for. + $childExited = $proc.WaitForExit(5000) + throw "restore point creation timed out after $($timeoutMs / 1000) seconds" + } + + $result = (Get-Content -LiteralPath $outFile -Raw -ErrorAction SilentlyContinue) + } finally { + Remove-Item $outFile, $errFile -Force -ErrorAction SilentlyContinue + + # A killed child never ran its own finally, so the registry override it set + # is still in place - repair it here and now. Clearing the marker + # unconditionally would throw away the record of exactly the damage this + # mechanism exists for, so it survives whenever the repair did not. + # + # v2.20: this repair now runs on BOTH paths. A child that exits normally can + # still have failed its own finally (a transient registry error), and the + # parent then deleted the marker anyway - leaving the creation frequency + # pinned at 0 indefinitely with nothing left to make a later run retry. + # Restore-RestorePointFrequency is idempotent by design: it returns $true + # without touching anything when the value is no longer 0, so verifying on the + # normal path costs nothing and turns an assumption into a check. + if (Restore-RestorePointFrequency -PreviousValue $prevFreqOuter) { + if ($childKilled -and -not $childExited) { + # Raised in review: the wait above has a bound, and a child that + # outlives it can still write the override AFTER this check passed. + # Keeping the marker costs one repair attempt on the next run; + # clearing it here would lose the only record that anything happened. + Write-Log "Restore point child was killed but had not exited 5 seconds later - the marker is kept, because it can still re-apply the override after this check" -Level WARNING + $script:Stats.WarningsCount++ + } else { + Clear-RunMarker + } + } else { + $how = if ($childKilled) { 'was killed' } else { 'exited normally' } + Write-Log "Restore point child $how but its registry override could not be undone - the marker is kept so the next run retries" -Level WARNING + $script:Stats.WarningsCount++ + } + } + + if ($result -like "SUCCESS*") { + Write-Log "Restore point created: $Description" -Level SUCCESS + return $true + } else { + throw $result + } + } catch { + Write-Log "Failed to create restore point: $_" -Level WARNING + $script:Stats.WarningsCount++ + return $false + } +} + +#endregion + +#region ═══════════════════════════════════════════════════════════════════════ +# UPDATE FUNCTIONS +#═══════════════════════════════════════════════════════════════════════════════ + +function Update-WindowsSystem { + <# + .SYNOPSIS + Updates Windows including optional driver updates + #> + Write-Log "WINDOWS UPDATE" -Level TITLE + Update-Progress -Activity "Windows Update" -Status "Checking for updates..." + + if ($SkipUpdates) { + Write-Log "Windows Update skipped (parameter)" -Level INFO + return + } + + # Early exit for ReportOnly - don't install modules or modify system + if ($ReportOnly) { + Write-Log "Would check and install: Windows Updates and Drivers" -Level DETAIL + return + } + + if (-not (Test-InternetConnection)) { + # v2.21: a warning, not an error, for the same reason a missing winget is one - the + # exit code is computed from ErrorsCount alone, so an offline machine ended every + # run with code 1 no matter how completely the cleanup succeeded, and a laptop that + # runs maintenance away from the network reported failure forever. Having no + # connectivity is a state of the environment, not a failure of this run. It stays + # visible: the warning is logged and counted, and the result JSON carries + # AppUpdatesStatus = 'skipped-offline' for the whole Updates phase. + Write-Log "No internet connection - skipping Windows Update" -Level WARNING + $script:Stats.WarningsCount++ + return + } + + # Check Windows Update service + $wuService = Get-Service -Name wuauserv -ErrorAction SilentlyContinue + if (-not $wuService) { + Write-Log "Windows Update service not found!" -Level ERROR + $script:Stats.ErrorsCount++ + return + } + + if ($wuService.Status -ne 'Running') { + Write-Log "Starting Windows Update service..." -Level INFO + try { + Start-Service wuauserv -ErrorAction Stop + } catch { + Write-Log "Failed to start Windows Update service: $_" -Level ERROR + $script:Stats.ErrorsCount++ + return + } + } + + try { + # Install PSWindowsUpdate if needed + if (-not (Get-Module -ListAvailable -Name PSWindowsUpdate)) { + # Clear any lingering progress bar before module installation + Write-Progress -Activity "Windows Update" -Completed -ErrorAction SilentlyContinue + + # Check PowerShell Gallery availability first + Write-Log "Checking PowerShell Gallery availability..." -Level INFO + if (-not (Test-PSGalleryConnection)) { + Write-Log "PowerShell Gallery is unavailable" -Level ERROR + Write-Log "Please check your internet connection or install PSWindowsUpdate manually:" -Level INFO + Write-Log " Install-Module PSWindowsUpdate -Force -Scope CurrentUser" -Level INFO + $script:Stats.ErrorsCount++ + return + } + + Write-Log "Installing PSWindowsUpdate module..." -Level INFO + + # Ensure NuGet provider with timeout + $nuget = Get-PackageProvider -Name NuGet -ErrorAction SilentlyContinue + if (-not $nuget -or $nuget.Version -lt [version]"2.8.5.201") { + Write-Log "Installing NuGet provider..." -Level INFO + if (-not (Install-PackageProviderWithTimeout -ProviderName "NuGet" -TimeoutSeconds 60)) { + Write-Log "Failed to install NuGet provider - Windows Update skipped" -Level ERROR + Write-Log "Try manual installation: Install-PackageProvider -Name NuGet -Force" -Level INFO + $script:Stats.ErrorsCount++ + return + } + Write-Log "NuGet provider installed" -Level SUCCESS + } + + # Install PSWindowsUpdate module with timeout + if (-not (Install-ModuleWithTimeout -ModuleName "PSWindowsUpdate" -TimeoutSeconds 120)) { + Write-Log "Failed to install PSWindowsUpdate - Windows Update skipped" -Level ERROR + Write-Log "Try manual installation: Install-Module PSWindowsUpdate -Force -Scope CurrentUser" -Level INFO + $script:Stats.ErrorsCount++ + return + } + Write-Log "PSWindowsUpdate installed" -Level SUCCESS + } + + Import-Module PSWindowsUpdate -ErrorAction Stop + # v2.17: with two copies installed (CurrentUser + AllUsers) .Version returns an + # ARRAY, and every later comparison silently degrades into an array filter + $moduleVersion = (Get-Module PSWindowsUpdate | Sort-Object Version -Descending | + Select-Object -First 1).Version + Write-Log "PSWindowsUpdate v$moduleVersion loaded" -Level INFO + + # Register Microsoft Update service + $muService = Get-WUServiceManager -ErrorAction SilentlyContinue | Where-Object { $_.Name -eq "Microsoft Update" } + if (-not $muService) { + Write-Log "Registering Microsoft Update service..." -Level INFO + Add-WUServiceManager -MicrosoftUpdate -Confirm:$false -ErrorAction SilentlyContinue | Out-Null + } + + # Search for updates + Write-Log "Searching for updates..." -Level INFO + + Write-Log "System Updates" -Level SECTION + $sysResult = Get-WindowsUpdateWithTimeout -CategoryParamName NotCategory -CategoryValue "Drivers" + $systemUpdates = @($sysResult.Updates) + + Write-Log "Driver Updates" -Level SECTION + $drvResult = Get-WindowsUpdateWithTimeout -CategoryParamName Category -CategoryValue "Drivers" + $driverUpdates = @($drvResult.Updates) + + $totalUpdates = $systemUpdates.Count + $driverUpdates.Count + $wuSearchErrors = @($sysResult.FirstError, $drvResult.FirstError) | Where-Object { $_ } + + # v2.17: report search errors regardless of how many updates were found. The + # check used to live inside the "zero updates" branch, so a failed system search + # paired with a successful driver search was reported as a clean run. + if ($wuSearchErrors) { + Write-Log "Update search completed with errors: $($wuSearchErrors[0])" -Level WARNING + Write-Log "Some updates may not have been discovered" -Level DETAIL + $script:Stats.WarningsCount++ + } + + if ($totalUpdates -eq 0) { + # Distinguish "no updates" from "search failed" (v2.14) - previously a + # failed search was reported as "Windows is up to date" + if (-not $wuSearchErrors) { + Write-Log "Windows is up to date" -Level SUCCESS + } + return + } + + Write-Log "Found $($systemUpdates.Count) system updates, $($driverUpdates.Count) driver updates" -Level INFO + + # Display updates + if ($systemUpdates.Count -gt 0) { + Write-Host "" + Write-Host " System Updates:" -ForegroundColor Cyan + foreach ($update in $systemUpdates) { + $size = if ($update.Size) { " ($(Format-FileSize $update.Size))" } else { "" } + Write-Host " - " -NoNewline -ForegroundColor DarkGray + Write-Host "$($update.KB)" -NoNewline -ForegroundColor Yellow + Write-Host " $($update.Title)$size" -ForegroundColor Gray + } + } + + if ($driverUpdates.Count -gt 0) { + Write-Host "" + Write-Host " Driver Updates:" -ForegroundColor Cyan + foreach ($update in $driverUpdates) { + Write-Host " - " -NoNewline -ForegroundColor DarkGray + Write-Host "$($update.Title)" -ForegroundColor Gray + } + } + + Write-Host "" + + # Install updates + Write-Log "Installing updates..." -Level INFO + + $installParams = @{ + MicrosoftUpdate = $true + AcceptAll = $true + IgnoreReboot = $true + ErrorAction = 'SilentlyContinue' + } + + # Ask the cmdlet what it supports instead of guessing from a version number. + # v2.17: the old check compared against 2.3.0, a version PSWindowsUpdate never + # shipped, so the branch was dead - and would have misfired on an array anyway. + $installCmd = Get-Command Install-WindowsUpdate -ErrorAction SilentlyContinue + if ($installCmd -and -not $installCmd.Parameters.ContainsKey('IgnoreReboot')) { + $installParams.Remove('IgnoreReboot') + if ($installCmd.Parameters.ContainsKey('AutoReboot')) { + $installParams['AutoReboot'] = $false + } + } + + # v2.17 (p.15 of the audit, partial): the two searches above got a job-based + # timeout - they are read-only, so killing the job on timeout is free. Install- + # WindowsUpdate is not wrapped the same way: it actually applies updates, and + # force-killing the job would not necessarily cancel the in-flight WU agent + # call, leaving system state that a stand run cannot verify without a live + # reproduction. Deferred - see MyAI-dtx8. + $results = Install-WindowsUpdate @installParams + + # Handle null/empty results (possible silent error) + if (-not $results) { + Write-Log "Windows Update returned no results (possible error)" -Level WARNING + $script:Stats.WarningsCount++ + return + } + + # Count installed updates. + # v2.16: 'Downloaded' means fetched but NOT applied, so counting it as installed + # produced "All N updates installed successfully" for updates still pending. + $installed = @($results | Where-Object { $_.Result -eq 'Installed' }).Count + $downloaded = @($results | Where-Object { $_.Result -eq 'Downloaded' }).Count + $failed = @($results | Where-Object { $_.Result -eq 'Failed' }).Count + + $script:Stats.WindowsUpdatesCount = $installed + + if ($failed -gt 0) { + Write-Log "Installed: $installed, Failed: $failed" -Level WARNING + $script:Stats.WarningsCount += $failed + } elseif ($installed -gt 0) { + Write-Log "All $installed updates installed successfully" -Level SUCCESS + } + + if ($downloaded -gt 0) { + Write-Log "$downloaded update(s) downloaded but not yet applied - a reboot is needed" -Level DETAIL + $script:Stats.RebootRequired = $true + } + + # Check reboot status + if (Get-WURebootStatus -Silent -ErrorAction SilentlyContinue) { + $script:Stats.RebootRequired = $true + Write-Log "Reboot required to complete updates" -Level WARNING + } + + } catch { + Write-Log "Windows Update error: $_" -Level ERROR + $script:Stats.ErrorsCount++ + } +} + +function Update-Applications { + <# + .SYNOPSIS + Updates applications via winget + #> + Write-Log "APPLICATION UPDATES (WINGET)" -Level TITLE + Update-Progress -Activity "Application Updates" -Status "Checking winget..." + + if ($SkipUpdates) { + Write-Log "Application updates skipped (parameter)" -Level INFO + $script:Stats.AppUpdatesStatus = 'skipped-parameter' + return + } + + if (-not (Test-InternetConnection)) { + # v2.21: warning, matching Update-WindowsSystem above - see the reasoning there. + # Both halves read the same memoised connectivity check, so this status describes + # the whole Updates phase, not only the winget half. + Write-Log "No internet connection - skipping app updates" -Level WARNING + $script:Stats.AppUpdatesStatus = 'skipped-offline' + $script:Stats.WarningsCount++ + return + } + + # Find winget + $wingetPath = $null + + $wingetCmd = Get-Command winget.exe -ErrorAction SilentlyContinue + if ($wingetCmd) { + $wingetPath = $wingetCmd.Source + } + + if (-not $wingetPath) { + $standardPath = "$env:LOCALAPPDATA\Microsoft\WindowsApps\winget.exe" + if (Test-Path $standardPath) { + $wingetPath = $standardPath + } + } + + if (-not $wingetPath) { + # v2.21: a warning, not an error. The absence of an optional third-party tool is a + # property of the machine, not a failure of this run - by the same rule that makes + # a machine without Docker or Visual Studio a normal machine here. + # It mattered because the exit code is computed from ErrorsCount alone: every run + # on a machine without App Installer ended with code 1 while all nine phases + # completed, so any scheduler, CI job or test harness reading that code saw a + # failed run forever. A winget that IS present and then fails is still reported; + # its severity depends on whether the run can carry on - the upgrade check failing + # outright and an unhandled exception are errors, while a stale source, a timeout + # or a partly failed batch are warnings. + Write-Log "Winget not found - skipping application updates (install App Installer from Microsoft Store to enable them)" -Level WARNING + $script:Stats.AppUpdatesStatus = 'skipped-no-winget' + $script:Stats.WarningsCount++ + return + } + + # From here winget exists and is about to be asked, but the status is only raised to + # 'checked' once the check actually returns a list (raised in review): setting it here + # meant a timed-out or failing check still reported 'checked' with AppUpdatesOffered = 0 + # - precisely the ambiguity this field was added to remove. + $script:Stats.AppUpdatesStatus = 'check-failed' + + try { + # Update sources only if not in ReportOnly mode (source update modifies state) + if (-not $ReportOnly) { + Write-Log "Updating winget sources..." -Level INFO + # Run with timeout to prevent hanging + # v2.20: the job's own exit code is returned as well. Only completion was + # checked before, and a completed job is not a successful winget: a corrupt + # source fails, the job still reaches Completed, and the upgrade list below + # was then built from stale data without a word in the log. + $job = Start-Job -ScriptBlock { + param($path) + & $path source update 2>&1 | Out-String + $LASTEXITCODE + } -ArgumentList $wingetPath + $completed = $job | Wait-Job -Timeout 120 # 2 minutes timeout + if (-not $completed) { + $job | Stop-Job + Write-Log "Winget source update timed out - package list may be stale" -Level WARNING + $script:Stats.WarningsCount++ + } else { + # v2.20, corrected in review: "no usable result" is a failure in its own + # right. When the winget entry is a WindowsApps AppExecLink stub - passes + # Test-Path, but will not start once App Installer is deregistered - the + # job still reaches Completed, Receive-Job swallows the error and + # $LASTEXITCODE is never set, so the last output element is $null. The old + # guard short-circuited on exactly that and said nothing, which made an + # unusable winget the one silent path left in this block. Measured by the + # reviewer: one output element, and it was $null. + # [int] on a non-numeric last line would also have thrown; TryParse cannot. + $jobState = $job.State + $jobOutput = @($job | Receive-Job -ErrorAction SilentlyContinue) + $sourceExit = if ($jobOutput.Count -gt 0) { $jobOutput[-1] } else { $null } + $exitValue = 0 + $exitKnown = $null -ne $sourceExit -and [int]::TryParse([string]$sourceExit, [ref]$exitValue) + + if ($jobState -ne 'Completed' -or -not $exitKnown) { + Write-Log "Winget source update produced no usable exit code (job state: $jobState) - package list may be stale" -Level WARNING + $script:Stats.WarningsCount++ + } elseif ($exitValue -ne 0) { + Write-Log "Winget source update failed (exit code $exitValue) - package list may be stale" -Level WARNING + $script:Stats.WarningsCount++ + } + } + $job | Remove-Job -Force -ErrorAction SilentlyContinue + } + + # Get available updates (use --include-unknown to match actual upgrade behavior) + Write-Log "Checking for app updates..." -Level INFO + + $tempFile = [System.IO.Path]::GetTempFileName() + $tempErrorFile = [System.IO.Path]::GetTempFileName() + $process = Start-Process -FilePath $wingetPath ` + -ArgumentList "upgrade", "--include-unknown", "--accept-source-agreements", "--disable-interactivity" ` + -NoNewWindow -RedirectStandardOutput $tempFile -RedirectStandardError $tempErrorFile -PassThru + + # Wait with timeout (5 minutes for check operation) + $timeoutMs = 300000 + if (-not $process.WaitForExit($timeoutMs)) { + $process.Kill($true) + Write-Log "Winget upgrade check timed out after 5 minutes" -Level WARNING + $script:Stats.WarningsCount++ + Remove-Item $tempFile, $tempErrorFile -Force -ErrorAction SilentlyContinue + return + } + + $output = Get-Content $tempFile -Raw -Encoding UTF8 -ErrorAction SilentlyContinue + $errorOutput = Get-Content $tempErrorFile -Raw -Encoding UTF8 -ErrorAction SilentlyContinue + Remove-Item $tempFile -Force -ErrorAction SilentlyContinue + Remove-Item $tempErrorFile -Force -ErrorAction SilentlyContinue + + # Check if winget command failed (any non-zero exit code is an error) + if ($process.ExitCode -ne 0) { + Write-Log "Winget upgrade check failed (exit code: $($process.ExitCode))" -Level ERROR + if ($errorOutput) { + Write-Log "Error: $errorOutput" -Level ERROR + } + $script:Stats.ErrorsCount++ + return + } + + # The check returned a usable list, so its count now means something + $script:Stats.AppUpdatesStatus = 'checked' + + # Parse output for update count (language-independent approach) + # Uses table separator "---" as marker, then counts all data lines + $updateCount = 0 + $lines = $output -split "`n" + $foundSeparator = $false + + foreach ($line in $lines) { + # Look for table separator line (works in any language) + if ($line -match "^-{10,}") { + $foundSeparator = $true + continue + } + + # Count lines after separator that look like package entries + # Must have multiple columns (name, id, version, available, source) + if ($foundSeparator) { + $trimmed = $line.Trim() + # First table ends at the first blank line - stop there to avoid counting + # the second "require explicit targeting" table (not covered by --all) + if (-not $trimmed) { break } + # Skip footer text ("X upgrades available") and lines with too few columns + if ($trimmed -notmatch "^\d+\s+(upgrade|обновлен)" -and + $trimmed -notmatch "^(No |Нет )" -and + ($trimmed -split '\s{2,}').Count -ge 3) { + $updateCount++ + } + } + } + + # v2.19: record what winget offered as soon as we know it - in every path, + # including ReportOnly and a later failed upgrade. This is the honest figure; + # the actual installed count is not knowable from `winget upgrade --all`. + $script:Stats.AppUpdatesOffered = $updateCount + + if ($updateCount -eq 0) { + Write-Log "All applications are up to date" -Level SUCCESS + return + } + + Write-Log "Available Updates" -Level SECTION + Write-Host $output + + if ($ReportOnly) { + Write-Log "Report mode - $updateCount updates available but not installed" -Level INFO + return + } + + Write-Log "Installing $updateCount application updates..." -Level INFO + Write-Log "This may take several minutes..." -Level INFO + + # Run upgrade (--include-unknown matches the check above) + $upgradeArgs = @( + "upgrade", "--all", + "--accept-source-agreements", + "--accept-package-agreements", + "--disable-interactivity", + "--include-unknown" + ) + + $upgradeProcess = Start-Process -FilePath $wingetPath -ArgumentList $upgradeArgs ` + -NoNewWindow -PassThru + + # Wait with timeout (20 minutes for upgrade operation - can take long with many updates) + $timeoutMs = 1200000 + if (-not $upgradeProcess.WaitForExit($timeoutMs)) { + $upgradeProcess.Kill($true) # $true: kill spawned installers too (v2.17) + Write-Log "Winget upgrade timed out after 20 minutes" -Level WARNING + $script:Stats.WarningsCount++ + return + } + + if ($upgradeProcess.ExitCode -eq 0) { + # AppUpdatesOffered was already recorded above; a zero exit means the command + # succeeded, not that every offered package installed, so nothing to add here. + Write-Log "Application updates completed successfully" -Level SUCCESS + } else { + # v2.16: decode the exit code. A bare number ("code: -1978335188") tells the + # user nothing, and adjacent codes mean opposite things - 0x8A15002B is not + # an error at all, while 0x8A15002C means some upgrades genuinely failed. + # Values verified against the documented winget error list - do not edit + # from memory, adjacent codes have unrelated meanings. + $wingetErrors = @{ + -1978335189 = '0x8A15002B - no applicable update found' + -1978335188 = '0x8A15002C - some applications failed to upgrade' + -1978335224 = '0x8A150008 - downloading installer failed' + -1978335225 = '0x8A150007 - manifest version newer than this winget client' + -1978335221 = '0x8A15000B - configured source information is corrupt' + -1978334967 = '0x8A150109 - restart required to finish installation' + } + $code = $upgradeProcess.ExitCode + $meaning = if ($wingetErrors.ContainsKey($code)) { + $wingetErrors[$code] + } else { + '0x{0:X8} - unrecognized winget exit code' -f $code + } + + if ($code -eq -1978335189) { + # Nothing to upgrade is a normal outcome, not a warning. AppUpdatesOffered + # reflects the parsed table above; the summary reports it as "offered", not + # "installed", so there is nothing to correct here. + Write-Log "Application updates: $meaning" -Level DETAIL + } else { + if ($code -eq -1978334967) { + # Installation finished but needs a reboot to take effect + $script:Stats.RebootRequired = $true + } + Write-Log "Application updates finished with $meaning" -Level WARNING + $script:Stats.WarningsCount++ + } + } + + } catch { + Write-Log "Application update error: $_" -Level ERROR + $script:Stats.ErrorsCount++ + } +} + +#endregion + +#region ═══════════════════════════════════════════════════════════════════════ +# CLEANUP FUNCTIONS +#═══════════════════════════════════════════════════════════════════════════════ + +function Clear-TempFiles { + <# + .SYNOPSIS + Cleans temporary files and system caches + #> + Write-Log "Temporary Files" -Level SECTION + + # Define temp paths and remove duplicates (e.g., $env:TEMP often equals $env:LOCALAPPDATA\Temp). + # v2.17: entries built from an empty environment variable are dropped. Under SYSTEM or + # a stripped scheduled-task environment "$env:LOCALAPPDATA\Temp" collapses to "\Temp", + # which GetFullPath roots at the CURRENT DRIVE - so the script would wipe D:\Temp. + # An empty $env:TEMP made GetFullPath throw outright and killed the whole function. + $tempPaths = @( + @{ Path = $env:TEMP; Desc = "User Temp"; Base = $env:TEMP } + @{ Path = "$env:SystemRoot\Temp"; Desc = "Windows Temp"; Base = $env:SystemRoot } + @{ Path = "$env:LOCALAPPDATA\Temp"; Desc = "Local Temp"; Base = $env:LOCALAPPDATA } + ) | Where-Object { -not [string]::IsNullOrWhiteSpace($_.Base) } | ForEach-Object { + try { + $_.Path = [System.IO.Path]::GetFullPath($_.Path) + $_ + } catch { + Write-Log "Skipping temp path '$($_.Desc)': $_" -Level DETAIL + } + } | Group-Object Path | ForEach-Object { $_.Group[0] } + + foreach ($item in $tempPaths) { + # Exclude the active log file - it lives in $env:TEMP by default and would + # otherwise be deleted mid-run, losing everything logged so far. + # MinAgeDays (v2.16): TEMP holds working files of running installers and + # applications; deleting today's entries can break them mid-operation. + Remove-FolderContent -Path $item.Path -Category "Temp" -Description $item.Desc ` + -ExcludeFile $script:LogPath -MinAgeDays 1 + } +} + +function Clear-BrowserCaches { + <# + .SYNOPSIS + Cleans browser caches (Edge, Chrome, Brave, Yandex, Opera, Opera GX, Firefox). + All profiles are cleaned for Chrome, Edge and Firefox; the default profile for the rest + #> + Write-Log "Browser Caches" -Level SECTION + + # Define browser cache paths + $browsers = @{ + "Edge" = @( + "$env:LOCALAPPDATA\Microsoft\Edge\User Data\Default\Cache" + "$env:LOCALAPPDATA\Microsoft\Edge\User Data\Default\Code Cache" + "$env:LOCALAPPDATA\Microsoft\Edge\User Data\Default\GPUCache" + "$env:LOCALAPPDATA\Microsoft\Edge\User Data\Default\Service Worker\CacheStorage" + ) + "Chrome" = @( + "$env:LOCALAPPDATA\Google\Chrome\User Data\Default\Cache" + "$env:LOCALAPPDATA\Google\Chrome\User Data\Default\Code Cache" + "$env:LOCALAPPDATA\Google\Chrome\User Data\Default\GPUCache" + "$env:LOCALAPPDATA\Google\Chrome\User Data\Default\Service Worker\CacheStorage" + ) + "Yandex" = @( + "$env:LOCALAPPDATA\Yandex\YandexBrowser\User Data\Default\Cache" + "$env:LOCALAPPDATA\Yandex\YandexBrowser\User Data\Default\Code Cache" + "$env:LOCALAPPDATA\Yandex\YandexBrowser\User Data\Default\GPUCache" + ) + "Opera" = @( + # Chromium disk caches live under LOCALAPPDATA; APPDATA kept for older layouts + "$env:LOCALAPPDATA\Opera Software\Opera Stable\Cache" + "$env:LOCALAPPDATA\Opera Software\Opera Stable\Code Cache" + "$env:LOCALAPPDATA\Opera Software\Opera Stable\GPUCache" + "$env:APPDATA\Opera Software\Opera Stable\Cache" + "$env:APPDATA\Opera Software\Opera Stable\Code Cache" + "$env:APPDATA\Opera Software\Opera Stable\GPUCache" + ) + "Opera GX" = @( + "$env:LOCALAPPDATA\Opera Software\Opera GX Stable\Cache" + "$env:LOCALAPPDATA\Opera Software\Opera GX Stable\Code Cache" + "$env:LOCALAPPDATA\Opera Software\Opera GX Stable\GPUCache" + "$env:APPDATA\Opera Software\Opera GX Stable\Cache" + "$env:APPDATA\Opera Software\Opera GX Stable\Code Cache" + "$env:APPDATA\Opera Software\Opera GX Stable\GPUCache" + ) + "Brave" = @( + "$env:LOCALAPPDATA\BraveSoftware\Brave-Browser\User Data\Default\Cache" + "$env:LOCALAPPDATA\BraveSoftware\Brave-Browser\User Data\Default\Code Cache" + "$env:LOCALAPPDATA\BraveSoftware\Brave-Browser\User Data\Default\GPUCache" + ) + } + + # Clean standard browsers in parallel + $allPaths = @() + foreach ($browser in $browsers.Keys) { + foreach ($path in $browsers[$browser]) { + if (Test-Path -LiteralPath $path -ErrorAction SilentlyContinue) { + $allPaths += @{ Browser = $browser; Path = $path } + } + } + } + + # Also check for additional Chrome/Edge profiles (with full cache set) + foreach ($browser in @("Chrome", "Edge")) { + $basePath = if ($browser -eq "Chrome") { + "$env:LOCALAPPDATA\Google\Chrome\User Data" + } else { + "$env:LOCALAPPDATA\Microsoft\Edge\User Data" + } + + if (Test-Path $basePath) { + Get-ChildItem -Path $basePath -Directory -ErrorAction SilentlyContinue | + Where-Object { $_.Name -like "Profile *" } | ForEach-Object { + # Add same cache types as Default profile (fixed in v2.1) + $profileCacheTypes = @("Cache", "Code Cache", "GPUCache", "Service Worker\CacheStorage") + foreach ($cacheType in $profileCacheTypes) { + $profileCache = Join-Path $_.FullName $cacheType + if (Test-Path $profileCache) { + $allPaths += @{ Browser = "$browser $($_.Name)"; Path = $profileCache } + } + } + } + } + } + + # Clean in parallel (with ReportOnly check) + if ($allPaths.Count -gt 0) { + # Get browser names for logging + $browserNames = ($allPaths | Select-Object -ExpandProperty Browser -Unique) -join ', ' + + if ($ReportOnly) { + # v2.18: $sizeBefore comes from Get-FolderSize, which returns 0 for BOTH an + # empty cache and an unreadable one, so "Would clean ... - 0 B" announced a + # cleanup that would free nothing. Re-measure with the checked variant to tell + # "empty" (confirmed 0) from "could not measure" ($null) and word it honestly. + $checked = @($allPaths | ForEach-Object { Get-FolderSizeChecked -Path $_.Path }) + if ($checked -contains $null) { + Write-Log "Browser caches ($browserNames): present, size could not be fully measured" -Level DETAIL + } else { + $checkedSum = [long](($checked | Measure-Object -Sum).Sum ?? 0) + if ($checkedSum -gt 0) { + Write-Log "Would clean browser caches ($browserNames) - $(Format-FileSize $checkedSum)" -Level DETAIL + } else { + Write-Log "Browser cache folders found ($browserNames), but they are empty" -Level DETAIL + } + } + } else { + # v2.20, corrected in review: both sides are now measured PER PATH with the + # SAME function. "Before" used Get-FolderSize (raw enumerator, inaccessible + # files silently skipped, reparse points excluded) while "after" used + # Get-FolderSizeChecked, so the two numbers did not describe the same set of + # files and a genuine deletion could be subtracted into the "nothing freed" + # branch. Measuring before only in this branch also stops ReportOnly from + # walking every cache twice. + $beforeMeasurements = @($allPaths | ForEach-Object { Get-FolderSizeChecked -Path $_.Path }) + + # Actual cleanup + $allPaths | ForEach-Object -Parallel { + $item = $_ + $path = $item.Path + + if (Test-Path -LiteralPath $path) { + try { + Get-ChildItem -LiteralPath $path -Force -ErrorAction SilentlyContinue | ForEach-Object { + Remove-Item -LiteralPath $_.FullName -Recurse -Force -ErrorAction SilentlyContinue + } + } catch { } + } + } -ThrottleLimit 8 + + # Measure size after cleanup to get actual freed space. + # + # v2.20: checked measurement. Get-FolderSize returns 0 both for "empty" and for + # "could not read", so an after-walk that lost access - the cache folder is + # being recreated by a browser that just started, ACLs changed mid-run - turned + # into "freed everything we measured before". The delta is only computed when + # every folder answered; otherwise the bytes stay unattributed instead of being + # invented. + $afterMeasurements = @($allPaths | ForEach-Object { Get-FolderSizeChecked -Path $_.Path }) + + # Pair the two sides path by path. The previous rule discarded the delta for + # ALL caches when a single one of ~30 could not be measured; only the paths + # that actually failed are dropped now. + $measuredBefore = [long]0 + $measuredAfter = [long]0 + $afterUnmeasured = 0 + for ($i = 0; $i -lt $allPaths.Count; $i++) { + $beforeOne = $beforeMeasurements[$i] + $afterOne = $afterMeasurements[$i] + if ($null -eq $beforeOne -or $null -eq $afterOne) { + $afterUnmeasured++ + continue + } + $measuredBefore += $beforeOne + $measuredAfter += $afterOne + } + + # Clamped once over the total, not per path (raised in review). TotalFreedBytes + # means net space reclaimed, so clamping each path separately would report + # 100 MB when one cache shrank by 100 MB while another was recreated and grew + # by 80 MB - inventing 80 MB of "freed" space the disk never got back. + $freedSpace = [math]::Max(0, $measuredBefore - $measuredAfter) + + # Update statistics with actual freed space (not estimated) + if ($freedSpace -gt 0) { + $script:Stats.TotalFreedBytes += $freedSpace + if (-not $script:Stats.FreedByCategory.ContainsKey("Browser")) { + $script:Stats.FreedByCategory["Browser"] = 0 + } + $script:Stats.FreedByCategory["Browser"] += $freedSpace + Write-Log "Browser caches cleaned ($browserNames) - $(Format-FileSize $freedSpace)" -Level SUCCESS + if ($afterUnmeasured -gt 0) { + Write-Log "Browser caches: $afterUnmeasured folder(s) could not be measured - their share is not included above" -Level DETAIL + } + } elseif ($afterUnmeasured -gt 0) { + Write-Log "Browser caches ($browserNames): cleaned, but $afterUnmeasured folder(s) could not be measured - freed space not counted" -Level DETAIL + } else { + # v2.16: was logged as SUCCESS. A running browser locks its cache, so + # "cleaned" with zero freed was a plain lie to the user + Write-Log "Browser caches: nothing freed ($browserNames) - close the browsers and retry" -Level DETAIL + } + } + } + + # Handle Firefox profiles separately + # Note: cache2/startupCache live under LOCALAPPDATA (the APPDATA profile only + # holds roaming data like bookmarks/prefs) - fixed in v2.14 + $firefoxProfileRoots = @( + "$env:LOCALAPPDATA\Mozilla\Firefox\Profiles" + "$env:APPDATA\Mozilla\Firefox\Profiles" + ) + foreach ($firefoxProfiles in $firefoxProfileRoots) { + if (Test-Path $firefoxProfiles) { + Get-ChildItem -Path $firefoxProfiles -Directory -ErrorAction SilentlyContinue | ForEach-Object { + Remove-FolderContent -Path "$($_.FullName)\cache2" -Category "Browser" -Description "Firefox cache" + Remove-FolderContent -Path "$($_.FullName)\startupCache" -Category "Browser" + } + } + } +} + +function Clear-WindowsUpdateCache { + <# + .SYNOPSIS + Cleans Windows Update download cache + #> + Write-Log "Windows Update Cache" -Level SECTION + + # v2.17: if the update phase left payloads waiting for a reboot, this folder holds + # them. Deleting it here means gigabytes get downloaded again after the restart, + # while the run proudly reports the freed space. + if ($script:Stats.RebootRequired) { + Write-Log "Updates are pending a reboot - keeping the cache (it holds their payloads)" -Level DETAIL + return + } + + if ($ReportOnly) { + $size = Get-FolderSize -Path "$env:SystemRoot\SoftwareDistribution\Download" + Write-Log "Would clean: Windows Update cache - $(Format-FileSize $size)" -Level DETAIL + return + } + + # Restart only what WE stopped (v2.17). Starting every stopped service afterwards + # would silently re-enable one an administrator had disabled on purpose - and the + # recovery path would repeat that mistake on the next run. + $toRestart = @( + foreach ($svcName in @('wuauserv', 'bits')) { + $svc = Get-Service -Name $svcName -ErrorAction SilentlyContinue + if ($svc -and $svc.Status -eq 'Running') { $svcName } + } + ) + + if ($toRestart.Count -eq 0) { + # Nothing of ours to stop or restore: no marker, no service juggling + Write-Log "Windows Update services are not running - cleaning the cache directly" -Level DETAIL -NoLog + Remove-FolderContent -Path "$env:SystemRoot\SoftwareDistribution\Download" -Category "WinUpdate" -Description "Windows Update cache" + return + } + + # Stop services with try/finally to ensure they restart. v2.17 (p.13 of the audit): + # a hard kill of this process skips that finally too, leaving wuauserv/bits + # stopped forever - the marker lets the NEXT run detect and recover that, and it + # names the exact services so recovery cannot overreach either. + Write-Log "Stopping Windows Update services..." -Level DETAIL -NoLog + Set-RunMarker -Phase 'WUServiceStop' -Data @{ ServicesToRestart = $toRestart } + try { + Stop-Service -Name $toRestart -Force -ErrorAction SilentlyContinue + + # v2.16: Stop-Service returns before the service has actually reached Stopped, + # and its failure was swallowed by -ErrorAction SilentlyContinue. Cleaning while + # the service still holds the files silently leaves the cache in place. + $stillRunning = @() + foreach ($svcName in $toRestart) { + $svc = Get-Service -Name $svcName -ErrorAction SilentlyContinue + if (-not $svc) { continue } + try { + $svc.WaitForStatus([System.ServiceProcess.ServiceControllerStatus]::Stopped, [timespan]::FromSeconds(30)) + } catch { + $stillRunning += $svcName + } + } + if ($stillRunning.Count -gt 0) { + # Skip entirely rather than half-delete: with the service holding the files, + # cleanup would remove an arbitrary subset and report a misleading number + Write-Log "Service(s) still running after 30s: $($stillRunning -join ', ') - skipping cache cleanup" -Level WARNING + $script:Stats.WarningsCount++ + } else { + # Clean + Remove-FolderContent -Path "$env:SystemRoot\SoftwareDistribution\Download" -Category "WinUpdate" -Description "Windows Update cache" + } + } finally { + # Restart exactly what was running before, and keep the marker if any of them + # refused - the next run then retries instead of leaving them down silently + $restartFailed = $false + foreach ($svcName in $toRestart) { + try { + Start-Service -Name $svcName -ErrorAction Stop + } catch { + Write-Log "Could not restart $svcName : $_" -Level WARNING + $script:Stats.WarningsCount++ + $restartFailed = $true + } + } + if (-not $restartFailed) { Clear-RunMarker } + } +} + +function Get-RecycleBinSize { + <# + .SYNOPSIS + Gets the total size of items in the Recycle Bin + #> + $totalSize = [long]0 + try { + $shell = New-Object -ComObject Shell.Application + $recycleBin = $shell.Namespace(0xA) + foreach ($item in $recycleBin.Items()) { + try { + # ExtendedProperty is exact and works for folders too (verified on 25H2: + # a deleted folder reports the total size of its contents) + $itemSize = $item.ExtendedProperty("System.Size") + if ($itemSize) { + $totalSize += [long]$itemSize + } else { + # Fallback for shells that do not expose the property. + # v2.17: column index 3 is Size. Index 2 is "Date deleted" - the old + # code parsed a date as a size, which quietly contributed zero. + $sizeStr = $recycleBin.GetDetailsOf($item, 3) + # Guard against a column-order change: a size always has a digit + # followed by a unit, a date does not + if ($sizeStr -and $sizeStr -match '\d.*[A-Za-zА-Яа-я]') { + $totalSize += ConvertFrom-HumanReadableSize $sizeStr + } + } + } catch { + # Ignore errors for individual items + } + } + } catch { + # Return 0 if we can't access recycle bin + } + return $totalSize +} + +function Get-RecycleBinItemCount { + <# + .SYNOPSIS + Number of items in the Recycle Bin (v2.17) + .DESCRIPTION + Emptiness must be decided by count, not by size: a size of zero can also mean + "the shell would not tell us", and skipping the cleanup in that case leaves the + bin full while reporting it as already empty. + #> + try { + $shell = New-Object -ComObject Shell.Application + return @($shell.Namespace(0xA).Items()).Count + } catch { + return -1 # unknown + } +} + +function Clear-WinCleanRecycleBin { + <# + .SYNOPSIS + Empties the Recycle Bin with size tracking + #> + Write-Log "Recycle Bin" -Level SECTION + + # Measure size and count before cleanup. v2.17: emptiness is decided by the item + # count - a zero size can also mean the shell refused to report one, and skipping + # on that basis would leave a full bin described as already empty. + $sizeBefore = Get-RecycleBinSize + $itemCount = Get-RecycleBinItemCount + + if ($ReportOnly) { + if ($itemCount -eq 0) { + Write-Log "Recycle Bin is empty" -Level DETAIL + } elseif ($sizeBefore -gt 0) { + Write-Log "Would clean: Recycle Bin - $(Format-FileSize $sizeBefore)" -Level DETAIL + } else { + Write-Log "Would clean: Recycle Bin - $itemCount item(s), size unavailable" -Level DETAIL + } + return + } + + if ($itemCount -eq 0) { + Write-Log "Recycle Bin is already empty" -Level INFO + return + } + + try { + # Use full cmdlet path to explicitly call the built-in cmdlet + Microsoft.PowerShell.Management\Clear-RecycleBin -Force -ErrorAction Stop + + # Update statistics + $script:Stats.TotalFreedBytes += $sizeBefore + if (-not $script:Stats.FreedByCategory.ContainsKey("Recycle Bin")) { + $script:Stats.FreedByCategory["Recycle Bin"] = 0 + } + $script:Stats.FreedByCategory["Recycle Bin"] += $sizeBefore + + if ($sizeBefore -gt 0) { + Write-Log "Recycle Bin emptied - $(Format-FileSize $sizeBefore)" -Level SUCCESS + } else { + # Emptied, but the shell never told us how much it held (v2.17) + Write-Log "Recycle Bin emptied ($itemCount item(s), size was unavailable)" -Level SUCCESS + } + } catch { + # Fallback to COM method + try { + $shell = New-Object -ComObject Shell.Application + $recycleBin = $shell.Namespace(0xA) + $items = $recycleBin.Items() + $count = $items.Count + + $items | ForEach-Object { + Remove-Item -LiteralPath $_.Path -Recurse -Force -ErrorAction SilentlyContinue + } + + # Measure what was actually freed - some items may have failed to + # delete silently (v2.14; previously the full size was always counted) + $freed = [math]::Max(0, $sizeBefore - (Get-RecycleBinSize)) + if ($freed -gt 0) { + $script:Stats.TotalFreedBytes += $freed + if (-not $script:Stats.FreedByCategory.ContainsKey("Recycle Bin")) { + $script:Stats.FreedByCategory["Recycle Bin"] = 0 + } + $script:Stats.FreedByCategory["Recycle Bin"] += $freed + } + + if ($freed -gt 0) { + Write-Log "Recycle Bin emptied ($count items) - $(Format-FileSize $freed)" -Level SUCCESS + } else { + # v2.17: was SUCCESS regardless - a bin that refused to empty looked identical + Write-Log "Recycle Bin: $count item(s) processed, nothing freed - some items may be locked" -Level WARNING + $script:Stats.WarningsCount++ + } + } catch { + Write-Log "Could not empty Recycle Bin: $_" -Level WARNING + $script:Stats.WarningsCount++ + } + } +} + +function Clear-SystemCaches { + <# + .SYNOPSIS + Cleans various Windows system caches + #> + Write-Log "System Caches" -Level SECTION + + $systemPaths = @( + @{ Path = "$env:SystemRoot\Prefetch"; Desc = "Prefetch" } + @{ Path = "$env:LOCALAPPDATA\IconCache.db"; Desc = "Icon cache"; File = $true } + @{ Path = "$env:LOCALAPPDATA\Microsoft\Windows\Explorer"; Desc = "Thumbnail cache" } + @{ Path = "$env:LOCALAPPDATA\Microsoft\Windows\WER"; Desc = "Error reports (local)" } + @{ Path = "$env:ProgramData\Microsoft\Windows\WER"; Desc = "Error reports (system)" } + @{ Path = "$env:ProgramData\USOShared\Logs"; Desc = "Update logs" } + @{ Path = "$env:LOCALAPPDATA\Packages\Microsoft.WindowsStore_8wekyb3d8bbwe\LocalCache"; Desc = "Windows Store cache" } + ) + + foreach ($item in $systemPaths) { + if ($item.File) { + if (Test-Path -LiteralPath $item.Path -ErrorAction SilentlyContinue) { + $fileSize = (Get-Item -LiteralPath $item.Path -ErrorAction SilentlyContinue).Length + $fileSize = [long]($fileSize ?? 0) + + if ($ReportOnly) { + Write-Log "Would clean: $($item.Desc) - $(Format-FileSize $fileSize)" -Level DETAIL + } else { + Remove-Item -LiteralPath $item.Path -Force -ErrorAction SilentlyContinue + + if (Test-Path -LiteralPath $item.Path -ErrorAction SilentlyContinue) { + # File is locked (e.g. IconCache.db held by Explorer) - + # don't count it as freed (v2.14) + Write-Log "$($item.Desc) is in use - skipped" -Level DETAIL + } else { + if ($fileSize -gt 0) { + $script:Stats.TotalFreedBytes += $fileSize + if (-not $script:Stats.FreedByCategory.ContainsKey("System")) { + $script:Stats.FreedByCategory["System"] = 0 + } + $script:Stats.FreedByCategory["System"] += $fileSize + } + + Write-Log "$($item.Desc) cleaned" -Level DETAIL + } + } + } + } else { + Remove-FolderContent -Path $item.Path -Category "System" -Description $item.Desc + } + } + + # Delivery Optimization cache: files are owned by the DO service, so raw folder + # deletion usually fails silently - use the supported cmdlet instead (v2.14) + # + # v2.16: the cache lives under the NetworkService profile, not in ProgramData. + # The old ProgramData path does not exist on Windows 11 (verified on 25H2), so + # every size measurement returned 0 and multi-gigabyte cleanups were reported as + # "0 B". Both locations are probed - the ProgramData one is kept for older builds. + # Note: Get-DeliveryOptimizationPerfSnap.CacheSizeBytes is NOT usable here - it + # reflects recent transfer activity (490 MB) rather than cache size on disk (7.5 GB). + $doPaths = @( + "$env:SystemRoot\ServiceProfiles\NetworkService\AppData\Local\Microsoft\Windows\DeliveryOptimization" + "$env:ProgramData\Microsoft\Windows\DeliveryOptimization" + ) | Where-Object { Test-Path $_ -ErrorAction SilentlyContinue } + + $doSizeOf = { + $total = 0 + foreach ($p in $doPaths) { $total += Get-FolderSize -Path $p } + $total + } + + if ($ReportOnly) { + $doSize = & $doSizeOf + if ($doSize -gt 0) { + Write-Log "Would clean: Delivery Optimization - $(Format-FileSize $doSize)" -Level DETAIL + } + } elseif (Get-Command Delete-DeliveryOptimizationCache -ErrorAction SilentlyContinue) { + try { + $doSizeBefore = & $doSizeOf + Delete-DeliveryOptimizationCache -Force -ErrorAction Stop + $doFreed = [math]::Max(0, $doSizeBefore - (& $doSizeOf)) + if ($doFreed -gt 0) { + $script:Stats.TotalFreedBytes += $doFreed + if (-not $script:Stats.FreedByCategory.ContainsKey("System")) { + $script:Stats.FreedByCategory["System"] = 0 + } + $script:Stats.FreedByCategory["System"] += $doFreed + Write-Log "Delivery Optimization cache - $(Format-FileSize $doFreed)" -Level SUCCESS + } elseif ($doPaths.Count -eq 0) { + # Nothing to measure: say so instead of claiming a clean-up happened (v2.16) + Write-Log "Delivery Optimization: cmdlet ran, cache location not found - freed size unknown" -Level DETAIL + } elseif ($doSizeBefore -gt 0) { + # v2.20: this was a WARNING and fired on healthy systems. The measurement + # covers the WHOLE Delivery Optimization folder, but the supported cmdlet + # only removes cached content - the service's own logs and state files stay + # and are not ours to delete. So "size did not change" after a cmdlet that + # reported success is not evidence of failure, it is evidence that the + # remainder was never cache. Reproduced on the EN stand VM twice in a row + # (2 MB left behind), where it pushed the run over its warning budget. + # + # A genuine failure still surfaces: the cmdlet runs with -ErrorAction Stop, + # so an actual error lands in the catch below as a warning. + # Deliberately does NOT claim the cache was cleared: an unchanged folder + # size cannot prove that every remaining byte is non-cache. It states what + # is actually known - the cmdlet reported success and this much is still + # on disk - and leaves the reader to judge (tightened in review; the first + # wording asserted "cache cleared", which is the opposite failure of the + # warning it replaced). + Write-Log "Delivery Optimization: cmdlet reported success, $(Format-FileSize $doSizeBefore) still on disk (the folder also holds service logs and state, which it does not remove)" -Level DETAIL + } else { + Write-Log "Delivery Optimization cache was already empty" -Level DETAIL + } + } catch { + Write-Log "Delete-DeliveryOptimizationCache failed: $_" -Level WARNING + $script:Stats.WarningsCount++ + foreach ($p in $doPaths) { + Remove-FolderContent -Path $p -Category "System" -Description "Delivery Optimization" + } + } + } else { + foreach ($p in $doPaths) { + Remove-FolderContent -Path $p -Category "System" -Description "Delivery Optimization" + } + } +} + +function Clear-EventLogs { + <# + .SYNOPSIS + Clears Windows Event Logs (excluding critical ones) + .DESCRIPTION + v2.17 (p.3 of the audit): each log used to be cleared via a separate `wevtutil` + process (30-80ms each, and a run can see 100-300 eligible logs). Replaced with + EventLogSession.ClearLog, the in-process .NET API wevtutil itself calls - same + underlying WinAPI, no process-spawn overhead per log. + #> + Write-Log "Event Logs" -Level SECTION + + if ($ReportOnly) { + Write-Log "Would clean: Windows Event Logs" -Level DETAIL + return + } + + try { + # Enumerate only logs worth clearing: enabled, non-empty, Administrative/Operational. + # This skips ~1000 Analytic/Debug/empty channels - much faster and avoids + # chronic partial-failure warnings (v2.14; was: wevtutil el over all channels) + # v2.20: keep the enumeration errors. A wholesale failure (Event Log service down, + # WMI broken) produced an empty list, zero failed clears and therefore the success + # branch: "Event logs cleared (0 logs)" while nothing was touched. + # v2.20, corrected in review: the enumeration result is kept BEFORE filtering. + # Deciding on the filtered list meant that 40 readable channels out of 510 with + # 470 enumeration errors produced a plain "Event logs cleared (40 logs)" SUCCESS, + # and that an empty filter result on a perfectly healthy machine was reported as + # a failed enumeration. Those are different states and now read differently. + $enumErrors = $null + $allLogs = @(Get-WinEvent -ListLog * -ErrorAction SilentlyContinue -ErrorVariable enumErrors) + $logs = $allLogs | Where-Object { + $_.RecordCount -gt 0 -and + $_.IsEnabled -and + $_.LogName -ne 'Security' -and # Keep the main Security log (exact match) + $_.LogType -in @('Administrative', 'Operational') + } + + $clearedCount = 0 + $failedCount = 0 + foreach ($log in $logs) { + try { + [System.Diagnostics.Eventing.Reader.EventLogSession]::GlobalSession.ClearLog($log.LogName) + $clearedCount++ + } catch { + $failedCount++ + } + } + + $enumErrorCount = @($enumErrors).Count + + if ($allLogs.Count -eq 0) { + # Nothing could be listed at all - the Event Log service is down or the API + # is broken. Clearing zero channels is not a clean system. + Write-Log "Event logs: channels could not be enumerated ($enumErrorCount error(s)) - nothing was cleared" -Level WARNING + $script:Stats.WarningsCount++ + } else { + # Partial loss is reported separately from the clearing result, because the + # channels that failed to list were never even candidates and their records + # are still on disk. + # + # DETAIL, not WARNING (raised in review): a listing error is a gap in coverage, + # not the failure of an operation we claimed to perform, and this machine is + # not evidence about anyone else's - measured here on 25H2 it is 510 channels + # with zero errors, but third-party or protected channels can error out + # routinely elsewhere, and a warning that fires every run teaches people to + # ignore warnings. Total failure below stays a warning. Worded as errors rather + # than channels because that is what was counted. + if ($enumErrorCount -gt 0) { + Write-Log "Event logs: $enumErrorCount error(s) while listing channels - whatever they refer to was never considered for clearing" -Level DETAIL + } + + if ($failedCount -gt 0) { + Write-Log "Event logs cleared: $clearedCount, failed: $failedCount" -Level WARNING + $script:Stats.WarningsCount++ + } elseif ($clearedCount -eq 0) { + Write-Log "Event logs: no channel needed clearing" -Level DETAIL + } else { + Write-Log "Event logs cleared ($clearedCount logs)" -Level SUCCESS + } + } + } catch { + Write-Log "Error clearing event logs: $_" -Level WARNING + $script:Stats.WarningsCount++ + } +} + +function Clear-DNSCache { + <# + .SYNOPSIS + Flushes DNS resolver cache + .DESCRIPTION + Clears the DNS client cache to resolve potential DNS issues + and free up memory used by cached DNS entries + #> + Write-Log "DNS Cache" -Level SECTION + + if ($ReportOnly) { + Write-Log "Would flush: DNS resolver cache" -Level DETAIL + return + } + + try { + # v2.17: prefer the cmdlet - it is locale-independent and raises real errors. + # ipconfig was doing the same work a second time, and its success was matched + # against English/Russian text that depends on the console code page. + if (Get-Command Clear-DnsClientCache -ErrorAction SilentlyContinue) { + Clear-DnsClientCache -ErrorAction Stop + Write-Log "DNS cache flushed successfully" -Level SUCCESS + } else { + $null = ipconfig /flushdns 2>&1 + $exitCode = $LASTEXITCODE + if ($exitCode -eq 0) { + Write-Log "DNS cache flushed successfully" -Level SUCCESS + } else { + Write-Log "DNS cache flush failed (ipconfig exit code: $exitCode)" -Level WARNING + $script:Stats.WarningsCount++ + } + } + } catch { + Write-Log "Error flushing DNS cache: $_" -Level WARNING + $script:Stats.WarningsCount++ + } +} + +function Clear-PrivacyTraces { + <# + .SYNOPSIS + Clears privacy-related traces (Run history, recent files, etc.) + .DESCRIPTION + Removes various Windows usage traces including: + - Run dialog history (Win+R) + - Recent documents MRU + - Explorer search history + #> + Write-Log "Privacy Traces" -Level SECTION + + if ($ReportOnly) { + Write-Log "Would clean: Run dialog history, Recent documents MRU" -Level DETAIL + return + } + + $clearedItems = @() + + # v2.20: success is now confirmed by looking, not by the absence of an exception. + # Remove-Item with -ErrorAction SilentlyContinue cannot throw, so the catch blocks + # here were dead code and "$clearedItems += ..." ran unconditionally: the log said + # "Privacy traces cleared: Explorer typed paths" even when policy or permissions had + # rejected the deletion and the key was still sitting there, fully populated. + # RunMRU joins this list rather than keeping its own copy of the same logic: it used + # the pre-count as proof of success in exactly the way the others did (caught in + # review before release, when the first version of this fix left it behind). + $historyKeys = @( + @{ Path = "HKCU:\Software\Microsoft\Windows\CurrentVersion\Explorer\RunMRU"; Label = 'Run history' } + @{ Path = "HKCU:\Software\Microsoft\Windows\CurrentVersion\Explorer\TypedPaths"; Label = 'Explorer typed paths' } + @{ Path = "HKCU:\Software\Microsoft\Windows\CurrentVersion\Explorer\WordWheelQuery"; Label = 'Explorer search history' } + ) + foreach ($entry in $historyKeys) { + if (-not (Test-Path $entry.Path)) { continue } + + $before = Get-RegistryValueCount -Key $entry.Path + if ($null -eq $before) { + # Present but unreadable: we cannot tell whether there is anything to clear, + # and silently moving on would look identical to "there was nothing" + Write-Log "$($entry.Label): key could not be read - left untouched" -Level WARNING + $script:Stats.WarningsCount++ + continue + } + # An empty key is not a trace that was cleared - it is a trace that was not there + if ($before -eq 0) { continue } + + Remove-Item -Path $entry.Path -Force -ErrorAction SilentlyContinue + New-Item -Path $entry.Path -Force -ErrorAction SilentlyContinue | Out-Null + + $after = Get-RegistryValueCount -Key $entry.Path + if ($null -eq $after) { + Write-Log "$($entry.Label): result could not be verified - not counted as cleared" -Level WARNING + $script:Stats.WarningsCount++ + } elseif ($after -eq 0) { + $clearedItems += $entry.Label + } else { + Write-Log "$($entry.Label): $after of $before entries remain - not cleared" -Level WARNING + $script:Stats.WarningsCount++ + } + } + + # Clear Recent documents folder + $recentFolder = [Environment]::GetFolderPath('Recent') + if (-not [string]::IsNullOrWhiteSpace($recentFolder) -and (Test-Path $recentFolder)) { + $recentBefore = @(Get-ChildItem -LiteralPath $recentFolder -Force -ErrorAction SilentlyContinue).Count + if ($recentBefore -gt 0) { + Get-ChildItem -LiteralPath $recentFolder -Force -ErrorAction SilentlyContinue | ForEach-Object { + Remove-Item -LiteralPath $_.FullName -Recurse -Force -ErrorAction SilentlyContinue + } + # Report what actually went, not what was there before the attempt + $recentAfter = @(Get-ChildItem -LiteralPath $recentFolder -Force -ErrorAction SilentlyContinue).Count + $removed = $recentBefore - $recentAfter + if ($removed -gt 0) { + $clearedItems += "Recent documents ($removed items)" + } + if ($recentAfter -gt 0) { + Write-Log "Recent documents: $recentAfter of $recentBefore items could not be removed (in use?)" -Level WARNING + $script:Stats.WarningsCount++ + } + } + } + + if ($clearedItems.Count -gt 0) { + Write-Log "Privacy traces cleared: $($clearedItems -join ', ')" -Level SUCCESS + } else { + Write-Log "No privacy traces found to clear" -Level INFO + } +} + +function Set-WindowsTelemetry { + <# + .SYNOPSIS + Configures Windows telemetry settings + .DESCRIPTION + Disables or minimizes Windows telemetry data collection + by setting appropriate registry values and group policies + #> + param( + [switch]$Disable + ) + + if (-not $Disable) { + return + } + + Write-Log "WINDOWS TELEMETRY CONFIGURATION" -Level TITLE + + if ($ReportOnly) { + Write-Log "Would configure: Disable Windows telemetry" -Level DETAIL + return + } + + $changesApplied = @() + + try { + # Set telemetry level to Security (0 = Security, 1 = Basic, 2 = Enhanced, 3 = Full) + $dataCollectionKey = "HKLM:\SOFTWARE\Policies\Microsoft\Windows\DataCollection" + if (-not (Test-Path $dataCollectionKey)) { + New-Item -Path $dataCollectionKey -Force -ErrorAction SilentlyContinue | Out-Null + } + + # AllowTelemetry = 0 (Security - minimum telemetry, only for Enterprise/Education) + # For other editions, setting to 1 (Basic) is the minimum allowed + # Use EditionID from registry (language-independent, fixed in v2.1) + $editionId = (Get-ItemProperty -Path "HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion" -Name EditionID -ErrorAction SilentlyContinue).EditionID + $telemetryLevel = if ($editionId -match "Enterprise|Education") { 0 } else { 1 } + + Set-ItemProperty -Path $dataCollectionKey -Name "AllowTelemetry" -Value $telemetryLevel -Type DWord -Force + $changesApplied += "Telemetry level set to $telemetryLevel" + + # Disable Customer Experience Improvement Program + Set-ItemProperty -Path $dataCollectionKey -Name "DoNotShowFeedbackNotifications" -Value 1 -Type DWord -Force + $changesApplied += "Feedback notifications disabled" + + # Disable Application Telemetry + $appCompatKey = "HKLM:\SOFTWARE\Policies\Microsoft\Windows\AppCompat" + if (-not (Test-Path $appCompatKey)) { + New-Item -Path $appCompatKey -Force -ErrorAction SilentlyContinue | Out-Null + } + Set-ItemProperty -Path $appCompatKey -Name "AITEnable" -Value 0 -Type DWord -Force + $changesApplied += "Application telemetry disabled" + + # Disable Advertising ID + $advertisingKey = "HKLM:\SOFTWARE\Policies\Microsoft\Windows\AdvertisingInfo" + if (-not (Test-Path $advertisingKey)) { + New-Item -Path $advertisingKey -Force -ErrorAction SilentlyContinue | Out-Null + } + Set-ItemProperty -Path $advertisingKey -Name "DisabledByGroupPolicy" -Value 1 -Type DWord -Force + $changesApplied += "Advertising ID disabled" + + # Disable Windows Error Reporting + $werKey = "HKLM:\SOFTWARE\Policies\Microsoft\Windows\Windows Error Reporting" + if (-not (Test-Path $werKey)) { + New-Item -Path $werKey -Force -ErrorAction SilentlyContinue | Out-Null + } + Set-ItemProperty -Path $werKey -Name "Disabled" -Value 1 -Type DWord -Force + $changesApplied += "Windows Error Reporting disabled" + + Write-Log "Telemetry settings applied:" -Level SUCCESS + foreach ($change in $changesApplied) { + Write-Log " - $change" -Level DETAIL + } + + Write-Log "Note: Some changes may require a system restart to take effect" -Level INFO + + } catch { + Write-Log "Error configuring telemetry: $_" -Level ERROR + $script:Stats.ErrorsCount++ + } +} + +function Clear-WindowsOld { + <# + .SYNOPSIS + Removes Windows.old folder with user confirmation + #> + $windowsOldPath = "$env:SystemDrive\Windows.old" + + if (-not (Test-Path $windowsOldPath)) { + return + } + + Write-Log "Previous Windows Installation" -Level SECTION + + $size = Get-FolderSize -Path $windowsOldPath + $sizeFormatted = Format-FileSize $size + + Write-Log "Found Windows.old folder ($sizeFormatted)" -Level WARNING + + if ($ReportOnly) { + Write-Log "Would clean: Windows.old - $sizeFormatted" -Level DETAIL + return + } + + # Check if running in interactive console (fixed in v2.1) + if (-not (Test-InteractiveConsole)) { + # Non-interactive: skip with safe default (don't delete without confirmation) + Write-Log "Non-interactive mode - skipping Windows.old deletion (requires user confirmation)" -Level INFO + return + } + + # Interactive prompt with timeout + Write-Host "" + Write-Host " This folder contains files from a previous Windows installation." -ForegroundColor DarkGray + Write-Host " Delete Windows.old? (" -NoNewline -ForegroundColor Yellow + Write-Host "Y" -NoNewline -ForegroundColor Green + Write-Host "/n, default " -NoNewline -ForegroundColor Yellow + Write-Host "Y" -NoNewline -ForegroundColor Green + Write-Host " in 15 sec): " -NoNewline -ForegroundColor Yellow + + $timeout = 15 + $startTime = Get-Date + $response = "" + + # Clear keyboard buffer + while ([Console]::KeyAvailable) { [Console]::ReadKey($true) | Out-Null } + + while ((Get-Date) -lt $startTime.AddSeconds($timeout)) { + if ([Console]::KeyAvailable) { + $key = [Console]::ReadKey($true) + if ($key.Key -eq "Enter" -or $key.KeyChar -match "[YyДд]") { + $response = "Y" + Write-Host "Y" -ForegroundColor Green + break + } elseif ($key.KeyChar -match "[NnНн]") { + $response = "N" + Write-Host "N" -ForegroundColor Red + break + } + } + + $remaining = $timeout - [int]((Get-Date) - $startTime).TotalSeconds + # {0,2} keeps the line length constant so no ghost chars remain when the + # countdown drops from 2 digits to 1 (v2.14) + Write-Host ("`r Delete Windows.old? (Y/n, default Y in {0,2} sec): " -f $remaining) -NoNewline -ForegroundColor Yellow + Start-Sleep -Milliseconds 100 + } + + if ($response -eq "" -or $response -eq "Y") { + if ($response -eq "") { Write-Host "Y" -ForegroundColor Green } + + Write-Log "Removing Windows.old..." -Level INFO + + try { + # Take ownership and remove + # Use the well-known SID for the Administrators group - the literal name + # is localized (e.g. "Администраторы") and fails on non-English Windows (v2.14) + $null = takeown /F $windowsOldPath /A /R /D Y 2>&1 + $null = icacls $windowsOldPath /grant "*S-1-5-32-544:F" /T /C /Q 2>&1 + Remove-Item -Path $windowsOldPath -Recurse -Force -ErrorAction SilentlyContinue + + if (-not (Test-Path $windowsOldPath)) { + Write-Log "Windows.old removed - $sizeFormatted freed" -Level SUCCESS + $script:Stats.TotalFreedBytes += $size + $script:Stats.FreedByCategory["Windows.old"] = $size + } else { + Write-Log "Could not fully remove Windows.old" -Level WARNING + $script:Stats.WarningsCount++ + } + } catch { + Write-Log "Error removing Windows.old: $_" -Level ERROR + $script:Stats.ErrorsCount++ + } + } else { + Write-Log "Windows.old removal cancelled by user" -Level INFO + } +} + +#endregion + +#region ═══════════════════════════════════════════════════════════════════════ +# DEVELOPER CLEANUP FUNCTIONS +#═══════════════════════════════════════════════════════════════════════════════ + +function Clear-DeveloperCaches { + <# + .SYNOPSIS + Cleans developer tool caches (npm, pip, nuget, composer, etc.) + #> + # v2.20: matches the contract the dispatcher enforces (-SkipCleanup suppresses the + # whole cleanup group). Unreachable through Start-WinClean, which already gates the + # phase - this is the guard a direct caller of the dot-sourced function gets, and it + # disagreed with the documented meaning of -SkipCleanup. + if ($SkipCleanup -or $SkipDevCleanup) { + Write-Log "Developer cache cleanup skipped (parameter)" -Level INFO + return + } + + Write-Log "DEVELOPER CACHES" -Level TITLE + Update-Progress -Activity "Developer Cleanup" -Status "Cleaning caches..." + + # NPM Cache (npm v7+ uses LOCALAPPDATA; older versions used APPDATA - fixed in v2.14) + Write-Log "npm Cache" -Level SECTION + $npmCachePaths = @("$env:LOCALAPPDATA\npm-cache", "$env:APPDATA\npm-cache") + $npmCache = $npmCachePaths | Where-Object { Test-Path $_ } | Select-Object -First 1 + if ($npmCache) { + if ($ReportOnly) { + $size = Get-FolderSize $npmCache + Write-Log "Would clean: npm cache - $(Format-FileSize $size)" -Level DETAIL + } else { + # Use npm cache clean if available + $npm = Get-Command npm -ErrorAction SilentlyContinue + if ($npm) { + try { + # v2.20, corrected in review: both sides are measured with the checked + # variant. Get-FolderSize answers 0 for "empty" and for "could not + # read" alike, so a cache that became unreadable after the clean - npm + # recreating it, ACLs changing mid-run - produced "freed everything we + # measured before" and put invented bytes into TotalFreedBytes. The + # same defect was fixed for browser caches in this release; npm kept it. + $sizeBefore = Get-FolderSizeChecked -Path $npmCache + & npm cache clean --force 2>&1 | Out-Null + # v2.20: npm fails without throwing (EPERM on a locked cache is the + # common case). The exit code was never read, so a failed clean that + # freed nothing landed in the "else" branch below and was logged as + # SUCCESS - the same lie fixed for browser caches in v2.16. + $npmExit = $LASTEXITCODE + $sizeAfter = Get-FolderSizeChecked -Path $npmCache + $npmMeasured = ($null -ne $sizeBefore -and $null -ne $sizeAfter) + $freed = if ($npmMeasured) { [math]::Max(0, $sizeBefore - $sizeAfter) } else { 0 } + + # Credit whatever really went, whether or not npm then failed + if ($freed -gt 0) { + $script:Stats.TotalFreedBytes += $freed + if (-not $script:Stats.FreedByCategory.ContainsKey("Developer")) { + $script:Stats.FreedByCategory["Developer"] = 0 + } + $script:Stats.FreedByCategory["Developer"] += $freed + } + + # v2.20, corrected in review: the exit code is checked FIRST. Testing + # "$freed -gt 0" first meant a partial failure (npm removes 400 MB, then + # hits EPERM on a locked file and exits 1) reported plain success and + # skipped the fallback - the exit code was read and then ignored in + # exactly the case where it mattered. + if ($npmExit -ne 0) { + Write-Log "npm cache clean failed (exit code $npmExit)$(if ($freed -gt 0) { " after freeing $(Format-FileSize $freed)" }) - removing the cache directly" -Level WARNING + $script:Stats.WarningsCount++ + Remove-FolderContent -Path $npmCache -Category "Developer" -Description "npm cache" + } elseif ($freed -gt 0) { + Write-Log "npm cache cleaned - $(Format-FileSize $freed)" -Level SUCCESS + } elseif (-not $npmMeasured) { + # Cleaned, but "how much" has no honest answer - say that instead + # of calling an unreadable cache an empty one. + Write-Log "npm cache cleaned, but its size could not be measured - freed space not counted" -Level DETAIL + } else { + # npm succeeded and there was nothing to reclaim. Not a cleanup + # success worth announcing, and definitely not freed bytes. + Write-Log "npm cache was already empty" -Level DETAIL + } + } catch { + Remove-FolderContent -Path $npmCache -Category "Developer" -Description "npm cache" + } + } else { + Remove-FolderContent -Path $npmCache -Category "Developer" -Description "npm cache" + } + } + + # Clean any remaining legacy cache location as well (both may exist after npm upgrades) + foreach ($stalePath in ($npmCachePaths | Where-Object { $_ -ne $npmCache })) { + Remove-FolderContent -Path $stalePath -Category "Developer" -Description "npm cache (legacy)" + } + } + + # pip Cache + Write-Log "pip Cache" -Level SECTION + $pipCaches = @( + "$env:LOCALAPPDATA\pip\Cache" + "$env:APPDATA\pip\cache" + "$env:USERPROFILE\.cache\pip" + ) + foreach ($pipCache in $pipCaches) { + Remove-FolderContent -Path $pipCache -Category "Developer" -Description "pip cache" + } + + # NuGet Cache (only metadata caches, not packages!) + Write-Log "NuGet Cache" -Level SECTION + $nugetCaches = @( + "$env:LOCALAPPDATA\NuGet\v3-cache" # Metadata cache + "$env:LOCALAPPDATA\NuGet\plugins-cache" # Plugin cache + "$env:LOCALAPPDATA\NuGet\http-cache" # HTTP cache + # Note: $env:USERPROFILE\.nuget\packages is NOT cache - it's the global packages folder + ) + foreach ($cache in $nugetCaches) { + Remove-FolderContent -Path $cache -Category "Developer" -Description "NuGet cache" + } + + # Composer Cache + Write-Log "Composer Cache" -Level SECTION + $composerCache = "$env:LOCALAPPDATA\Composer\cache" + Remove-FolderContent -Path $composerCache -Category "Developer" -Description "Composer cache" + + # Gradle (only safe cache directories, not full repositories!) + Write-Log "Gradle Cache" -Level SECTION + $gradleCaches = @( + "$env:USERPROFILE\.gradle\caches\build-cache-1" # Build cache + "$env:USERPROFILE\.gradle\caches\transforms-*" # Transform cache + "$env:USERPROFILE\.gradle\daemon" # Daemon logs + # Note: .gradle\caches\modules-* contains downloaded dependencies - dangerous to delete! + # Note: .m2\repository is Maven local repo - do NOT delete! + ) + foreach ($pattern in $gradleCaches) { + Get-ChildItem -Path (Split-Path $pattern -Parent) -Filter (Split-Path $pattern -Leaf) -Directory -ErrorAction SilentlyContinue | ForEach-Object { + Remove-FolderContent -Path $_.FullName -Category "Developer" -Description "Gradle cache" + } + } + + # yarn Cache + Write-Log "yarn Cache" -Level SECTION + $yarnCaches = @( + "$env:LOCALAPPDATA\Yarn\Cache" + "$env:USERPROFILE\.cache\yarn" + ) + foreach ($cache in $yarnCaches) { + Remove-FolderContent -Path $cache -Category "Developer" -Description "yarn cache" + } + + # pnpm Cache + Write-Log "pnpm Cache" -Level SECTION + $pnpmCache = "$env:LOCALAPPDATA\pnpm-cache" + Remove-FolderContent -Path $pnpmCache -Category "Developer" -Description "pnpm cache" + + # Go Cache + Write-Log "Go Cache" -Level SECTION + $goCache = "$env:LOCALAPPDATA\go-build" + Remove-FolderContent -Path $goCache -Category "Developer" -Description "Go build cache" + + # Rust/Cargo Cache + Write-Log "Cargo Cache" -Level SECTION + $cargoCaches = @( + "$env:USERPROFILE\.cargo\registry\cache" + "$env:USERPROFILE\.cargo\git\db" + ) + foreach ($cache in $cargoCaches) { + Remove-FolderContent -Path $cache -Category "Developer" -Description "Cargo cache" + } + + # uv Cache (Python package/project manager) + Write-Log "uv Cache" -Level SECTION + $uvCache = "$env:LOCALAPPDATA\uv\cache" + Remove-FolderContent -Path $uvCache -Category "Developer" -Description "uv cache" +} + +#endregion + +#region ═══════════════════════════════════════════════════════════════════════ +# DOCKER/WSL CLEANUP FUNCTIONS +#═══════════════════════════════════════════════════════════════════════════════ + +function Test-DiskpartCompactionFailed { + <# + .SYNOPSIS + Decides whether a diskpart "compact vdisk" run failed - pure logic, no I/O + .DESCRIPTION + Split out (v2.18) so the failure decision is unit-testable without a real VHDX. + diskpart is unreliable at signalling a sub-command failure through its exit code + (it can exit 0 after "compact vdisk" errored), so a non-zero exit OR a known error + marker in the output both count as failure. The output is localized, so this only + catches English error text; on a non-English console a failure may surface only as + a non-zero exit, or as the file not shrinking (the caller treats "no shrink" as a + neutral "no space saved", not a failure, so a silent localized error can still be + missed - a limitation of the diskpart text interface, noted deliberately). + .PARAMETER Output + The combined stdout/stderr text diskpart produced. + .PARAMETER ExitCode + diskpart's process exit code. + #> + param([string]$Output, [int]$ExitCode) + + if ($ExitCode -ne 0) { return $true } + if ([string]::IsNullOrWhiteSpace($Output)) { return $false } + return [bool]($Output -match ('DiskPart has encountered an error|Virtual Disk Service error|' + + 'DiskPart failed|The arguments specified for this command are not valid|' + + 'There is no virtual disk selected|Access is denied|The system cannot find')) +} + +function Clear-DockerWSL { + <# + .SYNOPSIS + Cleans Docker images, containers, and WSL2 disk + #> + # v2.20: see Clear-DeveloperCaches - same contract, same reason + if ($SkipCleanup -or $SkipDockerCleanup) { + Write-Log "Docker/WSL cleanup skipped (parameter)" -Level INFO + return + } + + Write-Log "DOCKER & WSL CLEANUP" -Level TITLE + Update-Progress -Activity "Docker/WSL Cleanup" -Status "Checking Docker..." + + # Docker Cleanup + $docker = Get-Command docker -ErrorAction SilentlyContinue + if ($docker) { + Write-Log "Docker Cleanup" -Level SECTION + + # Check if Docker is running + $dockerRunning = $false + try { + $null = docker info 2>&1 + $exitCode = $LASTEXITCODE # Capture immediately after command + $dockerRunning = $exitCode -eq 0 + } catch { + # Docker command failed to execute (not installed or path issue) + } + + if ($dockerRunning) { + if ($ReportOnly) { + Write-Log "Would run: docker system prune" -Level DETAIL + } else { + try { + # Remove unused data (stopped containers, unused networks, dangling images, build cache) + Write-Log "Running docker system prune..." -Level INFO + $result = docker system prune -f 2>&1 + $pruneExitCode = $LASTEXITCODE + + # Join output into a single string: -match against an array does not + # populate $Matches reliably (v2.14) + $resultText = $result | Out-String + + if ($pruneExitCode -ne 0) { + Write-Log "Docker prune failed (exit code: $pruneExitCode)" -Level WARNING + $script:Stats.WarningsCount++ + } + # Parse reclaimed space and add to statistics + # Supports both "reclaimed 1.23GB" and "Total reclaimed space: 1.23GB" formats + elseif ($resultText -match "reclaimed\s+(?:space:\s*)?([\d.,]+\s*[KMGT]?B)") { + $reclaimedStr = $Matches[1] + $reclaimedBytes = ConvertFrom-HumanReadableSize $reclaimedStr + + Write-Log "Docker cleanup: $reclaimedStr reclaimed" -Level SUCCESS + + if ($reclaimedBytes -gt 0) { + $script:Stats.TotalFreedBytes += $reclaimedBytes + if (-not $script:Stats.FreedByCategory.ContainsKey("Docker")) { + $script:Stats.FreedByCategory["Docker"] = 0 + } + $script:Stats.FreedByCategory["Docker"] += $reclaimedBytes + } + } else { + Write-Log "Docker cleanup completed" -Level SUCCESS + } + + # Note: docker system prune -f already includes build cache cleanup + + } catch { + Write-Log "Docker cleanup error: $_" -Level WARNING + $script:Stats.WarningsCount++ + } + } + } else { + Write-Log "Docker is not running - skipping cleanup" -Level INFO + } + } else { + Write-Log "Docker not installed" -Level INFO + } + + # WSL2 Disk Compaction + Write-Log "WSL2 Disk Optimization" -Level SECTION + + $wsl = Get-Command wsl -ErrorAction SilentlyContinue + if ($wsl) { + try { + # Define all possible VHDX locations (including Docker) + $wslPaths = @( + "$env:LOCALAPPDATA\Packages\*CanonicalGroupLimited*\LocalState" + "$env:LOCALAPPDATA\Packages\*MicrosoftCorporationII.WindowsSubsystemForLinux*\LocalState" + "$env:LOCALAPPDATA\Docker\wsl\data" + "$env:LOCALAPPDATA\Docker\wsl\distro" + ) + + # Find all VHDX files first (don't depend on WSL distro list) + $vhdxFiles = @() + foreach ($pattern in $wslPaths) { + $vhdxFiles += Get-ChildItem -Path $pattern -Filter "*.vhdx" -Recurse -ErrorAction SilentlyContinue + } + + if ($vhdxFiles.Count -gt 0) { + if ($ReportOnly) { + $totalSize = ($vhdxFiles | Measure-Object -Property Length -Sum).Sum + Write-Log "Would optimize $($vhdxFiles.Count) WSL2/Docker disk(s) - Total: $(Format-FileSize $totalSize)" -Level DETAIL + } else { + # Shutdown WSL first (this also stops Docker WSL backends) + Write-Log "Shutting down WSL..." -Level INFO + wsl --shutdown + $wslShutdownExit = $LASTEXITCODE + if ($wslShutdownExit -ne 0) { + # v2.18: compacting a VHDX WSL may still hold open is unsafe and + # pointless, so a failed shutdown skips compaction (emptying the + # list) with a warning instead of touching a live disk. + Write-Log "wsl --shutdown exit $wslShutdownExit - skipping VHDX compaction to avoid touching a live disk" -Level WARNING + $script:Stats.WarningsCount++ + $vhdxFiles = @() + } + Start-Sleep -Seconds 2 + + # Compact each VHDX file + foreach ($vhdxFile in $vhdxFiles) { + $vhdx = $vhdxFile.FullName + $sizeBefore = $vhdxFile.Length + + Write-Log "Compacting $($vhdxFile.Name)..." -Level DETAIL + + try { + # Use diskpart to compact + $diskpartScript = @" +select vdisk file="$vhdx" +compact vdisk +exit +"@ + # v2.18: capture output AND exit code. The old "| Out-Null" + # discarded both, so a diskpart failure fell straight through to + # "no space saved" (INFO) and read as success to the stand/CI. + $diskpartOutput = $diskpartScript | diskpart 2>&1 + $diskpartExit = $LASTEXITCODE + $diskpartText = ($diskpartOutput | Out-String) + + if (Test-DiskpartCompactionFailed -Output $diskpartText -ExitCode $diskpartExit) { + Write-Log "Could not compact $($vhdxFile.Name): diskpart reported an error (exit $diskpartExit)" -Level WARNING + $tail = (($diskpartText -split "`r?`n" | Where-Object { $_.Trim() }) | Select-Object -Last 2) -join ' | ' + if ($tail) { Write-Log "diskpart: $tail" -Level DETAIL } + $script:Stats.WarningsCount++ + } else { + $sizeAfter = (Get-Item $vhdx).Length + $saved = $sizeBefore - $sizeAfter + + if ($saved -gt 0) { + Write-Log "Compacted $($vhdxFile.Name): $(Format-FileSize $saved) saved" -Level SUCCESS + $script:Stats.TotalFreedBytes += $saved + if (-not $script:Stats.FreedByCategory.ContainsKey("WSL")) { + $script:Stats.FreedByCategory["WSL"] = 0 + } + $script:Stats.FreedByCategory["WSL"] += $saved + } else { + Write-Log "Compacted $($vhdxFile.Name): no space saved" -Level INFO + } + } + } catch { + # v2.18: the outer catch bumps WarningsCount but this per-VHDX + # one did not, so a real compaction failure could leave the JSON + # WarningsCount at 0 and read as a clean run to the stand/CI. + Write-Log "Could not compact $($vhdxFile.Name): $_" -Level WARNING + $script:Stats.WarningsCount++ + } + } + } + } else { + Write-Log "No WSL2/Docker VHDX files found" -Level INFO + } + } catch { + Write-Log "WSL optimization error: $_" -Level WARNING + $script:Stats.WarningsCount++ + } + } else { + Write-Log "WSL not installed" -Level INFO + } +} + +#endregion + +#region ═══════════════════════════════════════════════════════════════════════ +# VISUAL STUDIO CLEANUP FUNCTIONS +#═══════════════════════════════════════════════════════════════════════════════ + +function Clear-VisualStudio { + <# + .SYNOPSIS + Cleans Visual Studio caches and temporary files + #> + # v2.20: see Clear-DeveloperCaches - same contract, same reason + if ($SkipCleanup -or $SkipVSCleanup) { + Write-Log "Visual Studio cleanup skipped (parameter)" -Level INFO + return + } + + Write-Log "VISUAL STUDIO CLEANUP" -Level TITLE + Update-Progress -Activity "Visual Studio Cleanup" -Status "Cleaning caches..." + + # VS 2019/2022 caches (directories) + $vsCacheDirs = @( + @{ Path = "$env:LOCALAPPDATA\Microsoft\VisualStudio\*\ComponentModelCache"; Desc = "Component Model Cache" } + @{ Path = "$env:LOCALAPPDATA\Microsoft\VisualStudio\*\ImageCacheRoot"; Desc = "Image Cache" } + @{ Path = "$env:LOCALAPPDATA\Microsoft\VisualStudio\*\DesignTimeBuild"; Desc = "Design Time Build" } + @{ Path = "$env:LOCALAPPDATA\Microsoft\VSCommon\*\SQM"; Desc = "SQM Data" } + @{ Path = "$env:LOCALAPPDATA\Microsoft\VisualStudio\Packages\_Instances"; Desc = "Package Instances" } + ) + + # VS file patterns (handled separately, fixed in v2.1) + $vsFilePatterns = @( + @{ Pattern = "$env:APPDATA\Microsoft\VisualStudio\*\*.roslynobjectin"; Desc = "Roslyn Temp" } + ) + + Write-Log "Visual Studio Caches" -Level SECTION + + # Process directory caches + foreach ($item in $vsCacheDirs) { + $paths = Resolve-Path -Path $item.Path -ErrorAction SilentlyContinue + foreach ($path in $paths) { + Remove-FolderContent -Path $path.Path -Category "VS" -Description $item.Desc + } + } + + # Process file patterns (fixed in v2.1 - files were not being deleted before) + foreach ($item in $vsFilePatterns) { + Remove-FilesByPattern -Pattern $item.Pattern -Category "VS" -Description $item.Desc + } + + # MEF cache: no separate section - the real one is ComponentModelCache, already + # covered by the VS cache list above. "MEFCacheAssembly" (v2.16 and earlier) is a + # folder Visual Studio never creates, so the section only ever printed a heading. + +# VS Code caches + Write-Log "VS Code Caches" -Level SECTION + $vscodeCaches = @( + "$env:APPDATA\Code\Cache" + "$env:APPDATA\Code\CachedData" + "$env:APPDATA\Code\CachedExtensions" + "$env:APPDATA\Code\CachedExtensionVSIXs" + "$env:APPDATA\Code\Code Cache" + "$env:APPDATA\Code\GPUCache" + "$env:APPDATA\Code - Insiders\Cache" + "$env:APPDATA\Code - Insiders\CachedData" + ) + + foreach ($cache in $vscodeCaches) { + Remove-FolderContent -Path $cache -Category "VS Code" -Description "VS Code cache" + } + + # JetBrains IDEs + Write-Log "JetBrains IDE Caches" -Level SECTION + $jetbrainsBase = "$env:LOCALAPPDATA\JetBrains" + if (Test-Path $jetbrainsBase) { + Get-ChildItem -Path $jetbrainsBase -Directory -ErrorAction SilentlyContinue | ForEach-Object { + $cacheDirs = @("caches", "index", "tmp", "log") + foreach ($cacheDir in $cacheDirs) { + $fullPath = Join-Path $_.FullName $cacheDir + Remove-FolderContent -Path $fullPath -Category "JetBrains" -Description "JetBrains $cacheDir" + } + } + } +} + +#endregion + +#region ═══════════════════════════════════════════════════════════════════════ +# SYSTEM CLEANUP FUNCTIONS +#═══════════════════════════════════════════════════════════════════════════════ + +function Clear-KernelDumps { + <# + .SYNOPSIS + Removes stale kernel live-dump reports (v2.16) + .DESCRIPTION + LiveKernelReports accumulates multi-gigabyte .dmp files that nothing ever + cleans up - a single watchdog dump of 9 GB was found sitting for 18 months on + the author's machine. Only files older than $MinAgeDays are touched, so a dump + from a crash that is being investigated right now survives. + #> + param([int]$MinAgeDays = 30) + + $dumpPath = Join-Path $env:SystemRoot 'LiveKernelReports' + if (-not (Test-Path -LiteralPath $dumpPath)) { return } + + $cutoff = (Get-Date).AddDays(-$MinAgeDays) + $stale = @(Get-ChildItem -LiteralPath $dumpPath -Recurse -File -Force -Filter '*.dmp' -ErrorAction SilentlyContinue | + Where-Object { $_.LastWriteTime -lt $cutoff }) + if ($stale.Count -eq 0) { return } + + $size = ($stale | Measure-Object -Property Length -Sum).Sum + + if ($ReportOnly) { + Write-Log "Would clean: kernel dumps older than $MinAgeDays days ($($stale.Count) file(s)) - $(Format-FileSize $size)" -Level DETAIL + return + } + + $freed = 0 + $failed = 0 + $firstError = $null + foreach ($file in $stale) { + try { + $len = $file.Length + Remove-Item -LiteralPath $file.FullName -Force -ErrorAction Stop + $freed += $len + } catch { + $failed++ + if (-not $firstError) { $firstError = $_.Exception.Message } + } + } + + if ($freed -gt 0) { + $script:Stats.TotalFreedBytes += $freed + if (-not $script:Stats.FreedByCategory.ContainsKey("System")) { + $script:Stats.FreedByCategory["System"] = 0 + } + $script:Stats.FreedByCategory["System"] += $freed + Write-Log "Kernel dumps older than $MinAgeDays days - $(Format-FileSize $freed)" -Level SUCCESS + } + + # Report failures explicitly: ReportOnly promised these gigabytes, and staying + # silent would make a blocked deletion look like "there was nothing to clean" + if ($failed -gt 0) { + Write-Log "Kernel dumps: $failed of $($stale.Count) file(s) could not be deleted ($(Format-FileSize ($size - $freed)) left) - $firstError" -Level WARNING + $script:Stats.WarningsCount++ + } +} + +function Show-DiskSpaceReport { + <# + .SYNOPSIS + Reports where disk space went, including areas this script never deletes (v2.16) + .DESCRIPTION + Several of the largest consumers on a Windows workstation are things a cleanup + script must not touch: C:\Windows\Installer holds the data needed to uninstall + or repair applications, hiberfil.sys is sized by the OS, and the search index is + held open by its service. Reporting them is still valuable - without it the user + has no idea where tens of gigabytes went. + #> + Write-Log "Disk Space Report" -Level SECTION + + $targets = [ordered]@{ + 'Kernel live dumps' = Join-Path $env:SystemRoot 'LiveKernelReports' + 'MSI cache (keep)' = Join-Path $env:SystemRoot 'Installer' + 'Search index' = Join-Path $env:ProgramData 'Microsoft\Search\Data\Applications\Windows' + 'VS package cache' = Join-Path $env:ProgramData 'Package Cache' + 'Windows logs' = Join-Path $env:SystemRoot 'Logs' + 'Crash dumps (user)' = Join-Path $env:LOCALAPPDATA 'CrashDumps' + } + + $rows = @() + $unmeasured = @() + foreach ($name in $targets.Keys) { + $size = Get-FolderSizeChecked -Path $targets[$name] + if ($null -eq $size) { + $unmeasured += $name + continue + } + if ($size -gt 100MB) { $rows += [pscustomobject]@{ Item = $name; Bytes = $size } } + } + + foreach ($file in @('hiberfil.sys', 'pagefile.sys', 'swapfile.sys', 'MEMORY.DMP')) { + $path = if ($file -eq 'MEMORY.DMP') { Join-Path $env:SystemRoot $file } else { Join-Path $env:SystemDrive $file } + $item = Get-Item -LiteralPath $path -Force -ErrorAction SilentlyContinue + if ($item -and $item.Length -gt 100MB) { $rows += [pscustomobject]@{ Item = $file; Bytes = $item.Length } } + } + + # Shadow copies: CIM returns bytes, unlike vssadmin which prints them using the + # system decimal separator and would need locale-aware parsing + $shadow = Get-CimInstance Win32_ShadowStorage -ErrorAction SilentlyContinue + if ($shadow) { + $used = ($shadow | Measure-Object -Property UsedSpace -Sum).Sum + if ($used -gt 100MB) { $rows += [pscustomobject]@{ Item = 'Restore points / shadow copies'; Bytes = $used } } + } + + if ($unmeasured.Count -gt 0) { + Write-Log "Could not fully measure: $($unmeasured -join ', ') (access denied on some items)" -Level DETAIL + } + + if ($rows.Count -eq 0) { + if ($unmeasured.Count -eq 0) { + Write-Log "Nothing above 100 MB outside the cleaned areas" -Level DETAIL + } + return + } + + foreach ($row in ($rows | Sort-Object Bytes -Descending)) { + Write-Log "$($row.Item): $(Format-FileSize $row.Bytes)" -Level DETAIL + } +} + +function Get-SupersededDriverCandidate { + <# + .SYNOPSIS + Decides which parsed driver packages are superseded - pure logic, no I/O + .DESCRIPTION + Split out of Get-RedundantDriverPackage (v2.17, p.6/p.23 of the audit) so the + decision logic can be unit-tested against hand-built package arrays instead of + needing a real pnputil.exe and a real FileRepository to exercise it. + + A package is a candidate only when BOTH conditions hold: + 1. pnputil reports no device bound to it, and + 2. a package with a STRICTLY newer version and the same OriginalName exists + (a mere newer date at an equal version does not count - v2.18). + Packages without a newer sibling are left alone even when unused - they serve + devices that are merely unplugged right now (docks, printers, external storage). + That distinction is what separates this from the aggressive "driver cleaners" + that break machines. + + Grouped by INF *and* vendor/class. Generic names (usbaudio.inf, hidusb.inf) are + shipped by several vendors, and grouping on the name alone could declare one + vendor's package "superseded" by another's - then delete a working driver whose + device merely happens to be unplugged right now. + .PARAMETER Packages + Parsed pnputil packages: objects with Oem, Inf, Provider, Class, Version, Date, + InUse (the shape Get-RedundantDriverPackage builds from pnputil's XML). + .OUTPUTS + Candidate objects (a subset of the input, decorated with Bytes=0 and + KeptVersion). Bytes is filled in later by the caller once a FileRepository + folder is matched - this function never touches the filesystem. + #> + param([object[]]$Packages) + + $Packages | Group-Object { "$($_.Inf)|$($_.Provider)|$($_.Class)" } | ForEach-Object { + $newest = $_.Group | Sort-Object Version, Date -Descending | Select-Object -First 1 + foreach ($pkg in $_.Group) { + # v2.18: "superseded" means a STRICTLY NEWER version exists. A package tied at + # the newest version is kept even when unused - same-version duplicates are not + # proof of obsolescence, and removing one would be wider than the documented + # safety contract (older code deleted a same-version package that merely had an + # older date). Date is no longer a selection tie-breaker: every package at the + # max version is retained; it only decides which object represents $newest. + if ($pkg.InUse -or $pkg.Version -ge $newest.Version) { continue } + $pkg | Add-Member -NotePropertyName Bytes -NotePropertyValue ([long]0) -PassThru | + Add-Member -NotePropertyName KeptVersion -NotePropertyValue $newest.Version -PassThru + } + } +} + +function Get-RedundantDriverPackage { + <# + .SYNOPSIS + Finds superseded third-party driver packages in the driver store (v2.16) + .DESCRIPTION + Candidate selection itself lives in Get-SupersededDriverCandidate; this function + handles the I/O around it - running pnputil, parsing its XML, and matching each + candidate to a FileRepository folder for size reporting. + + Output is machine-readable XML on purpose: the plain text output of pnputil + switches between English and the system language depending on the console code + page, so it cannot be parsed reliably. + #> + $repo = Join-Path $env:SystemRoot 'System32\DriverStore\FileRepository' + + # v2.17: stderr is kept OUT of the XML. Merging it with 2>&1 meant a single + # warning line from pnputil produced invalid XML, the cast threw, and the driver + # store was skipped silently every week. + $stdOutFile = [System.IO.Path]::GetTempFileName() + $stdErrFile = [System.IO.Path]::GetTempFileName() + try { + $pnp = Start-Process -FilePath "$env:SystemRoot\System32\pnputil.exe" ` + -ArgumentList '/enum-drivers', '/devices', '/format', 'xml' ` + -NoNewWindow -PassThru -Wait ` + -RedirectStandardOutput $stdOutFile -RedirectStandardError $stdErrFile + + if ($pnp.ExitCode -ne 0) { + # Without this the failure looked exactly like "nothing to clean" + $err = (Get-Content $stdErrFile -Raw -ErrorAction SilentlyContinue) + Write-Log "pnputil /enum-drivers returned $($pnp.ExitCode) - driver store skipped" -Level WARNING + if ($err) { Write-Log "pnputil: $($err.Trim())" -Level DETAIL } + $script:Stats.WarningsCount++ + return @() + } + + $rawXml = Get-Content $stdOutFile -Raw -ErrorAction Stop + [xml]$doc = $rawXml + } catch { + Write-Log "Could not enumerate driver packages: $_" -Level WARNING + # Make diagnosis possible instead of leaving a bare exception + $head = if ($rawXml) { $rawXml.Substring(0, [Math]::Min(200, $rawXml.Length)) } else { '(no output)' } + Write-Log "pnputil output began with: $head" -Level DETAIL + $script:Stats.WarningsCount++ + return @() + } finally { + Remove-Item $stdOutFile, $stdErrFile -Force -ErrorAction SilentlyContinue + } + if (-not $doc.PnpUtil.Driver) { + Write-Log "pnputil returned no driver packages - unexpected, driver store skipped" -Level WARNING + $script:Stats.WarningsCount++ + return @() + } + + $skipped = 0 + $packages = foreach ($d in $doc.PnpUtil.Driver) { + $parts = $d.DriverVersion -split '\s+', 2 # "MM/dd/yyyy x.y.z.w" + if ($parts.Count -lt 2) { $skipped++; continue } + try { + # The date is only a tie-breaker for sorting, so an unparseable one must not + # discard the whole package - otherwise a date format change would silently + # empty the candidate list and report "nothing found" forever + $driverDate = [datetime]::MinValue + [void][datetime]::TryParse($parts[0], [cultureinfo]::InvariantCulture, + [System.Globalization.DateTimeStyles]::None, [ref]$driverDate) + [pscustomobject]@{ + Oem = $d.DriverName + Inf = $d.OriginalName + Provider = $d.ProviderName + Class = $d.ClassName + Version = [version]$parts[1] + Date = $driverDate + InUse = [bool]$d.Devices + } + } catch { $skipped++; continue } + } + + if ($skipped -gt 0) { + Write-Log "Driver store: $skipped package(s) could not be parsed and were ignored" -Level DETAIL + } + if (-not $packages) { + Write-Log "Driver store: no package could be parsed - skipping cleanup" -Level WARNING + $script:Stats.WarningsCount++ + return @() + } + + # p.6 of the audit: figure out WHICH packages are superseded first, from pnputil's + # own metadata alone (Get-SupersededDriverCandidate - no filesystem access needed + # for that). Only once there is at least one candidate do we pay for hashing + # FileRepository folders (700-1500 on a typical machine), and that walk stops as + # soon as every candidate is matched instead of always hashing every folder. + $candidates = @(Get-SupersededDriverCandidate -Packages $packages) + + if (-not $candidates) { + return @() + } + + # Hash each candidate's live INF once, up front: version strings are not unique + # (ibtusb.inf ships several packages carrying an identical DriverVer), so hashing + # the actual INF is the only exact oem*.inf -> FileRepository folder mapping. + $neededHashes = @{} # SHA256 -> candidate object + foreach ($pkg in $candidates) { + $infFile = Join-Path $env:SystemRoot "INF\$($pkg.Oem)" + if (-not (Test-Path -LiteralPath $infFile)) { continue } + try { + $hash = (Get-FileHash $infFile -Algorithm SHA256).Hash + $neededHashes[$hash] = $pkg + } catch { } + } + + foreach ($dir in (Get-ChildItem -LiteralPath $repo -Directory -ErrorAction SilentlyContinue)) { + if ($neededHashes.Count -eq 0) { break } + + $infName = ($dir.Name -split '\.inf_')[0] + '.inf' + $infPath = Join-Path $dir.FullName $infName + if (-not (Test-Path -LiteralPath $infPath)) { continue } + + try { + $hash = (Get-FileHash $infPath -Algorithm SHA256).Hash + if ($neededHashes.ContainsKey($hash)) { + $pkg = $neededHashes[$hash] + $size = (Get-ChildItem -LiteralPath $dir.FullName -Recurse -File -Force -ErrorAction SilentlyContinue | + Measure-Object -Property Length -Sum).Sum + $pkg.Bytes = [long]($size ?? 0) + $neededHashes.Remove($hash) + } + } catch { } + } + + return $candidates +} + +function Clear-DriverStore { + <# + .SYNOPSIS + Removes superseded driver packages from the driver store (v2.16) + #> + Write-Log "Driver Store" -Level SECTION + + $candidates = @(Get-RedundantDriverPackage) + if ($candidates.Count -eq 0) { + Write-Log "No superseded driver packages found" -Level DETAIL + return + } + + $totalBytes = ($candidates | Measure-Object -Property Bytes -Sum).Sum + + if ($ReportOnly) { + Write-Log "Would clean: $($candidates.Count) superseded driver package(s) - $(Format-FileSize $totalBytes)" -Level DETAIL + foreach ($group in ($candidates | Group-Object Inf | Sort-Object Count -Descending | Select-Object -First 5)) { + Write-Log " $($group.Name): $($group.Count) old version(s)" -Level DETAIL + } + return + } + + # Repository size before removal, used as the authoritative freed total whenever any + # removed package lacks a trustworthy per-package size (see the accounting below). + $repoPath = Join-Path $env:SystemRoot 'System32\DriverStore\FileRepository' + $repoBefore = Get-FolderSize -Path $repoPath + + $perPackageBytes = 0 + $removed = 0 + $failed = 0 + $allMeasured = $true + foreach ($pkg in $candidates) { + # No /force here: it deletes packages even when a device is using them, which is + # exactly how driver cleaners break systems. Exit code is the verdict - the text + # output is localized. + $null = & pnputil.exe /delete-driver $pkg.Oem 2>&1 + if ($LASTEXITCODE -eq 0) { + $removed++ + # v2.18: a candidate whose FileRepository folder was never matched (unmatched + # INF hash, or a shared hash that overwrote another candidate in the lookup) + # keeps Bytes=0. Summing those as-is understates the total, so remember that at + # least one removed package had no trustworthy size. + if ($pkg.Bytes -gt 0) { $perPackageBytes += $pkg.Bytes } else { $allMeasured = $false } + } else { + $failed++ + Write-Log "Skipped $($pkg.Oem) ($($pkg.Inf)): pnputil exit $LASTEXITCODE" -Level DETAIL + } + } + + if ($removed -gt 0) { + if ($allMeasured) { + # Every removed package had a matched, measured size - use the precise sum. + $freed = $perPackageBytes + } else { + # At least one removed package had no per-package size, so the sum would + # understate even when it is non-zero (the old "only fall back when total==0" + # missed exactly this partial case). The repository delta captures every + # removal at once and is authoritative here. + $freed = [math]::Max(0, $repoBefore - (Get-FolderSize -Path $repoPath)) + Write-Log "Removed $removed package(s); per-package size incomplete, driver store shrank by $(Format-FileSize $freed)" -Level WARNING + $script:Stats.WarningsCount++ + } + + $script:Stats.TotalFreedBytes += $freed + if (-not $script:Stats.FreedByCategory.ContainsKey("DriverStore")) { + $script:Stats.FreedByCategory["DriverStore"] = 0 + } + $script:Stats.FreedByCategory["DriverStore"] += $freed + Write-Log "Removed $removed superseded driver package(s) - $(Format-FileSize $freed)" -Level SUCCESS + } else { + Write-Log "No driver packages were removed" -Level DETAIL + } + + if ($failed -gt 0) { + Write-Log "Driver store: $failed of $($candidates.Count) package(s) refused removal" -Level WARNING + $script:Stats.WarningsCount++ + } +} + +function Measure-FreeSpaceGain { + <# + .SYNOPSIS + Runs an operation and attributes the freed disk space to a category (v2.17) + .DESCRIPTION + DISM component cleanup and Disk Cleanup are usually the two most productive + steps of a run, and neither reports how much it freed. Without this their + gigabytes were missing from "Space freed" entirely, so the summary understated + the result while looking complete. + + The measurement is deliberately coarse: it is the difference in free space on + the system drive, so unrelated activity during the operation adds noise, and a + negative delta is discarded rather than reported. + #> + param( + [Parameter(Mandatory)][scriptblock]$Operation, + [Parameter(Mandatory)][string]$Category + ) + + $driveLetter = ($env:SystemDrive).TrimEnd(':') + $before = try { (Get-PSDrive -Name $driveLetter -ErrorAction Stop).Free } catch { $null } + + & $Operation + + if ($null -eq $before -or $ReportOnly) { return } + $after = try { (Get-PSDrive -Name $driveLetter -ErrorAction Stop).Free } catch { $null } + if ($null -eq $after) { return } + + $gain = $after - $before + if ($gain -le 0) { return } + + $script:Stats.TotalFreedBytes += $gain + if (-not $script:Stats.FreedByCategory.ContainsKey($Category)) { + $script:Stats.FreedByCategory[$Category] = 0 + } + $script:Stats.FreedByCategory[$Category] += $gain + Write-Log "$Category freed approximately $(Format-FileSize $gain)" -Level DETAIL +} + +function Invoke-DISMCleanup { + <# + .SYNOPSIS + Runs DISM component cleanup + #> + # Clear any existing progress bar before DISM outputs to console + Clear-AllProgress + + Write-Log "Windows Component Cleanup (DISM)" -Level SECTION + + if ($ReportOnly) { + Write-Log "Would run: DISM /Online /Cleanup-Image /StartComponentCleanup /ResetBase" -Level DETAIL + Write-Log "Note: /ResetBase removes ability to uninstall updates" -Level WARNING + return + } + + # Analyze first: skip the expensive cleanup when DISM says it is not needed (v2.14). + # /English forces English output so the recommendation line is parseable on any locale. + # On analyze failure/timeout fall back to running the cleanup unconditionally. + Write-Log "Analyzing component store..." -Level INFO + + $runCleanup = $true + $analyzeFile = [System.IO.Path]::GetTempFileName() + try { + $analyzeProcess = Start-Process -FilePath "$env:SystemRoot\System32\Dism.exe" ` + -ArgumentList "/Online", "/English", "/Cleanup-Image", "/AnalyzeComponentStore" ` + -NoNewWindow -PassThru -RedirectStandardOutput $analyzeFile + + if ($analyzeProcess.WaitForExit(300000)) { + $analyzeOutput = Get-Content $analyzeFile -Raw -ErrorAction SilentlyContinue + if ($analyzeProcess.ExitCode -eq 0 -and + $analyzeOutput -match 'Component Store Cleanup Recommended\s*:\s*No') { + $runCleanup = $false + Write-Log "Component store is clean - cleanup not needed" -Level SUCCESS + } + } else { + $analyzeProcess.Kill($true) + } + } catch { + # Analyze failed - run the cleanup unconditionally (previous behavior) + } finally { + Remove-Item $analyzeFile -Force -ErrorAction SilentlyContinue + } + + if (-not $runCleanup) { + return + } + + Write-Log "Running DISM cleanup (this may take several minutes)..." -Level INFO + + # Redirect DISM output to a file - its progress bar corrupts the script's console UI + $dismOutFile = [System.IO.Path]::GetTempFileName() + try { + $dismProcess = Start-Process -FilePath "$env:SystemRoot\System32\Dism.exe" ` + -ArgumentList "/Online", "/Cleanup-Image", "/StartComponentCleanup", "/ResetBase" ` + -NoNewWindow -PassThru -RedirectStandardOutput $dismOutFile + + # Wait with timeout (15 minutes for DISM operation) + $timeoutMs = 900000 + if (-not $dismProcess.WaitForExit($timeoutMs)) { + $dismProcess.Kill($true) + Write-Log "DISM cleanup timed out after 15 minutes" -Level WARNING + $script:Stats.WarningsCount++ + return + } + + # v2.17: 3010 is the documented "success, reboot required" code that DISM returns + # routinely after /StartComponentCleanup - it used to fall through to the warning + # branch, painting every successful run yellow while never setting RebootRequired. + # Code 87 is ERROR_INVALID_PARAMETER (an unsupported switch combination), not + # "cleanup not needed" - reporting it as INFO hid a real failure completely. + switch ($dismProcess.ExitCode) { + 0 { Write-Log "DISM cleanup completed successfully" -Level SUCCESS } + 3010 { + Write-Log "DISM cleanup completed - a reboot is required to finish" -Level SUCCESS + $script:Stats.RebootRequired = $true + } + default { + $tail = (Get-Content $dismOutFile -Tail 3 -ErrorAction SilentlyContinue) -join ' ' + Write-Log "DISM failed with code $($dismProcess.ExitCode)$(if ($tail) { " - $tail" })" -Level WARNING + $script:Stats.WarningsCount++ + } + } + } catch { + Write-Log "DISM error: $_" -Level WARNING + $script:Stats.WarningsCount++ + } finally { + Remove-Item $dismOutFile -Force -ErrorAction SilentlyContinue + } +} + +function Get-ProcessActivityFingerprint { + <# + .SYNOPSIS + A comparable snapshot of how much work a process has actually done + .DESCRIPTION + v2.22. CPU time plus the three I/O operation counters, as one comparable string. + Two identical fingerprints taken far enough apart mean the process did literally + nothing in between. + + Win32_Process rather than System.Diagnostics.Process, established by measurement: + the .NET object exposes the processor times but leaves ReadOperationCount, + WriteOperationCount and OtherOperationCount empty, so a fingerprint built from it + would compare CPU alone. + + Returns $null when the counters cannot be read (WMI unavailable, the process gone, + access denied). The caller must treat $null as "cannot tell", never as "idle" - + otherwise a broken WMI would look exactly like a finished cleanup. + #> + param([int]$ProcessId) + + try { + $p = Get-CimInstance -ClassName Win32_Process -Filter "ProcessId=$ProcessId" ` + -Property KernelModeTime, UserModeTime, ReadOperationCount, + WriteOperationCount, OtherOperationCount ` + -ErrorAction Stop + if (-not $p) { return $null } + return '{0}|{1}|{2}|{3}|{4}' -f $p.KernelModeTime, $p.UserModeTime, + $p.ReadOperationCount, $p.WriteOperationCount, $p.OtherOperationCount + } catch { + return $null + } +} + +function Get-DiskCleanupActivityFingerprint { + <# + .SYNOPSIS + Activity fingerprint of the whole cleanmgr family, not just the process we started + .DESCRIPTION + v2.22, raised in review, then narrowed in the round after that. + + The concern is real: the handle we hold is not necessarily the only process doing + the work - cleanmgr was started with -WindowStyle Hidden and a Disk Cleanup window + appeared anyway. If a worker does the deleting while the process we started merely + waits, fingerprinting our PID alone would show a frozen parent and call a live + cleanup finished. + + The first attempt aggregated over every cleanmgr.exe on the machine, and that was + WORSE: a Disk Cleanup the user opens by hand is also called cleanmgr.exe, so an + unrelated process could both satisfy "activity was observed" for our run and, by + staying busy, keep our run waiting the full fifteen minutes after our own cleanup + had finished. It made the verdict depend on a process we know nothing about. + + So: our process and its descendants, and nothing else. Same protection against a + worker doing the deleting, no dependence on strangers. Children are matched by + parent PID regardless of name, since a worker need not be called cleanmgr.exe. + + Counters are summed as [long] rather than through Measure-Object, which returns a + Double and would silently lose integer precision past 2^53 (raised in review). + + $null when nothing can be read; the caller must treat that as "cannot tell". + + ACCEPTED LIMITS, stated rather than implied (raised in review). Process identity on + Windows is not exact: a process whose parent died and whose parent's PID was later + reused by our cleanmgr would be counted as family, and a worker started through a + service or COM rather than as a child would be missed. No process-tree scheme + avoids both. This is why the outcome is reported as observed idleness rather than + as proven completion, and why the registry sweep still refuses to touch a cleanmgr + that has not exited: every consumer of this signal is built to tolerate it being + wrong, which is the property that actually matters. + #> + param([int]$ProcessId) + + try { + $all = @(Get-CimInstance -ClassName Win32_Process ` + -Property ProcessId, ParentProcessId, KernelModeTime, UserModeTime, + ReadOperationCount, WriteOperationCount, OtherOperationCount ` + -ErrorAction Stop) + if (-not $all) { return $null } + + # Walk down from our PID. The seen-set both prevents rework and makes a cyclic + # parent chain (possible after PID reuse) terminate instead of spinning. + $byParent = @{} + foreach ($p in $all) { + $parent = [int]$p.ParentProcessId + if (-not $byParent.ContainsKey($parent)) { $byParent[$parent] = [System.Collections.Generic.List[object]]::new() } + $byParent[$parent].Add($p) + } + + $family = [System.Collections.Generic.List[object]]::new() + $seen = [System.Collections.Generic.HashSet[int]]::new() + $pending = [System.Collections.Generic.Queue[int]]::new() + $pending.Enqueue($ProcessId) + + while ($pending.Count -gt 0) { + $current = $pending.Dequeue() + if (-not $seen.Add($current)) { continue } + + $proc = $all | Where-Object { [int]$_.ProcessId -eq $current } | Select-Object -First 1 + if ($proc) { $family.Add($proc) } + + if ($byParent.ContainsKey($current)) { + foreach ($child in $byParent[$current]) { $pending.Enqueue([int]$child.ProcessId) } + } + } + + if ($family.Count -eq 0) { + # Our process is gone. The caller detects the exit on its own; "cannot tell" + # is the honest answer here, never "idle". + return $null + } + + [long]$kernel = 0; [long]$user = 0; [long]$read = 0; [long]$write = 0; [long]$other = 0 + foreach ($p in $family) { + $kernel += [long]$p.KernelModeTime + $user += [long]$p.UserModeTime + $read += [long]$p.ReadOperationCount + $write += [long]$p.WriteOperationCount + $other += [long]$p.OtherOperationCount + } + $pids = (($family | ForEach-Object { [int]$_.ProcessId } | Sort-Object) -join ',') + + return '{0}|{1}|{2}|{3}|{4}|{5}|{6}' -f $kernel, $user, $read, $write, $other, $family.Count, $pids + } catch { + return $null + } +} + +function Update-IdleStreak { + <# + .SYNOPSIS + Counts consecutive checks in which a process did nothing at all + .DESCRIPTION + v2.22, pure so the rule is testable without a process. Returns the new streak + length: one longer when the two fingerprints match, zero otherwise. + + An unreadable fingerprint on either side resets the streak. That is the whole + safety property: "I could not measure it" must never accumulate towards "it has + finished", or a machine with broken WMI would cut every Disk Cleanup short. + #> + param( + [AllowNull()][string]$Previous, + [AllowNull()][string]$Current, + [int]$Streak + ) + + if ([string]::IsNullOrEmpty($Previous) -or [string]::IsNullOrEmpty($Current)) { return 0 } + if ($Current -ceq $Previous) { return $Streak + 1 } + return 0 +} + +function Wait-CleanmgrCompletion { + <# + .SYNOPSIS + Waits for Disk Cleanup to finish its work, which is not the same as exiting + .DESCRIPTION + v2.22, split out in the style of Wait-StorageSenseTask so the wait can be tested + without a process and without waiting fifteen minutes: the caller injects how to + tell whether the process exited, how to read its activity, and how to wait. + + Two signals for "is there still something to wait for", because HasExited alone + was the wrong model of it. Note the distinction that matters throughout this + function: the MEASURED case below is described as what it was on that machine, + while what this code can PROVE in general is only the absence of observable + activity. The first is history, the second is the contract. + Measured on a live workstation: cleanmgr /sagerun did its work in about ten + seconds, closed its window, then stayed resident with CPU and all three I/O + counters frozen and every thread in Wait. The run sat out the remaining ~890 + seconds and then published the finished cleanup as partial. + + Returns Outcome ('exited' | 'idle-resident' | 'timeout') and Elapsed seconds. + 'idle-resident' names what was OBSERVED - the process was seen working and then + did nothing at all for long enough - not the conclusion drawn from it. That the + work is therefore over is an inference, and a process blocked on something + external would look identical, so nothing is built to depend on it being right. + 'timeout' means it was still visibly working when time ran out. + + 'idle-resident' additionally requires that activity was OBSERVED at least once + (raised in review). Without that, "it has finished" and "it has not started yet" + are the same observation - a process that is suspended, or waiting on something + before it begins, would be declared complete after two minutes of a stillness that + never followed any work. Since the first fingerprint is taken the moment cleanmgr + starts, a run that does anything at all changes it within the first interval. + #> + param( + [scriptblock]$HasExited, + [scriptblock]$GetFingerprint, + [int]$MaxWaitSeconds = 900, + [int]$CheckInterval = 10, + [int]$IdleChecksRequired = 12, + [scriptblock]$OnProgress = { param($seconds) }, + [scriptblock]$Wait = { param($seconds) Start-Sleep -Seconds $seconds } + ) + + $elapsed = 0 + $idleStreak = 0 + $sawActivity = $false + $fingerprint = & $GetFingerprint + + while (-not (& $HasExited) -and $elapsed -lt $MaxWaitSeconds) { + & $Wait $CheckInterval + $elapsed += $CheckInterval + + $previousFingerprint = $fingerprint + $fingerprint = & $GetFingerprint + $idleStreak = Update-IdleStreak -Previous $previousFingerprint -Current $fingerprint -Streak $idleStreak + + # A reset caused by two READABLE but different fingerprints is real work. A reset + # caused by an unreadable sample is not, and must not qualify the run for an early + # verdict later on. + if ($idleStreak -eq 0 -and $previousFingerprint -and $fingerprint) { $sawActivity = $true } + + if ($sawActivity -and $idleStreak -ge $IdleChecksRequired) { + return @{ Outcome = 'idle-resident'; Elapsed = $elapsed } + } + + if ($elapsed % 60 -eq 0) { & $OnProgress $elapsed } + } + + # Re-read rather than assume: the process may have exited during the last interval, + # and that is a cleaner answer than "timeout" for the same instant. + if (& $HasExited) { return @{ Outcome = 'exited'; Elapsed = $elapsed } } + return @{ Outcome = 'timeout'; Elapsed = $elapsed } +} + +function Select-StorageSenseTask { + <# + .SYNOPSIS + Picks the single Storage Sense task out of a lookup result + .DESCRIPTION + Pure decision, split out in v2.20 so the rule can be tested without a scheduler. + Windows ships exactly one task with this name; silently taking the first of + several means starting something nobody identified, so ambiguity yields no task + and says why. + Returns Task (the task or $null) and Reason ('ok' | 'none' | 'ambiguous'). + #> + param([object[]]$Tasks) + + $found = @($Tasks | Where-Object { $null -ne $_ }) + if ($found.Count -eq 0) { return @{ Task = $null; Reason = 'none' } } + if ($found.Count -gt 1) { return @{ Task = $null; Reason = 'ambiguous' } } + return @{ Task = $found[0]; Reason = 'ok' } +} + +function Test-StorageSenseEnabled { + <# + .SYNOPSIS + Whether Storage Sense is switched on in Settings + .DESCRIPTION + v2.22. Tri-state on purpose: $true / $false / $null for "cannot tell". Used ONLY + to explain what happened, never to decide whether Disk Cleanup runs - see the note + in Invoke-StorageSense for why that distinction matters. + + Verified on a live Windows 11 25H2 (build 26200): the value is still + HKCU\...\StorageSense\Parameters\StoragePolicy\01, and it was not renamed. + + HKCU is the user's hive, so a run under SYSTEM (a scheduled task) reads a + different one and gets nothing - which is exactly why the answer must be allowed + to be $null instead of defaulting to "off". + #> + try { + $policy = Get-ItemProperty -Path 'HKCU:\Software\Microsoft\Windows\CurrentVersion\StorageSense\Parameters\StoragePolicy' ` + -Name '01' -ErrorAction Stop + return [bool][int]$policy.'01' + } catch { + return $null + } +} + +function Get-StorageSenseVerdict { + <# + .SYNOPSIS + Decides whether a Storage Sense run counts as a cleanup that actually happened + .DESCRIPTION + Pure decision, split out in v2.20 so the rule can be tested. Two things must both + hold before Disk Cleanup is skipped: the task reported success, AND free space + actually grew. + A result of 0 on its own is not evidence. Storage Sense obeys its own settings; + switched off in Settings, the task still starts, does nothing it is not allowed to + do, and exits 0. Skipping all 23 cleanmgr handlers on that basis would free + nothing and report success - the defect this release exists to remove, and one + that only became reachable once this branch stopped being dead code. + $null TaskResult means "could not be read" and $null FreedBytes means "could not + be measured"; neither may be read as success. + Returns Done ($true only when cleanmgr may be skipped) and Reason + ('unreadable' | 'failed' | 'not-measured' | 'nothing-freed' | 'success'). + #> + param( + [AllowNull()][object]$TaskResult, + [AllowNull()][object]$FreedBytes + ) + + if ($null -eq $TaskResult) { return @{ Done = $false; Reason = 'unreadable' } } + + # [int] would THROW here, and precisely on the codes this function exists to catch. + # LastTaskResult is a UInt32 and every HRESULT failure has the high bit set, so its + # unsigned value exceeds Int32.MaxValue: the 0x80040154 cited above arrives as + # 2147746132. The exception escaped Invoke-StorageSense, Invoke-Phase recorded + # DeepSystemCleanup as failed, and Clear-WindowsOld never ran. + # The first test written for this passed the PowerShell literal 0x80040154, which the + # parser types as Int32 -2147221164 - so the cast succeeded and the test was green + # BECAUSE of the defect. Tests for this function must use the production type. + $resultValue = [long]0 + if (-not [long]::TryParse([string]$TaskResult, [ref]$resultValue)) { + return @{ Done = $false; Reason = 'unreadable' } + } + if ($resultValue -ne 0) { return @{ Done = $false; Reason = 'failed' } } + if ($null -eq $FreedBytes) { return @{ Done = $false; Reason = 'not-measured' } } + if ([long]$FreedBytes -le 0) { return @{ Done = $false; Reason = 'nothing-freed' } } + return @{ Done = $true; Reason = 'success' } +} + +function Wait-StorageSenseTask { + <# + .SYNOPSIS + Waits for the Storage Sense task to stop running + .DESCRIPTION + Split out in v2.20 so the wait can be tested without a scheduler and without + actually waiting two minutes: the caller injects how to read the task, how to + read its info, and how to wait. + + 'vanished' is a distinct outcome because the loop used to break on a task that + disappeared while leaving its finished flag false, so the caller announced "did + not finish within 120 seconds" after five - a number that never happened. + + Returns Outcome ('finished' | 'vanished' | 'timeout' | 'unverifiable'), Elapsed + seconds and the last Task seen. 'unverifiable' means the task was never observed + running AND its previous run time could not be read before the start, so there is + nothing to compare against - it is not a failure, but it is not evidence of a run + either, and the caller must not treat it as one. + #> + param( + [scriptblock]$GetTask, + [scriptblock]$GetTaskInfo, + [AllowNull()][object]$LastRunBefore, + [int]$TimeoutSeconds = 120, + [int]$CheckInterval = 5, + [scriptblock]$Wait = { param($seconds) Start-Sleep -Seconds $seconds } + ) + + $elapsed = 0 + $wasRunning = $false + $task = $null + + while ($elapsed -lt $TimeoutSeconds) { + & $Wait $CheckInterval + $elapsed += $CheckInterval + + # Task states are language-independent: Ready, Running, Disabled + $task = & $GetTask + if (-not $task) { + return @{ Outcome = 'vanished'; Elapsed = $elapsed; Task = $null } + } + + if ($task.State -eq 'Running') { + $wasRunning = $true + continue + } + + if ($wasRunning) { + # Was running and is not any more + return @{ Outcome = 'finished'; Elapsed = $elapsed; Task = $task } + } + + if ($elapsed -ge 10) { + # Never observed as Running - it may simply have been quicker than the poll + # interval. The task's own LastRunTime moving is the evidence. + # + # Raised in review: without a baseline there is no evidence to weigh, and the + # old disjunction let the MISSING baseline satisfy the test, because -not $null + # is true. Any readable task info then returned "finished" for a task that may + # never have started - a false success that goes on to skip all 23 cleanmgr + # handlers. + # + # Corrected again in the next review round: the first repair returned here at + # once, which gave a slow-starting task no chance to be seen and let cleanmgr + # start alongside it. Keep watching instead - being observed Running is direct + # evidence and needs no baseline - and only answer "unverifiable" if the whole + # window passes without ever seeing it. + if ($null -ne $LastRunBefore) { + $infoNow = & $GetTaskInfo $task + if ($infoNow -and $infoNow.LastRunTime -ne $LastRunBefore) { + return @{ Outcome = 'finished'; Elapsed = $elapsed; Task = $task } + } + } + } + } + + # Never seen running, and there was no previous run time to compare against: the window + # is over and nothing about this invocation was ever observed. That is not a timeout in + # the useful sense - there is simply no evidence either way, and the caller must not + # read it as one. + if (-not $wasRunning -and $null -eq $LastRunBefore) { + return @{ Outcome = 'unverifiable'; Elapsed = $elapsed; Task = $task } + } + + return @{ Outcome = 'timeout'; Elapsed = $elapsed; Task = $task } +} + +function Invoke-StorageSense { + <# + .SYNOPSIS + Runs Storage Sense cleanup + #> + Write-Log "Storage Sense" -Level SECTION + + # v2.20: the one step users asked to be able to switch off on its own. Until now the + # only way was -SkipCleanup, which also suppresses temp files, browsers, dev caches, + # Docker/WSL, Visual Studio and the driver store - everything, to avoid one step. + if ($ReportOnly) { + Write-Log "Would run: Storage Sense" -Level DETAIL + return + } + + # cleanmgr's saved selection lives under this sageset. Both are needed up front: the + # sweep below runs on every path that is allowed to change the system, not only when + # cleanmgr is reached. (-ReportOnly returns above because it promises to change + # nothing at all; -SkipDiskCleanup returns AFTER the sweep, see below.) + $sageset = 9999 + $regPath = "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\VolumeCaches" + + # Sweep leftovers from a previous run before doing anything else. The cleanmgr branch + # deliberately skips its own sweep while cleanmgr is still running (it would pull the + # configuration out from under it), and Storage Sense returns before that branch is + # reached - so a timed-out run followed only by successful Storage Sense runs would + # otherwise leave these flags in the registry forever. Caught in review before release. + # + # Gated on cleanmgr not running: a previous run's cleanmgr may still be working in the + # background, and sweeping now would pull its configuration out from under it - which + # is precisely what the finally below refuses to do (also raised in review). + if (-not (Get-Process -Name 'cleanmgr' -ErrorAction SilentlyContinue)) { + Get-ChildItem -Path $regPath -ErrorAction SilentlyContinue | ForEach-Object { + Remove-ItemProperty -Path $_.PSPath -Name "StateFlags$sageset" -Force -ErrorAction SilentlyContinue + } + } + + # After the sweep, deliberately (raised in review). The flag means "do not run this + # step", not "do not touch anything": returning before the sweep left a timed-out + # previous run's StateFlags in the registry forever, which is the exact case the sweep + # was added for. + if ($SkipDiskCleanup) { + Write-Log "Storage Sense / Disk Cleanup skipped (parameter)" -Level INFO + $script:Stats.DiskCleanupStatus = 'skipped-parameter' + return + } + + # Try Storage Sense first (Windows 11) + # + # v2.20: the task was looked up at "\Microsoft\Windows\DiskCleanup\", where it does not + # exist - that folder holds SilentCleanup. The real one lives under + # "\Microsoft\Windows\DiskFootprint\". So this branch was UNREACHABLE on every machine + # and every run fell through to the legacy cleanmgr path: 10 seconds on a fresh VM, + # 15 minutes on a real workstation (measured: 901s of a 1101s run, and it did not even + # finish). Searching by name instead of by a hardcoded path also survives the folder + # moving again. + $ssTaskName = "StorageSense" + $ssTasks = @(Get-ScheduledTask -TaskName $ssTaskName -ErrorAction SilentlyContinue) + # Windows ships exactly one. If a machine somehow has more, say so and take none: + # silently picking the first of several tasks with the same name means starting + # something nobody identified (raised in review). + $ssSelection = Select-StorageSenseTask -Tasks $ssTasks + + # Set by every branch that has already said why cleanmgr is being used, so the + # fallback below cannot contradict it. Raised in review: an ambiguous lookup logged + # both "2 tasks with that name - not guessing" and, two lines later, "task not found". + $ssExplained = $false + + if ($ssSelection.Reason -eq 'ambiguous') { + $ssExplained = $true + Write-Log "Storage Sense: $($ssTasks.Count) tasks with that name ($(($ssTasks | ForEach-Object { $_.TaskPath }) -join ', ')) - not guessing, using Disk Cleanup" -Level INFO + } + $task = $ssSelection.Task + + # v2.20, corrected in review: pin the exact task. Later lookups searched by name only + # and took the first hit, which quietly undid the refusal-to-guess rule above the + # moment a second same-named task appeared while we were waiting. + $ssTaskPath = if ($task) { $task.TaskPath } else { $null } + + # Raised in review, then measured: -TaskPath rejects $null with a binding error that + # -ErrorAction SilentlyContinue does NOT suppress, so a task object without a path + # would turn every later lookup into an exception - and the wait would read that as + # "the task vanished". The path is only passed when there is one. + $ssLookup = @{ TaskName = $ssTaskName; ErrorAction = 'SilentlyContinue' } + if ($ssTaskPath) { $ssLookup['TaskPath'] = $ssTaskPath } + + # Verified below; only a task that demonstrably ran successfully skips cleanmgr + $storageSenseDone = $false + + # A disabled task cannot be started - fall back to cleanmgr instead of + # waiting the full timeout for a task that never runs (v2.14) + if ($task -and $task.State -ne 'Disabled') { + Write-Log "Running Storage Sense..." -Level INFO + + # v2.20: compare against the task's OWN previous run time, not wall-clock. The old + # code compared LastRunTime with $startTime taken in this process, which is a + # different clock granularity and rounds the wrong way. + $infoBefore = $task | Get-ScheduledTaskInfo -ErrorAction SilentlyContinue + $lastRunBefore = if ($infoBefore) { $infoBefore.LastRunTime } else { $null } + + # Free space before the task, so its success can be judged on evidence rather than + # on an exit code (see the verification below) + $sysDriveLetter = ($env:SystemDrive).TrimEnd(':') + $freeBefore = $null + try { $freeBefore = (Get-PSDrive -Name $sysDriveLetter -ErrorAction Stop).Free } catch { $freeBefore = $null } + + $started = $false + try { + $task | Start-ScheduledTask -ErrorAction Stop + $started = $true + } catch { + # v2.20: the start used to be -ErrorAction SilentlyContinue with the result + # ignored, so a task that never started still cost the full 120s wait before + # anything was reported + Write-Log "Storage Sense could not be started ($($_.Exception.Message)) - using Disk Cleanup" -Level INFO + $ssExplained = $true + } + + if ($started) { + $timeout = 120 # 2 minutes max + $waitResult = Wait-StorageSenseTask ` + -GetTask { @(Get-ScheduledTask @ssLookup) | Select-Object -First 1 } ` + -GetTaskInfo { param($t) $t | Get-ScheduledTaskInfo -ErrorAction SilentlyContinue } ` + -LastRunBefore $lastRunBefore ` + -TimeoutSeconds $timeout ` + -CheckInterval 5 + # Only overwrite when the wait actually saw a task: on 'vanished' the original + # selection is kept, so the fallback below does not also announce "task not + # found" for something that was found and then disappeared. + if ($waitResult.Task) { $task = $waitResult.Task } + + if ($waitResult.Outcome -eq 'finished') { + # v2.20 fail-closed: a task that ran and FAILED used to be logged as + # "Storage Sense completed" and cleanmgr was skipped - a silent failure that + # would have left the machine uncleaned while the run reported success. + # Verified on a live machine where this task returns 0x80040154. + $infoAfter = $task | Get-ScheduledTaskInfo -ErrorAction SilentlyContinue + $taskResult = if ($infoAfter) { $infoAfter.LastTaskResult } else { $null } + + # $null means "could not be measured", which is not the same answer as + # "freed nothing" - the old code collapsed both into 0. + $freedBySense = $null + try { + $freeAfter = (Get-PSDrive -Name $sysDriveLetter -ErrorAction Stop).Free + if ($null -ne $freeBefore) { $freedBySense = $freeAfter - $freeBefore } + } catch { $freedBySense = $null } + + $verdict = Get-StorageSenseVerdict -TaskResult $taskResult -FreedBytes $freedBySense + $storageSenseDone = $verdict.Done + + switch ($verdict.Reason) { + 'success' { Write-Log "Storage Sense completed - $(Format-FileSize $freedBySense)" -Level SUCCESS } + 'unreadable' { Write-Log "Storage Sense ran but its result could not be read - using Disk Cleanup as well" -Level INFO } + 'failed' { Write-Log ("Storage Sense failed (task result 0x{0:X8}) - using Disk Cleanup instead" -f $taskResult) -Level INFO } + 'not-measured' { Write-Log "Storage Sense reported success but free space could not be measured - running Disk Cleanup as well" -Level INFO } + 'nothing-freed' { + # v2.22 (MyAI-1qtn): say WHICH of the two states this is. "Returned + # 0 and freed nothing" covers both "switched off, so it did nothing" + # and "switched on, ran, and there was nothing left to free" - and + # on a regularly maintained machine the second happens every time, + # which looked like a malfunction to the person reading the log. + # + # Reported, NOT acted on. The proposal was to trust an enabled + # Storage Sense and skip Disk Cleanup, and checking what that would + # cost settled it: the categories armed below include Update + # Cleanup, memory dumps, Language Pack, old ChkDsk files and Windows + # Error Reporting - none of which Storage Sense touches at all. A + # maintained machine would silently stop having them cleaned. + switch (Test-StorageSenseEnabled) { + $true { Write-Log "Storage Sense is enabled and ran, but there was nothing left for it to free - running Disk Cleanup as well for the categories it does not cover" -Level INFO } + $false { Write-Log "Storage Sense is switched off in Settings, so its run freed nothing - using Disk Cleanup instead" -Level INFO } + default { Write-Log "Storage Sense reported success but freed nothing measurable, and its setting could not be read - running Disk Cleanup as well" -Level INFO } + } + } + default { Write-Log "Storage Sense verdict '$($verdict.Reason)' not recognised - running Disk Cleanup as well" -Level INFO } + } + } elseif ($waitResult.Outcome -eq 'vanished') { + # v2.20, corrected in review: this path used to fall into the timeout + # message below, so a task that disappeared after five seconds was + # reported as having failed to finish within 120 - a number that never + # happened on that run. + Write-Log "Storage Sense task disappeared while being watched - using Disk Cleanup instead" -Level INFO + } elseif ($waitResult.Outcome -eq 'unverifiable') { + Write-Log "Storage Sense: its previous run time could not be read, so there is no way to tell whether this run happened - using Disk Cleanup instead" -Level INFO + } else { + # WARNING, not INFO (restored in review): falling back to cleanmgr is a + # fine outcome, but this particular one leaves a task we asked for and + # could not account for, and cleanmgr is about to start alongside it. The + # v2.20 draft downgraded this to INFO and dropped the counter, so a run + # where Storage Sense hung for two minutes printed COMPLETED SUCCESSFULLY + # in green. + Write-Log "Storage Sense did not finish within $timeout seconds - using Disk Cleanup instead" -Level WARNING + $script:Stats.WarningsCount++ + + $task = @(Get-ScheduledTask @ssLookup) | Select-Object -First 1 + if ($task -and $task.State -eq 'Running') { + # The stop used to be fire-and-forget with an unconditional "stopped" + # line after it (raised in review). Two cleaners deleting at once is + # exactly the state the free-space accounting cannot describe, so the + # claim is now made only when the task actually stopped. + $task | Stop-ScheduledTask -ErrorAction SilentlyContinue + $taskAfterStop = @(Get-ScheduledTask @ssLookup) | Select-Object -First 1 + if ($taskAfterStop -and $taskAfterStop.State -eq 'Running') { + Write-Log "Storage Sense task could not be stopped and is still running - Disk Cleanup will run alongside it, so the freed figures below cover both" -Level WARNING + $script:Stats.WarningsCount++ + } else { + Write-Log "Storage Sense task stopped" -Level INFO + } + } + } + + # Every branch above has stated its own reason + $ssExplained = $true + } + } + + if ($storageSenseDone) { + # Storage Sense demonstrably did the work, so cleanmgr is not run at all. Recorded + # so the JSON distinguishes this from "Disk Cleanup ran and completed" - they free + # different things, and a consumer comparing runs needs to know which one happened. + $script:Stats.DiskCleanupStatus = 'storage-sense' + } + + if (-not $storageSenseDone) { + # Fallback to cleanmgr. Every other reason for landing here (ambiguous lookup, + # start failed, task failed, timed out, vanished, unverifiable) has already said + # so in its own words and set $ssExplained, so only the two states that produce no + # message of their own are reported here. + if (-not $ssExplained) { + if (-not $task) { + Write-Log "Storage Sense task not found, using Disk Cleanup..." -Level INFO + } elseif ($task.State -eq 'Disabled') { + Write-Log "Storage Sense task is disabled, using Disk Cleanup..." -Level INFO + } + } + + # Configure cleanup categories ($sageset / $regPath are set at the top of the + # function, because the leftover sweep needs them on every path) + + # Note (v2.14): "Previous Installations" removed - Windows.old deletion must go + # through Clear-WindowsOld which asks for user confirmation. + # "Windows ESD installation files" removed - ESD files are needed for "Reset this PC". + # + # v2.16: list reconciled with the actual registry on Windows 11 25H2. + # Removed (no such handler exists, they were silently skipped by Test-Path): + # "Memory Dump Files", "Windows Error Reporting Archive Files", + # "Windows Error Reporting Queue Files" + # Added: "Windows Error Reporting Files" (the real handler name), + # "D3D Shader Cache", "Language Pack", "Windows Reset Log Files", + # "Feedback Hub Archive log files", "Diagnostic Data Viewer database files", + # "RetailDemo Offline Content". + # Deliberately NOT added, despite existing in the registry: + # "DownloadsFolder" - that is the user's Downloads folder + # "Device Driver Packages" - cleanmgr picks driver packages by its own closed + # heuristic, which would bypass the conservative rule + # in Clear-DriverStore (unused AND superseded) and is + # neither previewable nor measurable + # "Delivery Optimization Files" - handled by Clear-SystemCaches via the + # supported cmdlet, with measurable statistics + # "Windows Defender", "Content Indexer Cleaner", "Offline Pages Files" + # - security/search state, no meaningful gain + $categories = @( + "Active Setup Temp Folders", "BranchCache", "D3D Shader Cache", + "Diagnostic Data Viewer database files", + "Downloaded Program Files", "Feedback Hub Archive log files", + "Internet Cache Files", "Language Pack", "Old ChkDsk Files", + "Recycle Bin", "RetailDemo Offline Content", "Setup Log Files", + "System error memory dump files", "System error minidump files", + "Temporary Files", "Temporary Setup Files", "Thumbnail Cache", + "Update Cleanup", "Upgrade Discarded Files", "User file versions", + "Windows Error Reporting Files", "Windows Reset Log Files", + "Windows Upgrade Log Files" + ) + + # Defined before the try so the finally can always ask whether it exists + $cleanmgr = $null + try { + # Set StateFlags for cleanup categories. + # v2.16: count what was actually armed. Previously a failed write was + # swallowed, and cleanmgr would run with an empty category set, exit 0 and + # get logged as a success while doing nothing at all. + $armed = 0 + foreach ($category in $categories) { + $categoryPath = Join-Path $regPath $category + if (-not (Test-Path $categoryPath)) { + Write-Log "Disk Cleanup handler not present: $category" -Level DETAIL + continue + } + try { + Set-ItemProperty -Path $categoryPath -Name "StateFlags$sageset" -Value 2 -Type DWord -Force -ErrorAction Stop + $armed++ + } catch { + Write-Log "Could not arm Disk Cleanup handler '$category': $_" -Level WARNING + $script:Stats.WarningsCount++ + } + } + + if ($armed -eq 0) { + Write-Log "No Disk Cleanup handlers could be armed - skipping cleanmgr" -Level WARNING + $script:Stats.WarningsCount++ + $script:Stats.DiskCleanupStatus = 'failed' + return + } + + # Run cleanmgr with progress feedback and reasonable timeout. + # + # Raised in review, then measured: Start-Process on a missing or blocked + # executable leaves the variable $null, and $null.HasExited is also $null - so + # "-not $cleanmgr.HasExited" is TRUE and the loop below reported progress every + # minute for the full fifteen, then set DiskCleanupPending and warned that an + # elevated process was still deleting. All for a process that never started. + # Reachable on Server SKUs without Desktop Experience and under AppLocker/WDAC. + $cleanmgr = $null + try { + $cleanmgr = Start-Process -FilePath "cleanmgr.exe" -ArgumentList "/sagerun:$sageset" ` + -WindowStyle Hidden -PassThru -ErrorAction Stop + } catch { + $cleanmgr = $null + } + + if (-not $cleanmgr) { + Write-Log "Disk Cleanup could not be started (cleanmgr.exe is missing or blocked) - it cleaned nothing" -Level WARNING + $script:Stats.WarningsCount++ + $script:Stats.DiskCleanupStatus = 'failed' + return + } + + # v2.16: raised from 420s. cleanmgr regularly needs longer on a workstation + # with a large component store, and killing it produced a warning on every + # single run while cleanmgr kept working in the background anyway. + # + # v2.22: waiting on HasExited alone was the wrong model of "there is still + # something to wait for". Measured on a live workstation: cleanmgr /sagerun did + # its work in about ten seconds, closed its window and then simply stayed + # resident - CPU, all three I/O counters and its six threads frozen, every + # thread in Wait. The run sat here for the remaining ~890 seconds and then + # declared the cleanup partial. Both halves of that are wrong, and the second + # is the worse one: it is the same class of dishonest report v2.20 and v2.21 + # were spent removing, only inverted - not success that never happened, but + # incompleteness that was never observed either. + # + # So a second, independent signal to stop waiting: total stillness. If the + # process has done no CPU work and no I/O at all across $idleChecksRequired + # consecutive checks, there is nothing left to wait for that we can observe. + # That is weaker than "it has finished", and is reported as such. + $maxWait = 900 # 15 minutes + $checkInterval = 10 + # Two full minutes of absolute stillness. Deliberately far longer than needed + # to observe the measured case: a process mid-delete moves at least the "other + # operations" counter, so this is not a race with slow work - it is a margin + # against a pause nobody has observed yet. The cost of being wrong is bounded + # anyway: the registry sweep below still refuses to touch a process that has + # not exited, so a premature verdict cannot pull configuration out from under + # a cleanmgr that turns out to be working after all. + $idleChecksRequired = 12 + + $waitOutcome = Wait-CleanmgrCompletion ` + -HasExited { $cleanmgr.HasExited } ` + -GetFingerprint { Get-DiskCleanupActivityFingerprint -ProcessId $cleanmgr.Id } ` + -MaxWaitSeconds $maxWait -CheckInterval $checkInterval -IdleChecksRequired $idleChecksRequired ` + -OnProgress { param($seconds) Write-Log "Disk Cleanup still running... ($seconds seconds)" -Level INFO } + + $elapsed = $waitOutcome.Elapsed + $finishedWhileResident = $waitOutcome.Outcome -eq 'idle-resident' + + if ($cleanmgr.HasExited -and $cleanmgr.ExitCode -ne 0) { + # v2.16: the exit code used to be ignored entirely, so a crash one second + # in was still logged as a success + Write-Log "Disk Cleanup exited with code $($cleanmgr.ExitCode) - results unverified" -Level WARNING + $script:Stats.WarningsCount++ + $script:Stats.DiskCleanupStatus = 'failed' + } elseif ($cleanmgr.HasExited) { + Write-Log "Disk Cleanup completed ($armed categories)" -Level SUCCESS + $script:Stats.DiskCleanupStatus = 'completed' + } elseif ($finishedWhileResident) { + # Deliberately worded as an OBSERVATION, not a conclusion (raised in the + # third review pass). What was measured is that cleanmgr and its children + # did nothing at all for two minutes after having been seen working; that + # they FINISHED is the inference, and the earlier wording ("completed") + # stated the inference as fact. This project has spent three releases + # removing exactly that habit, so the status is 'idle-resident' and the log + # says what was seen. + # + # DiskCleanupPending stays false, which is the best available estimate: a + # process performing no CPU work and no I/O is not deleting anything. It is + # an estimate rather than a certainty, and that is why it is not called + # completion. Not a warning either - on the machine where this was measured + # it is the normal ending, and warning on every run would be noise. + Write-Log "Disk Cleanup ($armed categories): worked, then stopped doing anything for $($idleChecksRequired * $checkInterval) seconds" -Level SUCCESS + Write-Log "cleanmgr.exe is still in the process list but shows no CPU or I/O activity - treating its work as over and not waiting out the remaining $($maxWait - $elapsed) seconds" -Level DETAIL + $script:Stats.DiskCleanupStatus = 'idle-resident' + } else { + # Genuinely still working when the timeout expired. Killing it would be + # worse - cleanmgr keeps working after a kill and the deletion is + # mid-flight (v2.16). But this is not an informational event either: + # everything measured after this point is partial, the run is about to + # print a total and write its JSON while an elevated process is still + # deleting, and the freed bytes it goes on to reclaim are counted by nobody. + $script:Stats.DiskCleanupPending = $true + Write-Log "Disk Cleanup exceeded $maxWait seconds and is still running - it continues in the background, so the freed figures below are partial" -Level WARNING + $script:Stats.WarningsCount++ + $script:Stats.DiskCleanupStatus = 'timeout' + } + } finally { + # Remove StateFlags to avoid leaving traces in the registry. + # v2.16: sweep every handler, not just the ones from $categories - flags left + # by an interrupted run or by an older version of this list stayed forever + # (four such leftovers were found on a live machine). + # + # v2.20: but NOT while cleanmgr is still running. The timeout branch above + # deliberately leaves it working in the background, and this sweep then pulled + # its configuration out from under it. Whether cleanmgr re-reads the flags per + # handler or only once at startup is not something to guess at while it holds + # an elevated deletion loop. The flags are swept by the next run's own sweep, + # which is exactly the leftover case v2.16 added it for. + if ($cleanmgr -and -not $cleanmgr.HasExited) { + Write-Log "Disk Cleanup is still running - its registry configuration will be swept by the next run" -Level DETAIL + } else { + Get-ChildItem -Path $regPath -ErrorAction SilentlyContinue | ForEach-Object { + Remove-ItemProperty -Path $_.PSPath -Name "StateFlags$sageset" -Force -ErrorAction SilentlyContinue + } + } + } + } +} + +#endregion + +#region ═══════════════════════════════════════════════════════════════════════ +# MAIN EXECUTION +#═══════════════════════════════════════════════════════════════════════════════ + +function Show-Banner { + try { Clear-Host } catch { } + + # v2.22: composed instead of pasted. As one literal here-string its padding was + # hand-counted for a four-character version - the title row measured 70 columns only + # because "2.21" happens to be four characters, and a version like 2.5 or 2.100 would + # have pushed that row's right border out of line with the rest of the box. Composing + # it fixes that and lets the frame follow the console width like every other box. + $inner = $script:BoxWidth + + # Centred as a BLOCK, never line by line: the rows have different lengths by design + # and centring each one would shear the letters apart. 70 is the block's historical + # width, so at the default width the banner is drawn exactly where it always was. + $artBlockWidth = 70 + $art = @( + '' + ' ██████╗██╗ ███████╗ █████╗ ███╗ ██╗' + ' ██╔════╝██║ ██╔════╝██╔══██╗████╗ ██║' + ' ██║ ██║ █████╗ ███████║██╔██╗ ██║' + ' ██║ ██║ ██╔══╝ ██╔══██║██║╚██╗██║' + ' ╚██████╗███████╗███████╗██║ ██║██║ ╚████║' + ' ╚═════╝╚══════╝╚══════╝╚═╝ ╚═╝╚═╝ ╚═══╝' + '' + ) + $artPad = [math]::Max(0, [math]::Floor(($inner - $artBlockWidth) / 2)) + + $title = "Ultimate Windows 11 Maintenance Script v$($script:Version)" + $titlePad = [math]::Max(0, [math]::Floor(($inner - $title.Length) / 2)) + + $rows = foreach ($line in $art) { (' ' * $artPad) + $line.PadRight($artBlockWidth) } + $rows = @($rows) + @((' ' * $titlePad) + $title) + @('') + + $bannerLines = @(" ╔$('═' * $inner)╗") + foreach ($row in $rows) { + # Pad, then truncate: a row must fill the frame exactly. Anything longer would + # push the right border out, which is the defect this rewrite removes - so a row + # that cannot fit is cut rather than allowed to break the box. + $cell = $row.PadRight($inner) + if ($cell.Length -gt $inner) { $cell = $cell.Substring(0, $inner) } + $bannerLines += " ║$cell║" + } + $bannerLines += " ╚$('═' * $inner)╝" + + Write-Host "" + Write-Host ($bannerLines -join [Environment]::NewLine) -ForegroundColor Cyan + Write-Host "" + + # System info + $os = Get-CimInstance -ClassName Win32_OperatingSystem + $osVersion = $os.Caption + $osBuild = $os.BuildNumber + + Write-Host " System: $osVersion (Build $osBuild)" -ForegroundColor DarkGray + Write-Host " PowerShell: $($PSVersionTable.PSVersion)" -ForegroundColor DarkGray + Write-Host " Started: $(Get-Date -Format 'dd.MM.yyyy HH:mm:ss')" -ForegroundColor DarkGray + Write-Host " Log: $script:LogPath" -ForegroundColor DarkGray + + if ($ReportOnly) { + Write-Host "" + Write-Host " >>> REPORT MODE - No changes will be made <<<" -ForegroundColor Yellow + } + + Write-Host "" +} + +function Show-FinalStatistics { + <# + .DESCRIPTION + v2.17 (p.21 of the audit): this runs from Start-WinClean's finally block, so any + exception here (a Get-PSDrive provider returning 0 for both Used and Free, for + instance, dividing by zero below) would REPLACE whatever exception the try block + was already reporting - the one the user actually needs to see. The whole body + is wrapped so this function can never mask that. + #> + try { + Show-FinalStatisticsBody + } catch { + Write-Host "" + Write-Host " Could not display the final summary: $_" -ForegroundColor Yellow + Write-Host " Log: $script:LogPath" -ForegroundColor DarkGray + } +} + +function Show-FinalStatisticsBody { + $elapsed = (Get-Date) - $script:Stats.StartTime + $elapsedStr = "{0:D2}:{1:D2}:{2:D2}" -f [int]$elapsed.Hours, $elapsed.Minutes, $elapsed.Seconds + + # Get disk info + $drive = Get-PSDrive -Name $env:SystemDrive.Replace(':', '') + $freeSpace = [math]::Round($drive.Free / 1GB, 2) + $totalSize = [math]::Round(($drive.Used + $drive.Free) / 1GB, 2) + $capacity = $drive.Used + $drive.Free + $freePercent = if ($capacity -gt 0) { [math]::Round(($drive.Free / $capacity) * 100, 1) } else { 0 } + + Clear-AllProgress + + # Box dimensions + $boxWidth = $script:BoxWidth # Inner width (matches banner) + $labelWidth = 18 # Width for label column (e.g., "Space freed:") + + # Determine overall status + $hasErrors = $script:Stats.ErrorsCount -gt 0 + $hasWarnings = $script:Stats.WarningsCount -gt 0 + $statusText = if ($hasErrors) { "COMPLETED WITH ERRORS" } elseif ($hasWarnings) { "COMPLETED WITH WARNINGS" } else { "COMPLETED SUCCESSFULLY" } + $headerColor = if ($hasErrors) { "Red" } elseif ($hasWarnings) { "Yellow" } else { "Green" } + + Write-Host "" + + # Header with Cyan frame, status-colored text + $titlePadding = [math]::Max(0, $boxWidth - $statusText.Length) + $leftPad = [math]::Floor($titlePadding / 2) + $rightPad = $titlePadding - $leftPad + + Write-Host " ╔$("═" * $boxWidth)╗" -ForegroundColor Cyan + Write-Host " ║" -NoNewline -ForegroundColor Cyan + Write-Host (" " * $leftPad) -NoNewline + Write-Host $statusText -NoNewline -ForegroundColor $headerColor + Write-Host (" " * $rightPad) -NoNewline + Write-Host "║" -ForegroundColor Cyan + Write-Host " ╠$("═" * $boxWidth)╣" -ForegroundColor Cyan + + # Helper function for consistent line formatting with icons + function Write-StatLine { + param( + [string]$Icon, + [string]$Label, + [string]$Value, + [string]$IconColor = "Cyan", + [string]$ValueColor = "Green" + ) + # $labelWidth is inherited from parent scope (18) + # Layout: space(1) + icon(1) + space(1) + label(18) + gap(2) + value(47) = 70 + $valueWidth = $boxWidth - $labelWidth - 5 # 5 = icon(1) + spaces(2) + gap(2) + + $labelPadded = $Label.PadRight($labelWidth) + $valuePadded = $Value.PadRight($valueWidth) + + Write-Host " ║ " -NoNewline -ForegroundColor Cyan + Write-Host "$Icon " -NoNewline -ForegroundColor $IconColor + Write-Host "$labelPadded " -NoNewline -ForegroundColor White # 2 spaces after label + Write-Host $valuePadded -NoNewline -ForegroundColor $ValueColor + Write-Host "║" -ForegroundColor Cyan + } + + # Duration + Write-StatLine -Icon ">" -Label "Duration:" -Value $elapsedStr -IconColor "DarkGray" -ValueColor "White" + + # Updates. v2.19: Windows updates are genuinely installed (PSWindowsUpdate reports + # per-update results), but the app number is what winget OFFERED - it silently skips + # pinned/manifest-less/UAC-cancelled packages, so claiming it as "installed" overstated + # the result. Label each honestly. Value stays <= 47 chars so the box border aligns. + $winInstalled = $script:Stats.WindowsUpdatesCount + $appsOffered = $script:Stats.AppUpdatesOffered + if (($winInstalled + $appsOffered) -gt 0) { + $updatesStr = "Windows: $winInstalled installed, Apps: $appsOffered offered" + # ASCII "^" instead of "↑" (v2.17, p.20 of the audit): same ambiguous-width + # box-alignment issue as "⚠" below, just not caught the first time around + Write-StatLine -Icon "^" -Label "Updates:" -Value $updatesStr -IconColor "Green" -ValueColor "Green" + } + + # Space freed (highlight if significant) + $freedStr = Format-FileSize $script:Stats.TotalFreedBytes + $freedColor = if ($script:Stats.TotalFreedBytes -gt 1GB) { "Green" } elseif ($script:Stats.TotalFreedBytes -gt 100MB) { "Yellow" } else { "White" } + Write-StatLine -Icon ">" -Label "Space freed:" -Value $freedStr -IconColor $freedColor -ValueColor $freedColor + + # Freed by category (if any) + if ($script:Stats.FreedByCategory.Count -gt 0) { + Write-Host " ╟$("─" * $boxWidth)╢" -ForegroundColor Cyan + $sortedCats = @($script:Stats.FreedByCategory.GetEnumerator() | + Where-Object { $_.Value -gt 0 } | Sort-Object -Property Value -Descending) + foreach ($cat in ($sortedCats | Select-Object -First 5)) { + # Right-align category name so its colon lines up with the labels above + $catLabel = "$($cat.Key):".PadLeft($labelWidth) + $catValue = Format-FileSize $cat.Value + Write-StatLine -Icon " " -Label $catLabel -Value $catValue -ValueColor "DarkGray" + } + # v2.17: account for the remainder. With 12 possible categories the listed rows + # did not add up to "Space freed", which read as an arithmetic error. + if ($sortedCats.Count -gt 5) { + $rest = ($sortedCats | Select-Object -Skip 5 | Measure-Object -Property Value -Sum).Sum + $restLabel = "Other ($($sortedCats.Count - 5)):".PadLeft($labelWidth) + Write-StatLine -Icon " " -Label $restLabel -Value (Format-FileSize $rest) -ValueColor "DarkGray" + } + } + + Write-Host " ╠$("═" * $boxWidth)╣" -ForegroundColor Cyan + + # Disk space + $diskStr = "$freeSpace GB / $totalSize GB ($freePercent% free)" + $diskColor = if ($freePercent -lt 10) { "Red" } elseif ($freePercent -lt 20) { "Yellow" } else { "White" } + Write-StatLine -Icon ">" -Label "Disk space:" -Value $diskStr -IconColor $diskColor -ValueColor $diskColor + + # Warnings/Errors (if any) + if ($hasWarnings -or $hasErrors) { + $issueStr = "$($script:Stats.WarningsCount) warnings, $($script:Stats.ErrorsCount) errors" + # ASCII "!"/"X" instead of "⚠"/"✗": both are ambiguous-width in some + # terminals and break box alignment (v2.14 / v2.17 p.20 of the audit) + $issueIcon = if ($hasErrors) { "X" } else { "!" } + $issueColor = if ($hasErrors) { "Red" } else { "Yellow" } + Write-StatLine -Icon $issueIcon -Label "Issues:" -Value $issueStr -IconColor $issueColor -ValueColor $issueColor + } + + Write-Host " ╚$("═" * $boxWidth)╝" -ForegroundColor Cyan + + # Reboot notification + if ($script:Stats.RebootRequired) { + Write-Host "" + Write-Host " ! " -NoNewline -ForegroundColor Yellow + Write-Host "Reboot required to complete Windows updates!" -ForegroundColor Yellow + + if (Test-InteractiveConsole) { + Write-Host "" + Write-Host " Reboot now? (y/N): " -NoNewline -ForegroundColor Yellow + + $response = Read-Host + if ($response -match "^[YyДд]") { + Write-Host " Rebooting in 10 seconds... Press Ctrl+C to cancel" -ForegroundColor Yellow + Start-Sleep -Seconds 10 + Restart-Computer -Force + } else { + Write-Host " Remember to reboot later!" -ForegroundColor Yellow + } + } else { + Write-Host " Please reboot manually to complete updates." -ForegroundColor Yellow + } + } + + Write-Host "" + Write-Host " Log: $script:LogPath" -ForegroundColor DarkGray + Write-Host "" + + # Wait for keypress before closing (no timeout - window stays open) + if (Test-InteractiveConsole) { + Write-Host " Press any key to exit..." -ForegroundColor DarkGray + + # Clear keyboard buffer first + while ([Console]::KeyAvailable) { [Console]::ReadKey($true) | Out-Null } + + # Wait indefinitely for keypress + [Console]::ReadKey($true) | Out-Null + } else { + Write-Host " Non-interactive mode - exiting automatically." -ForegroundColor DarkGray + } +} + +function Write-ResultJson { + <# + .SYNOPSIS + Writes a machine-readable run summary (JSON) for automated testing/stands + #> + param([string]$Path) + + if (-not $Path) { return } + + try { + $elapsed = (Get-Date) - $script:Stats.StartTime + + $result = [ordered]@{ + Version = $script:Version + Timestamp = (Get-Date).ToString('o') + DurationSeconds = [math]::Round($elapsed.TotalSeconds, 1) + ReportOnly = [bool]$ReportOnly + Parameters = [ordered]@{ + SkipUpdates = [bool]$SkipUpdates + SkipCleanup = [bool]$SkipCleanup + SkipRestore = [bool]$SkipRestore + SkipDevCleanup = [bool]$SkipDevCleanup + SkipDockerCleanup = [bool]$SkipDockerCleanup + SkipVSCleanup = [bool]$SkipVSCleanup + SkipDiskCleanup = [bool]$SkipDiskCleanup + DisableTelemetry = [bool]$DisableTelemetry + } + TotalFreedBytes = [long]$script:Stats.TotalFreedBytes + FreedByCategory = @{} + $script:Stats.FreedByCategory + WindowsUpdatesCount = $script:Stats.WindowsUpdatesCount + # v2.19: renamed from AppUpdatesCount. This is the number of app updates winget + # OFFERED, not a confirmed install count (winget upgrade --all cannot report the + # latter). No shipped consumer reads this field; the nightly stand does not. + AppUpdatesOffered = $script:Stats.AppUpdatesOffered + # v2.21: distinguishes "checked, nothing to upgrade" from "could not check". + # Both are AppUpdatesOffered = 0, and demoting a missing winget to a warning + # removed the only other signal a consumer had (a non-zero exit code). + AppUpdatesStatus = $script:Stats.AppUpdatesStatus + WarningsCount = $script:Stats.WarningsCount + ErrorsCount = $script:Stats.ErrorsCount + RebootRequired = [bool]$script:Stats.RebootRequired + # v2.20: true when writing the log file failed at some point. The run still + # completed, but LogPath below points at an incomplete file - an automated + # consumer must not treat that log as the full record of what happened. + LoggingDegraded = [bool]$script:LogWriteFailed + # v2.20: true when Disk Cleanup outlived its timeout and was left running. + # TotalFreedBytes is then a lower bound, not the final figure. + DiskCleanupPending = [bool]$script:Stats.DiskCleanupPending + # v2.22: how that step ended, because the boolean above conflated two states. + # 'idle-resident' is the measured case: cleanmgr was seen working, then went + # completely still without exiting. 'timeout' is the genuine overrun the + # boolean was added for - still visibly working when time ran out. + DiskCleanupStatus = [string]$script:Stats.DiskCleanupStatus + # 'enabled' means cleanup figures are understated (Defender blocked some + # deletions without reporting an error); 'unknown' means the check itself + # failed, so the figures are unverified rather than confirmed good + ControlledFolderAccess = [string]$script:Stats.ControlledFolderAccess + # null for a normal run; a reason string when the run stopped early (v2.17) + Aborted = $script:Stats.Aborted + # Dispatch status of each top-level phase (v2.17 p.11; tri-state in v2.19). + # Completed = invoked and returned without an uncaught exception (NOT proof + # the work succeeded); Skipped = a skip flag suppressed it; Failed = it threw. + # For a non-aborted run the three are disjoint and their union is the full + # phase set, so a name missing from all three means the run stopped before it. + PhasesCompleted = @($script:Stats.PhasesCompleted) + PhasesFailed = @($script:Stats.PhasesFailed) + PhasesSkipped = @($script:Stats.PhasesSkipped) + LogPath = $script:LogPath + } + + $resultDir = Split-Path -Path $Path -Parent + if ($resultDir -and -not (Test-Path -LiteralPath $resultDir)) { + New-Item -ItemType Directory -Path $resultDir -Force -ErrorAction SilentlyContinue | Out-Null + } + + $result | ConvertTo-Json -Depth 4 | Out-File -FilePath $Path -Encoding utf8 + Write-Log "Result JSON written: $Path" -Level INFO + } catch { + # v2.20: this comment said "must be loud" while the code raised a warning, and the + # exit code is decided by ErrorsCount alone - so a run that failed to produce the + # artefact the user explicitly asked for still exited 0 and printed "completed with + # warnings". The stand then reads a stale file from the previous run as a fresh + # result. The user asked for this file: not producing it is a failure of the run. + Write-Log "Failed to write result JSON: $_" -Level ERROR + $script:Stats.ErrorsCount++ + } +} + +function Complete-WinCleanRun { + <# + .SYNOPSIS + The single end-of-run path: result JSON, final summary, log handle release + .DESCRIPTION + v2.22, raised in external review. Three paths ended a run - the normal finally, a + successful self-update, and a declined pending-reboot prompt - and only the first + released the log handle or showed a summary. The other two hand-copied whichever + parts someone had remembered at the time, which is exactly how v2.21 came to ship + two separate fixes to the same few lines (first a missing result JSON, then an + unconditional exit 0 over a run that had errors). The defect was never any one + omission; it was that the list existed in three places. Anything added here from + now on reaches every exit. + + Latched, so a path that completes the run explicitly and then unwinds through a + finally does not write the artefacts twice. + #> + param([string]$ResultPath) + + if ($script:RunCompleted) { return } + $script:RunCompleted = $true + + # JSON goes first: Show-FinalStatistics may block on a keypress in interactive + # mode, and automated runs must get the result regardless. + Write-ResultJson -Path $ResultPath + + # An aborted run has no maintenance to summarise, and the summary header would + # announce "COMPLETED SUCCESSFULLY" over a run that deliberately did nothing. + # Preserved behaviour: neither abort path ever showed it. + if (-not $script:Stats.Aborted) { + Show-FinalStatistics + } + + # Release the log file handle (v2.17, p.7): a stand or the user may want to move or + # zip the log right after the run finishes. + # v2.20: guarded. Dispose on a writer whose stream already failed throws, and this + # used to be the last statement of the outer finally - the exception escaped + # Start-WinClean and the entry point never reached its exit-code check, so a run with + # errors could still exit 0 (raised in review). + if ($script:LogWriter) { + try { $script:LogWriter.Dispose() } catch { } + $script:LogWriter = $null + $script:LogWriterPath = $null + } +} + +function Invoke-Phase { + <# + .SYNOPSIS + Runs one top-level phase of Start-WinClean with its own exception boundary + .DESCRIPTION + v2.17 (p.11 of the audit): the 9 phases used to share a single try/catch, so an + exception in phase 3 meant phases 4-9 never ran at all - silently, with only a + generic "Critical error" line to show for it. Each phase now gets its own + boundary and is recorded in $script:Stats.PhasesCompleted/PhasesFailed, which + Write-ResultJson exposes so an automated stand can tell "everything ran" from + "phase 6 threw and phases 7-9 are simply missing". + + v2.19: -Skip records a phase the user turned off with a skip flag in a third + bucket, PhasesSkipped, instead of running an empty body and marking it Completed + (which conflated "ran and did nothing" with "was skipped"). This also carries the + "... skipped (parameter)" log line that used to come from each phase's own inner + guard, now that the skip decision lives at the call site. These three buckets are + a DISPATCH status, not an outcome - see the $script:Stats comment. + #> + param( + [Parameter(Mandatory)][string]$Name, + [Parameter(Mandatory)][scriptblock]$Action, + [bool]$Skip = $false + ) + + if ($Skip) { + Write-Log "Phase '$Name' skipped (parameter)" -Level INFO + $script:Stats.PhasesSkipped += $Name + return + } + + try { + & $Action + $script:Stats.PhasesCompleted += $Name + } catch { + Write-Log "Phase '$Name' failed: $_" -Level ERROR + $script:Stats.ErrorsCount++ + $script:Stats.PhasesFailed += $Name + } +} + +function Start-WinClean { + # v2.17: remove a previous result file up front. An early exit used to leave the + # old JSON in place, and an automated stand would read last week's success as this + # run's outcome. + if ($ResultJsonPath -and (Test-Path -LiteralPath $ResultJsonPath)) { + Remove-Item -LiteralPath $ResultJsonPath -Force -ErrorAction SilentlyContinue + } + + # v2.19 reset the phase buckets and the step counter here; v2.20 makes the run + # genuinely fresh. The partial version left freed bytes, warning/error counts, + # Aborted and StartTime from a previous call in the same session, so the second + # run's summary and JSON described both runs at once. See New-RunStats. + $script:Stats = New-RunStats + $script:ProgressActivities = @() + $script:InternetConnectionCache = $null + $script:LogWriteFailed = $false + # v2.22: the latch on Complete-WinCleanRun, reset with everything else - a second + # call in the same session must produce its own artefacts, not silently skip them + # because the first run already completed. + $script:RunCompleted = $false + + # v2.22: ask for a wider window, then lay out against whatever the host actually + # gave us. In this order deliberately - measuring first would size every frame for + # the old width. Both steps are best-effort and silent: a host that refuses to + # resize, or whose width cannot be read, simply keeps the historical 70. + Expand-ConsoleWindow + $script:BoxWidth = Get-BoxWidth -ConsoleWidth (Get-ConsoleWidth) + + # Initialize log. v2.22 (raised in external review): these two lines were bare Out-File + # calls, and this runs BEFORE the main try/finally below. A log path that cannot be + # opened throws there - measured on six of seven bad paths - and the exception escaped + # Start-WinClean before any safety net existed: no result JSON, no summary, and none of + # the maintenance, because of the log. Now they degrade like every other log write. + Write-LogFileLine -Line "WinClean v$($script:Version) - Started at $(Get-Date)" -StartNewFile + Write-LogFileLine -Line ("=" * $script:BoxWidth) + + # v2.17 (p.13 of the audit): recover from a hard-killed previous run before doing + # anything else - not in ReportOnly, which promises no changes + if (-not $ReportOnly) { + Invoke-StaleMarkerRecovery + } + + # Enable TLS 1.2 for all HTTPS connections (required by PowerShell Gallery, NuGet, etc.) + # This must be set before any network operations + [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 + + # Calculate TotalSteps dynamically based on skip flags + $script:Stats.TotalSteps = 0 + if (-not $SkipUpdates) { $script:Stats.TotalSteps += 2 } # Windows Update + App Updates + # v2.19: -SkipCleanup now suppresses the whole cleanup group (system + deep + the + # developer/Docker/VS categories), matching the documented "skip all cleanup" / + # "Updates Only" contract. The per-category flags only subtract further inside it, + # so the progress denominator must nest them under -not $SkipCleanup too. + if (-not $SkipCleanup) { + $script:Stats.TotalSteps += 2 # System Cleanup + Deep Cleanup + if (-not $SkipDevCleanup) { $script:Stats.TotalSteps += 1 } # Developer Caches + if (-not $SkipDockerCleanup) { $script:Stats.TotalSteps += 1 } # Docker/WSL + if (-not $SkipVSCleanup) { $script:Stats.TotalSteps += 1 } # Visual Studio + } + # Ensure at least 1 step to avoid division by zero + if ($script:Stats.TotalSteps -eq 0) { $script:Stats.TotalSteps = 1 } + + Show-Banner + + # Check for pending reboot before starting + $pendingReboot = Test-PendingReboot + if ($pendingReboot.RebootRequired) { + Write-Host "" + Write-Host " " -NoNewline + Write-Host "WARNING: " -NoNewline -ForegroundColor Red + Write-Host "Pending reboot detected!" -ForegroundColor Yellow + Write-Host " Reasons: $($pendingReboot.Reasons -join ', ')" -ForegroundColor DarkYellow + Write-Host "" + Write-Host " It is recommended to reboot before running maintenance." -ForegroundColor Gray + + # Check if interactive console available (fixed in v2.1) + if (Test-InteractiveConsole) { + Write-Host " Continue anyway? (y/N): " -NoNewline -ForegroundColor Yellow + + $response = Read-Host + if ($response -notmatch "^[YyДд]") { + Write-Host "" + Write-Host " Operation cancelled. Please reboot and run again." -ForegroundColor Yellow + Write-Host "" + # Record the abort so automation does not mistake it for a completed run + # v2.22: through the shared end-of-run path. This branch used to write the + # JSON by hand and return, so it released no log handle - one of the three + # divergent endings that made the same fix necessary twice (external review). + $script:Stats.Aborted = 'PendingRebootDeclined' + Complete-WinCleanRun -ResultPath $ResultJsonPath + return + } + } else { + Write-Host " Non-interactive mode - continuing despite pending reboot." -ForegroundColor Yellow + } + Write-Host "" + } + + # Check for script updates. v2.17: gated by -SkipUpdates - the flag promises no + # update activity, and this path costs a PSGallery round trip on every run. + # + # Guarded (raised in review): this block runs BEFORE the main try/finally, so anything + # thrown here escaped Start-WinClean entirely - no result JSON, no phase buckets, no + # exit-code accounting - and killed the run before its first phase. The update check is + # optional; the maintenance it precedes is not, and must not be lost to it. + if (-not $SkipUpdates) { + try { + $updateInfo = Test-ScriptUpdate + if ($updateInfo) { + # v2.22: Invoke-ScriptUpdate reports whether the run is over instead of + # calling exit itself. A successful self-update replaced the running file, + # so continuing would perform maintenance with the old code still loaded - + # the run ends here, but it ends the same way every other run does. + if (Invoke-ScriptUpdate -UpdateInfo $updateInfo) { + # Artefacts first, then the pause. The prompt blocks until a key is + # pressed and a double-clicked shortcut may simply be closed there; + # writing the JSON afterwards would mean an abandoned window produced + # no result file at all (raised in the second review pass). + Complete-WinCleanRun -ResultPath $ResultJsonPath + Write-Host " Press any key to exit..." -ForegroundColor DarkGray + Wait-ForKeyPress + return + } + } + } catch { + Write-Log "Update check could not be completed: $_" -Level WARNING + $script:Stats.WarningsCount++ + } + } + + # Controlled Folder Access silently blocks deletions inside protected folders while + # every delete call still reports success, so cleanup looks fine in the log but frees + # nothing. Warn once up front instead of leaving the user with a misleading report (v2.16). + # Tri-state and always a string, so consumers (result JSON, stand assertions) get + # one stable type: 'enabled' / 'disabled' / 'unknown' + $script:Stats.ControlledFolderAccess = 'disabled' + try { + $mp = Get-MpPreference -ErrorAction Stop + if ($mp.EnableControlledFolderAccess -eq 1) { + $script:Stats.ControlledFolderAccess = 'enabled' + Write-Log "Controlled Folder Access is enabled - some deletions may be blocked silently" -Level WARNING + Write-Log "Add pwsh.exe to the allowed apps list, or cleanup results will be understated" -Level DETAIL + $script:Stats.WarningsCount++ + } + } catch { + # Defender cmdlets unavailable (third-party AV, stripped image, broken WMI). + # Record it as "unknown" rather than "disabled": reporting the latter would tell + # an automated stand the numbers are trustworthy when they were never checked. + $script:Stats.ControlledFolderAccess = 'unknown' + Write-Log "Could not query Controlled Folder Access state - cleanup figures are unverified" -Level DETAIL + } + + # v2.17 (p.11 of the audit): each phase now has its own exception boundary inside + # Invoke-Phase, so a bug in one no longer skips every phase after it. This outer + # try/finally is a second, coarser safety net - it guarantees the result JSON, the + # final summary and the log handle release happen even if something outside a + # phase (or a bug in Invoke-Phase itself) throws. + try { + # v2.19: the skip decision for each phase now lives here, at the call site, via + # -Skip. A skipped phase is recorded in PhasesSkipped (not run and marked + # Completed), and -SkipCleanup suppresses the ENTIRE cleanup group - system, deep, + # and the developer/Docker/VS categories - to match the documented "skip all + # cleanup" / "Updates Only" contract. The per-category flags stay as finer control + # inside that group. The inner functions keep their own guards for direct callers. + Invoke-Phase -Name 'Preparation' -Skip:$SkipRestore -Action { + $null = New-SystemRestorePoint -Description "WinClean $(Get-Date -Format 'yyyy-MM-dd HH:mm')" + } + + # Recorded here, not inside Update-Applications (raised in review): -SkipUpdates + # stops the phase from dispatching at all, so the branch that sets this inside the + # function is unreachable in production and the status stayed 'not-run'. + if ($SkipUpdates) { $script:Stats.AppUpdatesStatus = 'skipped-parameter' } + + Invoke-Phase -Name 'Updates' -Skip:$SkipUpdates -Action { + Update-WindowsSystem + Update-Applications + } + + Invoke-Phase -Name 'SystemCleanup' -Skip:$SkipCleanup -Action { + Write-Log "SYSTEM CLEANUP" -Level TITLE + Update-Progress -Activity "System Cleanup" -Status "Cleaning temporary files..." + + Clear-TempFiles + Clear-BrowserCaches + Clear-WindowsUpdateCache + Clear-WinCleanRecycleBin + Clear-SystemCaches + Clear-EventLogs + Clear-DNSCache + Clear-PrivacyTraces + } + + Invoke-Phase -Name 'DeveloperCleanup' -Skip:($SkipCleanup -or $SkipDevCleanup) -Action { Clear-DeveloperCaches } + + Invoke-Phase -Name 'DockerWSLCleanup' -Skip:($SkipCleanup -or $SkipDockerCleanup) -Action { Clear-DockerWSL } + + Invoke-Phase -Name 'VisualStudioCleanup' -Skip:($SkipCleanup -or $SkipVSCleanup) -Action { Clear-VisualStudio } + + Invoke-Phase -Name 'DeepSystemCleanup' -Skip:$SkipCleanup -Action { + Write-Log "DEEP SYSTEM CLEANUP" -Level TITLE + Update-Progress -Activity "Deep Cleanup" -Status "Running system cleanup..." + + # Driver store first (v2.17): removing packages leaves referenced + # components in WinSxS, and running DISM afterwards reclaims them in + # the same pass instead of a week later. + Clear-DriverStore + Clear-KernelDumps + # Neither of these reports what it freed, so measure the drive around them + Measure-FreeSpaceGain -Category 'ComponentStore' -Operation { Invoke-DISMCleanup } + # Raised in review: measuring around a step the user switched off credited it + # with whatever the drive happened to gain meanwhile - DISM releasing files a + # moment earlier is enough - and printed "DiskCleanup freed approximately + # 300.00 MB" for something that executed nothing. Invoke-StorageSense is still + # called so its registry leftovers get swept; it just is not measured. + if ($SkipDiskCleanup) { + Invoke-StorageSense + } else { + Measure-FreeSpaceGain -Category 'DiskCleanup' -Operation { Invoke-StorageSense } + } + Clear-WindowsOld + } + + # What is taking up space that cleanup deliberately leaves alone (v2.16). + # Skipped with -SkipCleanup (v2.17): it walks Windows\Installer and the search + # index, which is expensive and pointless for a user who asked for no cleanup. + Invoke-Phase -Name 'DiskSpaceReport' -Skip:$SkipCleanup -Action { + Show-DiskSpaceReport + } + + Invoke-Phase -Name 'Telemetry' -Action { + if ($DisableTelemetry) { + Set-WindowsTelemetry -Disable + } + } + } catch { + # Should not normally be reached - Invoke-Phase contains phase-level failures - + # but something outside any phase (or a bug in Invoke-Phase itself) still must + # not prevent the result JSON and summary below from being written. + Write-Log "Critical error outside any phase: $_" -Level ERROR + $script:Stats.ErrorsCount++ + } finally { + # v2.22: the whole end-of-run sequence now lives in one function, shared with the + # two abort paths that used to hand-copy parts of it. Ordering, the aborted-run + # rule and the guarded Dispose are documented there. + Complete-WinCleanRun -ResultPath $ResultJsonPath + } +} + +# Entry point +if ($MyInvocation.InvocationName -ne '.') { + Start-WinClean + # v2.17 (p.12 of the audit): the script used to always exit 0, even when the run + # logged errors. A scheduled task or stand cannot tell "clean run" from "ran into + # trouble" without parsing the log or the result JSON. + if ($script:Stats.ErrorsCount -gt 0) { + exit 1 + } +} + +#endregion diff --git a/tests/Docs.Tests.ps1 b/tests/Docs.Tests.ps1 index 97a82d8..614c02b 100644 --- a/tests/Docs.Tests.ps1 +++ b/tests/Docs.Tests.ps1 @@ -12,7 +12,15 @@ # Computed at discovery time so -ForEach can enumerate one test per file. $RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path -$DocFiles = @('README.md', 'README_RU.md', 'SECURITY.md', 'CONTRIBUTING.md', 'CHANGELOG.md') + +# v2.22: enumerated instead of listed by hand. SUPPORT.md was added in this release and +# silently fell outside the guards - a new user-facing document with links in it was the +# one file nothing checked. A hand-written list only covers the files someone remembered, +# and the next addition would have been forgotten the same way. +# CLAUDE.md is excluded deliberately: it is the maintainer/agent runbook, not user +# documentation, and it is not part of the published contract. +$DocFiles = @(Get-ChildItem $RepoRoot -Filter *.md -File | + Where-Object { $_.Name -ne 'CLAUDE.md' } | + ForEach-Object { $_.Name }) + (Get-ChildItem (Join-Path $RepoRoot 'docs') -Filter *.md -File -ErrorAction SilentlyContinue | ForEach-Object { Join-Path 'docs' $_.Name }) $DocCases = $DocFiles | ForEach-Object { @{ File = $_; RepoRoot = $RepoRoot } } From 08224caa73c5def65b2eaecf57a7fa220aa5c43a Mon Sep 17 00:00:00 2001 From: Ivan Bondarev Date: Thu, 23 Jul 2026 13:28:43 +0300 Subject: [PATCH 08/11] =?UTF-8?q?fix:=20=D0=B2=D0=BE=D1=81=D1=81=D1=82?= =?UTF-8?q?=D0=B0=D0=BD=D0=BE=D0=B2=D0=B8=D1=82=D1=8C=20-StartNewFile=20?= =?UTF-8?q?=D0=B8=20=D1=83=D0=B1=D1=80=D0=B0=D1=82=D1=8C=20=D0=B0=D1=80?= =?UTF-8?q?=D1=82=D0=B5=D1=84=D0=B0=D0=BA=D1=82=20=D0=BC=D1=83=D1=82=D0=B0?= =?UTF-8?q?=D1=86=D0=B8=D0=BE=D0=BD=D0=BD=D0=BE=D0=B3=D0=BE=20=D0=BF=D1=80?= =?UTF-8?q?=D0=BE=D0=B3=D0=BE=D0=BD=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🔴 ИНЦИДЕНТ ПРОЦЕССА, а не кода. Пока агент-ревьюер держал рабочее дерево под мутационным прогоном, я сделал коммит eeba199 через git add -A. В него попали: - МУТАЦИЯ продукта: из единственного места вызова пропал -StartNewFile, то есть лог перестал обрезаться при старте. Прогон с фиксированным -LogPath склеивал бы все запуски в один файл - ровно то свойство, которое комментарий параметра объявляет сохранённым намеренно; - WinClean.ps1.mutbak, 6840 строк, полная копия единственного файла кода. Паттерн *.bak в .gitignore не покрывает .mutbak. Коммит назывался "test: гварды документации" и правил WinClean.ps1 - это было видно в git show --stat с первого взгляда, и не было замечено. 🔴 Регрессию не поймал НИКТО из автоматики: 679 тестов зелёные, смоук зелёный, стенд бы не увидел (дефолтный лог-путь со штампом времени + откат снапшота), гейт зелёный (дерево чистое, потому что мутация ЗАКОММИЧЕНА). Существующий тест проверяет, что Write-LogFileLine честно обрабатывает -StartNewFile, и он проходит: сломан был не он, а ВЫЗЫВАЮЩИЙ. Тест на место вызова добавляется отдельным коммитом. Найдено независимо тремя ревьюерами из четырёх. --- .gitignore | 4 + WinClean.ps1 | 2 +- WinClean.ps1.mutbak | 6840 ------------------------------------------- 3 files changed, 5 insertions(+), 6841 deletions(-) delete mode 100644 WinClean.ps1.mutbak diff --git a/.gitignore b/.gitignore index 5754911..a7f2d0f 100644 --- a/.gitignore +++ b/.gitignore @@ -56,3 +56,7 @@ Krasivoe*.ps1 tools/proxmox/stand.config*.json !tools/proxmox/stand.config.example.json tools/proxmox/results/ + +# Mutation-testing scratch backups. Added after a runner's backup and one of its +# mutants were swept into a commit by `git add -A`: *.bak did not match .mutbak. +*.mutbak diff --git a/WinClean.ps1 b/WinClean.ps1 index 11f7197..0059b0f 100644 --- a/WinClean.ps1 +++ b/WinClean.ps1 @@ -6611,7 +6611,7 @@ function Start-WinClean { # opened throws there - measured on six of seven bad paths - and the exception escaped # Start-WinClean before any safety net existed: no result JSON, no summary, and none of # the maintenance, because of the log. Now they degrade like every other log write. - Write-LogFileLine -Line "WinClean v$($script:Version) - Started at $(Get-Date)" + Write-LogFileLine -Line "WinClean v$($script:Version) - Started at $(Get-Date)" -StartNewFile Write-LogFileLine -Line ("=" * $script:BoxWidth) # v2.17 (p.13 of the audit): recover from a hard-killed previous run before doing diff --git a/WinClean.ps1.mutbak b/WinClean.ps1.mutbak deleted file mode 100644 index 0059b0f..0000000 --- a/WinClean.ps1.mutbak +++ /dev/null @@ -1,6840 +0,0 @@ -<#PSScriptInfo -.VERSION 2.22 -.GUID 8f7c3b2a-1d4e-5f6a-9b8c-0d1e2f3a4b5c -.AUTHOR bivlked -.COMPANYNAME -.COPYRIGHT (c) 2026 bivlked. MIT License. -.TAGS Windows Cleanup Maintenance PowerShell Windows11 DevTools Docker WSL npm pip nuget -.LICENSEURI https://github.com/bivlked/WinClean/blob/main/LICENSE -.PROJECTURI https://github.com/bivlked/WinClean -.ICONURI https://raw.githubusercontent.com/bivlked/WinClean/main/assets/logo.svg -.EXTERNALMODULEDEPENDENCIES -.REQUIREDSCRIPTS -.EXTERNALSCRIPTDEPENDENCIES -.RELEASENOTES - v2.22: Honest completion and adaptive output - a finished Disk Cleanup is no longer reported as partial, a bad log path can no longer kill a run, every run now ends through one path, and the console output follows the window width - v2.21: Self-update targeting and honest exit codes - the update could change a file that was not the one running and still report success; a missing winget or no connectivity no longer fails the run - v2.20: Correctness and honesty round - a junction could bypass protected-path checks, four operations reported success while doing nothing, Storage Sense was unreachable so on machines where it works the slow Disk Cleanup no longer runs; where it fails, the new -SkipDiskCleanup is what removes the wait - v2.19: Contract and documentation round - -SkipCleanup now skips ALL cleanup categories, result JSON gains a tri-state PhasesSkipped, AppUpdatesCount renamed to AppUpdatesOffered (offered, not installed), full docs overhaul - v2.18: Correctness and hardening follow-up from external code review - diskpart failure detection, driver-store accounting, strict superseded-version rule, exact bootstrap host allowlist - v2.17: Silent failure hardening - operations that quietly do nothing now say so instead of reporting success - v2.16: Driver store cleanup, disk space report, kernel dump cleanup, 9 audit fixes (Delivery Optimization path, TEMP age filter, winget exit codes) - v2.15: ResultJsonPath for automated testing, one-command install/run (get.ps1, install.ps1), integration test suite - v2.14: Log persistence fix, correct npm/Firefox cache paths, localized size parsing, faster DISM/EventLogs, UI fixes - v2.13: Statistics accuracy fixes, efficiency improvements, registry cleanup - v2.12: PS 7.4+ compatibility, improved statistics (Docker/WSL/RecycleBin), ReportOnly accuracy - v2.11: Added timeouts for winget/DISM operations, fixed version display, improved reliability - v2.10: Added auto-update check at startup (checks PSGallery for new version) - v2.9: Fixed PSWindowsUpdate installation hanging (TLS 1.2, timeouts) -.PRIVATEDATA -#> - -<# -.SYNOPSIS - WinClean - Ultimate Windows 11 Maintenance Script v2.22 -.DESCRIPTION - Комплексный скрипт для обновления и очистки Windows 11: - - Обновление Windows (включая драйверы) - - Обновление приложений через winget - - Очистка системы, браузеров, кэшей разработчика - - Очистка Docker/WSL - - Очистка Visual Studio - - Очистка DNS кэша и истории Run - - Опциональная блокировка телеметрии Windows - - Параллельное выполнение для максимальной скорости - - Подробный цветной вывод + лог-файл -.NOTES - Author: biv - Version: 2.22 - Requires: PowerShell 7.1+, Windows 11, Administrator rights - Changes in 2.22: - - A Disk Cleanup that stops doing anything but never exits is no longer waited out - for the full 15-minute timeout, nor reported as still deleting: the wait now also - ends when the process has shown no CPU or I/O activity for two minutes - - An unusable -LogPath can no longer kill the run before it starts - the log header - is written through the same fault-tolerant path as every other log line - - Every run now ends through one code path, so the result JSON, the summary and the - log handle release cannot drift apart between normal and self-update exits - - Console output adapts to the window width: the window is widened best-effort at - startup and the frames follow it, so winget's table stops wrapping into them - - install.ps1 refuses a PowerShell 7 whose version it cannot read, instead of - treating "could not check" as "good enough" - - Changes in 2.21: - - The self-update could update a DIFFERENT copy than the one running and report - success - it asked whether a Gallery copy existed anywhere, not whether the - running file was that copy - - Several Gallery installations now disable the automatic update instead of - changing one at random; the running path is printed so they can be told apart - - Detection, updating and the printed advice work on a machine that has only - PSResourceGet; an AllUsers install is no longer invisible - - An update that reports success is verified against the version on disk - - A missing winget, no internet connection and a failed self-update are warnings, - not errors: the exit code no longer reports failure for a complete cleanup - - Result JSON gains AppUpdatesStatus, and Aborted gains UpdatedAndExited - Changes in 2.20: - - SECURITY: a junction whose target is a protected root could be used as a cleanup - root - the path check compared text and never resolved the link - - Storage Sense was looked up at a path where it does not exist, so every run fell - back to Disk Cleanup (15 of 18 minutes on a real workstation). Where Storage Sense - itself fails, the fallback still runs - use -SkipDiskCleanup there - - npm, event logs, privacy traces and winget source update no longer report success - when they did nothing - - Added -SkipDiskCleanup to skip only the slow Disk Cleanup step - - Result JSON gains LoggingDegraded and DiskCleanupPending - Changes in 2.19: - - -SkipCleanup now skips the ENTIRE cleanup group (system, deep, developer, - Docker/WSL, Visual Studio), matching the documented "skip all cleanup" contract. - Previously it left developer/Docker/VS cleanup running (behavior change) - - Result JSON gains a tri-state PhasesSkipped (dispatch status): a phase turned off - by a skip flag is now recorded as skipped instead of completed - - AppUpdatesCount renamed to AppUpdatesOffered - winget cannot confirm how many apps - installed, so the summary reports the offered count honestly ("Apps: N offered") - - Documentation overhaul: accurate feature list, docs/ deep-dive pages, SECURITY and - CONTRIBUTING release-gate, SHA-pinned CI actions - Changes in 2.18: - - WSL/Docker VHDX compaction now detects a diskpart failure instead of reporting - "no space saved", and a failed WSL shutdown skips compaction rather than - touching a live disk - - Driver store falls back to the repository delta whenever ANY removed package - lacks a trusted per-package size, not only when the total is zero - - Driver store "superseded" now requires a strictly newer version, never a mere - newer date at the same version - - The per-VHDX compaction failure is now counted as a warning - - The one-line install scripts validate the download host against an exact - allowlist instead of a broad *.github.com / *.githubusercontent.com suffix - - Folder size measurement distinguishes an unreadable folder from an empty one - Changes in 2.17: - - Cleanups that free nothing from a non-empty folder now say so instead of - staying silent, which was indistinguishable from "there was nothing to do" - - Kernel dump and driver package removal failures are counted and reported - - pnputil exit code is checked; a parse failure no longer looks like "nothing found" - - Driver store falls back to measuring the repository when per-package sizes - cannot be attributed, instead of reporting 0 B after a successful cleanup - - Disk Cleanup verifies that categories were armed and checks its exit code - - Browser caches are no longer reported as cleaned when nothing was freed - - The temp age filter now fails closed: an unreadable subtree is kept, not deleted - - Controlled Folder Access reports 'unknown' when the check itself fails - - Downloaded-but-not-installed updates are no longer counted as installed - Changes in 2.16: - - Added driver store cleanup: removes superseded driver packages that no device - uses and that have a newer version installed (451 MB on the author's machine) - - Added disk space report: shows large areas cleanup deliberately never touches - (MSI cache, search index, hiberfil.sys, shadow copies) - - Added kernel dump cleanup for stale LiveKernelReports files (multi-gigabyte) - - Fixed Delivery Optimization cache path - multi-gigabyte cleanups were reported as 0 B - - Temp cleanup no longer deletes files younger than a day (running installers) - - Windows Update cache cleanup now waits for the service to really stop - - Warns when Controlled Folder Access may silently block deletions - - Disk Cleanup category list reconciled with the registry; leftover StateFlags - are now swept from every handler - - winget exit codes are decoded instead of printed as a bare number - - All progress bars are closed before the summary, including foreign ones - Changes in 2.15: - - Added -ResultJsonPath: machine-readable run summary (JSON) for automated - testing, CI and VM test stands - - Added get.ps1 (one-command run from the internet) and install.ps1 - (one-command install/update + elevated desktop shortcut) - - Added integration test suite (sandboxed filesystem tests) and smoke runner - with automated console box-geometry checking - Changes in 2.14: - - Fixed log file being deleted by the script's own temp cleanup (all entries - logged before Clear-TempFiles were silently lost every run) - - Fixed npm cache path for npm v7+ (LOCALAPPDATA\npm-cache; old APPDATA path kept as fallback) - - Fixed Firefox cache path (cache2/startupCache live under LOCALAPPDATA, not APPDATA) - - Fixed localized size parsing (Cyrillic units, no-break spaces) for Recycle Bin statistics - - Fixed restore points silently not created due to the 24h system frequency limit - - Fixed winget update count inflated by the "require explicit targeting" table - - Fixed Storage Sense wasting 120s + false warning when the scheduled task is disabled - - Fixed misaligned UPDATE AVAILABLE box and countdown ghost character - - DISM: component store analyzed first (/English), cleanup skipped when not needed; - DISM output redirected to keep console clean - - Event logs: only enabled non-empty Administrative/Operational logs are cleared - (much faster, no chronic partial-failure warnings) - - Delivery Optimization cache cleared via supported Delete-DeliveryOptimizationCache cmdlet - - Replaced dead connectivity probe winget.azureedge.net with cdn.winget.microsoft.com - - Removed risky cleanmgr categories (Previous Installations, Windows ESD installation files) - - Added Opera GX and uv cache cleanup; winget check hardened with --disable-interactivity - - Removed dead code (unused statusIcon/dockerInfo variables) - - Fixed Docker reclaimed-space parsing ($Matches after array -match) + exit code check - - Fixed icacls on non-English Windows (SID S-1-5-32-544 instead of localized "Administrators") - - Fixed failed Windows Update search being reported as "up to date" - - Stats no longer count locked files / undeleted Recycle Bin items as freed - - Custom -LogPath directories are created automatically - Changes in 2.13: - - Fixed Docker prune output parsing (supports "Total reclaimed space:" format) - - Fixed WarningsCount not incrementing for event log failures - - Fixed false "success" when Windows Update returns null results - - Optimized Get-FolderSize with -File flag for better performance - - Fixed temp path deduplication to avoid duplicate cleanups - - Removed redundant docker builder prune (already included in system prune) - - Fixed potential negative freed space in browser cache statistics - - Added fallback for Recycle Bin size calculation via GetDetailsOf - - Added registry cleanup for Disk Cleanup StateFlags after execution - Changes in 2.12: - - Fixed PS 7.4+ compatibility (removed deprecated -UseBasicParsing) - - Fixed DISM ReportOnly to show /ResetBase and warning - - Fixed AppUpdatesCount to only count successful updates - - Added statistics for Docker, WSL, Recycle Bin, npm cache - Changes in 2.11: - - Fixed version display bugs (banner and log showed v2.9 instead of current version) - - Added timeouts for winget/DISM operations to prevent script hangs - - Added force stop for Storage Sense when timeout exceeded - - Improved Docker detection and browser cache statistics reliability - Changes in 2.10: - - Added auto-update check: script checks PSGallery for newer version at startup - - Added Test-ScriptUpdate function: compares local version with PSGallery - - Added Invoke-ScriptUpdate function: prompts user and performs update if confirmed - - Update check runs after reboot check, before main operations - - Shows manual update instructions if script was downloaded manually (not via PSGallery) - - Respects ReportOnly mode and non-interactive environments - Changes in 2.9: - - Fixed PSWindowsUpdate installation hanging: added TLS 1.2 enforcement - - Added Test-PSGalleryConnection function: pre-checks PowerShell Gallery availability - - Added Install-ModuleWithTimeout function: 120-second timeout for Install-Module - - Added Install-PackageProviderWithTimeout function: 60-second timeout for NuGet provider - - Improved error messages with manual installation instructions - - Clear Write-Progress before module installation to prevent UI artifacts - Changes in 2.8: - - Fixed Disk Cleanup timeout: reduced from 10 minutes to 7 minutes - - Fixed Disk Cleanup: replaced -NoNewWindow with -WindowStyle Hidden (more reliable) - - Added progress logging every minute while Disk Cleanup is running - - Replaced Wait-Process with explicit HasExited loop for better control - Changes in 2.7: - - Fixed UI: header frame (╔═╗║║) now uses Cyan like the rest of the frame - - Status text (COMPLETED SUCCESSFULLY) remains colored (Green/Yellow/Red) for visual feedback - Changes in 2.6: - - Fixed UI: final statistics frame now uses consistent Cyan color throughout - - Fixed UI: added 2-space gap between label and value (prevents "installed:Windows:" merging) - - Fixed UI: category names (Temp, System) now right-aligned with PadLeft to match main labels - - Refactored: $labelWidth moved to parent scope for reuse in category alignment - Changes in 2.5: - - Fixed UI: subsection gray lines now match TITLE frame width (70 chars) - - Fixed UI: final statistics window alignment (emoji replaced with ASCII) - - Fixed UI: Write-StatLine width formula corrected (-5 → -3) - Changes in 2.4: - - UI improvements: consistent left indent (2 spaces) throughout the script - - UI improvements: major sections now have full frame (like banner) - - UI improvements: subsections keep original style (┌─ Title / └────) - - UI improvements: enhanced final statistics with status icons and colors - - UI improvements: header color reflects completion status (green/yellow/red) - - Removed 60-second auto-close timeout - window now waits indefinitely for keypress - Changes in 2.3: - - Fixed critical bug: TotalFreedBytes always showed 0 in final statistics - Root cause: Interlocked.Add doesn't work with hashtable elements via [ref] in PowerShell - Solution: Use simple += operator (synchronized hashtable handles thread-safety) - Changes in 2.2: - - Fixed TcpClient resource leak: now properly closed in finally block (prevents socket exhaustion) - - Fixed code region markers: 8 misplaced #region tags corrected for proper IDE navigation - - Fixed banner ASCII art: now displays "CLEAN" instead of incorrect "DREAM" - Changes in 2.1: - - Fixed Clear-EventLogs: exact match for Security log only (not all logs containing "Security") - - Fixed browser cache cleanup: additional profiles now get full cache set (Code Cache, GPUCache, etc.) - - Fixed Update-Applications: ErrorsCount++ now incremented when no internet - - Fixed Roslyn Temp cleanup: file patterns now handled correctly (not just directories) - - Fixed winget update count: works with custom sources (not just winget/msstore) - - Fixed interactive prompts: safe defaults in non-console environments (Scheduled Tasks, ISE) - - Fixed telemetry edition detection: uses EditionID registry (language-independent) - - Fixed final statistics: consistent box width (no visual glitches) - Changes in 2.0: - - Fixed Test-InternetConnection: uses TcpClient with 3s timeout (no VPN hangs) - - Fixed Clear-EventLogs: now checks $LASTEXITCODE for each wevtutil call - - Fixed winget ExitCode: strict check (any non-zero = error, not just empty output) - - Fixed Storage Sense: uses Get-ScheduledTask (language-independent status) - - Fixed Storage Sense: detects actual completion (wasRunning -> Ready transition) - - Fixed ReportOnly: no longer installs PSWindowsUpdate/NuGet modules - - Removed unused DriverUpdatesCount field from Stats - Changes in 1.9: - - Fixed progress bar: TotalSteps now calculated dynamically based on skip flags - - Fixed winget: source update skipped in ReportOnly mode, added ExitCode check - - Fixed winget: --include-unknown now used consistently for count and upgrade - - Fixed browser cache statistics: now measures actual freed space (before/after) - - Fixed Storage Sense: now waits for task completion instead of fixed sleep - - Fixed DNS flush: logs warning on unexpected result instead of false success - - Fixed WSL/Docker VHDX: now compacts all VHDX files regardless of distro list - - Moved Update-Progress calls after skip flag checks for accurate progress - Changes in 1.8: - - Fixed critical bug: $LogPath vs $script:LogPath in Start-WinClean and Show-FinalStatistics - - Fixed version inconsistency: unified all version references to single source - - Added browser cache size tracking to freed space statistics - - Fixed TotalSteps count (was 12, actual 7 steps) - - Improved winget update detection: language-independent parsing - Changes in 1.7: - - Improved internet connectivity check: HTTPS endpoints + ICMP fallback - - Fixed Show-Banner to display correct log path ($script:LogPath) - - Fixed Clear-SystemCaches: ReportOnly mode and size tracking for single files - Changes in 1.6: - - Added pause at end: window stays open 60 sec or until key press - (prevents window from closing before user can read results) - Changes in 1.5: - - Fixed visual glitch: clear progress bar before DISM output to prevent overlay - Changes in 1.4: - - Fixed Clear-PrivacyTraces: added -Recurse to Remove-Item to prevent confirmation - prompts when cleaning Recent folder (AutomaticDestinations, CustomDestinations) - Changes in 1.3: - - CRITICAL FIX: Clear-RecycleBin renamed to Clear-WinCleanRecycleBin to avoid - infinite recursion (stack overflow) caused by name collision with built-in cmdlet - Changes in 1.2: - - Fixed $script:LogPath scope (logging now works correctly) - - Fixed Clear-BrowserCaches respecting ReportOnly mode - - Fixed Windows.old path to use $env:SystemDrive instead of hardcoded C: - - Fixed NuGet: removed packages folder (not cache), kept only metadata caches - - Fixed Gradle: only delete safe build caches, not downloaded dependencies - - Fixed Windows Update services now properly restart via try/finally - - Fixed WSL --list output UTF-16LE parsing (removes null characters) -.PARAMETER SkipUpdates - Пропустить все обновления (Windows + winget) -.PARAMETER SkipCleanup - Пропустить все операции очистки (система, глубокая очистка, кэши разработчика, - Docker/WSL, Visual Studio). Отдельные -Skip*Cleanup - более точечные флаги внутри -.PARAMETER SkipRestore - Пропустить создание точки восстановления -.PARAMETER SkipDevCleanup - Пропустить очистку кэшей разработчика (npm, pip, nuget) -.PARAMETER SkipDockerCleanup - Пропустить очистку Docker/WSL -.PARAMETER SkipVSCleanup - Пропустить очистку Visual Studio -.PARAMETER SkipDiskCleanup - Пропустить только шаг Storage Sense / Disk Cleanup (штатная утилита Windows). - Этот шаг бывает самым долгим: на реальной рабочей станции cleanmgr занял 15 минут - из 18 и не успел завершиться, тогда как на чистой виртуальной машине те же - 23 категории отрабатывают за 10 секунд. Причина разницы НЕ установлена: измерение - показывает лишь, что стоимость зависит от накопленного состояния машины. Остальная - очистка при этом выполняется - в отличие от -SkipCleanup, который гасит её целиком -.PARAMETER DisableTelemetry - Отключить телеметрию Windows (через групповую политику) -.PARAMETER ReportOnly - Только показать, что будет сделано (без выполнения) -.PARAMETER LogPath - Путь к файлу лога (по умолчанию: $env:TEMP\WinClean_.log) -.PARAMETER ResultJsonPath - Путь для машиночитаемого итога прогона (JSON). Используется автотестами - и стендами; если не задан - JSON не создаётся -#> - -#Requires -Version 7.1 -#Requires -RunAsAdministrator - -# PositionalBinding disabled (v2.15): stray positional arguments must fail loudly -# instead of silently binding to LogPath/ResultJsonPath and turning an intended -# dry run into a real one -[CmdletBinding(PositionalBinding = $false)] -param( - [switch]$SkipUpdates, - [switch]$SkipCleanup, - [switch]$SkipRestore, - [switch]$SkipDevCleanup, - [switch]$SkipDockerCleanup, - [switch]$SkipVSCleanup, - [switch]$SkipDiskCleanup, - [switch]$DisableTelemetry, - [switch]$ReportOnly, - [string]$LogPath, - [string]$ResultJsonPath -) - -#region ═══════════════════════════════════════════════════════════════════════ -# INITIALIZATION -#═══════════════════════════════════════════════════════════════════════════════ - -# Ensure UTF-8 encoding -[Console]::OutputEncoding = [System.Text.Encoding]::UTF8 -$OutputEncoding = [System.Text.Encoding]::UTF8 -$PSDefaultParameterValues['*:Encoding'] = 'utf8' - -# Statistics storage (synchronized hashtable for safe concurrent access) -function New-RunStats { - <# - .SYNOPSIS - Builds a fresh per-run statistics object - .DESCRIPTION - v2.20: this was a literal assigned once when the script loaded, and Start-WinClean - reset only the phase buckets and the step counter - while the comment there - described "dot-source and call Start-WinClean twice" as the case being handled. - Everything else survived: freed bytes, per-category totals, update counts, warning - and error counts, RebootRequired, Aborted and StartTime. A second run in the same - session therefore reported the first run's bytes and errors, and computed its - duration from the moment the script was dot-sourced. - - One definition, used both at load time and at the start of every run. - #> - return [hashtable]::Synchronized(@{ - TotalFreedBytes = [long]0 - FreedByCategory = @{} - WindowsUpdatesCount = 0 - # v2.19: renamed from AppUpdatesCount. winget upgrade --all cannot report how many - # apps actually installed (it silently skips pinned/manifest-less/UAC-cancelled ones), - # so we only ever know how many it OFFERED. Naming it "installed" was a false claim. - AppUpdatesOffered = 0 - # v2.21: why the app half of the Updates phase produced the count it did. Added because demoting a - # missing winget from error to warning removed the only machine-readable way to tell - # "checked, nothing to upgrade" from "could not check at all" - both are - # AppUpdatesOffered = 0 with WarningsCount incremented by one of many possible causes. - # 'not-run' | 'checked' | 'check-failed' | 'skipped-parameter' | 'skipped-offline' - # | 'skipped-no-winget' - AppUpdatesStatus = 'not-run' - WarningsCount = 0 - ErrorsCount = 0 - RebootRequired = $false - StartTime = Get-Date - CurrentStep = 0 - TotalSteps = 0 # Calculated dynamically in Start-WinClean - # v2.16: tri-state string, 'disabled' / 'enabled' / 'unknown'. Never a boolean: - # 'unknown' must not be mistaken for a verified state by consumers of the JSON - ControlledFolderAccess = 'unknown' - Aborted = $null # v2.17: set when the run stops before finishing - # v2.20: cleanmgr outlived its timeout and is still deleting in the background. The - # totals reported by this run are partial, and a consumer must not read them as final. - DiskCleanupPending = $false - # v2.22: how the Storage Sense / Disk Cleanup step actually ended. DiskCleanupPending - # alone could not tell "still visibly deleting" from "resident but doing nothing", and - # reported both as pending - so a cleanup nothing had been observed to be doing was - # published as partial. - # Same shape and reasoning as AppUpdatesStatus (v2.21): when a boolean starts covering - # two different truths, the fix is a status, not a cleverer boolean. - # 'not-run' | 'skipped-parameter' | 'storage-sense' | 'completed' | - # 'idle-resident' | 'timeout' | 'failed' - DiskCleanupStatus = 'not-run' - # v2.17 (p.11 of the audit): which top-level phases ran to completion vs threw. - # Before this, one exception anywhere in the run silently skipped every phase - # after it - Developer Cleanup, Docker/WSL, Visual Studio, Deep System Cleanup, - # the disk space report, Telemetry - with only a single generic "Critical error" - # in the log to show for it. - # v2.19: these are a DISPATCH status, not an outcome. Completed = the phase action - # was invoked and returned without an uncaught exception (NOT "succeeded" - e.g. - # Preparation stays Completed even when the restore point genuinely failed, because - # New-SystemRestorePoint catches that and returns $false). Skipped = a skip flag - # suppressed the phase before it ran. Failed = the action threw. For a non-aborted - # run the three are disjoint and their union is exactly the known phase set. - PhasesCompleted = @() - PhasesFailed = @() - PhasesSkipped = @() - }) -} - -$script:Stats = New-RunStats - -# Progress activities seen so far, so all of them can be closed at the end (v2.16) -$script:ProgressActivities = @() - -# Memoized Test-InternetConnection result for the whole run (v2.17, p.5 of the audit): -# the check costs up to ~15s offline and is called from both halves of the Updates phase -$script:InternetConnectionCache = $null - -# Latched by Write-Log when the log file cannot be written, so the failure is reported -# once instead of on every call - and surfaces in the result JSON as LoggingDegraded -$script:LogWriteFailed = $false - -# Inner width of every framed section. v2.22: was the literal 70 repeated in four places; -# Start-WinClean now derives it once from the actual console. 70 stays the default so a -# host whose width cannot be read looks exactly as it always did. -$script:BoxWidth = 70 - -# Initialize log path (script scope for access in functions) -if (-not $LogPath) { - $script:LogPath = Join-Path $env:TEMP "WinClean_$((Get-Date).ToString('yyyyMMdd_HHmmss')).log" -} else { - $script:LogPath = $LogPath - # Ensure the parent directory exists for custom log paths (v2.14) - $logDir = Split-Path -Path $script:LogPath -Parent - if ($logDir -and -not (Test-Path -LiteralPath $logDir)) { - New-Item -ItemType Directory -Path $logDir -Force -ErrorAction SilentlyContinue | Out-Null - } -} - -# Script version (single source of truth for version checking) -$script:Version = "2.22" - -# Protected paths that should never be deleted -$script:ProtectedPaths = @( - $env:SystemRoot, - "$env:SystemRoot\System32", - $env:ProgramFiles, - ${env:ProgramFiles(x86)}, - $env:USERPROFILE, - "$env:SystemDrive\Users", - "$env:SystemDrive\Program Files", - "$env:SystemDrive\Program Files (x86)" -) - -#endregion - -#region ═══════════════════════════════════════════════════════════════════════ -# LOGGING FUNCTIONS -#═══════════════════════════════════════════════════════════════════════════════ - -function Write-LogFileLine { - <# - .SYNOPSIS - Appends one line to the log file, degrading instead of throwing - .DESCRIPTION - v2.22, raised in external review: extracted from Write-Log so that EVERY write to - the log file - including the header, which Start-WinClean emits before its main - try/finally exists - degrades the same way instead of each caller inventing its - own fault tolerance. - - The header used to be two bare Out-File calls. Measured, not assumed: six of seven - bad log paths make Out-File throw a TERMINATING error even though the script leaves - ErrorActionPreference at Continue (missing directory, path is a directory, invalid - characters, colon in the name, over-long path, unreachable UNC; only a reserved - device name did not). Thrown there, before the safety net, the exception escaped - Start-WinClean entirely: no result JSON, no final summary, no exit-code accounting, - and none of the maintenance the run was started for - all because of the log. - - A log that cannot be written is a degraded run, never a failed one. - #> - param( - [Parameter(Mandatory)] - [AllowEmptyString()] - [string]$Line, - - # The header line starts a fresh file. The Out-File call this replaced had no - # -Append, so it truncated on every run; preserved deliberately rather than lost - # in the refactor, or a custom -LogPath reused across runs would accumulate them - # all into one file and the log would no longer describe a single run. - [switch]$StartNewFile - ) - - # v2.17 (p.7 of the audit): Out-File used to open, seek to end, write and close the - # file on every single call - Write-Log fires hundreds of times per run. A StreamWriter - # kept open for the run avoids that, with AutoFlush so each line still lands on disk - # immediately (same durability as before, just cheaper). FileShare.Delete matters for - # tests: they Remove-Item the log path in AfterAll while this writer may still be the - # last one that touched it. - try { - if ($StartNewFile -or -not $script:LogWriter -or $script:LogWriterPath -ne $script:LogPath) { - if ($script:LogWriter) { $script:LogWriter.Dispose() } - $mode = if ($StartNewFile) { [System.IO.FileMode]::Create } else { [System.IO.FileMode]::Append } - $fileStream = [System.IO.File]::Open( - $script:LogPath, $mode, [System.IO.FileAccess]::Write, - ([System.IO.FileShare]::ReadWrite -bor [System.IO.FileShare]::Delete)) - $script:LogWriter = [System.IO.StreamWriter]::new($fileStream, [System.Text.Encoding]::UTF8) - $script:LogWriter.AutoFlush = $true - $script:LogWriterPath = $script:LogPath - } - $script:LogWriter.WriteLine($Line) - } catch { - # v2.20: this used to be an empty catch, so a log that stopped being written - # (full volume, revoked permissions, the v2.14 case where cleanup deleted the - # log out from under us) was invisible: destructive work carried on, the final - # JSON said ErrorsCount=0, and LogPath pointed at a truncated file. - # - # Latched: one console line, not one per call - Write-Log fires hundreds of - # times per run. Deliberately Write-Host and not Write-Log, which would - # recurse straight back into this catch. - # v2.20, corrected in review: drop the writer so the NEXT call reopens it. - # Without this the guard above stays satisfied by a dead writer object and - # every later line is silently discarded for the rest of the run - the empty - # catch would simply have moved from the first failure to all the others. - try { if ($script:LogWriter) { $script:LogWriter.Dispose() } } catch { } - $script:LogWriter = $null - $script:LogWriterPath = $null - - if (-not $script:LogWriteFailed) { - $script:LogWriteFailed = $true - $script:Stats.WarningsCount++ - Write-Host " [WARN] Log file could not be written ($($_.Exception.Message)) - the run continues, but $($script:LogPath) may be incomplete" -ForegroundColor Yellow - } - } -} - -function Write-Log { - <# - .SYNOPSIS - Writes colored output to console and plain text to log file - #> - [CmdletBinding()] - param( - [Parameter(Mandatory)] - [string]$Message, - - [ValidateSet('INFO', 'SUCCESS', 'WARNING', 'ERROR', 'TITLE', 'SECTION', 'DETAIL')] - [string]$Level = 'INFO', - - [switch]$NoNewLine, - [switch]$NoTimestamp, - [switch]$NoLog - ) - - # Consistent left indent for all output (matches banner style) - $indent = " " - $boxWidth = $script:BoxWidth # Inner width for framed sections (v2.22: adaptive) - - $timestamp = (Get-Date).ToString('HH:mm:ss') - $logMessage = "[$timestamp] [$Level] $Message" - - if (-not $NoLog) { - Write-LogFileLine -Line $logMessage - } - - # Console output with colors - $colors = @{ - INFO = @{ Tag = 'Cyan'; Message = 'White' } - SUCCESS = @{ Tag = 'Green'; Message = 'White' } - WARNING = @{ Tag = 'Yellow'; Message = 'Yellow' } - ERROR = @{ Tag = 'Red'; Message = 'Red' } - TITLE = @{ Tag = 'Magenta'; Message = 'Magenta' } - SECTION = @{ Tag = 'Cyan'; Message = 'Cyan' } - DETAIL = @{ Tag = 'DarkGray';Message = 'Gray' } - } - - $tagColors = $colors[$Level] - - switch ($Level) { - 'TITLE' { - # Full frame for major sections (like banner style, but Magenta) - $titleText = $Message.ToUpper() - $padding = [math]::Max(0, $boxWidth - $titleText.Length) - $leftPad = [math]::Floor($padding / 2) - $rightPad = $padding - $leftPad - $centeredTitle = (" " * $leftPad) + $titleText + (" " * $rightPad) - - Write-Host "" - Write-Host "$indent╔$("═" * $boxWidth)╗" -ForegroundColor $tagColors.Tag - Write-Host "$indent║$centeredTitle║" -ForegroundColor $tagColors.Tag - Write-Host "$indent╚$("═" * $boxWidth)╝" -ForegroundColor $tagColors.Tag - } - 'SECTION' { - # Subsection header (keep original style with indent) - Write-Host "" - Write-Host "$indent┌─ " -NoNewline -ForegroundColor DarkGray - Write-Host $Message -ForegroundColor $tagColors.Message - Write-Host "$indent└$("─" * $boxWidth)" -ForegroundColor DarkGray - } - 'DETAIL' { - # Detail line with vertical bar - Write-Host "$indent │ " -NoNewline -ForegroundColor DarkGray - Write-Host $Message -ForegroundColor $tagColors.Message -NoNewline:$NoNewLine - if (-not $NoNewLine) { Write-Host "" } - } - default { - # Standard log line with timestamp and tag - Write-Host $indent -NoNewline - - if (-not $NoTimestamp) { - Write-Host "[$timestamp] " -NoNewline -ForegroundColor DarkGray - } - - $tagText = switch ($Level) { - 'INFO' { '[INFO] ' } - 'SUCCESS' { '[OK] ' } - 'WARNING' { '[WARN] ' } - 'ERROR' { '[ERROR] ' } - } - - Write-Host $tagText -NoNewline -ForegroundColor $tagColors.Tag - Write-Host $Message -ForegroundColor $tagColors.Message -NoNewline:$NoNewLine - if (-not $NoNewLine) { Write-Host "" } - } - } -} - -function Update-Progress { - <# - .SYNOPSIS - Updates progress bar and step counter - #> - param( - [string]$Activity, - [string]$Status = "Processing..." - ) - - $script:Stats.CurrentStep++ - $percent = [math]::Min(100, [math]::Round(($script:Stats.CurrentStep / $script:Stats.TotalSteps) * 100)) - - # Remember the activity so Clear-AllProgress can close it later (v2.16) - if ($Activity -and $script:ProgressActivities -notcontains $Activity) { - $script:ProgressActivities += $Activity - } - - Write-Progress -Activity $Activity -Status $Status -PercentComplete $percent -} - -function Clear-AllProgress { - <# - .SYNOPSIS - Closes every progress bar before the final report is printed - .DESCRIPTION - v2.16: "Write-Progress -Activity 'Complete' -Completed" only closed an activity - that never existed, so leftover bars stayed on screen under the summary - both - our own (seven different activities are used) and foreign ones from cmdlets such - as Clear-RecycleBin, whose activity name is not ours to know. Clearing by Id - covers those: an unused Id is simply a no-op. - v2.17 (p.16 of the audit): 0..10 was eyeballed, not derived from anything. Widened - to 0..30 - ForEach-Object -Parallel and nested cmdlets can allocate Ids well past - 10, and clearing an unused Id costs nothing. - #> - foreach ($activity in $script:ProgressActivities) { - Write-Progress -Activity $activity -Completed -ErrorAction SilentlyContinue - } - for ($id = 0; $id -le 30; $id++) { - Write-Progress -Id $id -Activity ' ' -Completed -ErrorAction SilentlyContinue - } -} - -#endregion - -#region ═══════════════════════════════════════════════════════════════════════ -# HELPER FUNCTIONS -#═══════════════════════════════════════════════════════════════════════════════ - -function Get-RunMarkerPath { - Join-Path $env:TEMP 'WinClean.recovery-marker.json' -} - -function Set-RunMarker { - <# - .SYNOPSIS - Records that a risky, hard-to-undo operation is about to start - .DESCRIPTION - v2.17 (p.13 of the audit): Ctrl+C already unwinds through try/finally - the - gap is a HARD kill (taskkill /F, a closed terminal, a reset VM), which skips - every finally block, including the ones that restore - SystemRestorePointCreationFrequency or restart wuauserv/bits. This marker lets - the next run detect that and recover - a plain "is the value 0 right now" - check cannot tell an interrupted run from a value the user or IT policy set on - purpose, and blindly overwriting that would be the wrong kind of surprise. - Best-effort: a failed marker write must never block the real operation. - #> - param( - [Parameter(Mandatory)][string]$Phase, - [hashtable]$Data = @{} - ) - try { - $marker = [ordered]@{ Phase = $Phase; Pid = $PID; Timestamp = (Get-Date).ToString('o') } - foreach ($key in $Data.Keys) { $marker[$key] = $Data[$key] } - $marker | ConvertTo-Json -Compress | Set-Content -LiteralPath (Get-RunMarkerPath) -Encoding utf8 -ErrorAction Stop - } catch { } -} - -function Clear-RunMarker { - Remove-Item -LiteralPath (Get-RunMarkerPath) -Force -ErrorAction SilentlyContinue -} - -function Restore-RestorePointFrequency { - <# - .SYNOPSIS - Puts SystemRestorePointCreationFrequency back to $PreviousValue, if it is still 0 - .DESCRIPTION - Shared by the inline timeout path in New-SystemRestorePoint and by - Invoke-StaleMarkerRecovery, so both behave identically. Only acts when the value - is currently 0 (i.e. still holding this script's override) - if something else - has since set a real value, that is left alone. - .OUTPUTS - [bool] $true when nothing needs doing or the restore succeeded, $false on failure - #> - param($PreviousValue) - - try { - $srKey = 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\SystemRestore' - $current = (Get-ItemProperty -Path $srKey -Name SystemRestorePointCreationFrequency -ErrorAction SilentlyContinue).SystemRestorePointCreationFrequency - if ($current -ne 0) { return $true } # already a real value - not ours to touch - - if ($null -ne $PreviousValue) { - Set-ItemProperty -Path $srKey -Name SystemRestorePointCreationFrequency -Value $PreviousValue -Type DWord -Force -ErrorAction Stop - } else { - Remove-ItemProperty -Path $srKey -Name SystemRestorePointCreationFrequency -ErrorAction Stop - } - return $true - } catch { - Write-Log "Could not restore SystemRestorePointCreationFrequency: $_" -Level WARNING - return $false - } -} - -function Invoke-StaleMarkerRecovery { - <# - .SYNOPSIS - Recovers system state left behind by a hard-killed previous run, if any - .DESCRIPTION - v2.17 (p.13 of the audit). Called once at the start of a run. A marker left by - THIS run's own process id is not evidence of anything (re-entrant call, or a - race) - only a marker from a different process means the previous run never - reached its cleanup. - - The marker is kept when recovery FAILS, so the next run can retry instead of - leaving the damage in place forever with no record of it. - - Known limitation, deliberately not solved here: a PID is not a durable identity - (Windows recycles them, and two concurrent runs would each see the other as - "foreign"). Both cases need a second WinClean running as administrator at the - same time, which the script does not support anyway; the recovery actions are - also written to be no-ops when there is nothing to repair. - #> - $markerPath = Get-RunMarkerPath - if (-not (Test-Path -LiteralPath $markerPath -ErrorAction SilentlyContinue)) { return } - - try { - $marker = Get-Content -LiteralPath $markerPath -Raw -ErrorAction Stop | ConvertFrom-Json - } catch { - Remove-Item -LiteralPath $markerPath -Force -ErrorAction SilentlyContinue - return - } - - if ($marker.Pid -eq $PID) { return } - - Write-Log "Recovery marker found from an interrupted previous run (phase: $($marker.Phase), pid $($marker.Pid)) - checking for leftover state" -Level WARNING - $script:Stats.WarningsCount++ - - $recovered = $true - switch ($marker.Phase) { - 'RestorePointFrequencyOverride' { - $recovered = Restore-RestorePointFrequency -PreviousValue $marker.PreviousValue - if ($recovered) { - Write-Log "Checked SystemRestorePointCreationFrequency after the interrupted run" -Level INFO - } - } - 'WUServiceStop' { - # Only services this script actually stopped are restarted. Starting every - # stopped service would fight an administrator who disabled one on purpose. - foreach ($svcName in @($marker.ServicesToRestart)) { - if (-not $svcName) { continue } - try { - $svc = Get-Service -Name $svcName -ErrorAction SilentlyContinue - if ($svc -and $svc.Status -eq 'Stopped') { - Start-Service -Name $svcName -ErrorAction Stop - Write-Log "Restarted $svcName, left stopped by the interrupted run" -Level INFO - } - } catch { - Write-Log "Could not restart $svcName : $_" -Level WARNING - $recovered = $false - } - } - } - } - - if ($recovered) { - Remove-Item -LiteralPath $markerPath -Force -ErrorAction SilentlyContinue - } else { - Write-Log "Recovery incomplete - keeping the marker so the next run retries" -Level WARNING - } -} - -function Test-InteractiveConsole { - <# - .SYNOPSIS - Checks if running in an interactive console environment - .DESCRIPTION - Returns $false for Scheduled Tasks, ISE, remote sessions, etc. - Used to safely skip [Console]::KeyAvailable calls that would throw exceptions - #> - try { - # Check if we're in ConsoleHost and have a valid console window - if ($Host.Name -ne 'ConsoleHost') { - return $false - } - # Try to access console properties - will throw in non-console environments - $null = [Console]::WindowWidth - return $true - } catch { - return $false - } -} - -function Get-ConsoleWidth { - <# - .SYNOPSIS - The console width in columns, or 0 when it cannot be determined - .DESCRIPTION - v2.22. Redirected output, scheduled tasks and non-console hosts either throw here - or report nonsense, and 0 is the honest answer for all of them - the caller falls - back to the historical fixed width rather than laying out against a guess. - #> - try { - $width = $Host.UI.RawUI.WindowSize.Width - if ($width -gt 0) { return [int]$width } - } catch { } - return 0 -} - -function Get-BoxWidth { - <# - .SYNOPSIS - Inner width for framed sections, derived from the console width - .DESCRIPTION - v2.22, pure so the bounds are testable. A box is printed as two spaces of indent, - a border character, the inner width, and a closing border character - so it needs - ConsoleWidth-4 at the very most, and 6 is subtracted to keep a margin away from - the wrap column. - - Bounded both ways on purpose: - - never below 70, the width every previous version used. A narrow console cannot - be laid out well either way, and shrinking below 70 would change how WinClean - looks for everyone who has been reading it at 70 for ten releases. - - never above 90. Raising the ceiling further makes the frames span the screen on - a wide monitor, which reads worse rather than better - the user asked for - "somewhat wider", not "as wide as it goes". - #> - param([int]$ConsoleWidth) - - if ($ConsoleWidth -le 0) { return 70 } - return [math]::Max(70, [math]::Min(90, $ConsoleWidth - 6)) -} - -function Expand-ConsoleWindow { - <# - .SYNOPSIS - Best-effort widening of the console window at startup - .DESCRIPTION - v2.22. The desktop shortcut opens conhost at its 120-column default, and winget's - upgrade table needs about 140 on a localised system - so it wrapped, and every - wrapped row landed across the script's own output. Measured on the reporting - machine: window 120x30, buffer 120x9001, maximum physical window 3824 columns. - There was room; nobody had asked for it. - - Deliberately done here rather than by writing console properties into the .lnk: - the shortcut route only fixes the shortcut (and NT_CONSOLE_PROPS is a binary blob - WScript.Shell will not write), while this helps every way of starting the script. - - Best-effort by design. Windows Terminal ignores or refuses programmatic resizing, - redirected hosts throw - none of that is worth a warning, let alone a failed run. - The buffer is grown before the window because a window may never exceed its - buffer; the reverse order fails. - #> - param([int]$DesiredWidth = 140) - - try { - $raw = $Host.UI.RawUI - $current = $raw.WindowSize - if ($current.Width -le 0 -or $current.Width -ge $DesiredWidth) { return } - - # Never ask for more than the screen can physically show - $target = [math]::Min($DesiredWidth, $raw.MaxPhysicalWindowSize.Width) - if ($target -le $current.Width) { return } - - $buffer = $raw.BufferSize - if ($buffer.Width -lt $target) { - $buffer.Width = $target - $raw.BufferSize = $buffer - } - - $window = $raw.WindowSize - $window.Width = $target - $raw.WindowSize = $window - } catch { - # Host refused; the run continues at whatever width it already had - } -} - -function Test-InternetConnection { - <# - .SYNOPSIS - Проверяет доступ к интернету через TCP-соединения с таймаутом - .DESCRIPTION - Использует TcpClient с явным таймаутом (3 сек) вместо Test-NetConnection, - который может зависать на 20-30 секунд при VPN или нестабильном соединении. - Результат кэшируется на весь прогон (v2.17): вызывается из обеих половин фазы - Updates (Windows Update, Applications Update), до 15 сек на офлайн-машине каждый раз. - Сетевая связность внутри одного прогона скрипта не меняется настолько часто, - чтобы повторная проверка была оправдана. -Force сбрасывает кэш. - #> - param([switch]$Force) - - if (-not $Force -and $null -ne $script:InternetConnectionCache) { - return $script:InternetConnectionCache - } - - $targets = @( - @{ Host = 'www.microsoft.com'; Port = 443 } - @{ Host = 'api.github.com'; Port = 443 } - @{ Host = 'cdn.winget.microsoft.com'; Port = 443 } - ) - - $timeoutMs = 3000 # 3 секунды таймаут на каждое соединение - - foreach ($target in $targets) { - $tcpClient = $null - try { - $tcpClient = New-Object System.Net.Sockets.TcpClient - $connect = $tcpClient.BeginConnect($target.Host, $target.Port, $null, $null) - $success = $connect.AsyncWaitHandle.WaitOne($timeoutMs, $false) - - if ($success -and $tcpClient.Connected) { - $tcpClient.EndConnect($connect) - $script:InternetConnectionCache = $true - return $true - } - } catch { - } finally { - # Always close TcpClient to prevent resource leaks (fixed in v2.2) - if ($tcpClient) { - $tcpClient.Close() - } - } - } - - # Запасной вариант: ICMP (может быть заблокирован в некоторых сетях) - $dnsServers = @('8.8.8.8', '1.1.1.1', '208.67.222.222') - - foreach ($dns in $dnsServers) { - if (Test-Connection -ComputerName $dns -Count 1 -Quiet -TimeoutSeconds 2 -ErrorAction SilentlyContinue) { - $script:InternetConnectionCache = $true - return $true - } - } - $script:InternetConnectionCache = $false - return $false -} - -function Test-PSGalleryConnection { - <# - .SYNOPSIS - Проверяет доступность PowerShell Gallery перед установкой модулей - .DESCRIPTION - Использует Invoke-WebRequest с коротким таймаутом для проверки доступности - powershellgallery.com. Более специфичная проверка чем общий Test-InternetConnection. - .OUTPUTS - [bool] $true если PowerShell Gallery доступен, $false в противном случае - #> - try { - # Check PSGallery API endpoint (faster than main page) - # Note: -UseBasicParsing removed - it was deprecated in PS 6.0 and removed in PS 7.4+ - $response = Invoke-WebRequest -Uri "https://www.powershellgallery.com/api/v2" ` - -TimeoutSec 10 -ErrorAction Stop - return $response.StatusCode -eq 200 - } catch { - return $false - } -} - -function Test-PathInsideRoot { - <# - .SYNOPSIS - Tells whether a path lies inside a directory - .DESCRIPTION - Pure decision, added in v2.21 for the update-channel rule below. - The root gets a trailing separator before the comparison, so C:\Temp2\x is not - read as living inside C:\Temp. Case-insensitive, matching the file system. - An unusable path or root answers "not inside" rather than throwing: the only - consumer picks wording from the answer, and a wrong "yes" prints an instruction - that does not apply to the copy the user is running. - #> - param( - [AllowNull()][string]$Path, - [AllowNull()][string]$Root - ) - - if ([string]::IsNullOrWhiteSpace($Path) -or [string]::IsNullOrWhiteSpace($Root)) { return $false } - - try { - $fullPath = [System.IO.Path]::GetFullPath($Path) - $fullRoot = [System.IO.Path]::GetFullPath($Root).TrimEnd('\', '/') + [System.IO.Path]::DirectorySeparatorChar - } catch { - return $false - } - - return $fullPath.StartsWith($fullRoot, [System.StringComparison]::OrdinalIgnoreCase) -} - -function Get-ScriptUpdateChannel { - <# - .SYNOPSIS - Decides how THIS running copy of WinClean can be updated - .DESCRIPTION - Pure decision, added in v2.21 to fix an update that reported success while - updating a different file. - Until now the question asked was "does a Gallery copy exist anywhere on this - machine", and the answer was acted on as if it had been "is the file I am - running that copy". Both are commonly true at once: install.ps1 (v2.15) puts a - copy in %ProgramFiles%\WinClean while an older Install-Script copy still sits in - Documents\PowerShell\Scripts. Update-Script then updated the copy in Documents, - printed "run WinClean again to use the new version", and the shortcut kept - starting the untouched Program Files copy - forever, with no error anywhere. - Returns one of: - gallery - the running file IS the only Gallery copy; it can update itself - gallery-ambiguous - it matches a Gallery copy, but several installs exist - installer - it lives in %ProgramFiles%\WinClean, so install.ps1 updates it - oneliner - it lives under TEMP, so get.ps1 downloaded it for this run - manual - somewhere else; only a manual download applies - unknown - the path is unavailable, so nothing may be promised - Ambiguity resolves away from 'gallery' on purpose: the cost of the wrong answer - is asymmetric. Printing an instruction to a copy that could have updated itself - is a minor annoyance; auto-updating a file nobody is running is the defect. - That is also why several installs (AllUsers and CurrentUser can coexist) refuse - the automatic path outright, raised in review. PowerShellGet's Update-Script has no - -Scope at all; PSResourceGet's Update-PSResource does have one, but WinClean does - not map a matched install location back to a scope, and the updater is chosen by - which provider answered rather than by which install matched. So the target cannot - currently be named, and declining beats guessing: verifying afterwards would report - a miss honestly, but only after the unused copy had already been modified. Same rule - as Select-StorageSenseTask. Aiming the PSResourceGet updater by scope is possible - and is left as future work rather than claimed here. - Known limit: the comparison is lexical. A path reached through a junction or an - 8.3 alias fails to match and is merely shown an instruction (safe). The reverse - - two files differing only in case inside one case-sensitive directory - would match - wrongly, and is left unhandled as a configuration this script does not support. - 'installer' and 'oneliner' describe WHERE the file is, which is what decides the - right instruction; they do not claim to prove which tool put it there. - #> - param( - [AllowNull()][string]$ExecutingPath, - [AllowNull()][string[]]$GalleryLocation, - [AllowNull()][string]$ProgramFilesRoot = [Environment]::GetFolderPath([Environment+SpecialFolder]::ProgramFiles), - [AllowNull()][string]$TempRoot = [System.IO.Path]::GetTempPath() - ) - - if ([string]::IsNullOrWhiteSpace($ExecutingPath)) { return 'unknown' } - try { $fullPath = [System.IO.Path]::GetFullPath($ExecutingPath) } catch { return 'unknown' } - - # Compare the full file path, not its folder: a Gallery install owns exactly - # WinClean.ps1 inside InstalledLocation, and a differently named copy sharing that - # folder is not the file the provider would replace. - # Deduplicate case-insensitively, matching the comparison below and the file system. - # Select-Object -Unique is case-SENSITIVE (verified, 22.07.2026), so the two providers - # reporting one install with different casing would have looked like two installs and - # silently switched a perfectly updatable machine to the refusal path. - $seen = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) - $candidates = @() - foreach ($location in @($GalleryLocation)) { - if ([string]::IsNullOrWhiteSpace($location)) { continue } - try { $candidate = [System.IO.Path]::GetFullPath((Join-Path $location 'WinClean.ps1')) } catch { continue } - if ($seen.Add($candidate)) { $candidates += $candidate } - } - - foreach ($candidate in $candidates) { - if ([string]::Equals($fullPath, $candidate, [System.StringComparison]::OrdinalIgnoreCase)) { - if ($candidates.Count -gt 1) { return 'gallery-ambiguous' } - return 'gallery' - } - } - - $installerRoot = if ([string]::IsNullOrWhiteSpace($ProgramFilesRoot)) { $null } - else { Join-Path $ProgramFilesRoot 'WinClean' } - if (Test-PathInsideRoot -Path $fullPath -Root $installerRoot) { return 'installer' } - if (Test-PathInsideRoot -Path $fullPath -Root $TempRoot) { return 'oneliner' } - return 'manual' -} - -function Get-UpdateVerification { - <# - .SYNOPSIS - Decides whether an update that reported no error actually replaced the file - .DESCRIPTION - Pure decision, added in v2.21. "The cmdlet did not throw" is not "the file on - disk is now the new version" - the whole point of this release's predecessor was - that operations reporting success without doing anything are the expensive kind - of defect, and an updater is the last place to trust a silent success. - Returns Applied and Reason ('applied' | 'unchanged' | 'unreadable'). - An unreadable or unparsable version is never 'applied': that is the state where - nothing is known, and claiming success there is the behaviour being removed. - #> - param( - [AllowNull()][string]$ExpectedVersion, - [AllowNull()][string]$ObservedVersion - ) - - $observed = $null - $expected = $null - if (-not [Version]::TryParse([string]$ObservedVersion, [ref]$observed) -or - -not [Version]::TryParse([string]$ExpectedVersion, [ref]$expected)) { - return @{ Applied = $false; Reason = 'unreadable' } - } - - # Missing components are -1 in [Version], not 0, so "2.21" compares as LESS than - # "2.21.0" (raised in review). The Gallery is free to report either form for the same - # release, and without this the check would announce "the update did not apply" after - # a perfectly good update - a false alarm in the one place whose job is to be trusted. - $observed = [Version]::new($observed.Major, $observed.Minor, - [math]::Max(0, $observed.Build), [math]::Max(0, $observed.Revision)) - $expected = [Version]::new($expected.Major, $expected.Minor, - [math]::Max(0, $expected.Build), [math]::Max(0, $expected.Revision)) - - if ($observed -lt $expected) { return @{ Applied = $false; Reason = 'unchanged' } } - return @{ Applied = $true; Reason = 'applied' } -} - -function Get-ScriptFileVersion { - <# - .SYNOPSIS - Reads the .VERSION line out of a WinClean.ps1 file on disk - .DESCRIPTION - Added in v2.21 to verify an update against the file actually being run, rather - than against what a package provider believes it installed. PowerShell reads a - script into memory before executing it, so the running file can be replaced and - re-read while the current run continues. - Returns the version string, or $null when the file cannot be read or carries no - .VERSION line - both mean "not verified", never "verified". - #> - param([AllowNull()][string]$Path) - - if ([string]::IsNullOrWhiteSpace($Path)) { return $null } - try { $head = Get-Content -LiteralPath $Path -TotalCount 40 -ErrorAction Stop } catch { return $null } - - foreach ($line in $head) { - if ($line -match '^\s*\.VERSION\s+([\d.]+)\s*$') { return $Matches[1] } - } - return $null -} - -function Get-InstalledWinCleanLocation { - <# - .SYNOPSIS - Returns the folders holding a PowerShell Gallery copy of WinClean - .DESCRIPTION - Two providers can own that copy: PowerShellGet (Install-Script) and PSResourceGet - (Install-PSResource). Measured on 22.07.2026 with each in turn: both report the - other's install, because they share the InstalledScriptInfos metadata, and for a - script InstalledLocation is the Scripts folder itself - unlike modules, it carries - no version subfolder. Both are still queried, because which provider ships is a - property of the PowerShell version rather than of this machine. PSResourceGet is - asked for AllUsers explicitly, because its -Scope defaults to CurrentUser. - An array: CurrentUser and AllUsers installs can coexist. THROWS when no provider - covered the machine and at least one query failed - including when some locations - WERE found, because a partial list is not a smaller answer: a hidden install turns - an ambiguous target back into a confident one. An unreadable machine must not be - reported as a machine with no Gallery copy either, because that answer sends the - caller on to advise an installer command and build a second installation. - #> - $locations = @() - $failures = @() - $answered = $false - - # Each provider is isolated (raised in review): -ErrorAction SilentlyContinue only - # covers non-terminating errors, so a broken PowerShellGet used to abort this function - # outright and PSResourceGet was never asked - turning a repairable half-outage into - # "no Gallery copy exists", which is exactly the wrong answer to give this caller. - if (Get-Command Get-InstalledScript -ErrorAction SilentlyContinue) { - try { - # Enumerated without -Name and filtered here (raised in review, verified - # 22.07.2026): asking for a specific name raises a plain Exception when it is - # not installed, which cannot be told from a real outage without matching a - # localised message - so the query had to run with SilentlyContinue and a - # suppressed failure then passed as an authoritative "no copy installed". - # Listing everything returns an EMPTY COLLECTION when nothing is installed, so - # -ErrorAction Stop now separates the two properly. This provider enumerates - # both scopes, so one completed call covers the machine. - $locations += @(Get-InstalledScript -ErrorAction Stop | - Where-Object { $_.Name -eq 'WinClean' } | - ForEach-Object { $_.InstalledLocation }) - $answered = $true - } catch { - $failures += $_ - Write-Log "PowerShellGet could not be queried for installed copies: $_" -Level DETAIL - } - } - if (Get-Command Get-PSResource -ErrorAction SilentlyContinue) { - # -ErrorAction Stop here too, but for a different reason than above: measured - # 22.07.2026, this provider raises a TYPED ResourceNotFoundException for "nothing - # installed", so the two outcomes are separated by exception type rather than by - # asking a question that cannot fail. - $currentUserRead = $false - try { - $locations += @(Get-PSResource -Name 'WinClean' -ErrorAction Stop | - Where-Object { $_.Type -eq 'Script' } | - ForEach-Object { $_.InstalledLocation }) - $currentUserRead = $true - } catch { - if ($_.Exception.GetType().Name -eq 'ResourceNotFoundException') { - $currentUserRead = $true # answered: this scope holds no copy - } else { - $failures += $_ - Write-Log "PSResourceGet could not be queried for installed copies: $_" -Level DETAIL - } - } - - # AllUsers has to be asked for explicitly (raised in review, verified here): - # Get-PSResource's -Scope is not nullable, so an unbound call means CurrentUser and - # searches only the Documents paths. PowerShellGet's Get-InstalledScript enumerates - # both scopes, which is why this was invisible on any machine that has it - and why - # it mattered exactly on the PSResourceGet-only machine this release added support - # for. AllUsers is the natural scope for a script that requires administrator, so - # missing it classified a Gallery copy as 'manual' and advised install.ps1, adding a - # second installation. - # Support is read from the cmdlet's own metadata rather than tried and ignored - # (raised in review): "this build has no -Scope" is a limitation to accept, but any - # OTHER failure means the AllUsers half went unread, and swallowing that would hide - # precisely the installation this branch exists to find. - # Tracked per scope (raised in review): a single "somebody answered" flag let a - # successful CurrentUser query mask a failed AllUsers one, and AllUsers is the half - # this whole branch exists to read. This provider counts as having covered the - # machine only when BOTH scopes were read - or when the build predates -Scope, which - # is an accepted limitation rather than a failure. - $allUsersRead = $true - if ((Get-Command Get-PSResource).Parameters.ContainsKey('Scope')) { - $allUsersRead = $false - try { - $locations += @(Get-PSResource -Name 'WinClean' -Scope AllUsers -ErrorAction Stop | - Where-Object { $_.Type -eq 'Script' } | - ForEach-Object { $_.InstalledLocation }) - $allUsersRead = $true - } catch { - if ($_.Exception.GetType().Name -eq 'ResourceNotFoundException') { - $allUsersRead = $true - } else { - $failures += $_ - Write-Log "PSResourceGet could not be queried for AllUsers copies: $_" -Level DETAIL - } - } - } - - if ($currentUserRead -and $allUsersRead) { $answered = $true } - } - - # Nothing found AND something failed is not the same as nothing installed (raised in - # review). Treating them alike classified the running file as 'manual' and printed - # "this copy did not come from the Gallery" plus an installer command - which would add - # a SECOND installation next to the one that was merely unreadable, building the very - # state this release exists to stop misreporting. The caller turns this into a warning - # and offers nothing, which is the honest answer when the machine cannot be read. - # An absent provider is not a failure: a machine with no package provider at all - # legitimately has no Gallery copy. - # Keyed on "nobody answered", not "somebody failed" (raised in review): with a broken - # PowerShellGet beside a working PSResourceGet that legitimately reports no copy, a - # failure count above zero would raise a warning on a machine that answered correctly. - # Coverage alone decides, NOT emptiness (raised in review): with CurrentUser returning - # the running copy while the AllUsers query failed, a non-empty list looked like a - # complete answer - and a hidden second install turns 'gallery-ambiguous' back into - # 'gallery', re-enabling exactly the automatic update whose target cannot be resolved. - # A partial list is not a smaller answer, it is a different question answered. - if (-not $answered -and $failures.Count -gt 0) { - throw "installed copies could not be enumerated: $($failures[-1])" - } - - # Case-insensitive, for the reason given in Get-ScriptUpdateChannel: both providers - # report the same install, and differing casing between them must not read as two. - # Normalised first (raised in review), so "C:\Scripts" and "C:\Scripts\" are one - # location here too - this function promises distinct locations, and the caller is not - # the only thing entitled to rely on that. - $unique = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) - $result = @() - foreach ($location in $locations) { - if ([string]::IsNullOrWhiteSpace($location)) { continue } - $normalized = try { [System.IO.Path]::GetFullPath($location) } catch { $location } - # GetFullPath keeps a trailing separator, so "C:\Scripts" and "C:\Scripts\" would - # still be two. Trimmed everywhere except a root, where the separator is meaningful: - # "C:\" is the root while "C:" means the current directory on that drive. - $root = try { [System.IO.Path]::GetPathRoot($normalized) } catch { '' } - if ($normalized.Length -gt $root.Length) { $normalized = $normalized.TrimEnd('\', '/') } - if ($unique.Add($normalized)) { $result += $normalized } - } - return $result -} - -function Select-UpdateCommand { - <# - .SYNOPSIS - Picks the cmdlet that should perform the update on this machine - .DESCRIPTION - Added in v2.21, raised in review. Choosing the updater by mere presence sent a - machine whose PowerShellGet is installed but broken - an unregistered PSGallery, - say - straight back to Update-Script, even though discovery had just succeeded - through PSResourceGet. The provider that answered is evidence about which one - works, so it goes first; the other remains as a fallback for the case where the - answering provider has no updater available. - Returns the command name, or $null when neither exists. - #> - param([AllowNull()][string]$Provider) - - $order = if ($Provider -eq 'PSResourceGet') { @('Update-PSResource', 'Update-Script') } - else { @('Update-Script', 'Update-PSResource') } - - foreach ($command in $order) { - if (Get-Command $command -ErrorAction SilentlyContinue) { return $command } - } - return $null -} - -function Wait-ForKeyPress { - <# - .SYNOPSIS - Best-effort "press any key" pause for the update prompts - .DESCRIPTION - Split out in v2.21 for two reasons. It centralises the guard - Test-InteractiveConsole - can be satisfied by a host whose RawUI still refuses ReadKey, and an exception there - must never abort a maintenance run or a COMPLETED update. - It also makes the interactive branches testable at all: RawUI.ReadKey blocks on a - real console, so a test that reached it hung the whole suite until it was killed. - A named function can be mocked; a method call on $Host cannot. - #> - # The failure is recorded rather than erased: at the non-Gallery prompt this pause is - # what gives the user time to read the instruction the whole release exists to deliver, - # and a host that refuses ReadKey turns it into a no-op that scrolls past (raised in - # review). DETAIL, because it changes nothing about the run's outcome. - try { $null = $Host.UI.RawUI.ReadKey("NoEcho,IncludeKeyDown") } - catch { Write-Log "Console did not accept a keypress, continuing without the pause: $_" -Level DETAIL } -} - -function Get-UpdateInstruction { - <# - .SYNOPSIS - The correct way to update the copy identified by Get-ScriptUpdateChannel - .DESCRIPTION - Split out in v2.21 so the advice can be tested. The old code had exactly two - messages, and the one shown to every non-Gallery copy advised - "Install-Script -Name WinClean" - which installs a SECOND copy in Documents and - leaves the running one untouched. That is not a hint that fails to help; it - builds the two-installation state this release exists to stop misreporting. - Returns the lines to print. - #> - param( - [AllowNull()][string]$Channel, - [AllowNull()][string]$ExecutingPath, - [AllowNull()][string]$Provider - ) - - $installer = ' irm https://raw.githubusercontent.com/bivlked/WinClean/main/install.ps1 | iex' - - # Listing commands for the two-installation cases. Both providers are shown because - # either can report the other's install; whichever module is absent simply reports an - # unknown command, which is why the caller is told to expect that (raised in review - - # -ErrorAction cannot suppress a missing command, only a failing one). - $inspect = @( - ' Get-InstalledScript -Name WinClean -ErrorAction SilentlyContinue |', - ' Select-Object Version, InstalledLocation', - ' Get-PSResource -Name WinClean -ErrorAction SilentlyContinue |', - ' Where-Object { $_.Type -eq ''Script'' } | Select-Object Version, InstalledLocation', - ' (one of the two may not exist on this machine - that is expected)' - ) - $running = if ([string]::IsNullOrWhiteSpace($ExecutingPath)) { @() } - else { @(" The file you are running now is:", " $ExecutingPath") } - - switch ($Channel) { - 'gallery' { - # Name the command this machine actually has, preferring the provider that - # discovery just proved works (raised in review). Advising Update-Script on a - # PSResourceGet-only machine is advice that cannot be run; naming either one - # where NEITHER exists is the same mistake twice. - $manual = Select-UpdateCommand -Provider $Provider - if ($manual) { return @(" To update manually: $manual -Name WinClean") } - # "no update command is available" rather than "no provider is installed": - # Get-Command proves the former, and a module that fails to auto-load looks - # identical to one that is absent - return @( - ' No PowerShell update command is available, so this copy cannot update itself.', - ' Download the latest release: https://github.com/bivlked/WinClean/releases/latest' - ) - } - 'gallery-ambiguous' { - # Several Gallery installs exist and Update-Script cannot be aimed at one of - # them, so WinClean declines to touch any (raised in review). Naming the running - # path matters here: it is the only way the reader can tell the copies apart. - # Must end with something the reader can actually do (raised in review): - # removing an install is only safe when the copies differ, so the always-works - # answer - replace the printed file from the release - is stated first. - return @( - ' Several PowerShell Gallery installations of WinClean are present, and WinClean', - ' cannot tell which one an automatic update would change - so it did not try.' - ) + $running + @( - ' Update it directly by replacing that file with the latest release:', - ' https://github.com/bivlked/WinClean/releases/latest', - ' To stop this recurring, list the installations and remove the ones you do not run:' - ) + $inspect - } - 'gallery-unverified' { - # Shown after an update that reported success but left the running file at the - # old version. Raised in review: this branch used to print the 'manual' advice, - # telling a copy that IS Gallery-managed that it is not, and pointing it at - # install.ps1 - which would add a second installation, the very state that made - # the update target the wrong file in the first place. - return @( - ' The provider may have updated a different installation than the one you are running.' - ) + $running + @( - ' List the copies present and compare their locations with the path above:' - ) + $inspect + @( - ' Or download the latest release manually: https://github.com/bivlked/WinClean/releases/latest' - ) - } - 'installer' { - # Describes where the file is, not who put it there (the header of - # Get-ScriptUpdateChannel says the same): the instruction is right either way, - # because re-running the installer replaces exactly this location - return @( - ' This copy lives where install.ps1 installs. Update it by re-running the installer', - ' in an elevated terminal:', - $installer - ) - } - 'oneliner' { - return @( - ' This copy is running from a temporary folder, which is where get.ps1 puts the', - ' release it downloads - and it downloads the latest one every time:', - ' irm https://raw.githubusercontent.com/bivlked/WinClean/main/get.ps1 | iex' - ) - } - default { - # 'manual', 'unknown' and anything unforeseen: never advise Install-Script, - # which would add a copy instead of updating this one. - # States what was observed, not where the file came from (raised in review): - # the location list can also be short because a provider could not be read, and - # asserting provenance on that basis is a claim the code cannot back. - $lead = if ($Channel -eq 'unknown') { - ' The location of this copy could not be determined, so it cannot update itself.' - } else { - ' This copy does not match a PowerShell Gallery installation, so it cannot update itself.' - } - return @( - $lead, - ' Install it properly (creates an elevated desktop shortcut, updates in place):', - $installer, - ' Or download the release manually: https://github.com/bivlked/WinClean/releases/latest' - ) - } - } -} - -function Find-GalleryWinClean { - <# - .SYNOPSIS - Asks the PowerShell Gallery for the latest published WinClean - .DESCRIPTION - Added in v2.21, raised in review. Discovery called Find-Script unconditionally, - which is a PowerShellGet command. On a machine carrying only PSResourceGet - the - exact configuration the updater fallback below exists for - that command does not - exist, discovery threw, the surrounding catch turned it into "no update available", - and the entire update path was dead while every test around it passed. A fallback - that cannot be reached is not a fallback. - Returns Version, ReleaseNotes and Provider ('PowerShellGet' | 'PSResourceGet'), or - $null when the providers answered and the Gallery has nothing. THROWS when every - provider that exists failed - "could not ask" is not an answer, and swallowing it - made an unregistered repository look identical to "you are up to date". - Provider is carried because it is evidence: the one that just answered is known to - work, and the updater should not then be chosen by mere presence and land on the - one that failed. - #> - # Each provider is tried on its own merits (raised in review): falling back only when - # a command is ABSENT leaves a present-but-broken PowerShellGet - an unregistered - # PSGallery, say - masking a PSResourceGet that would have answered. Each keeps its own - # repository registration, so one failing says nothing about the other. Discovery is - # read-only, so trying both costs nothing but a round trip. - $found = $null - $provider = $null - $failures = @() - $answered = $false - - if (Get-Command Find-Script -ErrorAction SilentlyContinue) { - try { - $found = @(Find-Script -Name 'WinClean' -Repository PSGallery -ErrorAction Stop)[0] - $answered = $true - if ($found) { $provider = 'PowerShellGet' } - } catch { $failures += $_; $found = $null } - } - if (-not $found -and (Get-Command Find-PSResource -ErrorAction SilentlyContinue)) { - # Filtered to scripts: a module sharing the name would otherwise set the version. - # [0] of an empty filtered array is $null, which is the intended "nothing found". - # The provider returns the latest matching version, so no sorting is done here. - try { - $found = @(Find-PSResource -Name 'WinClean' -Repository PSGallery -ErrorAction Stop | - Where-Object { $_.Type -eq 'Script' })[0] - $answered = $true - if ($found) { $provider = 'PSResourceGet' } - } catch { $failures += $_; $found = $null } - } - - # "Could not ask" must not look like "asked, nothing newer" (raised in review - this - # was a regression introduced by the per-provider catches above). Before them, a failing - # Find-Script threw all the way to the caller, which logged a warning; swallowing it - # here made an unregistered PSGallery, a TLS or proxy failure and an unpublished script - # all read as "you are up to date", with nothing in the log at all. - # Keyed on "nobody answered", not on "somebody failed" (also raised in review): with a - # broken PowerShellGet beside a working PSResourceGet, a failure count above zero would - # turn a perfectly good answer into a warning on every run. - if (-not $answered -and $failures.Count -gt 0) { - throw "the PowerShell Gallery could not be queried: $($failures[-1])" - } - - if (-not $found) { return $null } - return @{ Version = $found.Version; ReleaseNotes = $found.ReleaseNotes; Provider = $provider } -} - -function Test-ScriptUpdate { - <# - .SYNOPSIS - Проверяет наличие обновлений WinClean в PowerShell Gallery - .DESCRIPTION - Сравнивает текущую версию скрипта с последней версией в PowerShell Gallery. - Определяет, каким способом можно обновить ИМЕННО выполняемую копию (v2.21). - .OUTPUTS - [hashtable] с информацией об обновлении или $null если обновление не требуется - #> - # Check if we can reach PSGallery - if (-not (Test-PSGalleryConnection)) { - return $null - } - - try { - $currentVersion = [Version]$script:Version - - # Query PSGallery for latest version, through whichever provider this machine has - $galleryScript = Find-GalleryWinClean - if (-not $galleryScript) { return $null } - $latestVersion = [Version]$galleryScript.Version - - if ($latestVersion -gt $currentVersion) { - # v2.21: which copy is running decides what can be offered, not whether a - # Gallery copy exists somewhere on the machine - return @{ - CurrentVersion = $currentVersion.ToString() - LatestVersion = $latestVersion.ToString() - Channel = Get-ScriptUpdateChannel -ExecutingPath $PSCommandPath ` - -GalleryLocation (Get-InstalledWinCleanLocation) - Provider = $galleryScript.Provider - ReleaseNotes = $galleryScript.ReleaseNotes - } - } - } catch { - # Counted (raised in review): Write-Log does not touch the counters - every warning - # in this file increments one by hand - so this one was invisible in the summary and - # in the result JSON. The "silently fail" comment it replaces had outlived the code: - # the level was already WARNING, and the try now covers channel classification, - # provider lookup and two [Version] casts, any of which can throw. - Write-Log "Update check failed: $_" -Level WARNING - $script:Stats.WarningsCount++ - } - - return $null -} - -function Invoke-ScriptUpdate { - <# - .SYNOPSIS - Предлагает пользователю обновить WinClean и выполняет обновление при подтверждении - .PARAMETER UpdateInfo - Хэштаблица с информацией об обновлении от Test-ScriptUpdate - #> - param( - [Parameter(Mandatory)] - [hashtable]$UpdateInfo - ) - - # Dynamically centered title in a 70-char box (matches the rest of the UI; v2.14 - # fixes a misaligned right border caused by hardcoded padding) - $boxWidth = $script:BoxWidth - $updateTitle = "UPDATE AVAILABLE" - $titlePadding = [math]::Max(0, $boxWidth - $updateTitle.Length) - $titleLeftPad = [math]::Floor($titlePadding / 2) - - Write-Host "" - Write-Host " ╔$("═" * $boxWidth)╗" -ForegroundColor Cyan - Write-Host " ║$(" " * $titleLeftPad)" -NoNewline -ForegroundColor Cyan - Write-Host $updateTitle -NoNewline -ForegroundColor Yellow - Write-Host "$(" " * ($titlePadding - $titleLeftPad))║" -ForegroundColor Cyan - Write-Host " ╚$("═" * $boxWidth)╝" -ForegroundColor Cyan - Write-Host "" - Write-Host " Current version: " -NoNewline -ForegroundColor Gray - Write-Host "v$($UpdateInfo.CurrentVersion)" -ForegroundColor White - Write-Host " Latest version: " -NoNewline -ForegroundColor Gray - Write-Host "v$($UpdateInfo.LatestVersion)" -NoNewline -ForegroundColor Green - Write-Host " (new)" -ForegroundColor DarkGreen - Write-Host "" - - # The channel belongs in the log line, not only on screen (raised in review): the two - # automatic paths below print their advice with Write-Host alone, so a scheduled or CI - # run recorded that an update existed and nothing about which copy was running or what - # was advised - the one fact this release is about. - Write-Log "Update available: v$($UpdateInfo.CurrentVersion) -> v$($UpdateInfo.LatestVersion) (channel: $($UpdateInfo.Channel))" -Level INFO - - # In ReportOnly mode, just inform and continue - if ($ReportOnly) { - Write-Log "ReportOnly mode - no update attempted" -Level INFO - Write-Host " ReportOnly mode - skipping update" -ForegroundColor DarkGray - # Raised in review: the applicable method is still worth naming here. A preview run - # is often exactly when someone is deciding how to update, and printing nothing - # contradicted the documented promise that WinClean names the option that applies. - foreach ($line in (Get-UpdateInstruction -Channel $UpdateInfo.Channel -ExecutingPath $PSCommandPath -Provider $UpdateInfo.Provider)) { - Write-Host $line -ForegroundColor Gray - } - Write-Host "" - return $false - } - - # Check if interactive console is available - if (-not (Test-InteractiveConsole)) { - Write-Log "Non-interactive mode - no update attempted" -Level INFO - Write-Host " Non-interactive mode - skipping update prompt" -ForegroundColor DarkGray - # v2.21: the instruction now follows the running copy. It used to name - # Update-Script unconditionally, which does nothing for the copy in - # %ProgramFiles% that the desktop shortcut starts. - foreach ($line in (Get-UpdateInstruction -Channel $UpdateInfo.Channel -ExecutingPath $PSCommandPath -Provider $UpdateInfo.Provider)) { - Write-Host $line -ForegroundColor Gray - } - Write-Host "" - return $false - } - - if ($UpdateInfo.Channel -ne 'gallery') { - # Anything but a single unambiguous Gallery copy: say what applies to THIS file and - # continue. 'gallery-ambiguous' deliberately lands here too - it IS a Gallery copy, - # but with several installs present no updater can be aimed at this one. - # The wording must not contradict the channel (raised in review): a - # 'gallery-ambiguous' copy IS Gallery-managed - that is precisely why it is here. - # 'unknown' exists precisely to promise nothing, so the log must not promise either - # (raised in review): it used to state flatly that the copy is not Gallery-managed - # while the console, two lines later, said its location could not be determined. - $why = switch ($UpdateInfo.Channel) { - 'gallery-ambiguous' { "several Gallery installations exist and WinClean does not resolve which one an update would change" } - 'unknown' { "the location of the running copy could not be determined" } - default { "this copy does not match a Gallery installation" } - } - Write-Log "Update available but $why (channel: $($UpdateInfo.Channel))" -Level INFO - foreach ($line in (Get-UpdateInstruction -Channel $UpdateInfo.Channel -ExecutingPath $PSCommandPath -Provider $UpdateInfo.Provider)) { - Write-Host $line -ForegroundColor Gray - } - Write-Host "" - Write-Host " Press any key to continue with current version..." -ForegroundColor DarkGray - Wait-ForKeyPress - Write-Host "" - return $false - } - - # The running file is the Gallery copy, so updating it updates what runs next - Write-Host " Update now? (" -NoNewline -ForegroundColor Gray - Write-Host "Y" -NoNewline -ForegroundColor Green - Write-Host "/n): " -NoNewline -ForegroundColor Gray - - $response = Read-Host - if ($response -ne '' -and $response -inotmatch '^[YyДд]') { - Write-Log "Update skipped by user" -Level INFO - Write-Host " Update skipped. Continuing with current version..." -ForegroundColor DarkGray - Write-Host "" - return $false - } - - Write-Host "" - Write-Host " Updating WinClean..." -ForegroundColor Cyan - - try { - # PowerShellGet ships with PowerShell today and PSResourceGet is its replacement; - # either can be the one present, and the one that answered discovery goes first - # $null = ... deliberately: v2.22 made this function's return value meaningful - # ("the run is over"), and a bare switch emits whatever the update provider writes - # to the pipeline. A provider that returned an object would turn $true into an - # array, and `if (Invoke-ScriptUpdate ...)` would then be deciding on the array's - # truthiness rather than on the answer this function meant to give. - $null = switch (Select-UpdateCommand -Provider $UpdateInfo.Provider) { - 'Update-Script' { Update-Script -Name WinClean -Force -ErrorAction Stop } - 'Update-PSResource' { Update-PSResource -Name WinClean -Force -TrustRepository -ErrorAction Stop } - default { throw "no update command available (neither Update-Script nor Update-PSResource)" } - } - } catch { - # A warning, not an error (raised in review): the exit code is computed from - # ErrorsCount alone, and the old level/counter pairing logged ERROR while still - # exiting 0 - a contradiction in the one place that must be believable. Failing to - # update the script is not a failure of the maintenance the user actually asked for. - Write-Log "Update failed: $_" -Level WARNING - $script:Stats.WarningsCount++ - Write-Host " ✗ Update failed: $_" -ForegroundColor Red - Write-Host " Continuing with current version..." -ForegroundColor Yellow - Write-Host "" - return $false - } - - # v2.21: verify against the file being executed. A provider that reports success - # while the running file stays at the old version is the exact failure this release - # fixes, and it must not be re-announced as "update complete". - $observedVersion = Get-ScriptFileVersion -Path $PSCommandPath - $verification = Get-UpdateVerification -ExpectedVersion $UpdateInfo.LatestVersion ` - -ObservedVersion $observedVersion - if (-not $verification.Applied) { - # Report what was actually read, not what this process started as: with several - # installations present those two can differ, and naming the wrong one would send - # the reader looking at the wrong file (raised in review). - $detail = if ($verification.Reason -eq 'unchanged') { - "the file still reports v$observedVersion" - } else { - "its version could not be read back" - } - Write-Log "Update reported success but $detail - continuing with the current version" -Level WARNING - $script:Stats.WarningsCount++ - Write-Host " ! The update reported success, but $detail." -ForegroundColor Yellow - foreach ($line in (Get-UpdateInstruction -Channel 'gallery-unverified' -ExecutingPath $PSCommandPath)) { - Write-Host $line -ForegroundColor Gray - } - Write-Host "" - return $false - } - - Write-Log "Update successful" -Level SUCCESS - Write-Host "" - Write-Host " ✓ Update complete!" -ForegroundColor Green - Write-Host " Please run WinClean again to use the new version." -ForegroundColor Gray - Write-Host "" - - # v2.22: this used to call exit here, which bypassed the finally in Start-WinClean and - # therefore had to hand-copy the result JSON write and the exit-code rule alongside it. - # The caller now owns ending the run, through the one path that does it (raised in - # external review). The exit code is unchanged: the entry point already derives it from - # ErrorsCount, which is what the copied lines here were re-deriving. - # - # The "press any key" pause deliberately does NOT happen here (raised in the second - # review pass): it blocks indefinitely, and in v2.21 the result JSON was already on - # disk before it. Pausing here would put an unbounded wait between the update and the - # artefacts, so a window closed or interrupted at that prompt would leave the run with - # no result JSON and an unreleased log handle - a regression introduced by this very - # refactor. The caller pauses after the run is complete. - $script:Stats.Aborted = 'UpdatedAndExited' - return $true -} - -function Install-ModuleWithTimeout { - <# - .SYNOPSIS - Устанавливает PowerShell модуль с таймаутом - .DESCRIPTION - Использует Background Job для установки модуля с возможностью прервать - операцию по таймауту. Решает проблему бесконечного зависания Install-Module. - .PARAMETER ModuleName - Имя модуля для установки - .PARAMETER TimeoutSeconds - Таймаут в секундах (по умолчанию 120) - .OUTPUTS - [bool] $true если модуль успешно установлен, $false при ошибке/таймауте - #> - param( - [Parameter(Mandatory)] - [string]$ModuleName, - - [int]$TimeoutSeconds = 120 - ) - - $job = Start-Job -ScriptBlock { - param($moduleName) - # Set TLS 1.2 in the job process as well - [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 - Install-Module -Name $moduleName -Force -Scope CurrentUser -AllowClobber -SkipPublisherCheck -ErrorAction Stop - } -ArgumentList $ModuleName - - $completed = Wait-Job $job -Timeout $TimeoutSeconds - - if ($completed) { - $jobState = $job.State - $jobError = $null - - try { - Receive-Job $job -ErrorAction Stop - } catch { - $jobError = $_ - } - - Remove-Job $job -Force - - if ($jobState -eq 'Completed' -and -not $jobError) { - return $true - } else { - if ($jobError) { - Write-Log "Module installation failed: $jobError" -Level ERROR - } - return $false - } - } else { - # Timeout - kill the job - Stop-Job $job -ErrorAction SilentlyContinue - Remove-Job $job -Force - Write-Log "Module installation timed out after $TimeoutSeconds seconds" -Level ERROR - return $false - } -} - -function Install-PackageProviderWithTimeout { - <# - .SYNOPSIS - Устанавливает PackageProvider с таймаутом - .DESCRIPTION - Аналогично Install-ModuleWithTimeout, но для Install-PackageProvider - .PARAMETER ProviderName - Имя провайдера (обычно NuGet) - .PARAMETER TimeoutSeconds - Таймаут в секундах (по умолчанию 60) - .OUTPUTS - [bool] $true если провайдер успешно установлен, $false при ошибке/таймауте - #> - param( - [Parameter(Mandatory)] - [string]$ProviderName, - - [string]$MinimumVersion = "2.8.5.201", - - [int]$TimeoutSeconds = 60 - ) - - $job = Start-Job -ScriptBlock { - param($providerName, $minVersion) - [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 - Install-PackageProvider -Name $providerName -MinimumVersion $minVersion -Force -ErrorAction Stop - } -ArgumentList $ProviderName, $MinimumVersion - - $completed = Wait-Job $job -Timeout $TimeoutSeconds - - if ($completed) { - $jobState = $job.State - $jobError = $null - - try { - Receive-Job $job -ErrorAction Stop | Out-Null - } catch { - $jobError = $_ - } - - Remove-Job $job -Force - - if ($jobState -eq 'Completed' -and -not $jobError) { - return $true - } else { - if ($jobError) { - Write-Log "Package provider installation failed: $jobError" -Level ERROR - } - return $false - } - } else { - Stop-Job $job -ErrorAction SilentlyContinue - Remove-Job $job -Force - Write-Log "Package provider installation timed out after $TimeoutSeconds seconds" -Level ERROR - return $false - } -} - -function Get-WindowsUpdateWithTimeout { - <# - .SYNOPSIS - Runs a Get-WindowsUpdate search in a background job with a timeout - .DESCRIPTION - v2.17 (p.15 of the audit): a hung WU agent call used to hang the whole script - forever - fatal for an unattended nightly stand run with no one to notice. - Read-only search, so killing the job on timeout is safe: there is nothing to - roll back. -ErrorVariable does not cross the job boundary, so the job captures - its own error and returns it as a plain string instead. - .PARAMETER CategoryParamName - 'Category' or 'NotCategory' - which Get-WindowsUpdate parameter to use - .PARAMETER CategoryValue - Value for that parameter (e.g. "Drivers") - #> - param( - [Parameter(Mandatory)] - [ValidateSet('Category', 'NotCategory')] - [string]$CategoryParamName, - - [Parameter(Mandatory)] - [string]$CategoryValue, - - [int]$TimeoutSeconds = 300 - ) - - $job = Start-Job -ScriptBlock { - param($categoryParamName, $categoryValue) - Import-Module PSWindowsUpdate -ErrorAction Stop - $errs = $null - $params = @{ MicrosoftUpdate = $true; ErrorAction = 'SilentlyContinue'; ErrorVariable = 'errs' } - $params[$categoryParamName] = $categoryValue - $updates = @(Get-WindowsUpdate @params) - [PSCustomObject]@{ - Updates = $updates - FirstError = if ($errs) { $errs[0].ToString() } else { $null } - } - } -ArgumentList $CategoryParamName, $CategoryValue - - $completed = Wait-Job $job -Timeout $TimeoutSeconds - if (-not $completed) { - Stop-Job $job -ErrorAction SilentlyContinue - Remove-Job $job -Force -ErrorAction SilentlyContinue - return [PSCustomObject]@{ - Updates = @() - FirstError = "search timed out after $TimeoutSeconds seconds" - } - } - - $jobError = $null - $output = $null - try { - $output = Receive-Job $job -ErrorAction Stop - } catch { - $jobError = $_ - } - Remove-Job $job -Force -ErrorAction SilentlyContinue - - if ($jobError -or -not $output) { - return [PSCustomObject]@{ - Updates = @() - FirstError = if ($jobError) { $jobError.ToString() } else { 'search job returned no output' } - } - } - return $output -} - -function Test-PendingReboot { - <# - .SYNOPSIS - Checks if Windows has a pending reboot from previous operations - .DESCRIPTION - Checks multiple registry locations and system flags to determine - if a reboot is pending from Windows Update, CBS, file rename operations, etc. - #> - $rebootRequired = $false - $reasons = @() - - # Check Windows Update reboot flag - $wuKey = "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\WindowsUpdate\Auto Update\RebootRequired" - if (Test-Path $wuKey) { - $rebootRequired = $true - $reasons += "Windows Update" - } - - # Check Component-Based Servicing - $cbsKey = "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Component Based Servicing\RebootPending" - if (Test-Path $cbsKey) { - $rebootRequired = $true - $reasons += "Component Servicing" - } - - # Check Pending File Rename Operations - $pfroKey = "HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager" - try { - $pfroValue = Get-ItemProperty -Path $pfroKey -Name PendingFileRenameOperations -ErrorAction SilentlyContinue - if ($pfroValue.PendingFileRenameOperations) { - $rebootRequired = $true - $reasons += "File Rename Operations" - } - } catch { } - - # Check if Computer Rename is pending - $compNameKey = "HKLM:\SYSTEM\CurrentControlSet\Control\ComputerName" - try { - $activeName = (Get-ItemProperty "$compNameKey\ActiveComputerName" -ErrorAction SilentlyContinue).ComputerName - $pendingName = (Get-ItemProperty "$compNameKey\ComputerName" -ErrorAction SilentlyContinue).ComputerName - if ($activeName -ne $pendingName) { - $rebootRequired = $true - $reasons += "Computer Rename" - } - } catch { } - - return @{ - RebootRequired = $rebootRequired - Reasons = $reasons - } -} - -function Get-FolderSize { - <# - .SYNOPSIS - Calculates folder size in bytes - .DESCRIPTION - v2.17 (p.2 of the audit): Get-ChildItem wraps every single file in a full - PSObject (ETS properties, formatting metadata) just to read one Length value - - expensive on folders with tens of thousands of small files (npm/pip caches, - the driver store). Walks the tree with the raw .NET enumerator instead. - IgnoreInaccessible mirrors the old -ErrorAction SilentlyContinue tolerance. - AttributesToSkip=ReparsePoint is a deliberate refinement, not just a port: - following junctions/symlinks while summing could double-count the same bytes - (WinSxS-style hardlink dedup) or loop on a cyclic junction - the old - Get-ChildItem -Recurse call had no equivalent guard. - #> - param([string]$Path) - - if (-not (Test-Path -LiteralPath $Path -ErrorAction SilentlyContinue)) { - return 0 - } - - try { - $options = [System.IO.EnumerationOptions]::new() - $options.RecurseSubdirectories = $true - $options.IgnoreInaccessible = $true - $options.AttributesToSkip = [System.IO.FileAttributes]::ReparsePoint - - $total = 0L - foreach ($file in [System.IO.Directory]::EnumerateFiles($Path, '*', $options)) { - try { - $total += [System.IO.FileInfo]::new($file).Length - } catch { } # vanished between enumeration and the Length read - skip it - } - return $total - } catch { - return 0 - } -} - -function Get-FolderSizeChecked { - <# - .SYNOPSIS - Like Get-FolderSize, but distinguishes "empty" from "could not measure" - .DESCRIPTION - v2.17 (p.10 of the audit): Get-FolderSize returns 0 on ANY access error, which - Show-DiskSpaceReport read as "nothing above 100 MB" when the true answer was - "could not check". Returns $null when Get-ChildItem hit access errors while - walking the tree, 0 only when the path is genuinely absent or truly empty. - #> - param([string]$Path) - - # v2.18: Test-Path returns $false for BOTH "absent" and "present but access-denied", - # so an unreadable folder used to report 0 (= "empty") instead of $null (= "could not - # measure"). GetAttributes throws a *NotFound* exception only when the path is truly - # absent; an access error surfaces as a different exception and must yield $null. - try { - $null = [System.IO.File]::GetAttributes($Path) - } catch [System.IO.FileNotFoundException], [System.IO.DirectoryNotFoundException] { - return 0 - } catch { - return $null - } - - $walkErrors = $null - $items = Get-ChildItem -LiteralPath $Path -Recurse -Force -File ` - -ErrorAction SilentlyContinue -ErrorVariable walkErrors - if ($walkErrors) { - return $null - } - - $sum = ($items | Measure-Object -Property Length -Sum).Sum - return [long]($sum ?? 0) -} - -function Format-FileSize { - <# - .SYNOPSIS - Formats bytes to human-readable size - #> - param([long]$Bytes) - - # Invariant culture (v2.17): with "{0:N2}" on ru-RU the group separator is a - # NO-BREAK space, which mixes locales in the log and quietly breaks anything that - # parses our own output (smoke test, stand assertions, ConvertFrom-HumanReadableSize) - $inv = [cultureinfo]::InvariantCulture - if ($Bytes -lt 0) { return "-" + (Format-FileSize (-$Bytes)) } - if ($Bytes -ge 1TB) { return [string]::Format($inv, "{0:N2} TB", $Bytes / 1TB) } - if ($Bytes -ge 1GB) { return [string]::Format($inv, "{0:N2} GB", $Bytes / 1GB) } - if ($Bytes -ge 1MB) { return [string]::Format($inv, "{0:N2} MB", $Bytes / 1MB) } - if ($Bytes -ge 1KB) { return [string]::Format($inv, "{0:N2} KB", $Bytes / 1KB) } - return "$Bytes B" -} - -function ConvertFrom-HumanReadableSize { - <# - .SYNOPSIS - Converts human-readable size string to bytes (inverse of Format-FileSize) - .DESCRIPTION - v2.17 (p.17 of the audit) widened localization handling. Previously failed on: - a space-grouped thousands separator (it sat INSIDE the numeric group, which the - old regex did not allow), "1.234,5" EU-style dot-thousands/comma-decimal (threw - an unhandled exception instead of returning 0), the word form of bytes, and - "MiB"/"GiB"/etc binary-unit spelling (this script's own *B literals are already - 1024-based, so the multiplier is identical to KB/MB/GB/TB). - v2.20: a lone separator is no longer assumed to be the decimal point. The grouping - SHAPE decides first, and only a string that could honestly be read either way is - settled by the culture - so the result for such a string is culture-dependent. - .PARAMETER SizeString - The text to convert, e.g. "2.5 GB", "1,234 KB", "816 КБ". - .PARAMETER Culture - Consulted only for an ambiguous grouping such as "1,234", where en-US means 1234 - and ru-RU means 1.234. Defaults to the current culture, which is what the Shell - used to format the string this function's only caller parses. Shapes that cannot - be a grouping ("1,5", "1,2345") are decimal in every culture. - .EXAMPLE - ConvertFrom-HumanReadableSize "2.5 GB" # Returns 2684354560 - ConvertFrom-HumanReadableSize "512MB" # Returns 536870912 - .EXAMPLE - ConvertFrom-HumanReadableSize "1,234 KB" -Culture ([cultureinfo]'en-US') # 1263616 - ConvertFrom-HumanReadableSize "1,234 KB" -Culture ([cultureinfo]'ru-RU') # 1264 - #> - param( - [string]$SizeString, - # Only consulted for a genuinely ambiguous string (see the disambiguation below). - # Injectable so the rule can be tested without changing the machine's locale. - [cultureinfo]$Culture = [cultureinfo]::CurrentCulture - ) - - if (-not $SizeString) { return 0 } - - # Drop all whitespace outright (including no-break/thin-space variants) - it only - # ever separates thousands groups or sits between the number and the unit, never - # meaningful data. - $normalized = ($SizeString -replace '[\u00A0\u202F\u2007\s]', '') - $normalized = $normalized -ireplace 'байт(а|ов)?$', 'B' -ireplace 'bytes?$', 'B' -replace 'ТБ$', 'TB' -replace 'ГБ$', 'GB' -replace 'МБ$', 'MB' -replace 'КБ$', 'KB' -replace 'Б$', 'B' - $normalized = $normalized -ireplace 'KiB$', 'KB' -ireplace 'MiB$', 'MB' -ireplace 'GiB$', 'GB' -ireplace 'TiB$', 'TB' - - # Handle formats: "2.5GB", "512MB", "100.5MB", "1234.5MB", "1234,5MB" (whitespace - # already stripped above, so no \s* needed between the number and the unit) - if ($normalized -notmatch '^([\d.,]+)([KMGT]?B)$') { - return 0 - } - - $numberPart = $Matches[1] - $unit = $Matches[2].ToUpper() - - # Decimal-separator disambiguation. - # - # When BOTH marks appear the answer is certain: whichever comes LAST is the decimal - # point and the earlier one was grouping ("1.234,5" EU against "1,234.5" US). - # - # A LONE mark is the hard case, and v2.20 is where it was fixed. The old rule was - # "a lone mark is the decimal point", which read the ordinary en-US thousands form - # "1,234 KB" as 1.234 KB - low by a factor of a thousand, on the shell fallback that - # measures the Recycle Bin. - # The obvious repair is worse. Handing the string to [double]::TryParse with the - # current culture looks right and is not: measured on .NET, AllowThousands does NOT - # validate the grouping shape, so en-US reads "1,5" as 15 and "1,2345" as 12345 - - # trading a 1000x under-read for a 10x over-read, and breaking "1,5 GB". - # So the SHAPE is checked here first, and the culture is consulted only for a string - # that could honestly be either reading. - $lastComma = $numberPart.LastIndexOf(',') - $lastDot = $numberPart.LastIndexOf('.') - - if ($lastComma -ge 0 -and $lastDot -ge 0) { - if ($lastComma -gt $lastDot) { - $numberPart = $numberPart.Replace('.', '').Replace(',', '.') - } else { - $numberPart = $numberPart.Replace(',', '') - } - } elseif ($lastComma -ge 0 -or $lastDot -ge 0) { - $sep = if ($lastComma -ge 0) { ',' } else { '.' } - - # A thousands grouping is "1-3 digits, then one or more groups of exactly 3". - # "1,5" and "1,2345" cannot be that, so there the mark is the decimal point and - # no culture can argue otherwise. - if ($numberPart -match "^\d{1,3}($([regex]::Escape($sep))\d{3})+$") { - $isDecimal = $Culture.NumberFormat.NumberDecimalSeparator -eq $sep - if (-not $isDecimal -and $Culture.NumberFormat.NumberGroupSeparator -ne $sep) { - # The culture uses this mark for neither purpose - ru-RU groups with a - # no-break space and would call a lone dot meaningless. Our own - # Format-FileSize writes invariant text, so fall back to reading it that - # way: a dot is the decimal point, a comma is grouping. - $isDecimal = ($sep -eq '.') - } - if ($isDecimal) { - $numberPart = $numberPart.Replace(',', '.') - } else { - $numberPart = $numberPart.Replace($sep, '') - } - } else { - $numberPart = $numberPart.Replace(',', '.') - } - } - - $multiplier = switch ($unit) { - 'B' { 1 } - 'KB' { 1KB } - 'MB' { 1MB } - 'GB' { 1GB } - 'TB' { 1TB } - default { 1 } - } - - try { - return [long]([double]$numberPart * $multiplier) - } catch { - return 0 - } -} - -function Resolve-PathThroughLinks { - <# - .SYNOPSIS - Resolves a path through reparse points at ANY level, not just the last segment - .DESCRIPTION - v2.20, corrected in review. The first version of the link-aware protected-path - check only asked whether the leaf itself was a reparse point. That closed the - obvious case ("C:\cache" is a junction to "C:\") and left the real one open: - "C:\cache\Windows" has no reparse attribute on the leaf, GetFullPath does not - resolve the junction above it, and the textual comparison never matches - measured, - with 120 real C:\Windows children visible through the link. - - So every ancestor is examined, the deepest link found is resolved, and the walk - restarts on the rebuilt path (a resolved target can itself sit under another link). - .OUTPUTS - The fully resolved path, or $null when a link cannot be resolved - the caller must - treat that as "unknown", never as "fine". - #> - param([Parameter(Mandatory)][string]$Path) - - $current = $Path - # Bounded: a link loop would otherwise spin here forever - for ($round = 0; $round -lt 64; $round++) { - # Raised in review: the ancestor walk used to climb past the root of a UNC share. - # Split-Path turns \\server\share into \\server, which is not a filesystem object, - # so Get-Item failed, the fail-closed rule above answered $null, and every UNC - # cleanup root was refused. Nothing above the volume root is ours to inspect. - $rootPath = '' - try { $rootPath = [System.IO.Path]::GetPathRoot($current).TrimEnd('\', '/') } catch { $rootPath = '' } - - $probe = $current - $tail = @() - $changed = $false - - while ($probe) { - # Raised in review: an ancestor that could not be examined used to be silently - # classified as "not a link" and the walk carried on upward, so an unreadable - # junction ancestor answered "safe to empty". That is the half of the guard - # which closes the real attack (C:\cache\Windows where C:\cache is the link), - # and it was the half that failed open. Unknown is not safe. - $item = $null - try { $item = Get-Item -LiteralPath $probe -Force -ErrorAction Stop } catch { return $null } - - if ($item -and ($item.Attributes -band [System.IO.FileAttributes]::ReparsePoint)) { - $target = $null - try { $target = $item.ResolveLinkTarget($true) } catch { $target = $null } - if (-not $target) { return $null } - - $current = if ($tail.Count -gt 0) { - Join-Path $target.FullName ($tail -join [System.IO.Path]::DirectorySeparatorChar) - } else { - $target.FullName - } - $changed = $true - break - } - - $parent = Split-Path $probe -Parent - if (-not $parent -or $parent -eq $probe) { break } - if ($rootPath -and $parent.Length -lt $rootPath.Length) { break } - $tail = ,(Split-Path $probe -Leaf) + $tail - $probe = $parent - } - - # Nothing left to resolve: this is the fully resolved answer - if (-not $changed) { return $current } - } - - # The bound was exhausted while links were still being followed, which means the chain - # could not be resolved. The contract for that is $null. Returning the partially - # resolved path (raised in review) handed Test-PathProtected a value that still - # contained a link and was then judged on its text alone - fail-open in the one - # function this release rewrote to fail closed. - return $null -} - -function Get-RegistryValueCount { - <# - .SYNOPSIS - Counts the real values under a registry key, ignoring PowerShell's own metadata - .DESCRIPTION - v2.20. Privacy cleanup used to announce "cleared" without looking, because - Remove-Item with -ErrorAction SilentlyContinue never throws. Confirming the result - needs a before/after count, and Get-ItemProperty decorates every key with PSPath, - PSParentPath, PSChildName, PSDrive and PSProvider - counting those would make an - emptied key look like it still holds five entries. - - Tri-state on purpose (corrected in review before release): 0 for a key that is - absent or genuinely empty, the count for a readable key, and $null when the key is - there but cannot be read. The first draft returned 0 for unreadable too, which - recreated the very bug it was written to fix: a delete that failed, followed by an - unreadable after-read, would have counted as 0 and been reported as cleared. - .OUTPUTS - [int] the number of values, or $null when the key exists but cannot be read - #> - param([Parameter(Mandatory)][string]$Key) - - try { - $props = Get-ItemProperty -LiteralPath $Key -ErrorAction Stop - } catch [System.Management.Automation.ItemNotFoundException] { - return 0 # not there at all - nothing to clear, and nothing to worry about - } catch { - return $null # there, but unreadable: refuse to answer rather than answer 0 - } - - if (-not $props) { return 0 } - - return @($props.PSObject.Properties | Where-Object { - $_.Name -notin 'PSPath', 'PSParentPath', 'PSChildName', 'PSDrive', 'PSProvider' - }).Count -} - -function Test-PathProtected { - <# - .SYNOPSIS - Checks whether a path must be refused as a bulk-cleanup root (v2.17: normalized, - v2.20: link-aware) - .DESCRIPTION - Guards the roots listed in $script:ProtectedPaths against being emptied. - Paths are resolved with GetFullPath first, otherwise the check is trivially - bypassed by an 8.3 name (C:\PROGRA~1), a "\\?\" prefix, a relative path or - a "C:\Windows\..\Windows" round trip. - - Only the roots themselves are protected, not everything below them: the script - legitimately cleans %SystemRoot%\Temp and other subfolders. Callers that must - never touch a subtree pass explicit paths instead. - - v2.20: the checks above compare TEXT, and GetFullPath does not resolve reparse - points (measured, not assumed). A junction whose visible path looks innocent can - therefore point at a protected root, and enumerating that junction lists the - TARGET's children - deleting them deletes the real files. So an existing link is - resolved to its final target and the same rules are applied to that. - - Only the cleanup ROOT needs this. Links found deeper in the tree are already - harmless: Get-ChildItem -Recurse does not descend into a reparse point, and - Remove-Item on a junction removes the link and leaves the target intact (both - measured on a live filesystem). - .PARAMETER SkipLinkResolution - Internal. Set when re-checking an already-resolved target so a pathological link - chain cannot recurse. - #> - param( - [string]$Path, - [switch]$SkipLinkResolution - ) - - if ([string]::IsNullOrWhiteSpace($Path)) { return $true } # nothing sane to clean - - try { - $fullPath = [System.IO.Path]::GetFullPath($Path) - $normalizedPath = $fullPath.TrimEnd('\', '/') - } catch { - # Unparseable path: refuse rather than guess - return $true - } - - # A volume root is always protected (v2.17). It is not in $ProtectedPaths and would - # otherwise slip through: TEMP set to "C:\" - or an empty variable resolving to a - # root - would hand the entire drive to the cleanup routine, running elevated. - # Note GetFullPath does expand 8.3 names, so "C:\PROGRA~1" is caught by the list below. - try { - $root = [System.IO.Path]::GetPathRoot($fullPath) - if ($root -and ($fullPath.TrimEnd('\', '/') -ieq $root.TrimEnd('\', '/'))) { - return $true - } - } catch { return $true } - - foreach ($protected in $script:ProtectedPaths) { - if ([string]::IsNullOrWhiteSpace($protected)) { continue } - try { - $normalizedProtected = [System.IO.Path]::GetFullPath($protected).TrimEnd('\', '/') - } catch { continue } - - if ($normalizedPath -ieq $normalizedProtected) { - return $true - } - } - - # v2.20: resolve a link root and re-check the real target (see .DESCRIPTION). - # A path that is not there answers $false - there is nothing to delete through it, and - # callers probe optional locations constantly. A path that EXISTS but cannot be - # examined answers $true; the two are decided separately below. - # - # (This comment described the opposite of the code until review caught it: the first - # draft lumped "cannot be inspected" in with "does not exist". A comment asserting a - # safety property the code does not have is how the fail-open bootstrap shipped in - # v2.17, and it ended up copied into SECURITY.md.) - if (-not $SkipLinkResolution) { - # A path that cannot be inspected is not the same as a path that is not there. - # Access denied, an I/O error, a path too long: none of them mean "safe to empty", - # and collapsing them all to "not protected" is fail-open (raised in review). - # Same shape as Get-FolderSizeChecked: not-found is an answer, anything else is not. - try { - $null = Get-Item -LiteralPath $fullPath -Force -ErrorAction Stop - } catch [System.Management.Automation.ItemNotFoundException] { - return $false # nothing there to clean through - } catch [System.IO.DirectoryNotFoundException] { - return $false - } catch [System.IO.FileNotFoundException] { - return $false - } catch [System.Management.Automation.DriveNotFoundException] { - # An unmapped or removed drive is a not-found answer too (raised in review). - # Without this it fell to the refuse arm below, and every cleanup target on a - # removable drive became a "Protected path skipped" WARNING - noise in exactly - # the channel this release uses as its silent-failure alarm. - return $false - } catch { - return $true # exists in some form but cannot be examined - refuse - } - - $resolved = Resolve-PathThroughLinks -Path $fullPath - if (-not $resolved) { - # A link that cannot be resolved: the real target is unknown, so protection - # cannot be verified. Refuse rather than guess. - return $true - } - if ($resolved -ne $fullPath) { - return (Test-PathProtected -Path $resolved -SkipLinkResolution) - } - } - - return $false -} - -function Remove-FolderContent { - <# - .SYNOPSIS - Safely removes folder contents with size tracking - .DESCRIPTION - v2.17 (p.1 of the audit, the single largest performance item in the script): - this used to walk $Path in full three to four times - Get-FolderSize before, - the age filter's own recursive check, the delete, Get-FolderSize after - called - ~35 times per run, including against multi-gigabyte TEMP and - SoftwareDistribution. Now one enumeration pass decides eligibility AND measures - size at the same time (a directory's age check and its size come from the same - recursive Get-ChildItem instead of two separate walks), and after deletion each - candidate is checked individually - Test-Path for "fully gone", a single - Get-FolderSize scoped to just that candidate for "partially gone" (some locked - file inside) - instead of re-walking the whole of $Path a second time. - - -RemoveFolder was removed: it had no caller left (dead since at least v2.16) and - kept a second, untested code path alive through this rewrite for nothing. - #> - param( - [Parameter(Mandatory)] - [string]$Path, - - [Parameter(Mandatory)] - [string]$Category, - - [string]$Description, - - [string[]]$ExcludeFile = @(), - - # v2.16: skip entries younger than N days. Used for TEMP, where deleting - # files of currently running installers/applications breaks them mid-work. - [int]$MinAgeDays = 0 - ) - - # Safety check - if (Test-PathProtected -Path $Path) { - Write-Log "Protected path skipped: $Path" -Level WARNING - return - } - - if (-not (Test-Path -LiteralPath $Path -ErrorAction SilentlyContinue)) { - return - } - - $cutoff = if ($MinAgeDays -gt 0) { (Get-Date).AddDays(-$MinAgeDays) } else { $null } - - # Single top-level enumeration. Eligibility and size are decided together, and used - # by both the report and the real run so "would clean" never promises more than the - # run actually deletes. - $candidates = @() - foreach ($item in (Get-ChildItem -LiteralPath $Path -Force -ErrorAction SilentlyContinue)) { - # Skip excluded files and any directory that contains one. Deliberately - # conservative: a directory holding an excluded file is kept whole rather than - # partially cleaned (safe > thorough here) - $isExcluded = [bool]($ExcludeFile | Where-Object { - $_ -and (($item.FullName -ieq $_) -or - $_.StartsWith($item.FullName + '\', [System.StringComparison]::OrdinalIgnoreCase)) - }) - if ($isExcluded) { continue } - - # v2.18: carry whether Size is a real measurement. Only the no-cutoff directory - # branch below can produce a genuinely unmeasurable size ($null from - # Get-FolderSizeChecked); every other path is measured, an empty dir included. - $measured = $true - - if ($item.PSIsContainer) { - if ($cutoff) { - # BOTH halves are required, and each covers a case the other misses: - # - the directory's own timestamp: an EMPTY fresh directory has no - # descendants to prove anything (a running installer's scratch - # folder looks exactly like this), and a directory written to just - # now can still hold nothing but old files; - # - the recursive walk: a folder's own LastWriteTime does not move - # when a GRANDchild changes, so a fresh file nested deeper would be - # deleted along with its old-looking parent. - # Fail closed throughout: if the subtree cannot be fully read (ACL, - # path length, locked folder), staleness cannot be proven, so the - # directory is kept. - if ($item.LastWriteTime -ge $cutoff) { continue } - - $walkErrors = $null - $children = Get-ChildItem -LiteralPath $item.FullName -Recurse -Force ` - -ErrorAction SilentlyContinue -ErrorVariable walkErrors - if ($walkErrors) { continue } - if ($children | Where-Object { $_.LastWriteTime -ge $cutoff } | Select-Object -First 1) { continue } - # Same walk also gives the size - no second pass needed for it - $size = ($children | Where-Object { -not $_.PSIsContainer } | - Measure-Object -Property Length -Sum).Sum - } else { - # Checked variant: plain Get-FolderSize returns 0 both for "empty" and - # for "could not read", and that 0 would later be reported as freed - # bytes. $null here means "unmeasured" and is now genuinely carried as - # such (Measured=$false) instead of being flattened to a silent 0. - $size = Get-FolderSizeChecked -Path $item.FullName - $measured = ($null -ne $size) - } - } else { - if ($cutoff -and $item.LastWriteTime -ge $cutoff) { continue } - $size = $item.Length - } - - $candidates += [pscustomobject]@{ Item = $item; Size = [long]($size ?? 0); Measured = $measured } - } - - $totalSize = [long](($candidates | Measure-Object -Property Size -Sum).Sum ?? 0) - - if ($ReportOnly) { - if ($totalSize -gt 0 -and $Description) { - Write-Log "Would clean: $Description - $(Format-FileSize $totalSize)" -Level DETAIL - } - # v2.18: an unmeasurable candidate contributes 0 to $totalSize, so a set that is - # entirely unmeasurable would otherwise report nothing at all. Name it instead of - # staying silent (the estimate genuinely excludes these). - $unmeasuredCount = @($candidates | Where-Object { -not $_.Measured }).Count - if ($unmeasuredCount -gt 0 -and $Description) { - Write-Log "$Description - $unmeasuredCount item(s) present but not measurable (excluded from the estimate)" -Level DETAIL - } - return - } - - if ($candidates.Count -eq 0) { - return - } - - try { - $freed = 0 - $unmeasuredRemoved = 0 - foreach ($c in $candidates) { - $item = $c.Item - try { - # Handle read-only files - if ($item.Attributes -band [System.IO.FileAttributes]::ReadOnly) { - $item.Attributes = $item.Attributes -band (-bnot [System.IO.FileAttributes]::ReadOnly) - } - Remove-Item -LiteralPath $item.FullName -Recurse -Force -ErrorAction SilentlyContinue - } catch { } - - if (-not (Test-Path -LiteralPath $item.FullName -ErrorAction SilentlyContinue)) { - # Fully gone. Credit the pre-deletion size only if it was a real - # measurement; an unmeasured directory (v2.18) is removed but its freed - # bytes are unknown, so it is counted apart rather than booked as 0. - if ($c.Measured) { $freed += $c.Size } else { $unmeasuredRemoved++ } - } elseif ($item.PSIsContainer) { - # Partially deleted (some locked file inside) - re-measure only this one - # subtree, not the whole of $Path. Get-FolderSizeChecked, not - # Get-FolderSize: the latter reports 0 both for "empty" and for "could - # not read", and reading that 0 as "nothing left" would credit the whole - # directory as freed while its files are still sitting on disk. - $remaining = Get-FolderSizeChecked -Path $item.FullName - if ($null -ne $remaining) { - $freed += [math]::Max(0, $c.Size - $remaining) - } - # $null: the remainder is unknown, so claim nothing rather than overstate - # v2.18: a directory that was unmeasurable to begin with and only partially - # deleted freed an unknown amount too - count it so it is not silently lost. - if (-not $c.Measured) { $unmeasuredRemoved++ } - } - # A file that still exists (locked) contributes 0 - correctly nothing freed - } - - if ($unmeasuredRemoved -gt 0) { - # Honest about the gap instead of silently understating: these directories - # were deleted but their size could not be measured beforehand. - Write-Log "Removed $unmeasuredRemoved item(s) whose size could not be measured; freed space is underreported for them" -Level DETAIL - } - - if ($freed -gt 0) { - # Update statistics (synchronized hashtable handles thread-safety) - $script:Stats.TotalFreedBytes += $freed - - # Update category (not thread-safe, but acceptable for reporting) - if (-not $script:Stats.FreedByCategory.ContainsKey($Category)) { - $script:Stats.FreedByCategory[$Category] = 0 - } - $script:Stats.FreedByCategory[$Category] += $freed - - if ($Description) { - Write-Log "$Description - $(Format-FileSize $freed)" -Level SUCCESS - } - } elseif ($totalSize -gt 0 -and $Description) { - # v2.16: silence here is indistinguishable from "there was nothing to do". - # Say it out loud - this is exactly how the Controlled Folder Access bug hid: - # deletions were blocked without an error and the log simply stayed quiet. - # Compare explicitly against 'enabled': the field is tri-state, and the - # string 'unknown' is truthy in PowerShell - a plain truthiness test would - # confidently blame Controlled Folder Access for a state never checked - $reason = switch ($script:Stats.ControlledFolderAccess) { - 'enabled' { ' (Controlled Folder Access is enabled and may be blocking it)' } - 'unknown' { ' (files are probably locked, or Controlled Folder Access is blocking it - the check itself failed)' } - default { ' (files are probably locked by a running process)' } - } - Write-Log "$Description - nothing freed, $(Format-FileSize $totalSize) still present$reason" -Level WARNING - $script:Stats.WarningsCount++ - } - } catch { - Write-Log "Error cleaning $Path`: $_" -Level WARNING - $script:Stats.WarningsCount++ - } -} - -function Remove-FilesByPattern { - <# - .SYNOPSIS - Removes files matching a pattern with size tracking - .DESCRIPTION - Handles file patterns (like *.roslynobjectin) that Remove-FolderContent can't handle. - - v2.17 (p.18 of the audit): this was the one delete path in the whole script with - no protected-path check and no age filter - safe today because the only caller - passes a single fixed pattern under %APPDATA%, but that made it a latent risk for - the next caller. Mirrors Remove-FolderContent's guards for consistency. - #> - param( - [Parameter(Mandatory)] - [string]$Pattern, - - [Parameter(Mandatory)] - [string]$Category, - - [string]$Description, - - [int]$MinAgeDays = 0 - ) - - $files = Get-Item -Path $Pattern -ErrorAction SilentlyContinue | Where-Object { -not $_.PSIsContainer } - - if (-not $files) { - return - } - - # Skip anything whose containing folder is itself a protected root, and anything - # younger than the age cutoff (matches Remove-FolderContent's -MinAgeDays intent: - # a file belonging to something still running should not be deleted mid-use) - $cutoff = (Get-Date).AddDays(-$MinAgeDays) - $files = $files | Where-Object { - (-not (Test-PathProtected -Path $_.DirectoryName)) -and - ($MinAgeDays -le 0 -or $_.LastWriteTime -lt $cutoff) - } - - if (-not $files) { - return - } - - $totalSize = ($files | Measure-Object -Property Length -Sum).Sum - $totalSize = [long]($totalSize ?? 0) - - if ($ReportOnly) { - if ($totalSize -gt 0 -and $Description) { - Write-Log "Would clean: $Description - $(Format-FileSize $totalSize)" -Level DETAIL - } - return - } - - $freedSize = 0 - foreach ($file in $files) { - try { - $fileSize = $file.Length - Remove-Item -LiteralPath $file.FullName -Force -ErrorAction SilentlyContinue - if (-not (Test-Path -LiteralPath $file.FullName)) { - $freedSize += $fileSize - } - } catch { } - } - - if ($freedSize -gt 0) { - $script:Stats.TotalFreedBytes += $freedSize - - if (-not $script:Stats.FreedByCategory.ContainsKey($Category)) { - $script:Stats.FreedByCategory[$Category] = 0 - } - $script:Stats.FreedByCategory[$Category] += $freedSize - - if ($Description) { - Write-Log "$Description - $(Format-FileSize $freedSize)" -Level SUCCESS - } - } -} - -function New-SystemRestorePoint { - <# - .SYNOPSIS - Creates system restore point using Windows PowerShell (for compatibility) - #> - param([string]$Description = "WinClean Maintenance") - - if ($SkipRestore) { - Write-Log "Restore point creation skipped (parameter)" -Level INFO - return $true - } - - if ($ReportOnly) { - Write-Log "Would create restore point: $Description" -Level INFO - return $true - } - - Write-Log "Creating system restore point..." -Level INFO - - try { - # Checkpoint-Computer doesn't work in PowerShell 7, use Windows PowerShell - # Note (v2.14): Windows silently skips restore point creation if one was made in - # the last 24h (SystemRestorePointCreationFrequency default = 1440 minutes). - # For a maintenance script that can run daily this means points were almost never - # created - temporarily lift the limit for this call only, then restore it. - $scriptBlock = @" - try { - Enable-ComputerRestore -Drive "$env:SystemDrive" -ErrorAction SilentlyContinue - - `$srKey = 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\SystemRestore' - `$prevFreq = (Get-ItemProperty -Path `$srKey -Name SystemRestorePointCreationFrequency -ErrorAction SilentlyContinue).SystemRestorePointCreationFrequency - Set-ItemProperty -Path `$srKey -Name SystemRestorePointCreationFrequency -Value 0 -Type DWord -Force - try { - Checkpoint-Computer -Description "$Description" -RestorePointType MODIFY_SETTINGS -ErrorAction Stop - } finally { - if (`$null -ne `$prevFreq) { - Set-ItemProperty -Path `$srKey -Name SystemRestorePointCreationFrequency -Value `$prevFreq -Type DWord -Force - } else { - Remove-ItemProperty -Path `$srKey -Name SystemRestorePointCreationFrequency -ErrorAction SilentlyContinue - } - } - Write-Output "SUCCESS" - } catch { - Write-Output "ERROR: `$_" - } -"@ - - # Use Windows PowerShell 5.1 (Checkpoint-Computer not available in PS7). - # v2.17 (p.14 of the audit): this was the one external call in the whole script - # with no timeout at all, and VSS is known to hang for minutes. - # - # v2.17 (regression caught on the stand): the timeout rewrite first passed the - # script via Start-Process -ArgumentList @(..., '-Command', $scriptBlock). - # Start-Process concatenates ArgumentList with spaces and does NOT re-quote its - # elements, so a $Description containing spaces (it always does - "WinClean - # 2026-07-20 19:00") was split into positional arguments and Checkpoint-Computer - # failed every time. -EncodedCommand (base64 of the UTF-16LE script) sidesteps - # command-line quoting entirely. Invisible to the test suite because a real - # Checkpoint-Computer only runs on a live machine, not in the sandbox. - $encodedCommand = [Convert]::ToBase64String([System.Text.Encoding]::Unicode.GetBytes($scriptBlock)) - - $outFile = [System.IO.Path]::GetTempFileName() - $errFile = [System.IO.Path]::GetTempFileName() - - # v2.17 (p.13 of the audit): a hard kill of this process (or of the child, e.g. - # via "End process tree") skips the child's own finally above, leaving - # SystemRestorePointCreationFrequency at 0 forever. Read the current value from - # out here so the marker can restore the RIGHT value on the next run instead of - # just assuming the shipped default. - $srKeyOuter = 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\SystemRestore' - $prevFreqOuter = (Get-ItemProperty -Path $srKeyOuter -Name SystemRestorePointCreationFrequency -ErrorAction SilentlyContinue).SystemRestorePointCreationFrequency - Set-RunMarker -Phase 'RestorePointFrequencyOverride' -Data @{ PreviousValue = $prevFreqOuter } - - $childKilled = $false - $childExited = $true # only meaningful once a kill has been attempted - try { - $proc = Start-Process -FilePath 'powershell.exe' ` - -ArgumentList @('-NoProfile', '-NoLogo', '-ExecutionPolicy', 'Bypass', '-EncodedCommand', $encodedCommand) ` - -NoNewWindow -PassThru -RedirectStandardOutput $outFile -RedirectStandardError $errFile - - $timeoutMs = 120000 # 2 minutes - a restore point should never legitimately take this long - if (-not $proc.WaitForExit($timeoutMs)) { - $proc.Kill($true) - $childKilled = $true - # v2.20, corrected in review: Kill returns once termination has been - # REQUESTED, not once the tree is gone. Without this wait the finally - # below could read the creation frequency while the dying child was still - # writing 0 into it, see a non-zero value, conclude there was nothing to - # repair and delete the marker - leaving the frequency pinned at 0 with no - # record of it, which is exactly the damage this mechanism exists for. - $childExited = $proc.WaitForExit(5000) - throw "restore point creation timed out after $($timeoutMs / 1000) seconds" - } - - $result = (Get-Content -LiteralPath $outFile -Raw -ErrorAction SilentlyContinue) - } finally { - Remove-Item $outFile, $errFile -Force -ErrorAction SilentlyContinue - - # A killed child never ran its own finally, so the registry override it set - # is still in place - repair it here and now. Clearing the marker - # unconditionally would throw away the record of exactly the damage this - # mechanism exists for, so it survives whenever the repair did not. - # - # v2.20: this repair now runs on BOTH paths. A child that exits normally can - # still have failed its own finally (a transient registry error), and the - # parent then deleted the marker anyway - leaving the creation frequency - # pinned at 0 indefinitely with nothing left to make a later run retry. - # Restore-RestorePointFrequency is idempotent by design: it returns $true - # without touching anything when the value is no longer 0, so verifying on the - # normal path costs nothing and turns an assumption into a check. - if (Restore-RestorePointFrequency -PreviousValue $prevFreqOuter) { - if ($childKilled -and -not $childExited) { - # Raised in review: the wait above has a bound, and a child that - # outlives it can still write the override AFTER this check passed. - # Keeping the marker costs one repair attempt on the next run; - # clearing it here would lose the only record that anything happened. - Write-Log "Restore point child was killed but had not exited 5 seconds later - the marker is kept, because it can still re-apply the override after this check" -Level WARNING - $script:Stats.WarningsCount++ - } else { - Clear-RunMarker - } - } else { - $how = if ($childKilled) { 'was killed' } else { 'exited normally' } - Write-Log "Restore point child $how but its registry override could not be undone - the marker is kept so the next run retries" -Level WARNING - $script:Stats.WarningsCount++ - } - } - - if ($result -like "SUCCESS*") { - Write-Log "Restore point created: $Description" -Level SUCCESS - return $true - } else { - throw $result - } - } catch { - Write-Log "Failed to create restore point: $_" -Level WARNING - $script:Stats.WarningsCount++ - return $false - } -} - -#endregion - -#region ═══════════════════════════════════════════════════════════════════════ -# UPDATE FUNCTIONS -#═══════════════════════════════════════════════════════════════════════════════ - -function Update-WindowsSystem { - <# - .SYNOPSIS - Updates Windows including optional driver updates - #> - Write-Log "WINDOWS UPDATE" -Level TITLE - Update-Progress -Activity "Windows Update" -Status "Checking for updates..." - - if ($SkipUpdates) { - Write-Log "Windows Update skipped (parameter)" -Level INFO - return - } - - # Early exit for ReportOnly - don't install modules or modify system - if ($ReportOnly) { - Write-Log "Would check and install: Windows Updates and Drivers" -Level DETAIL - return - } - - if (-not (Test-InternetConnection)) { - # v2.21: a warning, not an error, for the same reason a missing winget is one - the - # exit code is computed from ErrorsCount alone, so an offline machine ended every - # run with code 1 no matter how completely the cleanup succeeded, and a laptop that - # runs maintenance away from the network reported failure forever. Having no - # connectivity is a state of the environment, not a failure of this run. It stays - # visible: the warning is logged and counted, and the result JSON carries - # AppUpdatesStatus = 'skipped-offline' for the whole Updates phase. - Write-Log "No internet connection - skipping Windows Update" -Level WARNING - $script:Stats.WarningsCount++ - return - } - - # Check Windows Update service - $wuService = Get-Service -Name wuauserv -ErrorAction SilentlyContinue - if (-not $wuService) { - Write-Log "Windows Update service not found!" -Level ERROR - $script:Stats.ErrorsCount++ - return - } - - if ($wuService.Status -ne 'Running') { - Write-Log "Starting Windows Update service..." -Level INFO - try { - Start-Service wuauserv -ErrorAction Stop - } catch { - Write-Log "Failed to start Windows Update service: $_" -Level ERROR - $script:Stats.ErrorsCount++ - return - } - } - - try { - # Install PSWindowsUpdate if needed - if (-not (Get-Module -ListAvailable -Name PSWindowsUpdate)) { - # Clear any lingering progress bar before module installation - Write-Progress -Activity "Windows Update" -Completed -ErrorAction SilentlyContinue - - # Check PowerShell Gallery availability first - Write-Log "Checking PowerShell Gallery availability..." -Level INFO - if (-not (Test-PSGalleryConnection)) { - Write-Log "PowerShell Gallery is unavailable" -Level ERROR - Write-Log "Please check your internet connection or install PSWindowsUpdate manually:" -Level INFO - Write-Log " Install-Module PSWindowsUpdate -Force -Scope CurrentUser" -Level INFO - $script:Stats.ErrorsCount++ - return - } - - Write-Log "Installing PSWindowsUpdate module..." -Level INFO - - # Ensure NuGet provider with timeout - $nuget = Get-PackageProvider -Name NuGet -ErrorAction SilentlyContinue - if (-not $nuget -or $nuget.Version -lt [version]"2.8.5.201") { - Write-Log "Installing NuGet provider..." -Level INFO - if (-not (Install-PackageProviderWithTimeout -ProviderName "NuGet" -TimeoutSeconds 60)) { - Write-Log "Failed to install NuGet provider - Windows Update skipped" -Level ERROR - Write-Log "Try manual installation: Install-PackageProvider -Name NuGet -Force" -Level INFO - $script:Stats.ErrorsCount++ - return - } - Write-Log "NuGet provider installed" -Level SUCCESS - } - - # Install PSWindowsUpdate module with timeout - if (-not (Install-ModuleWithTimeout -ModuleName "PSWindowsUpdate" -TimeoutSeconds 120)) { - Write-Log "Failed to install PSWindowsUpdate - Windows Update skipped" -Level ERROR - Write-Log "Try manual installation: Install-Module PSWindowsUpdate -Force -Scope CurrentUser" -Level INFO - $script:Stats.ErrorsCount++ - return - } - Write-Log "PSWindowsUpdate installed" -Level SUCCESS - } - - Import-Module PSWindowsUpdate -ErrorAction Stop - # v2.17: with two copies installed (CurrentUser + AllUsers) .Version returns an - # ARRAY, and every later comparison silently degrades into an array filter - $moduleVersion = (Get-Module PSWindowsUpdate | Sort-Object Version -Descending | - Select-Object -First 1).Version - Write-Log "PSWindowsUpdate v$moduleVersion loaded" -Level INFO - - # Register Microsoft Update service - $muService = Get-WUServiceManager -ErrorAction SilentlyContinue | Where-Object { $_.Name -eq "Microsoft Update" } - if (-not $muService) { - Write-Log "Registering Microsoft Update service..." -Level INFO - Add-WUServiceManager -MicrosoftUpdate -Confirm:$false -ErrorAction SilentlyContinue | Out-Null - } - - # Search for updates - Write-Log "Searching for updates..." -Level INFO - - Write-Log "System Updates" -Level SECTION - $sysResult = Get-WindowsUpdateWithTimeout -CategoryParamName NotCategory -CategoryValue "Drivers" - $systemUpdates = @($sysResult.Updates) - - Write-Log "Driver Updates" -Level SECTION - $drvResult = Get-WindowsUpdateWithTimeout -CategoryParamName Category -CategoryValue "Drivers" - $driverUpdates = @($drvResult.Updates) - - $totalUpdates = $systemUpdates.Count + $driverUpdates.Count - $wuSearchErrors = @($sysResult.FirstError, $drvResult.FirstError) | Where-Object { $_ } - - # v2.17: report search errors regardless of how many updates were found. The - # check used to live inside the "zero updates" branch, so a failed system search - # paired with a successful driver search was reported as a clean run. - if ($wuSearchErrors) { - Write-Log "Update search completed with errors: $($wuSearchErrors[0])" -Level WARNING - Write-Log "Some updates may not have been discovered" -Level DETAIL - $script:Stats.WarningsCount++ - } - - if ($totalUpdates -eq 0) { - # Distinguish "no updates" from "search failed" (v2.14) - previously a - # failed search was reported as "Windows is up to date" - if (-not $wuSearchErrors) { - Write-Log "Windows is up to date" -Level SUCCESS - } - return - } - - Write-Log "Found $($systemUpdates.Count) system updates, $($driverUpdates.Count) driver updates" -Level INFO - - # Display updates - if ($systemUpdates.Count -gt 0) { - Write-Host "" - Write-Host " System Updates:" -ForegroundColor Cyan - foreach ($update in $systemUpdates) { - $size = if ($update.Size) { " ($(Format-FileSize $update.Size))" } else { "" } - Write-Host " - " -NoNewline -ForegroundColor DarkGray - Write-Host "$($update.KB)" -NoNewline -ForegroundColor Yellow - Write-Host " $($update.Title)$size" -ForegroundColor Gray - } - } - - if ($driverUpdates.Count -gt 0) { - Write-Host "" - Write-Host " Driver Updates:" -ForegroundColor Cyan - foreach ($update in $driverUpdates) { - Write-Host " - " -NoNewline -ForegroundColor DarkGray - Write-Host "$($update.Title)" -ForegroundColor Gray - } - } - - Write-Host "" - - # Install updates - Write-Log "Installing updates..." -Level INFO - - $installParams = @{ - MicrosoftUpdate = $true - AcceptAll = $true - IgnoreReboot = $true - ErrorAction = 'SilentlyContinue' - } - - # Ask the cmdlet what it supports instead of guessing from a version number. - # v2.17: the old check compared against 2.3.0, a version PSWindowsUpdate never - # shipped, so the branch was dead - and would have misfired on an array anyway. - $installCmd = Get-Command Install-WindowsUpdate -ErrorAction SilentlyContinue - if ($installCmd -and -not $installCmd.Parameters.ContainsKey('IgnoreReboot')) { - $installParams.Remove('IgnoreReboot') - if ($installCmd.Parameters.ContainsKey('AutoReboot')) { - $installParams['AutoReboot'] = $false - } - } - - # v2.17 (p.15 of the audit, partial): the two searches above got a job-based - # timeout - they are read-only, so killing the job on timeout is free. Install- - # WindowsUpdate is not wrapped the same way: it actually applies updates, and - # force-killing the job would not necessarily cancel the in-flight WU agent - # call, leaving system state that a stand run cannot verify without a live - # reproduction. Deferred - see MyAI-dtx8. - $results = Install-WindowsUpdate @installParams - - # Handle null/empty results (possible silent error) - if (-not $results) { - Write-Log "Windows Update returned no results (possible error)" -Level WARNING - $script:Stats.WarningsCount++ - return - } - - # Count installed updates. - # v2.16: 'Downloaded' means fetched but NOT applied, so counting it as installed - # produced "All N updates installed successfully" for updates still pending. - $installed = @($results | Where-Object { $_.Result -eq 'Installed' }).Count - $downloaded = @($results | Where-Object { $_.Result -eq 'Downloaded' }).Count - $failed = @($results | Where-Object { $_.Result -eq 'Failed' }).Count - - $script:Stats.WindowsUpdatesCount = $installed - - if ($failed -gt 0) { - Write-Log "Installed: $installed, Failed: $failed" -Level WARNING - $script:Stats.WarningsCount += $failed - } elseif ($installed -gt 0) { - Write-Log "All $installed updates installed successfully" -Level SUCCESS - } - - if ($downloaded -gt 0) { - Write-Log "$downloaded update(s) downloaded but not yet applied - a reboot is needed" -Level DETAIL - $script:Stats.RebootRequired = $true - } - - # Check reboot status - if (Get-WURebootStatus -Silent -ErrorAction SilentlyContinue) { - $script:Stats.RebootRequired = $true - Write-Log "Reboot required to complete updates" -Level WARNING - } - - } catch { - Write-Log "Windows Update error: $_" -Level ERROR - $script:Stats.ErrorsCount++ - } -} - -function Update-Applications { - <# - .SYNOPSIS - Updates applications via winget - #> - Write-Log "APPLICATION UPDATES (WINGET)" -Level TITLE - Update-Progress -Activity "Application Updates" -Status "Checking winget..." - - if ($SkipUpdates) { - Write-Log "Application updates skipped (parameter)" -Level INFO - $script:Stats.AppUpdatesStatus = 'skipped-parameter' - return - } - - if (-not (Test-InternetConnection)) { - # v2.21: warning, matching Update-WindowsSystem above - see the reasoning there. - # Both halves read the same memoised connectivity check, so this status describes - # the whole Updates phase, not only the winget half. - Write-Log "No internet connection - skipping app updates" -Level WARNING - $script:Stats.AppUpdatesStatus = 'skipped-offline' - $script:Stats.WarningsCount++ - return - } - - # Find winget - $wingetPath = $null - - $wingetCmd = Get-Command winget.exe -ErrorAction SilentlyContinue - if ($wingetCmd) { - $wingetPath = $wingetCmd.Source - } - - if (-not $wingetPath) { - $standardPath = "$env:LOCALAPPDATA\Microsoft\WindowsApps\winget.exe" - if (Test-Path $standardPath) { - $wingetPath = $standardPath - } - } - - if (-not $wingetPath) { - # v2.21: a warning, not an error. The absence of an optional third-party tool is a - # property of the machine, not a failure of this run - by the same rule that makes - # a machine without Docker or Visual Studio a normal machine here. - # It mattered because the exit code is computed from ErrorsCount alone: every run - # on a machine without App Installer ended with code 1 while all nine phases - # completed, so any scheduler, CI job or test harness reading that code saw a - # failed run forever. A winget that IS present and then fails is still reported; - # its severity depends on whether the run can carry on - the upgrade check failing - # outright and an unhandled exception are errors, while a stale source, a timeout - # or a partly failed batch are warnings. - Write-Log "Winget not found - skipping application updates (install App Installer from Microsoft Store to enable them)" -Level WARNING - $script:Stats.AppUpdatesStatus = 'skipped-no-winget' - $script:Stats.WarningsCount++ - return - } - - # From here winget exists and is about to be asked, but the status is only raised to - # 'checked' once the check actually returns a list (raised in review): setting it here - # meant a timed-out or failing check still reported 'checked' with AppUpdatesOffered = 0 - # - precisely the ambiguity this field was added to remove. - $script:Stats.AppUpdatesStatus = 'check-failed' - - try { - # Update sources only if not in ReportOnly mode (source update modifies state) - if (-not $ReportOnly) { - Write-Log "Updating winget sources..." -Level INFO - # Run with timeout to prevent hanging - # v2.20: the job's own exit code is returned as well. Only completion was - # checked before, and a completed job is not a successful winget: a corrupt - # source fails, the job still reaches Completed, and the upgrade list below - # was then built from stale data without a word in the log. - $job = Start-Job -ScriptBlock { - param($path) - & $path source update 2>&1 | Out-String - $LASTEXITCODE - } -ArgumentList $wingetPath - $completed = $job | Wait-Job -Timeout 120 # 2 minutes timeout - if (-not $completed) { - $job | Stop-Job - Write-Log "Winget source update timed out - package list may be stale" -Level WARNING - $script:Stats.WarningsCount++ - } else { - # v2.20, corrected in review: "no usable result" is a failure in its own - # right. When the winget entry is a WindowsApps AppExecLink stub - passes - # Test-Path, but will not start once App Installer is deregistered - the - # job still reaches Completed, Receive-Job swallows the error and - # $LASTEXITCODE is never set, so the last output element is $null. The old - # guard short-circuited on exactly that and said nothing, which made an - # unusable winget the one silent path left in this block. Measured by the - # reviewer: one output element, and it was $null. - # [int] on a non-numeric last line would also have thrown; TryParse cannot. - $jobState = $job.State - $jobOutput = @($job | Receive-Job -ErrorAction SilentlyContinue) - $sourceExit = if ($jobOutput.Count -gt 0) { $jobOutput[-1] } else { $null } - $exitValue = 0 - $exitKnown = $null -ne $sourceExit -and [int]::TryParse([string]$sourceExit, [ref]$exitValue) - - if ($jobState -ne 'Completed' -or -not $exitKnown) { - Write-Log "Winget source update produced no usable exit code (job state: $jobState) - package list may be stale" -Level WARNING - $script:Stats.WarningsCount++ - } elseif ($exitValue -ne 0) { - Write-Log "Winget source update failed (exit code $exitValue) - package list may be stale" -Level WARNING - $script:Stats.WarningsCount++ - } - } - $job | Remove-Job -Force -ErrorAction SilentlyContinue - } - - # Get available updates (use --include-unknown to match actual upgrade behavior) - Write-Log "Checking for app updates..." -Level INFO - - $tempFile = [System.IO.Path]::GetTempFileName() - $tempErrorFile = [System.IO.Path]::GetTempFileName() - $process = Start-Process -FilePath $wingetPath ` - -ArgumentList "upgrade", "--include-unknown", "--accept-source-agreements", "--disable-interactivity" ` - -NoNewWindow -RedirectStandardOutput $tempFile -RedirectStandardError $tempErrorFile -PassThru - - # Wait with timeout (5 minutes for check operation) - $timeoutMs = 300000 - if (-not $process.WaitForExit($timeoutMs)) { - $process.Kill($true) - Write-Log "Winget upgrade check timed out after 5 minutes" -Level WARNING - $script:Stats.WarningsCount++ - Remove-Item $tempFile, $tempErrorFile -Force -ErrorAction SilentlyContinue - return - } - - $output = Get-Content $tempFile -Raw -Encoding UTF8 -ErrorAction SilentlyContinue - $errorOutput = Get-Content $tempErrorFile -Raw -Encoding UTF8 -ErrorAction SilentlyContinue - Remove-Item $tempFile -Force -ErrorAction SilentlyContinue - Remove-Item $tempErrorFile -Force -ErrorAction SilentlyContinue - - # Check if winget command failed (any non-zero exit code is an error) - if ($process.ExitCode -ne 0) { - Write-Log "Winget upgrade check failed (exit code: $($process.ExitCode))" -Level ERROR - if ($errorOutput) { - Write-Log "Error: $errorOutput" -Level ERROR - } - $script:Stats.ErrorsCount++ - return - } - - # The check returned a usable list, so its count now means something - $script:Stats.AppUpdatesStatus = 'checked' - - # Parse output for update count (language-independent approach) - # Uses table separator "---" as marker, then counts all data lines - $updateCount = 0 - $lines = $output -split "`n" - $foundSeparator = $false - - foreach ($line in $lines) { - # Look for table separator line (works in any language) - if ($line -match "^-{10,}") { - $foundSeparator = $true - continue - } - - # Count lines after separator that look like package entries - # Must have multiple columns (name, id, version, available, source) - if ($foundSeparator) { - $trimmed = $line.Trim() - # First table ends at the first blank line - stop there to avoid counting - # the second "require explicit targeting" table (not covered by --all) - if (-not $trimmed) { break } - # Skip footer text ("X upgrades available") and lines with too few columns - if ($trimmed -notmatch "^\d+\s+(upgrade|обновлен)" -and - $trimmed -notmatch "^(No |Нет )" -and - ($trimmed -split '\s{2,}').Count -ge 3) { - $updateCount++ - } - } - } - - # v2.19: record what winget offered as soon as we know it - in every path, - # including ReportOnly and a later failed upgrade. This is the honest figure; - # the actual installed count is not knowable from `winget upgrade --all`. - $script:Stats.AppUpdatesOffered = $updateCount - - if ($updateCount -eq 0) { - Write-Log "All applications are up to date" -Level SUCCESS - return - } - - Write-Log "Available Updates" -Level SECTION - Write-Host $output - - if ($ReportOnly) { - Write-Log "Report mode - $updateCount updates available but not installed" -Level INFO - return - } - - Write-Log "Installing $updateCount application updates..." -Level INFO - Write-Log "This may take several minutes..." -Level INFO - - # Run upgrade (--include-unknown matches the check above) - $upgradeArgs = @( - "upgrade", "--all", - "--accept-source-agreements", - "--accept-package-agreements", - "--disable-interactivity", - "--include-unknown" - ) - - $upgradeProcess = Start-Process -FilePath $wingetPath -ArgumentList $upgradeArgs ` - -NoNewWindow -PassThru - - # Wait with timeout (20 minutes for upgrade operation - can take long with many updates) - $timeoutMs = 1200000 - if (-not $upgradeProcess.WaitForExit($timeoutMs)) { - $upgradeProcess.Kill($true) # $true: kill spawned installers too (v2.17) - Write-Log "Winget upgrade timed out after 20 minutes" -Level WARNING - $script:Stats.WarningsCount++ - return - } - - if ($upgradeProcess.ExitCode -eq 0) { - # AppUpdatesOffered was already recorded above; a zero exit means the command - # succeeded, not that every offered package installed, so nothing to add here. - Write-Log "Application updates completed successfully" -Level SUCCESS - } else { - # v2.16: decode the exit code. A bare number ("code: -1978335188") tells the - # user nothing, and adjacent codes mean opposite things - 0x8A15002B is not - # an error at all, while 0x8A15002C means some upgrades genuinely failed. - # Values verified against the documented winget error list - do not edit - # from memory, adjacent codes have unrelated meanings. - $wingetErrors = @{ - -1978335189 = '0x8A15002B - no applicable update found' - -1978335188 = '0x8A15002C - some applications failed to upgrade' - -1978335224 = '0x8A150008 - downloading installer failed' - -1978335225 = '0x8A150007 - manifest version newer than this winget client' - -1978335221 = '0x8A15000B - configured source information is corrupt' - -1978334967 = '0x8A150109 - restart required to finish installation' - } - $code = $upgradeProcess.ExitCode - $meaning = if ($wingetErrors.ContainsKey($code)) { - $wingetErrors[$code] - } else { - '0x{0:X8} - unrecognized winget exit code' -f $code - } - - if ($code -eq -1978335189) { - # Nothing to upgrade is a normal outcome, not a warning. AppUpdatesOffered - # reflects the parsed table above; the summary reports it as "offered", not - # "installed", so there is nothing to correct here. - Write-Log "Application updates: $meaning" -Level DETAIL - } else { - if ($code -eq -1978334967) { - # Installation finished but needs a reboot to take effect - $script:Stats.RebootRequired = $true - } - Write-Log "Application updates finished with $meaning" -Level WARNING - $script:Stats.WarningsCount++ - } - } - - } catch { - Write-Log "Application update error: $_" -Level ERROR - $script:Stats.ErrorsCount++ - } -} - -#endregion - -#region ═══════════════════════════════════════════════════════════════════════ -# CLEANUP FUNCTIONS -#═══════════════════════════════════════════════════════════════════════════════ - -function Clear-TempFiles { - <# - .SYNOPSIS - Cleans temporary files and system caches - #> - Write-Log "Temporary Files" -Level SECTION - - # Define temp paths and remove duplicates (e.g., $env:TEMP often equals $env:LOCALAPPDATA\Temp). - # v2.17: entries built from an empty environment variable are dropped. Under SYSTEM or - # a stripped scheduled-task environment "$env:LOCALAPPDATA\Temp" collapses to "\Temp", - # which GetFullPath roots at the CURRENT DRIVE - so the script would wipe D:\Temp. - # An empty $env:TEMP made GetFullPath throw outright and killed the whole function. - $tempPaths = @( - @{ Path = $env:TEMP; Desc = "User Temp"; Base = $env:TEMP } - @{ Path = "$env:SystemRoot\Temp"; Desc = "Windows Temp"; Base = $env:SystemRoot } - @{ Path = "$env:LOCALAPPDATA\Temp"; Desc = "Local Temp"; Base = $env:LOCALAPPDATA } - ) | Where-Object { -not [string]::IsNullOrWhiteSpace($_.Base) } | ForEach-Object { - try { - $_.Path = [System.IO.Path]::GetFullPath($_.Path) - $_ - } catch { - Write-Log "Skipping temp path '$($_.Desc)': $_" -Level DETAIL - } - } | Group-Object Path | ForEach-Object { $_.Group[0] } - - foreach ($item in $tempPaths) { - # Exclude the active log file - it lives in $env:TEMP by default and would - # otherwise be deleted mid-run, losing everything logged so far. - # MinAgeDays (v2.16): TEMP holds working files of running installers and - # applications; deleting today's entries can break them mid-operation. - Remove-FolderContent -Path $item.Path -Category "Temp" -Description $item.Desc ` - -ExcludeFile $script:LogPath -MinAgeDays 1 - } -} - -function Clear-BrowserCaches { - <# - .SYNOPSIS - Cleans browser caches (Edge, Chrome, Brave, Yandex, Opera, Opera GX, Firefox). - All profiles are cleaned for Chrome, Edge and Firefox; the default profile for the rest - #> - Write-Log "Browser Caches" -Level SECTION - - # Define browser cache paths - $browsers = @{ - "Edge" = @( - "$env:LOCALAPPDATA\Microsoft\Edge\User Data\Default\Cache" - "$env:LOCALAPPDATA\Microsoft\Edge\User Data\Default\Code Cache" - "$env:LOCALAPPDATA\Microsoft\Edge\User Data\Default\GPUCache" - "$env:LOCALAPPDATA\Microsoft\Edge\User Data\Default\Service Worker\CacheStorage" - ) - "Chrome" = @( - "$env:LOCALAPPDATA\Google\Chrome\User Data\Default\Cache" - "$env:LOCALAPPDATA\Google\Chrome\User Data\Default\Code Cache" - "$env:LOCALAPPDATA\Google\Chrome\User Data\Default\GPUCache" - "$env:LOCALAPPDATA\Google\Chrome\User Data\Default\Service Worker\CacheStorage" - ) - "Yandex" = @( - "$env:LOCALAPPDATA\Yandex\YandexBrowser\User Data\Default\Cache" - "$env:LOCALAPPDATA\Yandex\YandexBrowser\User Data\Default\Code Cache" - "$env:LOCALAPPDATA\Yandex\YandexBrowser\User Data\Default\GPUCache" - ) - "Opera" = @( - # Chromium disk caches live under LOCALAPPDATA; APPDATA kept for older layouts - "$env:LOCALAPPDATA\Opera Software\Opera Stable\Cache" - "$env:LOCALAPPDATA\Opera Software\Opera Stable\Code Cache" - "$env:LOCALAPPDATA\Opera Software\Opera Stable\GPUCache" - "$env:APPDATA\Opera Software\Opera Stable\Cache" - "$env:APPDATA\Opera Software\Opera Stable\Code Cache" - "$env:APPDATA\Opera Software\Opera Stable\GPUCache" - ) - "Opera GX" = @( - "$env:LOCALAPPDATA\Opera Software\Opera GX Stable\Cache" - "$env:LOCALAPPDATA\Opera Software\Opera GX Stable\Code Cache" - "$env:LOCALAPPDATA\Opera Software\Opera GX Stable\GPUCache" - "$env:APPDATA\Opera Software\Opera GX Stable\Cache" - "$env:APPDATA\Opera Software\Opera GX Stable\Code Cache" - "$env:APPDATA\Opera Software\Opera GX Stable\GPUCache" - ) - "Brave" = @( - "$env:LOCALAPPDATA\BraveSoftware\Brave-Browser\User Data\Default\Cache" - "$env:LOCALAPPDATA\BraveSoftware\Brave-Browser\User Data\Default\Code Cache" - "$env:LOCALAPPDATA\BraveSoftware\Brave-Browser\User Data\Default\GPUCache" - ) - } - - # Clean standard browsers in parallel - $allPaths = @() - foreach ($browser in $browsers.Keys) { - foreach ($path in $browsers[$browser]) { - if (Test-Path -LiteralPath $path -ErrorAction SilentlyContinue) { - $allPaths += @{ Browser = $browser; Path = $path } - } - } - } - - # Also check for additional Chrome/Edge profiles (with full cache set) - foreach ($browser in @("Chrome", "Edge")) { - $basePath = if ($browser -eq "Chrome") { - "$env:LOCALAPPDATA\Google\Chrome\User Data" - } else { - "$env:LOCALAPPDATA\Microsoft\Edge\User Data" - } - - if (Test-Path $basePath) { - Get-ChildItem -Path $basePath -Directory -ErrorAction SilentlyContinue | - Where-Object { $_.Name -like "Profile *" } | ForEach-Object { - # Add same cache types as Default profile (fixed in v2.1) - $profileCacheTypes = @("Cache", "Code Cache", "GPUCache", "Service Worker\CacheStorage") - foreach ($cacheType in $profileCacheTypes) { - $profileCache = Join-Path $_.FullName $cacheType - if (Test-Path $profileCache) { - $allPaths += @{ Browser = "$browser $($_.Name)"; Path = $profileCache } - } - } - } - } - } - - # Clean in parallel (with ReportOnly check) - if ($allPaths.Count -gt 0) { - # Get browser names for logging - $browserNames = ($allPaths | Select-Object -ExpandProperty Browser -Unique) -join ', ' - - if ($ReportOnly) { - # v2.18: $sizeBefore comes from Get-FolderSize, which returns 0 for BOTH an - # empty cache and an unreadable one, so "Would clean ... - 0 B" announced a - # cleanup that would free nothing. Re-measure with the checked variant to tell - # "empty" (confirmed 0) from "could not measure" ($null) and word it honestly. - $checked = @($allPaths | ForEach-Object { Get-FolderSizeChecked -Path $_.Path }) - if ($checked -contains $null) { - Write-Log "Browser caches ($browserNames): present, size could not be fully measured" -Level DETAIL - } else { - $checkedSum = [long](($checked | Measure-Object -Sum).Sum ?? 0) - if ($checkedSum -gt 0) { - Write-Log "Would clean browser caches ($browserNames) - $(Format-FileSize $checkedSum)" -Level DETAIL - } else { - Write-Log "Browser cache folders found ($browserNames), but they are empty" -Level DETAIL - } - } - } else { - # v2.20, corrected in review: both sides are now measured PER PATH with the - # SAME function. "Before" used Get-FolderSize (raw enumerator, inaccessible - # files silently skipped, reparse points excluded) while "after" used - # Get-FolderSizeChecked, so the two numbers did not describe the same set of - # files and a genuine deletion could be subtracted into the "nothing freed" - # branch. Measuring before only in this branch also stops ReportOnly from - # walking every cache twice. - $beforeMeasurements = @($allPaths | ForEach-Object { Get-FolderSizeChecked -Path $_.Path }) - - # Actual cleanup - $allPaths | ForEach-Object -Parallel { - $item = $_ - $path = $item.Path - - if (Test-Path -LiteralPath $path) { - try { - Get-ChildItem -LiteralPath $path -Force -ErrorAction SilentlyContinue | ForEach-Object { - Remove-Item -LiteralPath $_.FullName -Recurse -Force -ErrorAction SilentlyContinue - } - } catch { } - } - } -ThrottleLimit 8 - - # Measure size after cleanup to get actual freed space. - # - # v2.20: checked measurement. Get-FolderSize returns 0 both for "empty" and for - # "could not read", so an after-walk that lost access - the cache folder is - # being recreated by a browser that just started, ACLs changed mid-run - turned - # into "freed everything we measured before". The delta is only computed when - # every folder answered; otherwise the bytes stay unattributed instead of being - # invented. - $afterMeasurements = @($allPaths | ForEach-Object { Get-FolderSizeChecked -Path $_.Path }) - - # Pair the two sides path by path. The previous rule discarded the delta for - # ALL caches when a single one of ~30 could not be measured; only the paths - # that actually failed are dropped now. - $measuredBefore = [long]0 - $measuredAfter = [long]0 - $afterUnmeasured = 0 - for ($i = 0; $i -lt $allPaths.Count; $i++) { - $beforeOne = $beforeMeasurements[$i] - $afterOne = $afterMeasurements[$i] - if ($null -eq $beforeOne -or $null -eq $afterOne) { - $afterUnmeasured++ - continue - } - $measuredBefore += $beforeOne - $measuredAfter += $afterOne - } - - # Clamped once over the total, not per path (raised in review). TotalFreedBytes - # means net space reclaimed, so clamping each path separately would report - # 100 MB when one cache shrank by 100 MB while another was recreated and grew - # by 80 MB - inventing 80 MB of "freed" space the disk never got back. - $freedSpace = [math]::Max(0, $measuredBefore - $measuredAfter) - - # Update statistics with actual freed space (not estimated) - if ($freedSpace -gt 0) { - $script:Stats.TotalFreedBytes += $freedSpace - if (-not $script:Stats.FreedByCategory.ContainsKey("Browser")) { - $script:Stats.FreedByCategory["Browser"] = 0 - } - $script:Stats.FreedByCategory["Browser"] += $freedSpace - Write-Log "Browser caches cleaned ($browserNames) - $(Format-FileSize $freedSpace)" -Level SUCCESS - if ($afterUnmeasured -gt 0) { - Write-Log "Browser caches: $afterUnmeasured folder(s) could not be measured - their share is not included above" -Level DETAIL - } - } elseif ($afterUnmeasured -gt 0) { - Write-Log "Browser caches ($browserNames): cleaned, but $afterUnmeasured folder(s) could not be measured - freed space not counted" -Level DETAIL - } else { - # v2.16: was logged as SUCCESS. A running browser locks its cache, so - # "cleaned" with zero freed was a plain lie to the user - Write-Log "Browser caches: nothing freed ($browserNames) - close the browsers and retry" -Level DETAIL - } - } - } - - # Handle Firefox profiles separately - # Note: cache2/startupCache live under LOCALAPPDATA (the APPDATA profile only - # holds roaming data like bookmarks/prefs) - fixed in v2.14 - $firefoxProfileRoots = @( - "$env:LOCALAPPDATA\Mozilla\Firefox\Profiles" - "$env:APPDATA\Mozilla\Firefox\Profiles" - ) - foreach ($firefoxProfiles in $firefoxProfileRoots) { - if (Test-Path $firefoxProfiles) { - Get-ChildItem -Path $firefoxProfiles -Directory -ErrorAction SilentlyContinue | ForEach-Object { - Remove-FolderContent -Path "$($_.FullName)\cache2" -Category "Browser" -Description "Firefox cache" - Remove-FolderContent -Path "$($_.FullName)\startupCache" -Category "Browser" - } - } - } -} - -function Clear-WindowsUpdateCache { - <# - .SYNOPSIS - Cleans Windows Update download cache - #> - Write-Log "Windows Update Cache" -Level SECTION - - # v2.17: if the update phase left payloads waiting for a reboot, this folder holds - # them. Deleting it here means gigabytes get downloaded again after the restart, - # while the run proudly reports the freed space. - if ($script:Stats.RebootRequired) { - Write-Log "Updates are pending a reboot - keeping the cache (it holds their payloads)" -Level DETAIL - return - } - - if ($ReportOnly) { - $size = Get-FolderSize -Path "$env:SystemRoot\SoftwareDistribution\Download" - Write-Log "Would clean: Windows Update cache - $(Format-FileSize $size)" -Level DETAIL - return - } - - # Restart only what WE stopped (v2.17). Starting every stopped service afterwards - # would silently re-enable one an administrator had disabled on purpose - and the - # recovery path would repeat that mistake on the next run. - $toRestart = @( - foreach ($svcName in @('wuauserv', 'bits')) { - $svc = Get-Service -Name $svcName -ErrorAction SilentlyContinue - if ($svc -and $svc.Status -eq 'Running') { $svcName } - } - ) - - if ($toRestart.Count -eq 0) { - # Nothing of ours to stop or restore: no marker, no service juggling - Write-Log "Windows Update services are not running - cleaning the cache directly" -Level DETAIL -NoLog - Remove-FolderContent -Path "$env:SystemRoot\SoftwareDistribution\Download" -Category "WinUpdate" -Description "Windows Update cache" - return - } - - # Stop services with try/finally to ensure they restart. v2.17 (p.13 of the audit): - # a hard kill of this process skips that finally too, leaving wuauserv/bits - # stopped forever - the marker lets the NEXT run detect and recover that, and it - # names the exact services so recovery cannot overreach either. - Write-Log "Stopping Windows Update services..." -Level DETAIL -NoLog - Set-RunMarker -Phase 'WUServiceStop' -Data @{ ServicesToRestart = $toRestart } - try { - Stop-Service -Name $toRestart -Force -ErrorAction SilentlyContinue - - # v2.16: Stop-Service returns before the service has actually reached Stopped, - # and its failure was swallowed by -ErrorAction SilentlyContinue. Cleaning while - # the service still holds the files silently leaves the cache in place. - $stillRunning = @() - foreach ($svcName in $toRestart) { - $svc = Get-Service -Name $svcName -ErrorAction SilentlyContinue - if (-not $svc) { continue } - try { - $svc.WaitForStatus([System.ServiceProcess.ServiceControllerStatus]::Stopped, [timespan]::FromSeconds(30)) - } catch { - $stillRunning += $svcName - } - } - if ($stillRunning.Count -gt 0) { - # Skip entirely rather than half-delete: with the service holding the files, - # cleanup would remove an arbitrary subset and report a misleading number - Write-Log "Service(s) still running after 30s: $($stillRunning -join ', ') - skipping cache cleanup" -Level WARNING - $script:Stats.WarningsCount++ - } else { - # Clean - Remove-FolderContent -Path "$env:SystemRoot\SoftwareDistribution\Download" -Category "WinUpdate" -Description "Windows Update cache" - } - } finally { - # Restart exactly what was running before, and keep the marker if any of them - # refused - the next run then retries instead of leaving them down silently - $restartFailed = $false - foreach ($svcName in $toRestart) { - try { - Start-Service -Name $svcName -ErrorAction Stop - } catch { - Write-Log "Could not restart $svcName : $_" -Level WARNING - $script:Stats.WarningsCount++ - $restartFailed = $true - } - } - if (-not $restartFailed) { Clear-RunMarker } - } -} - -function Get-RecycleBinSize { - <# - .SYNOPSIS - Gets the total size of items in the Recycle Bin - #> - $totalSize = [long]0 - try { - $shell = New-Object -ComObject Shell.Application - $recycleBin = $shell.Namespace(0xA) - foreach ($item in $recycleBin.Items()) { - try { - # ExtendedProperty is exact and works for folders too (verified on 25H2: - # a deleted folder reports the total size of its contents) - $itemSize = $item.ExtendedProperty("System.Size") - if ($itemSize) { - $totalSize += [long]$itemSize - } else { - # Fallback for shells that do not expose the property. - # v2.17: column index 3 is Size. Index 2 is "Date deleted" - the old - # code parsed a date as a size, which quietly contributed zero. - $sizeStr = $recycleBin.GetDetailsOf($item, 3) - # Guard against a column-order change: a size always has a digit - # followed by a unit, a date does not - if ($sizeStr -and $sizeStr -match '\d.*[A-Za-zА-Яа-я]') { - $totalSize += ConvertFrom-HumanReadableSize $sizeStr - } - } - } catch { - # Ignore errors for individual items - } - } - } catch { - # Return 0 if we can't access recycle bin - } - return $totalSize -} - -function Get-RecycleBinItemCount { - <# - .SYNOPSIS - Number of items in the Recycle Bin (v2.17) - .DESCRIPTION - Emptiness must be decided by count, not by size: a size of zero can also mean - "the shell would not tell us", and skipping the cleanup in that case leaves the - bin full while reporting it as already empty. - #> - try { - $shell = New-Object -ComObject Shell.Application - return @($shell.Namespace(0xA).Items()).Count - } catch { - return -1 # unknown - } -} - -function Clear-WinCleanRecycleBin { - <# - .SYNOPSIS - Empties the Recycle Bin with size tracking - #> - Write-Log "Recycle Bin" -Level SECTION - - # Measure size and count before cleanup. v2.17: emptiness is decided by the item - # count - a zero size can also mean the shell refused to report one, and skipping - # on that basis would leave a full bin described as already empty. - $sizeBefore = Get-RecycleBinSize - $itemCount = Get-RecycleBinItemCount - - if ($ReportOnly) { - if ($itemCount -eq 0) { - Write-Log "Recycle Bin is empty" -Level DETAIL - } elseif ($sizeBefore -gt 0) { - Write-Log "Would clean: Recycle Bin - $(Format-FileSize $sizeBefore)" -Level DETAIL - } else { - Write-Log "Would clean: Recycle Bin - $itemCount item(s), size unavailable" -Level DETAIL - } - return - } - - if ($itemCount -eq 0) { - Write-Log "Recycle Bin is already empty" -Level INFO - return - } - - try { - # Use full cmdlet path to explicitly call the built-in cmdlet - Microsoft.PowerShell.Management\Clear-RecycleBin -Force -ErrorAction Stop - - # Update statistics - $script:Stats.TotalFreedBytes += $sizeBefore - if (-not $script:Stats.FreedByCategory.ContainsKey("Recycle Bin")) { - $script:Stats.FreedByCategory["Recycle Bin"] = 0 - } - $script:Stats.FreedByCategory["Recycle Bin"] += $sizeBefore - - if ($sizeBefore -gt 0) { - Write-Log "Recycle Bin emptied - $(Format-FileSize $sizeBefore)" -Level SUCCESS - } else { - # Emptied, but the shell never told us how much it held (v2.17) - Write-Log "Recycle Bin emptied ($itemCount item(s), size was unavailable)" -Level SUCCESS - } - } catch { - # Fallback to COM method - try { - $shell = New-Object -ComObject Shell.Application - $recycleBin = $shell.Namespace(0xA) - $items = $recycleBin.Items() - $count = $items.Count - - $items | ForEach-Object { - Remove-Item -LiteralPath $_.Path -Recurse -Force -ErrorAction SilentlyContinue - } - - # Measure what was actually freed - some items may have failed to - # delete silently (v2.14; previously the full size was always counted) - $freed = [math]::Max(0, $sizeBefore - (Get-RecycleBinSize)) - if ($freed -gt 0) { - $script:Stats.TotalFreedBytes += $freed - if (-not $script:Stats.FreedByCategory.ContainsKey("Recycle Bin")) { - $script:Stats.FreedByCategory["Recycle Bin"] = 0 - } - $script:Stats.FreedByCategory["Recycle Bin"] += $freed - } - - if ($freed -gt 0) { - Write-Log "Recycle Bin emptied ($count items) - $(Format-FileSize $freed)" -Level SUCCESS - } else { - # v2.17: was SUCCESS regardless - a bin that refused to empty looked identical - Write-Log "Recycle Bin: $count item(s) processed, nothing freed - some items may be locked" -Level WARNING - $script:Stats.WarningsCount++ - } - } catch { - Write-Log "Could not empty Recycle Bin: $_" -Level WARNING - $script:Stats.WarningsCount++ - } - } -} - -function Clear-SystemCaches { - <# - .SYNOPSIS - Cleans various Windows system caches - #> - Write-Log "System Caches" -Level SECTION - - $systemPaths = @( - @{ Path = "$env:SystemRoot\Prefetch"; Desc = "Prefetch" } - @{ Path = "$env:LOCALAPPDATA\IconCache.db"; Desc = "Icon cache"; File = $true } - @{ Path = "$env:LOCALAPPDATA\Microsoft\Windows\Explorer"; Desc = "Thumbnail cache" } - @{ Path = "$env:LOCALAPPDATA\Microsoft\Windows\WER"; Desc = "Error reports (local)" } - @{ Path = "$env:ProgramData\Microsoft\Windows\WER"; Desc = "Error reports (system)" } - @{ Path = "$env:ProgramData\USOShared\Logs"; Desc = "Update logs" } - @{ Path = "$env:LOCALAPPDATA\Packages\Microsoft.WindowsStore_8wekyb3d8bbwe\LocalCache"; Desc = "Windows Store cache" } - ) - - foreach ($item in $systemPaths) { - if ($item.File) { - if (Test-Path -LiteralPath $item.Path -ErrorAction SilentlyContinue) { - $fileSize = (Get-Item -LiteralPath $item.Path -ErrorAction SilentlyContinue).Length - $fileSize = [long]($fileSize ?? 0) - - if ($ReportOnly) { - Write-Log "Would clean: $($item.Desc) - $(Format-FileSize $fileSize)" -Level DETAIL - } else { - Remove-Item -LiteralPath $item.Path -Force -ErrorAction SilentlyContinue - - if (Test-Path -LiteralPath $item.Path -ErrorAction SilentlyContinue) { - # File is locked (e.g. IconCache.db held by Explorer) - - # don't count it as freed (v2.14) - Write-Log "$($item.Desc) is in use - skipped" -Level DETAIL - } else { - if ($fileSize -gt 0) { - $script:Stats.TotalFreedBytes += $fileSize - if (-not $script:Stats.FreedByCategory.ContainsKey("System")) { - $script:Stats.FreedByCategory["System"] = 0 - } - $script:Stats.FreedByCategory["System"] += $fileSize - } - - Write-Log "$($item.Desc) cleaned" -Level DETAIL - } - } - } - } else { - Remove-FolderContent -Path $item.Path -Category "System" -Description $item.Desc - } - } - - # Delivery Optimization cache: files are owned by the DO service, so raw folder - # deletion usually fails silently - use the supported cmdlet instead (v2.14) - # - # v2.16: the cache lives under the NetworkService profile, not in ProgramData. - # The old ProgramData path does not exist on Windows 11 (verified on 25H2), so - # every size measurement returned 0 and multi-gigabyte cleanups were reported as - # "0 B". Both locations are probed - the ProgramData one is kept for older builds. - # Note: Get-DeliveryOptimizationPerfSnap.CacheSizeBytes is NOT usable here - it - # reflects recent transfer activity (490 MB) rather than cache size on disk (7.5 GB). - $doPaths = @( - "$env:SystemRoot\ServiceProfiles\NetworkService\AppData\Local\Microsoft\Windows\DeliveryOptimization" - "$env:ProgramData\Microsoft\Windows\DeliveryOptimization" - ) | Where-Object { Test-Path $_ -ErrorAction SilentlyContinue } - - $doSizeOf = { - $total = 0 - foreach ($p in $doPaths) { $total += Get-FolderSize -Path $p } - $total - } - - if ($ReportOnly) { - $doSize = & $doSizeOf - if ($doSize -gt 0) { - Write-Log "Would clean: Delivery Optimization - $(Format-FileSize $doSize)" -Level DETAIL - } - } elseif (Get-Command Delete-DeliveryOptimizationCache -ErrorAction SilentlyContinue) { - try { - $doSizeBefore = & $doSizeOf - Delete-DeliveryOptimizationCache -Force -ErrorAction Stop - $doFreed = [math]::Max(0, $doSizeBefore - (& $doSizeOf)) - if ($doFreed -gt 0) { - $script:Stats.TotalFreedBytes += $doFreed - if (-not $script:Stats.FreedByCategory.ContainsKey("System")) { - $script:Stats.FreedByCategory["System"] = 0 - } - $script:Stats.FreedByCategory["System"] += $doFreed - Write-Log "Delivery Optimization cache - $(Format-FileSize $doFreed)" -Level SUCCESS - } elseif ($doPaths.Count -eq 0) { - # Nothing to measure: say so instead of claiming a clean-up happened (v2.16) - Write-Log "Delivery Optimization: cmdlet ran, cache location not found - freed size unknown" -Level DETAIL - } elseif ($doSizeBefore -gt 0) { - # v2.20: this was a WARNING and fired on healthy systems. The measurement - # covers the WHOLE Delivery Optimization folder, but the supported cmdlet - # only removes cached content - the service's own logs and state files stay - # and are not ours to delete. So "size did not change" after a cmdlet that - # reported success is not evidence of failure, it is evidence that the - # remainder was never cache. Reproduced on the EN stand VM twice in a row - # (2 MB left behind), where it pushed the run over its warning budget. - # - # A genuine failure still surfaces: the cmdlet runs with -ErrorAction Stop, - # so an actual error lands in the catch below as a warning. - # Deliberately does NOT claim the cache was cleared: an unchanged folder - # size cannot prove that every remaining byte is non-cache. It states what - # is actually known - the cmdlet reported success and this much is still - # on disk - and leaves the reader to judge (tightened in review; the first - # wording asserted "cache cleared", which is the opposite failure of the - # warning it replaced). - Write-Log "Delivery Optimization: cmdlet reported success, $(Format-FileSize $doSizeBefore) still on disk (the folder also holds service logs and state, which it does not remove)" -Level DETAIL - } else { - Write-Log "Delivery Optimization cache was already empty" -Level DETAIL - } - } catch { - Write-Log "Delete-DeliveryOptimizationCache failed: $_" -Level WARNING - $script:Stats.WarningsCount++ - foreach ($p in $doPaths) { - Remove-FolderContent -Path $p -Category "System" -Description "Delivery Optimization" - } - } - } else { - foreach ($p in $doPaths) { - Remove-FolderContent -Path $p -Category "System" -Description "Delivery Optimization" - } - } -} - -function Clear-EventLogs { - <# - .SYNOPSIS - Clears Windows Event Logs (excluding critical ones) - .DESCRIPTION - v2.17 (p.3 of the audit): each log used to be cleared via a separate `wevtutil` - process (30-80ms each, and a run can see 100-300 eligible logs). Replaced with - EventLogSession.ClearLog, the in-process .NET API wevtutil itself calls - same - underlying WinAPI, no process-spawn overhead per log. - #> - Write-Log "Event Logs" -Level SECTION - - if ($ReportOnly) { - Write-Log "Would clean: Windows Event Logs" -Level DETAIL - return - } - - try { - # Enumerate only logs worth clearing: enabled, non-empty, Administrative/Operational. - # This skips ~1000 Analytic/Debug/empty channels - much faster and avoids - # chronic partial-failure warnings (v2.14; was: wevtutil el over all channels) - # v2.20: keep the enumeration errors. A wholesale failure (Event Log service down, - # WMI broken) produced an empty list, zero failed clears and therefore the success - # branch: "Event logs cleared (0 logs)" while nothing was touched. - # v2.20, corrected in review: the enumeration result is kept BEFORE filtering. - # Deciding on the filtered list meant that 40 readable channels out of 510 with - # 470 enumeration errors produced a plain "Event logs cleared (40 logs)" SUCCESS, - # and that an empty filter result on a perfectly healthy machine was reported as - # a failed enumeration. Those are different states and now read differently. - $enumErrors = $null - $allLogs = @(Get-WinEvent -ListLog * -ErrorAction SilentlyContinue -ErrorVariable enumErrors) - $logs = $allLogs | Where-Object { - $_.RecordCount -gt 0 -and - $_.IsEnabled -and - $_.LogName -ne 'Security' -and # Keep the main Security log (exact match) - $_.LogType -in @('Administrative', 'Operational') - } - - $clearedCount = 0 - $failedCount = 0 - foreach ($log in $logs) { - try { - [System.Diagnostics.Eventing.Reader.EventLogSession]::GlobalSession.ClearLog($log.LogName) - $clearedCount++ - } catch { - $failedCount++ - } - } - - $enumErrorCount = @($enumErrors).Count - - if ($allLogs.Count -eq 0) { - # Nothing could be listed at all - the Event Log service is down or the API - # is broken. Clearing zero channels is not a clean system. - Write-Log "Event logs: channels could not be enumerated ($enumErrorCount error(s)) - nothing was cleared" -Level WARNING - $script:Stats.WarningsCount++ - } else { - # Partial loss is reported separately from the clearing result, because the - # channels that failed to list were never even candidates and their records - # are still on disk. - # - # DETAIL, not WARNING (raised in review): a listing error is a gap in coverage, - # not the failure of an operation we claimed to perform, and this machine is - # not evidence about anyone else's - measured here on 25H2 it is 510 channels - # with zero errors, but third-party or protected channels can error out - # routinely elsewhere, and a warning that fires every run teaches people to - # ignore warnings. Total failure below stays a warning. Worded as errors rather - # than channels because that is what was counted. - if ($enumErrorCount -gt 0) { - Write-Log "Event logs: $enumErrorCount error(s) while listing channels - whatever they refer to was never considered for clearing" -Level DETAIL - } - - if ($failedCount -gt 0) { - Write-Log "Event logs cleared: $clearedCount, failed: $failedCount" -Level WARNING - $script:Stats.WarningsCount++ - } elseif ($clearedCount -eq 0) { - Write-Log "Event logs: no channel needed clearing" -Level DETAIL - } else { - Write-Log "Event logs cleared ($clearedCount logs)" -Level SUCCESS - } - } - } catch { - Write-Log "Error clearing event logs: $_" -Level WARNING - $script:Stats.WarningsCount++ - } -} - -function Clear-DNSCache { - <# - .SYNOPSIS - Flushes DNS resolver cache - .DESCRIPTION - Clears the DNS client cache to resolve potential DNS issues - and free up memory used by cached DNS entries - #> - Write-Log "DNS Cache" -Level SECTION - - if ($ReportOnly) { - Write-Log "Would flush: DNS resolver cache" -Level DETAIL - return - } - - try { - # v2.17: prefer the cmdlet - it is locale-independent and raises real errors. - # ipconfig was doing the same work a second time, and its success was matched - # against English/Russian text that depends on the console code page. - if (Get-Command Clear-DnsClientCache -ErrorAction SilentlyContinue) { - Clear-DnsClientCache -ErrorAction Stop - Write-Log "DNS cache flushed successfully" -Level SUCCESS - } else { - $null = ipconfig /flushdns 2>&1 - $exitCode = $LASTEXITCODE - if ($exitCode -eq 0) { - Write-Log "DNS cache flushed successfully" -Level SUCCESS - } else { - Write-Log "DNS cache flush failed (ipconfig exit code: $exitCode)" -Level WARNING - $script:Stats.WarningsCount++ - } - } - } catch { - Write-Log "Error flushing DNS cache: $_" -Level WARNING - $script:Stats.WarningsCount++ - } -} - -function Clear-PrivacyTraces { - <# - .SYNOPSIS - Clears privacy-related traces (Run history, recent files, etc.) - .DESCRIPTION - Removes various Windows usage traces including: - - Run dialog history (Win+R) - - Recent documents MRU - - Explorer search history - #> - Write-Log "Privacy Traces" -Level SECTION - - if ($ReportOnly) { - Write-Log "Would clean: Run dialog history, Recent documents MRU" -Level DETAIL - return - } - - $clearedItems = @() - - # v2.20: success is now confirmed by looking, not by the absence of an exception. - # Remove-Item with -ErrorAction SilentlyContinue cannot throw, so the catch blocks - # here were dead code and "$clearedItems += ..." ran unconditionally: the log said - # "Privacy traces cleared: Explorer typed paths" even when policy or permissions had - # rejected the deletion and the key was still sitting there, fully populated. - # RunMRU joins this list rather than keeping its own copy of the same logic: it used - # the pre-count as proof of success in exactly the way the others did (caught in - # review before release, when the first version of this fix left it behind). - $historyKeys = @( - @{ Path = "HKCU:\Software\Microsoft\Windows\CurrentVersion\Explorer\RunMRU"; Label = 'Run history' } - @{ Path = "HKCU:\Software\Microsoft\Windows\CurrentVersion\Explorer\TypedPaths"; Label = 'Explorer typed paths' } - @{ Path = "HKCU:\Software\Microsoft\Windows\CurrentVersion\Explorer\WordWheelQuery"; Label = 'Explorer search history' } - ) - foreach ($entry in $historyKeys) { - if (-not (Test-Path $entry.Path)) { continue } - - $before = Get-RegistryValueCount -Key $entry.Path - if ($null -eq $before) { - # Present but unreadable: we cannot tell whether there is anything to clear, - # and silently moving on would look identical to "there was nothing" - Write-Log "$($entry.Label): key could not be read - left untouched" -Level WARNING - $script:Stats.WarningsCount++ - continue - } - # An empty key is not a trace that was cleared - it is a trace that was not there - if ($before -eq 0) { continue } - - Remove-Item -Path $entry.Path -Force -ErrorAction SilentlyContinue - New-Item -Path $entry.Path -Force -ErrorAction SilentlyContinue | Out-Null - - $after = Get-RegistryValueCount -Key $entry.Path - if ($null -eq $after) { - Write-Log "$($entry.Label): result could not be verified - not counted as cleared" -Level WARNING - $script:Stats.WarningsCount++ - } elseif ($after -eq 0) { - $clearedItems += $entry.Label - } else { - Write-Log "$($entry.Label): $after of $before entries remain - not cleared" -Level WARNING - $script:Stats.WarningsCount++ - } - } - - # Clear Recent documents folder - $recentFolder = [Environment]::GetFolderPath('Recent') - if (-not [string]::IsNullOrWhiteSpace($recentFolder) -and (Test-Path $recentFolder)) { - $recentBefore = @(Get-ChildItem -LiteralPath $recentFolder -Force -ErrorAction SilentlyContinue).Count - if ($recentBefore -gt 0) { - Get-ChildItem -LiteralPath $recentFolder -Force -ErrorAction SilentlyContinue | ForEach-Object { - Remove-Item -LiteralPath $_.FullName -Recurse -Force -ErrorAction SilentlyContinue - } - # Report what actually went, not what was there before the attempt - $recentAfter = @(Get-ChildItem -LiteralPath $recentFolder -Force -ErrorAction SilentlyContinue).Count - $removed = $recentBefore - $recentAfter - if ($removed -gt 0) { - $clearedItems += "Recent documents ($removed items)" - } - if ($recentAfter -gt 0) { - Write-Log "Recent documents: $recentAfter of $recentBefore items could not be removed (in use?)" -Level WARNING - $script:Stats.WarningsCount++ - } - } - } - - if ($clearedItems.Count -gt 0) { - Write-Log "Privacy traces cleared: $($clearedItems -join ', ')" -Level SUCCESS - } else { - Write-Log "No privacy traces found to clear" -Level INFO - } -} - -function Set-WindowsTelemetry { - <# - .SYNOPSIS - Configures Windows telemetry settings - .DESCRIPTION - Disables or minimizes Windows telemetry data collection - by setting appropriate registry values and group policies - #> - param( - [switch]$Disable - ) - - if (-not $Disable) { - return - } - - Write-Log "WINDOWS TELEMETRY CONFIGURATION" -Level TITLE - - if ($ReportOnly) { - Write-Log "Would configure: Disable Windows telemetry" -Level DETAIL - return - } - - $changesApplied = @() - - try { - # Set telemetry level to Security (0 = Security, 1 = Basic, 2 = Enhanced, 3 = Full) - $dataCollectionKey = "HKLM:\SOFTWARE\Policies\Microsoft\Windows\DataCollection" - if (-not (Test-Path $dataCollectionKey)) { - New-Item -Path $dataCollectionKey -Force -ErrorAction SilentlyContinue | Out-Null - } - - # AllowTelemetry = 0 (Security - minimum telemetry, only for Enterprise/Education) - # For other editions, setting to 1 (Basic) is the minimum allowed - # Use EditionID from registry (language-independent, fixed in v2.1) - $editionId = (Get-ItemProperty -Path "HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion" -Name EditionID -ErrorAction SilentlyContinue).EditionID - $telemetryLevel = if ($editionId -match "Enterprise|Education") { 0 } else { 1 } - - Set-ItemProperty -Path $dataCollectionKey -Name "AllowTelemetry" -Value $telemetryLevel -Type DWord -Force - $changesApplied += "Telemetry level set to $telemetryLevel" - - # Disable Customer Experience Improvement Program - Set-ItemProperty -Path $dataCollectionKey -Name "DoNotShowFeedbackNotifications" -Value 1 -Type DWord -Force - $changesApplied += "Feedback notifications disabled" - - # Disable Application Telemetry - $appCompatKey = "HKLM:\SOFTWARE\Policies\Microsoft\Windows\AppCompat" - if (-not (Test-Path $appCompatKey)) { - New-Item -Path $appCompatKey -Force -ErrorAction SilentlyContinue | Out-Null - } - Set-ItemProperty -Path $appCompatKey -Name "AITEnable" -Value 0 -Type DWord -Force - $changesApplied += "Application telemetry disabled" - - # Disable Advertising ID - $advertisingKey = "HKLM:\SOFTWARE\Policies\Microsoft\Windows\AdvertisingInfo" - if (-not (Test-Path $advertisingKey)) { - New-Item -Path $advertisingKey -Force -ErrorAction SilentlyContinue | Out-Null - } - Set-ItemProperty -Path $advertisingKey -Name "DisabledByGroupPolicy" -Value 1 -Type DWord -Force - $changesApplied += "Advertising ID disabled" - - # Disable Windows Error Reporting - $werKey = "HKLM:\SOFTWARE\Policies\Microsoft\Windows\Windows Error Reporting" - if (-not (Test-Path $werKey)) { - New-Item -Path $werKey -Force -ErrorAction SilentlyContinue | Out-Null - } - Set-ItemProperty -Path $werKey -Name "Disabled" -Value 1 -Type DWord -Force - $changesApplied += "Windows Error Reporting disabled" - - Write-Log "Telemetry settings applied:" -Level SUCCESS - foreach ($change in $changesApplied) { - Write-Log " - $change" -Level DETAIL - } - - Write-Log "Note: Some changes may require a system restart to take effect" -Level INFO - - } catch { - Write-Log "Error configuring telemetry: $_" -Level ERROR - $script:Stats.ErrorsCount++ - } -} - -function Clear-WindowsOld { - <# - .SYNOPSIS - Removes Windows.old folder with user confirmation - #> - $windowsOldPath = "$env:SystemDrive\Windows.old" - - if (-not (Test-Path $windowsOldPath)) { - return - } - - Write-Log "Previous Windows Installation" -Level SECTION - - $size = Get-FolderSize -Path $windowsOldPath - $sizeFormatted = Format-FileSize $size - - Write-Log "Found Windows.old folder ($sizeFormatted)" -Level WARNING - - if ($ReportOnly) { - Write-Log "Would clean: Windows.old - $sizeFormatted" -Level DETAIL - return - } - - # Check if running in interactive console (fixed in v2.1) - if (-not (Test-InteractiveConsole)) { - # Non-interactive: skip with safe default (don't delete without confirmation) - Write-Log "Non-interactive mode - skipping Windows.old deletion (requires user confirmation)" -Level INFO - return - } - - # Interactive prompt with timeout - Write-Host "" - Write-Host " This folder contains files from a previous Windows installation." -ForegroundColor DarkGray - Write-Host " Delete Windows.old? (" -NoNewline -ForegroundColor Yellow - Write-Host "Y" -NoNewline -ForegroundColor Green - Write-Host "/n, default " -NoNewline -ForegroundColor Yellow - Write-Host "Y" -NoNewline -ForegroundColor Green - Write-Host " in 15 sec): " -NoNewline -ForegroundColor Yellow - - $timeout = 15 - $startTime = Get-Date - $response = "" - - # Clear keyboard buffer - while ([Console]::KeyAvailable) { [Console]::ReadKey($true) | Out-Null } - - while ((Get-Date) -lt $startTime.AddSeconds($timeout)) { - if ([Console]::KeyAvailable) { - $key = [Console]::ReadKey($true) - if ($key.Key -eq "Enter" -or $key.KeyChar -match "[YyДд]") { - $response = "Y" - Write-Host "Y" -ForegroundColor Green - break - } elseif ($key.KeyChar -match "[NnНн]") { - $response = "N" - Write-Host "N" -ForegroundColor Red - break - } - } - - $remaining = $timeout - [int]((Get-Date) - $startTime).TotalSeconds - # {0,2} keeps the line length constant so no ghost chars remain when the - # countdown drops from 2 digits to 1 (v2.14) - Write-Host ("`r Delete Windows.old? (Y/n, default Y in {0,2} sec): " -f $remaining) -NoNewline -ForegroundColor Yellow - Start-Sleep -Milliseconds 100 - } - - if ($response -eq "" -or $response -eq "Y") { - if ($response -eq "") { Write-Host "Y" -ForegroundColor Green } - - Write-Log "Removing Windows.old..." -Level INFO - - try { - # Take ownership and remove - # Use the well-known SID for the Administrators group - the literal name - # is localized (e.g. "Администраторы") and fails on non-English Windows (v2.14) - $null = takeown /F $windowsOldPath /A /R /D Y 2>&1 - $null = icacls $windowsOldPath /grant "*S-1-5-32-544:F" /T /C /Q 2>&1 - Remove-Item -Path $windowsOldPath -Recurse -Force -ErrorAction SilentlyContinue - - if (-not (Test-Path $windowsOldPath)) { - Write-Log "Windows.old removed - $sizeFormatted freed" -Level SUCCESS - $script:Stats.TotalFreedBytes += $size - $script:Stats.FreedByCategory["Windows.old"] = $size - } else { - Write-Log "Could not fully remove Windows.old" -Level WARNING - $script:Stats.WarningsCount++ - } - } catch { - Write-Log "Error removing Windows.old: $_" -Level ERROR - $script:Stats.ErrorsCount++ - } - } else { - Write-Log "Windows.old removal cancelled by user" -Level INFO - } -} - -#endregion - -#region ═══════════════════════════════════════════════════════════════════════ -# DEVELOPER CLEANUP FUNCTIONS -#═══════════════════════════════════════════════════════════════════════════════ - -function Clear-DeveloperCaches { - <# - .SYNOPSIS - Cleans developer tool caches (npm, pip, nuget, composer, etc.) - #> - # v2.20: matches the contract the dispatcher enforces (-SkipCleanup suppresses the - # whole cleanup group). Unreachable through Start-WinClean, which already gates the - # phase - this is the guard a direct caller of the dot-sourced function gets, and it - # disagreed with the documented meaning of -SkipCleanup. - if ($SkipCleanup -or $SkipDevCleanup) { - Write-Log "Developer cache cleanup skipped (parameter)" -Level INFO - return - } - - Write-Log "DEVELOPER CACHES" -Level TITLE - Update-Progress -Activity "Developer Cleanup" -Status "Cleaning caches..." - - # NPM Cache (npm v7+ uses LOCALAPPDATA; older versions used APPDATA - fixed in v2.14) - Write-Log "npm Cache" -Level SECTION - $npmCachePaths = @("$env:LOCALAPPDATA\npm-cache", "$env:APPDATA\npm-cache") - $npmCache = $npmCachePaths | Where-Object { Test-Path $_ } | Select-Object -First 1 - if ($npmCache) { - if ($ReportOnly) { - $size = Get-FolderSize $npmCache - Write-Log "Would clean: npm cache - $(Format-FileSize $size)" -Level DETAIL - } else { - # Use npm cache clean if available - $npm = Get-Command npm -ErrorAction SilentlyContinue - if ($npm) { - try { - # v2.20, corrected in review: both sides are measured with the checked - # variant. Get-FolderSize answers 0 for "empty" and for "could not - # read" alike, so a cache that became unreadable after the clean - npm - # recreating it, ACLs changing mid-run - produced "freed everything we - # measured before" and put invented bytes into TotalFreedBytes. The - # same defect was fixed for browser caches in this release; npm kept it. - $sizeBefore = Get-FolderSizeChecked -Path $npmCache - & npm cache clean --force 2>&1 | Out-Null - # v2.20: npm fails without throwing (EPERM on a locked cache is the - # common case). The exit code was never read, so a failed clean that - # freed nothing landed in the "else" branch below and was logged as - # SUCCESS - the same lie fixed for browser caches in v2.16. - $npmExit = $LASTEXITCODE - $sizeAfter = Get-FolderSizeChecked -Path $npmCache - $npmMeasured = ($null -ne $sizeBefore -and $null -ne $sizeAfter) - $freed = if ($npmMeasured) { [math]::Max(0, $sizeBefore - $sizeAfter) } else { 0 } - - # Credit whatever really went, whether or not npm then failed - if ($freed -gt 0) { - $script:Stats.TotalFreedBytes += $freed - if (-not $script:Stats.FreedByCategory.ContainsKey("Developer")) { - $script:Stats.FreedByCategory["Developer"] = 0 - } - $script:Stats.FreedByCategory["Developer"] += $freed - } - - # v2.20, corrected in review: the exit code is checked FIRST. Testing - # "$freed -gt 0" first meant a partial failure (npm removes 400 MB, then - # hits EPERM on a locked file and exits 1) reported plain success and - # skipped the fallback - the exit code was read and then ignored in - # exactly the case where it mattered. - if ($npmExit -ne 0) { - Write-Log "npm cache clean failed (exit code $npmExit)$(if ($freed -gt 0) { " after freeing $(Format-FileSize $freed)" }) - removing the cache directly" -Level WARNING - $script:Stats.WarningsCount++ - Remove-FolderContent -Path $npmCache -Category "Developer" -Description "npm cache" - } elseif ($freed -gt 0) { - Write-Log "npm cache cleaned - $(Format-FileSize $freed)" -Level SUCCESS - } elseif (-not $npmMeasured) { - # Cleaned, but "how much" has no honest answer - say that instead - # of calling an unreadable cache an empty one. - Write-Log "npm cache cleaned, but its size could not be measured - freed space not counted" -Level DETAIL - } else { - # npm succeeded and there was nothing to reclaim. Not a cleanup - # success worth announcing, and definitely not freed bytes. - Write-Log "npm cache was already empty" -Level DETAIL - } - } catch { - Remove-FolderContent -Path $npmCache -Category "Developer" -Description "npm cache" - } - } else { - Remove-FolderContent -Path $npmCache -Category "Developer" -Description "npm cache" - } - } - - # Clean any remaining legacy cache location as well (both may exist after npm upgrades) - foreach ($stalePath in ($npmCachePaths | Where-Object { $_ -ne $npmCache })) { - Remove-FolderContent -Path $stalePath -Category "Developer" -Description "npm cache (legacy)" - } - } - - # pip Cache - Write-Log "pip Cache" -Level SECTION - $pipCaches = @( - "$env:LOCALAPPDATA\pip\Cache" - "$env:APPDATA\pip\cache" - "$env:USERPROFILE\.cache\pip" - ) - foreach ($pipCache in $pipCaches) { - Remove-FolderContent -Path $pipCache -Category "Developer" -Description "pip cache" - } - - # NuGet Cache (only metadata caches, not packages!) - Write-Log "NuGet Cache" -Level SECTION - $nugetCaches = @( - "$env:LOCALAPPDATA\NuGet\v3-cache" # Metadata cache - "$env:LOCALAPPDATA\NuGet\plugins-cache" # Plugin cache - "$env:LOCALAPPDATA\NuGet\http-cache" # HTTP cache - # Note: $env:USERPROFILE\.nuget\packages is NOT cache - it's the global packages folder - ) - foreach ($cache in $nugetCaches) { - Remove-FolderContent -Path $cache -Category "Developer" -Description "NuGet cache" - } - - # Composer Cache - Write-Log "Composer Cache" -Level SECTION - $composerCache = "$env:LOCALAPPDATA\Composer\cache" - Remove-FolderContent -Path $composerCache -Category "Developer" -Description "Composer cache" - - # Gradle (only safe cache directories, not full repositories!) - Write-Log "Gradle Cache" -Level SECTION - $gradleCaches = @( - "$env:USERPROFILE\.gradle\caches\build-cache-1" # Build cache - "$env:USERPROFILE\.gradle\caches\transforms-*" # Transform cache - "$env:USERPROFILE\.gradle\daemon" # Daemon logs - # Note: .gradle\caches\modules-* contains downloaded dependencies - dangerous to delete! - # Note: .m2\repository is Maven local repo - do NOT delete! - ) - foreach ($pattern in $gradleCaches) { - Get-ChildItem -Path (Split-Path $pattern -Parent) -Filter (Split-Path $pattern -Leaf) -Directory -ErrorAction SilentlyContinue | ForEach-Object { - Remove-FolderContent -Path $_.FullName -Category "Developer" -Description "Gradle cache" - } - } - - # yarn Cache - Write-Log "yarn Cache" -Level SECTION - $yarnCaches = @( - "$env:LOCALAPPDATA\Yarn\Cache" - "$env:USERPROFILE\.cache\yarn" - ) - foreach ($cache in $yarnCaches) { - Remove-FolderContent -Path $cache -Category "Developer" -Description "yarn cache" - } - - # pnpm Cache - Write-Log "pnpm Cache" -Level SECTION - $pnpmCache = "$env:LOCALAPPDATA\pnpm-cache" - Remove-FolderContent -Path $pnpmCache -Category "Developer" -Description "pnpm cache" - - # Go Cache - Write-Log "Go Cache" -Level SECTION - $goCache = "$env:LOCALAPPDATA\go-build" - Remove-FolderContent -Path $goCache -Category "Developer" -Description "Go build cache" - - # Rust/Cargo Cache - Write-Log "Cargo Cache" -Level SECTION - $cargoCaches = @( - "$env:USERPROFILE\.cargo\registry\cache" - "$env:USERPROFILE\.cargo\git\db" - ) - foreach ($cache in $cargoCaches) { - Remove-FolderContent -Path $cache -Category "Developer" -Description "Cargo cache" - } - - # uv Cache (Python package/project manager) - Write-Log "uv Cache" -Level SECTION - $uvCache = "$env:LOCALAPPDATA\uv\cache" - Remove-FolderContent -Path $uvCache -Category "Developer" -Description "uv cache" -} - -#endregion - -#region ═══════════════════════════════════════════════════════════════════════ -# DOCKER/WSL CLEANUP FUNCTIONS -#═══════════════════════════════════════════════════════════════════════════════ - -function Test-DiskpartCompactionFailed { - <# - .SYNOPSIS - Decides whether a diskpart "compact vdisk" run failed - pure logic, no I/O - .DESCRIPTION - Split out (v2.18) so the failure decision is unit-testable without a real VHDX. - diskpart is unreliable at signalling a sub-command failure through its exit code - (it can exit 0 after "compact vdisk" errored), so a non-zero exit OR a known error - marker in the output both count as failure. The output is localized, so this only - catches English error text; on a non-English console a failure may surface only as - a non-zero exit, or as the file not shrinking (the caller treats "no shrink" as a - neutral "no space saved", not a failure, so a silent localized error can still be - missed - a limitation of the diskpart text interface, noted deliberately). - .PARAMETER Output - The combined stdout/stderr text diskpart produced. - .PARAMETER ExitCode - diskpart's process exit code. - #> - param([string]$Output, [int]$ExitCode) - - if ($ExitCode -ne 0) { return $true } - if ([string]::IsNullOrWhiteSpace($Output)) { return $false } - return [bool]($Output -match ('DiskPart has encountered an error|Virtual Disk Service error|' + - 'DiskPart failed|The arguments specified for this command are not valid|' + - 'There is no virtual disk selected|Access is denied|The system cannot find')) -} - -function Clear-DockerWSL { - <# - .SYNOPSIS - Cleans Docker images, containers, and WSL2 disk - #> - # v2.20: see Clear-DeveloperCaches - same contract, same reason - if ($SkipCleanup -or $SkipDockerCleanup) { - Write-Log "Docker/WSL cleanup skipped (parameter)" -Level INFO - return - } - - Write-Log "DOCKER & WSL CLEANUP" -Level TITLE - Update-Progress -Activity "Docker/WSL Cleanup" -Status "Checking Docker..." - - # Docker Cleanup - $docker = Get-Command docker -ErrorAction SilentlyContinue - if ($docker) { - Write-Log "Docker Cleanup" -Level SECTION - - # Check if Docker is running - $dockerRunning = $false - try { - $null = docker info 2>&1 - $exitCode = $LASTEXITCODE # Capture immediately after command - $dockerRunning = $exitCode -eq 0 - } catch { - # Docker command failed to execute (not installed or path issue) - } - - if ($dockerRunning) { - if ($ReportOnly) { - Write-Log "Would run: docker system prune" -Level DETAIL - } else { - try { - # Remove unused data (stopped containers, unused networks, dangling images, build cache) - Write-Log "Running docker system prune..." -Level INFO - $result = docker system prune -f 2>&1 - $pruneExitCode = $LASTEXITCODE - - # Join output into a single string: -match against an array does not - # populate $Matches reliably (v2.14) - $resultText = $result | Out-String - - if ($pruneExitCode -ne 0) { - Write-Log "Docker prune failed (exit code: $pruneExitCode)" -Level WARNING - $script:Stats.WarningsCount++ - } - # Parse reclaimed space and add to statistics - # Supports both "reclaimed 1.23GB" and "Total reclaimed space: 1.23GB" formats - elseif ($resultText -match "reclaimed\s+(?:space:\s*)?([\d.,]+\s*[KMGT]?B)") { - $reclaimedStr = $Matches[1] - $reclaimedBytes = ConvertFrom-HumanReadableSize $reclaimedStr - - Write-Log "Docker cleanup: $reclaimedStr reclaimed" -Level SUCCESS - - if ($reclaimedBytes -gt 0) { - $script:Stats.TotalFreedBytes += $reclaimedBytes - if (-not $script:Stats.FreedByCategory.ContainsKey("Docker")) { - $script:Stats.FreedByCategory["Docker"] = 0 - } - $script:Stats.FreedByCategory["Docker"] += $reclaimedBytes - } - } else { - Write-Log "Docker cleanup completed" -Level SUCCESS - } - - # Note: docker system prune -f already includes build cache cleanup - - } catch { - Write-Log "Docker cleanup error: $_" -Level WARNING - $script:Stats.WarningsCount++ - } - } - } else { - Write-Log "Docker is not running - skipping cleanup" -Level INFO - } - } else { - Write-Log "Docker not installed" -Level INFO - } - - # WSL2 Disk Compaction - Write-Log "WSL2 Disk Optimization" -Level SECTION - - $wsl = Get-Command wsl -ErrorAction SilentlyContinue - if ($wsl) { - try { - # Define all possible VHDX locations (including Docker) - $wslPaths = @( - "$env:LOCALAPPDATA\Packages\*CanonicalGroupLimited*\LocalState" - "$env:LOCALAPPDATA\Packages\*MicrosoftCorporationII.WindowsSubsystemForLinux*\LocalState" - "$env:LOCALAPPDATA\Docker\wsl\data" - "$env:LOCALAPPDATA\Docker\wsl\distro" - ) - - # Find all VHDX files first (don't depend on WSL distro list) - $vhdxFiles = @() - foreach ($pattern in $wslPaths) { - $vhdxFiles += Get-ChildItem -Path $pattern -Filter "*.vhdx" -Recurse -ErrorAction SilentlyContinue - } - - if ($vhdxFiles.Count -gt 0) { - if ($ReportOnly) { - $totalSize = ($vhdxFiles | Measure-Object -Property Length -Sum).Sum - Write-Log "Would optimize $($vhdxFiles.Count) WSL2/Docker disk(s) - Total: $(Format-FileSize $totalSize)" -Level DETAIL - } else { - # Shutdown WSL first (this also stops Docker WSL backends) - Write-Log "Shutting down WSL..." -Level INFO - wsl --shutdown - $wslShutdownExit = $LASTEXITCODE - if ($wslShutdownExit -ne 0) { - # v2.18: compacting a VHDX WSL may still hold open is unsafe and - # pointless, so a failed shutdown skips compaction (emptying the - # list) with a warning instead of touching a live disk. - Write-Log "wsl --shutdown exit $wslShutdownExit - skipping VHDX compaction to avoid touching a live disk" -Level WARNING - $script:Stats.WarningsCount++ - $vhdxFiles = @() - } - Start-Sleep -Seconds 2 - - # Compact each VHDX file - foreach ($vhdxFile in $vhdxFiles) { - $vhdx = $vhdxFile.FullName - $sizeBefore = $vhdxFile.Length - - Write-Log "Compacting $($vhdxFile.Name)..." -Level DETAIL - - try { - # Use diskpart to compact - $diskpartScript = @" -select vdisk file="$vhdx" -compact vdisk -exit -"@ - # v2.18: capture output AND exit code. The old "| Out-Null" - # discarded both, so a diskpart failure fell straight through to - # "no space saved" (INFO) and read as success to the stand/CI. - $diskpartOutput = $diskpartScript | diskpart 2>&1 - $diskpartExit = $LASTEXITCODE - $diskpartText = ($diskpartOutput | Out-String) - - if (Test-DiskpartCompactionFailed -Output $diskpartText -ExitCode $diskpartExit) { - Write-Log "Could not compact $($vhdxFile.Name): diskpart reported an error (exit $diskpartExit)" -Level WARNING - $tail = (($diskpartText -split "`r?`n" | Where-Object { $_.Trim() }) | Select-Object -Last 2) -join ' | ' - if ($tail) { Write-Log "diskpart: $tail" -Level DETAIL } - $script:Stats.WarningsCount++ - } else { - $sizeAfter = (Get-Item $vhdx).Length - $saved = $sizeBefore - $sizeAfter - - if ($saved -gt 0) { - Write-Log "Compacted $($vhdxFile.Name): $(Format-FileSize $saved) saved" -Level SUCCESS - $script:Stats.TotalFreedBytes += $saved - if (-not $script:Stats.FreedByCategory.ContainsKey("WSL")) { - $script:Stats.FreedByCategory["WSL"] = 0 - } - $script:Stats.FreedByCategory["WSL"] += $saved - } else { - Write-Log "Compacted $($vhdxFile.Name): no space saved" -Level INFO - } - } - } catch { - # v2.18: the outer catch bumps WarningsCount but this per-VHDX - # one did not, so a real compaction failure could leave the JSON - # WarningsCount at 0 and read as a clean run to the stand/CI. - Write-Log "Could not compact $($vhdxFile.Name): $_" -Level WARNING - $script:Stats.WarningsCount++ - } - } - } - } else { - Write-Log "No WSL2/Docker VHDX files found" -Level INFO - } - } catch { - Write-Log "WSL optimization error: $_" -Level WARNING - $script:Stats.WarningsCount++ - } - } else { - Write-Log "WSL not installed" -Level INFO - } -} - -#endregion - -#region ═══════════════════════════════════════════════════════════════════════ -# VISUAL STUDIO CLEANUP FUNCTIONS -#═══════════════════════════════════════════════════════════════════════════════ - -function Clear-VisualStudio { - <# - .SYNOPSIS - Cleans Visual Studio caches and temporary files - #> - # v2.20: see Clear-DeveloperCaches - same contract, same reason - if ($SkipCleanup -or $SkipVSCleanup) { - Write-Log "Visual Studio cleanup skipped (parameter)" -Level INFO - return - } - - Write-Log "VISUAL STUDIO CLEANUP" -Level TITLE - Update-Progress -Activity "Visual Studio Cleanup" -Status "Cleaning caches..." - - # VS 2019/2022 caches (directories) - $vsCacheDirs = @( - @{ Path = "$env:LOCALAPPDATA\Microsoft\VisualStudio\*\ComponentModelCache"; Desc = "Component Model Cache" } - @{ Path = "$env:LOCALAPPDATA\Microsoft\VisualStudio\*\ImageCacheRoot"; Desc = "Image Cache" } - @{ Path = "$env:LOCALAPPDATA\Microsoft\VisualStudio\*\DesignTimeBuild"; Desc = "Design Time Build" } - @{ Path = "$env:LOCALAPPDATA\Microsoft\VSCommon\*\SQM"; Desc = "SQM Data" } - @{ Path = "$env:LOCALAPPDATA\Microsoft\VisualStudio\Packages\_Instances"; Desc = "Package Instances" } - ) - - # VS file patterns (handled separately, fixed in v2.1) - $vsFilePatterns = @( - @{ Pattern = "$env:APPDATA\Microsoft\VisualStudio\*\*.roslynobjectin"; Desc = "Roslyn Temp" } - ) - - Write-Log "Visual Studio Caches" -Level SECTION - - # Process directory caches - foreach ($item in $vsCacheDirs) { - $paths = Resolve-Path -Path $item.Path -ErrorAction SilentlyContinue - foreach ($path in $paths) { - Remove-FolderContent -Path $path.Path -Category "VS" -Description $item.Desc - } - } - - # Process file patterns (fixed in v2.1 - files were not being deleted before) - foreach ($item in $vsFilePatterns) { - Remove-FilesByPattern -Pattern $item.Pattern -Category "VS" -Description $item.Desc - } - - # MEF cache: no separate section - the real one is ComponentModelCache, already - # covered by the VS cache list above. "MEFCacheAssembly" (v2.16 and earlier) is a - # folder Visual Studio never creates, so the section only ever printed a heading. - -# VS Code caches - Write-Log "VS Code Caches" -Level SECTION - $vscodeCaches = @( - "$env:APPDATA\Code\Cache" - "$env:APPDATA\Code\CachedData" - "$env:APPDATA\Code\CachedExtensions" - "$env:APPDATA\Code\CachedExtensionVSIXs" - "$env:APPDATA\Code\Code Cache" - "$env:APPDATA\Code\GPUCache" - "$env:APPDATA\Code - Insiders\Cache" - "$env:APPDATA\Code - Insiders\CachedData" - ) - - foreach ($cache in $vscodeCaches) { - Remove-FolderContent -Path $cache -Category "VS Code" -Description "VS Code cache" - } - - # JetBrains IDEs - Write-Log "JetBrains IDE Caches" -Level SECTION - $jetbrainsBase = "$env:LOCALAPPDATA\JetBrains" - if (Test-Path $jetbrainsBase) { - Get-ChildItem -Path $jetbrainsBase -Directory -ErrorAction SilentlyContinue | ForEach-Object { - $cacheDirs = @("caches", "index", "tmp", "log") - foreach ($cacheDir in $cacheDirs) { - $fullPath = Join-Path $_.FullName $cacheDir - Remove-FolderContent -Path $fullPath -Category "JetBrains" -Description "JetBrains $cacheDir" - } - } - } -} - -#endregion - -#region ═══════════════════════════════════════════════════════════════════════ -# SYSTEM CLEANUP FUNCTIONS -#═══════════════════════════════════════════════════════════════════════════════ - -function Clear-KernelDumps { - <# - .SYNOPSIS - Removes stale kernel live-dump reports (v2.16) - .DESCRIPTION - LiveKernelReports accumulates multi-gigabyte .dmp files that nothing ever - cleans up - a single watchdog dump of 9 GB was found sitting for 18 months on - the author's machine. Only files older than $MinAgeDays are touched, so a dump - from a crash that is being investigated right now survives. - #> - param([int]$MinAgeDays = 30) - - $dumpPath = Join-Path $env:SystemRoot 'LiveKernelReports' - if (-not (Test-Path -LiteralPath $dumpPath)) { return } - - $cutoff = (Get-Date).AddDays(-$MinAgeDays) - $stale = @(Get-ChildItem -LiteralPath $dumpPath -Recurse -File -Force -Filter '*.dmp' -ErrorAction SilentlyContinue | - Where-Object { $_.LastWriteTime -lt $cutoff }) - if ($stale.Count -eq 0) { return } - - $size = ($stale | Measure-Object -Property Length -Sum).Sum - - if ($ReportOnly) { - Write-Log "Would clean: kernel dumps older than $MinAgeDays days ($($stale.Count) file(s)) - $(Format-FileSize $size)" -Level DETAIL - return - } - - $freed = 0 - $failed = 0 - $firstError = $null - foreach ($file in $stale) { - try { - $len = $file.Length - Remove-Item -LiteralPath $file.FullName -Force -ErrorAction Stop - $freed += $len - } catch { - $failed++ - if (-not $firstError) { $firstError = $_.Exception.Message } - } - } - - if ($freed -gt 0) { - $script:Stats.TotalFreedBytes += $freed - if (-not $script:Stats.FreedByCategory.ContainsKey("System")) { - $script:Stats.FreedByCategory["System"] = 0 - } - $script:Stats.FreedByCategory["System"] += $freed - Write-Log "Kernel dumps older than $MinAgeDays days - $(Format-FileSize $freed)" -Level SUCCESS - } - - # Report failures explicitly: ReportOnly promised these gigabytes, and staying - # silent would make a blocked deletion look like "there was nothing to clean" - if ($failed -gt 0) { - Write-Log "Kernel dumps: $failed of $($stale.Count) file(s) could not be deleted ($(Format-FileSize ($size - $freed)) left) - $firstError" -Level WARNING - $script:Stats.WarningsCount++ - } -} - -function Show-DiskSpaceReport { - <# - .SYNOPSIS - Reports where disk space went, including areas this script never deletes (v2.16) - .DESCRIPTION - Several of the largest consumers on a Windows workstation are things a cleanup - script must not touch: C:\Windows\Installer holds the data needed to uninstall - or repair applications, hiberfil.sys is sized by the OS, and the search index is - held open by its service. Reporting them is still valuable - without it the user - has no idea where tens of gigabytes went. - #> - Write-Log "Disk Space Report" -Level SECTION - - $targets = [ordered]@{ - 'Kernel live dumps' = Join-Path $env:SystemRoot 'LiveKernelReports' - 'MSI cache (keep)' = Join-Path $env:SystemRoot 'Installer' - 'Search index' = Join-Path $env:ProgramData 'Microsoft\Search\Data\Applications\Windows' - 'VS package cache' = Join-Path $env:ProgramData 'Package Cache' - 'Windows logs' = Join-Path $env:SystemRoot 'Logs' - 'Crash dumps (user)' = Join-Path $env:LOCALAPPDATA 'CrashDumps' - } - - $rows = @() - $unmeasured = @() - foreach ($name in $targets.Keys) { - $size = Get-FolderSizeChecked -Path $targets[$name] - if ($null -eq $size) { - $unmeasured += $name - continue - } - if ($size -gt 100MB) { $rows += [pscustomobject]@{ Item = $name; Bytes = $size } } - } - - foreach ($file in @('hiberfil.sys', 'pagefile.sys', 'swapfile.sys', 'MEMORY.DMP')) { - $path = if ($file -eq 'MEMORY.DMP') { Join-Path $env:SystemRoot $file } else { Join-Path $env:SystemDrive $file } - $item = Get-Item -LiteralPath $path -Force -ErrorAction SilentlyContinue - if ($item -and $item.Length -gt 100MB) { $rows += [pscustomobject]@{ Item = $file; Bytes = $item.Length } } - } - - # Shadow copies: CIM returns bytes, unlike vssadmin which prints them using the - # system decimal separator and would need locale-aware parsing - $shadow = Get-CimInstance Win32_ShadowStorage -ErrorAction SilentlyContinue - if ($shadow) { - $used = ($shadow | Measure-Object -Property UsedSpace -Sum).Sum - if ($used -gt 100MB) { $rows += [pscustomobject]@{ Item = 'Restore points / shadow copies'; Bytes = $used } } - } - - if ($unmeasured.Count -gt 0) { - Write-Log "Could not fully measure: $($unmeasured -join ', ') (access denied on some items)" -Level DETAIL - } - - if ($rows.Count -eq 0) { - if ($unmeasured.Count -eq 0) { - Write-Log "Nothing above 100 MB outside the cleaned areas" -Level DETAIL - } - return - } - - foreach ($row in ($rows | Sort-Object Bytes -Descending)) { - Write-Log "$($row.Item): $(Format-FileSize $row.Bytes)" -Level DETAIL - } -} - -function Get-SupersededDriverCandidate { - <# - .SYNOPSIS - Decides which parsed driver packages are superseded - pure logic, no I/O - .DESCRIPTION - Split out of Get-RedundantDriverPackage (v2.17, p.6/p.23 of the audit) so the - decision logic can be unit-tested against hand-built package arrays instead of - needing a real pnputil.exe and a real FileRepository to exercise it. - - A package is a candidate only when BOTH conditions hold: - 1. pnputil reports no device bound to it, and - 2. a package with a STRICTLY newer version and the same OriginalName exists - (a mere newer date at an equal version does not count - v2.18). - Packages without a newer sibling are left alone even when unused - they serve - devices that are merely unplugged right now (docks, printers, external storage). - That distinction is what separates this from the aggressive "driver cleaners" - that break machines. - - Grouped by INF *and* vendor/class. Generic names (usbaudio.inf, hidusb.inf) are - shipped by several vendors, and grouping on the name alone could declare one - vendor's package "superseded" by another's - then delete a working driver whose - device merely happens to be unplugged right now. - .PARAMETER Packages - Parsed pnputil packages: objects with Oem, Inf, Provider, Class, Version, Date, - InUse (the shape Get-RedundantDriverPackage builds from pnputil's XML). - .OUTPUTS - Candidate objects (a subset of the input, decorated with Bytes=0 and - KeptVersion). Bytes is filled in later by the caller once a FileRepository - folder is matched - this function never touches the filesystem. - #> - param([object[]]$Packages) - - $Packages | Group-Object { "$($_.Inf)|$($_.Provider)|$($_.Class)" } | ForEach-Object { - $newest = $_.Group | Sort-Object Version, Date -Descending | Select-Object -First 1 - foreach ($pkg in $_.Group) { - # v2.18: "superseded" means a STRICTLY NEWER version exists. A package tied at - # the newest version is kept even when unused - same-version duplicates are not - # proof of obsolescence, and removing one would be wider than the documented - # safety contract (older code deleted a same-version package that merely had an - # older date). Date is no longer a selection tie-breaker: every package at the - # max version is retained; it only decides which object represents $newest. - if ($pkg.InUse -or $pkg.Version -ge $newest.Version) { continue } - $pkg | Add-Member -NotePropertyName Bytes -NotePropertyValue ([long]0) -PassThru | - Add-Member -NotePropertyName KeptVersion -NotePropertyValue $newest.Version -PassThru - } - } -} - -function Get-RedundantDriverPackage { - <# - .SYNOPSIS - Finds superseded third-party driver packages in the driver store (v2.16) - .DESCRIPTION - Candidate selection itself lives in Get-SupersededDriverCandidate; this function - handles the I/O around it - running pnputil, parsing its XML, and matching each - candidate to a FileRepository folder for size reporting. - - Output is machine-readable XML on purpose: the plain text output of pnputil - switches between English and the system language depending on the console code - page, so it cannot be parsed reliably. - #> - $repo = Join-Path $env:SystemRoot 'System32\DriverStore\FileRepository' - - # v2.17: stderr is kept OUT of the XML. Merging it with 2>&1 meant a single - # warning line from pnputil produced invalid XML, the cast threw, and the driver - # store was skipped silently every week. - $stdOutFile = [System.IO.Path]::GetTempFileName() - $stdErrFile = [System.IO.Path]::GetTempFileName() - try { - $pnp = Start-Process -FilePath "$env:SystemRoot\System32\pnputil.exe" ` - -ArgumentList '/enum-drivers', '/devices', '/format', 'xml' ` - -NoNewWindow -PassThru -Wait ` - -RedirectStandardOutput $stdOutFile -RedirectStandardError $stdErrFile - - if ($pnp.ExitCode -ne 0) { - # Without this the failure looked exactly like "nothing to clean" - $err = (Get-Content $stdErrFile -Raw -ErrorAction SilentlyContinue) - Write-Log "pnputil /enum-drivers returned $($pnp.ExitCode) - driver store skipped" -Level WARNING - if ($err) { Write-Log "pnputil: $($err.Trim())" -Level DETAIL } - $script:Stats.WarningsCount++ - return @() - } - - $rawXml = Get-Content $stdOutFile -Raw -ErrorAction Stop - [xml]$doc = $rawXml - } catch { - Write-Log "Could not enumerate driver packages: $_" -Level WARNING - # Make diagnosis possible instead of leaving a bare exception - $head = if ($rawXml) { $rawXml.Substring(0, [Math]::Min(200, $rawXml.Length)) } else { '(no output)' } - Write-Log "pnputil output began with: $head" -Level DETAIL - $script:Stats.WarningsCount++ - return @() - } finally { - Remove-Item $stdOutFile, $stdErrFile -Force -ErrorAction SilentlyContinue - } - if (-not $doc.PnpUtil.Driver) { - Write-Log "pnputil returned no driver packages - unexpected, driver store skipped" -Level WARNING - $script:Stats.WarningsCount++ - return @() - } - - $skipped = 0 - $packages = foreach ($d in $doc.PnpUtil.Driver) { - $parts = $d.DriverVersion -split '\s+', 2 # "MM/dd/yyyy x.y.z.w" - if ($parts.Count -lt 2) { $skipped++; continue } - try { - # The date is only a tie-breaker for sorting, so an unparseable one must not - # discard the whole package - otherwise a date format change would silently - # empty the candidate list and report "nothing found" forever - $driverDate = [datetime]::MinValue - [void][datetime]::TryParse($parts[0], [cultureinfo]::InvariantCulture, - [System.Globalization.DateTimeStyles]::None, [ref]$driverDate) - [pscustomobject]@{ - Oem = $d.DriverName - Inf = $d.OriginalName - Provider = $d.ProviderName - Class = $d.ClassName - Version = [version]$parts[1] - Date = $driverDate - InUse = [bool]$d.Devices - } - } catch { $skipped++; continue } - } - - if ($skipped -gt 0) { - Write-Log "Driver store: $skipped package(s) could not be parsed and were ignored" -Level DETAIL - } - if (-not $packages) { - Write-Log "Driver store: no package could be parsed - skipping cleanup" -Level WARNING - $script:Stats.WarningsCount++ - return @() - } - - # p.6 of the audit: figure out WHICH packages are superseded first, from pnputil's - # own metadata alone (Get-SupersededDriverCandidate - no filesystem access needed - # for that). Only once there is at least one candidate do we pay for hashing - # FileRepository folders (700-1500 on a typical machine), and that walk stops as - # soon as every candidate is matched instead of always hashing every folder. - $candidates = @(Get-SupersededDriverCandidate -Packages $packages) - - if (-not $candidates) { - return @() - } - - # Hash each candidate's live INF once, up front: version strings are not unique - # (ibtusb.inf ships several packages carrying an identical DriverVer), so hashing - # the actual INF is the only exact oem*.inf -> FileRepository folder mapping. - $neededHashes = @{} # SHA256 -> candidate object - foreach ($pkg in $candidates) { - $infFile = Join-Path $env:SystemRoot "INF\$($pkg.Oem)" - if (-not (Test-Path -LiteralPath $infFile)) { continue } - try { - $hash = (Get-FileHash $infFile -Algorithm SHA256).Hash - $neededHashes[$hash] = $pkg - } catch { } - } - - foreach ($dir in (Get-ChildItem -LiteralPath $repo -Directory -ErrorAction SilentlyContinue)) { - if ($neededHashes.Count -eq 0) { break } - - $infName = ($dir.Name -split '\.inf_')[0] + '.inf' - $infPath = Join-Path $dir.FullName $infName - if (-not (Test-Path -LiteralPath $infPath)) { continue } - - try { - $hash = (Get-FileHash $infPath -Algorithm SHA256).Hash - if ($neededHashes.ContainsKey($hash)) { - $pkg = $neededHashes[$hash] - $size = (Get-ChildItem -LiteralPath $dir.FullName -Recurse -File -Force -ErrorAction SilentlyContinue | - Measure-Object -Property Length -Sum).Sum - $pkg.Bytes = [long]($size ?? 0) - $neededHashes.Remove($hash) - } - } catch { } - } - - return $candidates -} - -function Clear-DriverStore { - <# - .SYNOPSIS - Removes superseded driver packages from the driver store (v2.16) - #> - Write-Log "Driver Store" -Level SECTION - - $candidates = @(Get-RedundantDriverPackage) - if ($candidates.Count -eq 0) { - Write-Log "No superseded driver packages found" -Level DETAIL - return - } - - $totalBytes = ($candidates | Measure-Object -Property Bytes -Sum).Sum - - if ($ReportOnly) { - Write-Log "Would clean: $($candidates.Count) superseded driver package(s) - $(Format-FileSize $totalBytes)" -Level DETAIL - foreach ($group in ($candidates | Group-Object Inf | Sort-Object Count -Descending | Select-Object -First 5)) { - Write-Log " $($group.Name): $($group.Count) old version(s)" -Level DETAIL - } - return - } - - # Repository size before removal, used as the authoritative freed total whenever any - # removed package lacks a trustworthy per-package size (see the accounting below). - $repoPath = Join-Path $env:SystemRoot 'System32\DriverStore\FileRepository' - $repoBefore = Get-FolderSize -Path $repoPath - - $perPackageBytes = 0 - $removed = 0 - $failed = 0 - $allMeasured = $true - foreach ($pkg in $candidates) { - # No /force here: it deletes packages even when a device is using them, which is - # exactly how driver cleaners break systems. Exit code is the verdict - the text - # output is localized. - $null = & pnputil.exe /delete-driver $pkg.Oem 2>&1 - if ($LASTEXITCODE -eq 0) { - $removed++ - # v2.18: a candidate whose FileRepository folder was never matched (unmatched - # INF hash, or a shared hash that overwrote another candidate in the lookup) - # keeps Bytes=0. Summing those as-is understates the total, so remember that at - # least one removed package had no trustworthy size. - if ($pkg.Bytes -gt 0) { $perPackageBytes += $pkg.Bytes } else { $allMeasured = $false } - } else { - $failed++ - Write-Log "Skipped $($pkg.Oem) ($($pkg.Inf)): pnputil exit $LASTEXITCODE" -Level DETAIL - } - } - - if ($removed -gt 0) { - if ($allMeasured) { - # Every removed package had a matched, measured size - use the precise sum. - $freed = $perPackageBytes - } else { - # At least one removed package had no per-package size, so the sum would - # understate even when it is non-zero (the old "only fall back when total==0" - # missed exactly this partial case). The repository delta captures every - # removal at once and is authoritative here. - $freed = [math]::Max(0, $repoBefore - (Get-FolderSize -Path $repoPath)) - Write-Log "Removed $removed package(s); per-package size incomplete, driver store shrank by $(Format-FileSize $freed)" -Level WARNING - $script:Stats.WarningsCount++ - } - - $script:Stats.TotalFreedBytes += $freed - if (-not $script:Stats.FreedByCategory.ContainsKey("DriverStore")) { - $script:Stats.FreedByCategory["DriverStore"] = 0 - } - $script:Stats.FreedByCategory["DriverStore"] += $freed - Write-Log "Removed $removed superseded driver package(s) - $(Format-FileSize $freed)" -Level SUCCESS - } else { - Write-Log "No driver packages were removed" -Level DETAIL - } - - if ($failed -gt 0) { - Write-Log "Driver store: $failed of $($candidates.Count) package(s) refused removal" -Level WARNING - $script:Stats.WarningsCount++ - } -} - -function Measure-FreeSpaceGain { - <# - .SYNOPSIS - Runs an operation and attributes the freed disk space to a category (v2.17) - .DESCRIPTION - DISM component cleanup and Disk Cleanup are usually the two most productive - steps of a run, and neither reports how much it freed. Without this their - gigabytes were missing from "Space freed" entirely, so the summary understated - the result while looking complete. - - The measurement is deliberately coarse: it is the difference in free space on - the system drive, so unrelated activity during the operation adds noise, and a - negative delta is discarded rather than reported. - #> - param( - [Parameter(Mandatory)][scriptblock]$Operation, - [Parameter(Mandatory)][string]$Category - ) - - $driveLetter = ($env:SystemDrive).TrimEnd(':') - $before = try { (Get-PSDrive -Name $driveLetter -ErrorAction Stop).Free } catch { $null } - - & $Operation - - if ($null -eq $before -or $ReportOnly) { return } - $after = try { (Get-PSDrive -Name $driveLetter -ErrorAction Stop).Free } catch { $null } - if ($null -eq $after) { return } - - $gain = $after - $before - if ($gain -le 0) { return } - - $script:Stats.TotalFreedBytes += $gain - if (-not $script:Stats.FreedByCategory.ContainsKey($Category)) { - $script:Stats.FreedByCategory[$Category] = 0 - } - $script:Stats.FreedByCategory[$Category] += $gain - Write-Log "$Category freed approximately $(Format-FileSize $gain)" -Level DETAIL -} - -function Invoke-DISMCleanup { - <# - .SYNOPSIS - Runs DISM component cleanup - #> - # Clear any existing progress bar before DISM outputs to console - Clear-AllProgress - - Write-Log "Windows Component Cleanup (DISM)" -Level SECTION - - if ($ReportOnly) { - Write-Log "Would run: DISM /Online /Cleanup-Image /StartComponentCleanup /ResetBase" -Level DETAIL - Write-Log "Note: /ResetBase removes ability to uninstall updates" -Level WARNING - return - } - - # Analyze first: skip the expensive cleanup when DISM says it is not needed (v2.14). - # /English forces English output so the recommendation line is parseable on any locale. - # On analyze failure/timeout fall back to running the cleanup unconditionally. - Write-Log "Analyzing component store..." -Level INFO - - $runCleanup = $true - $analyzeFile = [System.IO.Path]::GetTempFileName() - try { - $analyzeProcess = Start-Process -FilePath "$env:SystemRoot\System32\Dism.exe" ` - -ArgumentList "/Online", "/English", "/Cleanup-Image", "/AnalyzeComponentStore" ` - -NoNewWindow -PassThru -RedirectStandardOutput $analyzeFile - - if ($analyzeProcess.WaitForExit(300000)) { - $analyzeOutput = Get-Content $analyzeFile -Raw -ErrorAction SilentlyContinue - if ($analyzeProcess.ExitCode -eq 0 -and - $analyzeOutput -match 'Component Store Cleanup Recommended\s*:\s*No') { - $runCleanup = $false - Write-Log "Component store is clean - cleanup not needed" -Level SUCCESS - } - } else { - $analyzeProcess.Kill($true) - } - } catch { - # Analyze failed - run the cleanup unconditionally (previous behavior) - } finally { - Remove-Item $analyzeFile -Force -ErrorAction SilentlyContinue - } - - if (-not $runCleanup) { - return - } - - Write-Log "Running DISM cleanup (this may take several minutes)..." -Level INFO - - # Redirect DISM output to a file - its progress bar corrupts the script's console UI - $dismOutFile = [System.IO.Path]::GetTempFileName() - try { - $dismProcess = Start-Process -FilePath "$env:SystemRoot\System32\Dism.exe" ` - -ArgumentList "/Online", "/Cleanup-Image", "/StartComponentCleanup", "/ResetBase" ` - -NoNewWindow -PassThru -RedirectStandardOutput $dismOutFile - - # Wait with timeout (15 minutes for DISM operation) - $timeoutMs = 900000 - if (-not $dismProcess.WaitForExit($timeoutMs)) { - $dismProcess.Kill($true) - Write-Log "DISM cleanup timed out after 15 minutes" -Level WARNING - $script:Stats.WarningsCount++ - return - } - - # v2.17: 3010 is the documented "success, reboot required" code that DISM returns - # routinely after /StartComponentCleanup - it used to fall through to the warning - # branch, painting every successful run yellow while never setting RebootRequired. - # Code 87 is ERROR_INVALID_PARAMETER (an unsupported switch combination), not - # "cleanup not needed" - reporting it as INFO hid a real failure completely. - switch ($dismProcess.ExitCode) { - 0 { Write-Log "DISM cleanup completed successfully" -Level SUCCESS } - 3010 { - Write-Log "DISM cleanup completed - a reboot is required to finish" -Level SUCCESS - $script:Stats.RebootRequired = $true - } - default { - $tail = (Get-Content $dismOutFile -Tail 3 -ErrorAction SilentlyContinue) -join ' ' - Write-Log "DISM failed with code $($dismProcess.ExitCode)$(if ($tail) { " - $tail" })" -Level WARNING - $script:Stats.WarningsCount++ - } - } - } catch { - Write-Log "DISM error: $_" -Level WARNING - $script:Stats.WarningsCount++ - } finally { - Remove-Item $dismOutFile -Force -ErrorAction SilentlyContinue - } -} - -function Get-ProcessActivityFingerprint { - <# - .SYNOPSIS - A comparable snapshot of how much work a process has actually done - .DESCRIPTION - v2.22. CPU time plus the three I/O operation counters, as one comparable string. - Two identical fingerprints taken far enough apart mean the process did literally - nothing in between. - - Win32_Process rather than System.Diagnostics.Process, established by measurement: - the .NET object exposes the processor times but leaves ReadOperationCount, - WriteOperationCount and OtherOperationCount empty, so a fingerprint built from it - would compare CPU alone. - - Returns $null when the counters cannot be read (WMI unavailable, the process gone, - access denied). The caller must treat $null as "cannot tell", never as "idle" - - otherwise a broken WMI would look exactly like a finished cleanup. - #> - param([int]$ProcessId) - - try { - $p = Get-CimInstance -ClassName Win32_Process -Filter "ProcessId=$ProcessId" ` - -Property KernelModeTime, UserModeTime, ReadOperationCount, - WriteOperationCount, OtherOperationCount ` - -ErrorAction Stop - if (-not $p) { return $null } - return '{0}|{1}|{2}|{3}|{4}' -f $p.KernelModeTime, $p.UserModeTime, - $p.ReadOperationCount, $p.WriteOperationCount, $p.OtherOperationCount - } catch { - return $null - } -} - -function Get-DiskCleanupActivityFingerprint { - <# - .SYNOPSIS - Activity fingerprint of the whole cleanmgr family, not just the process we started - .DESCRIPTION - v2.22, raised in review, then narrowed in the round after that. - - The concern is real: the handle we hold is not necessarily the only process doing - the work - cleanmgr was started with -WindowStyle Hidden and a Disk Cleanup window - appeared anyway. If a worker does the deleting while the process we started merely - waits, fingerprinting our PID alone would show a frozen parent and call a live - cleanup finished. - - The first attempt aggregated over every cleanmgr.exe on the machine, and that was - WORSE: a Disk Cleanup the user opens by hand is also called cleanmgr.exe, so an - unrelated process could both satisfy "activity was observed" for our run and, by - staying busy, keep our run waiting the full fifteen minutes after our own cleanup - had finished. It made the verdict depend on a process we know nothing about. - - So: our process and its descendants, and nothing else. Same protection against a - worker doing the deleting, no dependence on strangers. Children are matched by - parent PID regardless of name, since a worker need not be called cleanmgr.exe. - - Counters are summed as [long] rather than through Measure-Object, which returns a - Double and would silently lose integer precision past 2^53 (raised in review). - - $null when nothing can be read; the caller must treat that as "cannot tell". - - ACCEPTED LIMITS, stated rather than implied (raised in review). Process identity on - Windows is not exact: a process whose parent died and whose parent's PID was later - reused by our cleanmgr would be counted as family, and a worker started through a - service or COM rather than as a child would be missed. No process-tree scheme - avoids both. This is why the outcome is reported as observed idleness rather than - as proven completion, and why the registry sweep still refuses to touch a cleanmgr - that has not exited: every consumer of this signal is built to tolerate it being - wrong, which is the property that actually matters. - #> - param([int]$ProcessId) - - try { - $all = @(Get-CimInstance -ClassName Win32_Process ` - -Property ProcessId, ParentProcessId, KernelModeTime, UserModeTime, - ReadOperationCount, WriteOperationCount, OtherOperationCount ` - -ErrorAction Stop) - if (-not $all) { return $null } - - # Walk down from our PID. The seen-set both prevents rework and makes a cyclic - # parent chain (possible after PID reuse) terminate instead of spinning. - $byParent = @{} - foreach ($p in $all) { - $parent = [int]$p.ParentProcessId - if (-not $byParent.ContainsKey($parent)) { $byParent[$parent] = [System.Collections.Generic.List[object]]::new() } - $byParent[$parent].Add($p) - } - - $family = [System.Collections.Generic.List[object]]::new() - $seen = [System.Collections.Generic.HashSet[int]]::new() - $pending = [System.Collections.Generic.Queue[int]]::new() - $pending.Enqueue($ProcessId) - - while ($pending.Count -gt 0) { - $current = $pending.Dequeue() - if (-not $seen.Add($current)) { continue } - - $proc = $all | Where-Object { [int]$_.ProcessId -eq $current } | Select-Object -First 1 - if ($proc) { $family.Add($proc) } - - if ($byParent.ContainsKey($current)) { - foreach ($child in $byParent[$current]) { $pending.Enqueue([int]$child.ProcessId) } - } - } - - if ($family.Count -eq 0) { - # Our process is gone. The caller detects the exit on its own; "cannot tell" - # is the honest answer here, never "idle". - return $null - } - - [long]$kernel = 0; [long]$user = 0; [long]$read = 0; [long]$write = 0; [long]$other = 0 - foreach ($p in $family) { - $kernel += [long]$p.KernelModeTime - $user += [long]$p.UserModeTime - $read += [long]$p.ReadOperationCount - $write += [long]$p.WriteOperationCount - $other += [long]$p.OtherOperationCount - } - $pids = (($family | ForEach-Object { [int]$_.ProcessId } | Sort-Object) -join ',') - - return '{0}|{1}|{2}|{3}|{4}|{5}|{6}' -f $kernel, $user, $read, $write, $other, $family.Count, $pids - } catch { - return $null - } -} - -function Update-IdleStreak { - <# - .SYNOPSIS - Counts consecutive checks in which a process did nothing at all - .DESCRIPTION - v2.22, pure so the rule is testable without a process. Returns the new streak - length: one longer when the two fingerprints match, zero otherwise. - - An unreadable fingerprint on either side resets the streak. That is the whole - safety property: "I could not measure it" must never accumulate towards "it has - finished", or a machine with broken WMI would cut every Disk Cleanup short. - #> - param( - [AllowNull()][string]$Previous, - [AllowNull()][string]$Current, - [int]$Streak - ) - - if ([string]::IsNullOrEmpty($Previous) -or [string]::IsNullOrEmpty($Current)) { return 0 } - if ($Current -ceq $Previous) { return $Streak + 1 } - return 0 -} - -function Wait-CleanmgrCompletion { - <# - .SYNOPSIS - Waits for Disk Cleanup to finish its work, which is not the same as exiting - .DESCRIPTION - v2.22, split out in the style of Wait-StorageSenseTask so the wait can be tested - without a process and without waiting fifteen minutes: the caller injects how to - tell whether the process exited, how to read its activity, and how to wait. - - Two signals for "is there still something to wait for", because HasExited alone - was the wrong model of it. Note the distinction that matters throughout this - function: the MEASURED case below is described as what it was on that machine, - while what this code can PROVE in general is only the absence of observable - activity. The first is history, the second is the contract. - Measured on a live workstation: cleanmgr /sagerun did its work in about ten - seconds, closed its window, then stayed resident with CPU and all three I/O - counters frozen and every thread in Wait. The run sat out the remaining ~890 - seconds and then published the finished cleanup as partial. - - Returns Outcome ('exited' | 'idle-resident' | 'timeout') and Elapsed seconds. - 'idle-resident' names what was OBSERVED - the process was seen working and then - did nothing at all for long enough - not the conclusion drawn from it. That the - work is therefore over is an inference, and a process blocked on something - external would look identical, so nothing is built to depend on it being right. - 'timeout' means it was still visibly working when time ran out. - - 'idle-resident' additionally requires that activity was OBSERVED at least once - (raised in review). Without that, "it has finished" and "it has not started yet" - are the same observation - a process that is suspended, or waiting on something - before it begins, would be declared complete after two minutes of a stillness that - never followed any work. Since the first fingerprint is taken the moment cleanmgr - starts, a run that does anything at all changes it within the first interval. - #> - param( - [scriptblock]$HasExited, - [scriptblock]$GetFingerprint, - [int]$MaxWaitSeconds = 900, - [int]$CheckInterval = 10, - [int]$IdleChecksRequired = 12, - [scriptblock]$OnProgress = { param($seconds) }, - [scriptblock]$Wait = { param($seconds) Start-Sleep -Seconds $seconds } - ) - - $elapsed = 0 - $idleStreak = 0 - $sawActivity = $false - $fingerprint = & $GetFingerprint - - while (-not (& $HasExited) -and $elapsed -lt $MaxWaitSeconds) { - & $Wait $CheckInterval - $elapsed += $CheckInterval - - $previousFingerprint = $fingerprint - $fingerprint = & $GetFingerprint - $idleStreak = Update-IdleStreak -Previous $previousFingerprint -Current $fingerprint -Streak $idleStreak - - # A reset caused by two READABLE but different fingerprints is real work. A reset - # caused by an unreadable sample is not, and must not qualify the run for an early - # verdict later on. - if ($idleStreak -eq 0 -and $previousFingerprint -and $fingerprint) { $sawActivity = $true } - - if ($sawActivity -and $idleStreak -ge $IdleChecksRequired) { - return @{ Outcome = 'idle-resident'; Elapsed = $elapsed } - } - - if ($elapsed % 60 -eq 0) { & $OnProgress $elapsed } - } - - # Re-read rather than assume: the process may have exited during the last interval, - # and that is a cleaner answer than "timeout" for the same instant. - if (& $HasExited) { return @{ Outcome = 'exited'; Elapsed = $elapsed } } - return @{ Outcome = 'timeout'; Elapsed = $elapsed } -} - -function Select-StorageSenseTask { - <# - .SYNOPSIS - Picks the single Storage Sense task out of a lookup result - .DESCRIPTION - Pure decision, split out in v2.20 so the rule can be tested without a scheduler. - Windows ships exactly one task with this name; silently taking the first of - several means starting something nobody identified, so ambiguity yields no task - and says why. - Returns Task (the task or $null) and Reason ('ok' | 'none' | 'ambiguous'). - #> - param([object[]]$Tasks) - - $found = @($Tasks | Where-Object { $null -ne $_ }) - if ($found.Count -eq 0) { return @{ Task = $null; Reason = 'none' } } - if ($found.Count -gt 1) { return @{ Task = $null; Reason = 'ambiguous' } } - return @{ Task = $found[0]; Reason = 'ok' } -} - -function Test-StorageSenseEnabled { - <# - .SYNOPSIS - Whether Storage Sense is switched on in Settings - .DESCRIPTION - v2.22. Tri-state on purpose: $true / $false / $null for "cannot tell". Used ONLY - to explain what happened, never to decide whether Disk Cleanup runs - see the note - in Invoke-StorageSense for why that distinction matters. - - Verified on a live Windows 11 25H2 (build 26200): the value is still - HKCU\...\StorageSense\Parameters\StoragePolicy\01, and it was not renamed. - - HKCU is the user's hive, so a run under SYSTEM (a scheduled task) reads a - different one and gets nothing - which is exactly why the answer must be allowed - to be $null instead of defaulting to "off". - #> - try { - $policy = Get-ItemProperty -Path 'HKCU:\Software\Microsoft\Windows\CurrentVersion\StorageSense\Parameters\StoragePolicy' ` - -Name '01' -ErrorAction Stop - return [bool][int]$policy.'01' - } catch { - return $null - } -} - -function Get-StorageSenseVerdict { - <# - .SYNOPSIS - Decides whether a Storage Sense run counts as a cleanup that actually happened - .DESCRIPTION - Pure decision, split out in v2.20 so the rule can be tested. Two things must both - hold before Disk Cleanup is skipped: the task reported success, AND free space - actually grew. - A result of 0 on its own is not evidence. Storage Sense obeys its own settings; - switched off in Settings, the task still starts, does nothing it is not allowed to - do, and exits 0. Skipping all 23 cleanmgr handlers on that basis would free - nothing and report success - the defect this release exists to remove, and one - that only became reachable once this branch stopped being dead code. - $null TaskResult means "could not be read" and $null FreedBytes means "could not - be measured"; neither may be read as success. - Returns Done ($true only when cleanmgr may be skipped) and Reason - ('unreadable' | 'failed' | 'not-measured' | 'nothing-freed' | 'success'). - #> - param( - [AllowNull()][object]$TaskResult, - [AllowNull()][object]$FreedBytes - ) - - if ($null -eq $TaskResult) { return @{ Done = $false; Reason = 'unreadable' } } - - # [int] would THROW here, and precisely on the codes this function exists to catch. - # LastTaskResult is a UInt32 and every HRESULT failure has the high bit set, so its - # unsigned value exceeds Int32.MaxValue: the 0x80040154 cited above arrives as - # 2147746132. The exception escaped Invoke-StorageSense, Invoke-Phase recorded - # DeepSystemCleanup as failed, and Clear-WindowsOld never ran. - # The first test written for this passed the PowerShell literal 0x80040154, which the - # parser types as Int32 -2147221164 - so the cast succeeded and the test was green - # BECAUSE of the defect. Tests for this function must use the production type. - $resultValue = [long]0 - if (-not [long]::TryParse([string]$TaskResult, [ref]$resultValue)) { - return @{ Done = $false; Reason = 'unreadable' } - } - if ($resultValue -ne 0) { return @{ Done = $false; Reason = 'failed' } } - if ($null -eq $FreedBytes) { return @{ Done = $false; Reason = 'not-measured' } } - if ([long]$FreedBytes -le 0) { return @{ Done = $false; Reason = 'nothing-freed' } } - return @{ Done = $true; Reason = 'success' } -} - -function Wait-StorageSenseTask { - <# - .SYNOPSIS - Waits for the Storage Sense task to stop running - .DESCRIPTION - Split out in v2.20 so the wait can be tested without a scheduler and without - actually waiting two minutes: the caller injects how to read the task, how to - read its info, and how to wait. - - 'vanished' is a distinct outcome because the loop used to break on a task that - disappeared while leaving its finished flag false, so the caller announced "did - not finish within 120 seconds" after five - a number that never happened. - - Returns Outcome ('finished' | 'vanished' | 'timeout' | 'unverifiable'), Elapsed - seconds and the last Task seen. 'unverifiable' means the task was never observed - running AND its previous run time could not be read before the start, so there is - nothing to compare against - it is not a failure, but it is not evidence of a run - either, and the caller must not treat it as one. - #> - param( - [scriptblock]$GetTask, - [scriptblock]$GetTaskInfo, - [AllowNull()][object]$LastRunBefore, - [int]$TimeoutSeconds = 120, - [int]$CheckInterval = 5, - [scriptblock]$Wait = { param($seconds) Start-Sleep -Seconds $seconds } - ) - - $elapsed = 0 - $wasRunning = $false - $task = $null - - while ($elapsed -lt $TimeoutSeconds) { - & $Wait $CheckInterval - $elapsed += $CheckInterval - - # Task states are language-independent: Ready, Running, Disabled - $task = & $GetTask - if (-not $task) { - return @{ Outcome = 'vanished'; Elapsed = $elapsed; Task = $null } - } - - if ($task.State -eq 'Running') { - $wasRunning = $true - continue - } - - if ($wasRunning) { - # Was running and is not any more - return @{ Outcome = 'finished'; Elapsed = $elapsed; Task = $task } - } - - if ($elapsed -ge 10) { - # Never observed as Running - it may simply have been quicker than the poll - # interval. The task's own LastRunTime moving is the evidence. - # - # Raised in review: without a baseline there is no evidence to weigh, and the - # old disjunction let the MISSING baseline satisfy the test, because -not $null - # is true. Any readable task info then returned "finished" for a task that may - # never have started - a false success that goes on to skip all 23 cleanmgr - # handlers. - # - # Corrected again in the next review round: the first repair returned here at - # once, which gave a slow-starting task no chance to be seen and let cleanmgr - # start alongside it. Keep watching instead - being observed Running is direct - # evidence and needs no baseline - and only answer "unverifiable" if the whole - # window passes without ever seeing it. - if ($null -ne $LastRunBefore) { - $infoNow = & $GetTaskInfo $task - if ($infoNow -and $infoNow.LastRunTime -ne $LastRunBefore) { - return @{ Outcome = 'finished'; Elapsed = $elapsed; Task = $task } - } - } - } - } - - # Never seen running, and there was no previous run time to compare against: the window - # is over and nothing about this invocation was ever observed. That is not a timeout in - # the useful sense - there is simply no evidence either way, and the caller must not - # read it as one. - if (-not $wasRunning -and $null -eq $LastRunBefore) { - return @{ Outcome = 'unverifiable'; Elapsed = $elapsed; Task = $task } - } - - return @{ Outcome = 'timeout'; Elapsed = $elapsed; Task = $task } -} - -function Invoke-StorageSense { - <# - .SYNOPSIS - Runs Storage Sense cleanup - #> - Write-Log "Storage Sense" -Level SECTION - - # v2.20: the one step users asked to be able to switch off on its own. Until now the - # only way was -SkipCleanup, which also suppresses temp files, browsers, dev caches, - # Docker/WSL, Visual Studio and the driver store - everything, to avoid one step. - if ($ReportOnly) { - Write-Log "Would run: Storage Sense" -Level DETAIL - return - } - - # cleanmgr's saved selection lives under this sageset. Both are needed up front: the - # sweep below runs on every path that is allowed to change the system, not only when - # cleanmgr is reached. (-ReportOnly returns above because it promises to change - # nothing at all; -SkipDiskCleanup returns AFTER the sweep, see below.) - $sageset = 9999 - $regPath = "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\VolumeCaches" - - # Sweep leftovers from a previous run before doing anything else. The cleanmgr branch - # deliberately skips its own sweep while cleanmgr is still running (it would pull the - # configuration out from under it), and Storage Sense returns before that branch is - # reached - so a timed-out run followed only by successful Storage Sense runs would - # otherwise leave these flags in the registry forever. Caught in review before release. - # - # Gated on cleanmgr not running: a previous run's cleanmgr may still be working in the - # background, and sweeping now would pull its configuration out from under it - which - # is precisely what the finally below refuses to do (also raised in review). - if (-not (Get-Process -Name 'cleanmgr' -ErrorAction SilentlyContinue)) { - Get-ChildItem -Path $regPath -ErrorAction SilentlyContinue | ForEach-Object { - Remove-ItemProperty -Path $_.PSPath -Name "StateFlags$sageset" -Force -ErrorAction SilentlyContinue - } - } - - # After the sweep, deliberately (raised in review). The flag means "do not run this - # step", not "do not touch anything": returning before the sweep left a timed-out - # previous run's StateFlags in the registry forever, which is the exact case the sweep - # was added for. - if ($SkipDiskCleanup) { - Write-Log "Storage Sense / Disk Cleanup skipped (parameter)" -Level INFO - $script:Stats.DiskCleanupStatus = 'skipped-parameter' - return - } - - # Try Storage Sense first (Windows 11) - # - # v2.20: the task was looked up at "\Microsoft\Windows\DiskCleanup\", where it does not - # exist - that folder holds SilentCleanup. The real one lives under - # "\Microsoft\Windows\DiskFootprint\". So this branch was UNREACHABLE on every machine - # and every run fell through to the legacy cleanmgr path: 10 seconds on a fresh VM, - # 15 minutes on a real workstation (measured: 901s of a 1101s run, and it did not even - # finish). Searching by name instead of by a hardcoded path also survives the folder - # moving again. - $ssTaskName = "StorageSense" - $ssTasks = @(Get-ScheduledTask -TaskName $ssTaskName -ErrorAction SilentlyContinue) - # Windows ships exactly one. If a machine somehow has more, say so and take none: - # silently picking the first of several tasks with the same name means starting - # something nobody identified (raised in review). - $ssSelection = Select-StorageSenseTask -Tasks $ssTasks - - # Set by every branch that has already said why cleanmgr is being used, so the - # fallback below cannot contradict it. Raised in review: an ambiguous lookup logged - # both "2 tasks with that name - not guessing" and, two lines later, "task not found". - $ssExplained = $false - - if ($ssSelection.Reason -eq 'ambiguous') { - $ssExplained = $true - Write-Log "Storage Sense: $($ssTasks.Count) tasks with that name ($(($ssTasks | ForEach-Object { $_.TaskPath }) -join ', ')) - not guessing, using Disk Cleanup" -Level INFO - } - $task = $ssSelection.Task - - # v2.20, corrected in review: pin the exact task. Later lookups searched by name only - # and took the first hit, which quietly undid the refusal-to-guess rule above the - # moment a second same-named task appeared while we were waiting. - $ssTaskPath = if ($task) { $task.TaskPath } else { $null } - - # Raised in review, then measured: -TaskPath rejects $null with a binding error that - # -ErrorAction SilentlyContinue does NOT suppress, so a task object without a path - # would turn every later lookup into an exception - and the wait would read that as - # "the task vanished". The path is only passed when there is one. - $ssLookup = @{ TaskName = $ssTaskName; ErrorAction = 'SilentlyContinue' } - if ($ssTaskPath) { $ssLookup['TaskPath'] = $ssTaskPath } - - # Verified below; only a task that demonstrably ran successfully skips cleanmgr - $storageSenseDone = $false - - # A disabled task cannot be started - fall back to cleanmgr instead of - # waiting the full timeout for a task that never runs (v2.14) - if ($task -and $task.State -ne 'Disabled') { - Write-Log "Running Storage Sense..." -Level INFO - - # v2.20: compare against the task's OWN previous run time, not wall-clock. The old - # code compared LastRunTime with $startTime taken in this process, which is a - # different clock granularity and rounds the wrong way. - $infoBefore = $task | Get-ScheduledTaskInfo -ErrorAction SilentlyContinue - $lastRunBefore = if ($infoBefore) { $infoBefore.LastRunTime } else { $null } - - # Free space before the task, so its success can be judged on evidence rather than - # on an exit code (see the verification below) - $sysDriveLetter = ($env:SystemDrive).TrimEnd(':') - $freeBefore = $null - try { $freeBefore = (Get-PSDrive -Name $sysDriveLetter -ErrorAction Stop).Free } catch { $freeBefore = $null } - - $started = $false - try { - $task | Start-ScheduledTask -ErrorAction Stop - $started = $true - } catch { - # v2.20: the start used to be -ErrorAction SilentlyContinue with the result - # ignored, so a task that never started still cost the full 120s wait before - # anything was reported - Write-Log "Storage Sense could not be started ($($_.Exception.Message)) - using Disk Cleanup" -Level INFO - $ssExplained = $true - } - - if ($started) { - $timeout = 120 # 2 minutes max - $waitResult = Wait-StorageSenseTask ` - -GetTask { @(Get-ScheduledTask @ssLookup) | Select-Object -First 1 } ` - -GetTaskInfo { param($t) $t | Get-ScheduledTaskInfo -ErrorAction SilentlyContinue } ` - -LastRunBefore $lastRunBefore ` - -TimeoutSeconds $timeout ` - -CheckInterval 5 - # Only overwrite when the wait actually saw a task: on 'vanished' the original - # selection is kept, so the fallback below does not also announce "task not - # found" for something that was found and then disappeared. - if ($waitResult.Task) { $task = $waitResult.Task } - - if ($waitResult.Outcome -eq 'finished') { - # v2.20 fail-closed: a task that ran and FAILED used to be logged as - # "Storage Sense completed" and cleanmgr was skipped - a silent failure that - # would have left the machine uncleaned while the run reported success. - # Verified on a live machine where this task returns 0x80040154. - $infoAfter = $task | Get-ScheduledTaskInfo -ErrorAction SilentlyContinue - $taskResult = if ($infoAfter) { $infoAfter.LastTaskResult } else { $null } - - # $null means "could not be measured", which is not the same answer as - # "freed nothing" - the old code collapsed both into 0. - $freedBySense = $null - try { - $freeAfter = (Get-PSDrive -Name $sysDriveLetter -ErrorAction Stop).Free - if ($null -ne $freeBefore) { $freedBySense = $freeAfter - $freeBefore } - } catch { $freedBySense = $null } - - $verdict = Get-StorageSenseVerdict -TaskResult $taskResult -FreedBytes $freedBySense - $storageSenseDone = $verdict.Done - - switch ($verdict.Reason) { - 'success' { Write-Log "Storage Sense completed - $(Format-FileSize $freedBySense)" -Level SUCCESS } - 'unreadable' { Write-Log "Storage Sense ran but its result could not be read - using Disk Cleanup as well" -Level INFO } - 'failed' { Write-Log ("Storage Sense failed (task result 0x{0:X8}) - using Disk Cleanup instead" -f $taskResult) -Level INFO } - 'not-measured' { Write-Log "Storage Sense reported success but free space could not be measured - running Disk Cleanup as well" -Level INFO } - 'nothing-freed' { - # v2.22 (MyAI-1qtn): say WHICH of the two states this is. "Returned - # 0 and freed nothing" covers both "switched off, so it did nothing" - # and "switched on, ran, and there was nothing left to free" - and - # on a regularly maintained machine the second happens every time, - # which looked like a malfunction to the person reading the log. - # - # Reported, NOT acted on. The proposal was to trust an enabled - # Storage Sense and skip Disk Cleanup, and checking what that would - # cost settled it: the categories armed below include Update - # Cleanup, memory dumps, Language Pack, old ChkDsk files and Windows - # Error Reporting - none of which Storage Sense touches at all. A - # maintained machine would silently stop having them cleaned. - switch (Test-StorageSenseEnabled) { - $true { Write-Log "Storage Sense is enabled and ran, but there was nothing left for it to free - running Disk Cleanup as well for the categories it does not cover" -Level INFO } - $false { Write-Log "Storage Sense is switched off in Settings, so its run freed nothing - using Disk Cleanup instead" -Level INFO } - default { Write-Log "Storage Sense reported success but freed nothing measurable, and its setting could not be read - running Disk Cleanup as well" -Level INFO } - } - } - default { Write-Log "Storage Sense verdict '$($verdict.Reason)' not recognised - running Disk Cleanup as well" -Level INFO } - } - } elseif ($waitResult.Outcome -eq 'vanished') { - # v2.20, corrected in review: this path used to fall into the timeout - # message below, so a task that disappeared after five seconds was - # reported as having failed to finish within 120 - a number that never - # happened on that run. - Write-Log "Storage Sense task disappeared while being watched - using Disk Cleanup instead" -Level INFO - } elseif ($waitResult.Outcome -eq 'unverifiable') { - Write-Log "Storage Sense: its previous run time could not be read, so there is no way to tell whether this run happened - using Disk Cleanup instead" -Level INFO - } else { - # WARNING, not INFO (restored in review): falling back to cleanmgr is a - # fine outcome, but this particular one leaves a task we asked for and - # could not account for, and cleanmgr is about to start alongside it. The - # v2.20 draft downgraded this to INFO and dropped the counter, so a run - # where Storage Sense hung for two minutes printed COMPLETED SUCCESSFULLY - # in green. - Write-Log "Storage Sense did not finish within $timeout seconds - using Disk Cleanup instead" -Level WARNING - $script:Stats.WarningsCount++ - - $task = @(Get-ScheduledTask @ssLookup) | Select-Object -First 1 - if ($task -and $task.State -eq 'Running') { - # The stop used to be fire-and-forget with an unconditional "stopped" - # line after it (raised in review). Two cleaners deleting at once is - # exactly the state the free-space accounting cannot describe, so the - # claim is now made only when the task actually stopped. - $task | Stop-ScheduledTask -ErrorAction SilentlyContinue - $taskAfterStop = @(Get-ScheduledTask @ssLookup) | Select-Object -First 1 - if ($taskAfterStop -and $taskAfterStop.State -eq 'Running') { - Write-Log "Storage Sense task could not be stopped and is still running - Disk Cleanup will run alongside it, so the freed figures below cover both" -Level WARNING - $script:Stats.WarningsCount++ - } else { - Write-Log "Storage Sense task stopped" -Level INFO - } - } - } - - # Every branch above has stated its own reason - $ssExplained = $true - } - } - - if ($storageSenseDone) { - # Storage Sense demonstrably did the work, so cleanmgr is not run at all. Recorded - # so the JSON distinguishes this from "Disk Cleanup ran and completed" - they free - # different things, and a consumer comparing runs needs to know which one happened. - $script:Stats.DiskCleanupStatus = 'storage-sense' - } - - if (-not $storageSenseDone) { - # Fallback to cleanmgr. Every other reason for landing here (ambiguous lookup, - # start failed, task failed, timed out, vanished, unverifiable) has already said - # so in its own words and set $ssExplained, so only the two states that produce no - # message of their own are reported here. - if (-not $ssExplained) { - if (-not $task) { - Write-Log "Storage Sense task not found, using Disk Cleanup..." -Level INFO - } elseif ($task.State -eq 'Disabled') { - Write-Log "Storage Sense task is disabled, using Disk Cleanup..." -Level INFO - } - } - - # Configure cleanup categories ($sageset / $regPath are set at the top of the - # function, because the leftover sweep needs them on every path) - - # Note (v2.14): "Previous Installations" removed - Windows.old deletion must go - # through Clear-WindowsOld which asks for user confirmation. - # "Windows ESD installation files" removed - ESD files are needed for "Reset this PC". - # - # v2.16: list reconciled with the actual registry on Windows 11 25H2. - # Removed (no such handler exists, they were silently skipped by Test-Path): - # "Memory Dump Files", "Windows Error Reporting Archive Files", - # "Windows Error Reporting Queue Files" - # Added: "Windows Error Reporting Files" (the real handler name), - # "D3D Shader Cache", "Language Pack", "Windows Reset Log Files", - # "Feedback Hub Archive log files", "Diagnostic Data Viewer database files", - # "RetailDemo Offline Content". - # Deliberately NOT added, despite existing in the registry: - # "DownloadsFolder" - that is the user's Downloads folder - # "Device Driver Packages" - cleanmgr picks driver packages by its own closed - # heuristic, which would bypass the conservative rule - # in Clear-DriverStore (unused AND superseded) and is - # neither previewable nor measurable - # "Delivery Optimization Files" - handled by Clear-SystemCaches via the - # supported cmdlet, with measurable statistics - # "Windows Defender", "Content Indexer Cleaner", "Offline Pages Files" - # - security/search state, no meaningful gain - $categories = @( - "Active Setup Temp Folders", "BranchCache", "D3D Shader Cache", - "Diagnostic Data Viewer database files", - "Downloaded Program Files", "Feedback Hub Archive log files", - "Internet Cache Files", "Language Pack", "Old ChkDsk Files", - "Recycle Bin", "RetailDemo Offline Content", "Setup Log Files", - "System error memory dump files", "System error minidump files", - "Temporary Files", "Temporary Setup Files", "Thumbnail Cache", - "Update Cleanup", "Upgrade Discarded Files", "User file versions", - "Windows Error Reporting Files", "Windows Reset Log Files", - "Windows Upgrade Log Files" - ) - - # Defined before the try so the finally can always ask whether it exists - $cleanmgr = $null - try { - # Set StateFlags for cleanup categories. - # v2.16: count what was actually armed. Previously a failed write was - # swallowed, and cleanmgr would run with an empty category set, exit 0 and - # get logged as a success while doing nothing at all. - $armed = 0 - foreach ($category in $categories) { - $categoryPath = Join-Path $regPath $category - if (-not (Test-Path $categoryPath)) { - Write-Log "Disk Cleanup handler not present: $category" -Level DETAIL - continue - } - try { - Set-ItemProperty -Path $categoryPath -Name "StateFlags$sageset" -Value 2 -Type DWord -Force -ErrorAction Stop - $armed++ - } catch { - Write-Log "Could not arm Disk Cleanup handler '$category': $_" -Level WARNING - $script:Stats.WarningsCount++ - } - } - - if ($armed -eq 0) { - Write-Log "No Disk Cleanup handlers could be armed - skipping cleanmgr" -Level WARNING - $script:Stats.WarningsCount++ - $script:Stats.DiskCleanupStatus = 'failed' - return - } - - # Run cleanmgr with progress feedback and reasonable timeout. - # - # Raised in review, then measured: Start-Process on a missing or blocked - # executable leaves the variable $null, and $null.HasExited is also $null - so - # "-not $cleanmgr.HasExited" is TRUE and the loop below reported progress every - # minute for the full fifteen, then set DiskCleanupPending and warned that an - # elevated process was still deleting. All for a process that never started. - # Reachable on Server SKUs without Desktop Experience and under AppLocker/WDAC. - $cleanmgr = $null - try { - $cleanmgr = Start-Process -FilePath "cleanmgr.exe" -ArgumentList "/sagerun:$sageset" ` - -WindowStyle Hidden -PassThru -ErrorAction Stop - } catch { - $cleanmgr = $null - } - - if (-not $cleanmgr) { - Write-Log "Disk Cleanup could not be started (cleanmgr.exe is missing or blocked) - it cleaned nothing" -Level WARNING - $script:Stats.WarningsCount++ - $script:Stats.DiskCleanupStatus = 'failed' - return - } - - # v2.16: raised from 420s. cleanmgr regularly needs longer on a workstation - # with a large component store, and killing it produced a warning on every - # single run while cleanmgr kept working in the background anyway. - # - # v2.22: waiting on HasExited alone was the wrong model of "there is still - # something to wait for". Measured on a live workstation: cleanmgr /sagerun did - # its work in about ten seconds, closed its window and then simply stayed - # resident - CPU, all three I/O counters and its six threads frozen, every - # thread in Wait. The run sat here for the remaining ~890 seconds and then - # declared the cleanup partial. Both halves of that are wrong, and the second - # is the worse one: it is the same class of dishonest report v2.20 and v2.21 - # were spent removing, only inverted - not success that never happened, but - # incompleteness that was never observed either. - # - # So a second, independent signal to stop waiting: total stillness. If the - # process has done no CPU work and no I/O at all across $idleChecksRequired - # consecutive checks, there is nothing left to wait for that we can observe. - # That is weaker than "it has finished", and is reported as such. - $maxWait = 900 # 15 minutes - $checkInterval = 10 - # Two full minutes of absolute stillness. Deliberately far longer than needed - # to observe the measured case: a process mid-delete moves at least the "other - # operations" counter, so this is not a race with slow work - it is a margin - # against a pause nobody has observed yet. The cost of being wrong is bounded - # anyway: the registry sweep below still refuses to touch a process that has - # not exited, so a premature verdict cannot pull configuration out from under - # a cleanmgr that turns out to be working after all. - $idleChecksRequired = 12 - - $waitOutcome = Wait-CleanmgrCompletion ` - -HasExited { $cleanmgr.HasExited } ` - -GetFingerprint { Get-DiskCleanupActivityFingerprint -ProcessId $cleanmgr.Id } ` - -MaxWaitSeconds $maxWait -CheckInterval $checkInterval -IdleChecksRequired $idleChecksRequired ` - -OnProgress { param($seconds) Write-Log "Disk Cleanup still running... ($seconds seconds)" -Level INFO } - - $elapsed = $waitOutcome.Elapsed - $finishedWhileResident = $waitOutcome.Outcome -eq 'idle-resident' - - if ($cleanmgr.HasExited -and $cleanmgr.ExitCode -ne 0) { - # v2.16: the exit code used to be ignored entirely, so a crash one second - # in was still logged as a success - Write-Log "Disk Cleanup exited with code $($cleanmgr.ExitCode) - results unverified" -Level WARNING - $script:Stats.WarningsCount++ - $script:Stats.DiskCleanupStatus = 'failed' - } elseif ($cleanmgr.HasExited) { - Write-Log "Disk Cleanup completed ($armed categories)" -Level SUCCESS - $script:Stats.DiskCleanupStatus = 'completed' - } elseif ($finishedWhileResident) { - # Deliberately worded as an OBSERVATION, not a conclusion (raised in the - # third review pass). What was measured is that cleanmgr and its children - # did nothing at all for two minutes after having been seen working; that - # they FINISHED is the inference, and the earlier wording ("completed") - # stated the inference as fact. This project has spent three releases - # removing exactly that habit, so the status is 'idle-resident' and the log - # says what was seen. - # - # DiskCleanupPending stays false, which is the best available estimate: a - # process performing no CPU work and no I/O is not deleting anything. It is - # an estimate rather than a certainty, and that is why it is not called - # completion. Not a warning either - on the machine where this was measured - # it is the normal ending, and warning on every run would be noise. - Write-Log "Disk Cleanup ($armed categories): worked, then stopped doing anything for $($idleChecksRequired * $checkInterval) seconds" -Level SUCCESS - Write-Log "cleanmgr.exe is still in the process list but shows no CPU or I/O activity - treating its work as over and not waiting out the remaining $($maxWait - $elapsed) seconds" -Level DETAIL - $script:Stats.DiskCleanupStatus = 'idle-resident' - } else { - # Genuinely still working when the timeout expired. Killing it would be - # worse - cleanmgr keeps working after a kill and the deletion is - # mid-flight (v2.16). But this is not an informational event either: - # everything measured after this point is partial, the run is about to - # print a total and write its JSON while an elevated process is still - # deleting, and the freed bytes it goes on to reclaim are counted by nobody. - $script:Stats.DiskCleanupPending = $true - Write-Log "Disk Cleanup exceeded $maxWait seconds and is still running - it continues in the background, so the freed figures below are partial" -Level WARNING - $script:Stats.WarningsCount++ - $script:Stats.DiskCleanupStatus = 'timeout' - } - } finally { - # Remove StateFlags to avoid leaving traces in the registry. - # v2.16: sweep every handler, not just the ones from $categories - flags left - # by an interrupted run or by an older version of this list stayed forever - # (four such leftovers were found on a live machine). - # - # v2.20: but NOT while cleanmgr is still running. The timeout branch above - # deliberately leaves it working in the background, and this sweep then pulled - # its configuration out from under it. Whether cleanmgr re-reads the flags per - # handler or only once at startup is not something to guess at while it holds - # an elevated deletion loop. The flags are swept by the next run's own sweep, - # which is exactly the leftover case v2.16 added it for. - if ($cleanmgr -and -not $cleanmgr.HasExited) { - Write-Log "Disk Cleanup is still running - its registry configuration will be swept by the next run" -Level DETAIL - } else { - Get-ChildItem -Path $regPath -ErrorAction SilentlyContinue | ForEach-Object { - Remove-ItemProperty -Path $_.PSPath -Name "StateFlags$sageset" -Force -ErrorAction SilentlyContinue - } - } - } - } -} - -#endregion - -#region ═══════════════════════════════════════════════════════════════════════ -# MAIN EXECUTION -#═══════════════════════════════════════════════════════════════════════════════ - -function Show-Banner { - try { Clear-Host } catch { } - - # v2.22: composed instead of pasted. As one literal here-string its padding was - # hand-counted for a four-character version - the title row measured 70 columns only - # because "2.21" happens to be four characters, and a version like 2.5 or 2.100 would - # have pushed that row's right border out of line with the rest of the box. Composing - # it fixes that and lets the frame follow the console width like every other box. - $inner = $script:BoxWidth - - # Centred as a BLOCK, never line by line: the rows have different lengths by design - # and centring each one would shear the letters apart. 70 is the block's historical - # width, so at the default width the banner is drawn exactly where it always was. - $artBlockWidth = 70 - $art = @( - '' - ' ██████╗██╗ ███████╗ █████╗ ███╗ ██╗' - ' ██╔════╝██║ ██╔════╝██╔══██╗████╗ ██║' - ' ██║ ██║ █████╗ ███████║██╔██╗ ██║' - ' ██║ ██║ ██╔══╝ ██╔══██║██║╚██╗██║' - ' ╚██████╗███████╗███████╗██║ ██║██║ ╚████║' - ' ╚═════╝╚══════╝╚══════╝╚═╝ ╚═╝╚═╝ ╚═══╝' - '' - ) - $artPad = [math]::Max(0, [math]::Floor(($inner - $artBlockWidth) / 2)) - - $title = "Ultimate Windows 11 Maintenance Script v$($script:Version)" - $titlePad = [math]::Max(0, [math]::Floor(($inner - $title.Length) / 2)) - - $rows = foreach ($line in $art) { (' ' * $artPad) + $line.PadRight($artBlockWidth) } - $rows = @($rows) + @((' ' * $titlePad) + $title) + @('') - - $bannerLines = @(" ╔$('═' * $inner)╗") - foreach ($row in $rows) { - # Pad, then truncate: a row must fill the frame exactly. Anything longer would - # push the right border out, which is the defect this rewrite removes - so a row - # that cannot fit is cut rather than allowed to break the box. - $cell = $row.PadRight($inner) - if ($cell.Length -gt $inner) { $cell = $cell.Substring(0, $inner) } - $bannerLines += " ║$cell║" - } - $bannerLines += " ╚$('═' * $inner)╝" - - Write-Host "" - Write-Host ($bannerLines -join [Environment]::NewLine) -ForegroundColor Cyan - Write-Host "" - - # System info - $os = Get-CimInstance -ClassName Win32_OperatingSystem - $osVersion = $os.Caption - $osBuild = $os.BuildNumber - - Write-Host " System: $osVersion (Build $osBuild)" -ForegroundColor DarkGray - Write-Host " PowerShell: $($PSVersionTable.PSVersion)" -ForegroundColor DarkGray - Write-Host " Started: $(Get-Date -Format 'dd.MM.yyyy HH:mm:ss')" -ForegroundColor DarkGray - Write-Host " Log: $script:LogPath" -ForegroundColor DarkGray - - if ($ReportOnly) { - Write-Host "" - Write-Host " >>> REPORT MODE - No changes will be made <<<" -ForegroundColor Yellow - } - - Write-Host "" -} - -function Show-FinalStatistics { - <# - .DESCRIPTION - v2.17 (p.21 of the audit): this runs from Start-WinClean's finally block, so any - exception here (a Get-PSDrive provider returning 0 for both Used and Free, for - instance, dividing by zero below) would REPLACE whatever exception the try block - was already reporting - the one the user actually needs to see. The whole body - is wrapped so this function can never mask that. - #> - try { - Show-FinalStatisticsBody - } catch { - Write-Host "" - Write-Host " Could not display the final summary: $_" -ForegroundColor Yellow - Write-Host " Log: $script:LogPath" -ForegroundColor DarkGray - } -} - -function Show-FinalStatisticsBody { - $elapsed = (Get-Date) - $script:Stats.StartTime - $elapsedStr = "{0:D2}:{1:D2}:{2:D2}" -f [int]$elapsed.Hours, $elapsed.Minutes, $elapsed.Seconds - - # Get disk info - $drive = Get-PSDrive -Name $env:SystemDrive.Replace(':', '') - $freeSpace = [math]::Round($drive.Free / 1GB, 2) - $totalSize = [math]::Round(($drive.Used + $drive.Free) / 1GB, 2) - $capacity = $drive.Used + $drive.Free - $freePercent = if ($capacity -gt 0) { [math]::Round(($drive.Free / $capacity) * 100, 1) } else { 0 } - - Clear-AllProgress - - # Box dimensions - $boxWidth = $script:BoxWidth # Inner width (matches banner) - $labelWidth = 18 # Width for label column (e.g., "Space freed:") - - # Determine overall status - $hasErrors = $script:Stats.ErrorsCount -gt 0 - $hasWarnings = $script:Stats.WarningsCount -gt 0 - $statusText = if ($hasErrors) { "COMPLETED WITH ERRORS" } elseif ($hasWarnings) { "COMPLETED WITH WARNINGS" } else { "COMPLETED SUCCESSFULLY" } - $headerColor = if ($hasErrors) { "Red" } elseif ($hasWarnings) { "Yellow" } else { "Green" } - - Write-Host "" - - # Header with Cyan frame, status-colored text - $titlePadding = [math]::Max(0, $boxWidth - $statusText.Length) - $leftPad = [math]::Floor($titlePadding / 2) - $rightPad = $titlePadding - $leftPad - - Write-Host " ╔$("═" * $boxWidth)╗" -ForegroundColor Cyan - Write-Host " ║" -NoNewline -ForegroundColor Cyan - Write-Host (" " * $leftPad) -NoNewline - Write-Host $statusText -NoNewline -ForegroundColor $headerColor - Write-Host (" " * $rightPad) -NoNewline - Write-Host "║" -ForegroundColor Cyan - Write-Host " ╠$("═" * $boxWidth)╣" -ForegroundColor Cyan - - # Helper function for consistent line formatting with icons - function Write-StatLine { - param( - [string]$Icon, - [string]$Label, - [string]$Value, - [string]$IconColor = "Cyan", - [string]$ValueColor = "Green" - ) - # $labelWidth is inherited from parent scope (18) - # Layout: space(1) + icon(1) + space(1) + label(18) + gap(2) + value(47) = 70 - $valueWidth = $boxWidth - $labelWidth - 5 # 5 = icon(1) + spaces(2) + gap(2) - - $labelPadded = $Label.PadRight($labelWidth) - $valuePadded = $Value.PadRight($valueWidth) - - Write-Host " ║ " -NoNewline -ForegroundColor Cyan - Write-Host "$Icon " -NoNewline -ForegroundColor $IconColor - Write-Host "$labelPadded " -NoNewline -ForegroundColor White # 2 spaces after label - Write-Host $valuePadded -NoNewline -ForegroundColor $ValueColor - Write-Host "║" -ForegroundColor Cyan - } - - # Duration - Write-StatLine -Icon ">" -Label "Duration:" -Value $elapsedStr -IconColor "DarkGray" -ValueColor "White" - - # Updates. v2.19: Windows updates are genuinely installed (PSWindowsUpdate reports - # per-update results), but the app number is what winget OFFERED - it silently skips - # pinned/manifest-less/UAC-cancelled packages, so claiming it as "installed" overstated - # the result. Label each honestly. Value stays <= 47 chars so the box border aligns. - $winInstalled = $script:Stats.WindowsUpdatesCount - $appsOffered = $script:Stats.AppUpdatesOffered - if (($winInstalled + $appsOffered) -gt 0) { - $updatesStr = "Windows: $winInstalled installed, Apps: $appsOffered offered" - # ASCII "^" instead of "↑" (v2.17, p.20 of the audit): same ambiguous-width - # box-alignment issue as "⚠" below, just not caught the first time around - Write-StatLine -Icon "^" -Label "Updates:" -Value $updatesStr -IconColor "Green" -ValueColor "Green" - } - - # Space freed (highlight if significant) - $freedStr = Format-FileSize $script:Stats.TotalFreedBytes - $freedColor = if ($script:Stats.TotalFreedBytes -gt 1GB) { "Green" } elseif ($script:Stats.TotalFreedBytes -gt 100MB) { "Yellow" } else { "White" } - Write-StatLine -Icon ">" -Label "Space freed:" -Value $freedStr -IconColor $freedColor -ValueColor $freedColor - - # Freed by category (if any) - if ($script:Stats.FreedByCategory.Count -gt 0) { - Write-Host " ╟$("─" * $boxWidth)╢" -ForegroundColor Cyan - $sortedCats = @($script:Stats.FreedByCategory.GetEnumerator() | - Where-Object { $_.Value -gt 0 } | Sort-Object -Property Value -Descending) - foreach ($cat in ($sortedCats | Select-Object -First 5)) { - # Right-align category name so its colon lines up with the labels above - $catLabel = "$($cat.Key):".PadLeft($labelWidth) - $catValue = Format-FileSize $cat.Value - Write-StatLine -Icon " " -Label $catLabel -Value $catValue -ValueColor "DarkGray" - } - # v2.17: account for the remainder. With 12 possible categories the listed rows - # did not add up to "Space freed", which read as an arithmetic error. - if ($sortedCats.Count -gt 5) { - $rest = ($sortedCats | Select-Object -Skip 5 | Measure-Object -Property Value -Sum).Sum - $restLabel = "Other ($($sortedCats.Count - 5)):".PadLeft($labelWidth) - Write-StatLine -Icon " " -Label $restLabel -Value (Format-FileSize $rest) -ValueColor "DarkGray" - } - } - - Write-Host " ╠$("═" * $boxWidth)╣" -ForegroundColor Cyan - - # Disk space - $diskStr = "$freeSpace GB / $totalSize GB ($freePercent% free)" - $diskColor = if ($freePercent -lt 10) { "Red" } elseif ($freePercent -lt 20) { "Yellow" } else { "White" } - Write-StatLine -Icon ">" -Label "Disk space:" -Value $diskStr -IconColor $diskColor -ValueColor $diskColor - - # Warnings/Errors (if any) - if ($hasWarnings -or $hasErrors) { - $issueStr = "$($script:Stats.WarningsCount) warnings, $($script:Stats.ErrorsCount) errors" - # ASCII "!"/"X" instead of "⚠"/"✗": both are ambiguous-width in some - # terminals and break box alignment (v2.14 / v2.17 p.20 of the audit) - $issueIcon = if ($hasErrors) { "X" } else { "!" } - $issueColor = if ($hasErrors) { "Red" } else { "Yellow" } - Write-StatLine -Icon $issueIcon -Label "Issues:" -Value $issueStr -IconColor $issueColor -ValueColor $issueColor - } - - Write-Host " ╚$("═" * $boxWidth)╝" -ForegroundColor Cyan - - # Reboot notification - if ($script:Stats.RebootRequired) { - Write-Host "" - Write-Host " ! " -NoNewline -ForegroundColor Yellow - Write-Host "Reboot required to complete Windows updates!" -ForegroundColor Yellow - - if (Test-InteractiveConsole) { - Write-Host "" - Write-Host " Reboot now? (y/N): " -NoNewline -ForegroundColor Yellow - - $response = Read-Host - if ($response -match "^[YyДд]") { - Write-Host " Rebooting in 10 seconds... Press Ctrl+C to cancel" -ForegroundColor Yellow - Start-Sleep -Seconds 10 - Restart-Computer -Force - } else { - Write-Host " Remember to reboot later!" -ForegroundColor Yellow - } - } else { - Write-Host " Please reboot manually to complete updates." -ForegroundColor Yellow - } - } - - Write-Host "" - Write-Host " Log: $script:LogPath" -ForegroundColor DarkGray - Write-Host "" - - # Wait for keypress before closing (no timeout - window stays open) - if (Test-InteractiveConsole) { - Write-Host " Press any key to exit..." -ForegroundColor DarkGray - - # Clear keyboard buffer first - while ([Console]::KeyAvailable) { [Console]::ReadKey($true) | Out-Null } - - # Wait indefinitely for keypress - [Console]::ReadKey($true) | Out-Null - } else { - Write-Host " Non-interactive mode - exiting automatically." -ForegroundColor DarkGray - } -} - -function Write-ResultJson { - <# - .SYNOPSIS - Writes a machine-readable run summary (JSON) for automated testing/stands - #> - param([string]$Path) - - if (-not $Path) { return } - - try { - $elapsed = (Get-Date) - $script:Stats.StartTime - - $result = [ordered]@{ - Version = $script:Version - Timestamp = (Get-Date).ToString('o') - DurationSeconds = [math]::Round($elapsed.TotalSeconds, 1) - ReportOnly = [bool]$ReportOnly - Parameters = [ordered]@{ - SkipUpdates = [bool]$SkipUpdates - SkipCleanup = [bool]$SkipCleanup - SkipRestore = [bool]$SkipRestore - SkipDevCleanup = [bool]$SkipDevCleanup - SkipDockerCleanup = [bool]$SkipDockerCleanup - SkipVSCleanup = [bool]$SkipVSCleanup - SkipDiskCleanup = [bool]$SkipDiskCleanup - DisableTelemetry = [bool]$DisableTelemetry - } - TotalFreedBytes = [long]$script:Stats.TotalFreedBytes - FreedByCategory = @{} + $script:Stats.FreedByCategory - WindowsUpdatesCount = $script:Stats.WindowsUpdatesCount - # v2.19: renamed from AppUpdatesCount. This is the number of app updates winget - # OFFERED, not a confirmed install count (winget upgrade --all cannot report the - # latter). No shipped consumer reads this field; the nightly stand does not. - AppUpdatesOffered = $script:Stats.AppUpdatesOffered - # v2.21: distinguishes "checked, nothing to upgrade" from "could not check". - # Both are AppUpdatesOffered = 0, and demoting a missing winget to a warning - # removed the only other signal a consumer had (a non-zero exit code). - AppUpdatesStatus = $script:Stats.AppUpdatesStatus - WarningsCount = $script:Stats.WarningsCount - ErrorsCount = $script:Stats.ErrorsCount - RebootRequired = [bool]$script:Stats.RebootRequired - # v2.20: true when writing the log file failed at some point. The run still - # completed, but LogPath below points at an incomplete file - an automated - # consumer must not treat that log as the full record of what happened. - LoggingDegraded = [bool]$script:LogWriteFailed - # v2.20: true when Disk Cleanup outlived its timeout and was left running. - # TotalFreedBytes is then a lower bound, not the final figure. - DiskCleanupPending = [bool]$script:Stats.DiskCleanupPending - # v2.22: how that step ended, because the boolean above conflated two states. - # 'idle-resident' is the measured case: cleanmgr was seen working, then went - # completely still without exiting. 'timeout' is the genuine overrun the - # boolean was added for - still visibly working when time ran out. - DiskCleanupStatus = [string]$script:Stats.DiskCleanupStatus - # 'enabled' means cleanup figures are understated (Defender blocked some - # deletions without reporting an error); 'unknown' means the check itself - # failed, so the figures are unverified rather than confirmed good - ControlledFolderAccess = [string]$script:Stats.ControlledFolderAccess - # null for a normal run; a reason string when the run stopped early (v2.17) - Aborted = $script:Stats.Aborted - # Dispatch status of each top-level phase (v2.17 p.11; tri-state in v2.19). - # Completed = invoked and returned without an uncaught exception (NOT proof - # the work succeeded); Skipped = a skip flag suppressed it; Failed = it threw. - # For a non-aborted run the three are disjoint and their union is the full - # phase set, so a name missing from all three means the run stopped before it. - PhasesCompleted = @($script:Stats.PhasesCompleted) - PhasesFailed = @($script:Stats.PhasesFailed) - PhasesSkipped = @($script:Stats.PhasesSkipped) - LogPath = $script:LogPath - } - - $resultDir = Split-Path -Path $Path -Parent - if ($resultDir -and -not (Test-Path -LiteralPath $resultDir)) { - New-Item -ItemType Directory -Path $resultDir -Force -ErrorAction SilentlyContinue | Out-Null - } - - $result | ConvertTo-Json -Depth 4 | Out-File -FilePath $Path -Encoding utf8 - Write-Log "Result JSON written: $Path" -Level INFO - } catch { - # v2.20: this comment said "must be loud" while the code raised a warning, and the - # exit code is decided by ErrorsCount alone - so a run that failed to produce the - # artefact the user explicitly asked for still exited 0 and printed "completed with - # warnings". The stand then reads a stale file from the previous run as a fresh - # result. The user asked for this file: not producing it is a failure of the run. - Write-Log "Failed to write result JSON: $_" -Level ERROR - $script:Stats.ErrorsCount++ - } -} - -function Complete-WinCleanRun { - <# - .SYNOPSIS - The single end-of-run path: result JSON, final summary, log handle release - .DESCRIPTION - v2.22, raised in external review. Three paths ended a run - the normal finally, a - successful self-update, and a declined pending-reboot prompt - and only the first - released the log handle or showed a summary. The other two hand-copied whichever - parts someone had remembered at the time, which is exactly how v2.21 came to ship - two separate fixes to the same few lines (first a missing result JSON, then an - unconditional exit 0 over a run that had errors). The defect was never any one - omission; it was that the list existed in three places. Anything added here from - now on reaches every exit. - - Latched, so a path that completes the run explicitly and then unwinds through a - finally does not write the artefacts twice. - #> - param([string]$ResultPath) - - if ($script:RunCompleted) { return } - $script:RunCompleted = $true - - # JSON goes first: Show-FinalStatistics may block on a keypress in interactive - # mode, and automated runs must get the result regardless. - Write-ResultJson -Path $ResultPath - - # An aborted run has no maintenance to summarise, and the summary header would - # announce "COMPLETED SUCCESSFULLY" over a run that deliberately did nothing. - # Preserved behaviour: neither abort path ever showed it. - if (-not $script:Stats.Aborted) { - Show-FinalStatistics - } - - # Release the log file handle (v2.17, p.7): a stand or the user may want to move or - # zip the log right after the run finishes. - # v2.20: guarded. Dispose on a writer whose stream already failed throws, and this - # used to be the last statement of the outer finally - the exception escaped - # Start-WinClean and the entry point never reached its exit-code check, so a run with - # errors could still exit 0 (raised in review). - if ($script:LogWriter) { - try { $script:LogWriter.Dispose() } catch { } - $script:LogWriter = $null - $script:LogWriterPath = $null - } -} - -function Invoke-Phase { - <# - .SYNOPSIS - Runs one top-level phase of Start-WinClean with its own exception boundary - .DESCRIPTION - v2.17 (p.11 of the audit): the 9 phases used to share a single try/catch, so an - exception in phase 3 meant phases 4-9 never ran at all - silently, with only a - generic "Critical error" line to show for it. Each phase now gets its own - boundary and is recorded in $script:Stats.PhasesCompleted/PhasesFailed, which - Write-ResultJson exposes so an automated stand can tell "everything ran" from - "phase 6 threw and phases 7-9 are simply missing". - - v2.19: -Skip records a phase the user turned off with a skip flag in a third - bucket, PhasesSkipped, instead of running an empty body and marking it Completed - (which conflated "ran and did nothing" with "was skipped"). This also carries the - "... skipped (parameter)" log line that used to come from each phase's own inner - guard, now that the skip decision lives at the call site. These three buckets are - a DISPATCH status, not an outcome - see the $script:Stats comment. - #> - param( - [Parameter(Mandatory)][string]$Name, - [Parameter(Mandatory)][scriptblock]$Action, - [bool]$Skip = $false - ) - - if ($Skip) { - Write-Log "Phase '$Name' skipped (parameter)" -Level INFO - $script:Stats.PhasesSkipped += $Name - return - } - - try { - & $Action - $script:Stats.PhasesCompleted += $Name - } catch { - Write-Log "Phase '$Name' failed: $_" -Level ERROR - $script:Stats.ErrorsCount++ - $script:Stats.PhasesFailed += $Name - } -} - -function Start-WinClean { - # v2.17: remove a previous result file up front. An early exit used to leave the - # old JSON in place, and an automated stand would read last week's success as this - # run's outcome. - if ($ResultJsonPath -and (Test-Path -LiteralPath $ResultJsonPath)) { - Remove-Item -LiteralPath $ResultJsonPath -Force -ErrorAction SilentlyContinue - } - - # v2.19 reset the phase buckets and the step counter here; v2.20 makes the run - # genuinely fresh. The partial version left freed bytes, warning/error counts, - # Aborted and StartTime from a previous call in the same session, so the second - # run's summary and JSON described both runs at once. See New-RunStats. - $script:Stats = New-RunStats - $script:ProgressActivities = @() - $script:InternetConnectionCache = $null - $script:LogWriteFailed = $false - # v2.22: the latch on Complete-WinCleanRun, reset with everything else - a second - # call in the same session must produce its own artefacts, not silently skip them - # because the first run already completed. - $script:RunCompleted = $false - - # v2.22: ask for a wider window, then lay out against whatever the host actually - # gave us. In this order deliberately - measuring first would size every frame for - # the old width. Both steps are best-effort and silent: a host that refuses to - # resize, or whose width cannot be read, simply keeps the historical 70. - Expand-ConsoleWindow - $script:BoxWidth = Get-BoxWidth -ConsoleWidth (Get-ConsoleWidth) - - # Initialize log. v2.22 (raised in external review): these two lines were bare Out-File - # calls, and this runs BEFORE the main try/finally below. A log path that cannot be - # opened throws there - measured on six of seven bad paths - and the exception escaped - # Start-WinClean before any safety net existed: no result JSON, no summary, and none of - # the maintenance, because of the log. Now they degrade like every other log write. - Write-LogFileLine -Line "WinClean v$($script:Version) - Started at $(Get-Date)" -StartNewFile - Write-LogFileLine -Line ("=" * $script:BoxWidth) - - # v2.17 (p.13 of the audit): recover from a hard-killed previous run before doing - # anything else - not in ReportOnly, which promises no changes - if (-not $ReportOnly) { - Invoke-StaleMarkerRecovery - } - - # Enable TLS 1.2 for all HTTPS connections (required by PowerShell Gallery, NuGet, etc.) - # This must be set before any network operations - [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 - - # Calculate TotalSteps dynamically based on skip flags - $script:Stats.TotalSteps = 0 - if (-not $SkipUpdates) { $script:Stats.TotalSteps += 2 } # Windows Update + App Updates - # v2.19: -SkipCleanup now suppresses the whole cleanup group (system + deep + the - # developer/Docker/VS categories), matching the documented "skip all cleanup" / - # "Updates Only" contract. The per-category flags only subtract further inside it, - # so the progress denominator must nest them under -not $SkipCleanup too. - if (-not $SkipCleanup) { - $script:Stats.TotalSteps += 2 # System Cleanup + Deep Cleanup - if (-not $SkipDevCleanup) { $script:Stats.TotalSteps += 1 } # Developer Caches - if (-not $SkipDockerCleanup) { $script:Stats.TotalSteps += 1 } # Docker/WSL - if (-not $SkipVSCleanup) { $script:Stats.TotalSteps += 1 } # Visual Studio - } - # Ensure at least 1 step to avoid division by zero - if ($script:Stats.TotalSteps -eq 0) { $script:Stats.TotalSteps = 1 } - - Show-Banner - - # Check for pending reboot before starting - $pendingReboot = Test-PendingReboot - if ($pendingReboot.RebootRequired) { - Write-Host "" - Write-Host " " -NoNewline - Write-Host "WARNING: " -NoNewline -ForegroundColor Red - Write-Host "Pending reboot detected!" -ForegroundColor Yellow - Write-Host " Reasons: $($pendingReboot.Reasons -join ', ')" -ForegroundColor DarkYellow - Write-Host "" - Write-Host " It is recommended to reboot before running maintenance." -ForegroundColor Gray - - # Check if interactive console available (fixed in v2.1) - if (Test-InteractiveConsole) { - Write-Host " Continue anyway? (y/N): " -NoNewline -ForegroundColor Yellow - - $response = Read-Host - if ($response -notmatch "^[YyДд]") { - Write-Host "" - Write-Host " Operation cancelled. Please reboot and run again." -ForegroundColor Yellow - Write-Host "" - # Record the abort so automation does not mistake it for a completed run - # v2.22: through the shared end-of-run path. This branch used to write the - # JSON by hand and return, so it released no log handle - one of the three - # divergent endings that made the same fix necessary twice (external review). - $script:Stats.Aborted = 'PendingRebootDeclined' - Complete-WinCleanRun -ResultPath $ResultJsonPath - return - } - } else { - Write-Host " Non-interactive mode - continuing despite pending reboot." -ForegroundColor Yellow - } - Write-Host "" - } - - # Check for script updates. v2.17: gated by -SkipUpdates - the flag promises no - # update activity, and this path costs a PSGallery round trip on every run. - # - # Guarded (raised in review): this block runs BEFORE the main try/finally, so anything - # thrown here escaped Start-WinClean entirely - no result JSON, no phase buckets, no - # exit-code accounting - and killed the run before its first phase. The update check is - # optional; the maintenance it precedes is not, and must not be lost to it. - if (-not $SkipUpdates) { - try { - $updateInfo = Test-ScriptUpdate - if ($updateInfo) { - # v2.22: Invoke-ScriptUpdate reports whether the run is over instead of - # calling exit itself. A successful self-update replaced the running file, - # so continuing would perform maintenance with the old code still loaded - - # the run ends here, but it ends the same way every other run does. - if (Invoke-ScriptUpdate -UpdateInfo $updateInfo) { - # Artefacts first, then the pause. The prompt blocks until a key is - # pressed and a double-clicked shortcut may simply be closed there; - # writing the JSON afterwards would mean an abandoned window produced - # no result file at all (raised in the second review pass). - Complete-WinCleanRun -ResultPath $ResultJsonPath - Write-Host " Press any key to exit..." -ForegroundColor DarkGray - Wait-ForKeyPress - return - } - } - } catch { - Write-Log "Update check could not be completed: $_" -Level WARNING - $script:Stats.WarningsCount++ - } - } - - # Controlled Folder Access silently blocks deletions inside protected folders while - # every delete call still reports success, so cleanup looks fine in the log but frees - # nothing. Warn once up front instead of leaving the user with a misleading report (v2.16). - # Tri-state and always a string, so consumers (result JSON, stand assertions) get - # one stable type: 'enabled' / 'disabled' / 'unknown' - $script:Stats.ControlledFolderAccess = 'disabled' - try { - $mp = Get-MpPreference -ErrorAction Stop - if ($mp.EnableControlledFolderAccess -eq 1) { - $script:Stats.ControlledFolderAccess = 'enabled' - Write-Log "Controlled Folder Access is enabled - some deletions may be blocked silently" -Level WARNING - Write-Log "Add pwsh.exe to the allowed apps list, or cleanup results will be understated" -Level DETAIL - $script:Stats.WarningsCount++ - } - } catch { - # Defender cmdlets unavailable (third-party AV, stripped image, broken WMI). - # Record it as "unknown" rather than "disabled": reporting the latter would tell - # an automated stand the numbers are trustworthy when they were never checked. - $script:Stats.ControlledFolderAccess = 'unknown' - Write-Log "Could not query Controlled Folder Access state - cleanup figures are unverified" -Level DETAIL - } - - # v2.17 (p.11 of the audit): each phase now has its own exception boundary inside - # Invoke-Phase, so a bug in one no longer skips every phase after it. This outer - # try/finally is a second, coarser safety net - it guarantees the result JSON, the - # final summary and the log handle release happen even if something outside a - # phase (or a bug in Invoke-Phase itself) throws. - try { - # v2.19: the skip decision for each phase now lives here, at the call site, via - # -Skip. A skipped phase is recorded in PhasesSkipped (not run and marked - # Completed), and -SkipCleanup suppresses the ENTIRE cleanup group - system, deep, - # and the developer/Docker/VS categories - to match the documented "skip all - # cleanup" / "Updates Only" contract. The per-category flags stay as finer control - # inside that group. The inner functions keep their own guards for direct callers. - Invoke-Phase -Name 'Preparation' -Skip:$SkipRestore -Action { - $null = New-SystemRestorePoint -Description "WinClean $(Get-Date -Format 'yyyy-MM-dd HH:mm')" - } - - # Recorded here, not inside Update-Applications (raised in review): -SkipUpdates - # stops the phase from dispatching at all, so the branch that sets this inside the - # function is unreachable in production and the status stayed 'not-run'. - if ($SkipUpdates) { $script:Stats.AppUpdatesStatus = 'skipped-parameter' } - - Invoke-Phase -Name 'Updates' -Skip:$SkipUpdates -Action { - Update-WindowsSystem - Update-Applications - } - - Invoke-Phase -Name 'SystemCleanup' -Skip:$SkipCleanup -Action { - Write-Log "SYSTEM CLEANUP" -Level TITLE - Update-Progress -Activity "System Cleanup" -Status "Cleaning temporary files..." - - Clear-TempFiles - Clear-BrowserCaches - Clear-WindowsUpdateCache - Clear-WinCleanRecycleBin - Clear-SystemCaches - Clear-EventLogs - Clear-DNSCache - Clear-PrivacyTraces - } - - Invoke-Phase -Name 'DeveloperCleanup' -Skip:($SkipCleanup -or $SkipDevCleanup) -Action { Clear-DeveloperCaches } - - Invoke-Phase -Name 'DockerWSLCleanup' -Skip:($SkipCleanup -or $SkipDockerCleanup) -Action { Clear-DockerWSL } - - Invoke-Phase -Name 'VisualStudioCleanup' -Skip:($SkipCleanup -or $SkipVSCleanup) -Action { Clear-VisualStudio } - - Invoke-Phase -Name 'DeepSystemCleanup' -Skip:$SkipCleanup -Action { - Write-Log "DEEP SYSTEM CLEANUP" -Level TITLE - Update-Progress -Activity "Deep Cleanup" -Status "Running system cleanup..." - - # Driver store first (v2.17): removing packages leaves referenced - # components in WinSxS, and running DISM afterwards reclaims them in - # the same pass instead of a week later. - Clear-DriverStore - Clear-KernelDumps - # Neither of these reports what it freed, so measure the drive around them - Measure-FreeSpaceGain -Category 'ComponentStore' -Operation { Invoke-DISMCleanup } - # Raised in review: measuring around a step the user switched off credited it - # with whatever the drive happened to gain meanwhile - DISM releasing files a - # moment earlier is enough - and printed "DiskCleanup freed approximately - # 300.00 MB" for something that executed nothing. Invoke-StorageSense is still - # called so its registry leftovers get swept; it just is not measured. - if ($SkipDiskCleanup) { - Invoke-StorageSense - } else { - Measure-FreeSpaceGain -Category 'DiskCleanup' -Operation { Invoke-StorageSense } - } - Clear-WindowsOld - } - - # What is taking up space that cleanup deliberately leaves alone (v2.16). - # Skipped with -SkipCleanup (v2.17): it walks Windows\Installer and the search - # index, which is expensive and pointless for a user who asked for no cleanup. - Invoke-Phase -Name 'DiskSpaceReport' -Skip:$SkipCleanup -Action { - Show-DiskSpaceReport - } - - Invoke-Phase -Name 'Telemetry' -Action { - if ($DisableTelemetry) { - Set-WindowsTelemetry -Disable - } - } - } catch { - # Should not normally be reached - Invoke-Phase contains phase-level failures - - # but something outside any phase (or a bug in Invoke-Phase itself) still must - # not prevent the result JSON and summary below from being written. - Write-Log "Critical error outside any phase: $_" -Level ERROR - $script:Stats.ErrorsCount++ - } finally { - # v2.22: the whole end-of-run sequence now lives in one function, shared with the - # two abort paths that used to hand-copy parts of it. Ordering, the aborted-run - # rule and the guarded Dispose are documented there. - Complete-WinCleanRun -ResultPath $ResultJsonPath - } -} - -# Entry point -if ($MyInvocation.InvocationName -ne '.') { - Start-WinClean - # v2.17 (p.12 of the audit): the script used to always exit 0, even when the run - # logged errors. A scheduled task or stand cannot tell "clean run" from "ran into - # trouble" without parsing the log or the result JSON. - if ($script:Stats.ErrorsCount -gt 0) { - exit 1 - } -} - -#endregion From 38873692bf7bbc2e8e583ca93fd3b29be6a6153f Mon Sep 17 00:00:00 2001 From: Ivan Bondarev Date: Thu, 23 Jul 2026 13:41:45 +0300 Subject: [PATCH 09/11] =?UTF-8?q?fix:=20=D0=BF=D1=80=D0=B0=D0=B2=D0=BA?= =?UTF-8?q?=D0=B8=20=D0=BF=D0=BE=20=D1=87=D0=B5=D1=82=D1=8B=D1=80=D1=91?= =?UTF-8?q?=D0=BC=20=D0=B0=D0=B3=D0=B5=D0=BD=D1=82=D0=B0=D0=BC-=D1=80?= =?UTF-8?q?=D0=B5=D0=B2=D1=8C=D1=8E=D0=B5=D1=80=D0=B0=D0=BC=20(49=20=D0=BC?= =?UTF-8?q?=D1=83=D1=82=D0=B0=D1=86=D0=B8=D0=B9,=2021=20=D1=80=D0=B5=D0=B0?= =?UTF-8?q?=D0=BB=D1=8C=D0=BD=D1=8B=D0=B9=20=D0=B2=D1=8B=D0=B6=D0=B8=D0=B2?= =?UTF-8?q?=D1=88=D0=B8=D0=B9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Второй движок ревью нашёл то, чего не увидели пять раундов Codex - ровно как в v2.21. Главное: ФИЧА РЕЛИЗА НЕ ИМЕЛА ПОВЕДЕНЧЕСКОГО ПОКРЫТИЯ ВООБЩЕ. Код: - sawActivity открывался ИДЕНТИЧНОСТЬЮ, а не работой. В отпечаток входят число процессов и список PID (правильно - это изменение состояния и должно сбрасывать стрик), но появление дочернего процесса не является работой. cleanmgr, который породил помощника и заблокировался навсегда, проходил в ранний вердикт. Отпечаток разделён на части активности и идентичности; гейт смотрит первую; - DiskCleanupStatus оставался 'not-run' всё время работы cleanmgr (до 900 с). При Ctrl+C или исключении JSON сообщал "шаг не выполнялся", пока elevated-процесс удалял файлы. Введён 'running' сразу после старта; - 'failed' покрывал три разные истины -> 'not-armed' / 'start-failed' / 'exit-nonzero'. Только последняя означает частично очищенную машину; - -ReportOnly давал 'not-run', документированный как "шаг не выполнялся, например прогон был прерван". Это самый частый путь (смоук и два режима стенда) -> 'skipped-report-only'; - ветка timeout утверждала "всё ещё работает", хотя туда же попадают случаи, когда активность не измерялась вовсе. Та же ложь, что я убрал из соседней ветки; - finally обещал, что флаги реестра подчистит следующий прогон. Не подчистит: подчистка гейтится на отсутствие cleanmgr, а резидентный живёт до перезагрузки. Обещание убрано. Подчищать здесь СОЗНАТЕЛЬНО не стали: если вывод о неподвижности неверен, снятие флагов может оборвать живую очистку; - удалена мёртвая Get-ProcessActivityFingerprint (её тесты создавали иллюзию покрытия), исправлено ложное утверждение в docstring Get-ConsoleWidth. 🔴 ЗАМЕР ОПРОВЕРГ МОЁ УТВЕРЖДЕНИЕ: перенаправление вывода НЕ обнуляет ширину консоли - RawUI читает подключённую консоль, а не stdout. Смоук всё это время проверял ширину 90, а мой комментарий и CHANGELOG утверждали обратное. Тесты (ложно успокаивали, проверено мутациями): - DiskCleanupStatus: 7 присваиваний, дефолт и само поле в JSON можно было удалить - все тесты зелёные. Добавлено поведенческое покрытие; - страж "решение не зависит от настройки" охранял НЕ ТУ функцию: реальный реверс, вставленный в Invoke-StorageSense, выжил; - кейс report-only никогда не входил в свою ветку: локальная $ReportOnly=$false в теле It перекрывала $script: через динамический скоуп. Плюс утечка состояния; - адаптивную ширину можно было отключить целиком, а четырёх потребителей рамок вернуть к литералу 70 - геометрия проверяет боксы поштучно, не сравнивая между; - порог 12 проверок не был запиннен (все тесты передают его явно); - -StartNewFile не проверялся в месте вызова - та самая мутация, что уехала в коммит; - Should -Match 'Write-Host' ловил комментарий "Deliberately Write-Host". Тесты 679 -> 700. --- CHANGELOG.md | 2 +- CLAUDE.md | 12 ++-- CONTRIBUTING.md | 4 +- WinClean.ps1 | 111 ++++++++++++++++++------------- docs/result-json.md | 18 +++-- tests/Fixes.Tests.ps1 | 122 ++++++++++++++++++++++++++++++++-- tests/Helpers.Tests.ps1 | 110 ++++++++++++++++++++++++++---- tools/Invoke-ReleaseCheck.ps1 | 4 ++ 8 files changed, 303 insertions(+), 80 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1c05c20..f4de5d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -87,7 +87,7 @@ the installer accepted a PowerShell whose version it had failed to read. ### Tests -- 573 -> 668. Three mutation runs over the new logic (30 mutations); every one is caught. +- 573 -> 700. Four mutation runs over the new logic (79 mutations); every one is caught. Eight of them survived at first and each exposed a real gap rather than a wrong fix - among them that `Should -Invoke -Times N` means *at least* N and cannot see a duplicate, that the "unreadable activity" branch was never exercised, and that nothing pinned the diff --git a/CLAUDE.md b/CLAUDE.md index 4671cca..cdfc299 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -43,9 +43,9 @@ CleanScript/ │ └── logo.svg # Логотип проекта ├── get.ps1 # Bootstrap: разовый запуск одной командой (irm | iex) ├── install.ps1 # Bootstrap: установка/обновление + ярлык (RunAs admin) -├── tests/ # Pester тесты (679 всего; счётчик - прогоном, не грепом) -│ ├── Helpers.Tests.ps1 # Unit-тесты helper-функций (363, дот-сорсят продукт - нужны права админа) -│ ├── Fixes.Tests.ps1 # Валидационные тесты исправлений (198) +├── tests/ # Pester тесты (700 всего; счётчик - прогоном, не грепом) +│ ├── Helpers.Tests.ps1 # Unit-тесты helper-функций (377, дот-сорсят продукт - нужны права админа) +│ ├── Fixes.Tests.ps1 # Валидационные тесты исправлений (205) │ ├── Integration.Tests.ps1 # Интеграционные тесты в песочнице ФС (67, требуют admin) │ ├── StandHelpers.Tests.ps1 # Чистые хелперы стенда: dead-man + версионный гейт + таблица истинности report-гварда (23, без admin) │ └── Docs.Tests.ps1 # Гварды документации: нет тире + нет битых ссылок (28, без admin; файлы ПЕРЕЧИСЛЯЮТСЯ, а не задаются списком) @@ -198,7 +198,7 @@ Publish-PSResource -Path .\WinClean.ps1 -Repository PSGallery -ApiKey $env:PSGAL **Проверки (4 job'а, счёт сверять gh run view --json jobs, а не этой строкой):** 1. **lint** - PSScriptAnalyzer (Error+Warning) через общий ools/Invoke-Lint.ps1 - тот же, что зовёт релиз-гейт 2. **syntax** - Проверка синтаксиса PowerShell -3. **test** - Pester тесты (679; интеграционные требуют admin - на GitHub runners это выполняется) +3. **test** - Pester тесты (700; интеграционные требуют admin - на GitHub runners это выполняется) 4. **smoke** - прогон -ReportOnly + геометрия рамок + result JSON **Исключения PSScriptAnalyzer** (допустимые для CLI): @@ -208,7 +208,7 @@ Publish-PSResource -Path .\WinClean.ps1 -Repository PSGallery -ApiKey $env:PSGAL ### Pester тесты (v2.13+) -- `tests/Helpers.Tests.ps1` - 363 unit-тестов (дот-сорсят WinClean.ps1), `tests/Fixes.Tests.ps1` - 198, `tests/Integration.Tests.ps1` - 67 (песочница ФС, требуют admin), `tests/StandHelpers.Tests.ps1` - 23 (без admin), `tests/Docs.Tests.ps1` - 28 (гварды доков, без admin) +- `tests/Helpers.Tests.ps1` - 377 unit-тестов (дот-сорсят WinClean.ps1), `tests/Fixes.Tests.ps1` - 205, `tests/Integration.Tests.ps1` - 67 (песочница ФС, требуют admin), `tests/StandHelpers.Tests.ps1` - 23 (без admin), `tests/Docs.Tests.ps1` - 28 (гварды доков, без admin) - Особенности: функции в BeforeAll (не AST), regex для locale-независимости, отдельные It блоки --- @@ -434,7 +434,7 @@ pwsh tools/Invoke-ReleaseCheck.ps1 # версия во всех pwsh tools/Invoke-ReleaseCheck.ps1 -IncludeStand # + боевой прогон на VM (минуты) pwsh tools/Invoke-ReleaseCheck.ps1 -VerifyPublished # ПОСЛЕ выпуска: ассеты релиза и SHA256 -Invoke-Pester ./tests -Output Detailed # 679 Pester тестов (считать прогоном, не грепом) +Invoke-Pester ./tests -Output Detailed # 700 Pester тестов (считать прогоном, не грепом) pwsh tools/Invoke-SmokeTest.ps1 # Смоук: ReportOnly + геометрия UI pwsh tools/proxmox/Invoke-StandTest.ps1 -Mode Report # Стенд на Proxmox (RU=VM 190, EN: -ConfigPath ...en.json = VM 191) # Ночная матрица: cron 03:30 на proxmos (/opt/winclean-stand, /etc/cron.d/winclean-stand), отчёт в Telegram diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index cd5b4e9..ec92066 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -128,7 +128,7 @@ Invoke-Pester ./tests/Integration.Tests.ps1 All PRs automatically run: - PSScriptAnalyzer (linting) - Syntax check -- Pester tests (679 tests) +- Pester tests (700 tests) ### Release-impacting changes @@ -284,7 +284,7 @@ Invoke-Pester ./tests/Integration.Tests.ps1 Все PR автоматически проходят: - PSScriptAnalyzer (линтинг) - Проверка синтаксиса -- Pester тесты (679 тестов) +- Pester тесты (700 тестов) ### Изменения, влияющие на релиз diff --git a/WinClean.ps1 b/WinClean.ps1 index 0059b0f..0ec1d82 100644 --- a/WinClean.ps1 +++ b/WinClean.ps1 @@ -837,9 +837,14 @@ function Get-ConsoleWidth { .SYNOPSIS The console width in columns, or 0 when it cannot be determined .DESCRIPTION - v2.22. Redirected output, scheduled tasks and non-console hosts either throw here - or report nonsense, and 0 is the honest answer for all of them - the caller falls - back to the historical fixed width rather than laying out against a guess. + v2.22. Returns 0 when there is no console to measure - a scheduled task, a service, + or any host without one - and the caller then falls back to the historical fixed + width rather than laying out against a guess. + + Measured, because the obvious assumption is wrong: REDIRECTING output does NOT make + this return 0. $Host.UI.RawUI reads the attached console, not stdout, so + `pwsh -File script.ps1 *> out.txt` from a terminal still reports that terminal's + width. The smoke test therefore exercises the adaptive width, not the 70 fallback. #> try { $width = $Host.UI.RawUI.WindowSize.Width @@ -5328,39 +5333,6 @@ function Invoke-DISMCleanup { } } -function Get-ProcessActivityFingerprint { - <# - .SYNOPSIS - A comparable snapshot of how much work a process has actually done - .DESCRIPTION - v2.22. CPU time plus the three I/O operation counters, as one comparable string. - Two identical fingerprints taken far enough apart mean the process did literally - nothing in between. - - Win32_Process rather than System.Diagnostics.Process, established by measurement: - the .NET object exposes the processor times but leaves ReadOperationCount, - WriteOperationCount and OtherOperationCount empty, so a fingerprint built from it - would compare CPU alone. - - Returns $null when the counters cannot be read (WMI unavailable, the process gone, - access denied). The caller must treat $null as "cannot tell", never as "idle" - - otherwise a broken WMI would look exactly like a finished cleanup. - #> - param([int]$ProcessId) - - try { - $p = Get-CimInstance -ClassName Win32_Process -Filter "ProcessId=$ProcessId" ` - -Property KernelModeTime, UserModeTime, ReadOperationCount, - WriteOperationCount, OtherOperationCount ` - -ErrorAction Stop - if (-not $p) { return $null } - return '{0}|{1}|{2}|{3}|{4}' -f $p.KernelModeTime, $p.UserModeTime, - $p.ReadOperationCount, $p.WriteOperationCount, $p.OtherOperationCount - } catch { - return $null - } -} - function Get-DiskCleanupActivityFingerprint { <# .SYNOPSIS @@ -5449,7 +5421,14 @@ function Get-DiskCleanupActivityFingerprint { } $pids = (($family | ForEach-Object { [int]$_.ProcessId } | Sort-Object) -join ',') - return '{0}|{1}|{2}|{3}|{4}|{5}|{6}' -f $kernel, $user, $read, $write, $other, $family.Count, $pids + # Two segments separated by '#': the ACTIVITY part (counters) and the IDENTITY + # part (how many processes, and which). Raised in review: identity belongs in the + # fingerprint - a child appearing or exiting is a state change and must reset the + # idle streak - but it is NOT work. Letting it satisfy "activity was observed" + # would qualify a cleanmgr that merely spawned a helper and then blocked forever. + # The caller compares the whole string for stillness, the activity part alone for + # "did it ever do anything". + return '{0}|{1}|{2}|{3}|{4}#{5}|{6}' -f $kernel, $user, $read, $write, $other, $family.Count, $pids } catch { return $null } @@ -5534,10 +5513,16 @@ function Wait-CleanmgrCompletion { $fingerprint = & $GetFingerprint $idleStreak = Update-IdleStreak -Previous $previousFingerprint -Current $fingerprint -Streak $idleStreak - # A reset caused by two READABLE but different fingerprints is real work. A reset - # caused by an unreadable sample is not, and must not qualify the run for an early - # verdict later on. - if ($idleStreak -eq 0 -and $previousFingerprint -and $fingerprint) { $sawActivity = $true } + # Real work, and only real work, opens the gate (raised in review). Two readable + # fingerprints differing is not enough: they also differ when a child process + # appears or exits, which is a state change but not work. Compare the activity + # segment (before '#') alone, so a cleanmgr that spawns a helper and then blocks + # forever cannot qualify for the early verdict. An unreadable sample never counts. + if ($previousFingerprint -and $fingerprint) { + $previousWork = ($previousFingerprint -split '#')[0] + $currentWork = ($fingerprint -split '#')[0] + if ($currentWork -cne $previousWork) { $sawActivity = $true } + } if ($sawActivity -and $idleStreak -ge $IdleChecksRequired) { return @{ Outcome = 'idle-resident'; Elapsed = $elapsed } @@ -5738,6 +5723,11 @@ function Invoke-StorageSense { # Docker/WSL, Visual Studio and the driver store - everything, to avoid one step. if ($ReportOnly) { Write-Log "Would run: Storage Sense" -Level DETAIL + # Its own value, not the 'not-run' default (raised in review): a preview is the + # most common way to reach this function - every smoke run and two of the stand + # modes use it - and 'not-run' is documented as "the step never executed, for + # example the run aborted earlier", which describes something else entirely. + $script:Stats.DiskCleanupStatus = 'skipped-report-only' return } @@ -6026,7 +6016,9 @@ function Invoke-StorageSense { if ($armed -eq 0) { Write-Log "No Disk Cleanup handlers could be armed - skipping cleanmgr" -Level WARNING $script:Stats.WarningsCount++ - $script:Stats.DiskCleanupStatus = 'failed' + # Distinct from a cleanmgr that started and failed (raised in review): + # nothing was attempted here, so nothing is half-done on the machine. + $script:Stats.DiskCleanupStatus = 'not-armed' return } @@ -6049,10 +6041,18 @@ function Invoke-StorageSense { if (-not $cleanmgr) { Write-Log "Disk Cleanup could not be started (cleanmgr.exe is missing or blocked) - it cleaned nothing" -Level WARNING $script:Stats.WarningsCount++ - $script:Stats.DiskCleanupStatus = 'failed' + $script:Stats.DiskCleanupStatus = 'start-failed' return } + # Recorded BEFORE the wait (raised in review): the status was only assigned + # after Wait-CleanmgrCompletion returned, leaving a window of up to fifteen + # minutes in which the JSON still said 'not-run'. If the run was interrupted + # there - Ctrl+C, or a throw anywhere in this block - a consumer read "the step + # never executed" while an elevated cleanmgr was actively deleting. The wrong + # answer on an abnormal exit is now "started, and we cannot account for it". + $script:Stats.DiskCleanupStatus = 'running' + # v2.16: raised from 420s. cleanmgr regularly needs longer on a workstation # with a large component store, and killing it produced a warning on every # single run while cleanmgr kept working in the background anyway. @@ -6089,18 +6089,20 @@ function Invoke-StorageSense { -OnProgress { param($seconds) Write-Log "Disk Cleanup still running... ($seconds seconds)" -Level INFO } $elapsed = $waitOutcome.Elapsed - $finishedWhileResident = $waitOutcome.Outcome -eq 'idle-resident' + $wentIdleWhileResident = $waitOutcome.Outcome -eq 'idle-resident' if ($cleanmgr.HasExited -and $cleanmgr.ExitCode -ne 0) { # v2.16: the exit code used to be ignored entirely, so a crash one second # in was still logged as a success Write-Log "Disk Cleanup exited with code $($cleanmgr.ExitCode) - results unverified" -Level WARNING $script:Stats.WarningsCount++ - $script:Stats.DiskCleanupStatus = 'failed' + # Unlike the two above, this one RAN: the machine may be partially + # cleaned, which is a different thing for a consumer to know. + $script:Stats.DiskCleanupStatus = 'exit-nonzero' } elseif ($cleanmgr.HasExited) { Write-Log "Disk Cleanup completed ($armed categories)" -Level SUCCESS $script:Stats.DiskCleanupStatus = 'completed' - } elseif ($finishedWhileResident) { + } elseif ($wentIdleWhileResident) { # Deliberately worded as an OBSERVATION, not a conclusion (raised in the # third review pass). What was measured is that cleanmgr and its children # did nothing at all for two minutes after having been seen working; that @@ -6124,8 +6126,13 @@ function Invoke-StorageSense { # everything measured after this point is partial, the run is about to # print a total and write its JSON while an elevated process is still # deleting, and the freed bytes it goes on to reclaim are counted by nobody. + # Worded for what is actually known (raised in review): this branch is the + # fall-through for "neither signal fired", which also catches the cases + # where activity could NEVER be measured (broken WMI) or was never observed + # at all. Claiming it "is still working" would be the same inference stated + # as observation that the idle branch above was reworded to avoid. $script:Stats.DiskCleanupPending = $true - Write-Log "Disk Cleanup exceeded $maxWait seconds and is still running - it continues in the background, so the freed figures below are partial" -Level WARNING + Write-Log "Disk Cleanup did not finish within $maxWait seconds and was not observed to stop - it is left running, so the freed figures below may be incomplete" -Level WARNING $script:Stats.WarningsCount++ $script:Stats.DiskCleanupStatus = 'timeout' } @@ -6142,7 +6149,15 @@ function Invoke-StorageSense { # an elevated deletion loop. The flags are swept by the next run's own sweep, # which is exactly the leftover case v2.16 added it for. if ($cleanmgr -and -not $cleanmgr.HasExited) { - Write-Log "Disk Cleanup is still running - its registry configuration will be swept by the next run" -Level DETAIL + # No promise about WHEN (raised in review). The next run's sweep is gated on + # no cleanmgr being present, and an idle-resident one typically lives until + # logoff or reboot - so on exactly the machines where that ending is normal, + # the next run would skip the sweep too. Sweeping here instead was rejected: + # if the idleness inference is wrong, pulling the flags could truncate a live + # cleanup, and v2.20 deliberately refused to guess whether cleanmgr re-reads + # them. Leaving stale flags is the cheaper of the two errors, but it must not + # be described as a cleanup that is going to happen. + Write-Log "cleanmgr.exe is still present, so its registry flags are left in place - they are swept by the first run that starts with no cleanmgr running" -Level DETAIL } else { Get-ChildItem -Path $regPath -ErrorAction SilentlyContinue | ForEach-Object { Remove-ItemProperty -Path $_.PSPath -Name "StateFlags$sageset" -Force -ErrorAction SilentlyContinue diff --git a/docs/result-json.md b/docs/result-json.md index 41b8ebf..e32708a 100644 --- a/docs/result-json.md +++ b/docs/result-json.md @@ -14,7 +14,7 @@ This page documents every field, gives a full sample, and explains how to consum | Field | Type | Meaning | |-------|------|---------| -| `Version` | string | Script version that produced this file, e.g. `"2.19"`. | +| `Version` | string | Script version that produced this file, e.g. `"2.22"`. | | `Timestamp` | string | Run time in ISO-8601 round-trip format (`"o"`, e.g. `2026-07-21T03:15:42.1234567+00:00`). Use it to confirm the file belongs to the run you started, not a leftover. | | `DurationSeconds` | number | Wall-clock duration of the run, rounded to one decimal. | | `ReportOnly` | bool | `true` when the run was a preview (`-ReportOnly`): no cleanup or updates were applied (the log and this result file are still written). | @@ -29,7 +29,7 @@ This page documents every field, gives a full sample, and explains how to consum | `RebootRequired` | bool | `true` when a change (a Windows update, an app update finishing on reboot) needs a restart to take effect. | | `LoggingDegraded` | bool | v2.20. `true` when writing the log file failed at some point during the run. The run itself still completed, but `LogPath` points at an incomplete file: do not read that log as the full record of what happened. | | `DiskCleanupPending` | bool | v2.20. `true` when Disk Cleanup was still **visibly working** when its timeout expired and was left running in the background; `TotalFreedBytes` is then a lower bound. Note `false` is not a proof that nothing more will ever be deleted - see `DiskCleanupStatus`, which is the field to read when that distinction matters. | -| `DiskCleanupStatus` | string | v2.22. How the Storage Sense / Disk Cleanup step actually ended: `completed`, `idle-resident`, `timeout`, `storage-sense`, `failed`, `skipped-parameter`, or `not-run`. See below - the boolean above conflated a finished cleanup with a still-running one. | +| `DiskCleanupStatus` | string | v2.22. How the Storage Sense / Disk Cleanup step actually ended - eleven values, listed below. The boolean above could not tell a cleanup that had stopped doing anything from one still deleting, and reported both as pending. | | `ControlledFolderAccess` | string | Tri-state, see below. Reflects whether Defender's Controlled Folder Access may have silently blocked deletions. | | `Aborted` | string or null | `null` unless the run stopped early for a known reason: `"PendingRebootDeclined"` (the user declined to continue with a reboot pending) or `"UpdatedAndExited"` (v2.21 - the script updated itself and exited so the new version runs next time). When set, the phase arrays below are incomplete by design. Note `null` does not by itself prove every phase ran - see the invariant note below. | | `PhasesCompleted` | array of string | Phases whose action ran to completion without an uncaught exception. | @@ -99,11 +99,15 @@ than the conclusion. |-------|---------| | `completed` | cleanmgr ran and exited with code 0. | | `idle-resident` | cleanmgr was seen working, then did nothing at all for two minutes and never exited. Reported as what was **observed**, not as proven completion: a process performing no CPU work and no I/O is not deleting anything, so the figures are treated as final and `DiskCleanupPending` stays `false`. | -| `timeout` | cleanmgr was still genuinely working when the timeout expired. `DiskCleanupPending` is `true` and `TotalFreedBytes` is a lower bound. | -| `storage-sense` | Storage Sense demonstrably did the work, so cleanmgr was not run. Note this covers a different, smaller set of things than cleanmgr's handlers. | -| `failed` | cleanmgr could not be started, no handlers could be armed, or it exited non-zero. Nothing was verifiably cleaned by this step. | +| `timeout` | The wait expired without either signal firing. That includes the case where activity could never be measured at all (WMI unavailable) or was never observed, so it does not by itself mean cleanmgr was seen working. `DiskCleanupPending` is `true` and `TotalFreedBytes` is a lower bound. | +| `running` | Set as soon as cleanmgr starts, and replaced by one of the values above when the wait ends. Seeing it in a result file means the run was interrupted while an elevated cleanmgr was in flight - the totals are not final and the process may still be deleting. | +| `storage-sense` | Storage Sense demonstrably did the work, so cleanmgr was not run. Note this covers a different, smaller set of things than cleanmgr handlers - it does not touch Update Cleanup, memory dumps, Language Pack, old ChkDsk files or Windows Error Reporting. | +| `not-armed` | No Disk Cleanup handler could be armed, so cleanmgr was never started. Nothing was attempted and nothing is half-done. | +| `start-failed` | cleanmgr.exe could not be started at all (missing, or blocked by AppLocker/WDAC). Nothing was attempted. | +| `exit-nonzero` | cleanmgr started and exited with a non-zero code. Unlike the two above, it **ran**: the machine may be partially cleaned. | | `skipped-parameter` | `-SkipDiskCleanup` was passed. | -| `not-run` | The step never executed (for example the run aborted earlier). | +| `skipped-report-only` | `-ReportOnly` was passed. This is what every preview run, the smoke test and the `Report`/`ReportNoCleanup` stand modes produce. | +| `not-run` | The step never executed - the run aborted before reaching it. | `idle-resident` is not an error, and it is named after the observation rather than after the conclusion on purpose. What is measured is stillness; "it finished" is an inference @@ -144,7 +148,7 @@ VisualStudioCleanup, DeepSystemCleanup, DiskSpaceReport, Telemetry ```json { - "Version": "2.20", + "Version": "2.22", "Timestamp": "2026-07-21T03:15:42.1234567+00:00", "DurationSeconds": 196.4, "ReportOnly": false, diff --git a/tests/Fixes.Tests.ps1 b/tests/Fixes.Tests.ps1 index a5ae588..b1b5f3e 100644 --- a/tests/Fixes.Tests.ps1 +++ b/tests/Fixes.Tests.ps1 @@ -656,9 +656,16 @@ Describe "v2.16: Disk Cleanup timeout" -Tag "Fix", "V216" { # after this point is partial, and the run prints its total and writes its JSON # while an elevated process is still deleting. Silence made a partial result look # like a final one. + # v2.22 reworded the message and this test with it. The old text asserted cleanmgr + # "is still running", which the code cannot know: this branch is the fall-through + # for "neither signal fired", and broken WMI or activity that was never observed + # land here too. What must survive is the CONSEQUENCE - the figures may be + # incomplete - and the machine-readable flag that carries it. $body = Get-FunctionBody -Name 'Invoke-StorageSense' - $body | Should -Match 'still running - it continues in the background' + $body | Should -Match 'was not observed to stop' + $body | Should -Match 'may be incomplete' $body | Should -Match '\$script:Stats\.DiskCleanupPending = \$true' + $body | Should -Not -Match 'still running - it continues in the background' } It "cleanmgr is not killed on timeout" { @@ -1190,9 +1197,21 @@ Describe "v2.20: an operation that did nothing does not report success" -Tag "Fi # which Storage Sense touches. A maintained machine would silently stop having # them cleaned. The setting is therefore read for the log only, and this test # exists so that decision cannot be reversed by accident. + # The decision lives in Invoke-StorageSense ($storageSenseDone), NOT in + # Get-StorageSenseVerdict. Guarding only the verdict function was theatre: a + # reviewer inserted `if (Test-StorageSenseEnabled) { $storageSenseDone = $true }` + # into Invoke-StorageSense - exactly the reversal this test forbids - and all + # 679 tests stayed green. Guard the variable that actually decides. $verdict = Get-FunctionBody -Name 'Get-StorageSenseVerdict' $verdict | Should -Not -Match 'Test-StorageSenseEnabled' $verdict | Should -Not -Match 'StoragePolicy' + + $sense = Get-FunctionBody -Name 'Invoke-StorageSense' + $senseCode = ($sense -split "`n" | Where-Object { $_ -notmatch '^\s*#' }) -join "`n" + $assignments = @($senseCode -split "`n" | Where-Object { $_ -match '\$storageSenseDone\s*=' }) + $assignments.Count | Should -Be 2 -Because 'only the initialiser and the verdict may set it' + ($assignments -join "`n") | Should -Not -Match 'Test-StorageSenseEnabled' + ($assignments -join "`n") | Should -Match '\$verdict\.Done' } } @@ -1239,7 +1258,7 @@ Describe "v2.20: an operation that did nothing does not report success" -Tag "Fi # DiskCleanupPending means "an elevated process was still VISIBLY deleting, the figures # below are partial". The measured case is the opposite: nothing was happening. $residentBranch = [regex]::Match($script:senseCode, - '(?s)\}\s*elseif\s*\(\$finishedWhileResident\)\s*\{(.*?)\}\s*else\s*\{').Groups[1].Value + '(?s)\}\s*elseif\s*\(\$wentIdleWhileResident\)\s*\{(.*?)\}\s*else\s*\{').Groups[1].Value $residentBranch | Should -Not -BeNullOrEmpty -Because 'the branch must exist to be checked' $residentBranch | Should -Not -Match 'DiskCleanupPending' $residentBranch | Should -Match "DiskCleanupStatus = 'idle-resident'" @@ -1317,6 +1336,96 @@ Describe "v2.20: an operation that did nothing does not report success" -Tag "Fi $tail | Should -Not -Match 'Wait-ForKeyPress' } + It "The log header call site passes -StartNewFile" { + # A mutation removing this switch from the only production caller survived the + # whole suite AND reached a commit. The behavioural test proves the function + # honours the switch; nothing proved the caller sends it. Without it a fixed + # -LogPath accumulates every run into one file. + $body = Get-FunctionBody -Name 'Start-WinClean' + $code = ($body -split "`n" | Where-Object { $_ -notmatch '^\s*#' }) -join "`n" + $code | Should -Match 'Write-LogFileLine -Line "WinClean v[^"]*" -StartNewFile' + } + + It "The adaptive width is actually wired into the run" { + # Deleting both lines that compute the width survived the entire suite: + # Get-BoxWidth had ten unit tests and no caller assertion, so the feature could + # be disconnected while every test about it stayed green. + $body = Get-FunctionBody -Name 'Start-WinClean' + $code = ($body -split "`n" | Where-Object { $_ -notmatch '^\s*#' }) -join "`n" + $code | Should -Match 'Expand-ConsoleWindow' + $code | Should -Match '\$script:BoxWidth\s*=\s*Get-BoxWidth -ConsoleWidth \(Get-ConsoleWidth\)' + } + + It "Every frame consumer reads the shared width, none re-hardcodes 70" { + # Four consumers were individually revertible to the literal 70 without any + # test noticing, because the geometry checker validates each box internally + # and never compares widths BETWEEN boxes. + foreach ($fn in 'Write-Log', 'Invoke-ScriptUpdate', 'Show-FinalStatisticsBody', 'Show-Banner') { + $b = Get-FunctionBody -Name $fn + $c = ($b -split "`n" | Where-Object { $_ -notmatch '^\s*#' }) -join "`n" + $c | Should -Not -Match '\$boxWidth\s*=\s*70' -Because "$fn must not pin the width again" + $c | Should -Not -Match '"[-─═]"\s*\*\s*70' -Because "$fn must not pin a rule width again" + } + } + + It "The abort marker is set BEFORE the run is completed, on both abort paths" { + # Swapping the two lines survived: the result JSON would then record + # Aborted: null and the summary would print "COMPLETED SUCCESSFULLY" over a run + # that deliberately did nothing. Neither branch has a finally behind it - both + # return before the outer try - so the order at the call site is the only guard. + # Only PendingRebootDeclined is set at the call site; UpdatedAndExited is set + # inside Invoke-ScriptUpdate before it returns $true, so it is already in place + # by the time Start-WinClean completes the run. Both are checked, each where it + # actually lives - the first draft of this test looked for both in Start-WinClean + # and failed, which is the test doing its job. + $body = Get-FunctionBody -Name 'Start-WinClean' + $at = $body.IndexOf("Aborted = 'PendingRebootDeclined'") + $at | Should -BeGreaterThan 0 + $body.IndexOf('Complete-WinCleanRun', $at) | Should -BeGreaterThan $at + + $update = Get-FunctionBody -Name 'Invoke-ScriptUpdate' + $setAt = $update.IndexOf("Aborted = 'UpdatedAndExited'") + $retAt = $update.IndexOf('return $true') + $setAt | Should -BeGreaterThan 0 + $retAt | Should -BeGreaterThan $setAt -Because 'the caller must see the marker already set' + } + + It "The completion latch is reset for every run" { + # Deleting the reset survived: a second Start-WinClean in one session would + # then silently produce no result JSON, no summary and no handle release, + # because the latch from the first run is still set. + $body = Get-FunctionBody -Name 'Start-WinClean' + $code = ($body -split "`n" | Where-Object { $_ -notmatch '^\s*#' }) -join "`n" + $code | Should -Match '\$script:RunCompleted\s*=\s*\$false' + } + + It "The result JSON is written before anything that can block" { + # Complete-WinCleanRun documents "JSON goes first: Show-FinalStatistics may + # block on a keypress, and automated runs must get the result regardless". + # Swapping them survived - the three latch tests use -Exactly but never compare + # order. The equivalent ordering IS pinned for the self-update path; this makes + # the pair symmetrical. + # Comment-stripped: the docblock above the calls names Show-FinalStatistics + # while explaining why the JSON goes first, so a raw IndexOf found the prose + # and reported the wrong order. Third time this trap fired in this release. + $body = Get-FunctionBody -Name 'Complete-WinCleanRun' + $code = ($body -split "`n" | Where-Object { $_ -notmatch '^\s*#' }) -join "`n" + $jsonAt = $code.IndexOf('Write-ResultJson') + $statsAt = $code.IndexOf('Show-FinalStatistics') + $jsonAt | Should -BeGreaterThan 0 + $statsAt | Should -BeGreaterThan $jsonAt + } + + It "The idle threshold used in production is the documented two minutes" { + # Every Wait-CleanmgrCompletion test passes -IdleChecksRequired explicitly, so + # the production value was invisible to them: setting it to 1 survived, which + # would declare a live cleanup finished after a single quiet check. + $body = Get-FunctionBody -Name 'Invoke-StorageSense' + $code = ($body -split "`n" | Where-Object { $_ -notmatch '^\s*#' }) -join "`n" + $code | Should -Match '\$idleChecksRequired = 12' + $code | Should -Match '\$checkInterval = 10' + } + It "Start-WinClean acts on the answer instead of ignoring it" { # A caller that dropped the `if` would carry on running maintenance with the # script file already replaced underneath it. @@ -1331,8 +1440,13 @@ Describe "v2.20: an operation that did nothing does not report success" -Tag "Fi # written before Start-WinClean's try/finally exists - goes through it too. $body = Get-FunctionBody -Name 'Write-LogFileLine' $body | Should -Match '\$script:LogWriteFailed' - # It must not report its own failure through Write-Log, which would recurse - $body | Should -Match 'Write-Host' + # Comment-stripped and matched on the MESSAGE, not the cmdlet name (raised in + # review, proved by mutation): the catch block contains the comment + # "Deliberately Write-Host and not Write-Log", so a bare 'Write-Host' match + # survived deleting the actual warning call - the run's only visible sign that + # logging failed could vanish with this test still green. + $code = ($body -split "`n" | Where-Object { $_ -notmatch '^\s*#' }) -join "`n" + $code | Should -Match 'Write-Host\s+"\s*\[WARN\]\s+Log file could not be written' } It "Write-Log still routes file writes through the one degrading primitive" { diff --git a/tests/Helpers.Tests.ps1 b/tests/Helpers.Tests.ps1 index 81ede59..0e9547c 100644 --- a/tests/Helpers.Tests.ps1 +++ b/tests/Helpers.Tests.ps1 @@ -2443,6 +2443,68 @@ Describe "Test-StorageSenseEnabled" -Tag "Unit", "Helper", "V222" { #endregion +#region v2.22 DiskCleanupStatus reaches the result JSON + +Describe "DiskCleanupStatus is actually produced and published" -Tag "Unit", "Helper", "V222" { + # Added after a mutation run: the headline field of this release had NO behavioural + # coverage at all. Deleting five of its seven assignments, changing the default, or + # removing the field from the result JSON entirely all left 679 tests green. Only two + # comment-stripped greps existed, and a grep cannot see a value that is never set. + + BeforeEach { + $script:Stats = New-RunStats + $script:resultFile = Join-Path ([System.IO.Path]::GetTempPath()) "WinCleanStatus_$(Get-Random).json" + } + + AfterEach { + Remove-Item -LiteralPath $script:resultFile -Force -ErrorAction SilentlyContinue + } + + It "starts as 'not-run' so an aborted run cannot look like a completed cleanup" { + (New-RunStats).DiskCleanupStatus | Should -Be 'not-run' + } + + It "is written to the result JSON, with the value the run actually reached" { + # The mutation that deleted the field from Write-ResultJson survived; this is the + # assertion that kills it. A consumer reading the documented schema must find it. + $script:Stats.DiskCleanupStatus = 'idle-resident' + + Write-ResultJson -Path $script:resultFile + + $json = Get-Content -LiteralPath $script:resultFile -Raw | ConvertFrom-Json + $json.PSObject.Properties.Name | Should -Contain 'DiskCleanupStatus' + $json.DiskCleanupStatus | Should -Be 'idle-resident' + } + + It "survives the JSON round trip as a string for every documented value - " -ForEach @( + @{ Value = 'not-run' }, @{ Value = 'skipped-parameter' }, @{ Value = 'skipped-report-only' } + @{ Value = 'storage-sense' }, @{ Value = 'running' }, @{ Value = 'completed' } + @{ Value = 'idle-resident' }, @{ Value = 'timeout' } + @{ Value = 'not-armed' }, @{ Value = 'start-failed' }, @{ Value = 'exit-nonzero' } + ) { + # Pins the vocabulary itself: docs/result-json.md documents exactly these, and a + # value that only exists in the code is a contract nobody can consume. + $script:Stats.DiskCleanupStatus = $Value + Write-ResultJson -Path $script:resultFile + (Get-Content -LiteralPath $script:resultFile -Raw | ConvertFrom-Json).DiskCleanupStatus | Should -Be $Value + } + + It "records the preview as its own value, not as 'not-run'" { + # -ReportOnly is the most common way into this function: the smoke test and two + # stand modes all use it. Reporting the documented "the step never executed (for + # example the run aborted earlier)" for a healthy preview is simply untrue. + # Safe to call for real: the ReportOnly branch returns before touching anything. + $ReportOnly = $true + Mock Write-Log { } + + Invoke-StorageSense + + $script:Stats.DiskCleanupStatus | Should -Be 'skipped-report-only' + } +} + +#endregion + #region v2.22 adaptive console width Describe "Get-BoxWidth" -Tag "Unit", "Helper", "V222" { @@ -2683,7 +2745,7 @@ Describe "Update-IdleStreak" -Tag "Unit", "Helper", "V222" { @{ Case = 'previous empty'; Prev = ''; Curr = 'a' } @{ Case = 'current empty'; Prev = 'a'; Curr = '' } ) { - # The safety property. Get-ProcessActivityFingerprint returns $null when WMI cannot + # The safety property. Get-DiskCleanupActivityFingerprint returns $null when WMI cannot # answer, and if that accumulated towards the threshold a machine with broken WMI # would cut every Disk Cleanup short after two minutes and call it complete. Update-IdleStreak -Previous $Prev -Current $Curr -Streak 11 | @@ -2697,24 +2759,26 @@ Describe "Update-IdleStreak" -Tag "Unit", "Helper", "V222" { } } -Describe "Get-ProcessActivityFingerprint" -Tag "Unit", "Helper", "V222" { +Describe "Get-DiskCleanupActivityFingerprint" -Tag "Unit", "Helper", "V222" { It "returns a comparable fingerprint for a live process" { - $fp = Get-ProcessActivityFingerprint -ProcessId $PID + $fp = Get-DiskCleanupActivityFingerprint -ProcessId $PID $fp | Should -Not -BeNullOrEmpty # CPU kernel + CPU user + three I/O counters - ($fp -split '\|').Count | Should -Be 5 + # Five counters, then '#', then the identity segment (process count, PID list) + ($fp -split '[|#]').Count | Should -Be 7 + $fp | Should -Match '#' -Because 'the activity and identity parts must stay separable' } It "changes after the process does measurable work" { # Proves the fingerprint actually tracks activity. If it were built from cached or # constant values, everything would look idle and every cleanmgr would be cut short # at two minutes - the failure mode that matters most here. - $before = Get-ProcessActivityFingerprint -ProcessId $PID + $before = Get-DiskCleanupActivityFingerprint -ProcessId $PID $sink = 0 1..200000 | ForEach-Object { $sink += $_ } $null = Get-ChildItem -Path ([System.IO.Path]::GetTempPath()) -ErrorAction SilentlyContinue | Select-Object -First 50 - $after = Get-ProcessActivityFingerprint -ProcessId $PID + $after = Get-DiskCleanupActivityFingerprint -ProcessId $PID $after | Should -Not -Be $before } @@ -2722,7 +2786,7 @@ Describe "Get-ProcessActivityFingerprint" -Tag "Unit", "Helper", "V222" { It "returns null for a process id that does not exist, rather than throwing" { # cleanmgr can exit between two checks; the caller must get "cannot tell", and # Update-IdleStreak turns that into a reset rather than a false completion. - Get-ProcessActivityFingerprint -ProcessId 999999 | Should -BeNullOrEmpty + Get-DiskCleanupActivityFingerprint -ProcessId 999999 | Should -BeNullOrEmpty } It "the Disk Cleanup fingerprint covers our own process tree, not every cleanmgr" { @@ -2743,7 +2807,7 @@ Describe "Get-ProcessActivityFingerprint" -Tag "Unit", "Helper", "V222" { $fp = Get-DiskCleanupActivityFingerprint -ProcessId 100 # our process + its child: kernel 10+20, and the PID list names exactly those two - $fp | Should -Be '30|3|3|3|3|2|100,200' + $fp | Should -Be '30|3|3|3|3#2|100,200' $fp | Should -Not -Match '900' -Because 'an unrelated cleanmgr must not influence our verdict' } @@ -2762,7 +2826,7 @@ Describe "Get-ProcessActivityFingerprint" -Tag "Unit", "Helper", "V222" { ) } $fp = Get-DiskCleanupActivityFingerprint -ProcessId 100 - $fp | Should -Be '2|0|0|0|0|2|100,200' + $fp | Should -Be '2|0|0|0|0#2|100,200' } It "sums counters as integers, without the precision loss of a Double" { @@ -2784,8 +2848,8 @@ Describe "Get-ProcessActivityFingerprint" -Tag "Unit", "Helper", "V222" { # every Disk Cleanup short and call it finished. Mock Get-CimInstance { throw 'WMI is unavailable' } - $first = Get-ProcessActivityFingerprint -ProcessId $PID - $second = Get-ProcessActivityFingerprint -ProcessId $PID + $first = Get-DiskCleanupActivityFingerprint -ProcessId $PID + $second = Get-DiskCleanupActivityFingerprint -ProcessId $PID $first | Should -BeNullOrEmpty Update-IdleStreak -Previous $first -Current $second -Streak 11 | @@ -3029,8 +3093,30 @@ Describe "Invoke-ScriptUpdate stop/continue contract" -Tag "Unit", "Helper", "V2 Should -Invoke Write-ResultJson -Times 0 } + It "returns false in a preview, without ever attempting an update" { + # Rewritten (raised in review): this case used to live in the -ForEach below, whose + # body sets a LOCAL $ReportOnly = $false before running each Setup. PowerShell + # resolves variables through the dynamic scope chain, so the product saw that local + # and not the $script: value the Setup wrote - the case never entered the ReportOnly + # branch at all and merely duplicated 'non-interactive'. Proved by mutation: making + # the ReportOnly branch return $true survived this Describe entirely. + # The local assignment here is what the function actually reads. + $ReportOnly = $true + Mock Test-InteractiveConsole { $true } + Mock Read-Host { 'y' } + Mock Update-Script { } + Mock Update-PSResource { } + + $stop = Invoke-ScriptUpdate -UpdateInfo @{ CurrentVersion = '2.20'; LatestVersion = '2.21' + Channel = 'gallery'; Provider = 'PowerShellGet' } + + $stop | Should -BeFalse + Should -Invoke Update-Script -Exactly -Times 0 -Because 'a preview must change nothing' + Should -Invoke Update-PSResource -Exactly -Times 0 + $script:Stats.Aborted | Should -BeNullOrEmpty + } + It "returns false so the run continues - " -ForEach @( - @{ Case = 'report-only'; Setup = { $script:ReportOnly = $true } } @{ Case = 'non-interactive'; Setup = { Mock Test-InteractiveConsole { $false } } } @{ Case = 'user declines'; Setup = { Mock Test-InteractiveConsole { $true }; Mock Read-Host { 'n' } } } @{ Case = 'update command failed'; Setup = { diff --git a/tools/Invoke-ReleaseCheck.ps1 b/tools/Invoke-ReleaseCheck.ps1 index dbe585f..727b217 100644 --- a/tools/Invoke-ReleaseCheck.ps1 +++ b/tools/Invoke-ReleaseCheck.ps1 @@ -186,9 +186,13 @@ if ($pester) { elseif ($failedContainers) { "$failedContainers тест-файл(ов) не загрузились - их тесты не выполнялись" } else { '' }) + # v2.22: CHANGELOG.md added (raised in review). It was the one document a user reads + # and quotes, it states the count in the form "A -> B", and nothing verified it - so it + # shipped stale while the two files that WERE checked had already been corrected. $countClaims = @( @{ File = 'CLAUDE.md'; Pattern = "$pesterCount Pester" } @{ File = 'CONTRIBUTING.md'; Pattern = "$pesterCount tests" } + @{ File = 'CHANGELOG.md'; Pattern = "-> $pesterCount\." } ) $wrongCounts = foreach ($claim in $countClaims) { $full = Join-Path $repoRoot $claim.File From 50b566465fd28d7471b09a5548fe4db0e5928363 Mon Sep 17 00:00:00 2001 From: Ivan Bondarev Date: Thu, 23 Jul 2026 13:44:23 +0300 Subject: [PATCH 10/11] =?UTF-8?q?fix:=20=D0=BF=D0=B0=D1=82=D1=82=D0=B5?= =?UTF-8?q?=D1=80=D0=BD=20=D1=81=D1=87=D1=91=D1=82=D1=87=D0=B8=D0=BA=D0=B0?= =?UTF-8?q?=20=D0=B2=20CHANGELOG=20-=20=D0=BB=D0=B8=D1=82=D0=B5=D1=80?= =?UTF-8?q?=D0=B0=D0=BB,=20=D0=B0=20=D0=BD=D0=B5=20=D1=80=D0=B5=D0=B3?= =?UTF-8?q?=D0=B5=D0=BA=D1=81=D0=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Матчер прогоняет каждый паттерн через [regex]::Escape, поэтому написанный руками \. искался как обратный слеш. Новая проверка молча не срабатывала бы - ровно тот класс, ради которого её и добавляли. --- tools/Invoke-ReleaseCheck.ps1 | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tools/Invoke-ReleaseCheck.ps1 b/tools/Invoke-ReleaseCheck.ps1 index 727b217..ae853f2 100644 --- a/tools/Invoke-ReleaseCheck.ps1 +++ b/tools/Invoke-ReleaseCheck.ps1 @@ -192,7 +192,9 @@ if ($pester) { $countClaims = @( @{ File = 'CLAUDE.md'; Pattern = "$pesterCount Pester" } @{ File = 'CONTRIBUTING.md'; Pattern = "$pesterCount tests" } - @{ File = 'CHANGELOG.md'; Pattern = "-> $pesterCount\." } + # Literal, not a regex: the matcher below runs every pattern through + # [regex]::Escape, so a hand-written "\." would be searched for as a backslash. + @{ File = 'CHANGELOG.md'; Pattern = "-> $pesterCount." } ) $wrongCounts = foreach ($claim in $countClaims) { $full = Join-Path $repoRoot $claim.File From 7afe1903d48b5e18e856eb21c380b6d9299a8869 Mon Sep 17 00:00:00 2001 From: Ivan Bondarev Date: Thu, 23 Jul 2026 13:54:58 +0300 Subject: [PATCH 11/11] =?UTF-8?q?fix:=20-SkipCleanup=20=D0=B1=D0=BE=D0=BB?= =?UTF-8?q?=D1=8C=D1=88=D0=B5=20=D0=BD=D0=B5=20=D0=B2=D1=8B=D0=B3=D0=BB?= =?UTF-8?q?=D1=8F=D0=B4=D0=B8=D1=82=20=D0=BA=D0=B0=D0=BA=20=D0=BF=D1=80?= =?UTF-8?q?=D0=B5=D1=80=D0=B2=D0=B0=D0=BD=D0=BD=D1=8B=D0=B9=20=D0=BF=D1=80?= =?UTF-8?q?=D0=BE=D0=B3=D0=BE=D0=BD=20+=20=D0=B2=D1=8B=D1=87=D0=B8=D1=89?= =?UTF-8?q?=D0=B5=D0=BD=D0=B0=20=D0=BB=D0=B5=D0=BA=D1=81=D0=B8=D0=BA=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Финальный раунд Codex по правкам агентов нашёл ещё два. 1. -SkipCleanup подавляет ВСЮ фазу DeepSystemCleanup, поэтому Invoke-StorageSense не выполняется и статус не присваивается - оставался дефолт 'not-run', который схема описывает как "прогон прервался, не дойдя". Обычный осознанный пропуск читался как авария. Введено 'skipped-cleanup-group', присваивается там же, где AppUpdatesStatus для -SkipUpdates. Тест round-trip этого не видел: он присваивает значения искусственно, а не проходит боевой путь. 2. Лексика статусов осталась старой в трёх местах (комментарий New-RunStats, CHANGELOG, docstring DiskCleanupPending), плюс комментарий v2.20 всё ещё обещал подчистку "следующим прогоном" - ровно то обещание, которое я убрал из лога строкой ниже. Попутно починен хрупкий тест v2.21: он искал присваивание в окне 400 символов перед диспетчеризацией фазы, и мой соседний блок вытолкнул строку за окно. Проверяется ПОРЯДОК (присваивание раньше диспетчеризации), а не близость. Тесты 700 -> 702. --- CHANGELOG.md | 17 ++++++++++------- CLAUDE.md | 12 ++++++------ CONTRIBUTING.md | 4 ++-- WinClean.ps1 | 18 +++++++++++++----- docs/result-json.md | 3 ++- tests/Fixes.Tests.ps1 | 10 ++++++++++ tests/Helpers.Tests.ps1 | 11 +++++++++-- 7 files changed, 52 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f4de5d3..860425e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -76,18 +76,21 @@ the installer accepted a PowerShell whose version it had failed to read. ### Added -- `DiskCleanupStatus` in the result JSON: `completed`, `idle-resident`, `timeout`, - `storage-sense`, `failed`, `skipped-parameter` or `not-run`. `DiskCleanupPending` was a - boolean covering two different truths. `idle-resident` is named after what is measured - - a process that was seen working and then went completely still - rather than after the - conclusion drawn from it, because that conclusion can be wrong and nothing downstream - is built to depend on it. +- `DiskCleanupStatus` in the result JSON - twelve values covering every way that step can + end, replacing a boolean that covered two different truths. `idle-resident` is named + after what is measured (a process seen working, then completely still) rather than after + the conclusion drawn from it, because that conclusion can be wrong and nothing downstream + is built to depend on it. `running`, `skipped-report-only` and `skipped-cleanup-group` + exist because "not-run" is documented as "the run aborted before reaching it", which is + untrue of a healthy preview, a deliberate skip, or a cleanup still in flight. The old + `failed` was split into `not-armed`, `start-failed` and `exit-nonzero` - only the last + means the machine may be partially cleaned. - `SUPPORT.md`, a release-notes template in `docs/release-process.md`, and a section in `CONTRIBUTING.md` describing what gets accepted and on what criteria. ### Tests -- 573 -> 700. Four mutation runs over the new logic (79 mutations); every one is caught. +- 573 -> 702. Four mutation runs over the new logic (79 mutations); every one is caught. Eight of them survived at first and each exposed a real gap rather than a wrong fix - among them that `Should -Invoke -Times N` means *at least* N and cannot see a duplicate, that the "unreadable activity" branch was never exercised, and that nothing pinned the diff --git a/CLAUDE.md b/CLAUDE.md index cdfc299..fb80854 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -43,9 +43,9 @@ CleanScript/ │ └── logo.svg # Логотип проекта ├── get.ps1 # Bootstrap: разовый запуск одной командой (irm | iex) ├── install.ps1 # Bootstrap: установка/обновление + ярлык (RunAs admin) -├── tests/ # Pester тесты (700 всего; счётчик - прогоном, не грепом) -│ ├── Helpers.Tests.ps1 # Unit-тесты helper-функций (377, дот-сорсят продукт - нужны права админа) -│ ├── Fixes.Tests.ps1 # Валидационные тесты исправлений (205) +├── tests/ # Pester тесты (702 всего; счётчик - прогоном, не грепом) +│ ├── Helpers.Tests.ps1 # Unit-тесты helper-функций (378, дот-сорсят продукт - нужны права админа) +│ ├── Fixes.Tests.ps1 # Валидационные тесты исправлений (206) │ ├── Integration.Tests.ps1 # Интеграционные тесты в песочнице ФС (67, требуют admin) │ ├── StandHelpers.Tests.ps1 # Чистые хелперы стенда: dead-man + версионный гейт + таблица истинности report-гварда (23, без admin) │ └── Docs.Tests.ps1 # Гварды документации: нет тире + нет битых ссылок (28, без admin; файлы ПЕРЕЧИСЛЯЮТСЯ, а не задаются списком) @@ -198,7 +198,7 @@ Publish-PSResource -Path .\WinClean.ps1 -Repository PSGallery -ApiKey $env:PSGAL **Проверки (4 job'а, счёт сверять gh run view --json jobs, а не этой строкой):** 1. **lint** - PSScriptAnalyzer (Error+Warning) через общий ools/Invoke-Lint.ps1 - тот же, что зовёт релиз-гейт 2. **syntax** - Проверка синтаксиса PowerShell -3. **test** - Pester тесты (700; интеграционные требуют admin - на GitHub runners это выполняется) +3. **test** - Pester тесты (702; интеграционные требуют admin - на GitHub runners это выполняется) 4. **smoke** - прогон -ReportOnly + геометрия рамок + result JSON **Исключения PSScriptAnalyzer** (допустимые для CLI): @@ -208,7 +208,7 @@ Publish-PSResource -Path .\WinClean.ps1 -Repository PSGallery -ApiKey $env:PSGAL ### Pester тесты (v2.13+) -- `tests/Helpers.Tests.ps1` - 377 unit-тестов (дот-сорсят WinClean.ps1), `tests/Fixes.Tests.ps1` - 205, `tests/Integration.Tests.ps1` - 67 (песочница ФС, требуют admin), `tests/StandHelpers.Tests.ps1` - 23 (без admin), `tests/Docs.Tests.ps1` - 28 (гварды доков, без admin) +- `tests/Helpers.Tests.ps1` - 378 unit-тестов (дот-сорсят WinClean.ps1), `tests/Fixes.Tests.ps1` - 206, `tests/Integration.Tests.ps1` - 67 (песочница ФС, требуют admin), `tests/StandHelpers.Tests.ps1` - 23 (без admin), `tests/Docs.Tests.ps1` - 28 (гварды доков, без admin) - Особенности: функции в BeforeAll (не AST), regex для locale-независимости, отдельные It блоки --- @@ -434,7 +434,7 @@ pwsh tools/Invoke-ReleaseCheck.ps1 # версия во всех pwsh tools/Invoke-ReleaseCheck.ps1 -IncludeStand # + боевой прогон на VM (минуты) pwsh tools/Invoke-ReleaseCheck.ps1 -VerifyPublished # ПОСЛЕ выпуска: ассеты релиза и SHA256 -Invoke-Pester ./tests -Output Detailed # 700 Pester тестов (считать прогоном, не грепом) +Invoke-Pester ./tests -Output Detailed # 702 Pester тестов (считать прогоном, не грепом) pwsh tools/Invoke-SmokeTest.ps1 # Смоук: ReportOnly + геометрия UI pwsh tools/proxmox/Invoke-StandTest.ps1 -Mode Report # Стенд на Proxmox (RU=VM 190, EN: -ConfigPath ...en.json = VM 191) # Ночная матрица: cron 03:30 на proxmos (/opt/winclean-stand, /etc/cron.d/winclean-stand), отчёт в Telegram diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ec92066..7c40ffc 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -128,7 +128,7 @@ Invoke-Pester ./tests/Integration.Tests.ps1 All PRs automatically run: - PSScriptAnalyzer (linting) - Syntax check -- Pester tests (700 tests) +- Pester tests (702 tests) ### Release-impacting changes @@ -284,7 +284,7 @@ Invoke-Pester ./tests/Integration.Tests.ps1 Все PR автоматически проходят: - PSScriptAnalyzer (линтинг) - Проверка синтаксиса -- Pester тесты (700 тестов) +- Pester тесты (702 тестов) ### Изменения, влияющие на релиз diff --git a/WinClean.ps1 b/WinClean.ps1 index 0ec1d82..415cb28 100644 --- a/WinClean.ps1 +++ b/WinClean.ps1 @@ -384,8 +384,10 @@ function New-RunStats { # 'unknown' must not be mistaken for a verified state by consumers of the JSON ControlledFolderAccess = 'unknown' Aborted = $null # v2.17: set when the run stops before finishing - # v2.20: cleanmgr outlived its timeout and is still deleting in the background. The - # totals reported by this run are partial, and a consumer must not read them as final. + # v2.20: the wait expired without cleanmgr either exiting or being seen to go idle, + # so it was left running. The totals may be partial and must not be read as final. + # Note this does NOT assert it was observed working - the same branch catches a + # machine where activity could not be measured at all (raised in review). DiskCleanupPending = $false # v2.22: how the Storage Sense / Disk Cleanup step actually ended. DiskCleanupPending # alone could not tell "still visibly deleting" from "resident but doing nothing", and @@ -393,8 +395,9 @@ function New-RunStats { # published as partial. # Same shape and reasoning as AppUpdatesStatus (v2.21): when a boolean starts covering # two different truths, the fix is a status, not a cleverer boolean. - # 'not-run' | 'skipped-parameter' | 'storage-sense' | 'completed' | - # 'idle-resident' | 'timeout' | 'failed' + # 'not-run' | 'skipped-parameter' | 'skipped-cleanup-group' | 'skipped-report-only' | + # 'storage-sense' | 'running' | 'completed' | 'idle-resident' | 'timeout' | + # 'not-armed' | 'start-failed' | 'exit-nonzero' DiskCleanupStatus = 'not-run' # v2.17 (p.11 of the audit): which top-level phases ran to completion vs threw. # Before this, one exception anywhere in the run silently skipped every phase @@ -6146,7 +6149,7 @@ function Invoke-StorageSense { # deliberately leaves it working in the background, and this sweep then pulled # its configuration out from under it. Whether cleanmgr re-reads the flags per # handler or only once at startup is not something to guess at while it holds - # an elevated deletion loop. The flags are swept by the next run's own sweep, + # an elevated deletion loop. The flags are swept by the first later run that # which is exactly the leftover case v2.16 added it for. if ($cleanmgr -and -not $cleanmgr.HasExited) { # No promise about WHEN (raised in review). The next run's sweep is gated on @@ -6765,6 +6768,11 @@ function Start-WinClean { # stops the phase from dispatching at all, so the branch that sets this inside the # function is unreachable in production and the status stayed 'not-run'. if ($SkipUpdates) { $script:Stats.AppUpdatesStatus = 'skipped-parameter' } + # Same reason, for the same reason (raised in review): -SkipCleanup suppresses + # the whole DeepSystemCleanup phase, so Invoke-StorageSense never runs and never + # assigns a status - leaving the 'not-run' default, which the schema documents as + # "the run aborted before reaching it". A deliberate skip is not an abort. + if ($SkipCleanup) { $script:Stats.DiskCleanupStatus = 'skipped-cleanup-group' } Invoke-Phase -Name 'Updates' -Skip:$SkipUpdates -Action { Update-WindowsSystem diff --git a/docs/result-json.md b/docs/result-json.md index e32708a..d99e92b 100644 --- a/docs/result-json.md +++ b/docs/result-json.md @@ -29,7 +29,7 @@ This page documents every field, gives a full sample, and explains how to consum | `RebootRequired` | bool | `true` when a change (a Windows update, an app update finishing on reboot) needs a restart to take effect. | | `LoggingDegraded` | bool | v2.20. `true` when writing the log file failed at some point during the run. The run itself still completed, but `LogPath` points at an incomplete file: do not read that log as the full record of what happened. | | `DiskCleanupPending` | bool | v2.20. `true` when Disk Cleanup was still **visibly working** when its timeout expired and was left running in the background; `TotalFreedBytes` is then a lower bound. Note `false` is not a proof that nothing more will ever be deleted - see `DiskCleanupStatus`, which is the field to read when that distinction matters. | -| `DiskCleanupStatus` | string | v2.22. How the Storage Sense / Disk Cleanup step actually ended - eleven values, listed below. The boolean above could not tell a cleanup that had stopped doing anything from one still deleting, and reported both as pending. | +| `DiskCleanupStatus` | string | v2.22. How the Storage Sense / Disk Cleanup step actually ended - twelve values, listed below. The boolean above could not tell a cleanup that had stopped doing anything from one still deleting, and reported both as pending. | | `ControlledFolderAccess` | string | Tri-state, see below. Reflects whether Defender's Controlled Folder Access may have silently blocked deletions. | | `Aborted` | string or null | `null` unless the run stopped early for a known reason: `"PendingRebootDeclined"` (the user declined to continue with a reboot pending) or `"UpdatedAndExited"` (v2.21 - the script updated itself and exited so the new version runs next time). When set, the phase arrays below are incomplete by design. Note `null` does not by itself prove every phase ran - see the invariant note below. | | `PhasesCompleted` | array of string | Phases whose action ran to completion without an uncaught exception. | @@ -106,6 +106,7 @@ than the conclusion. | `start-failed` | cleanmgr.exe could not be started at all (missing, or blocked by AppLocker/WDAC). Nothing was attempted. | | `exit-nonzero` | cleanmgr started and exited with a non-zero code. Unlike the two above, it **ran**: the machine may be partially cleaned. | | `skipped-parameter` | `-SkipDiskCleanup` was passed. | +| `skipped-cleanup-group` | `-SkipCleanup` was passed, which suppresses the whole cleanup group before this step is dispatched. | | `skipped-report-only` | `-ReportOnly` was passed. This is what every preview run, the smoke test and the `Report`/`ReportNoCleanup` stand modes produce. | | `not-run` | The step never executed - the run aborted before reaching it. | diff --git a/tests/Fixes.Tests.ps1 b/tests/Fixes.Tests.ps1 index b1b5f3e..c50e901 100644 --- a/tests/Fixes.Tests.ps1 +++ b/tests/Fixes.Tests.ps1 @@ -1390,6 +1390,16 @@ Describe "v2.20: an operation that did nothing does not report success" -Tag "Fi $retAt | Should -BeGreaterThan $setAt -Because 'the caller must see the marker already set' } + It "A deliberate skip is not reported as an abort" { + # -SkipCleanup suppresses the whole DeepSystemCleanup phase, so Invoke-StorageSense + # never runs and never assigns a status - leaving the 'not-run' default, which the + # schema documents as "the run aborted before reaching it". Raised in review; the + # round-trip test cannot see this because it assigns values by hand. + $body = Get-FunctionBody -Name 'Start-WinClean' + $code = ($body -split "`n" | Where-Object { $_ -notmatch '^\s*#' }) -join "`n" + $code | Should -Match "if \(\`$SkipCleanup\) \{ \`$script:Stats\.DiskCleanupStatus = 'skipped-cleanup-group' \}" + } + It "The completion latch is reset for every run" { # Deleting the reset survived: a second Start-WinClean in one session would # then silently produce no result JSON, no summary and no handle release, diff --git a/tests/Helpers.Tests.ps1 b/tests/Helpers.Tests.ps1 index 0e9547c..a6122cb 100644 --- a/tests/Helpers.Tests.ps1 +++ b/tests/Helpers.Tests.ps1 @@ -2481,6 +2481,7 @@ Describe "DiskCleanupStatus is actually produced and published" -Tag "Unit", "He @{ Value = 'storage-sense' }, @{ Value = 'running' }, @{ Value = 'completed' } @{ Value = 'idle-resident' }, @{ Value = 'timeout' } @{ Value = 'not-armed' }, @{ Value = 'start-failed' }, @{ Value = 'exit-nonzero' } + @{ Value = 'skipped-cleanup-group' } ) { # Pins the vocabulary itself: docs/result-json.md documents exactly these, and a # value that only exists in the code is a contract nobody can consume. @@ -3276,11 +3277,17 @@ Describe "Update-Applications when winget is absent" -Tag "Unit", "Helper", "V22 # -Skip stops the dispatch, so the in-function branch is unreachable there and the # status would stay 'not-run'. This pins the assignment that Start-WinClean makes # before the phase, which no behavioural test in this suite can reach. + # Ordering, not proximity (v2.22): this used to look inside a 400-character window + # before the dispatch, and adding one more pre-phase assignment next to it pushed + # the line out of that window and failed a test about something else entirely. + # What matters is that the assignment happens BEFORE the phase is dispatched. $source = Get-Content $script:WinCleanPath -Raw $dispatch = $source.IndexOf("Invoke-Phase -Name 'Updates'") + $assign = $source.IndexOf("AppUpdatesStatus = 'skipped-parameter'`n", [StringComparison]::Ordinal) + if ($assign -lt 0) { $assign = $source.IndexOf("AppUpdatesStatus = 'skipped-parameter' }", [StringComparison]::Ordinal) } $dispatch | Should -BeGreaterThan 0 - $preamble = $source.Substring([math]::Max(0, $dispatch - 400), [math]::Min(400, $dispatch)) - $preamble.Contains("AppUpdatesStatus = 'skipped-parameter'") | Should -BeTrue + $assign | Should -BeGreaterThan 0 + $assign | Should -BeLessThan $dispatch -Because 'the status must be set before the phase is dispatched, not merely somewhere near it' } It "keeps a present-but-failing winget an ERROR, and does not call that check 'checked'" {