Загрузка данных


#Requires -RunAsAdministrator
<#
.SYNOPSIS
  Admin-side cleanup of games, launchers and their leftovers on a shared Windows PC.

.DESCRIPTION
  DRY RUN BY DEFAULT: without -Apply the script only reports what it would do.
  With -Apply every deletion is still confirmed (y/N) unless -Force is given.

  What it covers
   1. Stops running game/launcher processes
   2. Removes Store (UWP) games and Xbox apps for all users
   3. Uninstalls installed launchers and games (registry uninstall entries)
   4. Third-party game services (launchers, anti-cheat)
   5. Autostart: Run keys (machine + every processed profile), Startup folders, scheduled tasks
   6. Game folders: drive roots, Program Files, ProgramData, shared folders (C:\Users\Public),
      Steam libraries on any drive, game data and installers inside user profiles
   7. Desktop / Start Menu shortcuts that point to games
   8. Game Bar / Game DVR off by policy
   9. REPORT ONLY (nothing deleted): big folders and big files for manual review

  Profiles of DISABLED local accounts are skipped by default, so the old account stays intact.
  Use -KeepProfiles to protect more profiles (by profile folder name or account name),
  or -IncludeDisabledProfiles to clean disabled accounts too.

  A log is written next to the script (or to %TEMP%).

.EXAMPLE
  # 1) look first (changes nothing)
  .\cleanup-games.ps1
  # 2) real run, asks before each deletion
  .\cleanup-games.ps1 -Apply
  # 3) real run without questions
  .\cleanup-games.ps1 -Apply -Force
  # protect an extra profile and add your own patterns (regex)
  .\cleanup-games.ps1 -Apply -KeepProfiles "Admin" -ExtraPatterns "MyGame","Foobar"

  Run in Windows PowerShell 5.1 as Administrator:
  powershell -ExecutionPolicy Bypass -File .\cleanup-games.ps1
#>
[CmdletBinding()]
param(
    [switch]$Apply,
    [switch]$Force,
    [string[]]$KeepProfiles = @(),
    [switch]$IncludeDisabledProfiles,
    [string[]]$ExtraPatterns = @(),
    [int]$BigFileGB = 1
)

$ErrorActionPreference = 'Continue'
$DryRun = -not $Apply

# =====================================================================
#  EDITABLE LISTS
# =====================================================================

# Regex (case-insensitive) matched against program names, shortcut names/targets,
# autostart entries, scheduled tasks, service names, files in shared folders
$GameAppPatterns = @(
    '\bSteam', 'Epic ?Games', 'Battle\.net', 'Blizzard', '\bEA app', '\bEA Desktop', 'Electronic Arts',
    '^Origin$', 'OriginSetup|OriginThinSetup', 'Ubisoft', 'Uplay', 'GOG ?GALAXY', '\bRiot',
    'VALORANT', 'League of Legends', 'Minecraft', 'Roblox', 'Rockstar', 'Wargaming', '\bLesta',
    'World of (Tanks|Warships)', 'HoYoverse', 'Genshin', 'Honkai', 'Overwolf', 'VK ?Play',
    'Game ?Center', 'Xbox', '^itch$', 'Playnite', 'Discord'
) + $ExtraPatterns

# Exact service names (besides pattern matches) that belong to anti-cheat / launchers
$GameServiceNames = '^(vgc|vgk|BEService|EasyAntiCheat.*|BattlEye.*|EpicOnlineServices)$'

# UWP / Store packages to remove (wildcards allowed)
$AppxPatterns = @(
    'Microsoft.Xbox*', 'Microsoft.GamingApp', 'Microsoft.MicrosoftSolitaireCollection',
    'king.com.*', 'Microsoft.Minecraft*', '*Roblox*'
)

