From 9123b4367399ce687b02a54dc3bc7d440115a829 Mon Sep 17 00:00:00 2001 From: denfry Date: Wed, 24 Jun 2026 09:20:06 +0300 Subject: [PATCH 1/6] refactor(core): extract shared engine library (WinSenior.Common.ps1) The admin check, WhatIf probe, byte formatter, console+file logger, and the System Restore point routine were copy-pasted into all three engines. Pull them into one dot-sourced WinSenior.Common.ps1 so a fix lands once instead of drifting between three copies. - Each engine dot-sources Common and keeps a 1-line wrapper (Write-*Log, New-*RestorePoint), so every call site is unchanged. - New-WinSeniorRestorePoint returns 'WhatIf'|'Created'|'Failed'; the caller owns its own $script:RestorePointMade flag and logs via a -LogAction block. - WinSenior.ps1 drops its local Test-Admin and uses Test-AdminPrivileges. - CI globs all root *.ps1 for parse + PSScriptAnalyzer instead of a hardcoded 4-file list, so WinSenior.UI.ps1 is finally linted too. - Add tests/WinSenior.Common.Tests.ps1 (Pester 5, mocked restore point). Net -175 duplicated lines across the engines. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/ci.yml | 5 +- Cleanup-Windows-Senior.ps1 | 68 ++++--------------- Optimize-Windows-Senior.ps1 | 65 ++++-------------- Repair-Windows-Senior.ps1 | 62 ++++-------------- WinSenior.Common.ps1 | 109 +++++++++++++++++++++++++++++++ WinSenior.ps1 | 18 ++--- tests/WinSenior.Common.Tests.ps1 | 75 +++++++++++++++++++++ 7 files changed, 227 insertions(+), 175 deletions(-) create mode 100644 WinSenior.Common.ps1 create mode 100644 tests/WinSenior.Common.Tests.ps1 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5e8550a..df26048 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,7 +15,8 @@ jobs: - name: Syntax parse check shell: pwsh run: | - $files = './Cleanup-Windows-Senior.ps1','./Optimize-Windows-Senior.ps1','./Repair-Windows-Senior.ps1','./WinSenior.ps1' + $files = (Get-ChildItem -Path . -Filter *.ps1 -File).FullName + Write-Host "Parsing: $($files -join ', ')" $failed = $false foreach ($f in $files) { $errors = $null @@ -39,7 +40,7 @@ jobs: - name: PSScriptAnalyzer (fail on errors only) shell: pwsh run: | - $files = './Cleanup-Windows-Senior.ps1','./Optimize-Windows-Senior.ps1','./Repair-Windows-Senior.ps1','./WinSenior.ps1' + $files = (Get-ChildItem -Path . -Filter *.ps1 -File).FullName $r = $files | ForEach-Object { Invoke-ScriptAnalyzer -Path $_ -Severity Error } if ($r) { $r | Format-Table -AutoSize | Out-String | Write-Host; Write-Error 'ScriptAnalyzer found error-level issues'; exit 1 } Write-Host 'No analyzer errors' diff --git a/Cleanup-Windows-Senior.ps1 b/Cleanup-Windows-Senior.ps1 index 1b4a054..5b836f7 100644 --- a/Cleanup-Windows-Senior.ps1 +++ b/Cleanup-Windows-Senior.ps1 @@ -120,6 +120,11 @@ $script:DenyList = @( ${env:ProgramFiles(x86)} ) | Where-Object { $_ } | ForEach-Object { $_.TrimEnd('\').ToLowerInvariant() } +# ===================================================================== +# SHARED LIBRARY (admin / restore-point / logging / format helpers) +# ===================================================================== +. (Join-Path $PSScriptRoot 'WinSenior.Common.ps1') + # ===================================================================== # LOGGING # ===================================================================== @@ -129,39 +134,12 @@ function Write-CleanupLog { [ValidateSet('Info','Success','Warning','Error','Debug','Step','WhatIf','Safety')] [string]$Level = 'Info' ) - $tag = switch ($Level) { - 'Success' { '[+]' } 'Warning' { '[!]' } 'Error' { '[x]' } - 'Step' { '==>' } 'WhatIf' { '[~]' } 'Safety' { '[#]' } - 'Debug' { ' ' } default { '[i]' } - } - $color = switch ($Level) { - 'Success' { 'Green' } 'Warning' { 'Yellow' } 'Error' { 'Red' } - 'Step' { 'Cyan' } 'WhatIf' { 'Cyan' } 'Safety' { 'Magenta' } - 'Debug' { 'DarkGray' } default { 'Gray' } - } - $line = "$tag $Message" - if ($Level -ne 'Debug' -or $VerbosePreference -ne 'SilentlyContinue') { - Write-Host $line -ForegroundColor $color - } - $stamp = "[{0:yyyy-MM-dd HH:mm:ss}] [{1}] {2}" -f (Get-Date), $Level, $Message - # Logging is infrastructure, not a cleanup action: never let -WhatIf suppress it. - try { Add-Content -Path $LogPath -Value $stamp -ErrorAction SilentlyContinue -WhatIf:$false } catch { } + Write-WsLog -Message $Message -Level $Level -LogPath $LogPath } # ===================================================================== # UTILITIES # ===================================================================== -function Format-FileSize { - param([long]$Size) - if ($Size -ge 1TB) { '{0:N2} TB' -f ($Size / 1TB) } - elseif ($Size -ge 1GB) { '{0:N2} GB' -f ($Size / 1GB) } - elseif ($Size -ge 1MB) { '{0:N2} MB' -f ($Size / 1MB) } - elseif ($Size -ge 1KB) { '{0:N2} KB' -f ($Size / 1KB) } - else { "$Size B" } -} - -function Test-WhatIfMode { [bool]$WhatIfPreference } - function Get-ItemSize { param([System.IO.FileSystemInfo]$Item) if ($Item.PSIsContainer) { @@ -344,36 +322,12 @@ function Remove-ProtectedFolder { # ===================================================================== # SAFETY / ENVIRONMENT # ===================================================================== -function Test-AdminPrivileges { - try { - $id = [Security.Principal.WindowsIdentity]::GetCurrent() - (New-Object Security.Principal.WindowsPrincipal($id)).IsInRole( - [Security.Principal.WindowsBuiltInRole]::Administrator) - } catch { $false } -} - function New-CleanupRestorePoint { - if (Test-WhatIfMode) { - Write-CleanupLog '[WhatIf] would create a System Restore point' 'WhatIf' - return $true - } - Write-CleanupLog 'Creating System Restore point...' 'Safety' - try { - $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 "Before Windows Cleanup $(Get-Date -Format 'yyyy-MM-dd HH:mm')" ` - -RestorePointType 'MODIFY_SETTINGS' -ErrorAction Stop - Write-CleanupLog 'System Restore point created' 'Success' - $script:RestorePointMade = $true - return $true - } - catch { - Write-CleanupLog "Restore point not created: $($_.Exception.Message)" 'Warning' - Write-CleanupLog 'Continuing without a restore point (System Protection may be off).' 'Warning' - return $false - } + $st = New-WinSeniorRestorePoint ` + -Description "Before Windows Cleanup $(Get-Date -Format 'yyyy-MM-dd HH:mm')" ` + -LogAction { param($m, $l) Write-CleanupLog $m $l } + if ($st -eq 'Created') { $script:RestorePointMade = $true } + return ($st -ne 'Failed') } function Stop-BrowserProcesses { diff --git a/Optimize-Windows-Senior.ps1 b/Optimize-Windows-Senior.ps1 index 2c781e4..77a03bf 100644 --- a/Optimize-Windows-Senior.ps1 +++ b/Optimize-Windows-Senior.ps1 @@ -98,6 +98,11 @@ $script:RestorePointMade = $false if ($DryRun) { $WhatIfPreference = $true } +# ===================================================================== +# SHARED LIBRARY (admin / restore-point / logging / format helpers) +# ===================================================================== +. (Join-Path $PSScriptRoot 'WinSenior.Common.ps1') + # ===================================================================== # LOGGING # ===================================================================== @@ -107,66 +112,18 @@ function Write-OptLog { [ValidateSet('Info','Success','Warning','Error','Debug','Step','WhatIf','Safety')] [string]$Level = 'Info' ) - $tag = switch ($Level) { - 'Success' { '[+]' } 'Warning' { '[!]' } 'Error' { '[x]' } - 'Step' { '==>' } 'WhatIf' { '[~]' } 'Safety' { '[#]' } - 'Debug' { ' ' } default { '[i]' } - } - $color = switch ($Level) { - 'Success' { 'Green' } 'Warning' { 'Yellow' } 'Error' { 'Red' } - 'Step' { 'Cyan' } 'WhatIf' { 'Cyan' } 'Safety' { 'Magenta' } - 'Debug' { 'DarkGray' } default { 'Gray' } - } - if ($Level -ne 'Debug' -or $VerbosePreference -ne 'SilentlyContinue') { - Write-Host "$tag $Message" -ForegroundColor $color - } - $stamp = "[{0:yyyy-MM-dd HH:mm:ss}] [{1}] {2}" -f (Get-Date), $Level, $Message - try { Add-Content -Path $LogPath -Value $stamp -ErrorAction SilentlyContinue -WhatIf:$false } catch { } + Write-WsLog -Message $Message -Level $Level -LogPath $LogPath } # ===================================================================== # UTILITIES # ===================================================================== -function Format-FileSize { - param([long]$Size) - if ($Size -ge 1TB) { '{0:N2} TB' -f ($Size / 1TB) } - elseif ($Size -ge 1GB) { '{0:N2} GB' -f ($Size / 1GB) } - elseif ($Size -ge 1MB) { '{0:N2} MB' -f ($Size / 1MB) } - elseif ($Size -ge 1KB) { '{0:N2} KB' -f ($Size / 1KB) } - else { "$Size B" } -} - -function Test-WhatIfMode { [bool]$WhatIfPreference } - -function Test-AdminPrivileges { - try { - $id = [Security.Principal.WindowsIdentity]::GetCurrent() - (New-Object Security.Principal.WindowsPrincipal($id)).IsInRole( - [Security.Principal.WindowsBuiltInRole]::Administrator) - } catch { $false } -} - function New-OptRestorePoint { - if (Test-WhatIfMode) { - Write-OptLog '[WhatIf] would create a System Restore point' 'WhatIf'; return $true - } - Write-OptLog 'Creating System Restore point...' 'Safety' - try { - $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 "Before Windows Optimize $(Get-Date -Format 'yyyy-MM-dd HH:mm')" ` - -RestorePointType 'MODIFY_SETTINGS' -ErrorAction Stop - Write-OptLog 'System Restore point created' 'Success' - $script:RestorePointMade = $true - return $true - } - catch { - Write-OptLog "Restore point not created: $($_.Exception.Message)" 'Warning' - Write-OptLog 'Continuing without a restore point (System Protection may be off).' 'Warning' - return $false - } + $st = New-WinSeniorRestorePoint ` + -Description "Before Windows Optimize $(Get-Date -Format 'yyyy-MM-dd HH:mm')" ` + -LogAction { param($m, $l) Write-OptLog $m $l } + if ($st -eq 'Created') { $script:RestorePointMade = $true } + return ($st -ne 'Failed') } # ===================================================================== diff --git a/Repair-Windows-Senior.ps1 b/Repair-Windows-Senior.ps1 index b27fdf6..776b51f 100644 --- a/Repair-Windows-Senior.ps1 +++ b/Repair-Windows-Senior.ps1 @@ -85,6 +85,11 @@ $script:RestorePointMade = $false if ($DryRun) { $WhatIfPreference = $true } +# ===================================================================== +# SHARED LIBRARY (admin / restore-point / logging / format helpers) +# ===================================================================== +. (Join-Path $PSScriptRoot 'WinSenior.Common.ps1') + # ===================================================================== # LOGGING / UTIL # ===================================================================== @@ -94,60 +99,15 @@ function Write-RepLog { [ValidateSet('Info','Success','Warning','Error','Debug','Step','WhatIf','Safety')] [string]$Level = 'Info' ) - $tag = switch ($Level) { - 'Success' { '[+]' } 'Warning' { '[!]' } 'Error' { '[x]' } - 'Step' { '==>' } 'WhatIf' { '[~]' } 'Safety' { '[#]' } - 'Debug' { ' ' } default { '[i]' } - } - $color = switch ($Level) { - 'Success' { 'Green' } 'Warning' { 'Yellow' } 'Error' { 'Red' } - 'Step' { 'Cyan' } 'WhatIf' { 'Cyan' } 'Safety' { 'Magenta' } - 'Debug' { 'DarkGray' } default { 'Gray' } - } - if ($Level -ne 'Debug' -or $VerbosePreference -ne 'SilentlyContinue') { - Write-Host "$tag $Message" -ForegroundColor $color - } - $stamp = "[{0:yyyy-MM-dd HH:mm:ss}] [{1}] {2}" -f (Get-Date), $Level, $Message - try { Add-Content -Path $LogPath -Value $stamp -ErrorAction SilentlyContinue -WhatIf:$false } catch { } -} - -function Format-FileSize { - param([long]$Size) - if ($Size -ge 1TB) { '{0:N2} TB' -f ($Size / 1TB) } - elseif ($Size -ge 1GB) { '{0:N2} GB' -f ($Size / 1GB) } - elseif ($Size -ge 1MB) { '{0:N2} MB' -f ($Size / 1MB) } - elseif ($Size -ge 1KB) { '{0:N2} KB' -f ($Size / 1KB) } - else { "$Size B" } -} - -function Test-WhatIfMode { [bool]$WhatIfPreference } - -function Test-AdminPrivileges { - try { - $id = [Security.Principal.WindowsIdentity]::GetCurrent() - (New-Object Security.Principal.WindowsPrincipal($id)).IsInRole( - [Security.Principal.WindowsBuiltInRole]::Administrator) - } catch { $false } + Write-WsLog -Message $Message -Level $Level -LogPath $LogPath } function New-RepairRestorePoint { - if (Test-WhatIfMode) { Write-RepLog '[WhatIf] would create a System Restore point' 'WhatIf'; return $true } - Write-RepLog 'Creating System Restore point...' 'Safety' - try { - $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 "Before Windows Repair $(Get-Date -Format 'yyyy-MM-dd HH:mm')" ` - -RestorePointType 'MODIFY_SETTINGS' -ErrorAction Stop - Write-RepLog 'System Restore point created' 'Success' - $script:RestorePointMade = $true - return $true - } - catch { - Write-RepLog "Restore point not created: $($_.Exception.Message)" 'Warning' - return $false - } + $st = New-WinSeniorRestorePoint ` + -Description "Before Windows Repair $(Get-Date -Format 'yyyy-MM-dd HH:mm')" ` + -LogAction { param($m, $l) Write-RepLog $m $l } + if ($st -eq 'Created') { $script:RestorePointMade = $true } + return ($st -ne 'Failed') } # ===================================================================== diff --git a/WinSenior.Common.ps1 b/WinSenior.Common.ps1 new file mode 100644 index 0000000..6778c0a --- /dev/null +++ b/WinSenior.Common.ps1 @@ -0,0 +1,109 @@ +<# +.SYNOPSIS + Shared library for the WinSenior engines (cleanup / optimize / repair). + +.DESCRIPTION + Dot-sourced by every engine and by the WinSenior menu. Holds the helpers that + were previously copy-pasted into each engine: the admin check, the WhatIf probe, + byte formatting, the console+file logger, and the System Restore point routine. + Keeping them in one place means a fix to the restore-point logic or the log + format lands everywhere at once instead of drifting between three copies. + + Source stays pure ASCII (no box glyphs, no Cyrillic) so it loads identically + under Windows PowerShell 5.1 regardless of file encoding. + +.NOTES + Author : denfry (https://github.com/denfry/WindowsCleaner) + Version : 6.0.0 +#> + +# ===================================================================== +# ENVIRONMENT PROBES +# ===================================================================== +function Test-AdminPrivileges { + try { + $id = [Security.Principal.WindowsIdentity]::GetCurrent() + (New-Object Security.Principal.WindowsPrincipal($id)).IsInRole( + [Security.Principal.WindowsBuiltInRole]::Administrator) + } catch { $false } +} + +function Test-WhatIfMode { [bool]$WhatIfPreference } + +# ===================================================================== +# FORMATTING +# ===================================================================== +function Format-FileSize { + param([long]$Size) + if ($Size -ge 1TB) { '{0:N2} TB' -f ($Size / 1TB) } + elseif ($Size -ge 1GB) { '{0:N2} GB' -f ($Size / 1GB) } + elseif ($Size -ge 1MB) { '{0:N2} MB' -f ($Size / 1MB) } + elseif ($Size -ge 1KB) { '{0:N2} KB' -f ($Size / 1KB) } + else { "$Size B" } +} + +# ===================================================================== +# LOGGING +# Canonical logger. Each engine keeps a thin Write-Log wrapper +# that forwards here with its own -LogPath, so call sites are unchanged. +# ===================================================================== +function Write-WsLog { + param( + [string]$Message, + [ValidateSet('Info','Success','Warning','Error','Debug','Step','WhatIf','Safety')] + [string]$Level = 'Info', + [string]$LogPath + ) + $tag = switch ($Level) { + 'Success' { '[+]' } 'Warning' { '[!]' } 'Error' { '[x]' } + 'Step' { '==>' } 'WhatIf' { '[~]' } 'Safety' { '[#]' } + 'Debug' { ' ' } default { '[i]' } + } + $color = switch ($Level) { + 'Success' { 'Green' } 'Warning' { 'Yellow' } 'Error' { 'Red' } + 'Step' { 'Cyan' } 'WhatIf' { 'Cyan' } 'Safety' { 'Magenta' } + 'Debug' { 'DarkGray' } default { 'Gray' } + } + if ($Level -ne 'Debug' -or $VerbosePreference -ne 'SilentlyContinue') { + Write-Host "$tag $Message" -ForegroundColor $color + } + # Logging is infrastructure, not a cleanup action: never let -WhatIf suppress it. + if ($LogPath) { + $stamp = "[{0:yyyy-MM-dd HH:mm:ss}] [{1}] {2}" -f (Get-Date), $Level, $Message + try { Add-Content -Path $LogPath -Value $stamp -ErrorAction SilentlyContinue -WhatIf:$false } catch { } + } +} + +# ===================================================================== +# SYSTEM RESTORE POINT +# Returns 'WhatIf' | 'Created' | 'Failed'. The caller owns its own +# $script:RestorePointMade flag (set it only on 'Created'). Logging is +# delegated through -LogAction so each engine logs in its own voice. +# ===================================================================== +function New-WinSeniorRestorePoint { + param( + [Parameter(Mandatory)][string]$Description, + [Parameter(Mandatory)][scriptblock]$LogAction + ) + if (Test-WhatIfMode) { + & $LogAction '[WhatIf] would create a System Restore point' 'WhatIf' + return 'WhatIf' + } + & $LogAction 'Creating System Restore point...' 'Safety' + 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 + & $LogAction 'System Restore point created' 'Success' + return 'Created' + } + catch { + & $LogAction "Restore point not created: $($_.Exception.Message)" 'Warning' + & $LogAction 'Continuing without a restore point (System Protection may be off).' 'Warning' + return 'Failed' + } +} diff --git a/WinSenior.ps1 b/WinSenior.ps1 index c8c1517..c26af30 100644 --- a/WinSenior.ps1 +++ b/WinSenior.ps1 @@ -35,28 +35,24 @@ try { [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 } catch { } # LOCATE ENGINES + ELEVATE # ===================================================================== $script:Root = $PSScriptRoot +$script:CommonScript = Join-Path $script:Root 'WinSenior.Common.ps1' $script:CleanupScript = Join-Path $script:Root 'Cleanup-Windows-Senior.ps1' $script:OptimizeScript = Join-Path $script:Root 'Optimize-Windows-Senior.ps1' $script:RepairScript = Join-Path $script:Root 'Repair-Windows-Senior.ps1' $script:UiScript = Join-Path $script:Root 'WinSenior.UI.ps1' -function Test-Admin { - try { - $id = [Security.Principal.WindowsIdentity]::GetCurrent() - (New-Object Security.Principal.WindowsPrincipal($id)).IsInRole( - [Security.Principal.WindowsBuiltInRole]::Administrator) - } catch { $false } -} - -foreach ($s in @($script:CleanupScript, $script:OptimizeScript, $script:RepairScript, $script:UiScript)) { +foreach ($s in @($script:CommonScript, $script:CleanupScript, $script:OptimizeScript, $script:RepairScript, $script:UiScript)) { if (-not (Test-Path $s)) { Write-Host "Engine not found: $s" -ForegroundColor Red - Write-Host 'Keep WinSenior.ps1 next to the two engine scripts.' -ForegroundColor Yellow + Write-Host 'Keep WinSenior.ps1 next to the engine scripts and WinSenior.Common.ps1.' -ForegroundColor Yellow exit 1 } } -if (-not (Test-Admin)) { +# Shared helpers (admin check, restore point, logging) - needed before elevation. +. $script:CommonScript + +if (-not (Test-AdminPrivileges)) { if ($NoElevate) { Write-Host '[!] Not running as Administrator - most actions will fail.' -ForegroundColor Yellow } diff --git a/tests/WinSenior.Common.Tests.ps1 b/tests/WinSenior.Common.Tests.ps1 new file mode 100644 index 0000000..25801f5 --- /dev/null +++ b/tests/WinSenior.Common.Tests.ps1 @@ -0,0 +1,75 @@ +# Pester tests for the shared library WinSenior.Common.ps1 +# Run: Invoke-Pester -Path .\tests +# Covers the helpers every engine now dot-sources: byte formatting, the canonical +# logger, and the System Restore point routine (mocked, so nothing touches the box). + +BeforeAll { + $script:Sut = Join-Path $PSScriptRoot '..\WinSenior.Common.ps1' + . $script:Sut +} + +Describe 'Test-AdminPrivileges' { + It 'returns a boolean' { + Test-AdminPrivileges | Should -BeOfType ([bool]) + } +} + +Describe 'Format-FileSize' { + # Decimal separator is culture-dependent (1.50 vs 1,50), so match structurally. + It 'formats bytes' { Format-FileSize 512 | Should -Be '512 B' } + It 'formats zero' { Format-FileSize 0 | Should -Be '0 B' } + It 'scales to KB' { Format-FileSize 1536 | Should -Match '^1[.,]50 KB$' } + It 'scales to MB' { Format-FileSize (5MB) | Should -Match '^5[.,]00 MB$' } + It 'scales to GB' { Format-FileSize (2GB) | Should -Match '^2[.,]00 GB$' } + It 'scales to TB' { Format-FileSize (3TB) | Should -Match '^3[.,]00 TB$' } +} + +Describe 'Write-WsLog' { + It 'writes a stamped line to the log file' { + $tmp = Join-Path $env:TEMP ("wslog_{0}.log" -f [guid]::NewGuid().ToString('N')) + Write-WsLog -Message 'hello world' -Level 'Info' -LogPath $tmp + $tmp | Should -Exist + (Get-Content $tmp -Raw) | Should -Match '\[Info\] hello world' + Remove-Item $tmp -ErrorAction SilentlyContinue + } + It 'does not throw when no LogPath is given' { + { Write-WsLog -Message 'console only' -Level 'Warning' } | Should -Not -Throw + } +} + +Describe 'New-WinSeniorRestorePoint' { + BeforeEach { + $script:captured = [System.Collections.Generic.List[string]]::new() + $script:logger = { param($m, $l) $script:captured.Add("$l|$m") }.GetNewClosure() + } + + It 'is a no-op under -WhatIf and never checkpoints' { + Mock Checkpoint-Computer { } + $WhatIfPreference = $true + $r = New-WinSeniorRestorePoint -Description 'test' -LogAction $script:logger + $r | Should -Be 'WhatIf' + ($script:captured -join "`n") | Should -Match 'would create a System Restore point' + Should -Invoke Checkpoint-Computer -Times 0 -Exactly + } + + It 'returns Created and checkpoints once on success' { + Mock New-ItemProperty { } + Mock Enable-ComputerRestore { } + Mock Checkpoint-Computer { } + $WhatIfPreference = $false + $r = New-WinSeniorRestorePoint -Description 'test' -LogAction $script:logger + $r | Should -Be 'Created' + Should -Invoke Checkpoint-Computer -Times 1 -Exactly + ($script:captured -join "`n") | Should -Match 'System Restore point created' + } + + It 'returns Failed when the checkpoint throws' { + Mock New-ItemProperty { } + Mock Enable-ComputerRestore { } + Mock Checkpoint-Computer { throw 'protection off' } + $WhatIfPreference = $false + $r = New-WinSeniorRestorePoint -Description 'test' -LogAction $script:logger + $r | Should -Be 'Failed' + ($script:captured -join "`n") | Should -Match 'Restore point not created' + } +} From 5f9779c085402f1ba056ac70ee08576ea7fa1e17 Mon Sep 17 00:00:00 2001 From: denfry Date: Wed, 24 Jun 2026 09:31:57 +0300 Subject: [PATCH 2/6] docs: spec for unified report + scheduled-task installer (#2) Co-Authored-By: Claude Opus 4.8 (1M context) --- ...24-winsenior-report-and-schedule-design.md | 136 ++++++++++++++++++ 1 file changed, 136 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-24-winsenior-report-and-schedule-design.md diff --git a/docs/superpowers/specs/2026-06-24-winsenior-report-and-schedule-design.md b/docs/superpowers/specs/2026-06-24-winsenior-report-and-schedule-design.md new file mode 100644 index 0000000..86a304f --- /dev/null +++ b/docs/superpowers/specs/2026-06-24-winsenior-report-and-schedule-design.md @@ -0,0 +1,136 @@ +# WinSenior #2 — Unified report + scheduled-task installer + +**Date:** 2026-06-24 +**Status:** Approved +**Branch:** tui-menu + +## Goal + +Two maintenance-suite improvements that build on the new `WinSenior.Common.ps1` +shared library: + +1. **Unified JSON report** across all three engines (cleanup / optimize / repair). + All three already accept `-ReportPath`, but each emits a differently shaped + document. Converge them on one envelope so a parser, dashboard, or fleet tool + reads every engine the same way. +2. **Scheduled-task installer** so recurring maintenance can be set up with one + command instead of hand-written `schtasks` / Task Scheduler clicks. + +## Part A — Unified report + +### Shared helper (in `WinSenior.Common.ps1`) + +```powershell +function Get-WinSeniorVersion { '6.0.0' } # single source of the version string + +function Write-WinSeniorReport { + param( + [string]$ReportPath, + [ValidateSet('Cleanup','Optimize','Repair')][string]$Engine, + [hashtable]$Summary = @{}, + $Items = @(), + [bool]$RestorePoint, + [datetime]$StartTime, + [scriptblock]$LogAction + ) + if (-not $ReportPath) { return } + $report = [ordered]@{ + Tool = 'WinSenior' + Version = (Get-WinSeniorVersion) + Engine = $Engine + Host = $env:COMPUTERNAME + Timestamp = (Get-Date).ToString('s') + Mode = if (Test-WhatIfMode) { 'DryRun' } else { 'Live' } + RestorePoint = [bool]$RestorePoint + DurationSec = if ($StartTime) { [math]::Round(((Get-Date) - $StartTime).TotalSeconds, 1) } else { $null } + Summary = $Summary + Items = @($Items) + } + try { + ($report | ConvertTo-Json -Depth 6) | Set-Content -Path $ReportPath -Encoding UTF8 -WhatIf:$false + if ($LogAction) { & $LogAction "JSON report written: $ReportPath" 'Info' } + } catch { + if ($LogAction) { & $LogAction "Could not write report: $($_.Exception.Message)" 'Warning' } + } +} +``` + +### Envelope + +Common top level for every engine: `Tool, Version, Engine, Host, Timestamp, +Mode, RestorePoint, DurationSec`, then engine-specific `Summary` (counters) and +`Items` (per-unit list). + +| Engine | `Summary` keys | `Items` | +|--------|----------------|---------| +| Cleanup | `TotalBytes, TotalFreed, TotalFiles, TotalErrors` | `$script:Stats` | +| Optimize | `Applied, Skipped, Errors, Manifest` | `$script:Stats` | +| Repair | `Fixed, FixErrors, Reboot` | `$script:Results` | + +Each engine's `Write-*Report` shrinks to a single call to the helper, passing its +`$script:RestorePointMade`, `$script:StartTime`, and a `-LogAction` that forwards +to its own logger. The `if (-not $ReportPath) { return }` guard moves into the +helper. + +**Breaking change (accepted):** the old top-level fields of `clean.json` / +`repair.json` / the optimize report move under `Summary`, and `Tasks` / `Results` +/ `Tweaks` are renamed to `Items`. Chosen deliberately for cross-engine +consistency. + +## Part B — Scheduled-task installer + +New dot-sourced library `WinSenior.Schedule.ps1`, following the UI library's +pure-core + thin-wrapper pattern. + +- **`Get-WinSeniorScheduleSpec -Root -ReportDir `** — pure function + returning an array of task specs. Each spec: `Name, TaskPath, Execute, + Argument, Cadence ('Weekly'|'Monthly'), Day, Time`. Unit-testable with no + registration side effect. +- **`Install-WinSeniorSchedule`** — turns each spec into a `Register-ScheduledTask` + (action + trigger + SYSTEM principal at RunLevel Highest), `-Force` to replace. + Weekly trigger via `New-ScheduledTaskTrigger -Weekly`; monthly trigger via the + CIM class `MSFT_TaskMonthlyTrigger` (no `-Monthly` on the cmdlet). +- **`Remove-WinSeniorSchedule`** — `Unregister-ScheduledTask` for each spec name. + +### Tasks (folder `\WinSenior\`, principal SYSTEM, RunLevel Highest) + +| Task | Cadence | Command | +|------|---------|---------| +| `WinSenior Weekly Cleanup` | Weekly, Sun 03:00 | `Cleanup-Windows-Senior.ps1 -Unattended -NoRestorePoint -SkipOptimization -ReportPath \cleanup.json` | +| `WinSenior Monthly Health Scan` | Monthly, day 1 03:30 | `Repair-Windows-Senior.ps1 -ScanOnly -Unattended -ReportPath \repair.json` | + +`ReportDir` defaults to `%ProgramData%\WinSenior\reports`. The scheduled run +overwrites a stable filename (the engines already timestamp inside the report); +this avoids unbounded report accumulation. + +### WinSenior.ps1 wiring + +New switches `-InstallSchedule` and `-RemoveSchedule`. Both self-elevate (already +done), dot-source `WinSenior.Schedule.ps1`, perform the action, print a summary, +and exit before the menu loop. A menu entry can come later; the CLI switch is the +automation-friendly surface now. + +## Testing + +- `tests/WinSenior.Common.Tests.ps1` — add a `Write-WinSeniorReport` block: + writes to a temp file, round-trips the JSON, asserts envelope fields and that + no file is written without `-ReportPath`. +- `tests/WinSenior.Schedule.Tests.ps1` — new: asserts `Get-WinSeniorScheduleSpec` + returns two specs, names/cadences are correct, every `Argument` contains + `-Unattended`, the report path sits under the given `ReportDir`, and the repair + task is scan-only. +- CI already globs root `*.ps1` and `./tests`, so the new library and test are + picked up automatically. + +## Verification limits + +`Register-ScheduledTask` / `Unregister-ScheduledTask` mutate the live system and +cannot be exercised safely in the dev sandbox. Part B is verified by parse-check +plus the pure-spec unit test; functional install/remove is left to a real machine. +Part A is fully verifiable locally by dot-sourcing and writing to a temp file. + +## Out of scope + +- Russian UI localization (separate item). +- A TUI menu entry for scheduling (CLI switch only for now). +- Distribution / signing / git hygiene (item #4). From 99f867bb0e7d4bad3024fba971da60e618437706 Mon Sep 17 00:00:00 2001 From: denfry Date: Wed, 24 Jun 2026 09:44:45 +0300 Subject: [PATCH 3/6] feat(report,schedule): unified report envelope + scheduled-task installer Part A - unified report. All three engines' -ReportPath now write one envelope via the new Write-WinSeniorReport helper in Common: Tool/Version/Engine/Host/Timestamp/Mode/RestorePoint/DurationSec + engine-specific Summary{} and Items[]. Each Write-*Report shrinks to a single call. Fixes a latent trap: @() throws 'Argument types do not match' on the Generic.List[object] the engines pass, so the helper casts via [object[]]. Part B - scheduled-task installer. New dot-sourced WinSenior.Schedule.ps1 with a pure planner (Get-WinSeniorScheduleSpec) plus Install-/Remove-WinSeniorSchedule. WinSenior.ps1 gains -InstallSchedule / -RemoveSchedule (fire-and-exit). Registers a weekly unattended cleanup and a monthly read-only health scan under \WinSenior\, reports to %ProgramData%\WinSenior\reports. Weekly trigger via the cmdlet; monthly via the MSFT_TaskMonthlyTrigger CIM class (MonthOfYear is singular and required). Tests: report round-trip + envelope assertions in Common.Tests; pure-spec coverage in the new Schedule.Tests. Verified by dot-sourcing under pwsh 7 and PS 5.1 (all three engines' reports + both trigger builds); live task registration is left to a real machine. README documents both. Co-Authored-By: Claude Opus 4.8 (1M context) --- Cleanup-Windows-Senior.ps1 | 26 ++--- Optimize-Windows-Senior.ps1 | 26 ++--- README.md | 36 +++++++ Repair-Windows-Senior.ps1 | 22 ++--- WinSenior.Common.ps1 | 44 +++++++++ WinSenior.Schedule.ps1 | 150 +++++++++++++++++++++++++++++ WinSenior.ps1 | 24 ++++- tests/WinSenior.Common.Tests.ps1 | 27 ++++++ tests/WinSenior.Schedule.Tests.ps1 | 47 +++++++++ 9 files changed, 355 insertions(+), 47 deletions(-) create mode 100644 WinSenior.Schedule.ps1 create mode 100644 tests/WinSenior.Schedule.Tests.ps1 diff --git a/Cleanup-Windows-Senior.ps1 b/Cleanup-Windows-Senior.ps1 index 5b836f7..e23224b 100644 --- a/Cleanup-Windows-Senior.ps1 +++ b/Cleanup-Windows-Senior.ps1 @@ -760,22 +760,16 @@ function Show-CleanupSummary { } function Write-CleanupReport { - if (-not $ReportPath) { return } - $report = [pscustomobject]@{ - Timestamp = (Get-Date).ToString('s') - Mode = if (Test-WhatIfMode) { 'DryRun' } else { 'Live' } - RestorePoint = $script:RestorePointMade - TotalBytes = $script:TotalBytes - TotalFreed = (Format-FileSize $script:TotalBytes) - TotalFiles = $script:TotalFiles - TotalErrors = $script:TotalErrors - DurationSec = [math]::Round(((Get-Date) - $script:StartTime).TotalSeconds, 1) - Tasks = $script:Stats - } - try { - $report | ConvertTo-Json -Depth 5 | Set-Content -Path $ReportPath -Encoding UTF8 -WhatIf:$false - Write-CleanupLog "JSON report written: $ReportPath" 'Info' - } catch { Write-CleanupLog "Could not write report: $($_.Exception.Message)" 'Warning' } + Write-WinSeniorReport -ReportPath $ReportPath -Engine 'Cleanup' ` + -RestorePoint $script:RestorePointMade -StartTime $script:StartTime ` + -Summary @{ + TotalBytes = $script:TotalBytes + TotalFreed = (Format-FileSize $script:TotalBytes) + TotalFiles = $script:TotalFiles + TotalErrors = $script:TotalErrors + } ` + -Items $script:Stats ` + -LogAction { param($m, $l) Write-CleanupLog $m $l } } # ===================================================================== diff --git a/Optimize-Windows-Senior.ps1 b/Optimize-Windows-Senior.ps1 index 77a03bf..5f66fb7 100644 --- a/Optimize-Windows-Senior.ps1 +++ b/Optimize-Windows-Senior.ps1 @@ -703,22 +703,16 @@ function Show-OptSummary { function Write-OptReport { param([string]$ManifestFile) - if (-not $ReportPath) { return } - $report = [pscustomobject]@{ - Timestamp = (Get-Date).ToString('s') - Mode = if (Test-WhatIfMode) { 'DryRun' } else { 'Live' } - RestorePoint = $script:RestorePointMade - Applied = $script:Applied - Skipped = $script:Skipped - Errors = $script:Errors - Manifest = $ManifestFile - DurationSec = [math]::Round(((Get-Date) - $script:StartTime).TotalSeconds, 1) - Tweaks = $script:Stats - } - try { - $report | ConvertTo-Json -Depth 6 | Set-Content -Path $ReportPath -Encoding UTF8 -WhatIf:$false - Write-OptLog "JSON report written: $ReportPath" 'Info' - } catch { Write-OptLog "Could not write report: $($_.Exception.Message)" 'Warning' } + Write-WinSeniorReport -ReportPath $ReportPath -Engine 'Optimize' ` + -RestorePoint $script:RestorePointMade -StartTime $script:StartTime ` + -Summary @{ + Applied = $script:Applied + Skipped = $script:Skipped + Errors = $script:Errors + Manifest = $ManifestFile + } ` + -Items $script:Stats ` + -LogAction { param($m, $l) Write-OptLog $m $l } } # ===================================================================== diff --git a/README.md b/README.md index c33e23f..4dd259f 100644 --- a/README.md +++ b/README.md @@ -235,6 +235,42 @@ powershell.exe -ExecutionPolicy Bypass -WindowStyle Hidden ` Exit codes: `0` success, `2` administrator privileges required. +### Recurring maintenance — one command + +`WinSenior.ps1` can register the recurring tasks for you (run as Administrator; +it self-elevates): + +```powershell +.\WinSenior.ps1 -InstallSchedule # register the maintenance tasks +.\WinSenior.ps1 -RemoveSchedule # remove them +``` + +This creates two Task Scheduler jobs under `\WinSenior\`, running as SYSTEM: + +| Task | When | What | +|------|------|------| +| **WinSenior Weekly Cleanup** | Weekly, Sun 03:00 | unattended cleanup, no restore point, skips slow SFC/DISM | +| **WinSenior Monthly Health Scan** | Monthly, 1st 03:30 | read-only health scan (changes nothing) | + +Both write JSON reports to `%ProgramData%\WinSenior\reports`. + +### Report format + +Every engine's `-ReportPath` writes the same envelope, so one parser reads them all: + +```json +{ + "Tool": "WinSenior", "Version": "6.0.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 }, + "Items": [ /* per-task / per-tweak / per-check detail */ ] +} +``` + +`Summary` holds the engine's counters (cleanup: freed bytes/files; optimize: +applied/skipped; repair: fixed/reboot), and `Items` the per-unit breakdown. + ## Tests The `tests\*.Tests.ps1` files cover the pure logic of all three engines — selection, the diff --git a/Repair-Windows-Senior.ps1 b/Repair-Windows-Senior.ps1 index 776b51f..86de4eb 100644 --- a/Repair-Windows-Senior.ps1 +++ b/Repair-Windows-Senior.ps1 @@ -397,19 +397,15 @@ function Show-ScanReport { } function Write-RepReport { - if (-not $ReportPath) { return } - $report = [pscustomobject]@{ - Timestamp = (Get-Date).ToString('s') - Mode = if (Test-WhatIfMode) { 'DryRun' } else { 'Live' } - Fixed = $script:Fixed - FixErrors = $script:FixErrors - Reboot = $script:RebootNeeded - Results = $script:Results - } - try { - $report | ConvertTo-Json -Depth 6 | Set-Content -Path $ReportPath -Encoding UTF8 -WhatIf:$false - Write-RepLog "JSON report written: $ReportPath" 'Info' - } catch { Write-RepLog "Could not write report: $($_.Exception.Message)" 'Warning' } + Write-WinSeniorReport -ReportPath $ReportPath -Engine 'Repair' ` + -RestorePoint $script:RestorePointMade -StartTime $script:StartTime ` + -Summary @{ + Fixed = $script:Fixed + FixErrors = $script:FixErrors + Reboot = $script:RebootNeeded + } ` + -Items $script:Results ` + -LogAction { param($m, $l) Write-RepLog $m $l } } # ===================================================================== diff --git a/WinSenior.Common.ps1 b/WinSenior.Common.ps1 index 6778c0a..676846d 100644 --- a/WinSenior.Common.ps1 +++ b/WinSenior.Common.ps1 @@ -107,3 +107,47 @@ function New-WinSeniorRestorePoint { return 'Failed' } } + +# ===================================================================== +# REPORTING +# One envelope for every engine so a parser reads them all the same. +# Common top level: Tool/Version/Engine/Host/Timestamp/Mode/RestorePoint/ +# DurationSec; engine-specific counters go in Summary, the per-unit list +# in Items. No-op without -ReportPath. +# ===================================================================== +function Get-WinSeniorVersion { '6.0.0' } + +function Write-WinSeniorReport { + param( + [string]$ReportPath, + [Parameter(Mandatory)][ValidateSet('Cleanup', 'Optimize', 'Repair')][string]$Engine, + [hashtable]$Summary = @{}, + $Items = @(), + [bool]$RestorePoint, + [datetime]$StartTime, + [scriptblock]$LogAction + ) + if (-not $ReportPath) { return } + # 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 } + $report = [ordered]@{ + Tool = 'WinSenior' + Version = (Get-WinSeniorVersion) + Engine = $Engine + Host = $env:COMPUTERNAME + Timestamp = (Get-Date).ToString('s') + Mode = if (Test-WhatIfMode) { 'DryRun' } else { 'Live' } + RestorePoint = [bool]$RestorePoint + DurationSec = if ($StartTime) { [math]::Round(((Get-Date) - $StartTime).TotalSeconds, 1) } else { $null } + Summary = $Summary + Items = $itemArr + } + try { + ($report | ConvertTo-Json -Depth 6) | Set-Content -Path $ReportPath -Encoding UTF8 -WhatIf:$false + if ($LogAction) { & $LogAction "JSON report written: $ReportPath" 'Info' } + } + catch { + if ($LogAction) { & $LogAction "Could not write report: $($_.Exception.Message)" 'Warning' } + } +} diff --git a/WinSenior.Schedule.ps1 b/WinSenior.Schedule.ps1 new file mode 100644 index 0000000..eafecba --- /dev/null +++ b/WinSenior.Schedule.ps1 @@ -0,0 +1,150 @@ +<# +.SYNOPSIS + Scheduled-task installer for WinSenior recurring maintenance. + +.DESCRIPTION + Dot-sourced by WinSenior.ps1. Registers two Task Scheduler jobs under the + \WinSenior\ folder so maintenance runs unattended on a cadence: + + - WinSenior Weekly Cleanup : weekly, Sunday 03:00 + - WinSenior Monthly Health Scan : monthly, day 1 03:30 (scan only) + + The work is split into a pure planner and thin registration wrappers, mirroring + WinSenior.UI.ps1: Get-WinSeniorScheduleSpec returns plain spec objects that can + be unit-tested with no side effect, while Install/Remove turn those specs into + Register-ScheduledTask / Unregister-ScheduledTask calls. + + Source stays pure ASCII so it loads identically under Windows PowerShell 5.1. + +.NOTES + Author : denfry (https://github.com/denfry/WindowsCleaner) + Version : 6.0.0 +#> + +$script:WinSeniorTaskPath = '\WinSenior\' + +# ===================================================================== +# PURE PLANNER +# Returns one spec per scheduled task. No registration, no I/O. +# ===================================================================== +function Get-WinSeniorScheduleSpec { + param( + [Parameter(Mandatory)][string]$Root, + [string]$ReportDir = "$env:ProgramData\WinSenior\reports" + ) + $common = '-NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File' + + [pscustomobject]@{ + Name = 'WinSenior Weekly Cleanup' + TaskPath = $script:WinSeniorTaskPath + Description = 'WinSenior: weekly unattended disk cleanup (no restore point, no slow SFC/DISM).' + Execute = 'powershell.exe' + Argument = ('{0} "{1}" -Unattended -NoRestorePoint -SkipOptimization -ReportPath "{2}"' -f + $common, (Join-Path $Root 'Cleanup-Windows-Senior.ps1'), (Join-Path $ReportDir 'cleanup.json')) + Cadence = 'Weekly' + Day = 'Sunday' + Time = '03:00' + } + + [pscustomobject]@{ + Name = 'WinSenior Monthly Health Scan' + TaskPath = $script:WinSeniorTaskPath + Description = 'WinSenior: monthly read-only health scan (changes nothing, writes a JSON report).' + Execute = 'powershell.exe' + Argument = ('{0} "{1}" -ScanOnly -Unattended -ReportPath "{2}"' -f + $common, (Join-Path $Root 'Repair-Windows-Senior.ps1'), (Join-Path $ReportDir 'repair.json')) + Cadence = 'Monthly' + Day = 1 + Time = '03:30' + } +} + +# ===================================================================== +# TRIGGER BUILDER +# Weekly via the cmdlet; monthly via the CIM class (New-ScheduledTaskTrigger +# has no -Monthly). DaysOfMonth and MonthsOfYear are bitmasks. +# ===================================================================== +function New-WinSeniorTrigger { + param([Parameter(Mandatory)]$Spec) + $at = [datetime]::ParseExact($Spec.Time, 'HH:mm', $null) + switch ($Spec.Cadence) { + 'Weekly' { + return New-ScheduledTaskTrigger -Weekly -DaysOfWeek $Spec.Day -At $at + } + 'Monthly' { + # New-ScheduledTaskTrigger has no -Monthly, so build the CIM trigger. + # MSFT_TaskMonthlyTrigger: DaysOfMonth and MonthOfYear (singular) are + # bitmasks; MonthOfYear MUST be set or the task never fires. + $cls = Get-CimClass -ClassName MSFT_TaskMonthlyTrigger ` + -Namespace 'Root/Microsoft/Windows/TaskScheduler' + $t = New-CimInstance -CimClass $cls -ClientOnly + $t.DaysOfMonth = 1 -shl ([int]$Spec.Day - 1) # day 1 -> bit 0 -> 1 + $t.MonthOfYear = 0xFFF # all 12 months + $t.StartBoundary = $at.ToString('yyyy-MM-ddTHH:mm:ss') + $t.Enabled = $true + return $t + } + default { throw "Unknown cadence: $($Spec.Cadence)" } + } +} + +# ===================================================================== +# INSTALL / REMOVE +# ===================================================================== +function Install-WinSeniorSchedule { + param( + [Parameter(Mandatory)][string]$Root, + [string]$ReportDir = "$env:ProgramData\WinSenior\reports", + [scriptblock]$LogAction = { param($m, $l) Write-Host $m } + ) + if (-not (Test-Path $ReportDir)) { + New-Item -ItemType Directory -Path $ReportDir -Force -ErrorAction SilentlyContinue | Out-Null + } + $principal = New-ScheduledTaskPrincipal -UserId 'SYSTEM' -LogonType ServiceAccount -RunLevel Highest + $settings = New-ScheduledTaskSettingsSet -StartWhenAvailable ` + -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -ExecutionTimeLimit (New-TimeSpan -Hours 2) + + $ok = 0 + foreach ($spec in (Get-WinSeniorScheduleSpec -Root $Root -ReportDir $ReportDir)) { + try { + $action = New-ScheduledTaskAction -Execute $spec.Execute -Argument $spec.Argument + $trigger = New-WinSeniorTrigger -Spec $spec + Register-ScheduledTask -TaskName $spec.Name -TaskPath $spec.TaskPath ` + -Action $action -Trigger $trigger -Principal $principal -Settings $settings ` + -Description $spec.Description -Force -ErrorAction Stop | Out-Null + & $LogAction ("Registered: {0} ({1})" -f $spec.Name, $spec.Cadence) 'Success' + $ok++ + } + catch { + & $LogAction ("Failed to register '{0}': {1}" -f $spec.Name, $_.Exception.Message) 'Error' + } + } + & $LogAction ("Scheduled tasks installed: {0}. Reports go to {1}" -f $ok, $ReportDir) 'Info' + return $ok +} + +function Remove-WinSeniorSchedule { + param( + [string]$Root = $PSScriptRoot, + [scriptblock]$LogAction = { param($m, $l) Write-Host $m } + ) + $removed = 0 + foreach ($spec in (Get-WinSeniorScheduleSpec -Root $Root)) { + try { + $existing = Get-ScheduledTask -TaskName $spec.Name -TaskPath $spec.TaskPath -ErrorAction SilentlyContinue + if ($existing) { + Unregister-ScheduledTask -TaskName $spec.Name -TaskPath $spec.TaskPath -Confirm:$false -ErrorAction Stop + & $LogAction ("Removed: {0}" -f $spec.Name) 'Success' + $removed++ + } + else { + & $LogAction ("Not present: {0}" -f $spec.Name) 'Info' + } + } + catch { + & $LogAction ("Failed to remove '{0}': {1}" -f $spec.Name, $_.Exception.Message) 'Warning' + } + } + & $LogAction ("Scheduled tasks removed: {0}" -f $removed) 'Info' + return $removed +} diff --git a/WinSenior.ps1 b/WinSenior.ps1 index c26af30..dc1cb7a 100644 --- a/WinSenior.ps1 +++ b/WinSenior.ps1 @@ -26,7 +26,11 @@ param( # Do not try to relaunch elevated; run with whatever rights we have. [switch]$NoElevate, # Force ASCII-only glyphs (for terminals that can't render box-drawing chars). - [switch]$Plain + [switch]$Plain, + # Register the recurring maintenance scheduled tasks, then exit. + [switch]$InstallSchedule, + # Remove the recurring maintenance scheduled tasks, then exit. + [switch]$RemoveSchedule ) try { [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 } catch { } @@ -40,8 +44,9 @@ $script:CleanupScript = Join-Path $script:Root 'Cleanup-Windows-Senior.ps1' $script:OptimizeScript = Join-Path $script:Root 'Optimize-Windows-Senior.ps1' $script:RepairScript = Join-Path $script:Root 'Repair-Windows-Senior.ps1' $script:UiScript = Join-Path $script:Root 'WinSenior.UI.ps1' +$script:ScheduleScript = Join-Path $script:Root 'WinSenior.Schedule.ps1' -foreach ($s in @($script:CommonScript, $script:CleanupScript, $script:OptimizeScript, $script:RepairScript, $script:UiScript)) { +foreach ($s in @($script:CommonScript, $script:CleanupScript, $script:OptimizeScript, $script:RepairScript, $script:UiScript, $script:ScheduleScript)) { if (-not (Test-Path $s)) { Write-Host "Engine not found: $s" -ForegroundColor Red Write-Host 'Keep WinSenior.ps1 next to the engine scripts and WinSenior.Common.ps1.' -ForegroundColor Yellow @@ -78,6 +83,21 @@ if (-not (Test-AdminPrivileges)) { . $script:UiScript Initialize-UiTheme -Plain:$Plain +# Load the scheduled-task installer library. +. $script:ScheduleScript + +# Schedule management is a fire-and-exit action, taken before the interactive menu. +if ($InstallSchedule) { + Install-WinSeniorSchedule -Root $script:Root ` + -LogAction { param($m, $l) Write-WsLog -Message $m -Level $l } | Out-Null + exit 0 +} +if ($RemoveSchedule) { + Remove-WinSeniorSchedule -Root $script:Root ` + -LogAction { param($m, $l) Write-WsLog -Message $m -Level $l } | Out-Null + exit 0 +} + # ===================================================================== # SELECTION STATE # ===================================================================== diff --git a/tests/WinSenior.Common.Tests.ps1 b/tests/WinSenior.Common.Tests.ps1 index 25801f5..eb66b82 100644 --- a/tests/WinSenior.Common.Tests.ps1 +++ b/tests/WinSenior.Common.Tests.ps1 @@ -73,3 +73,30 @@ Describe 'New-WinSeniorRestorePoint' { ($script:captured -join "`n") | Should -Match 'Restore point not created' } } + +Describe 'Write-WinSeniorReport' { + It 'is a no-op without -ReportPath' { + { Write-WinSeniorReport -Engine 'Cleanup' -Summary @{} } | Should -Not -Throw + } + It 'writes a unified envelope that round-trips' { + $tmp = Join-Path $env:TEMP ("wsrep_{0}.json" -f [guid]::NewGuid().ToString('N')) + $start = (Get-Date).AddSeconds(-5) + Write-WinSeniorReport -ReportPath $tmp -Engine 'Cleanup' ` + -RestorePoint $true -StartTime $start ` + -Summary @{ TotalFiles = 7; TotalBytes = 1024 } ` + -Items @([pscustomobject]@{ Id = 'a' }) + $tmp | Should -Exist + $obj = Get-Content $tmp -Raw | ConvertFrom-Json + $obj.Tool | Should -Be 'WinSenior' + $obj.Engine | Should -Be 'Cleanup' + $obj.Version | Should -Be (Get-WinSeniorVersion) + $obj.RestorePoint | Should -BeTrue + $obj.DurationSec | Should -BeGreaterThan 0 + $obj.Summary.TotalFiles | Should -Be 7 + @($obj.Items).Count | Should -Be 1 + Remove-Item $tmp -ErrorAction SilentlyContinue + } + It 'rejects an unknown engine' { + { Write-WinSeniorReport -ReportPath 'x' -Engine 'Bogus' } | Should -Throw + } +} diff --git a/tests/WinSenior.Schedule.Tests.ps1 b/tests/WinSenior.Schedule.Tests.ps1 new file mode 100644 index 0000000..f1f02bf --- /dev/null +++ b/tests/WinSenior.Schedule.Tests.ps1 @@ -0,0 +1,47 @@ +# Pester tests for WinSenior.Schedule.ps1 +# Run: Invoke-Pester -Path .\tests +# Covers the pure planner Get-WinSeniorScheduleSpec only - no task is registered, +# so the tests are safe and deterministic. Install/Remove call Register-/Unregister- +# ScheduledTask and are verified on a real machine. + +BeforeAll { + $script:Sut = Join-Path $PSScriptRoot '..\WinSenior.Schedule.ps1' + . $script:Sut + $script:Spec = Get-WinSeniorScheduleSpec -Root 'C:\WS' -ReportDir 'C:\WS\reports' +} + +Describe 'Get-WinSeniorScheduleSpec' { + It 'returns two task specs' { + @($script:Spec).Count | Should -Be 2 + } + It 'places both tasks under the \WinSenior\ folder' { + foreach ($s in $script:Spec) { $s.TaskPath | Should -Be '\WinSenior\' } + } + It 'launches powershell.exe with bypass and a hidden window' { + foreach ($s in $script:Spec) { + $s.Execute | Should -Be 'powershell.exe' + $s.Argument | Should -Match '-ExecutionPolicy Bypass' + $s.Argument | Should -Match '-WindowStyle Hidden' + } + } + It 'points every report under the given ReportDir' { + foreach ($s in $script:Spec) { + $s.Argument | Should -Match ([regex]::Escape('C:\WS\reports')) + } + } + It 'runs the cleanup weekly, unattended, with no restore point' { + $c = $script:Spec | Where-Object { $_.Name -match 'Cleanup' } + $c.Cadence | Should -Be 'Weekly' + $c.Day | Should -Be 'Sunday' + $c.Argument | Should -Match '-Unattended' + $c.Argument | Should -Match '-NoRestorePoint' + $c.Argument | Should -Match 'Cleanup-Windows-Senior\.ps1' + } + It 'runs the health scan monthly and read-only' { + $r = $script:Spec | Where-Object { $_.Name -match 'Scan' } + $r.Cadence | Should -Be 'Monthly' + $r.Day | Should -Be 1 + $r.Argument | Should -Match '-ScanOnly' + $r.Argument | Should -Match 'Repair-Windows-Senior\.ps1' + } +} From fe8d8eea10eb9d4bb9e05fcbab188fcdf291571e Mon Sep 17 00:00:00 2001 From: denfry Date: Wed, 24 Jun 2026 10:24:54 +0300 Subject: [PATCH 4/6] feat(coverage): expand engines to best-in-class (cleanup 63, optimize 49, repair 25) Competitive gap audit vs CCleaner/BleachBit/Wise/Tron, O&O ShutUp10/privacy.sexy/ Win11Debloat/Sophia/WinUtil, and built-in troubleshooters. Every proposed gap was grounded against the live registry first, so already-covered targets were not duplicated. Cleanup 57->63: win-caches, ps-modulecache, rdp-cache, livekernel, srum-db (stops DPS), eventtranscript (stops DiagTrack); extended shadercache (OptixCache/NV_Cache) and windows-old ($WinREAgent). Optimize 29->49: modern Win11 privacy/debloat the tool was missing - Recall & Click-to-Do, Copilot, tailored-ads, Spotlight policy, inking/typing & speech telemetry, CEIP/Appraiser/WER, Delivery-Optimization P2P, OneDrive pre-signin, cloud-clipboard(off); combined taskbar/Start ad-surface debloat, SCOOBE nag, show-file-extensions(off), classic context menu(off), Teredo(off), Fast-Startup(off). All reversible; none touch Defender RTP / Windows Update / Edge / Store. Repair 13->25: restore-enabled, hosts-integrity, proxy/PAC hijack, firewall-state, Defender signatures (update-only), SMBv1, critical scheduled-task health, BITS, print spooler, Store health, plus report-only SSD wear and crash history. Scans are locale-safe (CIM/registry/enums, Get-WinEvent in try/catch) and read-only; Get-AppxPackage wrapped in try/catch for pwsh 7. Tests: 'New coverage' Describe blocks added to all three suites. Verified by dot-sourcing under pwsh 7 and Windows PowerShell 5.1 (registries build, IDs unique, every new repair scan runs without throwing). README counts updated. Co-Authored-By: Claude Opus 4.8 (1M context) --- Cleanup-Windows-Senior.ps1 | 17 +++ Optimize-Windows-Senior.ps1 | 93 ++++++++++++ README.md | 13 +- Repair-Windows-Senior.ps1 | 188 ++++++++++++++++++++++++ tests/Cleanup-Windows-Senior.Tests.ps1 | 12 ++ tests/Optimize-Windows-Senior.Tests.ps1 | 23 +++ tests/Repair-Windows-Senior.Tests.ps1 | 23 +++ 7 files changed, 365 insertions(+), 4 deletions(-) diff --git a/Cleanup-Windows-Senior.ps1 b/Cleanup-Windows-Senior.ps1 index e23224b..068a1ac 100644 --- a/Cleanup-Windows-Senior.ps1 +++ b/Cleanup-Windows-Senior.ps1 @@ -432,6 +432,9 @@ function Get-CleanupTaskRegistry { '\go\pkg\mod\cache\download\*', '\AppData\Local\go-build\*', '\AppData\Local\Pub\Cache\*') + New-CleanupTask ps-modulecache 'PowerShell module analysis cache' DevTools Safe -Paths @( + '\AppData\Local\Microsoft\Windows\PowerShell\ModuleAnalysisCache', + '\AppData\Local\Microsoft\Windows\PowerShell\StartupProfileData-*') # ---------------- Apps / messengers (Safe) ---------------- New-CleanupTask appcache 'Windows app cache' Apps Safe -Paths @( @@ -466,6 +469,8 @@ function Get-CleanupTaskRegistry { '\AppData\Roaming\Adobe\Common\Media Cache\*', '\AppData\Roaming\Adobe\Common\Media Cache Files\*', '\AppData\Local\Adobe\CameraRaw\Cache\*') + New-CleanupTask rdp-cache 'Remote Desktop client bitmap cache' Apps Safe -Paths @( + '\AppData\Local\Microsoft\Terminal Server Client\Cache\*') # ---------------- Games (launcher caches, Safe) ---------------- New-CleanupTask game-caches 'Game launcher caches (Steam/Epic/Battle.net/GOG)' Games Safe -Paths @( @@ -492,7 +497,11 @@ function Get-CleanupTaskRegistry { '\AppData\Local\D3DSCache\*', '\AppData\Local\NVIDIA\DXCache\*', '\AppData\Local\NVIDIA\GLCache\*', + '\AppData\Local\NVIDIA\OptixCache\*', + '\AppData\Local\NVIDIA Corporation\NV_Cache\*', '\AppData\Local\AMD\DxCache\*') + New-CleanupTask win-caches 'Windows per-user app caches' System Safe -Paths @( + '\AppData\Local\Microsoft\Windows\Caches\*') New-CleanupTask gpu-leftovers 'GPU driver installer leftovers (NVIDIA/AMD)' System Safe -Paths @( 'NVIDIA\*', 'AMD\*', @@ -579,6 +588,13 @@ function Get-CleanupTaskRegistry { '%WINDIR%\inf\setupapi.dev*.log', '%WINDIR%\inf\setupapi.setup*.log', '%ProgramData%\Microsoft\Windows Defender\Scans\History\Results\*') + New-CleanupTask livekernel 'Live kernel crash dumps (driver/GPU TDR)' Logs Safe -Paths @( + '%WINDIR%\LiveKernelReports\*.dmp') + New-CleanupTask srum-db 'Network/app usage telemetry DB (SRUM)' Logs Moderate ` + -StopServices @('DPS') -Paths @('%WINDIR%\System32\sru\*') + New-CleanupTask eventtranscript 'Diagnostic telemetry database (EventTranscript)' Logs Moderate ` + -StopServices @('DiagTrack') -Paths @( + '%ProgramData%\Microsoft\Diagnosis\EventTranscript\*') New-CleanupTask crashdumps 'Crash & memory dumps' Logs Moderate -Paths @( '%WINDIR%\Minidump\*', '%WINDIR%\MEMORY.DMP', @@ -626,6 +642,7 @@ function Get-CleanupTaskRegistry { "$env:SystemDrive\Windows.old", "$env:SystemDrive\`$Windows.~BT", "$env:SystemDrive\`$Windows.~WS", + "$env:SystemDrive\`$WinREAgent", "$env:WINDIR\Downloaded Program Files")) { $r = Remove-ProtectedFolder -FullPath $folder -Description 'upgrade leftovers' if ($r) { $total.Bytes += $r.Bytes; $total.Errors += $r.Errors } diff --git a/Optimize-Windows-Senior.ps1 b/Optimize-Windows-Senior.ps1 index 5f66fb7..628b360 100644 --- a/Optimize-Windows-Senior.ps1 +++ b/Optimize-Windows-Senior.ps1 @@ -240,6 +240,10 @@ function Get-OptimizationTweakRegistry { -Path 'HKCU:\Software\Microsoft\Windows\CurrentVersion\BackgroundAccessApplications' ` -Values @((RegVal 'GlobalUserDisabled' DWord 1)) ` -Explain 'Stops UWP apps from running and updating in the background.' + 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') } ` @@ -319,6 +323,65 @@ function Get-OptimizationTweakRegistry { @{ 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.' + 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 ` + -Path 'HKCU:\Software\Policies\Microsoft\Windows\WindowsCopilot' ` + -Values @((RegVal 'TurnOffWindowsCopilot' DWord 1)) ` + -Explain 'Turns off the Windows Copilot assistant via user policy.' + 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-input 'Stop inking & typing personalization' Privacy Safe ` + -Path 'HKLM:\SOFTWARE\Policies\Microsoft\InputPersonalization' ` + -Values @( + (RegVal 'RestrictImplicitInkCollection' DWord 1), + (RegVal 'RestrictImplicitTextCollection' DWord 1), + (RegVal 'AllowInputPersonalization' DWord 0)) ` + -Explain 'Stops sampling of keystrokes/handwriting for personalization. Local dictation still works.' + New-RegTweak priv-typing 'Stop sending typing/inking data to Microsoft' Privacy Safe ` + -Path 'HKCU:\Software\Microsoft\Input\TIPC' ` + -Values @((RegVal 'Enabled' DWord 0)) ` + -Explain 'Disables the typing-insights upload channel.' + New-RegTweak priv-speech 'Decline online speech recognition' Privacy Safe ` + -Path 'HKCU:\Software\Microsoft\Speech_OneCore\Settings\OnlineSpeechPrivacy' ` + -Values @((RegVal 'HasAccepted' DWord 0)) ` + -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).' + 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)) ` + -Explain 'Stops the Application Compatibility Appraiser inventory that feeds telemetry.' + New-RegTweak priv-wer 'Disable Windows Error Reporting upload' Privacy Moderate ` + -Path 'HKLM:\SOFTWARE\Microsoft\Windows\Windows Error Reporting' ` + -Values @((RegVal 'Disabled' DWord 1)) ` + -Explain 'Blocks crash-report upload to Microsoft. Local crash logs are still created.' + New-RegTweak priv-feedback 'Never ask for Windows feedback' Privacy Safe ` + -Path 'HKCU:\Software\Microsoft\Siuf\Rules' ` + -Values @((RegVal 'NumberOfSIUFInPeriod' DWord 0)) ` + -Explain 'Stops the periodic "rate your experience" feedback prompts.' + New-RegTweak priv-deliveryopt 'Disable Delivery Optimization P2P upload' Privacy Moderate ` + -Path 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\DeliveryOptimization' ` + -Values @((RegVal 'DODownloadMode' DWord 0)) ` + -Explain 'Stops seeding update payloads to other PCs over the internet (HTTP-only; does not disable Windows Update).' + New-RegTweak priv-onedrive 'Block OneDrive network traffic before sign-in' Privacy Safe ` + -Path 'HKLM:\SOFTWARE\Microsoft\OneDrive' ` + -Values @((RegVal 'PreventNetworkTrafficPreUserSignIn' DWord 1)) ` + -Explain 'Stops OneDrive contacting the network before a user signs in. Does not disable OneDrive.' + New-RegTweak priv-clipboard 'Disable cloud clipboard sync' Privacy Moderate -DefaultOn $false ` + -Path 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\System' ` + -Values @((RegVal 'AllowCrossDeviceClipboard' DWord 0)) ` + -Explain 'Stops clipboard contents syncing to your Microsoft account across devices. Off by default.' # ============================================================= # DEBLOAT (UWP apps) @@ -377,6 +440,32 @@ function Get-OptimizationTweakRegistry { (RegVal 'OemPreInstalledAppsEnabled' DWord 0), (RegVal 'SubscribedContent-338388Enabled' DWord 0)) ` -Explain 'Stops the Start menu from showing suggested/promoted apps.' + 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.' + New-RegTweak debloat-scoobe 'Disable post-update setup nag (SCOOBE)' Debloat Safe ` + -Path 'HKCU:\Software\Microsoft\Windows\CurrentVersion\UserProfileEngagement' ` + -Values @((RegVal 'ScoobeSystemSettingEnabled' DWord 0)) ` + -Explain 'Stops the "Let''s finish setting up your device" full-screen prompt after updates.' + New-RegTweak ux-fileext 'Show file extensions in Explorer' Debloat Safe -DefaultOn $false ` + -Path 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Explorer\Advanced' ` + -Values @((RegVal 'HideFileExt' DWord 0)) ` + -Explain 'Shows known file extensions (security + usability). Off by default (preference).' + New-CustomTweak ux-context-menu 'Restore classic Win10 right-click menu' Debloat Safe -DefaultOn $false ` + -Explain 'Brings back the full Windows 10 context menu on Windows 11. Off by default (preference); needs an Explorer restart.' ` + -Test { Test-Path 'HKCU:\Software\Classes\CLSID\{86ca1aa0-34aa-4e8b-a509-50c905bae2a2}\InprocServer32' } ` + -Backup { @{ Existed = (Test-Path 'HKCU:\Software\Classes\CLSID\{86ca1aa0-34aa-4e8b-a509-50c905bae2a2}\InprocServer32') } } ` + -Apply { + 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 } # ============================================================= # NETWORK / GAMES @@ -397,6 +486,10 @@ function Get-OptimizationTweakRegistry { -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).' + 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.' 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.' diff --git a/README.md b/README.md index 4dd259f..1972b6e 100644 --- a/README.md +++ b/README.md @@ -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 57 targets across browsers, developer tools, apps, games, system caches, every +It cleans 63 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 -29 reversible system tweaks, a troubleshooting engine scans for 13 common problems and +49 reversible system tweaks, a troubleshooting engine scans for 25 common problems and repairs them, and a single menu ties everything together. ## One command — the menu @@ -157,7 +157,9 @@ run. A real restore point is created first as a second safety net. .\Optimize-Windows-Senior.ps1 -Undo ``` -It covers 29 tweaks across four areas: +It covers 49 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): - **Performance** — visual effects to best performance, zero menu/startup delay, High-Performance power plan, background apps off; (off by default) Ultimate plan, @@ -196,7 +198,10 @@ health report, and lets you pick which detected issues to repair. Fixes run thro .\Repair-Windows-Senior.ps1 -FixAll -IncludeHeavy -Unattended ``` -It runs 13 checks across eight categories: system image health (DISM), physical disk SMART +It runs 25 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 health, low free space, volumes flagged for chkdsk, pending reboot, Windows Update components, internet & DNS, devices with driver errors, stopped critical services, Microsoft Defender health, WMI repository consistency, time synchronization, and recent critical/error events. diff --git a/Repair-Windows-Senior.ps1 b/Repair-Windows-Senior.ps1 index 86de4eb..96d9250 100644 --- a/Repair-Windows-Senior.ps1 +++ b/Repair-Windows-Senior.ps1 @@ -330,6 +330,194 @@ function Get-DiagnosticCheckRegistry { $status = if ($ev.Count -gt 50) { 'Warn' } else { 'OK' } @{ Status = $status; Detail = "$($ev.Count) error/critical event(s) in 48h; top: $top" } } -Fix $null + + # ---------------- System / Security additions ---------------- + New-DiagnosticCheck restore-enabled 'System Restore protection' 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 { + 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' + + New-DiagnosticCheck hosts-integrity 'Hosts file integrity' Network ` + -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' } } + } ` + -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)' + + New-DiagnosticCheck proxy-hijack 'Proxy / PAC hijack' Network ` + -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' } } + } ` + -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' + + New-DiagnosticCheck firewall-state 'Windows Firewall enabled' Security ` + -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' } } + } ` + -Fix { Set-NetFirewallProfile -All -Enabled True -ErrorAction SilentlyContinue } ` + -FixRisk Moderate -FixLabel 'Re-enable all firewall profiles' + + New-DiagnosticCheck def-signatures 'Defender signatures & threats' Security ` + -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' } } + } ` + -Fix { Write-RepLog 'Updating Defender signatures...' 'Info'; if (Get-Command Update-MpSignature -ErrorAction SilentlyContinue) { Update-MpSignature -ErrorAction SilentlyContinue } } ` + -FixRisk Safe -FixLabel 'Update Defender signatures' + + New-DiagnosticCheck smb1-disabled 'SMBv1 protocol disabled' Security ` + -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' } } + } ` + -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 + } + } -FixRisk Safe -FixLabel 'Disable SMBv1' -Reboot $true + + New-DiagnosticCheck sched-task-health 'Critical scheduled tasks enabled' System ` + -Scan { + if (-not (Get-Command Get-ScheduledTask -ErrorAction SilentlyContinue)) { return @{ Status = 'Skip'; Detail = 'ScheduledTasks module unavailable' } } + $want = @( + @{ P = '\Microsoft\Windows\WindowsUpdate\'; N = 'Scheduled Start' }, + @{ P = '\Microsoft\Windows\UpdateOrchestrator\'; N = 'Schedule Scan' }, + @{ P = '\Microsoft\Windows\SystemRestore\'; N = 'SR' }, + @{ 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 } + } + $disabled = @($disabled) + if ($disabled.Count) { @{ Status = 'Warn'; Detail = ('Critical task(s) disabled: ' + ($disabled -join ', ')) } } + else { @{ Status = 'OK'; Detail = 'Monitored critical tasks are enabled' } } + } ` + -Fix { + $want = @( + @{ P = '\Microsoft\Windows\WindowsUpdate\'; N = 'Scheduled Start' }, + @{ P = '\Microsoft\Windows\UpdateOrchestrator\'; N = 'Schedule Scan' }, + @{ P = '\Microsoft\Windows\SystemRestore\'; N = 'SR' }, + @{ P = '\Microsoft\Windows\Windows Defender\'; N = 'Windows Defender Scheduled Scan' }) + 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 } + } + } -FixRisk Moderate -FixLabel 'Re-enable critical scheduled tasks (curated list)' + + New-DiagnosticCheck bits-health 'BITS transfer queue' 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 -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" } } + } ` + -Fix { Get-BitsTransfer -AllUsers -ErrorAction SilentlyContinue | Remove-BitsTransfer -ErrorAction SilentlyContinue } ` + -FixRisk Moderate -FixLabel 'Clear stuck BITS transfers' + + New-DiagnosticCheck spooler-health 'Print spooler' Services ` + -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' } } + } ` + -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' + + New-DiagnosticCheck store-health 'Microsoft Store health' 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" } } + } ` + -Fix { Write-RepLog 'Resetting Microsoft Store cache (wsreset)...' 'Info'; & wsreset.exe *>$null } ` + -FixRisk Safe -FixLabel 'Reset Store cache (wsreset)' + + New-DiagnosticCheck disk-reliability 'SSD wear & temperature' 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 ($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 ', ')) } + } + 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 crash-history 'Recent crashes (BSOD / unexpected shutdown)' 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)" } } + } -Fix $null ) } diff --git a/tests/Cleanup-Windows-Senior.Tests.ps1 b/tests/Cleanup-Windows-Senior.Tests.ps1 index d20f117..5ff6a83 100644 --- a/tests/Cleanup-Windows-Senior.Tests.ps1 +++ b/tests/Cleanup-Windows-Senior.Tests.ps1 @@ -120,3 +120,15 @@ Describe 'Invoke-PathCleanup (WhatIf accounting)' { (Get-ChildItem $script:Tmp).Count | Should -Be $countBefore } } + +Describe 'New coverage additions' { + It 'adds the SRUM telemetry DB cleanup with DPS stopped first' { + $t = $script:Reg | Where-Object Id -eq 'srum-db' + $t | Should -Not -BeNullOrEmpty + $t.Category | Should -Be 'Logs' + $t.StopServices | Should -Contain 'DPS' + } + It 'adds RDP / PowerShell / app cache and live-kernel dump tasks' { + @($script:Reg | Where-Object Id -in 'rdp-cache', 'ps-modulecache', 'win-caches', 'livekernel', 'eventtranscript').Count | Should -Be 5 + } +} diff --git a/tests/Optimize-Windows-Senior.Tests.ps1 b/tests/Optimize-Windows-Senior.Tests.ps1 index ddcf520..e3f00f6 100644 --- a/tests/Optimize-Windows-Senior.Tests.ps1 +++ b/tests/Optimize-Windows-Senior.Tests.ps1 @@ -127,3 +127,26 @@ Describe 'Tweak-level apply / undo (Registry type)' { Test-TweakApplied -Tweak $t | Should -BeFalse } } + +Describe 'New coverage additions' { + It 'adds the Recall/AI privacy tweak as a registry tweak under WindowsAI' { + $t = $script:Reg | Where-Object Id -eq 'priv-recall' + $t | Should -Not -BeNullOrEmpty + $t.Type | Should -Be 'Registry' + $t.Spec.Path | Should -Match 'WindowsAI' + } + It 'adds the classic context-menu tweak as a custom tweak, off by default' { + $t = $script:Reg | Where-Object Id -eq 'ux-context-menu' + $t.Type | Should -Be 'Custom' + $t.DefaultOn | Should -BeFalse + } + It 'adds the combined taskbar/Start ad-surface debloat tweak' { + $t = $script:Reg | Where-Object Id -eq 'debloat-taskbar-ads' + @($t.Spec.Values).Count | Should -BeGreaterThan 3 + } + It 'keeps debatable additions off by default' { + foreach ($id in 'priv-clipboard', 'net-teredo', 'perf-faststartup', 'ux-fileext') { + ($script:Reg | Where-Object Id -eq $id).DefaultOn | Should -BeFalse + } + } +} diff --git a/tests/Repair-Windows-Senior.Tests.ps1 b/tests/Repair-Windows-Senior.Tests.ps1 index 1efd1c8..c44e7b8 100644 --- a/tests/Repair-Windows-Senior.Tests.ps1 +++ b/tests/Repair-Windows-Senior.Tests.ps1 @@ -75,3 +75,26 @@ Describe 'Invoke-Scan / Invoke-Fix (synthetic checks)' { Invoke-Fix -Check $c -WhatIf | Should -BeFalse } } + +Describe 'New coverage additions' { + It 'adds a fixable firewall check' { + $c = $script:Reg | Where-Object Id -eq 'firewall-state' + $c.Fix | Should -Not -BeNullOrEmpty + $c.FixRisk | Should -Be 'Moderate' + } + It 'adds report-only disk-reliability and crash-history checks' { + foreach ($id in 'disk-reliability', 'crash-history') { + ($script:Reg | Where-Object Id -eq $id).Fix | Should -BeNullOrEmpty + } + } + It 'adds the security/network checks (proxy/hosts/smb1/defender-signatures)' { + @($script:Reg | Where-Object Id -in 'proxy-hijack', 'hosts-integrity', 'smb1-disabled', 'def-signatures').Count | Should -Be 4 + } + It 'keeps all new fix risks within the allowed tiers' { + $new = 'restore-enabled', 'hosts-integrity', 'proxy-hijack', 'firewall-state', 'def-signatures', 'smb1-disabled', 'sched-task-health', 'bits-health', 'spooler-health', 'store-health' + foreach ($id in $new) { + $c = $script:Reg | Where-Object Id -eq $id + if ($c.Fix) { $c.FixRisk | Should -BeIn @('Safe', 'Moderate', 'Aggressive') } + } + } +} From bdee10e77bd9f0e260cf670c159027a8c93bb35c Mon Sep 17 00:00:00 2001 From: denfry Date: Wed, 24 Jun 2026 10:33:32 +0300 Subject: [PATCH 5/6] chore(release): v6.1.0 - changelog, build/sign tooling, git hygiene - Bump version to 6.1.0 (Get-WinSeniorVersion single source + headers + README badge). - Add CHANGELOG.md (Keep a Changelog) documenting the v6.1.0 work. - Add tools/Build-Release.ps1 (zip + SHA256SUMS, version-driven) and tools/Sign-Scripts.ps1 (Authenticode signing of the root scripts). - .gitignore the per-tool/editor configs (.codex/.cursor/.opencode/.windsurf/.zed, AGENTS.md) and the dist/ build output - they are machine-local, not project files. Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitignore | 14 +++++++++ CHANGELOG.md | 58 +++++++++++++++++++++++++++++++++++++ Cleanup-Windows-Senior.ps1 | 2 +- Optimize-Windows-Senior.ps1 | 2 +- README.md | 4 +-- Repair-Windows-Senior.ps1 | 2 +- WinSenior.Common.ps1 | 4 +-- WinSenior.Schedule.ps1 | 2 +- WinSenior.ps1 | 2 +- tools/Build-Release.ps1 | 43 +++++++++++++++++++++++++++ tools/Sign-Scripts.ps1 | 39 +++++++++++++++++++++++++ 11 files changed, 163 insertions(+), 9 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 tools/Build-Release.ps1 create mode 100644 tools/Sign-Scripts.ps1 diff --git a/.gitignore b/.gitignore index e257c39..396435b 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,17 @@ __pycache__/ Thumbs.db desktop.ini *.tmp + +# codebase-index cache (machine-local; do not commit) +.claude/cache/codebase-index/ + +# AI assistant / editor tooling configs (installed per-tool, machine-local - not project files) +.codex/ +.cursor/ +.opencode/ +.windsurf/ +.zed/ +AGENTS.md + +# Release build output +dist/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..008d6a5 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,58 @@ +# Changelog + +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.1.0] - 2026-06-24 + +### Added +- **Shared library `WinSenior.Common.ps1`** dot-sourced by every engine and the menu: + the admin check, WhatIf probe, byte formatter, canonical logger (`Write-WsLog`), and the + System Restore routine (`New-WinSeniorRestorePoint`) now live in one place. +- **Unified JSON report** across all three engines. `-ReportPath` writes one envelope + (`Tool/Version/Engine/Host/Timestamp/Mode/RestorePoint/DurationSec` + `Summary` + `Items`) + via `Write-WinSeniorReport`, so a single parser reads cleanup, optimize and repair output. +- **Scheduled-task installer** (`WinSenior.Schedule.ps1`). `WinSenior.ps1 -InstallSchedule` + registers a weekly unattended cleanup and a monthly read-only health scan under `\WinSenior\` + (reports to `%ProgramData%\WinSenior\reports`); `-RemoveSchedule` removes them. +- **Cleanup coverage 57 → 63:** per-user Windows caches, PowerShell module cache, Remote Desktop + bitmap cache, live-kernel dumps, the SRUM usage database, and the EventTranscript telemetry DB; + extended the shader-cache task (NVIDIA OptixCache/NV_Cache) and Windows.old (`$WinREAgent`). +- **Optimization coverage 29 → 49:** modern Windows 11 privacy/debloat — disable Recall & + Click-to-Do, Copilot, tailored-ad experiences, the Windows Spotlight policy, inking/typing and + online-speech telemetry, CEIP, the App-Compat appraiser, Windows Error Reporting upload, + Delivery-Optimization P2P upload, OneDrive pre-sign-in traffic, cloud clipboard (off by + default); combined taskbar/Start ad-surface debloat, the SCOOBE setup nag, show-file-extensions + (off), the classic Windows 10 context menu (off), Teredo (off) and Fast Startup (off). +- **Troubleshooting coverage 13 → 25:** System Restore protection, hosts-file integrity, + proxy/PAC hijack, firewall state, Defender signatures & active threats, SMBv1, critical + scheduled-task health, BITS queue, print spooler, Microsoft Store health, and report-only + SSD wear/temperature and crash history. +- `CHANGELOG.md` and `tools/Build-Release.ps1` (zip + SHA256SUMS) and `tools/Sign-Scripts.ps1`. + +### Changed +- **Report schema is unified** (breaking for anyone parsing the old `clean.json`/`repair.json`): + engine-specific counters moved under `Summary`, and `Tasks`/`Results`/`Tweaks` are now `Items`. +- CI parses and analyzes **all** root `*.ps1` (was a hardcoded 4-file list), so the UI, common + and schedule libraries are linted too. +- Engine loggers and restore-point functions are now thin wrappers over the shared library + (~175 duplicated lines removed) with identical public signatures. + +### Fixed +- `Write-WinSeniorReport` casts `Items` via `[object[]]`: `@()` throws + `System.ArgumentException` on the `Generic.List[object]` the engines pass. +- The Store-health check wraps `Get-AppxPackage` in try/catch — the Appx module raises a + terminating load error under PowerShell 7 that `-ErrorAction SilentlyContinue` does not catch. + +## [6.0.0] - 2026-06-23 + +### Added +- Initial v6 release: registry-driven cleanup engine (57 tasks), optimization engine + (29 reversible tweaks), troubleshooting engine (13 checks), and the `WinSenior.ps1` + arrow-key TUI menu. Real `-WhatIf` via `SupportsShouldProcess`, a hard `Test-SafeToDelete` + guard, risk tiers (Safe/Moderate/Aggressive default; Dangerous behind `-IncludeDangerous`), + real System Restore points, and per-tweak undo. + +[6.1.0]: https://github.com/denfry/WindowsCleaner/releases/tag/v6.1.0 +[6.0.0]: https://github.com/denfry/WindowsCleaner/releases/tag/v6.0.0 diff --git a/Cleanup-Windows-Senior.ps1 b/Cleanup-Windows-Senior.ps1 index 068a1ac..4aef162 100644 --- a/Cleanup-Windows-Senior.ps1 +++ b/Cleanup-Windows-Senior.ps1 @@ -15,7 +15,7 @@ .NOTES Author : denfry (https://github.com/denfry/WindowsCleaner) - Version : 6.0.0 + Version : 6.1.0 Requires: PowerShell 5.1+ (Windows). Administrator rights for most tasks. .EXAMPLE diff --git a/Optimize-Windows-Senior.ps1 b/Optimize-Windows-Senior.ps1 index 628b360..f4d8d3e 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.0.0 + Version : 6.1.0 Requires: PowerShell 5.1+ (Windows). Administrator rights. .EXAMPLE diff --git a/README.md b/README.md index 1972b6e..d095ab5 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.0.0-blue.svg)](https://github.com/denfry/WindowsCleaner) +[![Version](https://img.shields.io/badge/version-6.1.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/) @@ -265,7 +265,7 @@ Every engine's `-ReportPath` writes the same envelope, so one parser reads them ```json { - "Tool": "WinSenior", "Version": "6.0.0", "Engine": "Cleanup", + "Tool": "WinSenior", "Version": "6.1.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 96d9250..2761b5c 100644 --- a/Repair-Windows-Senior.ps1 +++ b/Repair-Windows-Senior.ps1 @@ -15,7 +15,7 @@ .NOTES Author : denfry (https://github.com/denfry/WindowsCleaner) - Version : 6.0.0 + Version : 6.1.0 Requires: PowerShell 5.1+ (Windows). Administrator rights. .EXAMPLE diff --git a/WinSenior.Common.ps1 b/WinSenior.Common.ps1 index 676846d..ed11a99 100644 --- a/WinSenior.Common.ps1 +++ b/WinSenior.Common.ps1 @@ -14,7 +14,7 @@ .NOTES Author : denfry (https://github.com/denfry/WindowsCleaner) - Version : 6.0.0 + Version : 6.1.0 #> # ===================================================================== @@ -115,7 +115,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.0.0' } +function Get-WinSeniorVersion { '6.1.0' } function Write-WinSeniorReport { param( diff --git a/WinSenior.Schedule.ps1 b/WinSenior.Schedule.ps1 index eafecba..9937d62 100644 --- a/WinSenior.Schedule.ps1 +++ b/WinSenior.Schedule.ps1 @@ -18,7 +18,7 @@ .NOTES Author : denfry (https://github.com/denfry/WindowsCleaner) - Version : 6.0.0 + Version : 6.1.0 #> $script:WinSeniorTaskPath = '\WinSenior\' diff --git a/WinSenior.ps1 b/WinSenior.ps1 index dc1cb7a..7a7a409 100644 --- a/WinSenior.ps1 +++ b/WinSenior.ps1 @@ -11,7 +11,7 @@ .NOTES Author : denfry (https://github.com/denfry/WindowsCleaner) - Version : 6.0.0 + Version : 6.1.0 Requires: PowerShell 5.1+ (Windows). Administrator rights (auto-elevates). .EXAMPLE diff --git a/tools/Build-Release.ps1 b/tools/Build-Release.ps1 new file mode 100644 index 0000000..5e9318f --- /dev/null +++ b/tools/Build-Release.ps1 @@ -0,0 +1,43 @@ +<# +.SYNOPSIS + Package a WinSenior release: a versioned zip of the runtime files + SHA256SUMS. + +.DESCRIPTION + Reads the version from Get-WinSeniorVersion (single source of truth), bundles the + engines, libraries, menu, the .bat, README/LICENSE/CHANGELOG into dist\WinSenior-.zip, + and writes dist\SHA256SUMS.txt. The dist\ folder is git-ignored. + +.EXAMPLE + .\tools\Build-Release.ps1 +#> +#Requires -Version 5.1 +[CmdletBinding()] +param([string]$OutDir) + +$ErrorActionPreference = 'Stop' +$root = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path +if (-not $OutDir) { $OutDir = Join-Path $root 'dist' } + +. (Join-Path $root 'WinSenior.Common.ps1') +$version = Get-WinSeniorVersion + +$include = @( + 'WinSenior.ps1', 'WinSenior.Common.ps1', 'WinSenior.UI.ps1', 'WinSenior.Schedule.ps1', + 'Cleanup-Windows-Senior.ps1', 'Optimize-Windows-Senior.ps1', 'Repair-Windows-Senior.ps1', + 'Cleanup-Windows-Senior.bat', 'README.md', 'LICENSE', 'CHANGELOG.md' +) +$files = foreach ($f in $include) { $p = Join-Path $root $f; if (Test-Path $p) { $p } } + +if (-not (Test-Path $OutDir)) { New-Item -ItemType Directory -Path $OutDir -Force | Out-Null } +$zipName = "WinSenior-$version.zip" +$zip = Join-Path $OutDir $zipName +Compress-Archive -Path $files -DestinationPath $zip -CompressionLevel Optimal -Force + +$hash = (Get-FileHash $zip -Algorithm SHA256).Hash +"$hash $zipName" | Set-Content -Path (Join-Path $OutDir 'SHA256SUMS.txt') -Encoding ASCII + +Write-Host "Version : $version" -ForegroundColor Cyan +Write-Host "Files : $(@($files).Count) bundled" +Write-Host "Zip : $zip" -ForegroundColor Green +Write-Host "SHA256 : $hash" +[pscustomobject]@{ Version = $version; Zip = $zip; Sha256 = $hash; FileCount = @($files).Count } diff --git a/tools/Sign-Scripts.ps1 b/tools/Sign-Scripts.ps1 new file mode 100644 index 0000000..3dd8184 --- /dev/null +++ b/tools/Sign-Scripts.ps1 @@ -0,0 +1,39 @@ +<# +.SYNOPSIS + Authenticode-sign every root WinSenior .ps1 with a code-signing certificate. + +.DESCRIPTION + Signs the engines, libraries and menu so users do not need -ExecutionPolicy Bypass + (an AllSigned/RemoteSigned policy will trust them). Supply the SHA1 thumbprint of a + code-signing certificate in Cert:\CurrentUser\My (or Cert:\LocalMachine\My). + Run this AFTER Build-Release if you want the zip to contain signed scripts (re-zip after). + +.PARAMETER CertThumbprint + Thumbprint of the code-signing certificate. + +.PARAMETER TimestampUrl + RFC-3161 timestamp server (so signatures stay valid after the cert expires). + +.EXAMPLE + .\tools\Sign-Scripts.ps1 -CertThumbprint ABC123...DEF +#> +#Requires -Version 5.1 +[CmdletBinding()] +param( + [Parameter(Mandatory)][string]$CertThumbprint, + [string]$TimestampUrl = 'http://timestamp.digicert.com' +) + +$ErrorActionPreference = 'Stop' +$root = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path + +$cert = Get-ChildItem Cert:\CurrentUser\My, Cert:\LocalMachine\My -CodeSigningCert -ErrorAction SilentlyContinue | + Where-Object { $_.Thumbprint -eq $CertThumbprint } | Select-Object -First 1 +if (-not $cert) { throw "No code-signing certificate with thumbprint $CertThumbprint found in CurrentUser\My or LocalMachine\My." } + +$scripts = Get-ChildItem -Path $root -Filter *.ps1 -File +foreach ($s in $scripts) { + $r = Set-AuthenticodeSignature -FilePath $s.FullName -Certificate $cert ` + -TimestampServer $TimestampUrl -HashAlgorithm SHA256 + Write-Host ("{0,-34} {1}" -f $s.Name, $r.Status) -ForegroundColor $(if ($r.Status -eq 'Valid') { 'Green' } else { 'Yellow' }) +} From df0fe035559075c3477f67d519a1019e3c301ef8 Mon Sep 17 00:00:00 2001 From: denfry Date: Wed, 24 Jun 2026 10:41:26 +0300 Subject: [PATCH 6/6] test(common): fix restore-point logger capture under Pester 5 Use a function-local list + GetNewClosure instead of $script: scope: a script-scoped variable read inside the closure resolves to $null across the closure module boundary, which crashed the three New-WinSeniorRestorePoint tests with 'cannot call a method on a null-valued expression'. Verified the closure mechanism locally (WhatIf path) under pwsh 7. Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/WinSenior.Common.Tests.ps1 | 26 +++++++++++++++----------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/tests/WinSenior.Common.Tests.ps1 b/tests/WinSenior.Common.Tests.ps1 index eb66b82..049991d 100644 --- a/tests/WinSenior.Common.Tests.ps1 +++ b/tests/WinSenior.Common.Tests.ps1 @@ -38,17 +38,17 @@ Describe 'Write-WsLog' { } Describe 'New-WinSeniorRestorePoint' { - BeforeEach { - $script:captured = [System.Collections.Generic.List[string]]::new() - $script:logger = { param($m, $l) $script:captured.Add("$l|$m") }.GetNewClosure() - } - + # A capturing logger built from a LOCAL variable + GetNewClosure. Using $script: + # scope here does NOT survive the closure's module boundary under Pester 5 (the + # variable reads back as $null inside the scriptblock), so keep it function-local. It 'is a no-op under -WhatIf and never checkpoints' { Mock Checkpoint-Computer { } + $captured = [System.Collections.Generic.List[string]]::new() + $logger = { param($m, $l) $captured.Add("$l|$m") }.GetNewClosure() $WhatIfPreference = $true - $r = New-WinSeniorRestorePoint -Description 'test' -LogAction $script:logger + $r = New-WinSeniorRestorePoint -Description 'test' -LogAction $logger $r | Should -Be 'WhatIf' - ($script:captured -join "`n") | Should -Match 'would create a System Restore point' + ($captured -join "`n") | Should -Match 'would create a System Restore point' Should -Invoke Checkpoint-Computer -Times 0 -Exactly } @@ -56,21 +56,25 @@ Describe 'New-WinSeniorRestorePoint' { Mock New-ItemProperty { } Mock Enable-ComputerRestore { } Mock Checkpoint-Computer { } + $captured = [System.Collections.Generic.List[string]]::new() + $logger = { param($m, $l) $captured.Add("$l|$m") }.GetNewClosure() $WhatIfPreference = $false - $r = New-WinSeniorRestorePoint -Description 'test' -LogAction $script:logger + $r = New-WinSeniorRestorePoint -Description 'test' -LogAction $logger $r | Should -Be 'Created' Should -Invoke Checkpoint-Computer -Times 1 -Exactly - ($script:captured -join "`n") | Should -Match 'System Restore point created' + ($captured -join "`n") | Should -Match 'System Restore point created' } It 'returns Failed when the checkpoint throws' { Mock New-ItemProperty { } Mock Enable-ComputerRestore { } Mock Checkpoint-Computer { throw 'protection off' } + $captured = [System.Collections.Generic.List[string]]::new() + $logger = { param($m, $l) $captured.Add("$l|$m") }.GetNewClosure() $WhatIfPreference = $false - $r = New-WinSeniorRestorePoint -Description 'test' -LogAction $script:logger + $r = New-WinSeniorRestorePoint -Description 'test' -LogAction $logger $r | Should -Be 'Failed' - ($script:captured -join "`n") | Should -Match 'Restore point not created' + ($captured -join "`n") | Should -Match 'Restore point not created' } }