Public/Invoke-TechCareCleanup.ps1

function Invoke-TechCareCleanup {
    <#
    .SYNOPSIS
        Libera espacio en disco de forma segura y medida.
    .DESCRIPTION
        Borra temporales de usuario y de sistema, cache de Internet, informes de
        errores de Windows, la cache de descargas de Windows Update (parando y
        rearrancando los servicios en el orden correcto), la papelera de reciclaje
        y los componentes superados del almacen de Windows.

        No toca documentos, perfiles, descargas, instaladores ni aplicaciones. Lo
        que se borra se mide antes y despues, de modo que el informe no promete
        espacio que no se recupero: un fichero en uso no se borra y no se cuenta.
    .PARAMETER SkipWindowsUpdateCache
        No toca la cache de Windows Update. Util si hay una actualizacion a medio
        descargar que no se quiere perder.
    .EXAMPLE
        Invoke-TechCareCleanup -WhatIf

        Lista cada carpeta y cuanto ocupa, sin borrar nada.
    .EXAMPLE
        Invoke-TechCareCleanup -Confirm:$false

        Limpia sin preguntar por cada carpeta.
    .EXAMPLE
        Invoke-TechCareCleanup -SkipWindowsUpdateCache -Confirm:$false

        Limpieza sin parar wuauserv ni bits.
    .OUTPUTS
        PSCustomObject
    .LINK
        https://github.com/earbona23/TechCare
    #>

    [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'Medium')]
    [OutputType([pscustomobject])]
    param(
        [switch]$SkipWindowsUpdateCache
    )

    if (-not (Test-TechCarePlatform)) { throw $script:PlatformMessage }

    $findings = New-Object -TypeName System.Collections.ArrayList
    $actions  = New-Object -TypeName System.Collections.ArrayList
    $recs     = New-Object -TypeName System.Collections.ArrayList
    $status   = 'Green'
    $mod      = 'Cleanup'
    $deleted  = [int64]0

    $freeBefore = Get-TechCareFreeSpace

    $rawTargets = @(
        $env:TEMP,
        (Join-Path -Path $env:LOCALAPPDATA -ChildPath 'Temp'),
        (Join-Path -Path $env:SystemRoot -ChildPath 'Temp'),
        (Join-Path -Path $env:LOCALAPPDATA -ChildPath 'Microsoft\Windows\INetCache'),
        (Join-Path -Path $env:LOCALAPPDATA -ChildPath 'Microsoft\Windows\WER')
    )

    # %TEMP% y %LOCALAPPDATA%\Temp son la misma carpeta en una instalacion normal,
    # pero %TEMP% puede llegar en formato 8.3. Sin normalizar, la carpeta se
    # recorreria dos veces y el espacio liberado saldria contado por duplicado.
    $targets = New-Object -TypeName System.Collections.ArrayList
    foreach ($candidate in $rawTargets) {
        if ([string]::IsNullOrWhiteSpace($candidate)) { continue }
        try {
            $full = [System.IO.Path]::GetFullPath($candidate)
        } catch {
            continue
        }
        if (-not (Test-Path -LiteralPath $full)) { continue }
        $already = $false
        foreach ($seen in $targets) {
            if ($seen.Equals($full, [System.StringComparison]::OrdinalIgnoreCase)) { $already = $true; break }
        }
        if (-not $already) { [void]$targets.Add($full) }
    }

    $step = 0
    foreach ($target in $targets) {
        $step++
        $sizeBefore = Get-TechCarePathSize -Path $target
        if ($sizeBefore -le 0) {
            [void]$findings.Add(('{0}: ya vacia.' -f $target))
            continue
        }

        if (-not $PSCmdlet.ShouldProcess($target, ('Eliminar contenido ({0})' -f (Format-TechCareSize -Bytes $sizeBefore)))) {
            [void]$findings.Add(('Vista previa: {0} contiene {1} recuperables.' -f $target, (Format-TechCareSize -Bytes $sizeBefore)))
            continue
        }

        Write-Progress -Activity 'TechCare' -Status ('Limpiando ' + $target) -PercentComplete ([math]::Min(50, 10 + ($step * 8)))
        $locked = 0
        Get-ChildItem -LiteralPath $target -Force -ErrorAction SilentlyContinue | ForEach-Object {
            try {
                Remove-Item -LiteralPath $_.FullName -Recurse -Force -ErrorAction Stop
            } catch {
                # Un fichero abierto por otro proceso es lo normal en %TEMP%; se
                # cuenta y se sigue, nunca se fuerza el cierre de un handle ajeno.
                $locked++
            }
        }

        # Se vuelve a medir en lugar de dar por liberado el tamano inicial: los
        # ficheros bloqueados siguen ahi y no deben aparecer en el informe.
        $sizeAfter = Get-TechCarePathSize -Path $target
        $gain = $sizeBefore - $sizeAfter
        if ($gain -lt 0) { $gain = [int64]0 }
        $deleted += $gain
        [void]$actions.Add(('Limpiado {0}: {1} eliminados, {2} elementos bloqueados, {3} restantes.' -f
            $target, (Format-TechCareSize -Bytes $gain), $locked, (Format-TechCareSize -Bytes $sizeAfter)))
        Write-TechCareLog -ModuleName $mod -Message ('Limpiado {0} ({1}).' -f $target, (Format-TechCareSize -Bytes $gain))
    }

    if (-not $SkipWindowsUpdateCache) {
        $wuCache = Join-Path -Path $env:SystemRoot -ChildPath 'SoftwareDistribution\Download'
        # Los parentesis alrededor de Test-TechCareAdmin son obligatorios. Sin
        # ellos PowerShell pasa "-and" y el resultado de Test-Path como
        # argumentos posicionales a la funcion, que no los declara, y descarta
        # su VALOR: el if se decide solo con lo que devuelve Test-TechCareAdmin.
        # Medido con contadores: la segunda condicion SI se ejecuta, lo que se
        # pierde es su resultado y el cortocircuito. Aqui es Test-Path, que no
        # tiene efecto lateral, pero la forma rota ejecutaria sin guarda
        # cualquier segunda condicion que si lo tuviera.
        if ((Test-TechCareAdmin) -and (Test-Path -LiteralPath $wuCache)) {
            $sizeBefore = Get-TechCarePathSize -Path $wuCache
            if ($PSCmdlet.ShouldProcess($wuCache, ('Vaciar cache de Windows Update ({0})' -f (Format-TechCareSize -Bytes $sizeBefore)))) {
                Write-Progress -Activity 'TechCare' -Status 'Cache de Windows Update' -PercentComplete 60
                # dosvc (Optimizacion de distribucion) tambien mantiene handles en
                # esa carpeta; pararlo evita dejar media cache sin borrar.
                $services = @('wuauserv', 'bits', 'dosvc')
                $stopped = New-Object -TypeName System.Collections.ArrayList
                foreach ($name in $services) {
                    $svc = Get-Service -Name $name -ErrorAction SilentlyContinue
                    if ($null -eq $svc -or $svc.Status -ne 'Running') { continue }
                    try {
                        Stop-Service -Name $name -Force -ErrorAction Stop
                        [void]$stopped.Add($name)
                    } catch {
                        [void]$findings.Add(('No se pudo parar el servicio {0}: {1}' -f $name, $_.Exception.Message))
                    }
                }
                try {
                    $wuLocked = 0
                    Get-ChildItem -LiteralPath $wuCache -Force -ErrorAction SilentlyContinue | ForEach-Object {
                        try {
                            Remove-Item -LiteralPath $_.FullName -Recurse -Force -ErrorAction Stop
                        } catch {
                            # Una descarga en curso mantiene el fichero abierto aunque
                            # los servicios esten parados; se cuenta y se sigue.
                            $wuLocked++
                        }
                    }
                    if ($wuLocked -gt 0) {
                        [void]$findings.Add(('{0} elementos de la cache de Windows Update quedaron bloqueados.' -f $wuLocked))
                    }
                    $gain = $sizeBefore - (Get-TechCarePathSize -Path $wuCache)
                    if ($gain -lt 0) { $gain = [int64]0 }
                    $deleted += $gain
                    [void]$actions.Add(('Cache de Windows Update vaciada ({0}).' -f (Format-TechCareSize -Bytes $gain)))
                } catch {
                    $status = 'Yellow'
                    [void]$findings.Add('No se pudo vaciar por completo la cache de Windows Update: ' + $_.Exception.Message)
                } finally {
                    # El finally es el punto del modulo: dejar servicios de
                    # actualizacion parados es peor que no haber limpiado.
                    foreach ($name in $stopped) {
                        try {
                            Start-Service -Name $name -ErrorAction Stop
                        } catch {
                            $status = 'Red'
                            [void]$findings.Add(("El servicio $name NO rearranco: " + $_.Exception.Message))
                            [void]$recs.Add("Arrancar a mano el servicio $name antes de cerrar la intervencion.")
                        }
                    }
                }
            }
        } elseif (-not (Test-TechCareAdmin)) {
            [void]$findings.Add('Cache de Windows Update omitida: requiere administrador.')
        }
    }

    if ($PSCmdlet.ShouldProcess('Papelera de reciclaje', 'Vaciar')) {
        try {
            Clear-RecycleBin -Force -ErrorAction Stop
            [void]$actions.Add('Papelera de reciclaje vaciada.')
        } catch {
            # Clear-RecycleBin lanza si ya esta vacia, que no es un problema.
            [void]$findings.Add('Papelera vacia o no accesible: ' + $_.Exception.Message)
        }
    }

    $dism = Join-Path -Path $env:SystemRoot -ChildPath 'System32\DISM.exe'
    if ((Test-TechCareAdmin) -and (Test-Path -LiteralPath $dism)) {
        if ($PSCmdlet.ShouldProcess('Almacen de componentes de Windows', 'DISM /Online /Cleanup-Image /StartComponentCleanup')) {
            Write-Progress -Activity 'TechCare' -Status 'Limpieza de componentes' -PercentComplete 80
            Write-TechCareLog -ModuleName $mod -Message 'DISM StartComponentCleanup (sin /ResetBase: las actualizaciones siguen siendo desinstalables).'
            $r = Invoke-TechCareProcess -FilePath $dism -ArgumentList @('/Online', '/Cleanup-Image', '/StartComponentCleanup')
            if ($r.ExitCode -eq 0) {
                [void]$actions.Add('DISM StartComponentCleanup completado.')
            } else {
                $status = 'Yellow'
                [void]$findings.Add(('StartComponentCleanup devolvio codigo {0}.' -f $r.ExitCode))
            }
            Write-TechCareLog -ModuleName $mod -Level Detail -Message 'Salida de DISM registrada.' -ErrorText $r.Output
        }
    }

    $freeAfter = Get-TechCareFreeSpace
    $measured  = $freeAfter - $freeBefore

    [void]$findings.Add(('Espacio libre antes: {0} / despues: {1}' -f (Format-TechCareSize -Bytes $freeBefore), (Format-TechCareSize -Bytes $freeAfter)))
    [void]$findings.Add(('Eliminado por TechCare: {0}' -f (Format-TechCareSize -Bytes $deleted)))
    if ($measured -gt 0) {
        [void]$findings.Add(('Variacion de espacio libre del volumen: +{0}' -f (Format-TechCareSize -Bytes $measured)))
    } elseif ($measured -lt 0) {
        # El volumen puede perder espacio durante la limpieza por culpa de otro
        # proceso. Decirlo evita que el informe parezca incoherente.
        [void]$findings.Add(('El volumen tiene {0} menos libres que al empezar: otro proceso escribio durante la limpieza.' -f (Format-TechCareSize -Bytes ([math]::Abs($measured)))))
    }

    Write-Progress -Activity 'TechCare' -Status 'Limpieza completada' -Completed
    Write-TechCareLog -ModuleName $mod -Level Success -Message ('Limpieza finalizada. Eliminado: {0}' -f (Format-TechCareSize -Bytes $deleted))

    return Add-TechCareResult -Module $mod -Status $status -Findings $findings.ToArray() -Actions $actions.ToArray() -Recommendations $recs.ToArray() -SpaceFreedBytes $deleted
}