# Processes to stop before cleanup (Get-Process names, wildcards allowed)
$GameProcesses = @(
    'steam', 'steamwebhelper', 'EpicGamesLauncher', 'Battle.net', 'RiotClientServices',
    'RiotClientUx', 'Origin', 'EADesktop', 'UbisoftConnect', 'upc', 'GalaxyClient', 'Discord',
    'Overwolf', 'Roblox*', 'Minecraft*', 'GameCenter', 'VKPlay*'
)

# Folder names looked for in drive roots, Program Files, Program Files (x86), ProgramData
$GameFolderNames = @(
    'Steam', 'SteamLibrary', 'Epic Games', 'Battle.net', 'Riot Games', 'Origin', 'Origin Games',
    'EA Games', 'Electronic Arts', 'Ubisoft', 'GOG Games', 'GOG Galaxy', 'XboxGames',
    'Games', 'Wargaming.net', 'Lesta', 'Blizzard Entertainment', 'Rockstar Games', 'VK Play'
)
$ProgramDataExtra = @('Epic')

# Game folders inside every processed user profile
$GameProfileSubdirs = @(
    'Documents\My Games', 'Saved Games', 'AppData\Roaming\.minecraft',
    'AppData\Roaming\Battle.net', 'AppData\Roaming\discord', 'AppData\Roaming\Wargaming.net',
    'AppData\Local\Steam', 'AppData\Local\EpicGamesLauncher', 'AppData\Local\Battle.net',
    'AppData\Local\Riot Games', 'AppData\Local\Roblox', 'AppData\Local\Discord',
    'AppData\Local\Ubisoft Game Launcher', 'AppData\Local\Origin',
    'AppData\Local\Electronic Arts', 'AppData\Local\VK Play',
    'AppData\Local\Programs\Discord'
)

# Top-level folders ignored by the "big folders" report
$ReportSkipTop = @(
    'Windows', 'Program Files', 'Program Files (x86)', 'ProgramData', 'Users', 'Recovery',
    'PerfLogs', '$Recycle.Bin', 'System Volume Information', 'Documents and Settings',
    '$WinREAgent', 'Config.Msi', 'MSOCache'
)

# =====================================================================
#  LOG + HELPERS
# =====================================================================

$logDir = if ($PSScriptRoot) { $PSScriptRoot } else { $env:TEMP }
$logFile = Join-Path $logDir ("cleanup-games-{0:yyyyMMdd-HHmmss}.log" -f (Get-Date))
try { Start-Transcript -Path $logFile -Force | Out-Null } catch { }

$script:Stats = @{ Done = 0; Failed = 0; Skipped = 0 }
$script:Seen = New-Object 'System.Collections.Generic.HashSet[string]' ([StringComparer]::OrdinalIgnoreCase)
$pub = Join-Path $env:SystemDrive 'Users\Public'

function Step-Header {
    param([string]$Text)
    Write-Host "`n== $Text ==" -ForegroundColor Cyan
}

function Do-Action {
    param([string]$Text, [scriptblock]$Action)
    if ($DryRun) {
        Write-Host "[dry-run] $Text" -ForegroundColor Yellow
        return
    }
    Write-Host "  -> $Text"
    try {
        & $Action
        $script:Stats.Done++
    }
    catch {
        $script:Stats.Failed++
        Write-Warning "Failed: $Text :: $($_.Exception.Message)"
    }
}

function Confirm-Item {
    param([string]$Message)
    if ($DryRun -or $Force) { return $true }
    $a = Read-Host "$Message [y/N]"
    if ($a -match '^(y|yes)$') { return $true }
    $script:Stats.Skipped++
    return $false
}

function Test-GameText {
    param([string]$Text)
    if ([string]::IsNullOrWhiteSpace($Text)) { return $false }
    foreach ($p in $GameAppPatterns) {
        if ($Text -match $p) { return $true }
    }
    return $false
}

