From e60ec9be4bd0fb6d87a9d9c795dc037543f64ca7 Mon Sep 17 00:00:00 2001 From: denfry Date: Wed, 23 Sep 2026 10:39:00 +0300 Subject: [PATCH 1/2] v6.3.0 - audit release, one-line installer, startup manager, history Fixes - Desktop app ignored the tick-boxes: engines now split comma-joined -Include/-Exclude/-Drives/-Area passed through -File. - Restore points work under PowerShell 7 (WMI SystemRestore), throttle value restored. - Cleanup dry runs measured nothing for per-user paths (ForEach-Object member + WhatIf). - Junctions/symlinks removed as links only; age filter works on files; service dependents restarted; native exit codes checked; engine keeps its own log/report. - Browsers closed only in the current session; unattended runs skip running browsers. - Recycle Bin for every user and disk; event-log archives outside %TEMP%; shadow-old / old-drivers / gpu-leftovers scoped correctly. - LF batch files broke call :label in cmd.exe; release build forces CRLF. - Repair: dirty-bit, Defender threats, w32tm, ICMP-triggered network reset, hosts wipe, BITS job wipe; every fix is verified by re-scanning. - Optimize: TaskbarDa/UCPD abort, MMCSS clamp, Teredo policy, Ultimate plan pile-up, Appx under pwsh 7, incremental undo manifests. Changed - Harmful/placebo items off by default (Prefetch, standby purge, full WU reset, BITS reset, Store reset, bg-apps, dmwappush, GameDVR policy, throttling); DISM ResetBase -> Dangerous. - Scheduled tasks run from %ProgramFiles%\WinSenior and keep one report per run. Added - install.ps1: irm .../releases/latest/download/install.ps1 | iex (SHA256-verified). - Cleanup 97 -> 106, Optimize 49 -> 77, Troubleshoot 25 -> 43. - App: Startup apps page, History + HTML export, progress bar, filters, tooltips, confirmations, crash-safe handlers, UTF-8 output, drive/Conservative options. --- CHANGELOG.md | 90 ++ Cleanup-Windows-Senior.bat | 37 +- Cleanup-Windows-Senior.ps1 | 628 ++++++-- Optimize-Windows-Senior.ps1 | 663 +++++++-- README.md | 61 +- Repair-Windows-Senior.ps1 | 1469 +++++++++++++++---- WinSenior.Common.ps1 | 86 +- WinSenior.Gui.ps1 | 1753 +++++++++++++++++++---- WinSenior.Schedule.ps1 | 120 +- WinSenior.Startup.ps1 | 232 +++ WinSenior.cmd | 46 +- WinSenior.ps1 | 38 +- install.ps1 | 194 +++ tests/Cleanup-Windows-Senior.Tests.ps1 | 83 ++ tests/Optimize-Windows-Senior.Tests.ps1 | 180 +++ tests/Repair-Windows-Senior.Tests.ps1 | 183 ++- tests/WinSenior.Common.Tests.ps1 | 33 +- tests/WinSenior.Gui.Tests.ps1 | 248 +++- tests/WinSenior.Schedule.Tests.ps1 | 65 +- tests/WinSenior.Startup.Tests.ps1 | 144 ++ tests/install.Tests.ps1 | 52 + tools/Build-Release.ps1 | 51 +- 22 files changed, 5558 insertions(+), 898 deletions(-) create mode 100644 WinSenior.Startup.ps1 create mode 100644 install.ps1 create mode 100644 tests/WinSenior.Startup.Tests.ps1 create mode 100644 tests/install.Tests.ps1 diff --git a/CHANGELOG.md b/CHANGELOG.md index 03b74fa..74d8219 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,96 @@ All notable changes to this project are documented here. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [6.3.0] - 2026-09-23 + +A correctness and safety release: every engine was audited against Microsoft's +documentation and the common ways cleaners and tweakers break Windows. + +### Fixed +- **The desktop app ignored the tick-boxes.** It passed `-Include 'a,b,c'` through `-File`, + which arrives as one string, so every run used the engine defaults. All engines now split + comma-joined `-Include` / `-Exclude` (and `-Drives`, `-Area`). +- **No restore point under PowerShell 7** (the app's preferred host): `Checkpoint-Computer` + does not exist there. Restore points now go through the `SystemRestore` WMI class on both + hosts, and the 24-hour throttle value is put back afterwards instead of being left at 0. +- **Cleanup dry runs measured nothing for per-user paths**: `ForEach-Object ` is + itself subject to `-WhatIf`, so `` expanded to nothing in preview mode. +- **Junctions / symlinks**: a link inside a cleaned folder is now removed as a link; its + target is never enumerated, counted or queued for delete-at-reboot. +- **Age filter** works on files, not folders (a folder's timestamp does not change when a + file deep inside it does, so fresh files could be deleted with `-MaxAgeDays`). +- Services stopped for a task also restart their **dependents** (`cryptsvc` → AppLocker's + `AppIDSvc` stayed off until reboot); a task is skipped if its service refuses to stop. +- Native tools (DISM, sfc, powercfg, pnputil, wevtutil) are judged by **exit code**; failures + were logged as success. 3010 = success, reboot required. +- The engine no longer deletes **its own log / report** or the desktop app's capture files + out of `%TEMP%` mid-run. +- Browsers are closed **only in the current session**, and never by a silent scheduled run + (`-CloseApps` makes that explicit; otherwise running browsers' caches are skipped). +- Recycle Bin is emptied for **every user on every disk** with byte accounting + (`Clear-RecycleBin` only emptied the caller's bin — nothing when running as SYSTEM). +- Event-log archives go to `%ProgramData%\WinSenior\eventlogs` (the temp tasks deleted them) + and a log is only cleared after a successful export. +- `shadow-old` only prunes system-drive restore-point shadows (it also deleted other + volumes' and backup software's shadows); `old-drivers` groups by provider + class + inf. +- `gpu-leftovers` only targets installer extraction folders, not any `NVIDIA`/`AMD` folder + on a data disk; `disk-temp` only removes week-old files. +- `cmd.exe` mis-resolved `call :label` in LF-only batch files (the `.bat` skipped Chrome and + ran its tail twice); the release build now forces CRLF on scripts. +- Repair: `disk-dirty` flagged every volume (`fsutil dirty query` exits 0 either way), + `def-signatures` never saw threats, `time-sync` used an invalid `w32tm` switch, ICMP being + blocked triggered a full network reset, the hosts fix wiped the whole file, the BITS fix + cancelled healthy jobs, and fixes were reported as *Fixed* without checking — every fix is + now verified by re-scanning. +- Optimize: `TaskbarDa` (blocked by UCPD on current Windows 11) aborted the whole taskbar + tweak; `SystemResponsiveness=0` is clamped to 20 by MMCSS; Teredo is disabled through its + own policy instead of killing every IPv6 tunnel; the Ultimate plan no longer piles up a new + copy per run; Appx debloat actually works under PowerShell 7; undo manifests are written + after every tweak (a cancelled run stays undoable) and marked when undone. + +### Changed (defaults, per Microsoft guidance) +- Off by default: Prefetch (slows the next boots), standby-memory purge, full + SoftwareDistribution reset (wipes update history), BITS queue reset, Store reset, + background-app block, WAP Push service, GameDVR policy, network throttling. +- `DISM /ResetBase` moved to the Dangerous tier (updates become permanently uninstallable); + `/SPSuperseded` (Windows 7-era) removed. `catroot2` and Defender folders are no longer + treated as cache. Recent items no longer wipes pinned jump lists / Quick Access pins + (separate opt-in `jumplists` task). Update caches are skipped while an update waits for a reboot. +- Scheduled runs are installed into `%ProgramFiles%\WinSenior` (admins-only) instead of + running a user-writable clone as SYSTEM, and keep one report per run. + +### Added +- **One-line install** — `irm https://github.com/denfry/WindowsCleaner/releases/latest/download/install.ps1 | iex` + downloads the latest release, verifies its SHA256, installs to `%LOCALAPPDATA%\WinSenior`, + adds Start menu / desktop shortcuts and starts the app. Re-run to update; `-Uninstall`, + `-NoLaunch`, `-Console`, `-NoShortcut` via the scriptblock form. Uses only .NET for + download / hash / unzip, so it works on any Windows PowerShell 5.1 or PowerShell 7. +- **Cleanup 97 → 106:** Vivaldi, new Teams, WebView2 hosts (new Outlook, Widgets, Copilot), + Office Click-to-Run update payloads, `Windows\SystemTemp`, upgrade-assistant leftovers, + Chrome's 4 GB on-device AI model (opt-in), NuGet global packages (opt-in), jump lists + (opt-in); Chromium shader/component/crash caches, Steam libraries on every disk + (`libraryfolders.vdf`), nested LiveKernelReports dumps, legacy NVIDIA LocalLow caches; + Delivery Optimization is emptied through `Delete-DeliveryOptimizationCache`. +- **Optimize 49 → 77:** Recall removal, Copilot app removal, Paint/Notepad AI, Bing search + suggestions and search highlights, widgets/feeds/Meet Now policies, Edge startup boost / + background mode / sidebar / shopping, Chrome on-device AI download block, language-list + and settings-page ads, suggested actions, Sticky Keys prompt, End task in taskbar, + Home/Gallery in Explorer, HAGS, power throttling, USB selective suspend, reserved storage + and more (debatable ones off by default). Visual effects keep font smoothing. +- **Troubleshoot 25 → 43:** services disabled by tweak tools, Windows Update blocked by + policy, update-failure history with per-error-code routing, broken Start/taskbar shell + packages, WinRE disabled / recovery partition too small (0x80070643), Secure Boot 2023 + certificate status, page file, Winsock LSP, disabled network adapters, BitLocker + suspended, UAC off, broken PATH/TEMP, Driver Verifier left on, activation, TRIM, root + certificate updates, Search index health, suspicious Defender exclusions. +- **Desktop app:** Startup apps page (enable/disable exactly like Task Manager, fully + reversible), History page with all-time total and HTML export, progress bar, filter box + and sortable columns on every list, tooltips with paths / explanations, confirm dialogs + before Clean and Apply, drive and Conservative options, single-instance guard, no more + silent crashes (errors land in the log and `logs\gui-crash.log`), UTF-8 engine output + (no mojibake on non-English Windows), scrollable pages. +- `WinSenior.cmd` works from folders containing spaces, `)`, `&`, `'` or Cyrillic. + ## [6.2.0] - 2026-09-14 ### Added diff --git a/Cleanup-Windows-Senior.bat b/Cleanup-Windows-Senior.bat index 7cc7136..a66d391 100644 --- a/Cleanup-Windows-Senior.bat +++ b/Cleanup-Windows-Senior.bat @@ -3,7 +3,7 @@ setlocal enabledelayedexpansion :: ============================================================ :: Windows System Cleanup Script (Batch Version) -:: Version: 6.0 - mirrors the PowerShell engine's defaults +:: Version: 6.3.0 - mirrors the PowerShell engine's defaults :: - per-profile helper (cleans every user profile) :: - independent per-browser flags :: - safe targets: dev / messenger / shader / font caches @@ -12,7 +12,7 @@ setlocal enabledelayedexpansion :: Author: denfry - https://github.com/denfry/WindowsCleaner :: ============================================================ -set "SCRIPT_VERSION=6.0" +set "SCRIPT_VERSION=6.3.0" set "SCRIPT_NAME=Cleanup-Windows-Senior.bat" :: ---------- defaults (aggressive, matching the PS engine) ---------- @@ -31,7 +31,8 @@ set "CLEAN_BRAVE=1" set "CLEAN_TEMP=1" set "CLEAN_THUMBNAILS=1" -set "CLEAN_PREFETCH=1" +:: Prefetch is opt-in (/pf): Microsoft notes clearing it slows the next boots. +set "CLEAN_PREFETCH=0" set "CLEAN_SHADERCACHE=1" set "CLEAN_FONTCACHE=1" set "CLEAN_DEVCACHE=1" @@ -77,6 +78,7 @@ if /i "!A!"=="/nya" ( set "CLEAN_YANDEX=0" & goto :NEXT ) if /i "!A!"=="/nbr" ( set "CLEAN_BRAVE=0" & goto :NEXT ) if /i "!A!"=="/ntmp" ( set "CLEAN_TEMP=0" & goto :NEXT ) if /i "!A!"=="/npf" ( set "CLEAN_PREFETCH=0" & goto :NEXT ) +if /i "!A!"=="/pf" ( set "CLEAN_PREFETCH=1" & goto :NEXT ) if /i "!A!"=="/nrb" ( set "CLEAN_RECYCLE_BIN=0" & goto :NEXT ) if /i "!A!"=="/nwu" ( set "CLEAN_WU=0" & goto :NEXT ) if /i "!A!"=="/ndev" ( set "CLEAN_DEVCACHE=0" & goto :NEXT ) @@ -157,16 +159,20 @@ if "%CLEAN_IIS_LOGS%"=="1" if exist "%WINDIR%\System32\LogFiles" ( if "%CLEAN_WU%"=="1" ( call :LOG "Cleaning Windows Update cache..." if "%DRY_RUN%"=="1" ( - call :LOG "[DRY] would stop wuauserv/bits/cryptsvc and wipe SoftwareDistribution + catroot2" + call :LOG "[DRY] would stop wuauserv/bits and wipe SoftwareDistribution\Download" ) else ( - net stop wuauserv >nul 2>&1 - net stop bits >nul 2>&1 - net stop cryptsvc >nul 2>&1 - call :WIPE "%WINDIR%\SoftwareDistribution" - call :WIPE "%WINDIR%\System32\catroot2" - net start cryptsvc >nul 2>&1 - net start bits >nul 2>&1 - net start wuauserv >nul 2>&1 + rem Download cache only: DataStore is update history and catroot2 is a repair + rem step, not a cache. Skip while an update waits for a reboot. + reg query "HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\WindowsUpdate\Auto Update\RebootRequired" >nul 2>&1 + if !errorlevel! equ 0 ( + call :LOG_WARN "Update waiting for a reboot - Windows Update cache skipped (restart first)" + ) else ( + net stop wuauserv >nul 2>&1 + net stop bits >nul 2>&1 + call :WIPE "%WINDIR%\SoftwareDistribution\Download" + net start bits >nul 2>&1 + net start wuauserv >nul 2>&1 + ) ) ) @@ -175,12 +181,10 @@ if "%SKIP_OPTIMIZATION%"=="0" ( if "%OPTIMIZE_COMPONENTS%"=="1" ( call :LOG "DISM component cleanup..." if "%DRY_RUN%"=="1" ( - call :LOG "[DRY] would run DISM /StartComponentCleanup and /SPSuperseded" + call :LOG "[DRY] would run DISM /StartComponentCleanup" ) else ( Dism.exe /online /Cleanup-Image /StartComponentCleanup /Quiet >nul 2>&1 - Dism.exe /online /Cleanup-Image /SPSuperseded >nul 2>&1 - call :WIPE "%WINDIR%\Logs\DISM" - call :LOG_OK "Component store optimized" + if !errorlevel! equ 0 ( call :LOG_OK "Component store optimized" ) else if !errorlevel! equ 3010 ( call :LOG_OK "Component store optimized (reboot to finish)" ) else ( call :LOG_WARN "DISM component cleanup failed (exit !errorlevel!)" ) ) ) if "%RUN_SFC%"=="1" ( @@ -368,6 +372,7 @@ echo. echo Disable a target: /nch /ned /nff /nop /nya /nbr (browsers) echo /ntmp /npf /nrb /nwu /ndev /nmsg /ndisks echo. +echo Enable an opt-in target: /pf (Prefetch - slows the next boots while it rebuilds) echo /ndisks disables drive-level cleanup of all local disks (C:, D:, ...) echo. echo NOTE: requires Administrator. The PowerShell version diff --git a/Cleanup-Windows-Senior.ps1 b/Cleanup-Windows-Senior.ps1 index f76d044..b937790 100644 --- a/Cleanup-Windows-Senior.ps1 +++ b/Cleanup-Windows-Senior.ps1 @@ -11,11 +11,11 @@ Aggressive by default: Safe + Moderate + Aggressive tiers run out of the box. Irreversible operations (event-log clearing, patch cache, Windows.old) live in the Dangerous tier and require -IncludeDangerous. A real System Restore - point (Checkpoint-Computer) is created by default unless -NoRestorePoint. + point (WMI SystemRestore) is created by default unless -NoRestorePoint. .NOTES Author : denfry (https://github.com/denfry/WindowsCleaner) - Version : 6.2.0 + Version : 6.3.0 Requires: PowerShell 5.1+ (Windows). Administrator rights for most tasks. .EXAMPLE @@ -71,7 +71,7 @@ param( [Alias('Force','f')] [switch]$Unattended, - # Skip the real Checkpoint-Computer restore point that is otherwise created first + # Skip the real System Restore point that is otherwise created first [Alias('nrp')] [switch]$NoRestorePoint, @@ -87,6 +87,10 @@ param( [Alias('dl')] [switch]$DeferLocked, + # Close running browsers (current session only) instead of skipping their cache + # tasks. Interactive runs close by default; -Unattended runs only with this switch. + [switch]$CloseApps, + [string]$LogPath = "$env:TEMP\WindowsCleanup.log", # Optional path for a machine-readable JSON report @@ -114,6 +118,12 @@ $script:RestorePointMade = $false # flow into every ShouldProcess call below (and into nested helper functions). if ($DryRun) { $WhatIfPreference = $true } +# Callers that go through -File (desktop app, scheduler) pass 'a,b,c' as ONE string; +# split it so -Include/-Exclude match task ids either way. +$Include = @($Include | ForEach-Object { "$_" -split ',' } | ForEach-Object { $_.Trim() } | Where-Object { $_ }) +$Exclude = @($Exclude | ForEach-Object { "$_" -split ',' } | ForEach-Object { $_.Trim() } | Where-Object { $_ }) +$Drives = @($Drives | ForEach-Object { "$_" -split ',' } | ForEach-Object { $_.Trim() } | Where-Object { $_ }) + # Paths the engine must never operate on, no matter what a task or env var says. $script:DenyList = @( ($env:SystemDrive + '\'), @@ -126,6 +136,12 @@ $script:DenyList = @( ${env:ProgramFiles(x86)} ) | Where-Object { $_ } | ForEach-Object { $_.TrimEnd('\').ToLowerInvariant() } +# Files the engine itself is writing (its log, the report, the desktop app's +# output capture) live in %TEMP% - never delete them out from under the run. +$script:KeepPaths = @($LogPath, $ReportPath) | Where-Object { $_ } | + ForEach-Object { [IO.Path]::GetFullPath($_).ToLowerInvariant() } +$script:KeepNamePatterns = @('winsenior-*', 'WindowsCleanup.log', 'WindowsOptimize.log', 'WindowsRepair.log') + # ===================================================================== # SHARED LIBRARY (admin / restore-point / logging / format helpers) # ===================================================================== @@ -165,6 +181,46 @@ function Get-ItemFileCount { else { 1 } } +# Junctions / symlinks (and cloud placeholders) are reparse points. Enumerating one +# by its own path walks the TARGET, so size accounting and delete-on-reboot must +# treat it as a zero-byte link and never recurse into it. +function Test-ReparsePoint { + param([System.IO.FileSystemInfo]$Item) + [bool]($Item -and ($Item.Attributes -band [System.IO.FileAttributes]::ReparsePoint)) +} + +# Size + file count in a single enumeration (the old pair walked every folder twice). +function Measure-FsItem { + param([System.IO.FileSystemInfo]$Item) + if (-not $Item) { return [pscustomobject]@{ Bytes = [int64]0; Files = 0 } } + if (Test-ReparsePoint $Item) { return [pscustomobject]@{ Bytes = [int64]0; Files = 1 } } + if (-not $Item.PSIsContainer) { return [pscustomobject]@{ Bytes = [int64]$Item.Length; Files = 1 } } + $m = Get-ChildItem -LiteralPath $Item.FullName -Recurse -Force -File -ErrorAction SilentlyContinue | + Measure-Object -Property Length -Sum + [pscustomobject]@{ Bytes = [int64]$(if ($m.Sum) { $m.Sum } else { 0 }); Files = [int]$m.Count } +} + +# Sum of file bytes under a set of literal roots (used by tool-driven tasks that +# measure before/after instead of deleting item by item). +function Get-PathBytes { + param([string[]]$Root) + [int64]$sum = 0 + foreach ($r in $Root) { + $it = Get-Item -LiteralPath $r -Force -ErrorAction SilentlyContinue + if ($it) { $sum += (Measure-FsItem $it).Bytes } + } + $sum +} + +function Test-KeepItem { + param([string]$FullPath, [string]$Name, [string[]]$ExcludePattern) + if ($script:KeepPaths -contains $FullPath.ToLowerInvariant()) { return $true } + foreach ($p in (@($script:KeepNamePatterns) + @($ExcludePattern))) { + if ($p -and ($Name -like $p)) { return $true } + } + $false +} + function Test-SafeToDelete { param([string]$FullPath) if ([string]::IsNullOrWhiteSpace($FullPath)) { return $false } @@ -183,9 +239,48 @@ function Get-UserProfiles { if ($CurrentUserOnly) { return ,([pscustomobject]@{ Name = $env:USERNAME; FullName = $env:USERPROFILE }) } + # ProfileList is authoritative (profiles moved off C:\Users, AzureAD S-1-12-1-*); + # the C:\Users scan catches anything the registry misses. + $seen = @{} + $out = New-Object System.Collections.Generic.List[object] + $keys = Get-ChildItem 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList' -ErrorAction SilentlyContinue | + Where-Object { $_.PSChildName -match '^S-1-(5-21|12-1)-[\d-]+$' } + foreach ($k in $keys) { + $p = (Get-ItemProperty -LiteralPath $k.PSPath -Name ProfileImagePath -ErrorAction SilentlyContinue).ProfileImagePath + if (-not $p) { continue } + $p = [Environment]::ExpandEnvironmentVariables($p).TrimEnd('\') + if ((Test-Path -LiteralPath $p -PathType Container) -and -not $seen[$p.ToLowerInvariant()]) { + $seen[$p.ToLowerInvariant()] = $true + $out.Add([pscustomobject]@{ Name = (Split-Path $p -Leaf); FullName = $p }) + } + } Get-ChildItem "$env:SystemDrive\Users" -Directory -ErrorAction SilentlyContinue | - Where-Object { $_.Name -notin @('Public','Default','Default User','All Users') } | - ForEach-Object { [pscustomobject]@{ Name = $_.Name; FullName = $_.FullName } } + Where-Object { $_.Name -notin @('Public','Default','Default User','All Users') -and -not $seen[$_.FullName.ToLowerInvariant()] } | + ForEach-Object { $out.Add([pscustomobject]@{ Name = $_.Name; FullName = $_.FullName }) } + $out +} + +# Steam install roots (registry first, default path as fallback) and every +# library folder listed in libraryfolders.vdf - games live on other disks too. +function Get-SteamRoots { + $r = foreach ($k in 'HKLM:\SOFTWARE\WOW6432Node\Valve\Steam', 'HKLM:\SOFTWARE\Valve\Steam') { + (Get-ItemProperty -Path $k -Name InstallPath -ErrorAction SilentlyContinue).InstallPath + } + @(@($r) + "${env:ProgramFiles(x86)}\Steam" | Where-Object { $_ -and (Test-Path -LiteralPath $_) } | + ForEach-Object { $_.TrimEnd('\') } | Sort-Object -Unique) +} + +function Get-SteamLibraries { + $libs = New-Object System.Collections.Generic.List[string] + foreach ($root in (Get-SteamRoots)) { + $libs.Add($root) + $vdf = Join-Path $root 'steamapps\libraryfolders.vdf' + if (-not (Test-Path -LiteralPath $vdf)) { continue } + foreach ($m in [regex]::Matches((Get-Content -LiteralPath $vdf -Raw -ErrorAction SilentlyContinue), '"path"\s+"([^"]+)"')) { + $libs.Add($m.Groups[1].Value.Replace('\\', '\').TrimEnd('\')) + } + } + @($libs | Where-Object { Test-Path -LiteralPath $_ } | Sort-Object -Unique) } # Local fixed disks ('C:\','D:\',...). Filtered by -Drives when supplied. @@ -200,26 +295,40 @@ function Get-LocalDrives { $all } +# Tokens: every profile, every local disk ('C:\'), Steam +# install roots, every Steam library, the Recycle Bin owner +# folder pattern (current user's SID under -CurrentUserOnly, else all users). function Expand-TaskPath { param([string[]]$Raw) $out = New-Object System.Collections.Generic.List[string] - foreach ($entry in $Raw) { - $expanded = [Environment]::ExpandEnvironmentVariables($entry) - if ($expanded -like '**') { - foreach ($prof in (Get-UserProfiles)) { - $out.Add($expanded.Replace('', $prof.FullName)) - } + $tokens = [ordered]@{ + '' = { Get-UserProfiles | ForEach-Object { $_.FullName } } + '' = { Get-LocalDrives } + '' = { Get-SteamLibraries } + '' = { Get-SteamRoots } + '' = { + if ($CurrentUserOnly) { [Security.Principal.WindowsIdentity]::GetCurrent().User.Value } else { 'S-1-*' } } - elseif ($expanded -like '**') { - foreach ($d in (Get-LocalDrives)) { - $out.Add($expanded.Replace('', $d)) - } + } + foreach ($entry in $Raw) { + $cands = @([Environment]::ExpandEnvironmentVariables($entry)) + foreach ($t in $tokens.Keys) { + if (-not ($cands | Where-Object { $_.Contains($t) })) { continue } + $values = @(& $tokens[$t]) + $cands = @(foreach ($c in $cands) { + if ($c.Contains($t)) { foreach ($v in $values) { $c.Replace($t, $v) } } else { $c } + }) } - else { $out.Add($expanded) } + foreach ($c in $cands) { $out.Add($c) } } $out } +function Test-UpdateRebootPending { + (Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\WindowsUpdate\Auto Update\RebootRequired') -or + (Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Component Based Servicing\RebootPending') +} + # ===================================================================== # CORE: LOCKED-FILE HANDLING (delete-on-reboot via MoveFileEx) # ===================================================================== @@ -238,10 +347,12 @@ function Register-DeferredDelete { Initialize-DeferredDelete if (-not ('WinSenior.Native' -as [type])) { return 0 } $queued = 0 - $targets = if (Test-Path -LiteralPath $FullPath -PathType Container) { + $self = Get-Item -LiteralPath $FullPath -Force -ErrorAction SilentlyContinue + $targets = if (Test-ReparsePoint $self) { @($FullPath) } # queue the link, never its target + elseif (Test-Path -LiteralPath $FullPath -PathType Container) { # Files first (deepest first), then the directories themselves. @(Get-ChildItem -LiteralPath $FullPath -Recurse -Force -ErrorAction SilentlyContinue | - Sort-Object { $_.FullName.Length } -Descending | ForEach-Object FullName) + @($FullPath) + Sort-Object { $_.FullName.Length } -Descending | ForEach-Object { $_.FullName }) + @($FullPath) } else { @($FullPath) } foreach ($t in $targets) { if (-not (Test-Path -LiteralPath $t)) { continue } # already gone @@ -256,6 +367,7 @@ function Remove-EmptyDirectory { if (-not (Test-Path -LiteralPath $Root -PathType Container)) { return 0 } $removed = 0 $dirs = Get-ChildItem -LiteralPath $Root -Directory -Recurse -Force -ErrorAction SilentlyContinue | + Where-Object { -not (Test-ReparsePoint $_) } | Sort-Object { $_.FullName.Length } -Descending foreach ($d in $dirs) { if (-not (Test-SafeToDelete $d.FullName)) { continue } @@ -276,7 +388,9 @@ function Invoke-PathCleanup { param( [string[]]$Path, [int]$AgeDays = 0, - [string]$Description = 'items' + [string]$Description = 'items', + # Leaf-name wildcards to keep (e.g. Prefetch\Layout.ini, Quick Access pins) + [string[]]$ExcludePattern ) $files = 0; [int64]$bytes = 0; $errors = 0; $deferred = 0 @@ -284,30 +398,62 @@ function Invoke-PathCleanup { foreach ($spec in $Path) { # A bare directory path (no wildcard) means "empty this directory". - $container = if ($spec -match '[\*\?]') { Split-Path $spec -Parent } else { $spec } - if (-not (Test-Path -Path $container -ErrorAction SilentlyContinue)) { continue } + $wild = $spec -match '[\*\?]' + $container = if ($wild) { Split-Path $spec -Parent } else { $spec } + + # (Test-Path with a wildcard skips hidden folders such as $Recycle.Bin, so a + # wildcard spec goes straight to Get-ChildItem -Force, which is empty if absent.) + if ($wild) { + # 5.1 still writes access-denied globbing errors to stderr despite SilentlyContinue + try { $items = Get-ChildItem -Path $spec -Force -ErrorAction SilentlyContinue 2>$null } catch { $items = @() } + } + elseif (-not (Test-Path -LiteralPath $spec)) { continue } + else { + $root = Get-Item -LiteralPath $spec -Force -ErrorAction SilentlyContinue + if (Test-ReparsePoint $root) { + Write-CleanupLog "refusing to empty a linked folder: $spec" 'Warning'; continue + } + $items = Get-ChildItem -LiteralPath $spec -Force -ErrorAction SilentlyContinue + } - $items = Get-ChildItem -Path $spec -Force -ErrorAction SilentlyContinue - if ($cutoff) { $items = $items | Where-Object { $_.LastWriteTime -lt $cutoff } } + # Age filter works on FILES: a folder's own timestamp does not change when + # something deep inside it does, so filtering folders would delete fresh data. + if ($cutoff) { + $items = foreach ($it in $items) { + if ($it.PSIsContainer -and -not (Test-ReparsePoint $it)) { + Get-ChildItem -LiteralPath $it.FullName -Recurse -Force -File -ErrorAction SilentlyContinue | + Where-Object { $_.LastWriteTime -lt $cutoff } + } + elseif ($it.LastWriteTime -lt $cutoff) { $it } + } + } foreach ($item in $items) { $full = $item.FullName + if (Test-KeepItem -FullPath $full -Name $item.Name -ExcludePattern $ExcludePattern) { continue } if (-not (Test-SafeToDelete $full)) { Write-CleanupLog "refusing unsafe path: $full" 'Warning' continue } - $size = Get-ItemSize $item - $count = Get-ItemFileCount $item + $isLink = Test-ReparsePoint $item + $m = Measure-FsItem $item + $size = $m.Bytes + $count = $m.Files if ($PSCmdlet.ShouldProcess($full, "Remove ($Description)")) { try { - Remove-Item -LiteralPath $full -Recurse -Force -ErrorAction Stop + if ($isLink) { + # Remove the link itself; the target it points to is untouched. + if ($item.PSIsContainer) { [System.IO.Directory]::Delete($full, $false) } + else { [System.IO.File]::Delete($full) } + } + else { Remove-Item -LiteralPath $full -Recurse -Force -ErrorAction Stop } if (-not (Test-Path -LiteralPath $full)) { $files += $count; $bytes += $size } } catch { # Partial success inside a folder still counts: measure what is left. if (Test-Path -LiteralPath $full) { - $left = Get-ItemSize (Get-Item -LiteralPath $full -Force -ErrorAction SilentlyContinue) + $left = (Measure-FsItem (Get-Item -LiteralPath $full -Force -ErrorAction SilentlyContinue)).Bytes if ($left -lt $size) { $bytes += ($size - $left) } if ($DeferLocked -and (Register-DeferredDelete $full) -gt 0) { $deferred++ @@ -326,8 +472,12 @@ function Invoke-PathCleanup { } # Age-filtered passes leave empty folder skeletons behind - tidy them. - if ($cutoff -and -not (Test-WhatIfMode) -and ($spec -match '[\*\?]')) { - [void](Remove-EmptyDirectory -Root $container) + if ($cutoff -and -not (Test-WhatIfMode)) { + $roots = if ($wild) { Get-Item -Path $container -Force -ErrorAction SilentlyContinue } + else { Get-Item -LiteralPath $container -Force -ErrorAction SilentlyContinue } + foreach ($c in @($roots | Where-Object { $_.PSIsContainer -and -not (Test-ReparsePoint $_) })) { + [void](Remove-EmptyDirectory -Root $c.FullName) + } } } @@ -335,35 +485,83 @@ function Invoke-PathCleanup { } # Stop a set of services, run a body, then restart whatever was running. +# Stop-Service -Force also stops running DEPENDENTS (cryptsvc -> AppIDSvc, WSearch -> +# WMPNetworkSvc ...), so those are restarted too. If a service refuses to stop the +# body is skipped: deleting a live service's database is how caches get corrupted. function Use-StoppedService { param([string[]]$Name, [scriptblock]$Body) - $restart = @() + $restart = New-Object System.Collections.Generic.List[string] + $blocked = $null if (-not (Test-WhatIfMode)) { foreach ($n in $Name) { $svc = Get-Service -Name $n -ErrorAction SilentlyContinue - if ($svc -and $svc.Status -eq 'Running') { - Stop-Service -Name $n -Force -ErrorAction SilentlyContinue - $restart += $n - } + if (-not $svc -or $svc.Status -ne 'Running') { continue } + $deps = @($svc.DependentServices | Where-Object { $_.Status -eq 'Running' } | ForEach-Object { $_.Name }) + Stop-Service -Name $n -Force -ErrorAction SilentlyContinue + try { $svc.WaitForStatus('Stopped', [TimeSpan]::FromSeconds(20)) } catch { Write-Verbose "wait $n" } + $svc.Refresh() + if (-not $restart.Contains($n)) { $restart.Add($n) } + foreach ($d in $deps) { if (-not $restart.Contains($d)) { $restart.Add($d) } } + if ($svc.Status -ne 'Stopped') { $blocked = $n; break } } } - try { & $Body } + try { + if ($blocked) { + Write-CleanupLog " service '$blocked' did not stop - skipped to avoid corrupting its data" 'Warning' + [pscustomobject]@{ Files = 0; Bytes = [int64]0; Errors = 1; Deferred = 0 } + } + else { & $Body } + } finally { foreach ($n in $restart) { Start-Service -Name $n -ErrorAction SilentlyContinue } } } -# Run a native command unless in WhatIf mode. +# Run a native command unless in WhatIf mode. Native tools never throw, so the exit +# code decides: 0 = ok, 3010 = ok + reboot needed, anything else = failure. function Invoke-NativeStep { param([string]$Caption, [scriptblock]$Body) if (Test-WhatIfMode) { Write-CleanupLog "[WhatIf] would run: $Caption" 'WhatIf' return $true } - try { & $Body; Write-CleanupLog $Caption 'Success'; return $true } + try { + $global:LASTEXITCODE = 0 + & $Body + $code = [int]$global:LASTEXITCODE + if ($code -ne 0 -and $code -ne 3010) { throw ("exit code {0} (0x{1:X8})" -f $code, $code) } + $note = if ($code -eq 3010) { ' (reboot required to finish)' } else { '' } + Write-CleanupLog "$Caption$note" 'Success'; return $true + } catch { Write-CleanupLog "$Caption failed: $($_.Exception.Message)" 'Error'; return $false } } +# Browsers / apps that hold a task's files open. Returns $true when the task must be +# skipped. Interactive runs (or -CloseApps) close browsers in THIS session only - +# never another user's session and never from a silent scheduled run. +function Test-TaskBlocked { + param([object]$Task) + if (-not $Task.Processes -or (Test-WhatIfMode)) { return $false } + $running = @(Get-Process -Name $Task.Processes -ErrorAction SilentlyContinue) + if (-not $running) { return $false } + $mayClose = ($Task.Category -eq 'Browsers') -and ($CloseApps -or -not $Unattended) + if ($mayClose) { + $session = (Get-Process -Id $PID).SessionId + $mine = @($running | Where-Object { $_.SessionId -eq $session }) + if ($mine) { + $mine | Stop-Process -Force -ErrorAction SilentlyContinue + Start-Sleep -Milliseconds 1500 + Write-CleanupLog " closed $($mine.Count) $($Task.Processes -join '/') process(es)" 'Debug' + } + $running = @(Get-Process -Name $Task.Processes -ErrorAction SilentlyContinue) + if (-not $running) { return $false } + } + $names = ($running | ForEach-Object { $_.ProcessName } | Sort-Object -Unique) -join ', ' + $hint = if ($Task.Category -eq 'Browsers') { ' (close it, or use -CloseApps)' } else { ' (close it and re-run)' } + Write-CleanupLog " skipped: $names is running$hint" 'Warning' + $true +} + # Remove a top-level folder that needs ownership first (Windows.old etc.). function Remove-ProtectedFolder { [CmdletBinding(SupportsShouldProcess)] @@ -399,63 +597,93 @@ function New-CleanupRestorePoint { return ($st -ne 'Failed') } -function Stop-BrowserProcesses { - $names = 'chrome','msedge','firefox','opera','browser','brave' - if (Test-WhatIfMode) { - Write-CleanupLog '[WhatIf] would close running browsers' 'WhatIf'; return - } - foreach ($n in $names) { - $procs = Get-Process -Name $n -ErrorAction SilentlyContinue - if ($procs) { - $procs | Stop-Process -Force -ErrorAction SilentlyContinue - Write-CleanupLog "Closed $($procs.Count) $n process(es)" 'Debug' - } - } -} - # ===================================================================== # TASK REGISTRY (the single source of truth) +# -Processes process names that lock the task's files (skip / close first) +# -Exclude leaf-name wildcards to keep inside the task's paths +# -SkipIf scriptblock; a non-empty string return = skip with that reason # ===================================================================== function New-CleanupTask { param( [string]$Id, [string]$Name, [string]$Category, [string]$Risk, [bool]$DefaultOn = $true, [int]$AgeDays = 0, - [string[]]$Paths, [scriptblock]$Action, [string[]]$StopServices + [string[]]$Paths, [scriptblock]$Action, [string[]]$StopServices, + [string[]]$Processes, [string[]]$Exclude, [scriptblock]$SkipIf ) [pscustomobject]@{ Id = $Id; Name = $Name; Category = $Category; Risk = $Risk DefaultOn = $DefaultOn; AgeDays = $AgeDays Paths = $Paths; Action = $Action; StopServices = $StopServices + Processes = $Processes; Exclude = $Exclude; SkipIf = $SkipIf } } function Get-CleanupTaskRegistry { @( # ---------------- Browsers (Safe) ---------------- - New-CleanupTask chrome 'Chrome cache' Browsers Safe -Paths @( + # Chromium keeps per-profile caches under User Data\\ and browser-wide + # shader / component / crash caches directly under User Data\. + New-CleanupTask chrome 'Chrome cache' Browsers Safe -Processes chrome -Paths @( '\AppData\Local\Google\Chrome\User Data\*\Cache\*', '\AppData\Local\Google\Chrome\User Data\*\Code Cache\*', '\AppData\Local\Google\Chrome\User Data\*\GPUCache\*', - '\AppData\Local\Google\Chrome\User Data\*\Service Worker\CacheStorage\*') - New-CleanupTask edge 'Edge cache' Browsers Safe -Paths @( + '\AppData\Local\Google\Chrome\User Data\*\DawnGraphiteCache\*', + '\AppData\Local\Google\Chrome\User Data\*\DawnWebGPUCache\*', + '\AppData\Local\Google\Chrome\User Data\*\Service Worker\CacheStorage\*', + '\AppData\Local\Google\Chrome\User Data\GrShaderCache\*', + '\AppData\Local\Google\Chrome\User Data\ShaderCache\*', + '\AppData\Local\Google\Chrome\User Data\GraphiteDawnCache\*', + '\AppData\Local\Google\Chrome\User Data\component_crx_cache\*', + '\AppData\Local\Google\Chrome\User Data\Crashpad\reports\*') + New-CleanupTask edge 'Edge cache' Browsers Safe -Processes msedge -Paths @( '\AppData\Local\Microsoft\Edge\User Data\*\Cache\*', '\AppData\Local\Microsoft\Edge\User Data\*\Code Cache\*', '\AppData\Local\Microsoft\Edge\User Data\*\GPUCache\*', - '\AppData\Local\Microsoft\Edge\User Data\*\Service Worker\CacheStorage\*') - New-CleanupTask firefox 'Firefox cache' Browsers Safe -Paths @( + '\AppData\Local\Microsoft\Edge\User Data\*\DawnGraphiteCache\*', + '\AppData\Local\Microsoft\Edge\User Data\*\DawnWebGPUCache\*', + '\AppData\Local\Microsoft\Edge\User Data\*\Service Worker\CacheStorage\*', + '\AppData\Local\Microsoft\Edge\User Data\GrShaderCache\*', + '\AppData\Local\Microsoft\Edge\User Data\ShaderCache\*', + '\AppData\Local\Microsoft\Edge\User Data\GraphiteDawnCache\*', + '\AppData\Local\Microsoft\Edge\User Data\component_crx_cache\*', + '\AppData\Local\Microsoft\Edge\User Data\Crashpad\reports\*') + New-CleanupTask firefox 'Firefox cache' Browsers Safe -Processes firefox -Paths @( '\AppData\Local\Mozilla\Firefox\Profiles\*\cache2\*', '\AppData\Local\Mozilla\Firefox\Profiles\*\startupCache\*', - '\AppData\Local\Mozilla\Firefox\Profiles\*\thumbnails\*') - New-CleanupTask opera 'Opera cache' Browsers Safe -Paths @( + '\AppData\Local\Mozilla\Firefox\Profiles\*\thumbnails\*', + '\AppData\Local\Mozilla\Firefox\Profiles\*\shader-cache\*', + '\AppData\Roaming\Mozilla\Firefox\Crash Reports\pending\*') + New-CleanupTask opera 'Opera cache' Browsers Safe -Processes opera -Paths @( '\AppData\Roaming\Opera Software\Opera Stable\Cache\*', '\AppData\Roaming\Opera Software\Opera Stable\GPUCache\*', - '\AppData\Local\Opera Software\Opera Stable\Cache\*') - New-CleanupTask yandex 'Yandex cache' Browsers Safe -Paths @( + '\AppData\Roaming\Opera Software\Opera Stable\Code Cache\*', + '\AppData\Local\Opera Software\Opera Stable\Cache\*', + '\AppData\Roaming\Opera Software\Opera GX Stable\Cache\*', + '\AppData\Roaming\Opera Software\Opera GX Stable\GPUCache\*', + '\AppData\Local\Opera Software\Opera GX Stable\Cache\*') + New-CleanupTask yandex 'Yandex cache' Browsers Safe -Processes browser -Paths @( '\AppData\Local\Yandex\YandexBrowser\User Data\*\Cache\*', - '\AppData\Local\Yandex\YandexBrowser\User Data\*\GPUCache\*') - New-CleanupTask brave 'Brave cache' Browsers Safe -Paths @( + '\AppData\Local\Yandex\YandexBrowser\User Data\*\Code Cache\*', + '\AppData\Local\Yandex\YandexBrowser\User Data\*\GPUCache\*', + '\AppData\Local\Yandex\YandexBrowser\User Data\GrShaderCache\*', + '\AppData\Local\Yandex\YandexBrowser\User Data\ShaderCache\*') + New-CleanupTask brave 'Brave cache' Browsers Safe -Processes brave -Paths @( '\AppData\Local\BraveSoftware\Brave-Browser\User Data\*\Cache\*', - '\AppData\Local\BraveSoftware\Brave-Browser\User Data\*\GPUCache\*') + '\AppData\Local\BraveSoftware\Brave-Browser\User Data\*\Code Cache\*', + '\AppData\Local\BraveSoftware\Brave-Browser\User Data\*\GPUCache\*', + '\AppData\Local\BraveSoftware\Brave-Browser\User Data\GrShaderCache\*', + '\AppData\Local\BraveSoftware\Brave-Browser\User Data\ShaderCache\*', + '\AppData\Local\BraveSoftware\Brave-Browser\User Data\component_crx_cache\*') + New-CleanupTask vivaldi 'Vivaldi cache' Browsers Safe -Processes vivaldi -Paths @( + '\AppData\Local\Vivaldi\User Data\*\Cache\*', + '\AppData\Local\Vivaldi\User Data\*\Code Cache\*', + '\AppData\Local\Vivaldi\User Data\*\GPUCache\*', + '\AppData\Local\Vivaldi\User Data\GrShaderCache\*', + '\AppData\Local\Vivaldi\User Data\ShaderCache\*') + # Chrome's on-device Gemini Nano model (~4 GB). Chrome downloads it again unless + # the optimizer's priv-chrome-ai policy is applied, so it is opt-in here. + New-CleanupTask chrome-ai-model 'Chrome on-device AI model (Gemini Nano, ~4 GB)' Browsers Aggressive -DefaultOn $false -Processes chrome -Paths @( + '\AppData\Local\Google\Chrome\User Data\OptGuideOnDeviceModel\*') # ---------------- DevTools (Safe) ---------------- New-CleanupTask npm 'npm cache' DevTools Safe -Paths @('\AppData\Local\npm-cache\*') @@ -548,6 +776,10 @@ function Get-CleanupTaskRegistry { '\.cache\ms-playwright\*', '\AppData\Local\ms-playwright\*', '\.cache\puppeteer\*') + # The global NuGet package folder is re-downloaded on the next restore; opt-in + # because it breaks offline builds until then. + New-CleanupTask nuget-global 'NuGet global packages folder (re-downloaded on restore)' DevTools Aggressive -DefaultOn $false -Paths @( + '\.nuget\packages\*') New-CleanupTask docker-logs 'Docker Desktop / WSL logs & caches' DevTools Safe -Paths @( '\AppData\Local\Docker\log\*', '\AppData\Roaming\Docker Desktop\Cache\*', @@ -563,8 +795,23 @@ function Get-CleanupTaskRegistry { New-CleanupTask teams 'Microsoft Teams cache' Apps Safe -Paths @( '\AppData\Roaming\Microsoft\Teams\Cache\*', '\AppData\Roaming\Microsoft\Teams\GPUCache\*', - '\AppData\Roaming\Microsoft\Teams\Service Worker\CacheStorage\*', - '\AppData\Local\Packages\MSTeams_*\LocalCache\Microsoft\MSTeams\*Cache\*') + '\AppData\Roaming\Microsoft\Teams\Service Worker\CacheStorage\*') + # New Teams (MSTeams package): Microsoft's documented cache reset empties the whole + # LocalCache\Microsoft\MSTeams folder. Only safe while Teams is closed. + New-CleanupTask teams-new 'New Microsoft Teams cache' Apps Moderate -Processes ms-teams -Paths @( + '\AppData\Local\Packages\MSTeams_8wekyb3d8bbwe\LocalCache\Microsoft\MSTeams\*') + # WebView2 hosts (new Outlook, Widgets, Copilot, Teams, many desktop apps): only the + # Chromium cache folders inside each EBWebView profile, never cookies/storage. + New-CleanupTask webview2 'WebView2 app caches (new Outlook, Widgets, Copilot, ...)' Apps Moderate -Processes olk -Paths @( + '\AppData\Local\*\EBWebView\*\Cache\*', + '\AppData\Local\*\EBWebView\*\Code Cache\*', + '\AppData\Local\*\EBWebView\*\GPUCache\*', + '\AppData\Local\*\*\EBWebView\*\Cache\*', + '\AppData\Local\*\*\EBWebView\*\Code Cache\*', + '\AppData\Local\*\*\EBWebView\*\GPUCache\*', + '\AppData\Local\Packages\*\LocalState\EBWebView\*\Cache\*', + '\AppData\Local\Packages\*\LocalState\EBWebView\*\Code Cache\*', + '\AppData\Local\Microsoft\Olk\logs\*') New-CleanupTask discord 'Discord cache' Apps Safe -Paths @( '\AppData\Roaming\discord\Cache\*', '\AppData\Roaming\discord\Code Cache\*', @@ -580,6 +827,10 @@ function Get-CleanupTaskRegistry { '\AppData\Local\Microsoft\Office\*\OfficeFileCache\*', '\AppData\Local\Microsoft\Office\*\Wef\*', '\AppData\Local\Microsoft\Windows\INetCache\Content.Outlook\*') + # Click-to-Run keeps every downloaded update payload; stale ones are never reused. + New-CleanupTask office-c2r 'Office Click-to-Run update downloads (>7 days)' Apps Moderate -AgeDays 7 -Paths @( + '%ProgramFiles%\Microsoft Office\Updates\Download\PackageFiles\*', + '%ProgramFiles(x86)%\Microsoft Office\Updates\Download\PackageFiles\*') New-CleanupTask onedrive 'OneDrive logs' Apps Safe -Paths @( '\AppData\Local\Microsoft\OneDrive\logs\*', '\AppData\Local\Microsoft\OneDrive\setup\logs\*') @@ -651,16 +902,17 @@ function Get-CleanupTaskRegistry { # ---------------- Games (launcher caches, Safe) ---------------- New-CleanupTask game-caches 'Game launcher caches (Steam/Epic/Battle.net/GOG)' Games Safe -Paths @( - '%ProgramFiles(x86)%\Steam\appcache\httpcache\*', - '%ProgramFiles(x86)%\Steam\config\htmlcache\*', - '%ProgramFiles(x86)%\Steam\steamapps\shadercache\*', + '\appcache\httpcache\*', + '\config\htmlcache\*', + '\AppData\Local\Steam\htmlcache\*', '\AppData\Local\EpicGamesLauncher\Saved\webcache\*', + '\AppData\Local\EpicGamesLauncher\Saved\webcache_*\*', '\AppData\Local\Battle.net\Cache\*', '%ProgramData%\Battle.net\Agent\data\cache\*', '\AppData\Local\GOG.com\Galaxy\webcache\*') New-CleanupTask game-logs 'Game launcher logs & crash dumps (Steam/Epic/EA/Ubisoft/Riot/Xbox)' Games Safe -Paths @( - '%ProgramFiles(x86)%\Steam\logs\*', - '%ProgramFiles(x86)%\Steam\dumps\*', + '\logs\*', + '\dumps\*', '\AppData\Local\EpicGamesLauncher\Saved\Logs\*', '\AppData\Local\EpicGamesLauncher\Saved\Crashes\*', '\AppData\Local\Electronic Arts\EA Desktop\Logs\*', @@ -679,7 +931,9 @@ function Get-CleanupTaskRegistry { '\AppData\LocalLow\Unity\Caches\*', '\AppData\Roaming\Godot\shader_cache\*', '\AppData\Roaming\Godot\logs\*') - New-CleanupTask game-shaders 'Per-game shader caches on every disk (Steam libraries / Unity games)' Games Aggressive -Paths @( + # Aggressive: games recompile shaders on next launch (first-run stutter). + New-CleanupTask game-shaders 'Per-game shader caches on every disk (Steam libraries / Unity games)' Games Aggressive -Processes steam -Paths @( + '\steamapps\shadercache\*', 'SteamLibrary\steamapps\shadercache\*', 'Games\Steam\steamapps\shadercache\*', 'Steam\steamapps\shadercache\*', @@ -702,19 +956,55 @@ function Get-CleanupTaskRegistry { '\AppData\Local\NVIDIA\GLCache\*', '\AppData\Local\NVIDIA\OptixCache\*', '\AppData\Local\NVIDIA Corporation\NV_Cache\*', - '\AppData\Local\AMD\DxCache\*') + '\AppData\Local\AMD\DxCache\*', + # Drivers before 571.86 kept these under LocalLow; newer ones moved back to + # Local, so the LocalLow copy is dead weight. + '\AppData\LocalLow\NVIDIA\PerDriverVersion\DXCache\*', + '\AppData\LocalLow\NVIDIA\PerDriverVersion\GLCache\*') New-CleanupTask win-caches 'Windows per-user app caches' System Safe -Paths @( '\AppData\Local\Microsoft\Windows\Caches\*') + # Installer extraction folders only (C:\NVIDIA\DisplayDriver, C:\AMD\*Software*...), + # not any folder that happens to be called NVIDIA/AMD on a data disk. New-CleanupTask gpu-leftovers 'GPU driver installer leftovers (NVIDIA/AMD)' System Safe -Paths @( - 'NVIDIA\*', - 'AMD\*', + '%SystemDrive%\NVIDIA\DisplayDriver\*', + '%SystemDrive%\AMD\*Software*', + '%SystemDrive%\AMD\*Chipset*', + '%SystemDrive%\AMD\*Radeon*', + '%SystemDrive%\AMD\*Driver*', '%ProgramData%\NVIDIA Corporation\Downloader\*', '%ProgramData%\NVIDIA Corporation\NV_Cache\*') New-CleanupTask webcache 'WinINet WebCache database' System Moderate -Paths @( '\AppData\Local\Microsoft\Windows\WebCache\*') - New-CleanupTask deliveryopt 'Delivery Optimization cache' System Safe -Paths @( - '%WINDIR%\ServiceProfiles\NetworkService\AppData\Local\Microsoft\Windows\DeliveryOptimization\*', - '%ProgramData%\Microsoft\Windows\DeliveryOptimization\*') + # Microsoft's own cmdlet empties the cache through DoSvc instead of deleting its + # state files from under the running service. Folder delete is the fallback. + New-CleanupTask deliveryopt 'Delivery Optimization cache' System Safe -Action { + $roots = Expand-TaskPath @( + '%WINDIR%\ServiceProfiles\NetworkService\AppData\Local\Microsoft\Windows\DeliveryOptimization\Cache', + '%WINDIR%\SoftwareDistribution\DeliveryOptimization') + $before = Get-PathBytes $roots + if (Test-WhatIfMode) { + Write-CleanupLog "[WhatIf] would clear the Delivery Optimization cache ($(Format-FileSize $before))" 'WhatIf' + return [pscustomobject]@{ Files = 0; Bytes = $before; Errors = 0 } + } + $viaCmdlet = $false + try { + if (Get-Command Delete-DeliveryOptimizationCache -ErrorAction SilentlyContinue) { + Delete-DeliveryOptimizationCache -Force -ErrorAction Stop + $viaCmdlet = $true + } + } catch { Write-CleanupLog " Delete-DeliveryOptimizationCache: $($_.Exception.Message)" 'Debug' } + if (-not $viaCmdlet) { + $r = Use-StoppedService -Name 'DoSvc' -Body { + Invoke-PathCleanup -Path ($roots | ForEach-Object { "$_\*" }) -Description 'Delivery Optimization cache' + } + return $r + } + $freed = [Math]::Max([int64]0, $before - (Get-PathBytes $roots)) + [pscustomobject]@{ Files = 0; Bytes = $freed; Errors = 0 } + } + # Windows 11 (and patched Windows 10) give SYSTEM processes their own temp dir. + New-CleanupTask systemtemp 'SYSTEM temp folder (Windows\SystemTemp)' System Safe -Paths @( + '%WINDIR%\SystemTemp\*') New-CleanupTask svc-temp 'Service-account temp folders (LocalService / NetworkService / system profile)' System Safe -Paths @( '%WINDIR%\ServiceProfiles\LocalService\AppData\Local\Temp\*', '%WINDIR%\ServiceProfiles\NetworkService\AppData\Local\Temp\*', @@ -740,26 +1030,32 @@ function Get-CleanupTaskRegistry { '%WINDIR%\setupact.log', '%WINDIR%\setuperr.log', '%WINDIR%\PFRO.log') - New-CleanupTask win-misc 'Windows misc caches (Offline Web Pages, Defender scan cache, Search temp, PerfLogs)' System Safe -Paths @( + # Defender's own folders are deliberately NOT here: tamper protection blocks the + # deletes (and can raise tamper alerts), and wiping scan caches forces rescans. + New-CleanupTask win-misc 'Windows misc caches (Offline Web Pages, Search temp, PerfLogs)' System Safe -Paths @( '%WINDIR%\Offline Web Pages\*', - '%ProgramData%\Microsoft\Windows Defender\Scans\mpcache-*', - '%ProgramData%\Microsoft\Windows Defender\Scans\MetaStore\*', - '%ProgramData%\Microsoft\Windows Defender\Scans\FilesStash\*', '%ProgramData%\Microsoft\Windows\Caches\*', '%ProgramData%\Microsoft\Search\Data\Temp\*', '%SystemDrive%\PerfLogs\*') - New-CleanupTask bits-cache 'BITS transfer queue (stuck / partial background downloads)' System Moderate -StopServices @('BITS') -Paths @( + # Resets the whole BITS queue (cancels every job: Windows Update, Defender, Intune), + # so it is a troubleshooting step, opt-in. The ESE database is removed as a set - + # leaving edb*.log / edb.chk behind half-deletes it. + New-CleanupTask bits-cache 'Reset BITS transfer queue (cancels all background downloads)' System Moderate -DefaultOn $false -StopServices @('BITS') -Paths @( '%ProgramData%\Microsoft\Network\Downloader\*.tmp', '%ProgramData%\Microsoft\Network\Downloader\qmgr*.dat', '%ProgramData%\Microsoft\Network\Downloader\qmgr.db', - '%ProgramData%\Microsoft\Network\Downloader\qmgr.jfm') + '%ProgramData%\Microsoft\Network\Downloader\qmgr.jfm', + '%ProgramData%\Microsoft\Network\Downloader\edb*.log', + '%ProgramData%\Microsoft\Network\Downloader\edb*.jrs', + '%ProgramData%\Microsoft\Network\Downloader\edb.chk') New-CleanupTask dns-flush 'Flush DNS resolver, ARP & NetBIOS caches' System Safe -Action { Invoke-NativeStep 'ipconfig /flushdns' { & ipconfig.exe /flushdns *>$null } | Out-Null Invoke-NativeStep 'arp -d *' { & arp.exe -d * *>$null } | Out-Null Invoke-NativeStep 'nbtstat -R' { & nbtstat.exe -R *>$null } | Out-Null $null } - New-CleanupTask store-reset 'Reset Microsoft Store cache (WSReset, silent)' System Safe -Action { + # A repair step rather than cleanup (-i re-provisions the Store), so opt-in. + New-CleanupTask store-reset 'Reset Microsoft Store cache (WSReset, silent)' System Safe -DefaultOn $false -Action { $exe = Join-Path $env:WINDIR 'System32\WSReset.exe' if (-not (Test-Path $exe)) { return $null } Invoke-NativeStep 'WSReset.exe -i' { @@ -768,7 +1064,9 @@ function Get-CleanupTaskRegistry { } | Out-Null $null } - New-CleanupTask memory-standby 'Purge standby memory list & trim working sets (frees RAM, not disk)' System Safe -Action { + # Opt-in: emptying every working set makes apps page back in (stutter) and the + # standby list is Windows' file cache - it refills on its own. + New-CleanupTask memory-standby 'Purge standby memory list & trim working sets (frees RAM, not disk)' System Safe -DefaultOn $false -Action { # Same documented NtSetSystemInformation(SystemMemoryListInformation) call RAMMap uses. if (Test-WhatIfMode) { Write-CleanupLog '[WhatIf] would purge standby memory list' 'WhatIf'; return $null } if (-not ('WinSenior.Memory' -as [type])) { @@ -797,12 +1095,20 @@ public static bool Enable(string name) { Write-CleanupLog (" RAM freed: {0}" -f (Format-FileSize ([Math]::Max([int64]0, [int64]($after - $before))))) 'Success' $null } - New-CleanupTask recent 'Recent items & jump lists' System Moderate -DefaultOn $true -Paths @( - '\AppData\Roaming\Microsoft\Windows\Recent\*') + # Recent-item shortcuts only. Jump lists hold the user's PINNED items (and the + # Quick Access pins file f01b4d95cf55d32a...), so they are a separate opt-in task. + New-CleanupTask recent 'Recent items list' System Moderate -DefaultOn $true -Paths @( + '\AppData\Roaming\Microsoft\Windows\Recent\*.lnk') + New-CleanupTask jumplists 'Jump lists (unpins jump-list items; Quick Access pins kept)' System Aggressive -DefaultOn $false ` + -Exclude @('f01b4d95cf55d32a*') -Paths @( + '\AppData\Roaming\Microsoft\Windows\Recent\AutomaticDestinations\*', + '\AppData\Roaming\Microsoft\Windows\Recent\CustomDestinations\*') New-CleanupTask fontcache 'Font cache' System Moderate -StopServices @('FontCache') -Paths @( '%WINDIR%\ServiceProfiles\LocalService\AppData\Local\FontCache\*') New-CleanupTask winlogs 'Windows log files' System Moderate -Paths @('%WINDIR%\Logs\*') - New-CleanupTask prefetch 'Prefetch (rebuilt by Windows)' System Aggressive -Paths @( + # Microsoft: clearing Prefetch slows the next boots/app launches while it rebuilds. + # Kept for privacy-minded users, opt-in; Layout.ini (boot defrag layout) is kept. + New-CleanupTask prefetch 'Prefetch (slower boot until rebuilt)' System Aggressive -DefaultOn $false -Exclude @('Layout.ini') -Paths @( '%WINDIR%\Prefetch\*') New-CleanupTask search-index 'Rebuild Windows Search index (deletes Windows.edb, re-indexes in background)' System Aggressive -DefaultOn $false -Action { $db = @("$env:ProgramData\Microsoft\Search\Data\Applications\Windows\Windows.edb", @@ -840,10 +1146,12 @@ public static bool Enable(string name) { catch { Write-CleanupLog "Driver enumeration failed: $($_.Exception.Message)" 'Warning'; return $null } finally { $global:WhatIfPreference = $prevWhatIf } - # Group third-party packages by original .inf name; keep the newest version of + # Group third-party packages by provider + class + original .inf name (inf names + # alone collide across vendors, e.g. usbser.inf); keep the newest version of # each, mark older duplicates. Never touch boot-critical drivers. $stale = foreach ($g in ($pkgs | Where-Object { -not $_.BootCritical -and $_.OriginalFileName } | - Group-Object { [System.IO.Path]::GetFileName([string]$_.OriginalFileName).ToLowerInvariant() })) { + Group-Object { '{0}|{1}|{2}' -f $_.ProviderName, $_.ClassName, + [System.IO.Path]::GetFileName([string]$_.OriginalFileName).ToLowerInvariant() })) { if ($g.Count -lt 2) { continue } $g.Group | Sort-Object @{ E = { try { [version]$_.Version } catch { [version]'0.0' } } }, Date -Descending | @@ -878,7 +1186,8 @@ public static bool Enable(string name) { # ---------------- Disks (every local fixed drive) ---------------- # Recycle Bins on every drive are emptied by the 'recyclebin' task (Clear-RecycleBin # spans all drives). These add drive-level scratch/junk on C:, D:, E: ... - New-CleanupTask disk-temp 'Drive-level temp folders (all local disks)' Disks Moderate -Paths @( + # A 'Temp' folder on a data disk may be someone's working folder: only week-old files. + New-CleanupTask disk-temp 'Drive-level temp folders (all local disks, >7 days)' Disks Moderate -AgeDays 7 -Paths @( 'Temp\*', 'tmp\*') New-CleanupTask disk-chkdsk 'CHKDSK recovered fragments (FOUND.*)' Disks Safe -Paths @( @@ -889,13 +1198,13 @@ public static bool Enable(string name) { '%ProgramData%\Microsoft\Windows\WER\ReportQueue\*', '%ProgramData%\Microsoft\Windows\WER\ReportArchive\*', '\AppData\Local\Microsoft\Windows\WER\*') - New-CleanupTask extra-logs 'Setup logs & Defender scan history' Logs Safe -Paths @( + New-CleanupTask extra-logs 'Setup & device-install logs' Logs Safe -Paths @( '%WINDIR%\Panther\*', '%WINDIR%\inf\setupapi.dev*.log', - '%WINDIR%\inf\setupapi.setup*.log', - '%ProgramData%\Microsoft\Windows Defender\Scans\History\Results\*') + '%WINDIR%\inf\setupapi.setup*.log') New-CleanupTask livekernel 'Live kernel crash dumps (driver/GPU TDR)' Logs Safe -Paths @( - '%WINDIR%\LiveKernelReports\*.dmp') + '%WINDIR%\LiveKernelReports\*.dmp', + '%WINDIR%\LiveKernelReports\*\*.dmp') New-CleanupTask diag-telemetry 'Diagnostics & telemetry caches (Diagnosis, ETL traces, SleepStudy, WDI)' Logs Moderate -StopServices @('DiagTrack') -Paths @( '%ProgramData%\Microsoft\Diagnosis\ETLLogs\*', '%ProgramData%\Microsoft\Diagnosis\DownloadedSettings\*', @@ -959,42 +1268,52 @@ public static bool Enable(string name) { New-CleanupTask iislogs 'Old IIS logs (>14 days)' Logs Moderate -DefaultOn $true -AgeDays 14 -Paths @( '%WINDIR%\System32\LogFiles\W3SVC*\*.log', '%WINDIR%\System32\LogFiles\HTTPERR\*.log') - New-CleanupTask recyclebin 'Recycle Bin' Logs Moderate -Action { - if (Test-WhatIfMode) { Write-CleanupLog '[WhatIf] would empty the Recycle Bin' 'WhatIf'; return $null } - try { - Clear-RecycleBin -Force -ErrorAction Stop - Write-CleanupLog 'Recycle Bin emptied' 'Success' - } catch { - Write-CleanupLog "Recycle Bin: $($_.Exception.Message)" 'Warning' - } - $null - } + # Every user's bin on every disk, with real byte accounting. (Clear-RecycleBin only + # empties the CALLER's bin - nothing at all when the weekly task runs as SYSTEM.) + New-CleanupTask recyclebin 'Recycle Bin (all users, all disks)' Logs Moderate -Exclude @('desktop.ini') -Paths @( + '$Recycle.Bin\\*') New-CleanupTask eventlogs 'Clear event logs (archived first)' Logs Dangerous -Action { if (Test-WhatIfMode) { Write-CleanupLog '[WhatIf] would archive & clear Application/System/Setup logs' 'WhatIf'; return $null } - $archive = Join-Path $env:TEMP "EventLogBackup_$(Get-Date -Format 'yyyyMMdd_HHmmss')" + # Archive outside %TEMP% - the temp tasks would delete the backup on the next run. + $archive = Join-Path $env:ProgramData "WinSenior\eventlogs\$(Get-Date -Format 'yyyyMMdd_HHmmss')" New-Item -ItemType Directory -Path $archive -Force -ErrorAction SilentlyContinue | Out-Null + $err = 0 foreach ($log in 'Application','System','Setup') { $dest = Join-Path $archive "$log.evtx" & wevtutil.exe export-log $log "$dest" /overwrite:true 2>$null + if ($LASTEXITCODE -ne 0 -or -not (Test-Path -LiteralPath $dest)) { + Write-CleanupLog "Export of '$log' failed - log NOT cleared" 'Warning'; $err++; continue + } & wevtutil.exe clear-log $log 2>$null - Write-CleanupLog "Archived & cleared '$log' (backup: $dest)" 'Success' + if ($LASTEXITCODE -eq 0) { Write-CleanupLog "Archived & cleared '$log' (backup: $dest)" 'Success' } + else { Write-CleanupLog "Clearing '$log' failed (exit $LASTEXITCODE)" 'Warning'; $err++ } } - $null + [pscustomobject]@{ Files = 0; Bytes = [int64]0; Errors = $err } } # ---------------- Updates ---------------- + # Only the download cache. catroot2 is a troubleshooting reset (Troubleshoot tab), + # not a cache. Skipped while an update is staged and waiting for a reboot - + # deleting its payload then makes the install fail and re-download. New-CleanupTask wu-cache 'Windows Update download cache' Updates Moderate ` - -StopServices @('wuauserv','bits','cryptsvc') -Paths @( - '%WINDIR%\SoftwareDistribution\Download\*', - '%WINDIR%\System32\catroot2\*') - New-CleanupTask wu-full 'Full SoftwareDistribution reset' Updates Aggressive -DefaultOn $true ` - -StopServices @('wuauserv','bits','cryptsvc') -Paths @( + -SkipIf { if (Test-UpdateRebootPending) { 'an update is waiting for a reboot - restart first' } } ` + -StopServices @('wuauserv','bits') -Paths @( + '%WINDIR%\SoftwareDistribution\Download\*') + # Deletes update history (DataStore) too: a repair step, not routine cleanup. + New-CleanupTask wu-full 'Full SoftwareDistribution reset (wipes update history)' Updates Aggressive -DefaultOn $false ` + -SkipIf { if (Test-UpdateRebootPending) { 'an update is waiting for a reboot - restart first' } } ` + -StopServices @('wuauserv','bits','UsoSvc') -Paths @( '%WINDIR%\SoftwareDistribution\*') New-CleanupTask patchcache 'Windows Installer patch cache' Updates Dangerous -Paths @( '%WINDIR%\Installer\$PatchCache$\*', '%WINDIR%\Installer\*.tmp') New-CleanupTask windows-old 'Windows.old & upgrade leftovers' Updates Dangerous -Action { $total = [pscustomobject]@{ Files = 0; Bytes = 0; Errors = 0 } + $old = Get-Item -LiteralPath "$env:SystemDrive\Windows.old" -Force -ErrorAction SilentlyContinue + if ($old -and $old.CreationTime -gt (Get-Date).AddDays(-10)) { + Write-CleanupLog (" Windows.old is only {0:N0} day(s) old - removing it ends the 'go back' rollback window" -f ` + ((Get-Date) - $old.CreationTime).TotalDays) 'Safety' + } foreach ($folder in @( "$env:SystemDrive\Windows.old", "$env:SystemDrive\`$Windows.~BT", @@ -1007,6 +1326,13 @@ public static bool Enable(string name) { $total } + # Update Assistant / Media Creation / reset scratch left at the drive root once the + # upgrade or reset has finished (contents only - the engine never deletes roots). + New-CleanupTask upgrade-leftovers 'Upgrade assistant & reset leftovers ($GetCurrent, Windows10Upgrade, $SysReset)' Updates Moderate ` + -SkipIf { if (Test-UpdateRebootPending) { 'an update is waiting for a reboot - restart first' } } -Paths @( + '%SystemDrive%\$GetCurrent\*', + '%SystemDrive%\Windows10Upgrade\*', + '%SystemDrive%\$SysReset\*') New-CleanupTask hiberfil 'Disable hibernation & delete hiberfil.sys (also disables Fast Startup)' Updates Dangerous -DefaultOn $false -Action { $f = "$env:SystemDrive\hiberfil.sys" if (-not (Test-Path -LiteralPath $f)) { Write-CleanupLog ' hibernation already off' 'Debug'; return $null } @@ -1019,7 +1345,11 @@ public static bool Enable(string name) { [pscustomobject]@{ Files = 1; Bytes = $(if ($ok) { $size } else { 0 }); Errors = $(if ($ok) { 0 } else { 1 }) } } New-CleanupTask shadow-old 'Delete all but the newest restore point / shadow copy (vssadmin)' Updates Dangerous -DefaultOn $false -Action { - $shadows = @(Get-CimInstance Win32_ShadowCopy -ErrorAction SilentlyContinue | Sort-Object InstallDate) + # System-drive restore-point shadows only: other volumes' shadows and the + # non-client-accessible ones made by backup software are left alone. + $sysVol = (Get-CimInstance Win32_Volume -Filter "DriveLetter='$env:SystemDrive'" -ErrorAction SilentlyContinue).DeviceID + $shadows = @(Get-CimInstance Win32_ShadowCopy -ErrorAction SilentlyContinue | + Where-Object { $_.VolumeName -eq $sysVol -and $_.ClientAccessible } | Sort-Object InstallDate) if ($shadows.Count -le 1) { Write-CleanupLog ' nothing to prune' 'Debug'; return $null } $old = @($shadows | Select-Object -First ($shadows.Count - 1)) if (Test-WhatIfMode) { @@ -1041,28 +1371,42 @@ public static bool Enable(string name) { $out | Where-Object { $_ -match ':' } | ForEach-Object { Write-CleanupLog " $_" 'Debug' } Write-CleanupLog 'Component store analyzed' 'Success'; $null } - New-CleanupTask component-task 'Run StartComponentCleanup scheduled task' Optimization Moderate -Action { + # The scheduled task runs the same cleanup in the background; running it AND the + # synchronous DISM call below makes them compete, so the task is opt-in. + New-CleanupTask component-task 'Run StartComponentCleanup scheduled task (background)' Optimization Moderate -DefaultOn $false -Action { Invoke-NativeStep 'schtasks StartComponentCleanup' { & schtasks.exe /Run /TN '\Microsoft\Windows\Servicing\StartComponentCleanup' *>$null } | Out-Null $null } - New-CleanupTask dism-cleanup 'DISM component cleanup' Optimization Moderate -Action { - Invoke-NativeStep 'DISM /StartComponentCleanup' { + # WinSxS is hard-linked, so folder sizes lie; the system drive's free-space delta + # is the honest measure of what component cleanup gave back. + New-CleanupTask dism-cleanup 'DISM component cleanup' Optimization Moderate ` + -SkipIf { if (Test-UpdateRebootPending) { 'servicing is waiting for a reboot - restart first' } } -Action { + $free0 = (New-Object System.IO.DriveInfo($env:SystemDrive)).AvailableFreeSpace + $ok = Invoke-NativeStep 'DISM /StartComponentCleanup' { & dism.exe /online /Cleanup-Image /StartComponentCleanup /Quiet *>$null - } | Out-Null - $null + } + if (Test-WhatIfMode) { return $null } + if (-not $ok) { return [pscustomobject]@{ Files = 0; Bytes = [int64]0; Errors = 1 } } + $gain = (New-Object System.IO.DriveInfo($env:SystemDrive)).AvailableFreeSpace - $free0 + [pscustomobject]@{ Files = 0; Bytes = [int64][Math]::Max(0, $gain); Errors = 0 } } - New-CleanupTask dism-resetbase 'DISM reset base + remove superseded' Optimization Aggressive -DefaultOn $true -Action { - Invoke-NativeStep 'DISM /SPSuperseded' { - & dism.exe /online /Cleanup-Image /SPSuperseded *>$null - } | Out-Null - Invoke-NativeStep 'DISM /StartComponentCleanup /ResetBase' { + # /ResetBase makes every installed update permanently UNINSTALLABLE - irreversible, + # so it lives in the Dangerous tier with the other point-of-no-return operations. + # (/SPSuperseded only ever applied to Windows 7-era service packs.) + New-CleanupTask dism-resetbase 'DISM reset base (installed updates can no longer be uninstalled)' Optimization Dangerous -DefaultOn $true ` + -SkipIf { if (Test-UpdateRebootPending) { 'servicing is waiting for a reboot - restart first' } } -Action { + $free0 = (New-Object System.IO.DriveInfo($env:SystemDrive)).AvailableFreeSpace + $ok = Invoke-NativeStep 'DISM /StartComponentCleanup /ResetBase' { & dism.exe /online /Cleanup-Image /StartComponentCleanup /ResetBase /Quiet *>$null - } | Out-Null - $null + } + if (Test-WhatIfMode) { return $null } + if (-not $ok) { return [pscustomobject]@{ Files = 0; Bytes = [int64]0; Errors = 1 } } + $gain = (New-Object System.IO.DriveInfo($env:SystemDrive)).AvailableFreeSpace - $free0 + [pscustomobject]@{ Files = 0; Bytes = [int64][Math]::Max(0, $gain); Errors = 0 } } - New-CleanupTask dism-logs 'DISM logs' Optimization Safe -Paths @('%WINDIR%\Logs\DISM\*') + New-CleanupTask dism-logs 'DISM logs (>7 days)' Optimization Safe -AgeDays 7 -Paths @('%WINDIR%\Logs\DISM\*') New-CleanupTask sfc 'System File Checker (sfc /scannow)' Optimization Moderate -DefaultOn $true -Action { Invoke-NativeStep 'sfc /scannow' { & sfc.exe /scannow | Out-Null } | Out-Null $null @@ -1100,6 +1444,18 @@ function Invoke-CleanupTask { param([object]$Task) Write-CleanupLog "$($Task.Name) [$($Task.Category)/$($Task.Risk)]" 'Step' + $skip = $null + if ($Task.SkipIf) { $skip = & $Task.SkipIf } + if ($skip) { Write-CleanupLog " skipped: $skip" 'Warning' } + elseif (Test-TaskBlocked -Task $Task) { $skip = 'app running' } + if ($skip) { + $script:Stats.Add([pscustomobject]@{ + Task = $Task.Id; Name = $Task.Name; Category = $Task.Category; Risk = $Task.Risk + Files = 0; Bytes = [int64]0; Errors = 0; Deferred = 0; Skipped = [string]$skip + }) + return + } + $result = $null if ($Task.Action) { $result = & $Task.Action @@ -1107,13 +1463,14 @@ function Invoke-CleanupTask { else { $paths = Expand-TaskPath $Task.Paths $effAge = [Math]::Max($MaxAgeDays, $Task.AgeDays) + $excl = $Task.Exclude if ($Task.StopServices) { $result = Use-StoppedService -Name $Task.StopServices -Body { - Invoke-PathCleanup -Path $paths -AgeDays $effAge -Description $Task.Name + Invoke-PathCleanup -Path $paths -AgeDays $effAge -Description $Task.Name -ExcludePattern $excl } } else { - $result = Invoke-PathCleanup -Path $paths -AgeDays $effAge -Description $Task.Name + $result = Invoke-PathCleanup -Path $paths -AgeDays $effAge -Description $Task.Name -ExcludePattern $excl } } @@ -1126,6 +1483,7 @@ function Invoke-CleanupTask { $script:Stats.Add([pscustomobject]@{ Task = $Task.Id; Name = $Task.Name; Category = $Task.Category; Risk = $Task.Risk Files = [int]$result.Files; Bytes = [int64]$result.Bytes; Errors = [int]$result.Errors; Deferred = $def + Skipped = $null }) if ($result.Bytes -gt 0 -or $result.Files -gt 0) { $verb = if (Test-WhatIfMode) { 'would free' } else { 'freed' } @@ -1199,7 +1557,7 @@ function Show-TaskList { function Show-UsageHelp { @' -Windows System Cleaner and Optimizer v6.0 (registry-driven engine) +Windows System Cleaner and Optimizer (registry-driven engine) USAGE .\Cleanup-Windows-Senior.ps1 [options] @@ -1208,17 +1566,19 @@ SELECTION -Category Limit to: Browsers, DevTools, Apps, Games, System, Disks, Logs, Updates, Optimization -Include Force tasks on (see -ListTasks for ids) -Exclude Force tasks off - -IncludeDangerous Also run irreversible tier (event logs, patch cache, Windows.old, old drivers) + -IncludeDangerous Also run irreversible tier (event logs, patch cache, Windows.old, old drivers, DISM ResetBase) -Conservative Cap at Safe + Moderate (skip Aggressive) -CurrentUserOnly,-cu Clean only the current profile (default: all users) -Drives Local disks for drive-level cleanup, e.g. -Drives C,D (default: all local disks) -SkipOptimization,-so Skip the slow SFC/DISM category -MaxAgeDays Only delete files older than n days -DeferLocked, -dl Schedule locked/in-use files for deletion at next reboot + -CloseApps Close running browsers (this session) instead of skipping their caches + (interactive runs close them anyway; -Unattended runs skip without it) SAFETY -WhatIf / -DryRun,-dr Preview only, change nothing (real ShouldProcess) - -NoRestorePoint,-nrp Skip the Checkpoint-Computer restore point (created by default) + -NoRestorePoint,-nrp Skip the System Restore point (created by default) -Unattended,-Force,-f No prompts / no GUI - for scheduled tasks, GPO, SCCM, Intune OUTPUT @@ -1279,8 +1639,6 @@ function Start-WindowsCleanup { # Real restore point first (unless previewing or opted out). if (-not $NoRestorePoint -and -not (Test-WhatIfMode)) { New-CleanupRestorePoint | Out-Null } - if ($selection | Where-Object { $_.Category -eq 'Browsers' }) { Stop-BrowserProcesses } - $order = 'Browsers','DevTools','Apps','Games','System','Disks','Logs','Updates','Optimization' foreach ($cat in $order) { foreach ($task in ($selection | Where-Object { $_.Category -eq $cat })) { diff --git a/Optimize-Windows-Senior.ps1 b/Optimize-Windows-Senior.ps1 index a3c9ebf..1f3fdea 100644 --- a/Optimize-Windows-Senior.ps1 +++ b/Optimize-Windows-Senior.ps1 @@ -17,7 +17,7 @@ .NOTES Author : denfry (https://github.com/denfry/WindowsCleaner) - Version : 6.2.0 + Version : 6.3.0 Requires: PowerShell 5.1+ (Windows). Administrator rights. .EXAMPLE @@ -67,7 +67,7 @@ param( [Alias('Force','f')] [switch]$Unattended, - # Skip the real Checkpoint-Computer restore point that is otherwise created first + # Skip the real System Restore point that is otherwise created first [Alias('nrp')] [switch]$NoRestorePoint, @@ -95,9 +95,21 @@ $script:Applied = 0 $script:Skipped = 0 $script:Errors = 0 $script:RestorePointMade = $false +$script:ManifestFile = $null +$script:AppxReady = $null if ($DryRun) { $WhatIfPreference = $true } +# Split comma-joined id lists. A caller using -File (the desktop app) passes +# -Include 'a,b,c' as ONE string, which would never match a tweak id. +function ConvertTo-OptIdList { + param([string[]]$Value) + @($Value | ForEach-Object { "$_" -split ',' } | ForEach-Object { $_.Trim() } | Where-Object { $_ }) +} +$Area = ConvertTo-OptIdList -Value $Area +$Include = ConvertTo-OptIdList -Value $Include +$Exclude = ConvertTo-OptIdList -Value $Exclude + # ===================================================================== # SHARED LIBRARY (admin / restore-point / logging / format helpers) # ===================================================================== @@ -131,24 +143,38 @@ function New-OptRestorePoint { # ===================================================================== function Get-RegValueSnapshot { param([string]$Path, [string]$Name) - $snap = [ordered]@{ Name = $Name; Existed = $false; Value = $null; Kind = $null } + $snap = [ordered]@{ Path = $Path; Name = $Name; Existed = $false; Value = $null; Kind = $null } if (Test-Path -LiteralPath $Path) { $item = Get-Item -LiteralPath $Path -ErrorAction SilentlyContinue if ($item -and ($item.GetValueNames() -contains $Name)) { $snap.Existed = $true - $snap.Value = $item.GetValue($Name) + # Keep REG_EXPAND_SZ data unexpanded so a restore writes back the original text. + $snap.Value = $item.GetValue($Name, $null, [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames) try { $snap.Kind = [string]$item.GetValueKind($Name) } catch { $snap.Kind = $null } } } [pscustomobject]$snap } +# Coerce a value to what New-ItemProperty expects for the given kind. Needed on +# restore, where a manifest round-trip turns byte[] / string[] into object[]. +function ConvertTo-RegData { + param([string]$Kind, $Value) + if ($null -eq $Value -and $Kind -eq 'Binary') { return ,([byte[]]@()) } + switch ($Kind) { + 'Binary' { return ,([byte[]]@($Value | ForEach-Object { [byte]$_ })) } + 'MultiString' { return ,([string[]]@($Value | ForEach-Object { [string]$_ })) } + default { return $Value } + } +} + function Set-RegValue { param([string]$Path, [string]$Name, [string]$Kind, $Value) if (-not (Test-Path -LiteralPath $Path)) { New-Item -Path $Path -Force -ErrorAction Stop | Out-Null } - New-ItemProperty -Path $Path -Name $Name -PropertyType $Kind -Value $Value ` + $data = ConvertTo-RegData -Kind $Kind -Value $Value + New-ItemProperty -Path $Path -Name $Name -PropertyType $Kind -Value $data ` -Force -ErrorAction Stop | Out-Null } @@ -164,6 +190,182 @@ function Restore-RegValue { } } +# Comparable text for a registry value: arrays (Binary/MultiString) join with ',', +# and DWORD/QWORD compare as unsigned so 0xFFFFFFFF matches 4294967295. +function ConvertTo-RegCompareText { + param([string]$Kind, $Value) + if ($null -eq $Value) { return '' } + if ($Value -is [array]) { return ((@($Value) | ForEach-Object { [string]$_ }) -join ',') } + if ($Kind -eq 'DWord') { + $n = [int64]$Value + if ($n -lt 0) { $n += 4294967296 } + return [string]$n + } + [string]$Value +} + +# A RegVal may carry its own -Path; otherwise it lives under the tweak's Spec.Path. +function Get-RegValuePath { + param([object]$Tweak, [object]$Value) + if ($Value.Path) { [string]$Value.Path } else { [string]$Tweak.Spec.Path } +} + +# ===================================================================== +# NATIVE-TOOL HELPERS (powercfg / DISM exit codes, locale-safe parsing) +# ===================================================================== +# Run a read-only probe with -WhatIf lifted. Modules imported on demand (Appx, +# Dism, CimCmdlets) take WhatIf from the script scope, so under -WhatIf their own +# alias/proxy setup is suppressed (flooding the preview and half-loading Appx). +function Invoke-OptReadOnly { + param([scriptblock]$Script) + $saved = $script:WhatIfPreference + try { + $script:WhatIfPreference = $false + & $Script + } + finally { $script:WhatIfPreference = $saved } +} + +function Invoke-OptNative { + param([string]$FilePath, [string[]]$Arguments, [int[]]$OkCodes = @(0)) + $out = & $FilePath @Arguments 2>&1 + if ($OkCodes -notcontains $LASTEXITCODE) { + $msg = (@($out) | ForEach-Object { [string]$_ } | Where-Object { $_.Trim() }) -join ' ' + throw ("{0} {1} failed (exit {2}) {3}" -f $FilePath, ($Arguments -join ' '), $LASTEXITCODE, $msg) + } + $out +} + +# All power-scheme GUIDs from `powercfg /list`. Parses GUIDs only (output is localized). +function Get-OptPowerSchemeList { + $text = (& powercfg /list 2>$null) -join "`n" + $rx = '[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}' + @([regex]::Matches($text, $rx) | ForEach-Object { $_.Value.ToLowerInvariant() } | Sort-Object -Unique) +} + +function Get-OptActiveSchemeGuid { + $text = (& powercfg /getactivescheme 2>$null) -join ' ' + $m = [regex]::Match($text, '[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}') + if ($m.Success) { $m.Value.ToLowerInvariant() } else { $null } +} + +# Current AC/DC index of one power setting. `powercfg /q` prints the AC index then the +# DC index as the last two 0x######## tokens (range settings print min/max before them). +function Get-OptPowerSettingIndex { + param([string]$Scheme = 'SCHEME_CURRENT', [string]$SubGroup, [string]$Setting) + $text = (& powercfg /q $Scheme $SubGroup $Setting 2>$null) -join "`n" + if ($LASTEXITCODE -ne 0) { return $null } + $hex = @([regex]::Matches($text, '0x[0-9a-fA-F]{8}') | ForEach-Object { $_.Value }) + if ($hex.Count -lt 2) { return $null } + [pscustomobject]@{ + AC = [Convert]::ToInt64($hex[$hex.Count - 2].Substring(2), 16) + DC = [Convert]::ToInt64($hex[$hex.Count - 1].Substring(2), 16) + } +} + +# Why High Performance should be skipped on this machine, or $null to proceed. +function Get-OptPowerHighSkipReason { + if ((Get-OptPowerSchemeList) -notcontains '8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c') { + return 'High Performance plan is not available here (Modern Standby devices only expose Balanced)' + } + $bat = @() + try { $bat = @(Invoke-OptReadOnly -Script { Get-CimInstance -ClassName Win32_Battery -ErrorAction Stop }) } catch { $bat = @() } + if ($bat.Count) { return 'a battery is present (laptop or UPS) - High Performance would drain it; left unchanged' } + $null +} + +# ===================================================================== +# APPX HELPERS +# Under pwsh 7 the Appx module often fails to load natively ("Operation is +# not supported on this platform", 0x80131539), and -ErrorAction on the +# cmdlet does not catch a module-load failure. Fall back to the Windows +# PowerShell compatibility session; report a real error if both fail. +# ===================================================================== +function Initialize-OptAppx { + if ($null -ne $script:AppxReady) { return $script:AppxReady } + $script:AppxReady = $false + # Module loading is read-only; without this, -WhatIf suppresses the module's own + # alias/proxy setup (and floods the preview with it), leaving Appx half-loaded. + Invoke-OptReadOnly -Script { + try { + Import-Module Appx -ErrorAction Stop -WarningAction SilentlyContinue + $null = Get-AppxPackage -Name 'WinSenior.Probe.None' -ErrorAction Stop + $script:AppxReady = $true + } + catch { + if ($PSVersionTable.PSEdition -eq 'Core') { + try { + Remove-Module Appx -Force -ErrorAction SilentlyContinue + Import-Module Appx -UseWindowsPowerShell -ErrorAction Stop -WarningAction SilentlyContinue + $null = Get-AppxPackage -Name 'WinSenior.Probe.None' -ErrorAction Stop + $script:AppxReady = $true + } catch { $script:AppxReady = $false } + } + } + } + $script:AppxReady +} + +function Get-OptAppxPattern { + param([ValidateSet('junk','xbox','comms','copilot')][string]$Set) + switch ($Set) { + 'junk' { + @('king.com*','*CandyCrush*','*BubbleWitch*','*Microsoft.3DBuilder*','*Microsoft.Microsoft3DViewer*', + '*Microsoft.MicrosoftSolitaireCollection*','*Microsoft.MixedReality.Portal*','*Microsoft.WindowsFeedbackHub*', + '*Microsoft.Getstarted*','*Microsoft.WindowsMaps*','*Microsoft.BingNews*', + '*Microsoft.BingWeather*','*Microsoft.People*','*Clipchamp*','*Microsoft.Todos*','*Disney*','*SpotifyAB*') + } + 'xbox' { @('*Xbox*') } + 'comms' { @('*Microsoft.windowscommunicationsapps*','*Microsoft.SkypeApp*','*Microsoft.YourPhone*') } + 'copilot' { @('Microsoft.Copilot') } + } +} + +# Installed packages matching the patterns. Throws when Appx is unusable. +function Get-OptAppxMatch { + param([string[]]$Pattern, [switch]$AllUsers) + if (-not (Initialize-OptAppx)) { throw 'The Appx module cannot be loaded in this PowerShell session.' } + # One enumeration filtered locally: a Get-AppxPackage call per pattern costs seconds each. + $all = if ($AllUsers) { @(Get-AppxPackage -AllUsers -ErrorAction Stop) } else { @(Get-AppxPackage -ErrorAction Stop) } + $pk = foreach ($p in $Pattern) { $all | Where-Object { $_ -and ([string]$_.Name -like $p) } } + @($pk | + ForEach-Object { [pscustomobject]@{ Name = [string]$_.Name; FullName = [string]$_.PackageFullName } } | + Sort-Object FullName -Unique) +} + +# Remove matching packages (and optionally their provisioned copies). Logs each +# failure; throws when packages were found but none could be removed. +function Invoke-OptAppxRemoval { + param([string[]]$Pattern, [switch]$AllUsers, [switch]$Provisioned) + $found = @(Get-OptAppxMatch -Pattern $Pattern -AllUsers:$AllUsers) + $removed = 0 + $failed = @() + foreach ($x in $found) { + try { + if ($AllUsers) { Remove-AppxPackage -Package $x.FullName -AllUsers -ErrorAction Stop } + else { Remove-AppxPackage -Package $x.FullName -ErrorAction Stop } + $removed++ + } + catch { + $failed += $x.Name + Write-OptLog (" could not remove {0}: {1}" -f $x.Name, $_.Exception.Message) 'Warning' + } + } + if ($Provisioned) { + try { + $prov = @(Get-AppxProvisionedPackage -Online -ErrorAction Stop) + foreach ($p in $Pattern) { + foreach ($pp in ($prov | Where-Object { $_.DisplayName -like $p })) { + try { Remove-AppxProvisionedPackage -Online -PackageName $pp.PackageName -ErrorAction Stop | Out-Null } + catch { Write-OptLog (" could not deprovision {0}: {1}" -f $pp.DisplayName, $_.Exception.Message) 'Warning' } + } + } + } + catch { Write-OptLog " provisioned packages unavailable: $($_.Exception.Message)" 'Warning' } + } + if ($failed.Count -and -not $removed) { throw ("could not remove: {0}" -f ($failed -join ', ')) } +} + # ===================================================================== # TWEAK REGISTRY (the single source of truth) # ===================================================================== @@ -206,28 +408,37 @@ function New-CustomTweak { [string]$Id, [string]$Name, [string]$Area, [string]$Risk, [bool]$DefaultOn = $true, [scriptblock]$Test, [scriptblock]$Backup, [scriptblock]$Apply, [scriptblock]$Undo, + # Optional: returns a reason string when the tweak does not apply to this machine. + [scriptblock]$SkipIf, [string]$Explain ) [pscustomobject]@{ Id = $Id; Name = $Name; Area = $Area; Risk = $Risk; DefaultOn = $DefaultOn Type = 'Custom'; Explain = $Explain - Spec = @{ Test = $Test; Backup = $Backup; Apply = $Apply; Undo = $Undo } + Spec = @{ Test = $Test; Backup = $Backup; Apply = $Apply; Undo = $Undo; SkipIf = $SkipIf } } } -# Convenience for a single name/kind/value registry pair. -function RegVal { param([string]$Name, [string]$Kind, $Value) - [pscustomobject]@{ Name = $Name; Kind = $Kind; Value = $Value } } +# Convenience for a single name/kind/value registry pair. -Path overrides the +# tweak's Spec.Path for this one value (lets one tweak span several keys). +function RegVal { param([string]$Name, [string]$Kind, $Value, [string]$Path) + [pscustomobject]@{ Name = $Name; Kind = $Kind; Value = $Value; Path = $Path } } function Get-OptimizationTweakRegistry { @( # ============================================================= # PERFORMANCE # ============================================================= - New-RegTweak perf-visualfx 'Visual effects: best performance' Performance Safe ` + New-RegTweak perf-visualfx 'Visual effects: performance, keep font smoothing' Performance Safe ` -Path 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Explorer\VisualEffects' ` - -Values @((RegVal 'VisualFXSetting' DWord 2)) ` - -Explain 'Disables animations/shadows for snappier UI (Performance Options = best performance).' + -Values @( + (RegVal 'VisualFXSetting' DWord 3), + (RegVal 'UserPreferencesMask' Binary ([byte[]](0x90,0x12,0x03,0x80,0x10,0x00,0x00,0x00)) -Path 'HKCU:\Control Panel\Desktop'), + (RegVal 'FontSmoothing' String '2' -Path 'HKCU:\Control Panel\Desktop'), + (RegVal 'MinAnimate' String '0' -Path 'HKCU:\Control Panel\Desktop\WindowMetrics'), + (RegVal 'TaskbarAnimations' DWord 0 -Path 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Explorer\Advanced'), + (RegVal 'ListviewAlphaSelect' DWord 0 -Path 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Explorer\Advanced')) ` + -Explain 'Custom Performance Options: animations, fades and shadows off, but ClearType font smoothing and window contents while dragging kept. Takes full effect after sign-out.' New-RegTweak perf-menudelay 'Zero menu show delay' Performance Safe ` -Path 'HKCU:\Control Panel\Desktop' ` -Values @((RegVal 'MenuShowDelay' String '0')) ` @@ -236,26 +447,88 @@ function Get-OptimizationTweakRegistry { -Path 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Explorer\Serialize' ` -Values @((RegVal 'StartupDelayInMSec' DWord 0)) ` -Explain 'Startup programs launch without the artificial ~10 s delay.' - New-RegTweak perf-bgapps 'Disable background apps' Performance Moderate ` + New-RegTweak perf-bgapps 'Disable background apps' Performance Moderate -DefaultOn $false ` -Path 'HKCU:\Software\Microsoft\Windows\CurrentVersion\BackgroundAccessApplications' ` -Values @((RegVal 'GlobalUserDisabled' DWord 1)) ` - -Explain 'Stops UWP apps from running and updating in the background.' + -Explain 'Stops UWP apps from running and updating in the background. Off by default: breaks Phone Link, Mail/Calendar and Alarms notifications; negligible gain on modern builds.' New-RegTweak perf-faststartup 'Disable Fast Startup (hybrid boot)' Performance Moderate -DefaultOn $false ` -Path 'HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager\Power' ` -Values @((RegVal 'HiberbootEnabled' DWord 0)) ` -Explain 'Ensures a clean full shutdown (fixes dual-boot clock/filesystem issues). Off by default; slightly slower cold boot.' New-CustomTweak perf-power-high 'Power plan: High Performance' Performance Safe -DefaultOn $true ` - -Explain 'Switches the active power plan to High Performance (no CPU down-clocking on idle).' ` - -Test { $a = (& powercfg /getactivescheme) -join ' '; [bool]($a -match '8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c') } ` - -Backup { $a = (& powercfg /getactivescheme) -join ' '; $g = if ($a -match '([0-9a-f-]{36})') { $Matches[1] } else { $null }; @{ PreviousGuid = $g } } ` - -Apply { & powercfg /setactive '8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c' 2>$null; if ($LASTEXITCODE -ne 0) { & powercfg /setactive SCHEME_MIN 2>$null } } ` - -Undo { param($s) if ($s.PreviousGuid) { & powercfg /setactive $s.PreviousGuid 2>$null } } + -Explain 'Switches the active power plan to High Performance (no CPU down-clocking on idle). Skipped automatically when a battery is present or on Modern Standby devices that do not expose the plan.' ` + -SkipIf { Get-OptPowerHighSkipReason } ` + -Test { (Get-OptActiveSchemeGuid) -eq '8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c' } ` + -Backup { @{ PreviousGuid = (Get-OptActiveSchemeGuid) } } ` + -Apply { Invoke-OptNative -FilePath powercfg -Arguments @('/setactive', '8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c') | Out-Null } ` + -Undo { param($s) if ($s.PreviousGuid) { Invoke-OptNative -FilePath powercfg -Arguments @('/setactive', $s.PreviousGuid) | Out-Null } } New-CustomTweak perf-power-ultimate 'Power plan: Ultimate Performance' Performance Aggressive -DefaultOn $false ` - -Explain 'Creates and activates the hidden Ultimate Performance plan (desktops/workstations).' ` - -Test { $false } ` - -Backup { $a = (& powercfg /getactivescheme) -join ' '; $g = if ($a -match '([0-9a-f-]{36})') { $Matches[1] } else { $null }; @{ PreviousGuid = $g } } ` - -Apply { & powercfg /duplicatescheme e9a42b02-d5df-448d-aa00-03f14749eb61 2>$null; & powercfg /setactive e9a42b02-d5df-448d-aa00-03f14749eb61 2>$null } ` - -Undo { param($s) if ($s.PreviousGuid) { & powercfg /setactive $s.PreviousGuid 2>$null } } + -Explain 'Creates (once, under a fixed GUID) and activates the hidden Ultimate Performance plan. Desktops/workstations only; higher idle power draw. Undo re-activates the previous plan and deletes the plan it created.' ` + -Test { (Get-OptActiveSchemeGuid) -eq '57a1e0de-6d0f-4c1b-9e2a-5e0f5e0ed001' } ` + -Backup { @{ PreviousGuid = (Get-OptActiveSchemeGuid) + Existed = ((Get-OptPowerSchemeList) -contains '57a1e0de-6d0f-4c1b-9e2a-5e0f5e0ed001') } } ` + -Apply { + if ((Get-OptPowerSchemeList) -notcontains '57a1e0de-6d0f-4c1b-9e2a-5e0f5e0ed001') { + Invoke-OptNative -FilePath powercfg -Arguments @('/duplicatescheme', 'e9a42b02-d5df-448d-aa00-03f14749eb61', '57a1e0de-6d0f-4c1b-9e2a-5e0f5e0ed001') | Out-Null + } + Invoke-OptNative -FilePath powercfg -Arguments @('/setactive', '57a1e0de-6d0f-4c1b-9e2a-5e0f5e0ed001') | Out-Null + } ` + -Undo { param($s) + $fixed = '57a1e0de-6d0f-4c1b-9e2a-5e0f5e0ed001' + $prev = if ($s.PreviousGuid -and $s.PreviousGuid -ne $fixed) { $s.PreviousGuid } else { '381b4222-f694-41f0-9685-ff5bb260df2e' } + if ((Get-OptActiveSchemeGuid) -eq $fixed) { Invoke-OptNative -FilePath powercfg -Arguments @('/setactive', $prev) | Out-Null } + if (-not $s.Existed -and ((Get-OptPowerSchemeList) -contains $fixed)) { Invoke-OptNative -FilePath powercfg -Arguments @('/delete', $fixed) | Out-Null } + } + New-CustomTweak perf-usb-suspend 'Disable USB selective suspend (AC power)' Performance Moderate -DefaultOn $false ` + -Explain 'Stops Windows powering down idle USB ports while plugged in (fixes dropouts of USB audio, mice and hubs). Battery (DC) behaviour is unchanged. Off by default; slightly higher idle power.' ` + -Test { $i = Get-OptPowerSettingIndex -SubGroup '2a737441-1930-4402-8d77-b2bebba308a3' -Setting '48e6b7a6-50f5-4782-a5d4-53bb8f07e226'; ($null -ne $i) -and ($i.AC -eq 0) } ` + -Backup { + $g = Get-OptActiveSchemeGuid + $i = Get-OptPowerSettingIndex -Scheme $(if ($g) { $g } else { 'SCHEME_CURRENT' }) -SubGroup '2a737441-1930-4402-8d77-b2bebba308a3' -Setting '48e6b7a6-50f5-4782-a5d4-53bb8f07e226' + @{ Scheme = $g; AC = $(if ($i) { $i.AC } else { $null }) } + } ` + -Apply { param($s) + $sch = if ($s.Scheme) { $s.Scheme } else { 'SCHEME_CURRENT' } + Invoke-OptNative -FilePath powercfg -Arguments @('/setacvalueindex', $sch, '2a737441-1930-4402-8d77-b2bebba308a3', '48e6b7a6-50f5-4782-a5d4-53bb8f07e226', '0') | Out-Null + Invoke-OptNative -FilePath powercfg -Arguments @('/setactive', 'SCHEME_CURRENT') | Out-Null + } ` + -Undo { param($s) + if ($s.Scheme -and $null -ne $s.AC) { + Invoke-OptNative -FilePath powercfg -Arguments @('/setacvalueindex', $s.Scheme, '2a737441-1930-4402-8d77-b2bebba308a3', '48e6b7a6-50f5-4782-a5d4-53bb8f07e226', [string]$s.AC) | Out-Null + Invoke-OptNative -FilePath powercfg -Arguments @('/setactive', 'SCHEME_CURRENT') | Out-Null + } + else { Write-OptLog 'USB selective suspend: no prior value recorded; leaving as is.' 'Warning' } + } + New-RegTweak perf-powerthrottling 'Disable Power Throttling (EcoQoS)' Performance Moderate -DefaultOn $false ` + -Path 'HKLM:\SYSTEM\CurrentControlSet\Control\Power\PowerThrottling' ` + -Values @((RegVal 'PowerThrottlingOff' DWord 1)) ` + -Explain 'Stops Windows from throttling background processes to efficiency mode. Off by default: costs battery life on laptops; little gain on desktops.' + New-RegTweak perf-hags 'Enable hardware-accelerated GPU scheduling' Performance Moderate -DefaultOn $false ` + -Path 'HKLM:\SYSTEM\CurrentControlSet\Control\GraphicsDrivers' ` + -Values @((RegVal 'HwSchMode' DWord 2)) ` + -Explain 'Lets the GPU manage its own memory scheduling (HAGS). Needs a WDDM 2.7+ driver and a reboot; ignored otherwise. Off by default: can cause stutter or capture issues with some drivers.' + New-RegTweak perf-wu-latest 'Do not get updates as soon as available' Performance Safe ` + -Path 'HKLM:\SOFTWARE\Microsoft\WindowsUpdate\UX\Settings' ` + -Values @((RegVal 'IsContinuousInnovationOptedIn' DWord 0)) ` + -Explain 'Turns off "Get the latest updates as soon as they are available" (early feature drops). Security and monthly updates are unaffected.' + New-RegTweak perf-edge-bg 'Stop Edge preloading and running in background' Performance Safe ` + -Path 'HKLM:\SOFTWARE\Policies\Microsoft\Edge' ` + -Values @((RegVal 'StartupBoostEnabled' DWord 0), (RegVal 'BackgroundModeEnabled' DWord 0)) ` + -Explain 'Disables Edge Startup Boost and background mode so Edge does not sit in memory when closed. Edge will show "Your browser is managed by your organization" (harmless policy notice).' + New-CustomTweak perf-reserved-storage 'Disable Reserved Storage' Performance Moderate -DefaultOn $false ` + -Explain 'Frees the ~7 GB Windows reserves for updates. Off by default: low-space devices may then fail feature updates. Cannot be changed while an update is pending (Windows 10 2004+).' ` + -Test { try { [string](Invoke-OptReadOnly -Script { Get-WindowsReservedStorageState -ErrorAction Stop }).ReservedStorageState -eq 'Disabled' } catch { $false } } ` + -Backup { + $st = $null + try { $st = [string](Invoke-OptReadOnly -Script { Get-WindowsReservedStorageState -ErrorAction Stop }).ReservedStorageState } catch { $st = $null } + @{ Was = $st } + } ` + -Apply { Invoke-OptNative -FilePath "$env:SystemRoot\System32\dism.exe" -Arguments @('/Online', '/Set-ReservedStorageState', '/State:Disabled') -OkCodes @(0, 3010) | Out-Null } ` + -Undo { param($s) + if ($s.Was -ne 'Disabled') { + Invoke-OptNative -FilePath "$env:SystemRoot\System32\dism.exe" -Arguments @('/Online', '/Set-ReservedStorageState', '/State:Enabled') -OkCodes @(0, 3010) | Out-Null + } + } New-SvcTweak perf-sysmain 'Disable SysMain (Superfetch)' Performance Aggressive -DefaultOn $false ` -Service 'SysMain' -Startup 'Disabled' ` -Explain 'Frees RAM/disk activity. Helpful on SSDs; can slow app launches on HDDs. Off by default.' @@ -283,7 +556,7 @@ function Get-OptimizationTweakRegistry { New-RegTweak priv-consumer 'Disable consumer features / auto-installed apps' Privacy Safe ` -Path 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\CloudContent' ` -Values @((RegVal 'DisableWindowsConsumerFeatures' DWord 1), (RegVal 'DisableSoftLanding' DWord 1)) ` - -Explain 'Prevents Windows from silently installing promoted third-party apps.' + -Explain 'Consumer-features policy (Enterprise/Education only; ignored on Home/Pro). On Home/Pro the per-user ContentDeliveryManager switches in debloat-start-ads and priv-tips are what stop promoted app installs.' New-RegTweak priv-tips 'Disable tips, suggestions & spotlight' Privacy Safe ` -Path 'HKCU:\Software\Microsoft\Windows\CurrentVersion\ContentDeliveryManager' ` -Values @( @@ -302,8 +575,12 @@ function Get-OptimizationTweakRegistry { -Explain 'Stops Windows from collecting and uploading the activity history / Timeline.' New-RegTweak priv-websearch 'Disable web search in Start' Privacy Safe ` -Path 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Search' ` - -Values @((RegVal 'BingSearchEnabled' DWord 0), (RegVal 'CortanaConsent' DWord 0)) ` - -Explain 'Removes Bing web results and Cortana suggestions from the Start-menu search box.' + -Values @( + (RegVal 'BingSearchEnabled' DWord 0), + (RegVal 'CortanaConsent' DWord 0), + (RegVal 'DisableSearchBoxSuggestions' DWord 1 -Path 'HKCU:\Software\Policies\Microsoft\Windows\Explorer'), + (RegVal 'EnableDynamicContentInWSB' DWord 0 -Path 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\Windows Search')) ` + -Explain 'Removes Bing web results, web suggestions and "search highlights" from Start/taskbar search. Side effect: the suggestions policy also hides recent Explorer search entries.' New-RegTweak priv-cortana 'Disable Cortana (policy)' Privacy Moderate ` -Path 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\Windows Search' ` -Values @((RegVal 'AllowCortana' DWord 0)) ` @@ -311,9 +588,9 @@ function Get-OptimizationTweakRegistry { New-SvcTweak priv-diagtrack 'Disable Connected User Experiences (DiagTrack)' Privacy Moderate ` -Service 'DiagTrack' -Startup 'Disabled' ` -Explain 'Stops the main telemetry service that uploads diagnostic data.' - New-SvcTweak priv-dmwappush 'Disable WAP Push message service' Privacy Moderate ` + New-SvcTweak priv-dmwappush 'Disable WAP Push message service' Privacy Moderate -DefaultOn $false ` -Service 'dmwappushservice' -Startup 'Disabled' ` - -Explain 'Disables a device-management channel used for telemetry routing.' + -Explain 'Disables a device-management push channel also used for telemetry routing. Off by default: breaks Intune/MDM policy sync on work-managed PCs.' New-TaskTweak priv-telemetry-tasks 'Disable CEIP & telemetry scheduled tasks' Privacy Moderate ` -Tasks @( @{ Path = '\Microsoft\Windows\Customer Experience Improvement Program\'; Name = 'Consolidator' }, @@ -322,23 +599,72 @@ function Get-OptimizationTweakRegistry { @{ Path = '\Microsoft\Windows\Application Experience\'; Name = 'ProgramDataUpdater' }, @{ Path = '\Microsoft\Windows\Feedback\Siuf\'; Name = 'DmClient' }, @{ Path = '\Microsoft\Windows\Feedback\Siuf\'; Name = 'DmClientOnScenarioDownload' }) ` - -Explain 'Disables the recurring tasks that collect and send usage/compatibility data.' + -Explain 'Disables the recurring tasks that collect and send usage/compatibility data. Note: with the Compatibility Appraiser off, Windows Update may be slower to offer the next feature update (eligibility is re-evaluated at upgrade time).' New-RegTweak priv-recall 'Disable Recall & Click-to-Do (AI screen analysis)' Privacy Safe ` -Path 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\WindowsAI' ` -Values @((RegVal 'DisableAIDataAnalysis' DWord 1), (RegVal 'DisableClickToDo' DWord 1)) ` -Explain 'Blocks Windows Recall snapshots and Click-to-Do AI screen scraping (Win11 24H2+; harmless no-op on older builds).' - New-RegTweak priv-copilot 'Disable Windows Copilot' Privacy Safe ` + New-RegTweak priv-recall-remove 'Remove the Recall component (policy)' Privacy Moderate ` + -Path 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\WindowsAI' ` + -Values @((RegVal 'AllowRecallEnablement' DWord 0)) ` + -Explain 'Makes Windows uninstall the Recall optional feature (Win11 24H2 build 26100.3915+; no-op elsewhere). Takes effect after a reboot; undo removes the policy so Recall can be re-added.' + New-RegTweak priv-copilot 'Disable Windows Copilot (legacy policy)' Privacy Safe ` -Path 'HKCU:\Software\Policies\Microsoft\Windows\WindowsCopilot' ` -Values @((RegVal 'TurnOffWindowsCopilot' DWord 1)) ` - -Explain 'Turns off the Windows Copilot assistant via user policy.' + -Explain 'Turns off the built-in Copilot sidebar via user policy. Deprecated: only effective on Windows 10 and Windows 11 23H2 and earlier; the newer Copilot app ignores it (see debloat-copilot-app).' + New-RegTweak priv-ai-paint 'Disable AI features in Paint' Privacy Safe ` + -Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\Paint' ` + -Values @( + (RegVal 'DisableCocreator' DWord 1), + (RegVal 'DisableGenerativeFill' DWord 1), + (RegVal 'DisableImageCreator' DWord 1)) ` + -Explain 'Turns off Cocreator, Generative fill and Image Creator in Paint (Win11). No effect on older Paint versions.' + New-RegTweak priv-ai-notepad 'Disable AI features in Notepad' Privacy Safe ` + -Path 'HKLM:\SOFTWARE\Policies\WindowsNotepad' ` + -Values @((RegVal 'DisableAIFeatures' DWord 1)) ` + -Explain 'Hides Copilot rewrite/summarize in Notepad (Win11 Notepad 11.2410+). No effect on older Notepad.' + New-RegTweak priv-chrome-ai 'Stop Chrome downloading its on-device AI model' Privacy Safe ` + -Path 'HKLM:\SOFTWARE\Policies\Google\Chrome' ` + -Values @((RegVal 'GenAILocalFoundationalModelSettings' DWord 1)) ` + -Explain 'Prevents Chrome from downloading the ~4 GB Gemini Nano on-device model; on-device AI features stop working. Chrome will show "managed by your organization". Harmless if Chrome is not installed.' New-RegTweak priv-tailored 'Disable tailored experiences (ads from diagnostics)' Privacy Safe ` -Path 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Privacy' ` -Values @((RegVal 'TailoredExperiencesWithDiagnosticDataEnabled' DWord 0)) ` -Explain 'Stops Windows from using your diagnostic data to show personalized tips and ads.' - New-RegTweak priv-spotlight 'Disable Windows Spotlight features (policy)' Privacy Safe ` - -Path 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\CloudContent' ` - -Values @((RegVal 'DisableWindowsSpotlightFeatures' DWord 1)) ` - -Explain 'Disables lock-screen/desktop Spotlight ad rotation at the policy root.' + New-RegTweak priv-spotlight 'Disable Windows Spotlight lock-screen rotation' Privacy Safe ` + -Path 'HKCU:\Software\Policies\Microsoft\Windows\CloudContent' ` + -Values @( + (RegVal 'DisableWindowsSpotlightFeatures' DWord 1), + (RegVal 'RotatingLockScreenEnabled' DWord 0 -Path 'HKCU:\Software\Microsoft\Windows\CurrentVersion\ContentDeliveryManager'), + (RegVal 'SubscribedContent-338387Enabled' DWord 0 -Path 'HKCU:\Software\Microsoft\Windows\CurrentVersion\ContentDeliveryManager')) ` + -Explain 'Stops Spotlight picture/ad rotation and "fun facts" on the lock screen; the lock screen keeps a static picture. The user policy value is Enterprise/Education only; the ContentDeliveryManager values cover Home/Pro.' + New-RegTweak priv-settings-ads 'Disable suggested content in Settings' Privacy Safe ` + -Path 'HKCU:\Software\Microsoft\Windows\CurrentVersion\ContentDeliveryManager' ` + -Values @( + (RegVal 'SubscribedContent-338393Enabled' DWord 0), + (RegVal 'SubscribedContent-353694Enabled' DWord 0), + (RegVal 'SubscribedContent-353696Enabled' DWord 0)) ` + -Explain 'Hides the promoted/suggested content cards in the Settings app.' + New-RegTweak priv-suggested-actions 'Disable suggested actions on copy' Privacy Safe ` + -Path 'HKCU:\Software\Microsoft\Windows\CurrentVersion\SmartActionPlatform\SmartClipboard' ` + -Values @((RegVal 'Disabled' DWord 1)) ` + -Explain 'Stops the pop-up that suggests actions (call, add to calendar) when you copy a phone number or date (Win11 22H2+).' + New-RegTweak priv-langlist 'Hide language list from websites' Privacy Safe ` + -Path 'HKCU:\Control Panel\International\User Profile' ` + -Values @((RegVal 'HttpAcceptLanguageOptOut' DWord 1)) ` + -Explain 'Websites can no longer read your installed language list to serve locally relevant content (fingerprinting reduction).' + New-RegTweak priv-trackprogs 'Do not track app launches' Privacy Moderate -DefaultOn $false ` + -Path 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Explorer\Advanced' ` + -Values @((RegVal 'Start_TrackProgs' DWord 0)) ` + -Explain 'Stops Windows recording which apps you launch. Off by default: empties the Start "Most used" list and Run-dialog history.' + New-RegTweak priv-findmydevice 'Disable Find My Device' Privacy Moderate -DefaultOn $false ` + -Path 'HKLM:\SOFTWARE\Policies\Microsoft\FindMyDevice' ` + -Values @((RegVal 'AllowFindMyDevice' DWord 0)) ` + -Explain 'Stops periodic location reporting for Find My Device. Off by default: you lose the ability to locate or lock a lost laptop.' + New-RegTweak priv-location 'Block app access to location (policy)' Privacy Moderate -DefaultOn $false ` + -Path 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\AppPrivacy' ` + -Values @((RegVal 'LetAppsAccessLocation' DWord 2)) ` + -Explain 'Force-denies location to all apps. Off by default: breaks automatic time zone, Weather, Maps and Find My Device location.' New-RegTweak priv-input 'Stop inking & typing personalization' Privacy Safe ` -Path 'HKLM:\SOFTWARE\Policies\Microsoft\InputPersonalization' ` -Values @( @@ -356,8 +682,10 @@ function Get-OptimizationTweakRegistry { -Explain 'Opts out of cloud-based voice processing. Offline dictation is unaffected.' New-RegTweak priv-ceip 'Disable Customer Experience Improvement Program' Privacy Safe ` -Path 'HKLM:\SOFTWARE\Microsoft\SQMClient\Windows' ` - -Values @((RegVal 'CEIPEnable' DWord 0)) ` - -Explain 'Turns off the CEIP master switch (complements the CEIP scheduled-task tweak).' + -Values @( + (RegVal 'CEIPEnable' DWord 0), + (RegVal 'CEIPEnable' DWord 0 -Path 'HKLM:\SOFTWARE\Policies\Microsoft\SQMClient\Windows')) ` + -Explain 'Turns off the CEIP master switch and its policy equivalent (complements the CEIP scheduled-task tweak).' New-RegTweak priv-appcompat 'Disable application-compatibility telemetry' Privacy Moderate ` -Path 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\AppCompat' ` -Values @((RegVal 'DisableInventory' DWord 1), (RegVal 'AITEnable' DWord 0)) ` @@ -387,50 +715,48 @@ function Get-OptimizationTweakRegistry { # DEBLOAT (UWP apps) # ============================================================= New-CustomTweak debloat-junk 'Remove preinstalled junk apps' Debloat Aggressive -DefaultOn $true ` - -Explain 'Removes obvious bloat (King games, Solitaire, 3D Viewer, Clipchamp, Get Help, Maps, etc.) for all users.' ` - -Test { $false } ` + -Explain 'Removes obvious bloat (King games, Solitaire, 3D Viewer, Clipchamp, Maps, News/Weather, etc.) for all users. Get Help is kept (Win11 troubleshooters run through it). Reinstall from the Store.' ` + -Test { try { -not (Get-OptAppxMatch -Pattern (Get-OptAppxPattern -Set junk) -AllUsers) } catch { $false } } ` -Backup { - $pat = @('king.com*','*CandyCrush*','*BubbleWitch*','*Microsoft.3DBuilder*','*Microsoft.Microsoft3DViewer*', - '*Microsoft.MicrosoftSolitaireCollection*','*Microsoft.MixedReality.Portal*','*Microsoft.WindowsFeedbackHub*', - '*Microsoft.GetHelp*','*Microsoft.Getstarted*','*Microsoft.WindowsMaps*','*Microsoft.BingNews*', - '*Microsoft.BingWeather*','*Microsoft.People*','*Clipchamp*','*Microsoft.Todos*','*Disney*','*SpotifyAB*') - $found = foreach ($p in $pat) { Get-AppxPackage -AllUsers -Name $p -ErrorAction SilentlyContinue | Select-Object -Expand Name } - @{ Patterns = $pat; Found = @($found | Sort-Object -Unique) } - } ` - -Apply { - $pat = @('king.com*','*CandyCrush*','*BubbleWitch*','*Microsoft.3DBuilder*','*Microsoft.Microsoft3DViewer*', - '*Microsoft.MicrosoftSolitaireCollection*','*Microsoft.MixedReality.Portal*','*Microsoft.WindowsFeedbackHub*', - '*Microsoft.GetHelp*','*Microsoft.Getstarted*','*Microsoft.WindowsMaps*','*Microsoft.BingNews*', - '*Microsoft.BingWeather*','*Microsoft.People*','*Clipchamp*','*Microsoft.Todos*','*Disney*','*SpotifyAB*') - foreach ($p in $pat) { - Get-AppxPackage -AllUsers -Name $p -ErrorAction SilentlyContinue | Remove-AppxPackage -AllUsers -ErrorAction SilentlyContinue - Get-AppxProvisionedPackage -Online -ErrorAction SilentlyContinue | - Where-Object { $_.DisplayName -like $p } | - ForEach-Object { Remove-AppxProvisionedPackage -Online -PackageName $_.PackageName -ErrorAction SilentlyContinue | Out-Null } - } + $pat = Get-OptAppxPattern -Set junk + $found = @() + try { $found = @(Get-OptAppxMatch -Pattern $pat -AllUsers | ForEach-Object { $_.Name } | Sort-Object -Unique) } catch { $found = @() } + @{ Patterns = $pat; Found = $found } } ` + -Apply { Invoke-OptAppxRemoval -Pattern (Get-OptAppxPattern -Set junk) -AllUsers -Provisioned } ` -Undo { param($s) if ($s.Found) { Write-OptLog ("Removed UWP apps cannot be auto-reinstalled. Reinstall from the Store if needed: {0}" -f ($s.Found -join ', ')) 'Warning' } } New-CustomTweak debloat-xbox 'Remove Xbox apps' Debloat Aggressive -DefaultOn $false ` - -Explain 'Removes Xbox app, Game Bar overlay and related packages. Off by default (gamers may want them).' ` - -Test { $false } ` - -Backup { $f = Get-AppxPackage -AllUsers -Name '*Xbox*' -ErrorAction SilentlyContinue | Select-Object -Expand Name; @{ Found = @($f) } } ` - -Apply { Get-AppxPackage -AllUsers -Name '*Xbox*' -ErrorAction SilentlyContinue | Remove-AppxPackage -AllUsers -ErrorAction SilentlyContinue } ` + -Explain 'Removes Xbox app, Game Bar overlay and related packages. Off by default (gamers may want them). Reinstall from the Store.' ` + -Test { try { -not (Get-OptAppxMatch -Pattern (Get-OptAppxPattern -Set xbox) -AllUsers) } catch { $false } } ` + -Backup { + $f = @() + try { $f = @(Get-OptAppxMatch -Pattern (Get-OptAppxPattern -Set xbox) -AllUsers | ForEach-Object { $_.Name } | Sort-Object -Unique) } catch { $f = @() } + @{ Found = $f } + } ` + -Apply { Invoke-OptAppxRemoval -Pattern (Get-OptAppxPattern -Set xbox) -AllUsers } ` -Undo { param($s) if ($s.Found) { Write-OptLog ("Reinstall from the Store if needed: {0}" -f ($s.Found -join ', ')) 'Warning' } } New-CustomTweak debloat-comms 'Remove Mail/Calendar, Skype, Phone Link' Debloat Aggressive -DefaultOn $false ` - -Explain 'Removes the communications apps bundle. Off by default (some people use Mail/Calendar).' ` - -Test { $false } ` + -Explain 'Removes the communications apps bundle. Off by default (some people use Mail/Calendar). Reinstall from the Store.' ` + -Test { try { -not (Get-OptAppxMatch -Pattern (Get-OptAppxPattern -Set comms) -AllUsers) } catch { $false } } ` -Backup { - $pat = @('*Microsoft.windowscommunicationsapps*','*Microsoft.SkypeApp*','*Microsoft.YourPhone*') - $f = foreach ($p in $pat) { Get-AppxPackage -AllUsers -Name $p -ErrorAction SilentlyContinue | Select-Object -Expand Name } - @{ Patterns = $pat; Found = @($f | Sort-Object -Unique) } + $pat = Get-OptAppxPattern -Set comms + $f = @() + try { $f = @(Get-OptAppxMatch -Pattern $pat -AllUsers | ForEach-Object { $_.Name } | Sort-Object -Unique) } catch { $f = @() } + @{ Patterns = $pat; Found = $f } } ` - -Apply { - foreach ($p in @('*Microsoft.windowscommunicationsapps*','*Microsoft.SkypeApp*','*Microsoft.YourPhone*')) { - Get-AppxPackage -AllUsers -Name $p -ErrorAction SilentlyContinue | Remove-AppxPackage -AllUsers -ErrorAction SilentlyContinue - } + -Apply { Invoke-OptAppxRemoval -Pattern (Get-OptAppxPattern -Set comms) -AllUsers } ` + -Undo { param($s) if ($s.Found) { Write-OptLog ("Reinstall from the Store if needed: {0}" -f ($s.Found -join ', ')) 'Warning' } } + New-CustomTweak debloat-copilot-app 'Remove the Copilot app (current user)' Debloat Aggressive -DefaultOn $false ` + -Explain 'Uninstalls the Microsoft.Copilot Store app for the current user (Win11 24H2+ ships Copilot as an app the legacy policy no longer controls). Off by default. Reinstall from the Store.' ` + -Test { try { -not (Get-OptAppxMatch -Pattern (Get-OptAppxPattern -Set copilot)) } catch { $false } } ` + -Backup { + $f = @() + try { $f = @(Get-OptAppxMatch -Pattern (Get-OptAppxPattern -Set copilot) | ForEach-Object { $_.Name } | Sort-Object -Unique) } catch { $f = @() } + @{ Found = $f } } ` + -Apply { Invoke-OptAppxRemoval -Pattern (Get-OptAppxPattern -Set copilot) } ` -Undo { param($s) if ($s.Found) { Write-OptLog ("Reinstall from the Store if needed: {0}" -f ($s.Found -join ', ')) 'Warning' } } New-RegTweak debloat-start-ads 'Disable Start-menu app suggestions' Debloat Safe ` -Path 'HKCU:\Software\Microsoft\Windows\CurrentVersion\ContentDeliveryManager' ` @@ -443,12 +769,38 @@ function Get-OptimizationTweakRegistry { New-RegTweak debloat-taskbar-ads 'Hide taskbar/Start/Explorer ad surfaces' Debloat Safe ` -Path 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Explorer\Advanced' ` -Values @( - (RegVal 'TaskbarDa' DWord 0), (RegVal 'TaskbarMn' DWord 0), (RegVal 'Start_IrisRecommendations' DWord 0), (RegVal 'Start_AccountNotifications' DWord 0), (RegVal 'ShowSyncProviderNotifications' DWord 0)) ` - -Explain 'Hides the Widgets and Chat taskbar buttons, the Start "Recommended"/account-ad rows, and Explorer sync-provider ads.' + -Explain 'Hides the Start "Recommended" tips/account-ad rows, Explorer sync-provider ads and the Win11 22H2 Chat button (no-op on 23H2+). The Widgets button is handled by debloat-widgets-policy (the UCPD driver blocks TaskbarDa writes on current Win11).' + New-RegTweak debloat-widgets-policy 'Disable Widgets (policy)' Debloat Safe ` + -Path 'HKLM:\SOFTWARE\Policies\Microsoft\Dsh' ` + -Values @((RegVal 'AllowNewsAndInterests' DWord 0)) ` + -Explain 'Turns off the Win11 Widgets board and removes its taskbar button machine-wide (works where TaskbarDa is blocked). Sign-out or Explorer restart needed.' + New-RegTweak debloat-feeds-w10 'Disable News and Interests (Win10)' Debloat Safe ` + -Path 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\Windows Feeds' ` + -Values @((RegVal 'EnableFeeds' DWord 0)) ` + -Explain 'Removes the Windows 10 taskbar weather/news feed. No effect on Windows 11.' + New-RegTweak debloat-meetnow 'Hide Meet Now button (Win10)' Debloat Safe ` + -Path 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Policies\Explorer' ` + -Values @((RegVal 'HideSCAMeetNow' DWord 1)) ` + -Explain 'Hides the Skype "Meet Now" tray icon on Windows 10. No effect on Windows 11.' + New-RegTweak debloat-edge 'Remove Edge sidebar, shopping and recommendations' Debloat Safe ` + -Path 'HKLM:\SOFTWARE\Policies\Microsoft\Edge' ` + -Values @( + (RegVal 'HubsSidebarEnabled' DWord 0), + (RegVal 'EdgeShoppingAssistantEnabled' DWord 0), + (RegVal 'ShowRecommendationsEnabled' DWord 0)) ` + -Explain 'Hides the Edge sidebar (Copilot button), shopping coupons pop-ups and feature recommendations. Edge itself is untouched but will show "Your browser is managed by your organization".' + New-RegTweak debloat-start-reco 'Hide Start "Recommended" section (policy)' Debloat Moderate -DefaultOn $false ` + -Path 'HKCU:\Software\Policies\Microsoft\Windows\Explorer' ` + -Values @((RegVal 'HideRecommendedSection' DWord 1)) ` + -Explain 'Removes the whole Recommended section from Win11 Start. Honoured only on Education/SE (and recent Enterprise) editions; a no-op on Home/Pro. Off by default.' + New-RegTweak debloat-stickykeys 'Disable Sticky Keys shortcut (Shift x5)' Debloat Safe ` + -Path 'HKCU:\Control Panel\Accessibility\StickyKeys' ` + -Values @((RegVal 'Flags' String '506')) ` + -Explain 'Pressing Shift five times no longer pops up the Sticky Keys prompt (common in games). Sticky Keys can still be enabled in Settings.' New-RegTweak debloat-scoobe 'Disable post-update setup nag (SCOOBE)' Debloat Safe ` -Path 'HKCU:\Software\Microsoft\Windows\CurrentVersion\UserProfileEngagement' ` -Values @((RegVal 'ScoobeSystemSettingEnabled' DWord 0)) ` @@ -465,31 +817,85 @@ function Get-OptimizationTweakRegistry { New-Item -Path 'HKCU:\Software\Classes\CLSID\{86ca1aa0-34aa-4e8b-a509-50c905bae2a2}\InprocServer32' -Force | Out-Null Set-ItemProperty -Path 'HKCU:\Software\Classes\CLSID\{86ca1aa0-34aa-4e8b-a509-50c905bae2a2}\InprocServer32' -Name '(Default)' -Value '' } ` - -Undo { param($s) Remove-Item -Path 'HKCU:\Software\Classes\CLSID\{86ca1aa0-34aa-4e8b-a509-50c905bae2a2}' -Recurse -Force -ErrorAction SilentlyContinue } + -Undo { param($s) + # Only remove the key if this tweak created it; a pre-existing key belongs to the user. + if (-not $s.Existed) { Remove-Item -Path 'HKCU:\Software\Classes\CLSID\{86ca1aa0-34aa-4e8b-a509-50c905bae2a2}' -Recurse -Force -ErrorAction SilentlyContinue } + } + New-CustomTweak ux-explorer-home-gallery 'Hide Home and Gallery in Explorer' Debloat Safe -DefaultOn $false ` + -Explain 'Removes the Home and Gallery entries from the Win11 Explorer navigation pane. Off by default (preference); needs an Explorer restart. Pair with ux-launch-thispc.' ` + -Test { + $bad = foreach ($c in '{f874310e-b6b7-47dc-bc84-b9e6b38f5903}', '{e88865ea-0e1c-4e20-9aa6-edcd0212c87c}') { + $sn = Get-RegValueSnapshot -Path "HKCU:\Software\Classes\CLSID\$c" -Name 'System.IsPinnedToNameSpaceTree' + if (-not $sn.Existed -or [string]$sn.Value -ne '0') { $c } + } + -not $bad + } ` + -Backup { + $keys = foreach ($c in '{f874310e-b6b7-47dc-bc84-b9e6b38f5903}', '{e88865ea-0e1c-4e20-9aa6-edcd0212c87c}') { + $k = "HKCU:\Software\Classes\CLSID\$c" + @{ Key = $k; KeyExisted = (Test-Path -LiteralPath $k) + Snap = (Get-RegValueSnapshot -Path $k -Name 'System.IsPinnedToNameSpaceTree') } + } + @{ Keys = @($keys) } + } ` + -Apply { + foreach ($c in '{f874310e-b6b7-47dc-bc84-b9e6b38f5903}', '{e88865ea-0e1c-4e20-9aa6-edcd0212c87c}') { + Set-RegValue -Path "HKCU:\Software\Classes\CLSID\$c" -Name 'System.IsPinnedToNameSpaceTree' -Kind DWord -Value 0 + } + } ` + -Undo { param($s) + foreach ($e in @($s.Keys)) { + if (-not $e.KeyExisted) { + if (Test-Path -LiteralPath $e.Key) { Remove-Item -LiteralPath $e.Key -Recurse -Force -ErrorAction Stop } + } + else { Restore-RegValue -Path $e.Key -Snap $e.Snap } + } + } + New-RegTweak ux-launch-thispc 'Open Explorer to This PC' Debloat Safe -DefaultOn $false ` + -Path 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Explorer\Advanced' ` + -Values @((RegVal 'LaunchTo' DWord 1)) ` + -Explain 'File Explorer opens on This PC instead of Home/Quick access. Off by default (preference).' + New-RegTweak ux-hidden 'Show hidden files in Explorer' Debloat Safe -DefaultOn $false ` + -Path 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Explorer\Advanced' ` + -Values @((RegVal 'Hidden' DWord 1)) ` + -Explain 'Shows hidden files and folders (protected OS files stay hidden). Off by default (preference).' + New-RegTweak ux-endtask 'Add "End task" to taskbar right-click' Debloat Safe -DefaultOn $false ` + -Path 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Explorer\Advanced\TaskbarDeveloperSettings' ` + -Values @((RegVal 'TaskbarEndTask' DWord 1)) ` + -Explain 'Adds an End task entry to taskbar app menus (Win11 23H2+). Off by default (preference).' + New-RegTweak ux-mouseaccel 'Disable mouse acceleration' Debloat Safe -DefaultOn $false ` + -Path 'HKCU:\Control Panel\Mouse' ` + -Values @( + (RegVal 'MouseSpeed' String '0'), + (RegVal 'MouseThreshold1' String '0'), + (RegVal 'MouseThreshold2' String '0')) ` + -Explain 'Turns off "Enhance pointer precision" for 1:1 pointer movement (gaming). Off by default (preference); applies after sign-out.' # ============================================================= # NETWORK / GAMES # ============================================================= New-RegTweak net-gamedvr 'Disable GameDVR / background recording' Network Safe ` -Path 'HKCU:\System\GameConfigStore' ` - -Values @((RegVal 'GameDVR_Enabled' DWord 0), (RegVal 'GameDVR_FSEBehaviorMode' DWord 2)) ` - -Explain 'Disables the background game recorder that can cost frames and CPU.' - New-RegTweak net-gamedvr-policy 'Disable GameDVR (policy)' Network Safe ` + -Values @( + (RegVal 'GameDVR_Enabled' DWord 0), + (RegVal 'AppCaptureEnabled' DWord 0 -Path 'HKCU:\Software\Microsoft\Windows\CurrentVersion\GameDVR')) ` + -Explain 'Disables the background game recorder / app capture that can cost frames and CPU. Fullscreen optimizations are left alone.' + New-RegTweak net-gamedvr-policy 'Disable GameDVR (policy)' Network Safe -DefaultOn $false ` -Path 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\GameDVR' ` -Values @((RegVal 'AllowGameDVR' DWord 0)) ` - -Explain 'Enforces GameDVR off machine-wide via policy.' + -Explain 'Enforces GameDVR off machine-wide via policy. Off by default: also greys out Game Bar capture settings for every user (net-gamedvr covers the per-user switch).' New-RegTweak net-gamemode 'Enable Game Mode' Network Safe ` -Path 'HKCU:\Software\Microsoft\GameBar' ` -Values @((RegVal 'AutoGameModeEnabled' DWord 1), (RegVal 'AllowAutoGameMode' DWord 1)) ` -Explain 'Prioritizes the foreground game for CPU/GPU scheduling.' - New-RegTweak net-throttling 'Disable network throttling / multimedia reservation' Network Moderate ` + New-RegTweak net-throttling 'Disable network throttling / multimedia reservation' Network Moderate -DefaultOn $false ` -Path 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Multimedia\SystemProfile' ` - -Values @((RegVal 'NetworkThrottlingIndex' DWord 4294967295), (RegVal 'SystemResponsiveness' DWord 0)) ` - -Explain 'Lifts the 10-packet/ms network throttle and the 20% CPU multimedia reservation (better for gaming/streaming).' + -Values @((RegVal 'NetworkThrottlingIndex' DWord 4294967295), (RegVal 'SystemResponsiveness' DWord 10)) ` + -Explain 'Lifts the MMCSS network throttle and lowers the CPU share kept for low-priority tasks during multimedia playback from 20% to 10% (MMCSS clamps values under 10 back to 20). Off by default: only affects MMCSS-registered audio/video streams; placebo for most games.' New-RegTweak net-teredo 'Disable Teredo IPv6 tunneling' Network Moderate -DefaultOn $false ` - -Path 'HKLM:\SYSTEM\CurrentControlSet\Services\Tcpip6\Parameters' ` - -Values @((RegVal 'DisabledComponents' DWord 1)) ` - -Explain 'Disables the Teredo transition tunnel (reduces attack surface/latency). Off by default; can affect some P2P/NAT-traversal.' + -Path 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\TCPIP\v6Transition' ` + -Values @((RegVal 'Teredo_State' String 'Disabled')) ` + -Explain 'Disables only the Teredo transition tunnel via policy (other IPv6 transition settings untouched). Off by default; can affect Xbox party chat / some P2P NAT traversal.' New-SvcTweak net-ndu 'Disable Network Data Usage monitor (NDU)' Network Aggressive -DefaultOn $false ` -Service 'Ndu' -Startup 'Disabled' ` -Explain 'Stops the NDU driver that can cause high memory use. Off by default; removes per-app data usage stats.' @@ -560,9 +966,10 @@ function Test-TweakApplied { switch ($Tweak.Type) { 'Registry' { foreach ($v in $Tweak.Spec.Values) { - $snap = Get-RegValueSnapshot -Path $Tweak.Spec.Path -Name $v.Name + $snap = Get-RegValueSnapshot -Path (Get-RegValuePath -Tweak $Tweak -Value $v) -Name $v.Name if (-not $snap.Existed) { return $false } - if ([string]$snap.Value -ne [string]$v.Value) { return $false } + if ((ConvertTo-RegCompareText -Kind $v.Kind -Value $snap.Value) -ne + (ConvertTo-RegCompareText -Kind $v.Kind -Value $v.Value)) { return $false } } return $true } @@ -595,7 +1002,7 @@ function Get-TweakSnapshot { param([object]$Tweak) switch ($Tweak.Type) { 'Registry' { - $vals = foreach ($v in $Tweak.Spec.Values) { Get-RegValueSnapshot -Path $Tweak.Spec.Path -Name $v.Name } + $vals = foreach ($v in $Tweak.Spec.Values) { Get-RegValueSnapshot -Path (Get-RegValuePath -Tweak $Tweak -Value $v) -Name $v.Name } return @{ Path = $Tweak.Spec.Path; Values = @($vals) } } 'Service' { @@ -624,7 +1031,19 @@ function Set-TweakState { param([object]$Tweak, [object]$Snapshot) switch ($Tweak.Type) { 'Registry' { - foreach ($v in $Tweak.Spec.Values) { Set-RegValue -Path $Tweak.Spec.Path -Name $v.Name -Kind $v.Kind -Value $v.Value } + # One blocked value (e.g. UCPD-protected keys on Win11) must not abort the rest. + $failed = @() + foreach ($v in $Tweak.Spec.Values) { + $p = Get-RegValuePath -Tweak $Tweak -Value $v + try { Set-RegValue -Path $p -Name $v.Name -Kind $v.Kind -Value $v.Value } + catch { + $failed += $v.Name + Write-OptLog (" {0}\{1}: {2}" -f $p, $v.Name, $_.Exception.Message) 'Warning' + } + } + if ($failed.Count -and $failed.Count -ge @($Tweak.Spec.Values).Count) { + throw ("no value could be written ({0})" -f ($failed -join ', ')) + } } 'Service' { Set-Service -Name $Tweak.Spec.Service -StartupType $Tweak.Spec.Startup -ErrorAction Stop @@ -645,6 +1064,17 @@ function Invoke-Tweak { [CmdletBinding(SupportsShouldProcess)] param([object]$Tweak) + if ($Tweak.Type -eq 'Custom' -and $Tweak.Spec.SkipIf) { + $why = $null + try { $why = & $Tweak.Spec.SkipIf } catch { $why = $null } + if ($why) { + Write-OptLog "$($Tweak.Name) [skipped: $why]" 'Info' + $script:Skipped++ + $script:Stats.Add([pscustomobject]@{ Id = $Tweak.Id; Area = $Tweak.Area; Risk = $Tweak.Risk; Result = 'skipped' }) + return + } + } + $applied = Test-TweakApplied -Tweak $Tweak if ($applied -eq $true) { Write-OptLog "$($Tweak.Name) [already applied]" 'Debug' @@ -652,7 +1082,13 @@ function Invoke-Tweak { return } - $snapshot = Get-TweakSnapshot -Tweak $Tweak + try { $snapshot = Get-TweakSnapshot -Tweak $Tweak } + catch { + $script:Errors++ + Write-OptLog " $($Tweak.Name): could not record prior state, not applied - $($_.Exception.Message)" 'Error' + $script:Stats.Add([pscustomobject]@{ Id = $Tweak.Id; Area = $Tweak.Area; Risk = $Tweak.Risk; Result = 'error' }) + return + } $target = $Tweak.Name $action = "Apply tweak [$($Tweak.Area)/$($Tweak.Risk)]" @@ -663,6 +1099,8 @@ function Invoke-Tweak { $script:Applied++ $script:Snapshots.Add([pscustomobject]@{ Id = $Tweak.Id; Type = $Tweak.Type; Snapshot = $snapshot }) $script:Stats.Add([pscustomobject]@{ Id = $Tweak.Id; Area = $Tweak.Area; Risk = $Tweak.Risk; Result = 'applied' }) + # Persist after every tweak so a cancelled run still leaves an undo record. + Write-BackupManifest | Out-Null } catch { $script:Errors++ @@ -678,28 +1116,38 @@ function Invoke-Tweak { # ===================================================================== # BACKUP MANIFEST # ===================================================================== +# Written (and rewritten) after every applied tweak; one file per run. function Write-BackupManifest { - if (Test-WhatIfMode -or $script:Snapshots.Count -eq 0) { return $null } + if ((Test-WhatIfMode) -or ($script:Snapshots.Count -eq 0)) { return $null } if (-not (Test-Path $BackupDir)) { New-Item -ItemType Directory -Path $BackupDir -Force -ErrorAction SilentlyContinue -WhatIf:$false | Out-Null } - $file = Join-Path $BackupDir ("optimize-backup-{0:yyyyMMdd-HHmmss}.json" -f (Get-Date)) + $first = -not $script:ManifestFile + if ($first) { + $script:ManifestFile = Join-Path $BackupDir ("optimize-backup-{0:yyyyMMdd-HHmmss}.json" -f $script:StartTime) + } $manifest = [pscustomobject]@{ - Timestamp = (Get-Date).ToString('s') + Timestamp = $script:StartTime.ToString('s') RestorePoint = $script:RestorePointMade Tweaks = $script:Snapshots } try { - $manifest | ConvertTo-Json -Depth 8 | Set-Content -Path $file -Encoding UTF8 -WhatIf:$false - Write-OptLog "Backup manifest written: $file" 'Info' - return $file + $manifest | ConvertTo-Json -Depth 8 | Set-Content -Path $script:ManifestFile -Encoding UTF8 -WhatIf:$false + if ($first) { Write-OptLog "Backup manifest: $($script:ManifestFile)" 'Info' } + return $script:ManifestFile } catch { Write-OptLog "Could not write backup manifest: $($_.Exception.Message)" 'Warning'; return $null } } +# Newest manifest that has not been reverted yet (UndoneAt is stamped by -Undo). function Get-LatestManifest { if (-not (Test-Path $BackupDir)) { return $null } - Get-ChildItem -Path $BackupDir -Filter 'optimize-backup-*.json' -ErrorAction SilentlyContinue | - Sort-Object LastWriteTime -Descending | Select-Object -First 1 -ExpandProperty FullName + $files = Get-ChildItem -Path $BackupDir -Filter 'optimize-backup-*.json' -ErrorAction SilentlyContinue | + Sort-Object LastWriteTime -Descending + foreach ($f in $files) { + try { $j = Get-Content -Path $f.FullName -Raw | ConvertFrom-Json } catch { continue } + if (-not $j.UndoneAt) { return $f.FullName } + } + $null } # ===================================================================== @@ -717,7 +1165,13 @@ function Restore-Tweak { try { switch ($Entry.Type) { 'Registry' { - foreach ($v in $snap.Values) { Restore-RegValue -Path $snap.Path -Snap $v } + $failed = @() + foreach ($v in @($snap.Values)) { + $p = if ($v.Path) { [string]$v.Path } else { [string]$snap.Path } + try { Restore-RegValue -Path $p -Snap $v } + catch { $failed += $v.Name; Write-OptLog (" {0}\{1}: {2}" -f $p, $v.Name, $_.Exception.Message) 'Warning' } + } + if ($failed.Count) { throw ("could not restore: {0}" -f ($failed -join ', ')) } } 'Service' { if ($snap.Found) { @@ -749,11 +1203,12 @@ function Start-WindowsUndo { Write-OptLog 'Windows Optimize - UNDO' 'Step' $manifestPath = if ($BackupManifest) { $BackupManifest } else { Get-LatestManifest } if (-not $manifestPath -or -not (Test-Path $manifestPath)) { - Write-OptLog 'No backup manifest found - nothing to undo.' 'Warning'; return + Write-OptLog 'No un-reverted backup manifest found - nothing to undo.' 'Warning'; return } Write-OptLog "Using manifest: $manifestPath" 'Info' try { $manifest = Get-Content -Path $manifestPath -Raw | ConvertFrom-Json } catch { Write-OptLog "Could not read manifest: $($_.Exception.Message)" 'Error'; return } + if ($manifest.UndoneAt) { Write-OptLog "This manifest was already reverted at $($manifest.UndoneAt); reverting again." 'Warning' } $registry = Get-OptimizationTweakRegistry $entries = @($manifest.Tweaks) @@ -763,6 +1218,14 @@ function Start-WindowsUndo { [array]::Reverse($entries) foreach ($e in $entries) { Restore-Tweak -Entry $e -Registry $registry } + # Stamp a clean revert so "undo newest run" moves on to the previous manifest. + if (-not (Test-WhatIfMode) -and $script:Errors -eq 0) { + try { + $manifest | Add-Member -NotePropertyName UndoneAt -NotePropertyValue ((Get-Date).ToString('s')) -Force + $manifest | ConvertTo-Json -Depth 8 | Set-Content -Path $manifestPath -Encoding UTF8 -WhatIf:$false + } catch { Write-OptLog "Could not mark manifest as reverted: $($_.Exception.Message)" 'Warning' } + } + Write-OptLog '' 'Info' Write-OptLog ("Reverted {0} tweak(s), {1} error(s)." -f $script:Applied, $script:Errors) 'Success' } @@ -846,7 +1309,7 @@ UNDO SAFETY -WhatIf / -DryRun,-dr Preview only, change nothing (real ShouldProcess) - -NoRestorePoint,-nrp Skip the Checkpoint-Computer restore point (created by default) + -NoRestorePoint,-nrp Skip the System Restore point (created by default) -Unattended,-Force,-f No prompts - for automation OUTPUT diff --git a/README.md b/README.md index 1411d60..5358d5c 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # Windows System Cleaner and Optimizer 🧹 [![CI](https://github.com/denfry/WindowsCleaner/actions/workflows/ci.yml/badge.svg)](https://github.com/denfry/WindowsCleaner/actions/workflows/ci.yml) -[![Version](https://img.shields.io/badge/version-6.2.0-blue.svg)](https://github.com/denfry/WindowsCleaner) +[![Version](https://img.shields.io/badge/version-6.3.0-blue.svg)](https://github.com/denfry/WindowsCleaner) [![License](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE) [![PowerShell](https://img.shields.io/badge/powershell-5.1%2B%20%7C%207%2B-blue.svg)](https://learn.microsoft.com/powershell/) [![Platform](https://img.shields.io/badge/platform-Windows%2010%20%7C%2011-blue.svg)](https://www.microsoft.com/windows/) @@ -10,10 +10,10 @@ > a small engine resolves what to run, reclaims disk space, and deletes through > PowerShell's `ShouldProcess` — so **`-WhatIf` is real**, not a parallel code path. -It cleans 97 targets across browsers, developer tools, apps, games, system caches, every +It cleans 106 targets across browsers, developer tools, apps, games, system caches, every local disk, logs, Windows Update, and driver leftovers — all from one declarative registry with a real dry-run mode and a hard safety guard. A companion optimization engine applies -49 reversible system tweaks, a troubleshooting engine scans for 25 common problems and +77 reversible system tweaks, a troubleshooting engine scans for 43 common problems and repairs them, and a single menu ties everything together. A free, open-source **Windows 10 and Windows 11 cleaner, debloater, and optimizer**, written @@ -40,8 +40,34 @@ safety net. Works on desktops, laptops, and Windows Server, and runs unattended ## One command — the desktop app -Clone, run one file, done. Nothing is downloaded or installed: the window is WPF, which -ships with every Windows 10/11. +Open **PowerShell** (Win + X → *Terminal* / *Windows PowerShell*) and paste one line: + +```powershell +irm https://github.com/denfry/WindowsCleaner/releases/latest/download/install.ps1 | iex +``` + +That's it — no git, no zip, no admin prompt to install. The installer: + +1. reads the latest release's `SHA256SUMS.txt`, downloads the zip over HTTPS and **refuses + to install if the SHA256 does not match**; +2. puts the app in `%LOCALAPPDATA%\WinSenior\app` and adds **Windows Senior** to the Start + menu and the desktop; +3. starts the app, which asks for Administrator once (UAC). + +Run the same line again to **update**. Options (the long form accepts parameters): + +```powershell +$i = [scriptblock]::Create((irm https://github.com/denfry/WindowsCleaner/releases/latest/download/install.ps1)) +& $i -NoLaunch # install / update only +& $i -Console # open the arrow-key console menu instead of the app +& $i -NoShortcut # no Start menu / desktop shortcuts +& $i -Uninstall # remove the app and its shortcuts +``` + +Prefer to read before running? Download `install.ps1` from the +[latest release](https://github.com/denfry/WindowsCleaner/releases/latest) — it is ~200 +readable lines. Or work from a clone: the window is WPF, which ships with every Windows +10/11, so nothing else is needed. ```powershell git clone https://github.com/denfry/WindowsCleaner @@ -54,16 +80,22 @@ Administrator once (UAC) and opens **Windows Senior** without a console window. `.\WinSenior.ps1 -Gui` does the same from PowerShell; `.\WinSenior.cmd console` opens the arrow-key menu instead. -The app has six pages: +The app has eight pages: - **Disk cleanup** — every task with its category and risk badge. *Scan* runs the real `-WhatIf` dry run and fills a "Would free" column per task; *Clean now* runs the checked - tasks. Options: current user only, restore point, delete locked files at reboot, skip - SFC/DISM, minimum file age. Dangerous tasks are never pre-checked and ask for confirmation. + tasks. Options: current user only, restore point, delete locked files at reboot, close + running browsers, skip SFC/DISM, Conservative, minimum file age, which disks. Dangerous + tasks are never pre-checked, and *Clean now* shows a confirmation with the estimate first. + A filter box, sortable columns and tooltips with each task's paths on every list. - **Optimize** — all tweaks with their live *applied / not applied* state; preview or apply, restore point first, every applied tweak is backed up. - **Troubleshoot** — read-only scan with OK / Warn / Fail per check and the detail text; tick the problems to repair and press *Fix selected*, or auto-fix everything. +- **Startup apps** — everything that starts with Windows (Run keys, Startup folders); + untick to disable it exactly the way Task Manager does — the entry is kept, so ticking it + again restores it. +- **History** — every run's report (app and scheduled), all-time total freed, HTML export. - **Undo & restore** — list of optimization backup manifests (undo newest or a chosen one), create a System Restore point, open System Restore, open log folders. - **Schedule** — install/remove the recurring Task Scheduler jobs and see their next run. @@ -97,8 +129,11 @@ characters (ASCII `+ - |` borders instead). (`-ListTasks` prints it). Adding a target is one line; nothing else to wire up. - **Real `-WhatIf` / `-DryRun`.** Implemented through `SupportsShouldProcess`. Preview shows exactly what would be removed and reports honest would-free totals. -- **Real restore point.** `Checkpoint-Computer` actually creates a System Restore point - (and clears the 24-hour throttle first) — non-interactive, safe for automation. +- **Real restore point.** The `SystemRestore` WMI class actually creates a System Restore + point under both Windows PowerShell 5.1 and PowerShell 7 (the 24-hour throttle is lifted + for that one checkpoint and then put back) — non-interactive, safe for automation. +- **Links are never followed.** A junction or symlink inside a cleaned folder is removed as + a link; its target is never enumerated, counted or deleted. - **Honest accounting.** Reclaimed bytes are summed from items that were actually removed, not estimated and not counted from log lines. Partially-deleted folders count only what actually went away. @@ -239,7 +274,7 @@ run. A real restore point is created first as a second safety net. .\Optimize-Windows-Senior.ps1 -Undo ``` -It covers 49 tweaks across four areas (including modern Windows 11 items — Recall/Copilot, +It covers 77 tweaks across four areas (including modern Windows 11 items — Recall/Copilot, tailored-ads and Spotlight, inking/typing & speech telemetry, and the taskbar/Start ad surfaces): @@ -280,7 +315,7 @@ health report, and lets you pick which detected issues to repair. Fixes run thro .\Repair-Windows-Senior.ps1 -FixAll -IncludeHeavy -Unattended ``` -It runs 25 checks across eight categories — including security checks (firewall state, SMBv1, +It runs 43 checks across eight categories — including security checks (firewall state, SMBv1, hosts-file and proxy/PAC hijack, Defender signatures), a System Restore safety-net check, print spooler / BITS / Store health, and predictive SSD wear & crash-history reporting, plus the originals: system image health (DISM), physical disk SMART @@ -347,7 +382,7 @@ Every engine's `-ReportPath` writes the same envelope, so one parser reads them ```json { - "Tool": "WinSenior", "Version": "6.2.0", "Engine": "Cleanup", + "Tool": "WinSenior", "Version": "6.3.0", "Engine": "Cleanup", "Host": "PC01", "Timestamp": "2026-06-24T03:00:11", "Mode": "Live", "RestorePoint": true, "DurationSec": 42.3, "Summary": { "TotalFreed": "1.20 GB", "TotalFiles": 8123, "TotalErrors": 2 }, diff --git a/Repair-Windows-Senior.ps1 b/Repair-Windows-Senior.ps1 index 29b8bc1..edadeba 100644 --- a/Repair-Windows-Senior.ps1 +++ b/Repair-Windows-Senior.ps1 @@ -8,14 +8,20 @@ The default flow is scan-then-choose: it scans (changing nothing), prints a health report, and lets you pick which detected issues to repair. Fixes run through PowerShell's ShouldProcess (so -WhatIf is real) after a real System Restore point. + After every fix the check is scanned again, and it only counts as Fixed when the + re-scan comes back OK. - Heavy repairs (SFC, DISM RestoreHealth, Windows Update reset, network stack reset) are - included but only run when you explicitly select them (or pass -FixAll -IncludeHeavy). - Repairs only ever improve health - this engine enables Defender, it never disables it. + Heavy repairs (SFC, DISM RestoreHealth, network stack reset) are included but only + run when you explicitly select them (or pass -FixAll -IncludeHeavy). + Repairs only ever improve health - this engine enables Defender, the firewall and + UAC, it never disables them. + + Scans are read-only, locale-independent (CIM / registry / .NET / exit codes / enum + names - never localized command output) and every network probe has a timeout. .NOTES Author : denfry (https://github.com/denfry/WindowsCleaner) - Version : 6.2.0 + Version : 6.3.0 Requires: PowerShell 5.1+ (Windows). Administrator rights. .EXAMPLE @@ -36,10 +42,14 @@ [CmdletBinding(SupportsShouldProcess)] param( # Limit to these categories: Integrity, Disk, Update, Network, Devices, Services, Security, System + [AllowEmptyString()][AllowEmptyCollection()] [string[]]$Category, - # Force these check ids on / off (see -ListChecks for ids) + # Run only these check ids (or add them to -Category). Comma-joined strings are accepted. + [AllowEmptyString()][AllowEmptyCollection()] [string[]]$Include, + # Force these check ids off. Comma-joined strings are accepted. + [AllowEmptyString()][AllowEmptyCollection()] [string[]]$Exclude, # Scan and report only - never offer or apply fixes @@ -51,13 +61,14 @@ param( # With -FixAll, also auto-apply Aggressive (heavy / reboot) repairs [switch]$IncludeHeavy, - # Cap auto-fixes at Safe + Moderate (skip Aggressive) + # Cap every fix path at Safe + Moderate (skip Aggressive), even with -IncludeHeavy [Alias('SafeMode')] [switch]$Conservative, [Alias('dr')] [switch]$DryRun, + # No prompts. On its own it only scans and reports; add -FixAll to repair. [Alias('Force','f')] [switch]$Unattended, @@ -79,9 +90,16 @@ param( $script:StartTime = Get-Date $script:Results = New-Object System.Collections.Generic.List[object] $script:Fixed = 0 +$script:PendingReboot = 0 +$script:StillFailing = 0 +$script:Previewed = 0 $script:FixErrors = 0 $script:RebootNeeded = $false +$script:FixNeedsReboot = $false # set by a Fix scriptblock that needs a restart to finish $script:RestorePointMade = $false +$script:RepNcsiCache = $null +$script:RepNetScanState = $null +$script:RepLicenseJob = $null if ($DryRun) { $WhatIfPreference = $true } @@ -110,9 +128,268 @@ function New-RepairRestorePoint { return ($st -ne 'Failed') } +# Splits comma-joined ids ("a,b" arrives as ONE element through powershell -File), +# trims them and drops empties, so -Include/-Exclude '' is harmless. +function ConvertTo-RepIdList { + param([string[]]$Value) + @($Value | ForEach-Object { "$_" -split ',' } | ForEach-Object { $_.Trim() } | Where-Object { $_ }) +} + +# Registry value or $null (never throws). REG_BINARY / REG_MULTI_SZ come back intact (not unrolled). +function Get-RepRegValue { + param([string]$Path, [string]$Name) + try { $v = (Get-ItemProperty -LiteralPath $Path -Name $Name -ErrorAction Stop).$Name } catch { return $null } + if ($v -is [array]) { , $v } else { $v } +} + +function Get-RepBackupDir { + $d = Join-Path $env:ProgramData 'WinSenior\backups' + if (-not (Test-Path -LiteralPath $d)) { New-Item -ItemType Directory -Path $d -Force -WhatIf:$false | Out-Null } + $d +} + +function Test-RepPartOfDomain { + [bool](Get-CimInstance Win32_ComputerSystem -Property PartOfDomain -ErrorAction SilentlyContinue).PartOfDomain +} + +function Test-RepMdmEnrolled { + foreach ($k in (Get-ChildItem -LiteralPath 'HKLM:\SOFTWARE\Microsoft\Enrollments' -ErrorAction SilentlyContinue)) { + if ((Get-RepRegValue $k.PSPath 'ProviderID') -eq 'MS DM Server') { return $true } + } + $false +} + +# Restores a service start type; falls back to the registry Start value when the +# service ACL denies Set-Service (DoSvc, WaaSMedicSvc...). Returns how it was written. +function Restore-RepServiceStart { + param([Parameter(Mandatory)][string]$Name, [ValidateSet('Automatic','Manual')][string]$StartType = 'Manual') + try { Set-Service -Name $Name -StartupType $StartType -ErrorAction Stop; return 'Set-Service' } + catch { + $v = if ($StartType -eq 'Automatic') { 2 } else { 3 } + Set-ItemProperty -LiteralPath "HKLM:\SYSTEM\CurrentControlSet\Services\$Name" -Name Start -Value $v -Type DWord -ErrorAction Stop + return 'registry' + } +} + +# Runs a script in a separate runspace with a hard timeout (COM calls can hang). +function Invoke-RepWithTimeout { + param([Parameter(Mandatory)][string]$ScriptText, [int]$TimeoutSec = 20) + $ps = [powershell]::Create() + $h = $null + try { + [void]$ps.AddScript($ScriptText) + $h = $ps.BeginInvoke() + if (-not $h.AsyncWaitHandle.WaitOne($TimeoutSec * 1000)) { + [void]$ps.BeginStop($null, $null) + throw "timed out after $TimeoutSec s" + } + $out = $ps.EndInvoke($h) + if ($ps.Streams.Error.Count) { throw $ps.Streams.Error[0].Exception } + @($out) + } + finally { if ($h -and $h.IsCompleted) { $ps.Dispose() } } +} + +# NCSI probe (the same URL Windows uses). Cached for 60 s so net + time checks share it. +function Get-RepNcsiProbe { + param([switch]$Fresh) + if (-not $Fresh -and $script:RepNcsiCache -and ((Get-Date) - $script:RepNcsiCache.At).TotalSeconds -lt 60) { + return $script:RepNcsiCache + } + $res = @{ At = Get-Date; Ok = $false; ServerTime = $null; LocalTime = $null; Error = $null } + $oldProgress = $ProgressPreference + $ProgressPreference = 'SilentlyContinue' # 5.1 progress bar slows Invoke-WebRequest badly + try { + $t0 = [DateTimeOffset]::UtcNow + $r = Invoke-WebRequest -Uri 'http://www.msftconnecttest.com/connecttest.txt' -UseBasicParsing -TimeoutSec 5 -ErrorAction Stop + $t1 = [DateTimeOffset]::UtcNow + $res.LocalTime = $t0.AddTicks([long](($t1 - $t0).Ticks / 2)) + $res.Ok = ("$($r.Content)".Trim() -eq 'Microsoft Connect Test') + $d = @($r.Headers['Date'])[0] # string in 5.1, string[] in 7 + if ($d) { $res.ServerTime = [DateTimeOffset]::Parse("$d", [Globalization.CultureInfo]::InvariantCulture) } + } + catch { $res.Error = $_.Exception.Message } + finally { $ProgressPreference = $oldProgress } + $script:RepNcsiCache = $res + $res +} + +# Internet / DNS state. OK | DnsFail | ProbeBlocked | Offline +function Get-RepNetState { + param([switch]$Fresh) + $inet = $false + try { + $inet = [bool](Get-NetConnectionProfile -ErrorAction Stop | Where-Object { + "$($_.IPv4Connectivity)" -eq 'Internet' -or "$($_.IPv6Connectivity)" -eq 'Internet' }) + } catch { $inet = $false } + $probe = Get-RepNcsiProbe -Fresh:$Fresh + # System resolver with a hard 5 s cap (Resolve-DnsName -QuickTimeout still took 8 s and + # failed spuriously with a slow secondary DNS server; this is what applications see). + $dns = $false + try { + $ar = [System.Net.Dns]::BeginGetHostAddresses('www.microsoft.com', $null, $null) + if ($ar.AsyncWaitHandle.WaitOne(5000)) { $dns = [bool]@([System.Net.Dns]::EndGetHostAddresses($ar)).Count } + } catch { $dns = $false } + $state = if ($probe.Ok -and $dns) { 'OK' } + elseif ($probe.Ok) { 'DnsFail' } + elseif ($dns -and $inet) { 'ProbeBlocked' } + elseif ($inet) { 'DnsFail' } + else { 'Offline' } + @{ State = $state; Internet = $inet; Probe = $probe.Ok; Dns = $dns } +} + +# Defender mode: passive when a third-party AV owns real-time protection. +function Get-RepDefenderMode { + $res = @{ Present = $false; Passive = $false; Mode = ''; ThirdParty = @(); Status = $null } + if (-not (Get-Command Get-MpComputerStatus -ErrorAction SilentlyContinue)) { return $res } + try { $st = Get-MpComputerStatus -ErrorAction Stop } catch { return $res } + $res.Present = $true + $res.Status = $st + $res.Mode = "$($st.AMRunningMode)" + $res.Passive = [bool]($res.Mode -and $res.Mode -ne 'Normal') + # Defender registers with pathToSignedProductExe 'windowsdefender://' (display names are localized). + $res.ThirdParty = @(Get-CimInstance -Namespace root/SecurityCenter2 -ClassName AntiVirusProduct -ErrorAction SilentlyContinue | + Where-Object { "$($_.pathToSignedProductExe)" -notmatch '^windowsdefender:' } | ForEach-Object { "$($_.displayName)" }) + $res +} + +# True when a hosts line maps a Windows Update / Defender / activation / NCSI host. +function Test-RepHostsHijackLine { + param([string]$Line) + $l = ("$Line" -replace '#.*$', '').Trim() + if (-not $l) { return $false } + $tok = @($l -split '\s+') + if ($tok.Count -lt 2) { return $false } + $pat = '(?i)(^|\.)(windowsupdate\.com|windowsupdate\.microsoft\.com|update\.microsoft\.com|delivery\.mp\.microsoft\.com|' + + 'wustat\.windows\.com|ntservicepack\.microsoft\.com|wdcp\.microsoft\.com|wdcpalt\.microsoft\.com|wd\.microsoft\.com|' + + 'definitionupdates\.microsoft\.com|smartscreen\.microsoft\.com|smartscreen-prod\.microsoft\.com|sls\.microsoft\.com|' + + 'licensing\.mp\.microsoft\.com|msftncsi\.com|msftconnecttest\.com)\.?$' + foreach ($h in $tok[1..($tok.Count - 1)]) { if ($h -match $pat) { return $true } } + $false +} + +# Windows Update HRESULT -> the check that addresses it (pure; accepts int or '0x...' string). +function Get-WuErrorRoute { + param($HResult) + if ($null -eq $HResult -or "$HResult" -eq '') { return $null } + if ($HResult -is [string]) { + $hex = ($HResult.Trim() -replace '^0[xX]', '').ToUpperInvariant().PadLeft(8, '0') + } else { + $hex = '{0:X8}' -f ([int64]$HResult -band [int64]4294967295) + } + $map = @{ + '800F081F' = 'img-health'; '800F0831' = 'img-health'; '80073712' = 'img-health'; '80073701' = 'img-health' + '8007371B' = 'img-health'; '80070570' = 'img-health'; '800F0825' = 'img-health' + '80070643' = 'winre' + '80070422' = 'svc-defaults' + '8024401B' = 'net-connectivity/proxy-hijack'; '80244022' = 'net-connectivity/proxy-hijack' + '80072EE2' = 'net-connectivity/proxy-hijack'; '80072EFD' = 'net-connectivity/proxy-hijack' + '80072EFE' = 'net-connectivity/proxy-hijack' + '80242006' = 'wu-health'; '8007000D' = 'wu-health' + '80070BC9' = 'reboot-pending'; '80242014' = 'reboot-pending' + '80070070' = 'disk-space' + '80072F8F' = 'time-sync' + } + $map[$hex] +} + +# Appx package state for the current user. Get-AppxPackage cannot load under pwsh 7 +# (and -ErrorAction SilentlyContinue does not catch that), so route through powershell.exe. +function Get-RepAppxState { + param([string[]]$Name) + if ($PSVersionTable.PSEdition -eq 'Core') { + $list = ($Name | ForEach-Object { "'$_'" }) -join ',' + $cmd = "`$ProgressPreference = 'SilentlyContinue'; @(foreach (`$n in @($list)) { Get-AppxPackage -Name `$n -ErrorAction SilentlyContinue | Select-Object -First 1 | " + + "ForEach-Object { [pscustomobject]@{ Name = `$_.Name; Status = [string]`$_.Status } } }) | ConvertTo-Json -Compress" + $enc = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($cmd)) + $json = & "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -NonInteractive -EncodedCommand $enc 2>$null + if ($LASTEXITCODE -ne 0) { throw "powershell.exe exited $LASTEXITCODE" } + $txt = ($json -join '').Trim() + if (-not $txt) { return @() } + return @($txt | ConvertFrom-Json) + } + foreach ($n in $Name) { + Get-AppxPackage -Name $n -ErrorAction Stop | Select-Object -First 1 | + ForEach-Object { [pscustomobject]@{ Name = $_.Name; Status = [string]$_.Status } } + } +} + +# Provider DLL paths from the Winsock catalog (PackedCatalogItem starts with an ANSI path). +function Get-RepWinsockProvider { + $ansi = try { [Text.Encoding]::GetEncoding([Globalization.CultureInfo]::CurrentCulture.TextInfo.ANSICodePage) } catch { [Text.Encoding]::ASCII } + $base = 'HKLM:\SYSTEM\CurrentControlSet\Services\WinSock2\Parameters\Protocol_Catalog9' + foreach ($sub in 'Catalog_Entries', 'Catalog_Entries64') { + foreach ($k in (Get-ChildItem -LiteralPath "$base\$sub" -ErrorAction SilentlyContinue)) { + $bytes = Get-RepRegValue $k.PSPath 'PackedCatalogItem' + if ($bytes -isnot [byte[]]) { continue } + $end = [Array]::IndexOf($bytes, [byte]0) + if ($end -le 0) { continue } + $raw = $ansi.GetString($bytes, 0, $end) + $path = [Environment]::ExpandEnvironmentVariables($raw) + $ok = (Test-Path -LiteralPath $path) -or (Test-Path -LiteralPath ($path -replace '(?i)\\system32\\', '\SysWOW64\')) + [pscustomobject]@{ Catalog = $sub; Path = $raw; Exists = $ok } + } + } +} + +# Windows LicenseStatus via ONE SoftwareLicensingProduct query. The licensing provider +# needs ~30 s cold, so the main flow starts it in a background runspace before the scan +# loop (Invoke-RepLicensePrefetch) and the activation check only collects the answer. +function Invoke-RepLicensePrefetch { + if ($script:RepLicenseJob) { return } + $q = "Get-CimInstance -ClassName SoftwareLicensingProduct -Property LicenseStatus -OperationTimeoutSec 60 -ErrorAction Stop " + + "-Filter `"ApplicationID='55c92734-d682-4d71-983e-d6ec3f16059f' AND PartialProductKey IS NOT NULL`" | ForEach-Object { [int]`$_.LicenseStatus }" + $ps = [powershell]::Create() + [void]$ps.AddScript($q) + $script:RepLicenseJob = @{ PS = $ps; Handle = $ps.BeginInvoke(); Started = Get-Date } +} + +function Get-RepLicenseStatus { + param([int]$TimeoutSec = 45) + if (-not $script:RepLicenseJob) { Invoke-RepLicensePrefetch } + $j = $script:RepLicenseJob + $script:RepLicenseJob = $null + $left = [math]::Max(0, $TimeoutSec - ((Get-Date) - $j.Started).TotalSeconds) + if (-not $j.Handle.AsyncWaitHandle.WaitOne([int]($left * 1000))) { + [void]$j.PS.BeginStop($null, $null) + throw "licensing service did not answer within $TimeoutSec s" + } + try { + $out = $j.PS.EndInvoke($j.Handle) + if ($j.PS.Streams.Error.Count) { throw $j.PS.Streams.Error[0].Exception } + @($out) + } + finally { $j.PS.Dispose() } +} + +function Test-RepInteractiveUser { + $me = [Security.Principal.WindowsIdentity]::GetCurrent().Name + $console = (Get-CimInstance Win32_ComputerSystem -Property UserName -ErrorAction SilentlyContinue).UserName + @{ Ok = [bool]($console -and $console -eq $me); Console = $console; Me = $me } +} + +# svc-defaults table: services whose Start=4 breaks Windows, and their default start type. +function Get-RepServiceDefault { + $auto = 'CryptSvc', 'AudioEndpointBuilder', 'Audiosrv', 'WSearch', 'WlanSvc' + $lvl = [ordered]@{ + Fail = 'wuauserv', 'BITS', 'CryptSvc', 'msiserver', 'AppXSvc' + # WSearch is deliberately absent: Optimize (perf-wsearch) may disable it on purpose. + Warn = 'ClipSVC', 'TrustedInstaller', 'DoSvc', 'UsoSvc', 'W32Time', 'AudioEndpointBuilder', 'Audiosrv', + 'InstallService', 'StateRepository', 'TokenBroker', 'WlanSvc' + } + foreach ($level in $lvl.Keys) { + foreach ($n in $lvl[$level]) { + [pscustomobject]@{ Name = $n; Level = $level; Default = $(if ($auto -contains $n) { 'Automatic' } else { 'Manual' }) } + } + } +} + # ===================================================================== # CHECK REGISTRY (the single source of truth) -# Scan returns @{ Status = 'OK'|'Warn'|'Fail'; Detail = '...' } +# Scan returns @{ Status = 'OK'|'Warn'|'Fail'|'Skip'; Detail = '...' } and may add +# per-result overrides: NoFix = $true (fix does not apply to this finding), +# FixRisk / FixLabel / Reboot (e.g. a DNS-only failure only needs a Safe flush). +# A Fix may set $script:FixNeedsReboot = $true when it needs a restart to finish. # ===================================================================== function New-DiagnosticCheck { param( @@ -128,38 +405,44 @@ function New-DiagnosticCheck { function Get-DiagnosticCheckRegistry { @( - # ---------------- Integrity ---------------- - New-DiagnosticCheck img-health 'System image health (DISM)' Integrity ` + # ================= Integrity ================= + New-DiagnosticCheck -Id img-health -Name 'System image health (DISM)' -Category Integrity ` -Scan { if (-not (Get-Command Repair-WindowsImage -ErrorAction SilentlyContinue)) { return @{ Status = 'Skip'; Detail = 'DISM module unavailable' } } $state = (Repair-WindowsImage -Online -CheckHealth -ErrorAction Stop).ImageHealthState switch ("$state") { - 'Healthy' { @{ Status = 'OK'; Detail = 'Component store healthy' } } - 'Repairable' { @{ Status = 'Fail'; Detail = 'Component store corruption is repairable' } } - default { @{ Status = 'Warn'; Detail = "Image health: $state (deep scan with DISM /ScanHealth)" } } + 'Healthy' { @{ Status = 'OK'; Detail = 'Component store healthy' } } + 'Repairable' { @{ Status = 'Fail'; Detail = 'Component store corruption is repairable' } } + 'NonRepairable' { @{ Status = 'Fail'; NoFix = $true + Detail = 'Component store is NOT repairable - do an in-place upgrade (run setup.exe from a Windows ISO, keep files and apps)' } } + default { @{ Status = 'Warn'; Detail = "Image health: $state (deep scan with DISM /ScanHealth)" } } } } ` -Fix { Write-RepLog 'Running DISM /RestoreHealth (may take several minutes)...' 'Info' - Repair-WindowsImage -Online -RestoreHealth -ErrorAction SilentlyContinue | Out-Null + & dism.exe /Online /Cleanup-Image /RestoreHealth /NoRestart | Out-Null + $dismExit = $LASTEXITCODE + if ($dismExit -eq 3010) { $script:FixNeedsReboot = $true } Write-RepLog 'Running sfc /scannow...' 'Info' & sfc.exe /scannow | Out-Null - } -FixRisk Aggressive -FixLabel 'DISM RestoreHealth + SFC' -Reboot $false + Write-RepLog ("sfc exit code {0}" -f $LASTEXITCODE) 'Debug' + if ($dismExit -notin 0, 3010) { throw ("DISM RestoreHealth failed (exit 0x{0:X8})" -f $dismExit) } + } -FixRisk Aggressive -FixLabel 'DISM RestoreHealth + SFC' - # ---------------- Disk ---------------- - New-DiagnosticCheck disk-smart 'Physical disk health (SMART)' Disk ` + # ================= Disk ================= + New-DiagnosticCheck -Id disk-smart -Name 'Physical disk health (SMART)' -Category Disk ` -Scan { if (-not (Get-Command Get-PhysicalDisk -ErrorAction SilentlyContinue)) { return @{ Status = 'Skip'; Detail = 'Storage module unavailable' } } - $bad = Get-PhysicalDisk -ErrorAction SilentlyContinue | Where-Object { $_.HealthStatus -and $_.HealthStatus -ne 'Healthy' } + $bad = Get-PhysicalDisk -ErrorAction SilentlyContinue | Where-Object { $_.HealthStatus -and "$($_.HealthStatus)" -ne 'Healthy' } if ($bad) { @{ Status = 'Fail'; Detail = ('Unhealthy disk(s): ' + (($bad | ForEach-Object { "$($_.FriendlyName)=$($_.HealthStatus)" }) -join ', ') + ' - back up now') } } else { @{ Status = 'OK'; Detail = 'All physical disks report Healthy' } } } -Fix $null - New-DiagnosticCheck disk-space 'Low free disk space' Disk ` + New-DiagnosticCheck -Id disk-space -Name 'Low free disk space' -Category Disk ` -Scan { $worst = 'OK'; $lines = @() foreach ($d in (Get-CimInstance Win32_LogicalDisk -Filter 'DriveType=3' -ErrorAction SilentlyContinue)) { @@ -167,104 +450,406 @@ function Get-DiagnosticCheckRegistry { $pct = [math]::Round(($d.FreeSpace / $d.Size) * 100, 1) $freeGB = [math]::Round($d.FreeSpace / 1GB, 1) $lines += "$($d.DeviceID) $freeGB GB free ($pct%)" - if ($pct -lt 5 -or $freeGB -lt 5) { $worst = 'Fail' } - elseif (($pct -lt 12 -or $freeGB -lt 15) -and $worst -ne 'Fail') { $worst = 'Warn' } + if ($d.DeviceID -eq $env:SystemDrive) { + # System drive: absolute limits (updates / pagefile / hibernation need GBs, not %). + if ($freeGB -lt 5) { $worst = 'Fail' } + elseif ($freeGB -lt 15 -and $worst -ne 'Fail') { $worst = 'Warn' } + } + elseif ($pct -lt 5 -and $worst -ne 'Fail') { $worst = 'Warn' } } @{ Status = $worst; Detail = ($lines -join ' | ') + $(if ($worst -ne 'OK') { ' - run Disk cleanup' } else { '' }) } } -Fix $null - New-DiagnosticCheck disk-dirty 'Volumes flagged for chkdsk' Disk ` + New-DiagnosticCheck -Id disk-dirty -Name 'File system errors (dirty bit / NTFS / disk events)' -Category Disk ` -Scan { - $dirty = @() - foreach ($d in (Get-CimInstance Win32_LogicalDisk -Filter 'DriveType=3' -ErrorAction SilentlyContinue)) { - & fsutil.exe dirty query "$($d.DeviceID)" *>$null - if ($LASTEXITCODE -eq 0) { $dirty += $d.DeviceID } + $dirty = @(Get-CimInstance Win32_Volume -Filter 'DriveType=3' -ErrorAction SilentlyContinue | Where-Object DirtyBitSet | + ForEach-Object { if ($_.DriveLetter) { $_.DriveLetter } elseif ($_.Label) { $_.Label } else { $_.DeviceID } }) + $ev = @(Get-WinEvent -FilterHashtable @{ LogName = 'System'; Id = 55, 98, 7, 153; Level = 1, 2, 3; StartTime = (Get-Date).AddDays(-7) } -MaxEvents 200 -ErrorAction SilentlyContinue | + Where-Object { ($_.Id -in 55, 98 -and $_.ProviderName -match 'Ntfs') -or ($_.Id -in 7, 153 -and $_.ProviderName -eq 'disk') }) + $ntfs = @($ev | Where-Object { $_.Id -in 55, 98 }).Count + $io = @($ev | Where-Object { $_.Id -in 7, 153 }).Count + if ($dirty.Count) { @{ Status = 'Fail'; Detail = ('Dirty bit set on: ' + ($dirty -join ', ') + " (NTFS events 7d: $ntfs, disk I/O events 7d: $io)") } } + elseif ($ev.Count) { @{ Status = 'Warn'; Detail = "No dirty volume, but in 7 days: $ntfs NTFS corruption event(s), $io disk bad-block/retry event(s)" } } + else { @{ Status = 'OK'; Detail = 'No volume flagged dirty; no NTFS/disk error events in 7 days' } } + } ` + -Fix { + $failed = @() + foreach ($v in (Get-CimInstance Win32_Volume -Filter 'DriveType=3' -ErrorAction SilentlyContinue | Where-Object DriveLetter)) { + $dl = "$($v.DriveLetter)".TrimEnd(':') + try { + Write-RepLog "Repair-Volume ${dl}: -Scan (online)..." 'Info' + $r = Repair-Volume -DriveLetter $dl -Scan -ErrorAction Stop + if ("$r" -ne 'NoErrorsFound') { + Write-RepLog " ${dl}: scan result $r - running -SpotFix" 'Warning' + $r2 = Repair-Volume -DriveLetter $dl -SpotFix -ErrorAction Stop + Write-RepLog " ${dl}: spot fix result $r2" 'Info' + } + # autochk clears a dirty bit at the next boot. + $still = Get-CimInstance Win32_Volume -Filter "DriveLetter='${dl}:'" -ErrorAction SilentlyContinue + if ($still.DirtyBitSet) { $script:FixNeedsReboot = $true } + } catch { $failed += "${dl}: $($_.Exception.Message)" } + } + if ($failed) { throw ('Repair-Volume failed: ' + ($failed -join '; ')) } + } -FixRisk Moderate -FixLabel 'Repair-Volume -Scan (online), -SpotFix if errors' + + New-DiagnosticCheck -Id disk-reliability -Name 'SSD wear & temperature' -Category Disk ` + -Scan { + if (-not (Get-Command Get-PhysicalDisk -ErrorAction SilentlyContinue)) { return @{ Status = 'Skip'; Detail = 'Storage module unavailable' } } + $worst = 'OK'; $lines = @() + foreach ($pd in (Get-PhysicalDisk -ErrorAction SilentlyContinue)) { + $rc = $pd | Get-StorageReliabilityCounter -ErrorAction SilentlyContinue + if (-not $rc) { continue } + $parts = @() + if ($null -ne $rc.Wear) { $parts += "wear $($rc.Wear)%"; if ($rc.Wear -ge 90) { $worst = 'Fail' } elseif ($rc.Wear -ge 80 -and $worst -ne 'Fail') { $worst = 'Warn' } } + if ($rc.Temperature) { + # Use the drive's own rated maximum when it reports one. + if ($rc.TemperatureMax -gt 0) { $failAt = [int]$rc.TemperatureMax; $warnAt = $failAt - 10 } + else { $failAt = 80; $warnAt = 70 } + $parts += "$($rc.Temperature) C (max $failAt)" + if ($rc.Temperature -ge $failAt) { $worst = 'Fail' } elseif ($rc.Temperature -ge $warnAt -and $worst -ne 'Fail') { $worst = 'Warn' } + } + if ($rc.ReadErrorsUncorrected) { $worst = 'Fail'; $parts += "$($rc.ReadErrorsUncorrected) uncorrected" } + if ($parts.Count) { $lines += ("$($pd.FriendlyName): " + ($parts -join ', ')) } + } + if ($lines.Count -eq 0) { @{ Status = 'OK'; Detail = 'No reliability counters reported (HDD/USB/older SATA)' } } + else { @{ Status = $worst; Detail = ($lines -join ' | ') } } + } -Fix $null + + New-DiagnosticCheck -Id trim -Name 'SSD TRIM & scheduled optimization' -Category Disk ` + -Scan { + $issues = @() + $ddn = Get-RepRegValue 'HKLM:\SYSTEM\CurrentControlSet\Control\FileSystem' 'DisableDeleteNotification' + $ssd = @() + if (Get-Command Get-PhysicalDisk -ErrorAction SilentlyContinue) { + $ssd = @(Get-PhysicalDisk -ErrorAction SilentlyContinue | Where-Object { "$($_.MediaType)" -eq 'SSD' }) } - if ($dirty) { @{ Status = 'Warn'; Detail = ('Dirty bit set on: ' + ($dirty -join ', ')) } } - else { @{ Status = 'OK'; Detail = 'No volume flagged dirty' } } + if ($ddn -eq 1 -and $ssd.Count) { $issues += 'TRIM is disabled (DisableDeleteNotification=1) on a PC with an SSD' } + if (Get-Command Get-ScheduledTask -ErrorAction SilentlyContinue) { + $t = Get-ScheduledTask -TaskPath '\Microsoft\Windows\Defrag\' -TaskName 'ScheduledDefrag' -ErrorAction SilentlyContinue + if ($t -and "$($t.State)" -eq 'Disabled') { $issues += 'ScheduledDefrag (drive optimization / retrim) task is disabled' } + } + if ($issues) { @{ Status = 'Warn'; Detail = ($issues -join '; ') } } + else { @{ Status = 'OK'; Detail = "TRIM enabled; drive optimization scheduled ($($ssd.Count) SSD)" } } } ` -Fix { - foreach ($d in (Get-CimInstance Win32_LogicalDisk -Filter 'DriveType=3' -ErrorAction SilentlyContinue)) { - & fsutil.exe dirty query "$($d.DeviceID)" *>$null - if ($LASTEXITCODE -eq 0) { Write-RepLog "chkdsk $($d.DeviceID) /scan (online)..." 'Info'; & chkdsk.exe "$($d.DeviceID)" /scan | Out-Null } + if ((Get-RepRegValue 'HKLM:\SYSTEM\CurrentControlSet\Control\FileSystem' 'DisableDeleteNotification') -eq 1) { + & fsutil.exe behavior set DisableDeleteNotify NTFS 0 *> $null + if ($LASTEXITCODE -ne 0) { throw "fsutil behavior set DisableDeleteNotify failed (exit $LASTEXITCODE)" } + } + $t = Get-ScheduledTask -TaskPath '\Microsoft\Windows\Defrag\' -TaskName 'ScheduledDefrag' -ErrorAction SilentlyContinue + if ($t -and "$($t.State)" -eq 'Disabled') { + Enable-ScheduledTask -TaskPath '\Microsoft\Windows\Defrag\' -TaskName 'ScheduledDefrag' -ErrorAction Stop | Out-Null } - } -FixRisk Moderate -FixLabel 'chkdsk /scan (online, no reboot)' + } -FixRisk Safe -FixLabel 'Enable TRIM + scheduled drive optimization' - # ---------------- Update ---------------- - New-DiagnosticCheck reboot-pending 'Pending reboot' Update ` + # ================= Update ================= + New-DiagnosticCheck -Id reboot-pending -Name 'Pending reboot' -Category Update ` -Scan { $reasons = @() if (Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Component Based Servicing\RebootPending') { $reasons += 'CBS' } if (Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\WindowsUpdate\Auto Update\RebootRequired') { $reasons += 'WindowsUpdate' } - $pfro = (Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager' -Name PendingFileRenameOperations -ErrorAction SilentlyContinue).PendingFileRenameOperations - if ($pfro) { $reasons += 'PendingFileRename' } - if ($reasons) { @{ Status = 'Warn'; Detail = ('Reboot required: ' + ($reasons -join ', ')) } } - else { @{ Status = 'OK'; Detail = 'No pending reboot' } } + $pfro = Get-RepRegValue 'HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager' 'PendingFileRenameOperations' + if ($reasons) { @{ Status = 'Warn'; Detail = ('Reboot required: ' + ($reasons -join ', ') + $(if ($pfro) { ' (+ pending file renames)' } else { '' })) } } + elseif ($pfro) { @{ Status = 'OK'; Detail = 'Pending file renames queued (applied at next restart; no action needed)' } } + else { @{ Status = 'OK'; Detail = 'No pending reboot' } } } ` - -Fix { Write-RepLog 'Scheduling reboot in 60s (cancel with: shutdown /a)' 'Warning'; & shutdown.exe /r /t 60 /c 'WinSenior repair reboot' } ` - -FixRisk Aggressive -FixLabel 'Reboot in 60s (cancel: shutdown /a)' -Reboot $true + -Fix { + # Never restart mid-run: just flag it so the summary / GUI tells the user. + Write-RepLog 'Restart Windows to finish pending updates (not restarting automatically).' 'Warning' + $script:FixNeedsReboot = $true + } -FixRisk Safe -FixLabel 'Flag reboot required (no automatic restart)' -Reboot $true - New-DiagnosticCheck wu-health 'Windows Update components' Update ` + New-DiagnosticCheck -Id wu-health -Name 'Windows Update components' -Category Update ` -Scan { $sd = "$env:WINDIR\SoftwareDistribution\Download" $sizeGB = 0 if (Test-Path $sd) { $sizeGB = [math]::Round(((Get-ChildItem $sd -Recurse -Force -File -ErrorAction SilentlyContinue | Measure-Object Length -Sum).Sum) / 1GB, 2) } $wu = Get-Service wuauserv -ErrorAction SilentlyContinue - if ($wu -and $wu.StartType -eq 'Disabled') { return @{ Status = 'Warn'; Detail = 'wuauserv is Disabled; SoftwareDistribution ' + $sizeGB + ' GB' } } + if ($wu -and "$($wu.StartType)" -eq 'Disabled') { return @{ Status = 'Warn'; Detail = "wuauserv is Disabled; SoftwareDistribution $sizeGB GB" } } if ($sizeGB -gt 4) { return @{ Status = 'Warn'; Detail = "SoftwareDistribution cache is large ($sizeGB GB)" } } @{ Status = 'OK'; Detail = "Update cache $sizeGB GB; service OK" } } ` -Fix { Write-RepLog 'Resetting Windows Update components...' 'Info' - foreach ($s in 'wuauserv','bits','cryptsvc') { Stop-Service $s -Force -ErrorAction SilentlyContinue } - foreach ($p in @("$env:WINDIR\SoftwareDistribution","$env:WINDIR\System32\catroot2")) { - if (Test-Path $p) { Rename-Item $p "$p.old_$(Get-Date -Format 'yyyyMMddHHmmss')" -Force -ErrorAction SilentlyContinue } + $wu = Get-Service wuauserv -ErrorAction SilentlyContinue + if ($wu -and "$($wu.StartType)" -eq 'Disabled') { + $via = Restore-RepServiceStart -Name wuauserv -StartType Manual + Write-RepLog "wuauserv set back to Manual (via $via)" 'Info' + } + foreach ($s in 'wuauserv', 'bits', 'cryptsvc') { Stop-Service $s -Force -ErrorAction SilentlyContinue } + $stamp = Get-Date -Format 'yyyyMMddHHmmss' + $errs = @() + foreach ($p in @("$env:WINDIR\SoftwareDistribution", "$env:WINDIR\System32\catroot2")) { + $leaf = Split-Path $p -Leaf + if (Test-Path -LiteralPath $p) { + try { Rename-Item -LiteralPath $p -NewName "$leaf.old_$stamp" -Force -ErrorAction Stop } + catch { $errs += "${leaf}: $($_.Exception.Message)" } + } + # Keep only the newest .old_* backup. + Get-ChildItem -LiteralPath (Split-Path $p -Parent) -Directory -Filter "$leaf.old_*" -Force -ErrorAction SilentlyContinue | + Sort-Object Name -Descending | Select-Object -Skip 1 | + ForEach-Object { Remove-Item -LiteralPath $_.FullName -Recurse -Force -ErrorAction SilentlyContinue } } - foreach ($s in 'cryptsvc','bits','wuauserv') { Start-Service $s -ErrorAction SilentlyContinue } + foreach ($s in 'cryptsvc', 'bits', 'wuauserv') { Start-Service $s -ErrorAction SilentlyContinue } + if ($errs) { throw ('Could not rename: ' + ($errs -join '; ')) } } -FixRisk Moderate -FixLabel 'Reset Windows Update (rename SoftwareDistribution/catroot2)' - # ---------------- Network ---------------- - New-DiagnosticCheck net-connectivity 'Internet & DNS' Network ` + New-DiagnosticCheck -Id wu-policy -Name 'Windows Update blocked by policy' -Category Update ` + -Scan { + $pol = 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate' + $au = "$pol\AU" + $fail = @(); $warn = @() + foreach ($n in 'DisableWindowsUpdateAccess', 'SetDisableUXWUAccess', 'DoNotConnectToWindowsUpdateInternetLocations') { + if ((Get-RepRegValue $pol $n) -eq 1) { $fail += "$n=1" } + } + $domain = Test-RepPartOfDomain + $wsus = Get-RepRegValue $pol 'WUServer' + if ($wsus -and (Get-RepRegValue $au 'UseWUServer') -eq 1 -and -not $domain) { $fail += "WSUS server '$wsus' on a non-domain PC" } + if ((Get-RepRegValue $au 'NoAutoUpdate') -eq 1) { $warn += 'NoAutoUpdate=1' } + $exp = Get-RepRegValue 'HKLM:\SOFTWARE\Microsoft\WindowsUpdate\UX\Settings' 'PauseUpdatesExpiryTime' + if ($exp) { + try { + $dt = [DateTime]::Parse("$exp", [Globalization.CultureInfo]::InvariantCulture, [Globalization.DateTimeStyles]'AdjustToUniversal, AssumeUniversal') + if ($dt -gt (Get-Date).ToUniversalTime().AddDays(35)) { $warn += "updates paused until $($dt.ToString('yyyy-MM-dd'))" } + } catch { Write-Verbose "PauseUpdatesExpiryTime unparsable: $exp" } + } + if (-not ($fail -or $warn)) { return @{ Status = 'OK'; Detail = 'No policy blocks Windows Update' } } + $managed = $domain -or (Test-RepMdmEnrolled) + $detail = (@($fail) + @($warn)) -join '; ' + if ($managed) { return @{ Status = 'Warn'; NoFix = $true; Detail = "$detail (domain/MDM-managed PC - left to the administrator)" } } + @{ Status = $(if ($fail) { 'Fail' } else { 'Warn' }); Detail = $detail } + } ` + -Fix { + if ((Test-RepPartOfDomain) -or (Test-RepMdmEnrolled)) { throw 'Domain-joined or MDM-enrolled PC - Windows Update policy left to the administrator' } + $pol = 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate' + $au = "$pol\AU" + $ux = 'HKLM:\SOFTWARE\Microsoft\WindowsUpdate\UX\Settings' + $dir = Get-RepBackupDir + $stamp = Get-Date -Format 'yyyyMMdd-HHmmss' + foreach ($k in @(@{ Reg = 'HKLM\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate'; Tag = 'policy' }, + @{ Reg = 'HKLM\SOFTWARE\Microsoft\WindowsUpdate\UX\Settings'; Tag = 'ux' })) { + if (Test-Path -LiteralPath "Registry::$($k.Reg)") { + $f = Join-Path $dir "wu-$($k.Tag)-$stamp.reg" + & reg.exe export $k.Reg $f /y *> $null + if ($LASTEXITCODE -ne 0) { throw "reg export $($k.Reg) failed (exit $LASTEXITCODE) - nothing changed" } + Write-RepLog "Backed up $($k.Reg) to $f" 'Info' + } + } + foreach ($n in 'DisableWindowsUpdateAccess', 'SetDisableUXWUAccess', 'DoNotConnectToWindowsUpdateInternetLocations') { + Remove-ItemProperty -LiteralPath $pol -Name $n -ErrorAction SilentlyContinue + } + if ((Get-RepRegValue $pol 'WUServer') -and (Get-RepRegValue $au 'UseWUServer') -eq 1) { + foreach ($n in 'WUServer', 'WUStatusServer') { Remove-ItemProperty -LiteralPath $pol -Name $n -ErrorAction SilentlyContinue } + Remove-ItemProperty -LiteralPath $au -Name 'UseWUServer' -ErrorAction SilentlyContinue + } + Remove-ItemProperty -LiteralPath $au -Name 'NoAutoUpdate' -ErrorAction SilentlyContinue + $exp = Get-RepRegValue $ux 'PauseUpdatesExpiryTime' + if ($exp) { + foreach ($n in 'PauseUpdatesExpiryTime', 'PauseUpdatesStartTime', 'PauseFeatureUpdatesStartTime', 'PauseFeatureUpdatesEndTime', + 'PauseQualityUpdatesStartTime', 'PauseQualityUpdatesEndTime') { + Remove-ItemProperty -LiteralPath $ux -Name $n -ErrorAction SilentlyContinue + } + } + } -FixRisk Moderate -FixLabel 'Back up + remove Windows Update blocking policies' + + New-DiagnosticCheck -Id wu-history -Name 'Windows Update install failures' -Category Update ` + -Scan { + $wuScript = @' +$s = New-Object -ComObject Microsoft.Update.Session +$q = $s.CreateUpdateSearcher() +$n = $q.GetTotalHistoryCount() +if ($n -gt 0) { + foreach ($e in $q.QueryHistory(0, [math]::Min($n, 50))) { + [pscustomobject]@{ Date = $e.Date; ResultCode = [int]$e.ResultCode; HResult = [int]$e.HResult + Operation = [int]$e.Operation; UpdateId = [string]$e.UpdateIdentity.UpdateID; Title = [string]$e.Title } + } +} +'@ + try { $hist = @(Invoke-RepWithTimeout -ScriptText $wuScript -TimeoutSec 20) } + catch { return @{ Status = 'Warn'; Detail = "Update history unreadable ($($_.Exception.Message)) - history DB may be corrupt; see wu-health" } } + $cut = (Get-Date).ToUniversalTime().AddDays(-30) + $bad = foreach ($g in ($hist | Where-Object { $_.Operation -eq 1 -and $_.UpdateId } | Group-Object UpdateId)) { + $items = @($g.Group | Sort-Object Date) + $lastOk = @($items | Where-Object { $_.ResultCode -in 2, 3 } | Select-Object -Last 1) + $fails = @($items | Where-Object { $_.ResultCode -in 4, 5 -and $_.Date -gt $cut -and (-not $lastOk -or $_.Date -gt $lastOk[0].Date) }) + if ($fails.Count) { + $last = $fails[-1] + $kb = if ($last.Title -match 'KB\d{6,8}') { $Matches[0] } else { $g.Name.Substring(0, 8) } + $hr = '0x{0:X8}' -f $last.HResult + [pscustomobject]@{ Kb = $kb; Count = $fails.Count; HResult = $hr; Route = (Get-WuErrorRoute $last.HResult) } + } + } + $bad = @($bad) + if (-not $bad.Count) { return @{ Status = 'OK'; Detail = "$($hist.Count) history entr(ies); no unresolved install failures in 30 days" } } + $txt = ($bad | Sort-Object Count -Descending | Select-Object -First 4 | ForEach-Object { + "$($_.Kb) failed $($_.Count)x ($($_.HResult)$(if ($_.Route) { " -> check $($_.Route)" }))" }) -join '; ' + @{ Status = $(if ($bad | Where-Object { $_.Count -ge 3 }) { 'Fail' } else { 'Warn' }); Detail = $txt } + } -Fix $null + + New-DiagnosticCheck -Id bits-health -Name 'BITS transfer queue' -Category Update ` + -Scan { + if (-not (Get-Command Get-BitsTransfer -ErrorAction SilentlyContinue)) { return @{ Status = 'Skip'; Detail = 'BITS module unavailable' } } + $jobs = @(Get-BitsTransfer -AllUsers -ErrorAction SilentlyContinue) + $err = @($jobs | Where-Object { "$($_.JobState)" -eq 'Error' }) + $trans = @($jobs | Where-Object { "$($_.JobState)" -eq 'TransientError' }).Count + if ($err.Count) { @{ Status = 'Warn'; Detail = "$($err.Count) BITS job(s) in Error state ($trans transient - normal)" } } + elseif ($jobs.Count -gt 50) { @{ Status = 'Warn'; NoFix = $true; Detail = "$($jobs.Count) BITS jobs queued (backlog)" } } + else { @{ Status = 'OK'; Detail = "$($jobs.Count) BITS job(s); none in Error ($trans transient - normal)" } } + } ` + -Fix { + Get-BitsTransfer -AllUsers -ErrorAction SilentlyContinue | Where-Object { "$($_.JobState)" -eq 'Error' } | + Remove-BitsTransfer -ErrorAction Stop + } -FixRisk Moderate -FixLabel 'Remove BITS jobs stuck in Error' + + # ================= Network ================= + New-DiagnosticCheck -Id net-connectivity -Name 'Internet & DNS' -Category Network ` -Scan { - # Address held in a variable so PSScriptAnalyzer doesn't flag it as a hardcoded host. - $pingTarget = '8.8.8.8'; $dnsTarget = 'microsoft.com' - $ping = Test-Connection -ComputerName $pingTarget -Count 1 -Quiet -ErrorAction SilentlyContinue - $dns = $false - if (Get-Command Resolve-DnsName -ErrorAction SilentlyContinue) { - $dns = [bool](Resolve-DnsName $dnsTarget -ErrorAction SilentlyContinue) + $n = Get-RepNetState + $script:RepNetScanState = $n.State + switch ($n.State) { + 'OK' { @{ Status = 'OK'; Detail = 'Internet (NCSI probe) and DNS reachable' } } + 'DnsFail' { @{ Status = 'Warn'; FixRisk = 'Safe'; Reboot = $false; FixLabel = 'Flush DNS cache + re-register DNS' + Detail = "DNS resolution failing (NCSI probe ok: $($n.Probe); profile says Internet: $($n.Internet))" } } + 'ProbeBlocked' { @{ Status = 'Warn'; NoFix = $true + Detail = 'Windows reports Internet and DNS works, but the HTTP connectivity probe is blocked (proxy/firewall?)' } } + default { @{ Status = 'Fail'; Detail = 'No internet: connectivity probe and DNS both fail, no network profile has Internet access' } } } - if (-not $ping) { @{ Status = 'Fail'; Detail = 'No reply from 8.8.8.8 (no internet)' } } - elseif (-not $dns) { @{ Status = 'Warn'; Detail = 'Internet OK but DNS resolution failed' } } - else { @{ Status = 'OK'; Detail = 'Internet and DNS reachable' } } } ` -Fix { - Write-RepLog 'Flushing DNS and resetting the network stack...' 'Info' + $now = (Get-RepNetState -Fresh).State + Write-RepLog 'Flushing DNS cache and re-registering DNS...' 'Info' & ipconfig.exe /flushdns | Out-Null + & ipconfig.exe /registerdns | Out-Null + if ($script:RepNetScanState -eq 'Offline' -and $now -eq 'Offline') { + Write-RepLog 'Still no connectivity - resetting Winsock and TCP/IP. This WIPES static IP/DNS settings; re-enter them after the restart.' 'Warning' + & netsh.exe winsock reset | Out-Null + if ($LASTEXITCODE -ne 0) { throw "netsh winsock reset failed (exit $LASTEXITCODE)" } + & netsh.exe int ip reset | Out-Null + if ($LASTEXITCODE -ne 0) { Write-RepLog "netsh int ip reset exit $LASTEXITCODE (partial reset is common)" 'Warning' } + $script:FixNeedsReboot = $true + } + $script:RepNcsiCache = $null + } -FixRisk Aggressive -FixLabel 'Flush DNS; winsock/IP reset only when fully offline (wipes static IP)' -Reboot $false + + New-DiagnosticCheck -Id net-adapter -Name 'Network adapters & WLAN service' -Category Network ` + -Scan { + $ad = @(Get-CimInstance Win32_NetworkAdapter -ErrorAction SilentlyContinue) + $connected = @($ad | Where-Object { $_.NetConnectionStatus -eq 2 }) + $disabled = @($ad | Where-Object { $_.PhysicalAdapter -and $_.ConfigManagerErrorCode -eq 22 }) + $wifi = @() + if (Get-Command Get-NetAdapter -ErrorAction SilentlyContinue) { + $wifi = @(Get-NetAdapter -Physical -ErrorAction SilentlyContinue | Where-Object { $_.NdisPhysicalMedium -eq 9 }) + } + $wlan = Get-Service WlanSvc -ErrorAction SilentlyContinue + $issues = @() + if ($wifi.Count -and (-not $wlan -or "$($wlan.Status)" -ne 'Running')) { $issues += 'Wi-Fi adapter present but WLAN AutoConfig (WlanSvc) is not running' } + if (-not $connected.Count -and $disabled.Count) { $issues += ('No adapter connected; disabled physical adapter(s): ' + (($disabled | ForEach-Object { $_.NetConnectionID }) -join ', ')) } + if ($issues) { @{ Status = 'Fail'; Detail = ($issues -join '; ') } } + elseif (-not $connected.Count) { @{ Status = 'Warn'; NoFix = $true; Detail = 'No network adapter is connected (cable / Wi-Fi?)' } } + else { @{ Status = 'OK'; Detail = ("Connected: " + (($connected | ForEach-Object { $_.NetConnectionID }) -join ', ') + $(if ($disabled.Count) { " ($($disabled.Count) adapter(s) disabled by user)" } else { '' })) } } + } ` + -Fix { + $ad = @(Get-CimInstance Win32_NetworkAdapter -ErrorAction SilentlyContinue) + if (-not @($ad | Where-Object { $_.NetConnectionStatus -eq 2 }).Count) { + foreach ($a in @($ad | Where-Object { $_.PhysicalAdapter -and $_.ConfigManagerErrorCode -eq 22 })) { + $r = Invoke-CimMethod -InputObject $a -MethodName Enable -ErrorAction Stop + Write-RepLog "Enabled adapter $($a.NetConnectionID) (rc $($r.ReturnValue))" 'Info' + } + } + $wlan = Get-Service WlanSvc -ErrorAction SilentlyContinue + if ($wlan -and "$($wlan.Status)" -ne 'Running') { + $via = Restore-RepServiceStart -Name WlanSvc -StartType Automatic + Write-RepLog "WlanSvc set to Automatic (via $via)" 'Info' + Start-Service WlanSvc -ErrorAction Stop + } + } -FixRisk Safe -FixLabel 'Enable disabled adapters + start WLAN AutoConfig' + + New-DiagnosticCheck -Id winsock-lsp -Name 'Winsock catalog (broken LSP)' -Category Network ` + -Scan { + $prov = @(Get-RepWinsockProvider) + if (-not $prov.Count) { return @{ Status = 'Skip'; Detail = 'Winsock catalog not readable' } } + $missing = @($prov | Where-Object { -not $_.Exists } | ForEach-Object { $_.Path } | Sort-Object -Unique) + if ($missing.Count) { @{ Status = 'Fail'; Detail = ('Winsock provider DLL missing: ' + ($missing -join ', ') + ' - breaks all networking') } } + else { @{ Status = 'OK'; Detail = "$($prov.Count) catalog entries; all provider DLLs present" } } + } ` + -Fix { & netsh.exe winsock reset | Out-Null - & netsh.exe int ip reset | Out-Null - & ipconfig.exe /release | Out-Null - & ipconfig.exe /renew | Out-Null - } -FixRisk Aggressive -FixLabel 'Flush DNS + winsock/IP reset' -Reboot $true + if ($LASTEXITCODE -ne 0) { throw "netsh winsock reset failed (exit $LASTEXITCODE)" } + $script:FixNeedsReboot = $true + } -FixRisk Moderate -FixLabel 'netsh winsock reset' -Reboot $true + + New-DiagnosticCheck -Id hosts-integrity -Name 'Hosts file integrity' -Category Network ` + -Scan { + $hosts = "$env:WINDIR\System32\drivers\etc\hosts" + if (-not (Test-Path -LiteralPath $hosts)) { return @{ Status = 'OK'; Detail = 'No hosts file (default)' } } + $lines = @(Get-Content -LiteralPath $hosts -ErrorAction SilentlyContinue) + $active = @($lines | Where-Object { ("$_" -replace '#.*$', '').Trim() } | + Where-Object { $_ -notmatch '^\s*(127\.0\.0\.1|::1)\s+localhost\s*(#.*)?$' }) + $bad = @($lines | Where-Object { Test-RepHostsHijackLine $_ }) + if ($bad.Count) { @{ Status = 'Fail'; Detail = "$($bad.Count) hosts entry(ies) redirect Windows Update / Defender / activation hosts - possible hijack" } } + else { @{ Status = 'OK'; Detail = $(if ($active.Count) { "$($active.Count) custom entry(ies); none touch update/security hosts" } else { 'Hosts file has no active redirects' }) } } + } ` + -Fix { + $hosts = "$env:WINDIR\System32\drivers\etc\hosts" + $bak = "$hosts.winsenior.bak" + if (Test-Path -LiteralPath $bak) { $bak = "$hosts.winsenior-$(Get-Date -Format 'yyyyMMddHHmmss').bak" } + # Latin-1 round-trips every byte, so untouched lines keep their exact encoding. + $enc = [Text.Encoding]::GetEncoding(28591) + $text = [IO.File]::ReadAllText($hosts, $enc) + Copy-Item -LiteralPath $hosts -Destination $bak -Force -ErrorAction Stop + $fi = Get-Item -LiteralPath $hosts -Force + if ($fi.IsReadOnly) { $fi.IsReadOnly = $false } + $parts = [regex]::Split($text, '(?<=\n)') + $kept = @($parts | Where-Object { -not (Test-RepHostsHijackLine ($_.TrimEnd("`r", "`n"))) }) + [IO.File]::WriteAllText($hosts, (-join $kept), $enc) + Write-RepLog "Removed $($parts.Count - $kept.Count) hijacking hosts line(s); backup: $bak" 'Info' + & ipconfig.exe /flushdns | Out-Null + } -FixRisk Moderate -FixLabel 'Remove only the hijacking hosts lines (backup first)' + + New-DiagnosticCheck -Id proxy-hijack -Name 'Proxy / PAC hijack' -Category Network ` + -Scan { + $is = 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Internet Settings' + $p = Get-ItemProperty $is -ErrorAction SilentlyContinue + if ($p.AutoConfigURL) { + if (Test-RepPartOfDomain) { @{ Status = 'Warn'; NoFix = $true; Detail = "PAC set on a domain-joined PC (likely corporate, left alone): $($p.AutoConfigURL)" } } + else { @{ Status = 'Fail'; Detail = "AutoConfigURL (PAC) set: $($p.AutoConfigURL)" } } + } + elseif ($p.ProxyEnable -eq 1 -and $p.ProxyServer -match '(^|=|;)\s*(127\.\d+\.\d+\.\d+|localhost|\[?::1\]?)(:|;|$)') { + # A loopback proxy is usually a VPN / proxy client or debugger the user runs: never pre-select its removal. + @{ Status = 'Warn'; FixRisk = 'Aggressive'; FixLabel = 'Remove local proxy (breaks VPN/proxy apps that set it)' + Detail = "Local proxy enabled: $($p.ProxyServer) (VPN/proxy client? verify you run one)" } + } + elseif ($p.ProxyEnable -eq 1 -and $p.ProxyServer) { @{ Status = 'Warn'; Detail = "Proxy enabled: $($p.ProxyServer)" } } + else { @{ Status = 'OK'; Detail = 'No proxy / PAC configured' } } + } ` + -Fix { + $is = 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Internet Settings' + Set-ItemProperty $is -Name ProxyEnable -Value 0 -ErrorAction SilentlyContinue + Remove-ItemProperty $is -Name ProxyServer -ErrorAction SilentlyContinue + if (-not (Test-RepPartOfDomain)) { Remove-ItemProperty $is -Name AutoConfigURL -ErrorAction SilentlyContinue } + & netsh.exe winhttp reset proxy *> $null + } -FixRisk Moderate -FixLabel 'Reset WinINET/WinHTTP proxy settings' - # ---------------- Devices ---------------- - New-DiagnosticCheck dev-errors 'Devices with driver problems' Devices ` + # ================= Devices ================= + New-DiagnosticCheck -Id dev-errors -Name 'Devices with driver problems' -Category Devices ` -Scan { - $bad = Get-CimInstance Win32_PnPEntity -ErrorAction SilentlyContinue | Where-Object { $_.ConfigManagerErrorCode -and $_.ConfigManagerErrorCode -ne 0 } + # Code 22 = disabled by the user: a choice, not a fault. + $bad = Get-CimInstance Win32_PnPEntity -ErrorAction SilentlyContinue | Where-Object { $_.ConfigManagerErrorCode -and $_.ConfigManagerErrorCode -notin 0, 22 } if ($bad) { $names = ($bad | Select-Object -First 5 | ForEach-Object { "$($_.Name) (code $($_.ConfigManagerErrorCode))" }) -join '; ' @{ Status = 'Warn'; Detail = "$(@($bad).Count) device(s) with errors: $names" } } else { @{ Status = 'OK'; Detail = 'No devices report driver errors' } } } ` - -Fix { Write-RepLog 'Rescanning for hardware changes...' 'Info'; & pnputil.exe /scan-devices *>$null } ` + -Fix { Write-RepLog 'Rescanning for hardware changes...' 'Info'; & pnputil.exe /scan-devices *> $null } ` -FixRisk Safe -FixLabel 'Rescan devices (pnputil /scan-devices)' - # ---------------- Services ---------------- - New-DiagnosticCheck svc-critical 'Critical services stopped' Services ` + # ================= Services ================= + New-DiagnosticCheck -Id svc-critical -Name 'Critical services stopped' -Category Services ` -Scan { $want = 'Audiosrv','Dhcp','Dnscache','EventLog','mpssvc','Winmgmt','Schedule','BFE','LanmanWorkstation','ProfSvc','nsi','Power' $stopped = foreach ($n in $want) { $s = Get-Service $n -ErrorAction SilentlyContinue - if ($s -and $s.StartType -in 'Automatic','Boot','System' -and $s.Status -ne 'Running') { $n } + if ($s -and "$($s.StartType)" -in 'Automatic','Boot','System' -and "$($s.Status)" -ne 'Running') { $n } } $stopped = @($stopped) if ($stopped.Count) { @{ Status = 'Fail'; Detail = ('Stopped: ' + ($stopped -join ', ')) } } @@ -274,20 +859,90 @@ function Get-DiagnosticCheckRegistry { $want = 'Audiosrv','Dhcp','Dnscache','EventLog','mpssvc','Winmgmt','Schedule','BFE','LanmanWorkstation','ProfSvc','nsi','Power' foreach ($n in $want) { $s = Get-Service $n -ErrorAction SilentlyContinue - if ($s -and $s.StartType -in 'Automatic','Boot','System' -and $s.Status -ne 'Running') { + if ($s -and "$($s.StartType)" -in 'Automatic','Boot','System' -and "$($s.Status)" -ne 'Running') { Start-Service $n -ErrorAction SilentlyContinue Write-RepLog "started $n" 'Debug' } } } -FixRisk Safe -FixLabel 'Start stopped critical services' - # ---------------- Security ---------------- - New-DiagnosticCheck def-health 'Microsoft Defender health' Security ` + New-DiagnosticCheck -Id svc-defaults -Name 'Core services disabled' -Category Services ` + -Scan { + $bad = @(foreach ($s in (Get-RepServiceDefault)) { + if ((Get-RepRegValue "HKLM:\SYSTEM\CurrentControlSet\Services\$($s.Name)" 'Start') -eq 4) { $s } + }) + $note = if ((Get-RepRegValue 'HKLM:\SYSTEM\CurrentControlSet\Services\WSearch' 'Start') -eq 4) { ' (WSearch disabled - left alone, may be an Optimize tweak)' } else { '' } + if (-not $bad.Count) { return @{ Status = 'OK'; Detail = "No core Windows service is disabled$note" } } + $txt = 'Disabled: ' + (($bad | ForEach-Object { $_.Name }) -join ', ') + $note + @{ Status = $(if ($bad | Where-Object Level -eq 'Fail') { 'Fail' } else { 'Warn' }); Detail = $txt } + } ` + -Fix { + $failed = @() + foreach ($s in (Get-RepServiceDefault)) { + if ((Get-RepRegValue "HKLM:\SYSTEM\CurrentControlSet\Services\$($s.Name)" 'Start') -ne 4) { continue } + try { + $via = Restore-RepServiceStart -Name $s.Name -StartType $s.Default + Write-RepLog "$($s.Name) -> $($s.Default) (via $via)" 'Info' + } catch { $failed += "$($s.Name): $($_.Exception.Message)" } + } + if ($failed) { throw ('Could not restore: ' + ($failed -join '; ')) } + } -FixRisk Moderate -FixLabel 'Restore default start type of disabled core services' + + New-DiagnosticCheck -Id spooler-health -Name 'Print spooler' -Category Services ` -Scan { - if (-not (Get-Command Get-MpComputerStatus -ErrorAction SilentlyContinue)) { - return @{ Status = 'Skip'; Detail = 'Defender module unavailable (3rd-party AV?)' } + $sp = Get-Service Spooler -ErrorAction SilentlyContinue + if (-not $sp) { return @{ Status = 'Skip'; Detail = 'Spooler service not found' } } + if ("$($sp.StartType)" -eq 'Disabled') { return @{ Status = 'OK'; Detail = 'Spooler disabled (printing turned off)' } } + # Check the service first: Get-Printer against a dead spooler errors or stalls. + if ("$($sp.Status)" -ne 'Running') { + if ("$($sp.StartType)" -like 'Automatic*') { return @{ Status = 'Warn'; Detail = 'Spooler should run but is stopped' } } + return @{ Status = 'OK'; Detail = "Spooler stopped (start type $($sp.StartType))" } } - $st = Get-MpComputerStatus -ErrorAction Stop + $printers = @(Get-Printer -ErrorAction SilentlyContinue) + if ($printers.Count -eq 0) { return @{ Status = 'OK'; Detail = 'No printers installed' } } + $queue = @(Get-ChildItem "$env:WINDIR\System32\spool\PRINTERS" -ErrorAction SilentlyContinue) + if ($queue.Count -gt 0) { @{ Status = 'Warn'; Detail = "$($queue.Count) file(s) stuck in the print queue" } } + else { @{ Status = 'OK'; Detail = "Spooler running; $($printers.Count) printer(s); queue clear" } } + } ` + -Fix { + Stop-Service Spooler -Force -ErrorAction SilentlyContinue + Get-ChildItem "$env:WINDIR\System32\spool\PRINTERS\*" -ErrorAction SilentlyContinue | Remove-Item -Force -ErrorAction SilentlyContinue + Start-Service Spooler -ErrorAction Stop + } -FixRisk Safe -FixLabel 'Clear print queue + restart spooler' + + New-DiagnosticCheck -Id search-health -Name 'Windows Search index' -Category Services ` + -Scan { + $ws = Get-Service WSearch -ErrorAction SilentlyContinue + if (-not $ws) { return @{ Status = 'OK'; Detail = 'Windows Search not installed' } } + if ("$($ws.StartType)" -eq 'Disabled') { return @{ Status = 'OK'; Detail = 'Windows Search disabled (intentional?)' } } + $dir = "$env:ProgramData\Microsoft\Search\Data\Applications\Windows" + $size = 0 + foreach ($f in 'Windows.edb', 'Windows.db') { + $it = Get-Item -LiteralPath (Join-Path $dir $f) -Force -ErrorAction SilentlyContinue + if ($it) { $size += $it.Length } + } + $gb = [math]::Round($size / 1GB, 1) + if ("$($ws.StartType)" -like 'Automatic*' -and "$($ws.Status)" -ne 'Running') { @{ Status = 'Warn'; Detail = "WSearch is Automatic but not running (index $gb GB)" } } + elseif ($gb -gt 20) { @{ Status = 'Warn'; Detail = "Search index is $gb GB (bloated)" } } + else { @{ Status = 'OK'; Detail = "Search running; index $gb GB" } } + } ` + -Fix { + Write-RepLog 'Rebuilding the Windows Search index...' 'Info' + Stop-Service WSearch -Force -ErrorAction Stop + Set-ItemProperty -LiteralPath 'HKLM:\SOFTWARE\Microsoft\Windows Search' -Name SetupCompletedSuccessfully -Value 0 -Type DWord -ErrorAction Stop + Start-Service WSearch -ErrorAction Stop + } -FixRisk Moderate -FixLabel 'Rebuild the search index' + + # ================= Security ================= + New-DiagnosticCheck -Id def-health -Name 'Microsoft Defender health' -Category Security ` + -Scan { + $m = Get-RepDefenderMode + if (-not $m.Present) { return @{ Status = 'Skip'; Detail = 'Defender module unavailable (3rd-party AV?)' } } + if ($m.Passive) { + if ($m.ThirdParty) { return @{ Status = 'OK'; Detail = "Third-party AV active ($($m.ThirdParty -join ', ')); Defender mode: $($m.Mode)" } } + return @{ Status = 'Warn'; NoFix = $true; Detail = "Defender not in active mode ($($m.Mode)) and no other antivirus is registered" } + } + $st = $m.Status $issues = @() if (-not $st.RealTimeProtectionEnabled) { $issues += 'real-time protection OFF' } if ($st.AntivirusSignatureAge -gt 7) { $issues += "signatures $($st.AntivirusSignatureAge)d old" } @@ -300,131 +955,249 @@ function Get-DiagnosticCheckRegistry { Update-MpSignature -ErrorAction SilentlyContinue } -FixRisk Safe -FixLabel 'Enable real-time protection + update signatures' - # ---------------- System ---------------- - New-DiagnosticCheck wmi-repo 'WMI repository consistency' System ` + New-DiagnosticCheck -Id def-signatures -Name 'Defender signatures & threats' -Category Security ` -Scan { - $out = & winmgmt.exe /verifyrepository 2>&1 - if ($LASTEXITCODE -eq 0) { @{ Status = 'OK'; Detail = 'WMI repository is consistent' } } - else { @{ Status = 'Fail'; Detail = 'WMI repository inconsistent' } } + $m = Get-RepDefenderMode + if (-not $m.Present) { return @{ Status = 'Skip'; Detail = 'Defender module unavailable' } } + if ($m.Passive) { return @{ Status = 'OK'; Detail = "Defender passive (mode: $($m.Mode)) - third-party AV handles threats" } } + # Latest detection per threat; 1=Detected 102=QuarantineFailed 103=RemoveFailed 107=BlockFailed. + $latest = Get-MpThreatDetection -ErrorAction SilentlyContinue | Group-Object ThreatID | + ForEach-Object { $_.Group | Sort-Object LastThreatStatusChangeTime, InitialDetectionTime | Select-Object -Last 1 } + $active = @($latest | Where-Object { $_.ThreatStatusID -in 1, 102, 103, 107 }) + if ($active.Count) { @{ Status = 'Fail'; NoFix = $true; Detail = "$($active.Count) active/unremediated threat(s) - open Windows Security > Protection history" } } + elseif ($m.Status.DefenderSignaturesOutOfDate) { @{ Status = 'Warn'; Detail = 'Defender signatures are out of date' } } + else { @{ Status = 'OK'; Detail = 'Defender signatures current; no active threats' } } } ` - -Fix { Write-RepLog 'Salvaging WMI repository...' 'Info'; & winmgmt.exe /salvagerepository 2>&1 | Out-Null } ` - -FixRisk Moderate -FixLabel 'Salvage WMI repository' + -Fix { Write-RepLog 'Updating Defender signatures...' 'Info'; Update-MpSignature -ErrorAction Stop } ` + -FixRisk Safe -FixLabel 'Update Defender signatures' + + New-DiagnosticCheck -Id def-exclusions -Name 'Dangerous Defender exclusions' -Category Security ` + -Scan { + $m = Get-RepDefenderMode + if (-not $m.Present) { return @{ Status = 'Skip'; Detail = 'Defender module unavailable' } } + if ($m.Passive) { return @{ Status = 'OK'; Detail = "Defender passive (mode: $($m.Mode)) - exclusions not in effect" } } + $p = Get-MpPreference -ErrorAction SilentlyContinue + $real = { param($v) @($v | Where-Object { $_ -and "$_" -notmatch '^N/A' }) } + $hits = @() + foreach ($x in (& $real $p.ExclusionPath)) { + $e = [Environment]::ExpandEnvironmentVariables("$x").TrimEnd('\') + if ($e -match '^[A-Za-z]:$' -or $e -match '(?i)^[A-Z]:\\Users$' -or $e -match '(?i)\\AppData\\Local\\Temp$' -or + $e -match '(?i)\\Windows\\Temp$' -or $e -ieq $env:TEMP.TrimEnd('\') -or $e -ieq $env:WINDIR.TrimEnd('\') -or + $e -match '(?i)\\AppData$' -or $e -match '(?i)\\AppData\\(Local|Roaming)$' -or $e -match '(?i)\\Downloads$') { $hits += "path $x" } + } + foreach ($x in (& $real $p.ExclusionExtension)) { + if ("$x".TrimStart('*').TrimStart('.') -in 'exe', 'dll', 'ps1', 'bat', 'cmd', 'vbs', 'js', 'scr', 'msi', 'hta') { $hits += "extension $x" } + } + foreach ($x in (& $real $p.ExclusionProcess)) { + if ((Split-Path "$x" -Leaf) -in 'powershell.exe', 'pwsh.exe', 'cmd.exe', 'wscript.exe', 'cscript.exe', 'mshta.exe', 'rundll32.exe', 'regsvr32.exe') { $hits += "process $x" } + } + if ($hits) { @{ Status = 'Warn'; Detail = ('Risky exclusion(s) (common malware persistence): ' + ($hits -join '; ')) } } + else { @{ Status = 'OK'; Detail = 'No broad or high-risk Defender exclusions' } } + } -Fix $null - New-DiagnosticCheck time-sync 'System time synchronization' System ` + New-DiagnosticCheck -Id firewall-state -Name 'Windows Firewall enabled' -Category Security ` -Scan { - $w = Get-Service w32time -ErrorAction SilentlyContinue - if (-not $w) { return @{ Status = 'Skip'; Detail = 'w32time service not found' } } - if ($w.Status -ne 'Running') { return @{ Status = 'Warn'; Detail = 'Time service (w32time) is stopped' } } - @{ Status = 'OK'; Detail = 'Time service running' } + if (-not (Get-Command Get-NetFirewallProfile -ErrorAction SilentlyContinue)) { return @{ Status = 'Skip'; Detail = 'Firewall module unavailable' } } + $off = @(Get-NetFirewallProfile -ErrorAction SilentlyContinue | Where-Object { -not $_.Enabled }) + if ($off.Count -ge 3) { @{ Status = 'Fail'; Detail = 'All firewall profiles are OFF' } } + elseif ($off.Count) { @{ Status = 'Warn'; Detail = ('Firewall off for: ' + (($off.Name) -join ', ')) } } + else { @{ Status = 'OK'; Detail = 'All firewall profiles enabled' } } } ` - -Fix { Start-Service w32time -ErrorAction SilentlyContinue; & w32tm.exe /resync /force *>$null } ` - -FixRisk Safe -FixLabel 'Start w32time + resync clock' + -Fix { Set-NetFirewallProfile -All -Enabled True -ErrorAction Stop } ` + -FixRisk Moderate -FixLabel 'Re-enable all firewall profiles' - New-DiagnosticCheck event-errors 'Recent critical/error events' System ` + New-DiagnosticCheck -Id smb1-disabled -Name 'SMBv1 protocol disabled' -Category Security ` -Scan { - $ev = Get-WinEvent -FilterHashtable @{ LogName = 'System'; Level = 1,2; StartTime = (Get-Date).AddDays(-2) } -MaxEvents 300 -ErrorAction SilentlyContinue - $ev = @($ev) - if ($ev.Count -eq 0) { return @{ Status = 'OK'; Detail = 'No critical/error events in the last 48h' } } - $top = ($ev | Group-Object ProviderName | Sort-Object Count -Descending | Select-Object -First 3 | - ForEach-Object { "$($_.Name)=$($_.Count)" }) -join ', ' - $status = if ($ev.Count -gt 50) { 'Warn' } else { 'OK' } - @{ Status = $status; Detail = "$($ev.Count) error/critical event(s) in 48h; top: $top" } - } -Fix $null + $srv = (Get-SmbServerConfiguration -ErrorAction SilentlyContinue).EnableSMB1Protocol + if ($null -eq $srv) { return @{ Status = 'Skip'; Detail = 'SMB module unavailable' } } + if ($srv) { @{ Status = 'Warn'; Detail = 'SMBv1 is ENABLED (EternalBlue/WannaCry vector)' } } + else { @{ Status = 'OK'; Detail = 'SMBv1 disabled' } } + } ` + -Fix { + Set-SmbServerConfiguration -EnableSMB1Protocol $false -Force -ErrorAction Stop + if (Get-Command Disable-WindowsOptionalFeature -ErrorAction SilentlyContinue) { + Disable-WindowsOptionalFeature -Online -FeatureName SMB1Protocol -NoRestart -ErrorAction SilentlyContinue | Out-Null + } + } -FixRisk Moderate -FixLabel 'Disable SMBv1 (very old NAS/printers may lose shares)' -Reboot $true - # ---------------- System / Security additions ---------------- - New-DiagnosticCheck restore-enabled 'System Restore protection' System ` + New-DiagnosticCheck -Id uac-enabled -Name 'User Account Control enabled' -Category Security ` -Scan { - $rp = Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\SystemRestore' -ErrorAction SilentlyContinue - $pts = 0 - try { $pts = @(Get-CimInstance -Namespace root/default -ClassName SystemRestore -ErrorAction SilentlyContinue).Count } catch { $pts = 0 } - if ($rp.DisableSR -eq 1) { @{ Status = 'Warn'; Detail = 'System Restore is disabled (no rollback safety net)' } } - elseif ($pts -eq 0) { @{ Status = 'Warn'; Detail = 'System Restore on but no restore points exist' } } - else { @{ Status = 'OK'; Detail = "System Restore on; $pts restore point(s)" } } + $lua = Get-RepRegValue 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System' 'EnableLUA' + if ($lua -eq 0) { @{ Status = 'Fail'; Detail = 'UAC is OFF (EnableLUA=0) - breaks Store/UWP apps and weakens security' } } + else { @{ Status = 'OK'; Detail = 'UAC enabled' } } } ` -Fix { - if (Get-Command Enable-ComputerRestore -ErrorAction SilentlyContinue) { - Enable-ComputerRestore -Drive "$env:SystemDrive\" -ErrorAction SilentlyContinue - $rk = 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\SystemRestore' - New-ItemProperty -Path $rk -Name 'SystemRestorePointCreationFrequency' -Value 0 -PropertyType DWord -Force -ErrorAction SilentlyContinue | Out-Null - Checkpoint-Computer -Description 'WinSenior baseline' -RestorePointType 'MODIFY_SETTINGS' -ErrorAction SilentlyContinue - } else { Write-RepLog 'Enable-ComputerRestore unavailable (PowerShell 7?) - enable System Protection manually' 'Warning' } - } -FixRisk Safe -FixLabel 'Enable System Restore + create a checkpoint' + Set-ItemProperty -LiteralPath 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System' -Name EnableLUA -Value 1 -Type DWord -ErrorAction Stop + } -FixRisk Moderate -FixLabel 'Turn UAC back on (EnableLUA=1)' -Reboot $true - New-DiagnosticCheck hosts-integrity 'Hosts file integrity' Network ` + New-DiagnosticCheck -Id bitlocker -Name 'BitLocker protection (system drive)' -Category Security ` -Scan { - $hosts = "$env:WINDIR\System32\drivers\etc\hosts" - if (-not (Test-Path $hosts)) { return @{ Status = 'OK'; Detail = 'No hosts file (default)' } } - $lines = Get-Content $hosts -ErrorAction SilentlyContinue | Where-Object { $_ -and ($_ -notmatch '^\s*#') -and ($_ -match '\S') } - $active = @($lines | Where-Object { $_ -notmatch '^\s*(127\.0\.0\.1|::1)\s+localhost\s*$' }) - $susp = @($active | Where-Object { $_ -match '(?i)(microsoft|windowsupdate|defender|msftncsi|office|sophos|mcafee|avast|kaspersky)' }) - if ($susp.Count) { @{ Status = 'Fail'; Detail = "$($susp.Count) hosts entry(ies) redirect Microsoft/AV/update domains - possible hijack" } } - elseif ($active.Count) { @{ Status = 'Warn'; Detail = "$($active.Count) custom hosts entry(ies) present" } } - else { @{ Status = 'OK'; Detail = 'Hosts file has no active redirects' } } + $ns = 'root/cimv2/Security/MicrosoftVolumeEncryption' + try { $vol = Get-CimInstance -Namespace $ns -ClassName Win32_EncryptableVolume -Filter "DriveLetter='$env:SystemDrive'" -ErrorAction Stop } + catch { + $code = "$($_.Exception.NativeErrorCode)" + if ($code -in 'InvalidNamespace', 'InvalidClass', 'NotFound') { return @{ Status = 'OK'; Detail = 'BitLocker not available on this edition (not applicable)' } } + if ($code -eq 'AccessDenied') { return @{ Status = 'Skip'; Detail = 'BitLocker status needs administrator rights' } } + return @{ Status = 'Skip'; Detail = "BitLocker status unavailable ($code)" } + } + if (-not $vol) { return @{ Status = 'OK'; Detail = 'System drive not encryptable (not applicable)' } } + if ($vol.ConversionStatus -eq 0) { return @{ Status = 'OK'; Detail = 'System drive not encrypted' } } + $issues = @(); $suspended = $false + if ($vol.ConversionStatus -eq 1 -and $vol.ProtectionStatus -eq 0) { $issues += 'BitLocker protection is SUSPENDED'; $suspended = $true } + $kp = Invoke-CimMethod -InputObject $vol -MethodName GetKeyProtectors -Arguments @{ KeyProtectorType = [uint32]3 } -ErrorAction SilentlyContinue + if ($kp -and $kp.ReturnValue -eq 0 -and -not @($kp.VolumeKeyProtectorID).Count) { $issues += 'no recovery password protector (back one up!)' } + if ($issues) { @{ Status = 'Warn'; NoFix = (-not $suspended); Detail = ($issues -join '; ') } } + else { @{ Status = 'OK'; Detail = "Encrypted (conversion $($vol.ConversionStatus)); protection on; recovery password present" } } } ` -Fix { - $hosts = "$env:WINDIR\System32\drivers\etc\hosts" - $bak = "$hosts.winsenior_$(Get-Date -Format 'yyyyMMddHHmmss').bak" - Copy-Item $hosts $bak -Force -ErrorAction SilentlyContinue - Write-RepLog "Backed up hosts to $bak; writing default header" 'Info' - Set-Content -Path $hosts -Value '# Copyright (c) 1993-2009 Microsoft Corp.' -Encoding ASCII -ErrorAction SilentlyContinue - & ipconfig.exe /flushdns | Out-Null - } -FixRisk Moderate -FixLabel 'Back up & reset hosts to default (then flush DNS)' + $vol = Get-CimInstance -Namespace root/cimv2/Security/MicrosoftVolumeEncryption -ClassName Win32_EncryptableVolume -Filter "DriveLetter='$env:SystemDrive'" -ErrorAction Stop + if ($vol.ProtectionStatus -eq 0) { + # Same as Resume-BitLocker, but works under pwsh 7 too. + $r = Invoke-CimMethod -InputObject $vol -MethodName EnableKeyProtectors -ErrorAction Stop + if ($r.ReturnValue -ne 0) { throw ("EnableKeyProtectors returned 0x{0:X8}" -f [uint32]$r.ReturnValue) } + } + } -FixRisk Safe -FixLabel 'Resume BitLocker protection' - New-DiagnosticCheck proxy-hijack 'Proxy / PAC hijack' Network ` + New-DiagnosticCheck -Id secureboot-cert -Name 'Secure Boot & 2023 certificate update' -Category Security ` -Scan { - $is = 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Internet Settings' - $p = Get-ItemProperty $is -ErrorAction SilentlyContinue - if ($p.AutoConfigURL) { @{ Status = 'Fail'; Detail = "AutoConfigURL (PAC) set: $($p.AutoConfigURL)" } } - elseif ($p.ProxyEnable -eq 1 -and $p.ProxyServer) { @{ Status = 'Warn'; Detail = "Proxy enabled: $($p.ProxyServer)" } } - else { @{ Status = 'OK'; Detail = 'No proxy / PAC configured' } } + if ($env:firmware_type -eq 'Legacy') { return @{ Status = 'OK'; Detail = 'Legacy BIOS - Secure Boot not applicable' } } + $sbReg = Get-RepRegValue 'HKLM:\SYSTEM\CurrentControlSet\Control\SecureBoot\State' 'UEFISecureBootEnabled' + if ($null -ne $sbReg) { $sb = ($sbReg -eq 1) } + else { + try { $sb = [bool](Confirm-SecureBootUEFI -ErrorAction Stop) } + catch { return @{ Status = 'OK'; Detail = 'Secure Boot not supported on this platform (not applicable)' } } + } + $svc = 'HKLM:\SYSTEM\CurrentControlSet\Control\SecureBoot\Servicing' + $st = Get-RepRegValue $svc 'UEFICA2023Status' + $err = Get-RepRegValue $svc 'UEFICA2023Error' + $issues = @(); $status = 'OK' + if (-not $sb) { $issues += 'Secure Boot is OFF'; $status = 'Warn' } + if ($err) { $issues += ("2023 CA update failed (UEFICA2023Error 0x{0:X8}) - update BIOS/UEFI firmware" -f $err); $status = 'Fail' } + elseif ("$st" -ne 'Updated') { + $issues += "2023 Secure Boot CA not applied (status: $(if ($st) { $st } else { 'unknown' })) - the 2011 CAs expired June 2026; install the latest cumulative update / OEM firmware" + if ($status -ne 'Fail') { $status = 'Warn' } + } + if ($issues) { @{ Status = $status; Detail = ($issues -join '; ') } } + else { @{ Status = 'OK'; Detail = 'Secure Boot on; 2023 CA certificates applied' } } + } -Fix $null + + New-DiagnosticCheck -Id rootcert-update -Name 'Root certificate auto-update' -Category Security ` + -Scan { + if ((Get-RepRegValue 'HKLM:\SOFTWARE\Policies\Microsoft\SystemCertificates\AuthRoot' 'DisableRootAutoUpdate') -eq 1) { + @{ Status = 'Warn'; Detail = 'Root certificate auto-update disabled by policy (TLS / Store / update errors)' } + } else { @{ Status = 'OK'; Detail = 'Root certificates update automatically' } } } ` -Fix { - $is = 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Internet Settings' - Set-ItemProperty $is -Name ProxyEnable -Value 0 -ErrorAction SilentlyContinue - Remove-ItemProperty $is -Name ProxyServer -ErrorAction SilentlyContinue - Remove-ItemProperty $is -Name AutoConfigURL -ErrorAction SilentlyContinue - & netsh.exe winhttp reset proxy *>$null - } -FixRisk Moderate -FixLabel 'Reset WinINET/WinHTTP proxy settings' + Remove-ItemProperty -LiteralPath 'HKLM:\SOFTWARE\Policies\Microsoft\SystemCertificates\AuthRoot' -Name DisableRootAutoUpdate -ErrorAction Stop + } -FixRisk Safe -FixLabel 'Re-enable root certificate auto-update' - New-DiagnosticCheck firewall-state 'Windows Firewall enabled' Security ` + # ================= System ================= + New-DiagnosticCheck -Id wmi-repo -Name 'WMI repository consistency' -Category System ` -Scan { - if (-not (Get-Command Get-NetFirewallProfile -ErrorAction SilentlyContinue)) { return @{ Status = 'Skip'; Detail = 'Firewall module unavailable' } } - $off = @(Get-NetFirewallProfile -ErrorAction SilentlyContinue | Where-Object { -not $_.Enabled }) - if ($off.Count -ge 3) { @{ Status = 'Fail'; Detail = 'All firewall profiles are OFF' } } - elseif ($off.Count) { @{ Status = 'Warn'; Detail = ('Firewall off for: ' + (($off.Name) -join ', ')) } } - else { @{ Status = 'OK'; Detail = 'All firewall profiles enabled' } } + & winmgmt.exe /verifyrepository *> $null + if ($LASTEXITCODE -eq 0) { @{ Status = 'OK'; Detail = 'WMI repository is consistent' } } + else { @{ Status = 'Fail'; Detail = "WMI repository inconsistent (exit $LASTEXITCODE)" } } } ` - -Fix { Set-NetFirewallProfile -All -Enabled True -ErrorAction SilentlyContinue } ` - -FixRisk Moderate -FixLabel 'Re-enable all firewall profiles' + -Fix { + Write-RepLog 'Salvaging WMI repository...' 'Info' + & winmgmt.exe /salvagerepository *> $null + if ($LASTEXITCODE -ne 0) { throw "winmgmt /salvagerepository failed (exit $LASTEXITCODE)" } + } -FixRisk Moderate -FixLabel 'Salvage WMI repository' - New-DiagnosticCheck def-signatures 'Defender signatures & threats' Security ` + New-DiagnosticCheck -Id time-sync -Name 'System time synchronization' -Category System ` -Scan { - if (-not (Get-Command Get-MpComputerStatus -ErrorAction SilentlyContinue)) { return @{ Status = 'Skip'; Detail = 'Defender module unavailable' } } - $s = Get-MpComputerStatus -ErrorAction SilentlyContinue - if (-not $s) { return @{ Status = 'Skip'; Detail = 'Defender status unavailable' } } - $active = @(Get-MpThreat -ErrorAction SilentlyContinue | Where-Object { $_.ThreatStatusID -in 1, 102, 103, 107 }) - if ($active.Count) { @{ Status = 'Fail'; Detail = "$($active.Count) active/unremediated threat(s)" } } - elseif ($s.DefenderSignaturesOutOfDate) { @{ Status = 'Warn'; Detail = 'Defender signatures are out of date' } } - else { @{ Status = 'OK'; Detail = 'Defender signatures current; no active threats' } } + $w = Get-Service W32Time -ErrorAction SilentlyContinue + if (-not $w) { return @{ Status = 'Skip'; Detail = 'W32Time service not found' } } + # Manual + stopped is normal on non-domain PCs (trigger-started), so judge by config + real offset. + $issues = @(); $status = 'OK' + if ("$($w.StartType)" -eq 'Disabled') { $issues += 'time service disabled'; $status = 'Warn' } + if ((Get-RepRegValue 'HKLM:\SYSTEM\CurrentControlSet\Services\W32Time\Parameters' 'Type') -eq 'NoSync') { $issues += 'time sync turned off (Type=NoSync)'; $status = 'Warn' } + $probe = Get-RepNcsiProbe + if ($probe.ServerTime -and $probe.LocalTime) { + $off = ($probe.LocalTime - $probe.ServerTime).TotalSeconds + $abs = [math]::Abs($off) + $txt = 'clock offset {0:+0;-0}s vs internet time' -f $off + if ($abs -gt 600) { $status = 'Fail'; $issues += $txt } + elseif ($abs -gt 120) { if ($status -ne 'Fail') { $status = 'Warn' }; $issues += $txt } + elseif (-not $issues) { return @{ Status = 'OK'; Detail = "Clock in sync ($txt)" } } + } + elseif (-not $issues) { return @{ Status = 'OK'; Detail = 'Time sync configured; offset unknown (offline)' } } + @{ Status = $status; Detail = ($issues -join '; ') } } ` - -Fix { Write-RepLog 'Updating Defender signatures...' 'Info'; if (Get-Command Update-MpSignature -ErrorAction SilentlyContinue) { Update-MpSignature -ErrorAction SilentlyContinue } } ` - -FixRisk Safe -FixLabel 'Update Defender signatures' + -Fix { + $w = Get-Service W32Time -ErrorAction Stop + if ("$($w.StartType)" -eq 'Disabled') { Restore-RepServiceStart -Name W32Time -StartType Manual | Out-Null } + $pk = 'HKLM:\SYSTEM\CurrentControlSet\Services\W32Time\Parameters' + if ((Get-RepRegValue $pk 'Type') -eq 'NoSync') { + Set-ItemProperty -LiteralPath $pk -Name Type -Value $(if (Test-RepPartOfDomain) { 'NT5DS' } else { 'NTP' }) -ErrorAction Stop + } + if ("$((Get-Service W32Time).Status)" -ne 'Running') { Start-Service W32Time -ErrorAction Stop } + & w32tm.exe /config /update *> $null + & w32tm.exe /resync /rediscover *> $null + $rc = $LASTEXITCODE + $script:RepNcsiCache = $null + if ($rc -ne 0) { throw "w32tm /resync failed (exit $rc) - NTP (UDP 123) blocked?" } + } -FixRisk Safe -FixLabel 'Enable time sync + resync clock' + + New-DiagnosticCheck -Id event-errors -Name 'Recent critical/error events' -Category System ` + -Scan { + $ev = @(Get-WinEvent -FilterHashtable @{ LogName = 'System'; Level = 1,2; StartTime = (Get-Date).AddDays(-2) } -MaxEvents 300 -ErrorAction SilentlyContinue | + Where-Object { -not ($_.ProviderName -match 'DistributedCOM' -and $_.Id -in 10016, 10010) }) # known-benign DCOM noise + if ($ev.Count -eq 0) { return @{ Status = 'OK'; Detail = 'No critical/error events in the last 48h' } } + $top = ($ev | Group-Object ProviderName | Sort-Object Count -Descending | Select-Object -First 3 | + ForEach-Object { "$($_.Name)=$($_.Count)" }) -join ', ' + $status = if ($ev.Count -gt 50) { 'Warn' } else { 'OK' } + @{ Status = $status; Detail = "$($ev.Count) error/critical event(s) in 48h; top: $top" } + } -Fix $null - New-DiagnosticCheck smb1-disabled 'SMBv1 protocol disabled' Security ` + New-DiagnosticCheck -Id crash-history -Name 'Recent crashes (BSOD / unexpected shutdown)' -Category System ` -Scan { - $srv = (Get-SmbServerConfiguration -ErrorAction SilentlyContinue).EnableSMB1Protocol - if ($null -eq $srv) { return @{ Status = 'Skip'; Detail = 'SMB module unavailable' } } - if ($srv) { @{ Status = 'Warn'; Detail = 'SMBv1 is ENABLED (EternalBlue/WannaCry vector)' } } - else { @{ Status = 'OK'; Detail = 'SMBv1 disabled' } } + $since = (Get-Date).AddDays(-30) + $marks = @() + $marks += @(Get-ChildItem "$env:WINDIR\Minidump\*.dmp" -ErrorAction SilentlyContinue | Where-Object { $_.LastWriteTime -gt $since } | + ForEach-Object { [pscustomobject]@{ T = $_.LastWriteTime; Bsod = $true } }) + $marks += @(Get-WinEvent -FilterHashtable @{ LogName = 'System'; ProviderName = 'Microsoft-Windows-WER-SystemErrorReporting'; StartTime = $since } -MaxEvents 100 -ErrorAction SilentlyContinue | + ForEach-Object { [pscustomobject]@{ T = $_.TimeCreated; Bsod = $true } }) + $marks += @(Get-WinEvent -FilterHashtable @{ LogName = 'System'; ProviderName = 'Microsoft-Windows-Kernel-Power'; Id = 41; StartTime = $since } -MaxEvents 100 -ErrorAction SilentlyContinue | + ForEach-Object { [pscustomobject]@{ T = $_.TimeCreated; Bsod = $false } }) + # One crash leaves a minidump + WER event + Kernel-Power 41 at next boot: artifacts within 10 min = 1 crash. + $crashes = @(); $cur = $null + foreach ($x in ($marks | Sort-Object T)) { + if ($cur -and ($x.T - $cur.Last).TotalMinutes -le 10) { $cur.Last = $x.T; if ($x.Bsod) { $cur.Bsod = $true } } + else { $cur = [pscustomobject]@{ Last = $x.T; Bsod = $x.Bsod }; $crashes += $cur } + } + $n = $crashes.Count + $b = @($crashes | Where-Object Bsod).Count + $txt = "$n crash(es) in 30 days ($b BSOD, $($n - $b) unexpected power-off/reset)" + if ($n -eq 0) { @{ Status = 'OK'; Detail = 'No crashes or unexpected shutdowns in 30 days' } } + elseif ($n -ge 2) { @{ Status = 'Warn'; Detail = "$txt - recurring instability" } } + else { @{ Status = 'OK'; Detail = "$txt (isolated)" } } + } -Fix $null + + New-DiagnosticCheck -Id restore-enabled -Name 'System Restore protection' -Category System ` + -Scan { + $rp = Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\SystemRestore' -ErrorAction SilentlyContinue + $pts = 0 + try { $pts = @(Get-CimInstance -Namespace root/default -ClassName SystemRestore -ErrorAction SilentlyContinue).Count } catch { $pts = 0 } + if ($rp.DisableSR -eq 1) { @{ Status = 'Warn'; Detail = 'System Restore is disabled (no rollback safety net)' } } + elseif ($pts -eq 0) { @{ Status = 'Warn'; Detail = 'System Restore on but no restore points exist' } } + else { @{ Status = 'OK'; Detail = "System Restore on; $pts restore point(s)" } } } ` -Fix { - Set-SmbServerConfiguration -EnableSMB1Protocol $false -Force -ErrorAction SilentlyContinue - if (Get-Command Disable-WindowsOptionalFeature -ErrorAction SilentlyContinue) { - Disable-WindowsOptionalFeature -Online -FeatureName SMB1Protocol -NoRestart -ErrorAction SilentlyContinue | Out-Null + # WMI path works under both Windows PowerShell 5.1 and pwsh 7. + Enable-WsSystemRestore -Drive "$env:SystemDrive\" + $rk = 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\SystemRestore' + $prev = Get-RepRegValue $rk 'SystemRestorePointCreationFrequency' + # Lift the 24h throttle only for this checkpoint, then put it back. + New-ItemProperty -Path $rk -Name 'SystemRestorePointCreationFrequency' -Value 0 -PropertyType DWord -Force -ErrorAction SilentlyContinue | Out-Null + try { Invoke-WsCheckpoint -Description 'WinSenior baseline' } + finally { + if ($null -eq $prev) { Remove-ItemProperty -LiteralPath $rk -Name 'SystemRestorePointCreationFrequency' -ErrorAction SilentlyContinue } + else { Set-ItemProperty -LiteralPath $rk -Name 'SystemRestorePointCreationFrequency' -Value $prev -Type DWord -ErrorAction SilentlyContinue } } - } -FixRisk Safe -FixLabel 'Disable SMBv1' -Reboot $true + } -FixRisk Safe -FixLabel 'Enable System Restore + create a checkpoint' - New-DiagnosticCheck sched-task-health 'Critical scheduled tasks enabled' System ` + New-DiagnosticCheck -Id sched-task-health -Name 'Critical scheduled tasks enabled' -Category System ` -Scan { if (-not (Get-Command Get-ScheduledTask -ErrorAction SilentlyContinue)) { return @{ Status = 'Skip'; Detail = 'ScheduledTasks module unavailable' } } $want = @( @@ -434,7 +1207,7 @@ function Get-DiagnosticCheckRegistry { @{ P = '\Microsoft\Windows\Windows Defender\'; N = 'Windows Defender Scheduled Scan' }) $disabled = foreach ($t in $want) { $st = Get-ScheduledTask -TaskPath $t.P -TaskName $t.N -ErrorAction SilentlyContinue - if ($st -and $st.State -eq 'Disabled') { $t.N } + if ($st -and "$($st.State)" -eq 'Disabled') { $t.N } } $disabled = @($disabled) if ($disabled.Count) { @{ Status = 'Warn'; Detail = ('Critical task(s) disabled: ' + ($disabled -join ', ')) } } @@ -446,89 +1219,226 @@ function Get-DiagnosticCheckRegistry { @{ P = '\Microsoft\Windows\UpdateOrchestrator\'; N = 'Schedule Scan' }, @{ P = '\Microsoft\Windows\SystemRestore\'; N = 'SR' }, @{ P = '\Microsoft\Windows\Windows Defender\'; N = 'Windows Defender Scheduled Scan' }) + $failed = @() foreach ($t in $want) { $st = Get-ScheduledTask -TaskPath $t.P -TaskName $t.N -ErrorAction SilentlyContinue - if ($st -and $st.State -eq 'Disabled') { Enable-ScheduledTask -TaskPath $t.P -TaskName $t.N -ErrorAction SilentlyContinue | Out-Null } + if ($st -and "$($st.State)" -eq 'Disabled') { + try { Enable-ScheduledTask -TaskPath $t.P -TaskName $t.N -ErrorAction Stop | Out-Null; Write-RepLog "enabled $($t.P)$($t.N)" 'Debug' } + catch { $failed += "$($t.N): $($_.Exception.Message)" } + } } + if ($failed) { throw ('Could not enable: ' + ($failed -join '; ')) } } -FixRisk Moderate -FixLabel 'Re-enable critical scheduled tasks (curated list)' - New-DiagnosticCheck bits-health 'BITS transfer queue' Update ` + New-DiagnosticCheck -Id store-health -Name 'Microsoft Store health' -Category System ` -Scan { - if (-not (Get-Command Get-BitsTransfer -ErrorAction SilentlyContinue)) { return @{ Status = 'Skip'; Detail = 'BITS module unavailable' } } - $jobs = @(Get-BitsTransfer -AllUsers -ErrorAction SilentlyContinue) - $err = @($jobs | Where-Object { $_.JobState -in 'Error', 'TransientError' }) - if ($err.Count) { @{ Status = 'Warn'; Detail = "$($err.Count) BITS job(s) in error state" } } - elseif ($jobs.Count -gt 50) { @{ Status = 'Warn'; Detail = "$($jobs.Count) BITS jobs queued (backlog)" } } - else { @{ Status = 'OK'; Detail = "$($jobs.Count) BITS job(s); none in error" } } + try { $store = @(Get-RepAppxState -Name 'Microsoft.WindowsStore') | Select-Object -First 1 } + catch { return @{ Status = 'Skip'; Detail = "Appx query failed: $($_.Exception.Message)" } } + if (-not $store) { return @{ Status = 'Warn'; Detail = 'Microsoft Store package not found for this user' } } + if ($store.Status -and $store.Status -ne 'Ok') { @{ Status = 'Warn'; Detail = "Store package status: $($store.Status)" } } + else { @{ Status = 'OK'; Detail = 'Store package present' } } } ` - -Fix { Get-BitsTransfer -AllUsers -ErrorAction SilentlyContinue | Remove-BitsTransfer -ErrorAction SilentlyContinue } ` - -FixRisk Moderate -FixLabel 'Clear stuck BITS transfers' + -Fix { Write-RepLog 'Resetting Microsoft Store cache (wsreset)...' 'Info'; & wsreset.exe *> $null } ` + -FixRisk Safe -FixLabel 'Reset Store cache (wsreset)' - New-DiagnosticCheck spooler-health 'Print spooler' Services ` + New-DiagnosticCheck -Id shell-appx -Name 'Start menu / shell packages' -Category System ` -Scan { - $sp = Get-Service Spooler -ErrorAction SilentlyContinue - if (-not $sp) { return @{ Status = 'Skip'; Detail = 'Spooler service not found' } } - $printers = @(Get-Printer -ErrorAction SilentlyContinue) - if ($printers.Count -eq 0) { return @{ Status = 'OK'; Detail = 'No printers installed' } } - $queue = @(Get-ChildItem "$env:WINDIR\System32\spool\PRINTERS" -ErrorAction SilentlyContinue) - if ($sp.StartType -ne 'Disabled' -and $sp.Status -ne 'Running') { @{ Status = 'Warn'; Detail = 'Spooler should run but is stopped' } } - elseif ($queue.Count -gt 0) { @{ Status = 'Warn'; Detail = "$($queue.Count) file(s) stuck in the print queue" } } - else { @{ Status = 'OK'; Detail = 'Spooler running; queue clear' } } + $sysApps = Join-Path $env:SystemRoot 'SystemApps' + $want = @('Microsoft.Windows.StartMenuExperienceHost', 'Microsoft.Windows.ShellExperienceHost') + if ([Environment]::OSVersion.Version.Build -ge 22000) { + foreach ($opt in 'MicrosoftWindows.Client.CBS', 'MicrosoftWindows.Client.Core') { + if (Get-ChildItem -LiteralPath $sysApps -Directory -Filter "$($opt)_*" -ErrorAction SilentlyContinue) { $want += $opt } + } + } + try { $pk = @(Get-RepAppxState -Name $want) } + catch { return @{ Status = 'Skip'; Detail = "Appx query failed: $($_.Exception.Message)" } } + $issues = @() + foreach ($n in $want) { + $p = $pk | Where-Object { $_.Name -eq $n } | Select-Object -First 1 + if (-not $p) { $issues += "$n not registered" } + elseif ($p.Status -and $p.Status -ne 'Ok') { $issues += "$n status $($p.Status)" } + } + $shellExe = 'explorer.exe', 'StartMenuExperienceHost.exe', 'SearchHost.exe', 'SearchApp.exe', 'ShellExperienceHost.exe' + $crash = @(Get-WinEvent -FilterHashtable @{ LogName = 'Application'; ProviderName = 'Application Error'; Id = 1000; StartTime = (Get-Date).AddDays(-7) } -MaxEvents 500 -ErrorAction SilentlyContinue | + Where-Object { $_.Properties.Count -and "$($_.Properties[0].Value)" -in $shellExe }) + if ($crash.Count -ge 3) { + $issues += ('shell crashes in 7 days: ' + (($crash | Group-Object { "$($_.Properties[0].Value)" } | ForEach-Object { "$($_.Name) x$($_.Count)" }) -join ', ')) + } + if ($issues) { @{ Status = 'Warn'; Detail = ($issues -join '; ') } } + else { @{ Status = 'OK'; Detail = "$($want.Count) shell package(s) registered; no repeated shell crashes" } } } ` -Fix { - Stop-Service Spooler -Force -ErrorAction SilentlyContinue - Get-ChildItem "$env:WINDIR\System32\spool\PRINTERS\*" -ErrorAction SilentlyContinue | Remove-Item -Force -ErrorAction SilentlyContinue - Start-Service Spooler -ErrorAction SilentlyContinue - } -FixRisk Safe -FixLabel 'Clear print queue + restart spooler' + $u = Test-RepInteractiveUser + if (-not $u.Ok) { throw "Shell packages must be re-registered from the signed-in user's session (console user: $($u.Console); running as: $($u.Me))" } + $rx = '^(Microsoft\.Windows\.StartMenuExperienceHost|Microsoft\.Windows\.ShellExperienceHost|MicrosoftWindows\.Client\.CBS|MicrosoftWindows\.Client\.Core)_' + $manifests = @(Get-ChildItem -LiteralPath (Join-Path $env:SystemRoot 'SystemApps') -Directory -ErrorAction SilentlyContinue | + Where-Object { $_.Name -match $rx } | ForEach-Object { Join-Path $_.FullName 'AppxManifest.xml' } | Where-Object { Test-Path -LiteralPath $_ }) + if (-not $manifests.Count) { throw 'No shell package manifests found under SystemApps' } + if ($PSVersionTable.PSEdition -eq 'Core') { + $list = ($manifests | ForEach-Object { "'$_'" }) -join ',' + $cmd = "`$ProgressPreference = 'SilentlyContinue'; foreach (`$m in @($list)) { Add-AppxPackage -Register -DisableDevelopmentMode `$m -ErrorAction Stop }" + $enc = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($cmd)) + & "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -NonInteractive -EncodedCommand $enc + if ($LASTEXITCODE -ne 0) { throw "Add-AppxPackage -Register failed (powershell.exe exit $LASTEXITCODE)" } + } else { + foreach ($m in $manifests) { Add-AppxPackage -Register -DisableDevelopmentMode $m -ErrorAction Stop } + } + Write-RepLog "Re-registered $($manifests.Count) shell package(s); restarting shell hosts" 'Info' + Stop-Process -Name StartMenuExperienceHost, ShellExperienceHost -Force -ErrorAction SilentlyContinue + } -FixRisk Moderate -FixLabel 'Re-register Start/shell packages (current user)' - New-DiagnosticCheck store-health 'Microsoft Store health' System ` + New-DiagnosticCheck -Id winre -Name 'Windows Recovery Environment' -Category System ` -Scan { - try { $store = Get-AppxPackage -Name Microsoft.WindowsStore -ErrorAction Stop | Select-Object -First 1 } - catch { return @{ Status = 'Skip'; Detail = 'Appx module unavailable in this PowerShell host' } } - if (-not $store) { return @{ Status = 'Warn'; Detail = 'Microsoft Store package not found for this user' } } - if ($store.Status -and $store.Status -ne 'Ok') { @{ Status = 'Warn'; Detail = "Store package status: $($store.Status)" } } - else { @{ Status = 'OK'; Detail = "Store $($store.Version) present" } } + $xmlPath = "$env:WINDIR\System32\Recovery\ReAgent.xml" + if (-not (Test-Path -LiteralPath $xmlPath)) { return @{ Status = 'Warn'; Detail = 'ReAgent.xml missing - WinRE is not configured' } } + try { $x = [xml](Get-Content -LiteralPath $xmlPath -Raw -ErrorAction Stop) } + catch { return @{ Status = 'Skip'; Detail = 'ReAgent.xml unreadable' } } + $issues = @(); $enabled = ("$($x.WindowsRE.InstallState.state)" -eq '1') + if (-not $enabled) { $issues += 'WinRE is disabled (no recovery / reset options at boot)' } + if (Get-Command Get-Partition -ErrorAction SilentlyContinue) { + $parts = @(Get-Partition -ErrorAction SilentlyContinue | Where-Object { "$($_.GptType)" -eq '{de94bba4-06d1-4d40-a16a-bfd50179d6ac}' -or $_.MbrType -eq 39 }) + $off = "$($x.WindowsRE.WinreLocation.offset)" + if ($off -and $off -ne '0') { $m = @($parts | Where-Object { "$($_.Offset)" -eq $off }); if ($m.Count) { $parts = $m } } + foreach ($p in $parts) { + $v = $p | Get-Volume -ErrorAction SilentlyContinue + if ($v -and $v.SizeRemaining -lt 250MB) { + $issues += ('recovery partition has only {0} MB free (<250 MB; WinRE updates fail with 0x80070643 - see KB5028997)' -f [math]::Round($v.SizeRemaining / 1MB)) + } + } + } + if ($issues) { @{ Status = 'Warn'; NoFix = $enabled; Detail = ($issues -join '; ') } } + else { @{ Status = 'OK'; Detail = 'WinRE enabled; recovery partition has room' } } } ` - -Fix { Write-RepLog 'Resetting Microsoft Store cache (wsreset)...' 'Info'; & wsreset.exe *>$null } ` - -FixRisk Safe -FixLabel 'Reset Store cache (wsreset)' + -Fix { + & reagentc.exe /enable *> $null + if ($LASTEXITCODE -ne 0) { throw "reagentc /enable failed (exit $LASTEXITCODE)" } + } -FixRisk Moderate -FixLabel 'Enable WinRE (reagentc /enable)' - New-DiagnosticCheck disk-reliability 'SSD wear & temperature' Disk ` + New-DiagnosticCheck -Id pagefile -Name 'Page file & commit memory' -Category System ` -Scan { - if (-not (Get-Command Get-PhysicalDisk -ErrorAction SilentlyContinue)) { return @{ Status = 'Skip'; Detail = 'Storage module unavailable' } } - $worst = 'OK'; $lines = @() - foreach ($pd in (Get-PhysicalDisk -ErrorAction SilentlyContinue)) { - $rc = $pd | Get-StorageReliabilityCounter -ErrorAction SilentlyContinue - if (-not $rc) { continue } - $parts = @() - if ($null -ne $rc.Wear) { $parts += "wear $($rc.Wear)%"; if ($rc.Wear -ge 90) { $worst = 'Fail' } elseif ($rc.Wear -ge 80 -and $worst -ne 'Fail') { $worst = 'Warn' } } - if ($null -ne $rc.Temperature) { $parts += "$($rc.Temperature) C"; if ($rc.Temperature -gt 70) { $worst = 'Fail' } elseif ($rc.Temperature -gt 60 -and $worst -ne 'Fail') { $worst = 'Warn' } } - if ($rc.ReadErrorsUncorrected) { $worst = 'Fail'; $parts += "$($rc.ReadErrorsUncorrected) uncorrected" } - if ($parts.Count) { $lines += ("$($pd.FriendlyName): " + ($parts -join ', ')) } + $cs = Get-CimInstance Win32_ComputerSystem -Property AutomaticManagedPagefile -ErrorAction Stop + $pfs = @(Get-CimInstance Win32_PageFileSetting -ErrorAction SilentlyContinue) + $os = Get-CimInstance Win32_OperatingSystem -Property FreeVirtualMemory, TotalVirtualMemorySize -ErrorAction Stop + $pct = if ($os.TotalVirtualMemorySize) { [math]::Round(100 * $os.FreeVirtualMemory / $os.TotalVirtualMemorySize, 1) } else { 100 } + $ev = @(Get-WinEvent -FilterHashtable @{ LogName = 'System'; ProviderName = 'Microsoft-Windows-Resource-Exhaustion-Detector'; Id = 2004; StartTime = (Get-Date).AddDays(-7) } -MaxEvents 50 -ErrorAction SilentlyContinue).Count + $auto = [bool]$cs.AutomaticManagedPagefile + $mode = if ($auto) { 'system-managed' } else { "custom ($($pfs.Count) file(s))" } + if (-not $auto -and $pfs.Count -eq 0) { return @{ Status = 'Fail'; Detail = 'No page file - apps crash with out-of-memory and no crash dumps are written' } } + $issues = @() + if ($pct -lt 10) { $issues += "only $pct% commit free" } + if ($ev) { $issues += "$ev low-memory event(s) in 7 days" } + if ($issues) { @{ Status = 'Warn'; NoFix = $auto; Detail = "Page file $mode; " + ($issues -join '; ') } } + else { @{ Status = 'OK'; Detail = "Page file $mode; $pct% commit free" } } + } ` + -Fix { + Get-CimInstance Win32_ComputerSystem -ErrorAction Stop | Set-CimInstance -Property @{ AutomaticManagedPagefile = $true } -ErrorAction Stop + } -FixRisk Moderate -FixLabel 'Let Windows manage the page file' -Reboot $true + + New-DiagnosticCheck -Id env-path -Name 'System PATH & TEMP variables' -Category System ` + -Scan { + $k = [Microsoft.Win32.Registry]::LocalMachine.OpenSubKey('SYSTEM\CurrentControlSet\Control\Session Manager\Environment') + if (-not $k) { return @{ Status = 'Skip'; Detail = 'Environment key unreadable' } } + try { + $raw = "$($k.GetValue('Path', $null, 'DoNotExpandEnvironmentNames'))" + $temps = @{ 'machine TEMP' = $k.GetValue('TEMP', $null, 'DoNotExpandEnvironmentNames'); 'machine TMP' = $k.GetValue('TMP', $null, 'DoNotExpandEnvironmentNames') } + } finally { $k.Close() } + $uk = [Microsoft.Win32.Registry]::CurrentUser.OpenSubKey('Environment') + if ($uk) { + try { $temps['user TEMP'] = $uk.GetValue('TEMP', $null, 'DoNotExpandEnvironmentNames'); $temps['user TMP'] = $uk.GetValue('TMP', $null, 'DoNotExpandEnvironmentNames') } + finally { $uk.Close() } } - if ($lines.Count -eq 0) { @{ Status = 'OK'; Detail = 'No reliability counters reported (HDD/USB/older SATA)' } } - else { @{ Status = $worst; Detail = ($lines -join ' | ') } } - } -Fix $null + $sys32 = (Join-Path $env:SystemRoot 'System32').TrimEnd('\') + $entries = @($raw -split ';' | Where-Object { $_.Trim() }) + $expanded = @($entries | ForEach-Object { [Environment]::ExpandEnvironmentVariables($_.Trim()).TrimEnd('\') }) + $fail = @(); $warn = @() + if (-not ($expanded | Where-Object { $_ -ieq $sys32 })) { $fail += 'System32 missing from machine PATH' } + foreach ($t in $temps.Keys) { + if ($temps[$t]) { + $p = [Environment]::ExpandEnvironmentVariables("$($temps[$t])") + if (-not (Test-Path -LiteralPath $p)) { $fail += "$t points to a missing folder ($p)" } + } + } + $dead = @($expanded | Where-Object { -not (Test-Path -LiteralPath $_) }).Count + if ($raw.Length -gt 2047) { $warn += "PATH is $($raw.Length) chars (>2047)" } + if ($dead -gt 5) { $warn += "$dead PATH entries point to missing folders" } + if ($fail) { @{ Status = 'Fail'; Detail = (@($fail) + @($warn)) -join '; ' } } + elseif ($warn) { @{ Status = 'Warn'; NoFix = $true; Detail = ($warn -join '; ') + ' (review manually; entries are never removed automatically)' } } + else { @{ Status = 'OK'; Detail = "PATH has $($entries.Count) entries ($dead dead); TEMP folders exist" } } + } ` + -Fix { + $keyPath = 'SYSTEM\CurrentControlSet\Control\Session Manager\Environment' + $k = [Microsoft.Win32.Registry]::LocalMachine.OpenSubKey($keyPath, $true) + if (-not $k) { throw 'Cannot open the machine Environment key for writing' } + try { + $raw = "$($k.GetValue('Path', $null, 'DoNotExpandEnvironmentNames'))" + $have = @($raw -split ';' | Where-Object { $_.Trim() } | ForEach-Object { [Environment]::ExpandEnvironmentVariables($_.Trim()).TrimEnd('\') }) + $defaults = '%SystemRoot%\system32', '%SystemRoot%', '%SystemRoot%\System32\Wbem', '%SYSTEMROOT%\System32\WindowsPowerShell\v1.0\' + $missing = @($defaults | Where-Object { $d = [Environment]::ExpandEnvironmentVariables($_).TrimEnd('\'); -not ($have | Where-Object { $_ -ieq $d }) }) + if ($missing.Count) { + $bak = Join-Path (Get-RepBackupDir) ("path-{0}.txt" -f (Get-Date -Format 'yyyyMMdd-HHmmss')) + Set-Content -LiteralPath $bak -Value $raw -Encoding UTF8 -ErrorAction Stop + $new = (($missing -join ';') + ';' + $raw.TrimStart(';')) + $k.SetValue('Path', $new, [Microsoft.Win32.RegistryValueKind]::ExpandString) + Write-RepLog "Prepended to machine PATH: $($missing -join ';') (backup: $bak)" 'Info' + } + foreach ($n in 'TEMP', 'TMP') { + $v = $k.GetValue($n, $null, 'DoNotExpandEnvironmentNames') + if ($v) { $p = [Environment]::ExpandEnvironmentVariables("$v"); if (-not (Test-Path -LiteralPath $p)) { New-Item -ItemType Directory -Path $p -Force -ErrorAction Stop | Out-Null } } + } + } finally { $k.Close() } + $uk = [Microsoft.Win32.Registry]::CurrentUser.OpenSubKey('Environment') + if ($uk) { + try { + foreach ($n in 'TEMP', 'TMP') { + $v = $uk.GetValue($n, $null, 'DoNotExpandEnvironmentNames') + if ($v) { $p = [Environment]::ExpandEnvironmentVariables("$v"); if (-not (Test-Path -LiteralPath $p)) { New-Item -ItemType Directory -Path $p -Force -ErrorAction Stop | Out-Null } } + } + } finally { $uk.Close() } + } + } -FixRisk Safe -FixLabel 'Re-add default PATH entries (backup first) + create missing TEMP folders' - New-DiagnosticCheck crash-history 'Recent crashes (BSOD / unexpected shutdown)' System ` + New-DiagnosticCheck -Id verifier-on -Name 'Driver Verifier left enabled' -Category System ` -Scan { - $dumps = @(Get-ChildItem "$env:WINDIR\Minidump\*.dmp" -ErrorAction SilentlyContinue | Where-Object { $_.LastWriteTime -gt (Get-Date).AddDays(-30) }) - $bug = @(Get-WinEvent -FilterHashtable @{ LogName = 'System'; ProviderName = 'Microsoft-Windows-WER-SystemErrorReporting'; StartTime = (Get-Date).AddDays(-30) } -ErrorAction SilentlyContinue) - $n = $dumps.Count + $bug.Count - if ($n -eq 0) { @{ Status = 'OK'; Detail = 'No crash dumps or bugcheck events in 30 days' } } - elseif ($n -ge 2) { @{ Status = 'Warn'; Detail = "$($dumps.Count) minidump(s), $($bug.Count) bugcheck event(s) in 30 days - recurring instability" } } - else { @{ Status = 'OK'; Detail = "$n crash artifact in 30 days (isolated)" } } + $mm = 'HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager\Memory Management' + $drv = Get-RepRegValue $mm 'VerifyDrivers' + $lvl = Get-RepRegValue $mm 'VerifyDriverLevel' + if ("$drv".Trim() -or ($null -ne $lvl -and $lvl -ne 0)) { + @{ Status = 'Fail'; Detail = "Driver Verifier is active (drivers: '$drv', level: $lvl) - causes slowdowns and deliberate BSODs" } + } else { @{ Status = 'OK'; Detail = 'Driver Verifier off' } } + } ` + -Fix { + & verifier.exe /reset *> $null + if ($LASTEXITCODE -notin 0, 2) { throw "verifier /reset failed (exit $LASTEXITCODE)" } + } -FixRisk Moderate -FixLabel 'Turn Driver Verifier off (verifier /reset)' -Reboot $true + + New-DiagnosticCheck -Id activation -Name 'Windows activation' -Category System ` + -Scan { + try { $lic = @(Get-RepLicenseStatus -TimeoutSec 45) } + catch { return @{ Status = 'Skip'; Detail = "Activation status unavailable: $($_.Exception.Message)" } } + if (-not $lic.Count) { return @{ Status = 'Warn'; Detail = 'No Windows product key installed' } } + $names = @{ 0 = 'Unlicensed'; 1 = 'Licensed'; 2 = 'OOB grace'; 3 = 'OOT grace'; 4 = 'Non-genuine grace'; 5 = 'Notification'; 6 = 'Extended grace' } + $s = if ($lic -contains 1) { 1 } else { [int]$lic[0] } + $label = if ($names.ContainsKey($s)) { $names[$s] } else { "status $s" } + if ($s -eq 1) { @{ Status = 'OK'; Detail = 'Windows is activated' } } + elseif ($s -in 0, 4) { @{ Status = 'Fail'; Detail = "Windows not activated ($label)" } } + else { @{ Status = 'Warn'; Detail = "Windows activation: $label" } } } -Fix $null ) } # ===================================================================== # SELECTION +# -Include alone = run only those ids; with -Category it adds to the category set. # ===================================================================== function Resolve-CheckSelection { param([object[]]$Registry, [string[]]$Category, [string[]]$Include, [string[]]$Exclude) + $Category = ConvertTo-RepIdList $Category + $Include = ConvertTo-RepIdList $Include + $Exclude = ConvertTo-RepIdList $Exclude foreach ($c in $Registry) { $on = $true if ($Category -and ($c.Category -notin $Category)) { $on = $false } + if ($Include -and -not $Category) { $on = $false } if (($Include -contains $c.Id) -or ($Include -contains $c.Name)) { $on = $true } if (($Exclude -contains $c.Id) -or ($Exclude -contains $c.Name)) { $on = $false } if ($on) { $c } @@ -540,29 +1450,62 @@ function Resolve-CheckSelection { # ===================================================================== function Invoke-Scan { param([object]$Check) - $r = @{ Status = 'Skip'; Detail = '' } + $r = $null + $sw = [Diagnostics.Stopwatch]::StartNew() try { $r = & $Check.Scan } catch { $r = @{ Status = 'Skip'; Detail = $_.Exception.Message } } + $sw.Stop() + # Tolerate stray pipeline output: keep the last hashtable the scan emitted. + if ($r -isnot [hashtable]) { $r = @($r | Where-Object { $_ -is [hashtable] }) | Select-Object -Last 1 } + if (-not $r) { $r = @{ Status = 'Skip'; Detail = 'Scan returned no result' } } [pscustomobject]@{ Id = $Check.Id; Name = $Check.Name; Category = $Check.Category Status = $r.Status; Detail = $r.Detail - HasFix = [bool]$Check.Fix; FixRisk = $Check.FixRisk; FixLabel = $Check.FixLabel; Reboot = $Check.Reboot + HasFix = ([bool]$Check.Fix -and -not $r.NoFix) + FixRisk = $(if ($r.FixRisk) { $r.FixRisk } else { $Check.FixRisk }) + FixLabel = $(if ($r.FixLabel) { $r.FixLabel } else { $Check.FixLabel }) + Reboot = $(if ($r.ContainsKey('Reboot')) { [bool]$r.Reboot } else { $Check.Reboot }) + ScanMs = $sw.ElapsedMilliseconds + PreFixStatus = $null + FixOutcome = $null } } +# Applies a fix, then re-scans. Returns the outcome: +# Fixed | PendingReboot | StillFailing | Unverified | Error | Previewed +# and (with -Result) updates that report row with the post-fix status. function Invoke-Fix { [CmdletBinding(SupportsShouldProcess)] - param([object]$Check) - if ($PSCmdlet.ShouldProcess($Check.Name, "Fix: $($Check.FixLabel)")) { - try { - & $Check.Fix - Write-RepLog "Fixed: $($Check.Name)" 'Success' - $script:Fixed++ - if ($Check.Reboot) { $script:RebootNeeded = $true } - return $true - } - catch { $script:FixErrors++; Write-RepLog " fix $($Check.Name): $($_.Exception.Message)" 'Error'; return $false } + param([object]$Check, [object]$Result) + if (-not $PSCmdlet.ShouldProcess($Check.Name, "Fix: $($Check.FixLabel)")) { + $script:Previewed++ + if ($Result) { $Result.FixOutcome = 'Previewed' } + return 'Previewed' } - $false + $script:FixNeedsReboot = $false + $err = $null + try { & $Check.Fix } catch { $err = $_.Exception.Message } + $post = Invoke-Scan -Check $Check + $needsReboot = [bool]($Check.Reboot -or $script:FixNeedsReboot -or ($Result -and $Result.Reboot)) + $outcome = if ($err) { 'Error' } + elseif ($post.Status -eq 'OK') { 'Fixed' } + elseif ($needsReboot) { 'PendingReboot' } + elseif ($post.Status -eq 'Skip') { 'Unverified' } + else { 'StillFailing' } + switch ($outcome) { + 'Fixed' { $script:Fixed++; Write-RepLog "Fixed: $($Check.Name)" 'Success' } + 'PendingReboot' { $script:PendingReboot++; Write-RepLog "Applied, restart required: $($Check.Name) - $($post.Detail)" 'Warning' } + 'Error' { $script:FixErrors++; Write-RepLog " fix $($Check.Name): $err" 'Error' } + default { $script:StillFailing++; Write-RepLog "Fix applied but not confirmed ($($post.Status)): $($Check.Name) - $($post.Detail)" 'Warning' } + } + if ($script:FixNeedsReboot -or ($needsReboot -and $outcome -in 'Fixed', 'PendingReboot')) { $script:RebootNeeded = $true } + if ($Result) { + $Result.PreFixStatus = $Result.Status + $Result.Status = $post.Status + $Result.Detail = $post.Detail + $Result.HasFix = $post.HasFix + $Result.FixOutcome = $outcome + } + $outcome } function Get-StatusColor { param([string]$S) @@ -588,9 +1531,12 @@ function Write-RepReport { Write-WinSeniorReport -ReportPath $ReportPath -Engine 'Repair' ` -RestorePoint $script:RestorePointMade -StartTime $script:StartTime ` -Summary @{ - Fixed = $script:Fixed - FixErrors = $script:FixErrors - Reboot = $script:RebootNeeded + Fixed = $script:Fixed + PendingReboot = $script:PendingReboot + StillFailing = $script:StillFailing + Previewed = $script:Previewed + FixErrors = $script:FixErrors + Reboot = $script:RebootNeeded } ` -Items $script:Results ` -LogAction { param($m, $l) Write-RepLog $m $l } @@ -612,14 +1558,14 @@ function Show-CheckList { function Show-RepUsageHelp { @' -Windows Troubleshooting engine v6.0 (scan -> report -> repair) +Windows Troubleshooting engine v{VERSION} (scan -> report -> repair) USAGE .\Repair-Windows-Senior.ps1 [options] SELECTION -Category Limit to: Integrity, Disk, Update, Network, Devices, Services, Security, System - -Include Force checks on (see -ListChecks for ids) + -Include Run only these checks (with -Category: add them) (see -ListChecks) -Exclude Force checks off FLOW @@ -627,16 +1573,16 @@ FLOW -ScanOnly Diagnose only - never change anything -FixAll Non-interactive: auto-apply fixable issues (Safe+Moderate) -IncludeHeavy With -FixAll, also apply Aggressive (heavy/reboot) repairs - -Conservative Cap auto-fixes at Safe + Moderate + -Conservative Cap every fix path at Safe + Moderate SAFETY -WhatIf / -DryRun,-dr Preview only, change nothing (real ShouldProcess) -NoRestorePoint,-nrp Skip the restore point made before repairs - -Unattended,-Force,-f No prompts - for automation + -Unattended,-Force,-f No prompts; alone it only scans + reports (add -FixAll to repair) OUTPUT -LogPath Text log (default: %TEMP%\WindowsRepair.log) - -ReportPath Machine-readable JSON report + -ReportPath Machine-readable JSON report (rewritten after repairs) -ListChecks Print the check registry and exit -Help Show this help @@ -644,14 +1590,14 @@ EXAMPLES .\Repair-Windows-Senior.ps1 .\Repair-Windows-Senior.ps1 -ScanOnly .\Repair-Windows-Senior.ps1 -FixAll -IncludeHeavy -Unattended -'@ | Write-Host +'@ -replace '\{VERSION\}', (Get-WinSeniorVersion) | Write-Host } # ===================================================================== # MAIN # ===================================================================== function Start-WindowsRepair { - Write-RepLog 'Windows Troubleshooting v6.0' 'Step' + Write-RepLog ("Windows Troubleshooting v{0}" -f (Get-WinSeniorVersion)) 'Step' Write-RepLog ("PowerShell {0} | Mode: {1}" -f $PSVersionTable.PSVersion, $(if (Test-WhatIfMode) { 'DryRun' } else { 'Live' })) 'Info' if (-not (Test-AdminPrivileges)) { Write-RepLog 'Administrator privileges are required. Re-run as Administrator.' 'Error'; exit 2 } @@ -660,6 +1606,8 @@ function Start-WindowsRepair { if (-not $selection.Count) { Write-RepLog 'No checks selected.' 'Warning'; return } Write-RepLog ("Scanning {0} check(s)..." -f $selection.Count) 'Info' + # The licensing query is slow cold: run it alongside the other scans. + if ($selection | Where-Object { $_.Id -eq 'activation' }) { Invoke-RepLicensePrefetch } foreach ($c in $selection) { Write-RepLog (" scanning: {0}" -f $c.Name) 'Debug' $script:Results.Add((Invoke-Scan -Check $c)) @@ -669,17 +1617,22 @@ function Start-WindowsRepair { if ($ScanOnly) { return } - # Fixable = Warn/Fail with a Fix defined. + # Fixable = Warn/Fail with a Fix that applies to this finding. $rank = @{ Safe = 0; Moderate = 1; Aggressive = 2 } $fixable = @($script:Results | Where-Object { $_.HasFix -and $_.Status -in 'Warn','Fail' }) if (-not $fixable.Count) { Write-RepLog 'No auto-fixable issues detected.' 'Success'; return } # Decide which to fix. $toFix = @() - if ($FixAll -or $Unattended) { - $cap = if ($IncludeHeavy -and -not $Conservative) { 2 } elseif ($Conservative) { 1 } else { 1 } + if ($FixAll) { + $cap = if ($IncludeHeavy -and -not $Conservative) { 2 } else { 1 } $toFix = $fixable | Where-Object { $rank[$_.FixRisk] -le $cap } } + elseif ($Unattended) { + # -Unattended alone means "no prompts", not "repair everything". + Write-RepLog ("{0} fixable issue(s) found; report only (add -FixAll to repair)." -f $fixable.Count) 'Info' + return + } elseif (-not (Test-WhatIfMode)) { Write-RepLog '' 'Info' Write-RepLog 'Fixable issues:' 'Step' @@ -706,6 +1659,13 @@ function Start-WindowsRepair { $toFix = $fixable } + # -Conservative caps every path (interactive 'h' and -WhatIf previews included). + if ($Conservative) { + $skipped = @($toFix | Where-Object { $rank[$_.FixRisk] -gt 1 }) + foreach ($s in $skipped) { Write-RepLog "Conservative: skipping Aggressive fix '$($s.Name)'" 'Info' } + $toFix = $toFix | Where-Object { $rank[$_.FixRisk] -le 1 } + } + $toFix = @($toFix) if (-not $toFix.Count) { Write-RepLog 'Nothing to repair.' 'Info'; return } @@ -713,13 +1673,18 @@ function Start-WindowsRepair { foreach ($r in $toFix) { $check = $registry | Where-Object { $_.Id -eq $r.Id } | Select-Object -First 1 - if ($check) { Invoke-Fix -Check $check | Out-Null } + if ($check) { Invoke-Fix -Check $check -Result $r | Out-Null } } Write-RepLog '' 'Info' - $verb = if (Test-WhatIfMode) { 'Would fix' } else { 'Fixed' } - Write-RepLog ("{0}: {1} issue(s), {2} error(s)" -f $verb, $script:Fixed, $script:FixErrors) 'Success' + if (Test-WhatIfMode) { Write-RepLog ("Would fix: {0} issue(s)" -f $script:Previewed) 'WhatIf' } + else { + Write-RepLog ("Fixed: {0} | restart pending: {1} | not resolved: {2} | errors: {3}" -f + $script:Fixed, $script:PendingReboot, $script:StillFailing, $script:FixErrors) $(if ($script:FixErrors -or $script:StillFailing) { 'Warning' } else { 'Success' }) + } if ($script:RebootNeeded) { Write-RepLog 'A reboot is required to complete some repairs.' 'Warning' } + # Rewrite the report with post-fix statuses, counters and the reboot flag. + Write-RepReport Write-RepLog ("Duration: {0:N1}s Log: {1}" -f ((Get-Date) - $script:StartTime).TotalSeconds, $LogPath) 'Info' } @@ -729,5 +1694,9 @@ function Start-WindowsRepair { if ($MyInvocation.InvocationName -ne '.') { if ($Help) { Show-RepUsageHelp; exit 0 } if ($ListChecks) { Show-CheckList; exit 0 } + # The desktop app passes '-Include a,b,c' through -File as ONE string: split it. + $Category = ConvertTo-RepIdList $Category + $Include = ConvertTo-RepIdList $Include + $Exclude = ConvertTo-RepIdList $Exclude Start-WindowsRepair } diff --git a/WinSenior.Common.ps1 b/WinSenior.Common.ps1 index d9a106b..d09306e 100644 --- a/WinSenior.Common.ps1 +++ b/WinSenior.Common.ps1 @@ -14,9 +14,16 @@ .NOTES Author : denfry (https://github.com/denfry/WindowsCleaner) - Version : 6.2.0 + Version : 6.3.0 #> +# When an engine's stdout is redirected (desktop app, scheduler, CI) emit UTF-8 +# instead of the OEM code page, so non-English text (paths, localized errors) +# survives the round-trip. An interactive console keeps its own code page. +try { + if ([Console]::IsOutputRedirected) { [Console]::OutputEncoding = New-Object System.Text.UTF8Encoding($false) } +} catch { Write-Verbose "OutputEncoding: $($_.Exception.Message)" } + # ===================================================================== # ENVIRONMENT PROBES # ===================================================================== @@ -80,6 +87,28 @@ function Write-WsLog { # $script:RestorePointMade flag (set it only on 'Created'). Logging is # delegated through -LogAction so each engine logs in its own voice. # ===================================================================== +# Checkpoint-Computer / Enable-ComputerRestore do not exist in PowerShell 7, and the +# desktop app prefers pwsh - so go through the SystemRestore WMI class, which both +# runtimes have. Thin wrappers so tests can mock them. +function Enable-WsSystemRestore { + param([string]$Drive) + try { + Invoke-CimMethod -Namespace root/default -ClassName SystemRestore -MethodName Enable ` + -Arguments @{ Drive = $Drive } -ErrorAction Stop | Out-Null + } catch { Write-Verbose "SystemRestore.Enable: $($_.Exception.Message)" } +} + +function Invoke-WsCheckpoint { + param([string]$Description) + # RestorePointType 12 = MODIFY_SETTINGS, EventType 100 = BEGIN_SYSTEM_CHANGE + $r = Invoke-CimMethod -Namespace root/default -ClassName SystemRestore -MethodName CreateRestorePoint ` + -Arguments @{ Description = $Description; RestorePointType = [uint32]12; EventType = [uint32]100 } ` + -ErrorAction Stop + if ($r.ReturnValue -ne 0) { + throw ("SystemRestore.CreateRestorePoint returned 0x{0:X8} (System Protection may be off)" -f [uint32]$r.ReturnValue) + } +} + function New-WinSeniorRestorePoint { param( [Parameter(Mandatory)][string]$Description, @@ -90,14 +119,12 @@ function New-WinSeniorRestorePoint { return 'WhatIf' } & $LogAction 'Creating System Restore point...' 'Safety' + # Lift the 24-hour throttle for this one checkpoint, then put the user's setting + # back exactly as it was (value or absence) so the system default is not changed. + $throttle = Set-WsRestoreThrottle -Value 0 try { - # Clear the 24-hour throttle so a back-to-back run still gets a point. - $rk = 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\SystemRestore' - New-ItemProperty -Path $rk -Name 'SystemRestorePointCreationFrequency' ` - -Value 0 -PropertyType DWord -Force -ErrorAction SilentlyContinue | Out-Null - Enable-ComputerRestore -Drive "$env:SystemDrive\" -ErrorAction SilentlyContinue - Checkpoint-Computer -Description $Description ` - -RestorePointType 'MODIFY_SETTINGS' -ErrorAction Stop + Enable-WsSystemRestore -Drive "$env:SystemDrive\" + Invoke-WsCheckpoint -Description $Description & $LogAction 'System Restore point created' 'Success' return 'Created' } @@ -106,6 +133,29 @@ function New-WinSeniorRestorePoint { & $LogAction 'Continuing without a restore point (System Protection may be off).' 'Warning' return 'Failed' } + finally { Set-WsRestoreThrottle -Restore $throttle | Out-Null } +} + +# Set (-Value) or restore (-Restore ) SystemRestorePointCreationFrequency. +# Returns the previous value ($null = was absent). Uses the .NET registry API so the +# restore path can delete the value without Remove-ItemProperty. +function Set-WsRestoreThrottle { + param([Nullable[int]]$Value, [object]$Restore = 'none') + $sub = 'SOFTWARE\Microsoft\Windows NT\CurrentVersion\SystemRestore' + $name = 'SystemRestorePointCreationFrequency' + try { + $key = [Microsoft.Win32.Registry]::LocalMachine.OpenSubKey($sub, $true) + if (-not $key) { return $null } + try { + $prev = $key.GetValue($name, $null) + if ($Restore -ne 'none') { + if ($null -eq $Restore) { $key.DeleteValue($name, $false) } + else { $key.SetValue($name, [int]$Restore, [Microsoft.Win32.RegistryValueKind]::DWord) } + } + elseif ($null -ne $Value) { $key.SetValue($name, [int]$Value, [Microsoft.Win32.RegistryValueKind]::DWord) } + return $prev + } finally { $key.Close() } + } catch { Write-Verbose "restore throttle: $($_.Exception.Message)"; return $null } } # ===================================================================== @@ -115,7 +165,7 @@ function New-WinSeniorRestorePoint { # DurationSec; engine-specific counters go in Summary, the per-unit list # in Items. No-op without -ReportPath. # ===================================================================== -function Get-WinSeniorVersion { '6.2.0' } +function Get-WinSeniorVersion { '6.3.0' } function Write-WinSeniorReport { param( @@ -128,6 +178,24 @@ function Write-WinSeniorReport { [scriptblock]$LogAction ) if (-not $ReportPath) { return } + # '{timestamp}' in the path gives every run its own file (scheduled runs keep a + # history instead of overwriting one report). Resolved once per process so an + # engine that rewrites its report (Repair: after scan, again after fixes) keeps + # a single file. Only the newest 60 files of that pattern are kept. + if ($ReportPath -like '*{timestamp}*') { + if (-not $script:WsReportStamp) { $script:WsReportStamp = (Get-Date).ToString('yyyyMMdd-HHmmss') } + $pattern = [IO.Path]::GetFileName($ReportPath).Replace('{timestamp}', '*') + $ReportPath = $ReportPath.Replace('{timestamp}', $script:WsReportStamp) + $dir = [IO.Path]::GetDirectoryName($ReportPath) + if ($dir -and -not (Test-Path -LiteralPath $dir)) { + New-Item -ItemType Directory -Path $dir -Force -WhatIf:$false | Out-Null + } + if ($dir -and (Test-Path -LiteralPath $dir)) { + Get-ChildItem -LiteralPath $dir -Filter $pattern -File -ErrorAction SilentlyContinue | + Sort-Object LastWriteTime -Descending | Select-Object -Skip 59 | + ForEach-Object { try { [IO.File]::Delete($_.FullName) } catch { Write-Verbose "prune: $($_.Exception.Message)" } } + } + } # Normalise to a flat array. Note: @() throws "Argument types do not match" # on a Generic.List[object] (which is exactly what the engines pass), so cast. $itemArr = if ($null -eq $Items) { @() } else { [object[]]$Items } diff --git a/WinSenior.Gui.ps1 b/WinSenior.Gui.ps1 index 6acb0d0..d0e225c 100644 --- a/WinSenior.Gui.ps1 +++ b/WinSenior.Gui.ps1 @@ -8,25 +8,89 @@ install). It drives the three engines exactly like the console menu does - by launching them with parameters - so every action keeps the engines' real -WhatIf, the safety guard, restore points and per-tweak undo. Engine output streams into - the log panel; JSON reports feed the per-task size and health columns. + the log panel; JSON reports feed the per-task size and health columns and the + History page. The Startup page toggles autostart entries the way Task Manager + does (WinSenior.Startup.ps1). Normally started through WinSenior.cmd (double-click) or WinSenior.ps1 -Gui; both elevate first. Run directly with -NoElevate to skip the UAC prompt. + Automation hooks (smoke tests; all optional environment variables): + WINSENIOR_GUI_AUTOCLOSE= close by itself after seconds (waits while an engine runs) + WINSENIOR_GUI_SCREENSHOT= render the window to a PNG right before closing + WINSENIOR_GUI_AUTORUN=