$ProtectedExact = @(
    $env:SystemDrive, "$env:SystemDrive\Users", $env:ProgramFiles, ${env:ProgramFiles(x86)},
    $env:ProgramData, $env:windir
) | Where-Object { $_ } | ForEach-Object { $_.TrimEnd('\') }

function Test-SafePath {
    param([string]$Path)
    $full = $Path.TrimEnd('\')
    if ($full -match '^[A-Za-z]:$') { return $false }
    if ($ProtectedExact -contains $full) { return $false }
    if ($full.StartsWith($env:windir + '\', [StringComparison]::OrdinalIgnoreCase)) { return $false }
    return $true
}

function Get-SizeGB {
    param([string]$Path)
    try {
        $item = Get-Item -LiteralPath $Path -Force -ErrorAction Stop
        if (-not $item.PSIsContainer) { return [math]::Round($item.Length / 1GB, 2) }
        $sum = (Get-ChildItem -LiteralPath $Path -Recurse -Force -File -ErrorAction SilentlyContinue |
                Measure-Object -Property Length -Sum).Sum
        if (-not $sum) { $sum = 0 }
        return [math]::Round($sum / 1GB, 2)
    }
    catch { return 0 }
}

function Remove-GameItem {
    param([string]$Path)
    if (-not (Test-SafePath $Path)) { Write-Warning "Protected path skipped: $Path"; return }
    $size = Get-SizeGB $Path
    if ($DryRun) {
        Write-Host ("[dry-run] Delete: {0}  ({1} GB)" -f $Path, $size) -ForegroundColor Yellow
        return
    }
    if (Confirm-Item ("Delete '{0}' ({1} GB)?" -f $Path, $size)) {
        Write-Host "  -> Deleting $Path"
        Remove-Item -LiteralPath $Path -Recurse -Force -ErrorAction SilentlyContinue
        if (Test-Path -LiteralPath $Path) {
            $script:Stats.Failed++
            Write-Warning "Not fully deleted (in use? reboot and rerun): $Path"
        }
        else { $script:Stats.Done++ }
    }
}

$script:Wsh = New-Object -ComObject WScript.Shell

function Test-ShortcutGame {
    param([System.IO.FileInfo]$File)
    if (Test-GameText $File.BaseName) { return $true }
    try {
        if ($File.Extension -ieq '.lnk') {
            $s = $script:Wsh.CreateShortcut($File.FullName)
            return (Test-GameText ("{0} {1}" -f $s.TargetPath, $s.Arguments))
        }
        if ($File.Extension -ieq '.url') {
            return (Test-GameText (Get-Content -LiteralPath $File.FullName -Raw -ErrorAction Stop))
        }
    }
    catch { }
    return $false
}

function Remove-GameFile {
    param([System.IO.FileInfo]$File, [string]$What)
    if (-not $script:Seen.Add($File.FullName)) { return }
    if ($DryRun) {
        Write-Host "[dry-run] $What : $($File.FullName)" -ForegroundColor Yellow
        return
    }
    if (Confirm-Item "$What '$($File.FullName)'?") {
        $p = $File.FullName
        Do-Action "$What $p" { Remove-Item -LiteralPath $p -Force -ErrorAction Stop }
    }
}

function Clear-RunKey {
    param([string]$Key)
    if (-not (Test-Path -LiteralPath $Key)) { return }
    $skip = 'PSPath', 'PSParentPath', 'PSChildName', 'PSDrive', 'PSProvider'
    $props = (Get-ItemProperty -LiteralPath $Key).PSObject.Properties |
        Where-Object { $skip -notcontains $_.Name }
    foreach ($pr in $props) {
        if (Test-GameText ("{0} {1}" -f $pr.Name, $pr.Value)) {
            $name = $pr.Name
            Do-Action "Remove autostart '$name' from $Key" {
                Remove-ItemProperty -LiteralPath $Key -Name $name -Force -ErrorAction Stop
            }
        }
    }
}

function Use-UserHive {
    # runs $Body with the registry root of the user's hive (loads NTUSER.DAT if the user is not logged in)
    param($Profile, [scriptblock]$Body)
    $sid = $Profile.Sid
    $tmp = $null
    if (Test-Path -LiteralPath "Registry::HKEY_USERS\$sid") {
        $root = "Registry::HKEY_USERS\$sid"
    }
    else {
        $nt = Join-Path $Profile.Path 'NTUSER.DAT'
        if (-not (Test-Path -LiteralPath $nt)) { return }
        $tmp = 'CleanupTmp_' + $sid.Replace('-', '_')
        & reg.exe load "HKU\$tmp" $nt | Out-Null
        if ($LASTEXITCODE -ne 0) { Write-Warning "Could not load registry hive of $($Profile.Path)"; return }
        $root = "Registry::HKEY_USERS\$tmp"
    }
    try { & $Body $root }
    finally {
        if ($tmp) {
            [gc]::Collect(); [gc]::WaitForPendingFinalizers()
            & reg.exe unload "HKU\$tmp" | Out-Null
        }
    }
}

function Uninstall-Program {
    param($App)
    $cmd = if ($App.QuietUninstallString) { $App.QuietUninstallString } else { $App.UninstallString }
    if (-not $cmd) { Write-Warning "No uninstall command for '$($App.DisplayName)' - remove manually"; return }
    if ($cmd -match 'msiexec') {
        $cmd = $cmd -replace '/I', '/X'
        if ($cmd -notmatch '/q') { $cmd += ' /qn /norestart' }
    }
    elseif (-not $App.QuietUninstallString) {
        $cmd += ' /S'   # silent flag for NSIS-style uninstallers; ignored by others
    }
    Start-Process -FilePath 'cmd.exe' -ArgumentList "/c `"$cmd`"" -Wait -WindowStyle Hidden
}

function Get-CleanupProfiles {
    $disabled = @(Get-LocalUser -ErrorAction SilentlyContinue |
            Where-Object { -not $_.Enabled } | ForEach-Object { $_.SID.Value })
    foreach ($p in (Get-CimInstance Win32_UserProfile)) {
        if ($p.Special -or -not $p.LocalPath) { continue }
        $folder = Split-Path $p.LocalPath -Leaf
        $acct = ''
        try {
            $acct = (New-Object System.Security.Principal.SecurityIdentifier($p.SID)).Translate(
                [System.Security.Principal.NTAccount]).Value.Split('\')[-1]
        }
        catch { }
        $why = ''
        if (($KeepProfiles -contains $folder) -or ($acct -and ($KeepProfiles -contains $acct))) {
            $why = 'in -KeepProfiles'
        }
        elseif ((-not $IncludeDisabledProfiles) -and ($disabled -contains $p.SID)) {
            $why = 'disabled account'
        }
        [pscustomobject]@{ Path = $p.LocalPath; Sid = $p.SID; Account = $acct; SkipReason = $why }
    }
}

# =====================================================================
#  START
# =====================================================================

if ($DryRun) {
    Write-Host "DRY RUN: nothing will be changed. Add -Apply to really clean." -ForegroundColor Yellow
}
else {
    Write-Host "APPLY mode: deletions are real." -ForegroundColor Red
}

$Profiles = @(Get-CleanupProfiles)
$Targets = @($Profiles | Where-Object { -not $_.SkipReason })
Write-Host "`nProfiles:"
foreach ($p in $Profiles) {
    if ($p.SkipReason) {
        Write-Host ("  SKIP   {0}  ({1})" -f $p.Path, $p.SkipReason) -ForegroundColor DarkGray
    }
    else {
        Write-Host ("  clean  {0}" -f $p.Path)
    }
}

$fixed = @(Get-CimInstance Win32_LogicalDisk -Filter 'DriveType=3' | ForEach-Object { $_.DeviceID })

# ---------------------------------------------------------------------
Step-Header '1. Processes'
foreach ($n in $GameProcesses) {
    foreach ($proc in (Get-Process -Name $n -ErrorAction SilentlyContinue)) {
        $id = $proc.Id
        Do-Action "Stop process $($proc.Name) (PID $id)" { Stop-Process -Id $id -Force -ErrorAction Stop }
    }
}

# ---------------------------------------------------------------------
Step-Header '2. Store (UWP) games'
foreach ($pattern in $AppxPatterns) {
    foreach ($pkg in (Get-AppxPackage -AllUsers -Name $pattern -ErrorAction SilentlyContinue)) {
        $full = $pkg.PackageFullName
        Do-Action "Remove UWP package $($pkg.Name)" {
            Remove-AppxPackage -Package $full -AllUsers -ErrorAction Stop
        }
    }
    foreach ($prov in (Get-AppxProvisionedPackage -Online -ErrorAction SilentlyContinue |
            Where-Object { $_.DisplayName -like $pattern })) {
        $pn = $prov.PackageName
        Do-Action "Remove provisioned package $($prov.DisplayName)" {
            Remove-AppxProvisionedPackage -Online -PackageName $pn -ErrorAction Stop | Out-Null
        }
    }
}

# ---------------------------------------------------------------------
Step-Header '3. Installed launchers and games'
$uninstallKeys = @(
    'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*',
    'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*',
    'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*'
)
$apps = @(foreach ($a in (Get-ItemProperty $uninstallKeys -ErrorAction SilentlyContinue)) {
        if ($a.DisplayName -and (Test-GameText $a.DisplayName)) { $a }
    })
if ($apps.Count -eq 0) { Write-Host '  nothing found' }
foreach ($app in $apps) {
    if ($DryRun) {
        Write-Host "[dry-run] Uninstall program: $($app.DisplayName)" -ForegroundColor Yellow
    }
    elseif (Confirm-Item "Uninstall '$($app.DisplayName)'?") {
        Write-Host "  -> Uninstalling $($app.DisplayName)"
        try { Uninstall-Program -App $app; $script:Stats.Done++ }
        catch { $script:Stats.Failed++; Write-Warning $_.Exception.Message }
    }
}

# ---------------------------------------------------------------------
Step-Header '4. Third-party game services'
foreach ($svc in (Get-CimInstance Win32_Service)) {
    $path = "$($svc.PathName)"
    if ($path -match '(?i)\\Windows\\') { continue }   # leave Windows' own services alone
    if ((Test-GameText $svc.Name) -or (Test-GameText $svc.DisplayName) -or ($svc.Name -match $GameServiceNames)) {
        $name = $svc.Name
        if (Confirm-Item "Remove service '$name' ($($svc.DisplayName))?") {
            Do-Action "Stop and delete service $name" {
                Stop-Service -Name $name -Force -ErrorAction SilentlyContinue
                & sc.exe delete $name | Out-Null
            }
        }
    }
}

# ---------------------------------------------------------------------
Step-Header '5. Autostart'
Write-Host '-- Run keys (machine)'
Clear-RunKey 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Run'
Clear-RunKey 'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Run'
Write-Host '-- Run keys (profiles)'
foreach ($t in $Targets) {
    Use-UserHive -Profile $t -Body {
        param($root)
        Clear-RunKey "$root\SOFTWARE\Microsoft\Windows\CurrentVersion\Run"
    }
}
Write-Host '-- Startup folders'
$startupDirs = @("$env:ProgramData\Microsoft\Windows\Start Menu\Programs\StartUp")
foreach ($t in $Targets) {
    $startupDirs += "$($t.Path)\AppData\Roaming\Microsoft\Windows\Start Menu\Programs\Startup"
}
foreach ($dir in $startupDirs) {
    if (-not (Test-Path -LiteralPath $dir)) { continue }
    foreach ($f in (Get-ChildItem -LiteralPath $dir -Force -File -ErrorAction SilentlyContinue)) {
        if (Test-ShortcutGame $f) { Remove-GameFile -File $f -What 'Remove startup item' }
    }
}
Write-Host '-- Scheduled tasks'
foreach ($task in (Get-ScheduledTask -ErrorAction SilentlyContinue)) {
    if ($task.TaskPath -like '\Microsoft\*') { continue }
    if (Test-GameText $task.TaskName) {
        $tn = $task.TaskName; $tp = $task.TaskPath
        if (Confirm-Item "Remove scheduled task '$tp$tn'?") {
            Do-Action "Remove scheduled task $tp$tn" {
                Unregister-ScheduledTask -TaskName $tn -TaskPath $tp -Confirm:$false -ErrorAction Stop
            }
        }
    }
}

# ---------------------------------------------------------------------
Step-Header '6. Game folders and files'
$cand = New-Object 'System.Collections.Generic.HashSet[string]' ([StringComparer]::OrdinalIgnoreCase)

foreach ($d in $fixed) {
    foreach ($n in $GameFolderNames) {
        foreach ($base in @("$d\", "$d\Program Files\", "$d\Program Files (x86)\")) {
            $c = Join-Path $base $n
            if (Test-Path -LiteralPath $c) { [void]$cand.Add($c) }
        }
    }
    # Steam libraries anywhere on the drive (depth-limited)
    Get-ChildItem -LiteralPath "$d\" -Directory -Filter 'steamapps' -Recurse -Depth 3 -Force -ErrorAction SilentlyContinue |
        Where-Object { -not $_.FullName.StartsWith($env:windir, [StringComparison]::OrdinalIgnoreCase) } |
        ForEach-Object { [void]$cand.Add($_.FullName) }
}
foreach ($n in ($GameFolderNames + $ProgramDataExtra)) {
    $c = Join-Path $env:ProgramData $n
    if (Test-Path -LiteralPath $c) { [void]$cand.Add($c) }
}
# shared folder C:\Users\Public: game-related items by name
$pubGames = Join-Path $pub 'Games'
if (Test-Path -LiteralPath $pubGames) { [void]$cand.Add($pubGames) }
foreach ($sub in @('', 'Documents', 'Downloads', 'Music', 'Pictures', 'Videos')) {
    $dir = if ($sub) { Join-Path $pub $sub } else { $pub }
    if (-not (Test-Path -LiteralPath $dir)) { continue }
    $depthArgs = if ($sub) { @{ Recurse = $true; Depth = 2 } } else { @{} }
    Get-ChildItem -LiteralPath $dir -Force -ErrorAction SilentlyContinue @depthArgs |
        Where-Object { (Test-GameText $_.Name) -and $_.Name -ne 'Desktop' } |
        ForEach-Object { [void]$cand.Add($_.FullName) }
}
# processed profiles: game data + installers/archives in Downloads
foreach ($t in $Targets) {
    foreach ($sub in $GameProfileSubdirs) {
        $c = Join-Path $t.Path $sub
        if (Test-Path -LiteralPath $c) { [void]$cand.Add($c) }
    }
    $dl = Join-Path $t.Path 'Downloads'
    if (Test-Path -LiteralPath $dl) {
        Get-ChildItem -LiteralPath $dl -Recurse -Depth 2 -Force -File -ErrorAction SilentlyContinue |
            Where-Object { $_.Extension -match '^\.(exe|msi|iso|zip|rar|7z)$' -and (Test-GameText $_.Name) } |
            ForEach-Object { [void]$cand.Add($_.FullName) }
    }
}

if ($cand.Count -eq 0) { Write-Host '  nothing found' }
$handled = New-Object System.Collections.Generic.List[string]
foreach ($c in ($cand | Sort-Object { $_.Length })) {
    $isChild = $false
    foreach ($h in $handled) {
        if ($c.StartsWith($h.TrimEnd('\') + '\', [StringComparison]::OrdinalIgnoreCase)) { $isChild = $true; break }
    }
    if ($isChild) { continue }
    if (-not (Test-Path -LiteralPath $c)) { continue }
    $handled.Add($c)
    Remove-GameItem -Path $c
}

# ---------------------------------------------------------------------
Step-Header '7. Shortcuts to games'
$shortcutRoots = @("$pub\Desktop", "$env:ProgramData\Microsoft\Windows\Start Menu\Programs")
foreach ($t in $Targets) {
    $shortcutRoots += "$($t.Path)\Desktop"
    $shortcutRoots += "$($t.Path)\OneDrive\Desktop"
    $shortcutRoots += "$($t.Path)\AppData\Roaming\Microsoft\Windows\Start Menu\Programs"
}
foreach ($r in $shortcutRoots) {
    if (-not (Test-Path -LiteralPath $r)) { continue }
    foreach ($f in (Get-ChildItem -LiteralPath $r -Recurse -Force -File -ErrorAction SilentlyContinue |
            Where-Object { $_.Extension -match '^\.(lnk|url)$' })) {
        if (Test-ShortcutGame $f) { Remove-GameFile -File $f -What 'Remove shortcut' }
    }
}

# ---------------------------------------------------------------------
Step-Header '8. Game Bar / Game DVR'
Do-Action 'Disable Game Bar / Game DVR via policy' {
    $k = 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\GameDVR'
    if (-not (Test-Path $k)) { New-Item -Path $k -Force | Out-Null }
    New-ItemProperty -Path $k -Name 'AllowGameDVR' -Value 0 -PropertyType DWord -Force | Out-Null
}

# ---------------------------------------------------------------------
Step-Header '9. REPORT (nothing is deleted here): review by hand'
Write-Host '-- Top-level folders on fixed drives (system folders hidden), GB'
$rows = foreach ($d in $fixed) {
    Get-ChildItem -LiteralPath "$d\" -Directory -Force -ErrorAction SilentlyContinue |
        Where-Object { $ReportSkipTop -notcontains $_.Name } |
        ForEach-Object { [pscustomobject]@{ GB = (Get-SizeGB $_.FullName); Folder = $_.FullName } }
}
if ($rows) { $rows | Sort-Object GB -Descending | Format-Table -AutoSize | Out-String | Write-Host }
else { Write-Host '  none' }

Write-Host "-- Files bigger than $BigFileGB GB (non-system drives, Public, processed profiles)"
$scanRoots = @($pub) + @($Targets | ForEach-Object { $_.Path })
foreach ($d in $fixed) { if ($d -ine $env:SystemDrive) { $scanRoots += "$d\" } }
$big = foreach ($root in $scanRoots) {
    if (-not (Test-Path -LiteralPath $root)) { continue }
    Get-ChildItem -LiteralPath $root -Recurse -Force -File -ErrorAction SilentlyContinue |
        Where-Object { $_.Length -gt ($BigFileGB * 1GB) } |
        ForEach-Object { [pscustomobject]@{ GB = [math]::Round($_.Length / 1GB, 1); File = $_.FullName } }
}
if ($big) { $big | Sort-Object GB -Descending | Select-Object -First 30 | Format-Table -AutoSize | Out-String | Write-Host }
else { Write-Host '  none' }

# =====================================================================
#  SUMMARY
# =====================================================================
Write-Host "`nSummary: done=$($script:Stats.Done)  failed=$($script:Stats.Failed)  skipped=$($script:Stats.Skipped)" -ForegroundColor Cyan
if ($DryRun) { Write-Host 'This was a dry run. Run again with -Apply to clean.' -ForegroundColor Yellow }
else {
    Write-Host 'Reboot, then run the script once more (without -Apply): the lists should be empty.'
}
Write-Host "Log: $logFile"
try { Stop-Transcript | Out-Null } catch { }