Skip to content

Free Up User Disk Space (Package)

This Tanium package frees disk space on Windows endpoints. It removes files that Windows does not need. It sets old OneDrive files to online-only. It asks the signed-in user before it empties old items from the Recycle Bin.

Test status

The package was run through Tanium on endpoints with the default settings and with all options set to Yes, including Windows.old. All runs completed with exit code 0. The time limit for the user part was not tested. Deploy to one endpoint first and read the action log.

Options that you cannot undo

The Windows.old option removes the previous Windows version. After this, the endpoint cannot go back to that version. Use this option only when the upgrade is stable.

What the package does

The package has two scripts:

Script Runs as Function
Invoke-DiskCleanup.ps1 SYSTEM Does the cleanup for the full endpoint. Starts the second script for each signed-in user.
Invoke-UserDiskCleanup.ps1 The signed-in user Does the cleanup for that user. Shows the Recycle Bin question to that user.

The Tanium Client runs scripts as SYSTEM. SYSTEM cannot show a window to a user, and SYSTEM cannot get access to the OneDrive of a user. Thus, the first script starts the second script in the session of each signed-in user. It uses a scheduled task that runs one time. The task uses the normal rights of the user, not administrator rights. The first script removes the task when the work is complete.

Steps that always run

These steps run on the full endpoint. They run also when no user is signed in.

Item Location Rule
Windows temporary files C:\Windows\Temp Removes files older than 7 days
Windows error reports C:\ProgramData\Microsoft\Windows\WER\ReportArchive and ReportQueue Removes all
Delivery Optimization cache Managed by Windows Removes all

These steps run for each signed-in user:

Item Location Rule
Temporary files of the user %LOCALAPPDATA%\Temp Removes files older than 7 days
Error reports of the user %LOCALAPPDATA%\Microsoft\Windows\WER Removes all
OneDrive files All folders that OneDrive keeps in sync for the user Sets files that the user did not use for 14 days to online-only
Recycle Bin C:\$Recycle.Bin\<user ID> on each fixed drive Asks the user before it removes items older than 30 days

Optional steps

All optional steps are off by default.

Option Location Rule
Windows Update downloads C:\Windows\SoftwareDistribution\Download Removes all. Stops the Windows Update service for a short time.
Crash dump files C:\Windows\Minidump, C:\Windows\LiveKernelReports, C:\Windows\MEMORY.DMP Removes files older than 7 days
Browser caches Chrome, Edge, and Firefox cache folders of each signed-in user Removes cache files only
Windows.old C:\Windows.old Removes the previous Windows version

How each step works

OneDrive

The script finds the files that are on the endpoint and that the user did not use for 14 days. A file is "used" when the user opened it, changed it, or created it. The script uses the newest of these three dates.

The script sets each of these files to online-only. This is the same as Free up space in File Explorer. The file stays in OneDrive. Windows downloads the file again when the user opens it.

The script does not change these files:

  • Files that the user marked Always keep on this device
  • Files that are already online-only
  • Hidden files and system files
  • Empty files

The OneDrive step has a time limit of 8 minutes for each user. If the time limit occurs, the script stops this step. The next run continues with the remaining files.

Note

The script does not change files marked Always keep on this device. On some endpoints, most OneDrive files have this mark. On these endpoints, the OneDrive step frees little space.

Recycle Bin

The script finds the Recycle Bin items that the user deleted more than 30 days ago. If there are no such items, the script does not show a question.

If there are such items, the user sees this window:

  • The window shows the number of old items and their size.
  • Delete old items removes these items permanently.
  • Keep them closes the window. The script does not remove items.
  • The Enter key and the Esc key also select Keep them.
  • If the user does not answer, the window closes after the time limit. The script does not remove items.

The script never removes items that the user deleted in the last 30 days.

Browser caches

The script removes only the cache files of Chrome, Edge, and Firefox. It does not remove bookmarks, passwords, history, cookies, or sign-ins. If the browser is open, the script does not remove the files that the browser uses.

Windows Update downloads

If Windows must restart to complete an update installation, the script does not do this step. Run the package again after the restart.

If an update is downloaded but not installed, Windows downloads it again.

Windows.old

The script uses the Windows Disk Cleanup tool to remove C:\Windows.old. This is the same as Previous Windows installation(s) in Settings > System > Storage > Temporary files.

The script does not remove C:\Windows.old with its own commands. This folder contains links to user profile folders in C:\Users. A tool that follows these links can delete user data. Disk Cleanup does not follow these links.

The step does these actions:

  1. Tells Windows that the endpoint cannot go back to the previous version.
  2. Sets Disk Cleanup profile number 731 to remove Previous Windows installation(s) only.
  3. Starts Disk Cleanup with profile 731.
  4. Monitors C:\Windows.old every 15 seconds. The step stops when Disk Cleanup removes the folder, or after 40 minutes.
  5. Removes profile 731 from the registry.

This step usually takes 5 to 30 minutes.

The script does not do this step if a Windows upgrade is in progress.

Safety rules

The scripts obey these rules:

  • They never follow a link to a folder. They remove only the link, not the target of the link.
  • In C:\Windows\Temp, they do not open folders that a standard user created. This prevents a standard user from making the SYSTEM script delete files in a different location.
  • They do not remove files that are in use.
  • They write counts and sizes to the log. They do not write file names or folder names.
  • They do not restart the endpoint.

Requirements

  • Windows 10 or Windows 11
  • Windows PowerShell 5.1
  • The Tanium Client
  • For the OneDrive step: the OneDrive sync app, with the user signed in
  • For the Windows.old option: C:\Windows\System32\cleanmgr.exe on the endpoint

Create the package

  1. In the Tanium Console, go to Administration > Content > Packages.
  2. Click Create Package.
  3. Fill in the fields with the values in the table below.
  4. In the Files section, add the two script files.
  5. In the Parameters section, add the seven parameters in the second table below.
  6. Click Save.
Field Value
Package Name Free Up User Disk Space
Display Name Free Up User Disk Space
Description Frees disk space. Always: removes temp files older than 7 days, Windows error reports, and the Delivery Optimization cache; sets OneDrive files not used for a set number of days (default 14) to online-only; asks the signed-in user before removing Recycle Bin items older than a set number of days (default 30). Optional, off by default: Windows Update downloads, old crash dumps, browser caches, and C:\Windows.old. Windows only.
Content Set Your custom content set
Command cmd.exe /d /c powershell.exe -ExecutionPolicy Bypass -WindowStyle Hidden -NonInteractive -NoProfile -File Invoke-DiskCleanup.ps1 "$1" "$2" "$3" "$4" "$5" "$6" "$7"
Command Timeout 60 minutes
Download Timeout 10 minutes
Ignore Action Lock Clear (not selected)
Launch this package command in a process group Selected
Files Invoke-DiskCleanup.ps1, Invoke-UserDiskCleanup.ps1

Parameters

Add the parameters in this order. The order sets the number ($1 to $7).

Key Type Label Values Default
$1 Numeric OneDrive: days since last use 1 to 3650 14
$2 Numeric Recycle Bin: days since deleted 1 to 3650 30
$3 Numeric Seconds to wait for the user's answer 30 to 600 300
$4 Drop Down List Empty the Windows Update download folder No, Yes No
$5 Drop Down List Remove crash dump files older than 7 days No, Yes No
$6 Drop Down List Empty browser caches (Chrome, Edge, Firefox) No, Yes No
$7 Drop Down List Remove C:\Windows.old (no going back to the previous Windows version) No, Yes No

Deploy the package

  1. In the Tanium Console, go to Interact.
  2. In the question bar, type this question:

    Get Computer Name and Disk Free Space from all machines with Is Windows equals True
    
  3. Press Enter.

  4. Sort the results by Disk Free Space.
  5. Select the endpoints that you want to clean.
  6. Click Deploy Action.
  7. In the Deployment Package field, type Free Up User Disk Space. Select the package.
  8. Set the parameters. For the first run, keep the default values.
  9. Click Show preview to continue.
  10. Make sure that the list shows the correct endpoints.
  11. Click Deploy Action.

Tip

Users can see the Recycle Bin question. Tell the users before you deploy the package.

Exit codes

Code Meaning
0 The package completed. This includes "no user is signed in" and "the user selected Keep them".
1 A step failed. Read the action log.
2 The cleanup for a user did not complete in the time limit. The work that completed stays complete.

Read the results

The action log shows each step, the number of items, and the space freed. It also shows the results of the user part.

To read the action log from the Tanium Console:

  1. Go to Interact.
  2. Type this question. Replace <action ID> with the ID of your action.

    Get Tanium Action Log[<action ID>] from all machines with Computer Name equals ENDPOINT-01
    
  3. Press Enter.

To compare the free space before and after the action, ask this question:

Get Computer Name and Disk Free Space from all machines with Is Windows equals True

Example log

[INFO] Free space on C: before: 23.80 GB
[INFO] Signed-in people found: EXAMPLE\user01
[INFO] Windows temp folder (older than 7 days): 8 items removed, about 2.7 MB freed.
[INFO] Delivery Optimization cache: emptied, about 798.1 MB freed.
[INFO] Crash dump files (older than 7 days): 2 items removed, about 3.65 GB freed.
[INFO] Windows.old: removed, about 25.87 GB freed.
[INFO] Free space on C: after: 56.91 GB (change: 33.11 GB)
[INFO] Disk cleanup finished with exit code 0.

Troubleshooting

Log message Cause Action
Nobody is signed in. No user is signed in. The user steps do not run. The steps for the full endpoint run. Deploy again when a user is signed in.
OneDrive is not running for this person. The OneDrive sync app is not open. The files are marked. OneDrive frees the space when it starts.
the 8-minute time limit was reached The OneDrive folder has many files. Deploy the package again. The next run continues.
no answer within ... seconds The user did not answer the Recycle Bin question. Nothing was removed from the Recycle Bin. Deploy again later.
Windows is waiting for a restart An update installation is not complete. Restart the endpoint. Then deploy again.
Windows.old: still there after Disk Cleanup Disk Cleanup did not complete. Restart the endpoint. Then deploy again with $7 set to Yes.
Delivery Optimization cache: could not be emptied The Delivery Optimization module is not available. No action is necessary.

Scripts

The package has two scripts. Select a tab to see each script.

<#
.SYNOPSIS
    Tanium package entry point. Frees disk space on this computer and for the people signed in to it.

.DESCRIPTION
    The Tanium Client runs this script as SYSTEM. It does two kinds of work.

    A. Computer-wide cleanup, done by this script. It runs even when nobody is signed in.
         Always:
           - Windows temp folder (C:\Windows\Temp): files older than 7 days
           - Windows error report files (C:\ProgramData\Microsoft\Windows\WER)
           - Delivery Optimization cache
         Only when switched on:
           - Windows Update download folder (C:\Windows\SoftwareDistribution\Download)
           - Crash dump files older than 7 days
           - C:\Windows.old (the previous Windows version kept after an upgrade), removed
             with Windows' own Disk Cleanup "Previous Windows installation(s)" option

    B. Per-person cleanup, done by Invoke-UserDiskCleanup.ps1. SYSTEM has no desktop, cannot
       show a message to a person, and is not signed in to anyone's OneDrive. So this script
       copies that second script to a folder standard users can read but not change
       ("C:\Program Files\DiskCleanup") and starts it inside each signed-in person's own
       session with a one-time scheduled task that uses the person's normal rights.
         - The person's temp folder: files older than 7 days
         - Browser caches (only when switched on)
         - OneDrive: files not used for a set number of days become online-only
         - Recycle Bin: the person is asked before items older than a set number of days are removed

.PARAMETER OneDriveDays
    $1  OneDrive files not used for this many days are set to online-only. Default 14.
.PARAMETER RecycleBinDays
    $2  The person is asked about Recycle Bin items deleted more than this many days ago. Default 30.
.PARAMETER PromptTimeoutSeconds
    $3  How long the Recycle Bin question stays on screen. No answer counts as "no". Default 300.
.PARAMETER CleanWindowsUpdateDownloads
    $4  Yes or No. Empty the Windows Update download folder. Default No.
.PARAMETER CleanCrashDumps
    $5  Yes or No. Remove crash dump files older than 7 days. Default No.
.PARAMETER CleanBrowserCaches
    $6  Yes or No. Empty the Chrome, Edge, and Firefox caches of signed-in people. Default No.
.PARAMETER RemoveWindowsOld
    $7  Yes or No. Remove C:\Windows.old. After this the computer cannot go back to the
        previous Windows version. Default No.

.NOTES
    Tanium command line:
      cmd.exe /d /c powershell.exe -ExecutionPolicy Bypass -WindowStyle Hidden -NonInteractive -NoProfile -File Invoke-DiskCleanup.ps1 "$1" "$2" "$3" "$4" "$5" "$6" "$7"

    Exit codes:
      0 = finished (this includes "nobody signed in" and "the person said no")
      1 = something failed (see the action log)
      2 = the per-person cleanup did not finish in time for at least one person and was stopped
#>
[CmdletBinding()]
param(
    [Parameter(Position = 0)][string]$OneDriveDays = '14',
    [Parameter(Position = 1)][string]$RecycleBinDays = '30',
    [Parameter(Position = 2)][string]$PromptTimeoutSeconds = '300',
    [Parameter(Position = 3)][string]$CleanWindowsUpdateDownloads = 'No',
    [Parameter(Position = 4)][string]$CleanCrashDumps = 'No',
    [Parameter(Position = 5)][string]$CleanBrowserCaches = 'No',
    [Parameter(Position = 6)][string]$RemoveWindowsOld = 'No'
)

$ErrorActionPreference = 'Stop'

# Empty values from Tanium fall back to the defaults.
if ([string]::IsNullOrWhiteSpace($OneDriveDays))                { $OneDriveDays = '14' }
if ([string]::IsNullOrWhiteSpace($RecycleBinDays))              { $RecycleBinDays = '30' }
if ([string]::IsNullOrWhiteSpace($PromptTimeoutSeconds))        { $PromptTimeoutSeconds = '300' }
if ([string]::IsNullOrWhiteSpace($CleanWindowsUpdateDownloads)) { $CleanWindowsUpdateDownloads = 'No' }
if ([string]::IsNullOrWhiteSpace($CleanCrashDumps))             { $CleanCrashDumps = 'No' }
if ([string]::IsNullOrWhiteSpace($CleanBrowserCaches))          { $CleanBrowserCaches = 'No' }
if ([string]::IsNullOrWhiteSpace($RemoveWindowsOld))            { $RemoveWindowsOld = 'No' }

# If the Tanium Client started the 32-bit PowerShell on 64-bit Windows, start again in the
# 64-bit one. The 32-bit one sees a different Program Files and a different System32.
if ([Environment]::Is64BitOperatingSystem -and -not [Environment]::Is64BitProcess) {
    $powershell64 = Join-Path $env:windir 'Sysnative\WindowsPowerShell\v1.0\powershell.exe'
    if (Test-Path -LiteralPath $powershell64) {
        & $powershell64 -ExecutionPolicy Bypass -NoProfile -NonInteractive -File $PSCommandPath `
            $OneDriveDays $RecycleBinDays $PromptTimeoutSeconds $CleanWindowsUpdateDownloads $CleanCrashDumps $CleanBrowserCaches $RemoveWindowsOld
        exit $LASTEXITCODE
    }
}

# ----------------------------------------------------------------------------------------
# Fixed settings
# ----------------------------------------------------------------------------------------
$TempDays           = 7                                   # temp files and crash dumps newer than this are kept
$MaxOneDriveMinutes = 8                                   # time limit for the OneDrive step, per person
$WorkRoot           = Join-Path $env:ProgramFiles 'DiskCleanup'
$UserScriptName     = 'Invoke-UserDiskCleanup.ps1'
$TaskPrefix         = 'DiskCleanup-'
$RunId              = [guid]::NewGuid().ToString('N')     # lets us tell this run's results from an older run
$ExitCode           = 0
$Jobs               = @()

# Folders made by Windows or by an administrator: SYSTEM, Administrators, TrustedInstaller.
$TrustedOwners = @(
    'S-1-5-18',
    'S-1-5-32-544',
    'S-1-5-80-956008885-3418522649-1831038044-1853292631-2271478464'
)

$ATTR_READONLY  = 0x00000001
$ATTR_DIRECTORY = 0x00000010
$ATTR_REPARSE   = 0x00000400   # link or other special folder

# ----------------------------------------------------------------------------------------
# General helpers
# ----------------------------------------------------------------------------------------
function Write-Log {
    # Write-Host is used on purpose: it goes to the Tanium action log and does not
    # get mixed into the values that functions return.
    param([string]$Message, [string]$Level = 'INFO')
    Write-Host ('{0} [{1}] {2}' -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'), $Level, $Message)
}

function Get-WholeNumber {
    # Turns a Tanium parameter (always text) into a number and checks that it is in range.
    param([string]$Value, [string]$Name, [int]$Min, [int]$Max)
    $number = 0
    if (-not [int]::TryParse($Value.Trim(), [ref]$number)) {
        throw "$Name must be a whole number. Received: '$Value'"
    }
    if ($number -lt $Min -or $number -gt $Max) {
        throw "$Name must be between $Min and $Max. Received: $number"
    }
    return $number
}

function Get-YesNo {
    param([string]$Value, [string]$Name)
    $text = $Value.Trim().ToLowerInvariant()
    if (@('yes', 'y', 'true', '1', 'on') -contains $text)  { return $true }
    if (@('no', 'n', 'false', '0', 'off') -contains $text) { return $false }
    throw "$Name must be Yes or No. Received: '$Value'"
}

function Get-FreeBytes {
    return (New-Object System.IO.DriveInfo($env:SystemDrive)).AvailableFreeSpace
}

function Format-Size {
    param([double]$Bytes)
    if ($Bytes -ge 1GB) { return '{0:N2} GB' -f ($Bytes / 1GB) }
    if ($Bytes -ge 1MB) { return '{0:N1} MB' -f ($Bytes / 1MB) }
    if ($Bytes -ge 1KB) { return '{0:N0} KB' -f ($Bytes / 1KB) }
    return '{0:N0} bytes' -f $Bytes
}

function Get-SignedInUser {
    # Every signed-in person has an explorer.exe (the Windows desktop) running under their
    # account. This also finds people connected over Remote Desktop.
    $found = @{}
    $desktops = @(Get-CimInstance -ClassName Win32_Process -Filter "Name='explorer.exe'" -ErrorAction SilentlyContinue)
    foreach ($desktop in $desktops) {
        try {
            $sid = (Invoke-CimMethod -InputObject $desktop -MethodName GetOwnerSid).Sid
            if ([string]::IsNullOrEmpty($sid)) { continue }
            if ($found.ContainsKey($sid)) { continue }
            # Real people only: local or domain accounts (S-1-5-21-) and Entra ID accounts (S-1-12-1-).
            if ($sid -notmatch '^S-1-(5-21|12-1)-') { continue }
            $owner = Invoke-CimMethod -InputObject $desktop -MethodName GetOwner
            $found[$sid] = [pscustomobject]@{
                Sid  = $sid
                Name = ('{0}\{1}' -f $owner.Domain, $owner.User)
            }
        }
        catch {
            Write-Log ("Could not read the owner of explorer.exe (process {0}): {1}" -f $desktop.ProcessId, $_.Exception.Message) 'WARN'
        }
    }
    return @($found.Values)
}

function Read-ResultLog {
    # The result log is written by the signed-in person, so it is only read as text.
    param([string]$Path)
    try {
        if (-not [System.IO.File]::Exists($Path)) { return @() }
        if ((New-Object System.IO.FileInfo($Path)).Length -gt 1MB) {
            return @('[The result log is larger than expected and was not read.]')
        }
        return @(Get-Content -LiteralPath $Path -Encoding UTF8 -ErrorAction Stop)
    }
    catch {
        return @()
    }
}

function Test-RestartPending {
    # True when Windows is waiting for a restart to finish installing updates.
    try {
        $machine = [Microsoft.Win32.RegistryKey]::OpenBaseKey([Microsoft.Win32.RegistryHive]::LocalMachine, [Microsoft.Win32.RegistryView]::Registry64)
        foreach ($keyPath in @(
                'SOFTWARE\Microsoft\Windows\CurrentVersion\Component Based Servicing\RebootPending',
                'SOFTWARE\Microsoft\Windows\CurrentVersion\WindowsUpdate\Auto Update\RebootRequired')) {
            $key = $machine.OpenSubKey($keyPath)
            if ($key) { $key.Close(); return $true }
        }
    }
    catch { }
    return $false
}

# ----------------------------------------------------------------------------------------
# Safe file removal
# ----------------------------------------------------------------------------------------
function Get-OwnerSid {
    param([string]$Path)
    try {
        $security = [System.IO.Directory]::GetAccessControl($Path, [System.Security.AccessControl.AccessControlSections]::Owner)
        return $security.GetOwner([System.Security.Principal.SecurityIdentifier]).Value
    }
    catch { return '' }
}

function New-Stats {
    return @{ Removed = 0; Bytes = [long]0; Kept = 0; SkippedFolders = 0 }
}

function Clear-OldFiles {
    # Removes files in $Folder (and the folders below it) that are older than $Cutoff.
    #
    # Two safety rules, because this runs as SYSTEM:
    #   1. A shortcut-to-a-folder (junction or symbolic link) is never followed. Only the
    #      shortcut is removed, never what it points to.
    #   2. With -TrustedOnly, it only goes into folders made by Windows or an administrator.
    #      C:\Windows\Temp lets standard users create folders. Skipping those folders means a
    #      standard user cannot trick this script into deleting something somewhere else.
    param([string]$Folder, [datetime]$Cutoff, [bool]$TrustedOnly, [hashtable]$Stats)

    try { $entries = @((New-Object System.IO.DirectoryInfo($Folder)).GetFileSystemInfos()) }
    catch { $Stats.Kept += 1; return }

    foreach ($entry in $entries) {
        $attributes = [int]$entry.Attributes
        $isFolder   = ($attributes -band $ATTR_DIRECTORY) -ne 0
        $isLink     = (($attributes -band $ATTR_REPARSE) -ne 0) -and [bool]$entry.LinkType

        # Age = the newer of "last changed" and "created". Read before anything inside is removed.
        $newest = $entry.LastWriteTime
        if ($entry.CreationTime -gt $newest) { $newest = $entry.CreationTime }

        if ($isFolder -and -not $isLink) {
            if ($TrustedOnly -and ($TrustedOwners -notcontains (Get-OwnerSid -Path $entry.FullName))) {
                $Stats.SkippedFolders += 1
                continue
            }
            Clear-OldFiles -Folder $entry.FullName -Cutoff $Cutoff -TrustedOnly $TrustedOnly -Stats $Stats
            if ($newest -lt $Cutoff) {
                try { [System.IO.Directory]::Delete($entry.FullName, $false) } catch { }    # only works when it is empty
            }
            continue
        }

        if ($newest -ge $Cutoff) { continue }

        try {
            $size = 0
            if (-not $isFolder) { $size = $entry.Length }
            if ($attributes -band $ATTR_READONLY) {
                $entry.Attributes = [System.IO.FileAttributes]($attributes -band (-bnot $ATTR_READONLY))
            }
            if ($isFolder) { [System.IO.Directory]::Delete($entry.FullName, $false) }       # a shortcut: remove the shortcut only
            else           { [System.IO.File]::Delete($entry.FullName) }
            $Stats.Removed += 1
            $Stats.Bytes   += $size
        }
        catch {
            $Stats.Kept += 1                                                                 # in use or protected
        }
    }
}

function Write-StepResult {
    param([string]$Step, [hashtable]$Stats)
    Write-Log ("{0}: {1} items removed, about {2} freed." -f $Step, $Stats.Removed, (Format-Size $Stats.Bytes))
    if ($Stats.Kept -gt 0) {
        Write-Log ("{0}: {1} items are in use or protected and were left in place." -f $Step, $Stats.Kept)
    }
    if ($Stats.SkippedFolders -gt 0) {
        Write-Log ("{0}: {1} folders made by standard users were skipped." -f $Step, $Stats.SkippedFolders)
    }
}

# ----------------------------------------------------------------------------------------
# Computer-wide cleanup steps
# ----------------------------------------------------------------------------------------
function Clear-WindowsTemp {
    $folder = Join-Path $env:windir 'Temp'
    if (-not [System.IO.Directory]::Exists($folder)) { return }
    $stats = New-Stats
    Clear-OldFiles -Folder $folder -Cutoff (Get-Date).AddDays(-$TempDays) -TrustedOnly $true -Stats $stats
    Write-StepResult -Step ("Windows temp folder (older than {0} days)" -f $TempDays) -Stats $stats
}

function Clear-ErrorReports {
    $stats = New-Stats
    foreach ($name in 'ReportArchive', 'ReportQueue') {
        $folder = Join-Path $env:ProgramData ('Microsoft\Windows\WER\' + $name)
        if ([System.IO.Directory]::Exists($folder)) {
            Clear-OldFiles -Folder $folder -Cutoff ([datetime]::MaxValue) -TrustedOnly $true -Stats $stats
        }
    }
    Write-StepResult -Step 'Windows error reports' -Stats $stats
}

function Clear-DeliveryOptimization {
    $before = Get-FreeBytes
    try {
        # -DisableNameChecking hides the "unapproved verbs" warning. The redirects hide the
        # cmdlet's own "Deleting..." lines, so the action log only shows the result line below.
        Import-Module DeliveryOptimization -DisableNameChecking -ErrorAction Stop 3>$null
        Delete-DeliveryOptimizationCache -Force -ErrorAction Stop 3>$null 6>$null | Out-Null
        Write-Log ("Delivery Optimization cache: emptied, about {0} freed." -f (Format-Size ([math]::Max([long]0, (Get-FreeBytes) - $before))))
    }
    catch {
        Write-Log ("Delivery Optimization cache: could not be emptied on this computer ({0})." -f $_.Exception.Message) 'WARN'
    }
}

function Clear-WindowsUpdateDownloads {
    $folder = Join-Path $env:windir 'SoftwareDistribution\Download'
    if (-not [System.IO.Directory]::Exists($folder)) { return }

    if (Test-RestartPending) {
        Write-Log 'Windows Update downloads: Windows is waiting for a restart to finish installing updates. Skipped. Run again after the restart.' 'WARN'
        return
    }

    # Windows Update keeps these files open, so the service is stopped for a moment.
    $service    = Get-Service -Name 'wuauserv' -ErrorAction SilentlyContinue
    $wasRunning = $service -and $service.Status -eq 'Running'
    try {
        if ($wasRunning) {
            Stop-Service -Name 'wuauserv' -Force -ErrorAction Stop
            $service.WaitForStatus('Stopped', (New-TimeSpan -Seconds 60))
        }
        $stats = New-Stats
        Clear-OldFiles -Folder $folder -Cutoff ([datetime]::MaxValue) -TrustedOnly $false -Stats $stats
        Write-StepResult -Step 'Windows Update downloads' -Stats $stats
    }
    finally {
        if ($wasRunning) {
            try { Start-Service -Name 'wuauserv' -ErrorAction Stop }
            catch { Write-Log ("Windows Update downloads: the Windows Update service could not be started again: {0}" -f $_.Exception.Message) 'WARN' }
        }
    }
}

function Clear-CrashDumps {
    $cutoff = (Get-Date).AddDays(-$TempDays)
    $stats  = New-Stats
    foreach ($name in 'Minidump', 'LiveKernelReports') {
        $folder = Join-Path $env:windir $name
        if ([System.IO.Directory]::Exists($folder)) {
            Clear-OldFiles -Folder $folder -Cutoff $cutoff -TrustedOnly $false -Stats $stats
        }
    }
    $memoryDump = Join-Path $env:windir 'MEMORY.DMP'
    if ([System.IO.File]::Exists($memoryDump)) {
        $file = New-Object System.IO.FileInfo($memoryDump)
        if ($file.LastWriteTime -lt $cutoff) {
            try {
                $size = $file.Length
                [System.IO.File]::Delete($memoryDump)
                $stats.Removed += 1
                $stats.Bytes   += $size
            }
            catch { $stats.Kept += 1 }
        }
    }
    Write-StepResult -Step ("Crash dump files (older than {0} days)" -f $TempDays) -Stats $stats
}

function Remove-WindowsOld {
    # Removes C:\Windows.old with Windows' own Disk Cleanup handler, "Previous Windows
    # installation(s)". This is the same thing as ticking that box in Disk Cleanup or in
    # Settings > System > Storage. Windows knows how to remove its own folder safely: it does
    # not follow the shortcuts-to-folders inside it that point into the live C:\Users, and it
    # handles file paths that are too long for PowerShell.
    #
    #   1. Tell Windows that going back to the previous version is no longer possible.
    #   2. Switch on "Previous Windows installation(s)" (and nothing else) in a private
    #      Disk Cleanup profile, number 731.
    #   3. Run Disk Cleanup with that profile and wait until the folder is gone or time runs out.
    #   4. Remove the profile again.
    $target    = Join-Path ($env:SystemDrive + '\') 'Windows.old'
    $profileId = 731
    $flagName  = 'StateFlags{0:D4}' -f $profileId
    $maxWait   = New-TimeSpan -Minutes 40
    $cachesKey = 'SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\VolumeCaches'

    if (-not [System.IO.Directory]::Exists($target)) {
        Write-Log 'Windows.old: the folder does not exist. Nothing to do.'
        return
    }
    $setupRunning = @(Get-Process -Name 'SetupHost', 'SetupPrep', 'Windows10UpgraderApp', 'WindowsUpdateBox' -ErrorAction SilentlyContinue)
    if ($setupRunning.Count -gt 0) {
        Write-Log 'Windows.old: a Windows upgrade is running right now. Left in place.' 'WARN'
        return
    }
    $cleanmgr = Join-Path $env:windir 'System32\cleanmgr.exe'
    if (-not (Test-Path -LiteralPath $cleanmgr)) {
        Write-Log 'Windows.old: Disk Cleanup (cleanmgr.exe) is not on this computer. Left in place.' 'WARN'
        return
    }

    $before = Get-FreeBytes
    $dism   = Join-Path $env:windir 'System32\dism.exe'

    # Step 1
    try {
        & $dism /Online /Remove-OSUninstall 2>$null | Out-Null
        if ($LASTEXITCODE -eq 0) {
            Write-Log 'Windows.old: Windows was told that the previous version can no longer be restored.'
        }
        else {
            Write-Log ("Windows.old: Windows did not confirm that step (DISM code {0}). This is normal when the time to go back has already passed." -f $LASTEXITCODE)
        }
    }
    catch {
        Write-Log ("Windows.old: could not run DISM: {0}" -f $_.Exception.Message) 'WARN'
    }

    $machine = [Microsoft.Win32.RegistryKey]::OpenBaseKey([Microsoft.Win32.RegistryHive]::LocalMachine, [Microsoft.Win32.RegistryView]::Registry64)
    $caches  = $machine.OpenSubKey($cachesKey, $true)
    if (-not $caches) {
        Write-Log 'Windows.old: the Disk Cleanup settings were not found in the registry. Left in place.' 'WARN'
        return
    }

    $process = $null
    try {
        # Step 2. Clear profile 731 everywhere first, so only "Previous Installations" runs.
        foreach ($handlerName in $caches.GetSubKeyNames()) {
            $handler = $caches.OpenSubKey($handlerName, $true)
            if ($handler) {
                if ($null -ne $handler.GetValue($flagName)) { $handler.DeleteValue($flagName, $false) }
                $handler.Close()
            }
        }
        $previous = $caches.OpenSubKey('Previous Installations', $true)
        if (-not $previous) {
            Write-Log 'Windows.old: the "Previous Windows installation(s)" cleanup is not available on this computer. Left in place.' 'WARN'
            return
        }
        $previous.SetValue($flagName, 2, [Microsoft.Win32.RegistryValueKind]::DWord)
        $previous.Close()

        # Step 3. Disk Cleanup sometimes keeps running after its work is done, so the script
        # watches the folder instead of waiting for Disk Cleanup to close.
        Write-Log 'Windows.old: Disk Cleanup started. This usually takes 5 to 30 minutes.'
        $process = Start-Process -FilePath $cleanmgr -ArgumentList ('/sagerun:{0}' -f $profileId) -WindowStyle Hidden -PassThru
        $started = Get-Date
        $goneAt  = $null

        while (((Get-Date) - $started) -lt $maxWait) {
            Start-Sleep -Seconds 15
            $folderGone = -not [System.IO.Directory]::Exists($target)
            if ($folderGone -and $process.HasExited) { break }
            if ($folderGone) {
                if (-not $goneAt) { $goneAt = Get-Date }
                if (((Get-Date) - $goneAt).TotalSeconds -ge 60) { break }    # folder gone, give Disk Cleanup a minute to finish
            }
            elseif ($process.HasExited -and ((Get-Date) - $started).TotalSeconds -ge 60) {
                break                                                        # Disk Cleanup closed but the folder is still there
            }
        }
    }
    finally {
        # Step 4. Stop Disk Cleanup if it is still open and remove profile 731.
        if ($process -and -not $process.HasExited) {
            try { $process.Kill() } catch { }
        }
        try {
            $previous = $caches.OpenSubKey('Previous Installations', $true)
            if ($previous) {
                if ($null -ne $previous.GetValue($flagName)) { $previous.DeleteValue($flagName, $false) }
                $previous.Close()
            }
        }
        catch { }
        $caches.Close()
    }

    # Disk Cleanup sometimes leaves the empty Windows.old folder behind. Remove it only if it is
    # completely empty (this call refuses to delete a folder that still has anything in it).
    if ([System.IO.Directory]::Exists($target)) {
        try { [System.IO.Directory]::Delete($target, $false) } catch { }
    }

    $freed = Format-Size ([math]::Max([long]0, (Get-FreeBytes) - $before))
    if ([System.IO.Directory]::Exists($target)) {
        Write-Log ("Windows.old: still there after Disk Cleanup, about {0} freed. Run the package again after a restart. If it stays, open Settings > System > Storage > Temporary files on that computer and check 'Previous Windows installation(s)'." -f $freed) 'WARN'
    }
    else {
        Write-Log ("Windows.old: removed, about {0} freed. This computer can no longer go back to the previous Windows version." -f $freed)
    }
}

function Invoke-Step {
    # Runs one cleanup step. A step that fails is logged and does not stop the other steps.
    param([string]$Name, [scriptblock]$Action)
    try { & $Action }
    catch {
        $script:ExitCode = 1
        Write-Log ("{0} failed: {1}" -f $Name, $_.Exception.Message) 'ERROR'
    }
}

# ----------------------------------------------------------------------------------------
# Main
# ----------------------------------------------------------------------------------------
try {
    Write-Log 'Disk cleanup started.'

    $odDays      = Get-WholeNumber -Value $OneDriveDays         -Name 'OneDrive days'          -Min 1  -Max 3650
    $rbDays      = Get-WholeNumber -Value $RecycleBinDays       -Name 'Recycle Bin days'       -Min 1  -Max 3650
    $promptLimit = Get-WholeNumber -Value $PromptTimeoutSeconds -Name 'Prompt timeout seconds' -Min 30 -Max 600
    $doWuCache   = Get-YesNo -Value $CleanWindowsUpdateDownloads -Name 'Windows Update downloads'
    $doDumps     = Get-YesNo -Value $CleanCrashDumps             -Name 'Crash dumps'
    $doBrowsers  = Get-YesNo -Value $CleanBrowserCaches          -Name 'Browser caches'
    $doWinOld    = Get-YesNo -Value $RemoveWindowsOld            -Name 'Windows.old'
    $browserText = 'No'
    if ($doBrowsers) { $browserText = 'Yes' }

    Write-Log ("Settings: OneDrive files not used for {0} days, Recycle Bin items older than {1} days, question stays on screen for {2} seconds." -f $odDays, $rbDays, $promptLimit)
    Write-Log ("Options: Windows Update downloads={0}, crash dumps={1}, browser caches={2}, Windows.old={3}" -f $doWuCache, $doDumps, $doBrowsers, $doWinOld)

    $freeBefore = Get-FreeBytes
    Write-Log ("Free space on {0} before: {1}" -f $env:SystemDrive, (Format-Size $freeBefore))

    # Windows decides which OneDrive files are "not used" from each file's last-opened date.
    # This line records whether Windows is keeping that date up to date on this computer.
    try {
        $lastAccess = (& "$env:windir\System32\fsutil.exe" behavior query disablelastaccess | Out-String).Trim()
        if ($lastAccess) { Write-Log ("Last-opened date tracking: {0}" -f $lastAccess) }
    }
    catch {
        Write-Log ("Could not read the last-opened date setting: {0}" -f $_.Exception.Message) 'WARN'
    }

    # ---- Part B first: start the per-person cleanup so it runs while Part A works. ----
    $users = @(Get-SignedInUser)

    if ($users.Count -eq 0) {
        Write-Log 'Nobody is signed in. The per-person steps (temp folder, browser caches, OneDrive, Recycle Bin) are skipped. The computer-wide steps still run.'
    }
    else {
        Write-Log ("Signed-in people found: {0}" -f (($users | ForEach-Object { $_.Name }) -join ', '))

        try {
            $source = Join-Path $PSScriptRoot $UserScriptName
            if (-not (Test-Path -LiteralPath $source -PathType Leaf)) {
                throw "$UserScriptName is missing from the package. Add it to the package files."
            }

            # Remove anything left behind by an earlier run that was interrupted.
            Get-ScheduledTask -TaskName ($TaskPrefix + '*') -ErrorAction SilentlyContinue |
                Unregister-ScheduledTask -Confirm:$false -ErrorAction SilentlyContinue
            if (Test-Path -LiteralPath $WorkRoot) {
                Remove-Item -LiteralPath $WorkRoot -Recurse -Force
            }

            # Standard users can read this folder but cannot change what is in it.
            New-Item -ItemType Directory -Path $WorkRoot -Force | Out-Null
            $userScript = Join-Path $WorkRoot $UserScriptName
            Copy-Item -LiteralPath $source -Destination $userScript -Force
            Write-Log ("Copied {0} to {1}" -f $UserScriptName, $WorkRoot)

            $powershellExe = Join-Path $env:windir 'System32\WindowsPowerShell\v1.0\powershell.exe'
            $index = 0

            foreach ($user in $users) {
                $index++
                try {
                    $userProfile = Get-CimInstance -ClassName Win32_UserProfile -Filter ("SID='{0}'" -f $user.Sid)
                    if (-not $userProfile -or [string]::IsNullOrEmpty($userProfile.LocalPath)) {
                        throw 'Could not find the profile folder.'
                    }
                    $resultPath = Join-Path $userProfile.LocalPath 'AppData\Local\DiskCleanup\DiskCleanup.log'
                    $taskName   = '{0}{1}-{2}' -f $TaskPrefix, $RunId.Substring(0, 8), $index

                    $arguments = '-NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File "{0}" -OneDriveDays {1} -RecycleBinDays {2} -PromptTimeoutSeconds {3} -MaxOneDriveMinutes {4} -TempDays {5} -CleanBrowserCaches {6} -RunId {7} -ResultPath "{8}"' -f `
                        $userScript, $odDays, $rbDays, $promptLimit, $MaxOneDriveMinutes, $TempDays, $browserText, $RunId, $resultPath

                    $action   = New-ScheduledTaskAction -Execute $powershellExe -Argument $arguments
                    $settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries `
                                    -ExecutionTimeLimit (New-TimeSpan -Minutes 30) -MultipleInstances IgnoreNew

                    # Run as the signed-in person, inside their session, with their normal rights.
                    try {
                        $principal = New-ScheduledTaskPrincipal -UserId $user.Sid -LogonType Interactive -RunLevel Limited
                        Register-ScheduledTask -TaskName $taskName -Action $action -Principal $principal -Settings $settings -Force | Out-Null
                    }
                    catch {
                        # Some systems want the account name instead of the account ID.
                        $principal = New-ScheduledTaskPrincipal -UserId $user.Name -LogonType Interactive -RunLevel Limited
                        Register-ScheduledTask -TaskName $taskName -Action $action -Principal $principal -Settings $settings -Force | Out-Null
                    }

                    Start-ScheduledTask -TaskName $taskName
                    Write-Log ("Started the per-person cleanup for {0}." -f $user.Name)

                    $Jobs += [pscustomobject]@{
                        Name       = $user.Name
                        TaskName   = $taskName
                        ResultPath = $resultPath
                        Started    = Get-Date
                        Finished   = $false
                        Outcome    = 'pending'
                    }
                }
                catch {
                    $ExitCode = 1
                    Write-Log ("Could not start the per-person cleanup for {0}: {1}" -f $user.Name, $_.Exception.Message) 'ERROR'
                }
            }
        }
        catch {
            $ExitCode = 1
            Write-Log ("Could not prepare the per-person cleanup: {0}" -f $_.Exception.Message) 'ERROR'
        }
    }

    # ---- Part A: computer-wide cleanup. ----
    Write-Log '---- Computer-wide cleanup ----'
    Invoke-Step -Name 'Windows temp folder'         -Action { Clear-WindowsTemp }
    Invoke-Step -Name 'Windows error reports'       -Action { Clear-ErrorReports }
    Invoke-Step -Name 'Delivery Optimization cache' -Action { Clear-DeliveryOptimization }
    if ($doWuCache) { Invoke-Step -Name 'Windows Update downloads' -Action { Clear-WindowsUpdateDownloads } }
    if ($doDumps)   { Invoke-Step -Name 'Crash dump files'         -Action { Clear-CrashDumps } }
    if ($doWinOld)  { Invoke-Step -Name 'Windows.old'              -Action { Remove-WindowsOld } }

    # ---- Wait for the per-person cleanup and copy its results into the action log. ----
    if ($Jobs.Count -gt 0) {
        $doneMarker = 'DONE {0} status=' -f $RunId
        $maxSeconds = ($MaxOneDriveMinutes * 60) + $promptLimit + 180
        Write-Log 'Waiting for the per-person cleanup to finish.'

        while (@($Jobs | Where-Object { -not $_.Finished }).Count -gt 0) {
            foreach ($job in @($Jobs | Where-Object { -not $_.Finished })) {
                $doneLine = @(Read-ResultLog -Path $job.ResultPath | Where-Object { $_ -like ('*' + $doneMarker + '*') }) | Select-Object -Last 1
                if ($doneLine) {
                    $job.Finished = $true
                    if ($doneLine -like '*status=ok*') { $job.Outcome = 'ok' } else { $job.Outcome = 'error' }
                    continue
                }
                # No result yet. If the task already ended, it ended without finishing its work.
                $secondsRunning = ((Get-Date) - $job.Started).TotalSeconds
                $task = Get-ScheduledTask -TaskName $job.TaskName -ErrorAction SilentlyContinue
                if ($secondsRunning -gt 30 -and (-not $task -or $task.State -ne 'Running')) {
                    $job.Finished = $true
                    $job.Outcome  = 'stopped early'
                }
                elseif ($secondsRunning -gt $maxSeconds) {
                    $job.Finished = $true
                    $job.Outcome  = 'timed out'
                    Stop-ScheduledTask -TaskName $job.TaskName -ErrorAction SilentlyContinue
                }
            }
            if (@($Jobs | Where-Object { -not $_.Finished }).Count -gt 0) { Start-Sleep -Seconds 5 }
        }

        foreach ($job in $Jobs) {
            Write-Log ("---- Results for {0} ({1}) ----" -f $job.Name, $job.Outcome)
            $lines = @(Read-ResultLog -Path $job.ResultPath)
            if ($lines.Count -eq 0) {
                Write-Log '    No result log was found.' 'WARN'
            }
            foreach ($line in ($lines | Select-Object -Last 200)) {
                Write-Host ('    ' + $line)
            }

            switch ($job.Outcome) {
                'ok'        { }
                'timed out' { if ($ExitCode -eq 0) { $ExitCode = 2 }
                              Write-Log ("The per-person cleanup for {0} did not finish in time and was stopped. What it had already done stays done." -f $job.Name) 'WARN' }
                default     { $ExitCode = 1
                              Write-Log ("The per-person cleanup for {0} did not finish cleanly ({1})." -f $job.Name, $job.Outcome) 'ERROR' }
            }
        }
    }

    $freeAfter = Get-FreeBytes
    Write-Log ("Free space on {0} after: {1} (change: {2})" -f $env:SystemDrive, (Format-Size $freeAfter), (Format-Size ([math]::Max([long]0, $freeAfter - $freeBefore))))
    if ($Jobs.Count -gt 0) {
        Write-Log 'OneDrive frees its space in the background, so this number can keep going up for a few minutes.'
    }
}
catch {
    $ExitCode = 1
    Write-Log ("Disk cleanup failed: {0}" -f $_.Exception.Message) 'ERROR'
}
finally {
    # Always remove the scheduled tasks and the working folder.
    foreach ($job in $Jobs) {
        try {
            Stop-ScheduledTask -TaskName $job.TaskName -ErrorAction SilentlyContinue
            Unregister-ScheduledTask -TaskName $job.TaskName -Confirm:$false -ErrorAction SilentlyContinue
        }
        catch { }
    }
    try {
        if (Test-Path -LiteralPath $WorkRoot) {
            Remove-Item -LiteralPath $WorkRoot -Recurse -Force -ErrorAction Stop
        }
    }
    catch {
        Write-Log ("Could not remove {0}: {1}" -f $WorkRoot, $_.Exception.Message) 'WARN'
    }
    Write-Log ("Disk cleanup finished with exit code {0}." -f $ExitCode)
}

exit $ExitCode
<#
.SYNOPSIS
    Frees disk space for the signed-in person. Started by Invoke-DiskCleanup.ps1.

.DESCRIPTION
    Runs inside the signed-in person's own session, with their normal rights. It first removes
    files older than -TempDays days from the person's temp folder, the person's own Windows
    error report files, and (only with -CleanBrowserCaches Yes) the browser caches. Then:

      1. OneDrive: files that are stored on this computer and have not been used for
         -OneDriveDays days are set to online-only. This is the same as right-clicking a
         file and choosing "Free up space". The file stays in OneDrive and downloads again
         when it is opened. Files marked "Always keep on this device" are left alone.

      2. Recycle Bin: if it holds items that were deleted more than -RecycleBinDays days
         ago, the person is asked whether to remove them for good. If they say no, close
         the window, or do not answer in time, nothing in the Recycle Bin is touched.
         Newer items are never touched.

    File and folder names are never written to the log. Only counts and sizes are.

.PARAMETER OneDriveDays
    OneDrive files not used for this many days are set to online-only.

.PARAMETER RecycleBinDays
    Only Recycle Bin items deleted more than this many days ago are offered for removal.

.PARAMETER PromptTimeoutSeconds
    How long the Recycle Bin question stays on screen. No answer counts as "no".

.PARAMETER MaxOneDriveMinutes
    Time limit for the OneDrive step. Files not reached are handled on the next run.

.PARAMETER TempDays
    Files in the person's temp folder that are older than this many days are removed.

.PARAMETER CleanBrowserCaches
    Yes or No. Empty the Chrome, Edge, and Firefox caches. Files the browser has open are skipped.

.PARAMETER RunId
    Written on the last line of the log so the calling script knows this run has finished.

.PARAMETER ResultPath
    Log file. Default: %LOCALAPPDATA%\DiskCleanup\DiskCleanup.log
#>
[CmdletBinding()]
param(
    [ValidateRange(1, 3650)][int]$OneDriveDays = 14,
    [ValidateRange(1, 3650)][int]$RecycleBinDays = 30,
    [ValidateRange(10, 3600)][int]$PromptTimeoutSeconds = 300,
    [ValidateRange(1, 120)][int]$MaxOneDriveMinutes = 8,
    [ValidateRange(1, 3650)][int]$TempDays = 7,
    [ValidateSet('Yes', 'No')][string]$CleanBrowserCaches = 'No',
    [string]$RunId = 'manual',
    [string]$ResultPath = (Join-Path $env:LOCALAPPDATA 'DiskCleanup\DiskCleanup.log')
)

$ErrorActionPreference = 'Stop'
$script:HadError = $false

# File markers that Windows uses for cloud files (from the Windows SDK, winnt.h).
$ATTR_HIDDEN      = 0x00000002   # hidden file
$ATTR_SYSTEM      = 0x00000004   # Windows system file
$ATTR_OFFLINE     = 0x00001000   # content is not on this computer
$ATTR_PINNED      = 0x00080000   # "Always keep on this device"
$ATTR_RECALL_DATA = 0x00400000   # online-only, downloads when opened
$ATTR_REPARSE     = 0x00000400   # link or cloud placeholder
$ATTR_DIRECTORY   = 0x00000010

# ----------------------------------------------------------------------------------------
# Helpers
# ----------------------------------------------------------------------------------------
function Write-Log {
    param([string]$Message, [string]$Level = 'INFO')
    $line = '{0} [{1}] {2}' -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'), $Level, $Message
    try { Add-Content -LiteralPath $ResultPath -Value $line -Encoding UTF8 -ErrorAction Stop } catch { }
}

function Format-Size {
    param([double]$Bytes)
    if ($Bytes -ge 1GB) { return '{0:N2} GB' -f ($Bytes / 1GB) }
    if ($Bytes -ge 1MB) { return '{0:N1} MB' -f ($Bytes / 1MB) }
    if ($Bytes -ge 1KB) { return '{0:N0} KB' -f ($Bytes / 1KB) }
    return '{0:N0} bytes' -f $Bytes
}

function Clear-OldFiles {
    # Removes files in $Folder (and the folders below it) that are older than $Cutoff.
    # A shortcut-to-a-folder (junction or symbolic link) is never followed. Only the shortcut
    # is removed, never what it points to. Files that are in use are left in place.
    param([string]$Folder, [datetime]$Cutoff, [hashtable]$Stats)

    try { $entries = @((New-Object System.IO.DirectoryInfo($Folder)).GetFileSystemInfos()) }
    catch { $Stats.Kept += 1; return }

    foreach ($entry in $entries) {
        $attributes = [int]$entry.Attributes
        $isFolder   = ($attributes -band $ATTR_DIRECTORY) -ne 0
        $isLink     = (($attributes -band $ATTR_REPARSE) -ne 0) -and [bool]$entry.LinkType

        # Age = the newer of "last changed" and "created". Read before anything inside is removed.
        $newest = $entry.LastWriteTime
        if ($entry.CreationTime -gt $newest) { $newest = $entry.CreationTime }

        if ($isFolder -and -not $isLink) {
            Clear-OldFiles -Folder $entry.FullName -Cutoff $Cutoff -Stats $Stats
            if ($newest -lt $Cutoff) {
                try { [System.IO.Directory]::Delete($entry.FullName, $false) } catch { }    # only works when it is empty
            }
            continue
        }

        if ($newest -ge $Cutoff) { continue }

        try {
            $size = 0
            if (-not $isFolder) { $size = $entry.Length }
            if ($attributes -band 1) {
                $entry.Attributes = [System.IO.FileAttributes]($attributes -band (-bnot 1))
            }
            if ($isFolder) { [System.IO.Directory]::Delete($entry.FullName, $false) }       # a shortcut: remove the shortcut only
            else           { [System.IO.File]::Delete($entry.FullName) }
            $Stats.Removed += 1
            $Stats.Bytes   += $size
        }
        catch {
            $Stats.Kept += 1                                                                 # in use or protected
        }
    }
}

function Write-StepResult {
    param([string]$Step, [hashtable]$Stats)
    Write-Log ("{0}: {1} items removed, about {2} freed." -f $Step, $Stats.Removed, (Format-Size $Stats.Bytes))
    if ($Stats.Kept -gt 0) {
        Write-Log ("{0}: {1} items are in use or protected and were left in place." -f $Step, $Stats.Kept)
    }
}

function Invoke-TempCleanup {
    # The person's own temp folder and their own Windows error report files.
    $temp = Join-Path $env:LOCALAPPDATA 'Temp'
    if ([System.IO.Directory]::Exists($temp)) {
        $stats = @{ Removed = 0; Bytes = [long]0; Kept = 0 }
        Clear-OldFiles -Folder $temp -Cutoff (Get-Date).AddDays(-$TempDays) -Stats $stats
        Write-StepResult -Step ("Temp folder (older than {0} days)" -f $TempDays) -Stats $stats
    }

    $stats = @{ Removed = 0; Bytes = [long]0; Kept = 0 }
    foreach ($name in 'ReportArchive', 'ReportQueue') {
        $folder = Join-Path $env:LOCALAPPDATA ('Microsoft\Windows\WER\' + $name)
        if ([System.IO.Directory]::Exists($folder)) {
            Clear-OldFiles -Folder $folder -Cutoff ([datetime]::MaxValue) -Stats $stats
        }
    }
    Write-StepResult -Step 'Windows error reports' -Stats $stats
}

function Invoke-BrowserCacheCleanup {
    # Empties the caches only. Bookmarks, passwords, history, cookies, and sign-ins are not touched.
    # If the browser is open, the files it is using are left in place.
    $stats      = @{ Removed = 0; Bytes = [long]0; Kept = 0 }
    $everything = [datetime]::MaxValue

    $chromiumFolders = @(
        (Join-Path $env:LOCALAPPDATA 'Google\Chrome\User Data'),
        (Join-Path $env:LOCALAPPDATA 'Microsoft\Edge\User Data')
    )
    foreach ($userData in $chromiumFolders) {
        if (-not [System.IO.Directory]::Exists($userData)) { continue }
        foreach ($profileFolder in [System.IO.Directory]::GetDirectories($userData)) {
            $leaf = [System.IO.Path]::GetFileName($profileFolder)
            if ($leaf -ne 'Default' -and $leaf -notlike 'Profile *') { continue }
            foreach ($cacheName in 'Cache', 'Code Cache', 'GPUCache') {
                $cache = Join-Path $profileFolder $cacheName
                if ([System.IO.Directory]::Exists($cache)) {
                    Clear-OldFiles -Folder $cache -Cutoff $everything -Stats $stats
                }
            }
        }
    }

    $firefoxProfiles = Join-Path $env:LOCALAPPDATA 'Mozilla\Firefox\Profiles'
    if ([System.IO.Directory]::Exists($firefoxProfiles)) {
        foreach ($profileFolder in [System.IO.Directory]::GetDirectories($firefoxProfiles)) {
            $cache = Join-Path $profileFolder 'cache2'
            if ([System.IO.Directory]::Exists($cache)) {
                Clear-OldFiles -Folder $cache -Cutoff $everything -Stats $stats
            }
        }
    }

    Write-StepResult -Step 'Browser caches (Chrome, Edge, Firefox)' -Stats $stats
}

function Get-OneDriveFolder {
    # Returns every folder on this computer that OneDrive keeps in sync for this person.
    $candidates = New-Object System.Collections.Generic.List[string]

    foreach ($name in 'OneDrive', 'OneDriveCommercial', 'OneDriveConsumer') {
        $value = [Environment]::GetEnvironmentVariable($name)
        if ($value) { $candidates.Add($value) }
    }

    $accountsKey = 'HKCU:\Software\Microsoft\OneDrive\Accounts'
    if (Test-Path -LiteralPath $accountsKey) {
        foreach ($account in @(Get-ChildItem -LiteralPath $accountsKey -ErrorAction SilentlyContinue)) {
            # The person's own OneDrive folder.
            $userFolder = (Get-ItemProperty -LiteralPath $account.PSPath -ErrorAction SilentlyContinue).UserFolder
            if ($userFolder) { $candidates.Add($userFolder) }

            # SharePoint and Teams libraries that are synced to this computer.
            $tenantsKey = Join-Path $account.PSPath 'Tenants'
            if (Test-Path -LiteralPath $tenantsKey) {
                foreach ($tenant in @(Get-ChildItem -LiteralPath $tenantsKey -ErrorAction SilentlyContinue)) {
                    foreach ($valueName in $tenant.GetValueNames()) {
                        if ($valueName) { $candidates.Add($valueName) }
                    }
                }
            }
        }
    }

    # Keep real folders only, drop duplicates, and drop folders that sit inside another one.
    $unique = @{}
    foreach ($candidate in $candidates) {
        $clean = $candidate.Trim().TrimEnd('\')
        if ($clean.Length -lt 4) { continue }                      # never a whole drive
        if (-not [System.IO.Directory]::Exists($clean)) { continue }
        $unique[$clean.ToLowerInvariant()] = $clean
    }
    $kept = New-Object System.Collections.Generic.List[string]
    foreach ($folder in @($unique.Values | Sort-Object Length)) {
        $inside = $false
        foreach ($parent in $kept) {
            if ($folder.StartsWith($parent + '\', [System.StringComparison]::OrdinalIgnoreCase)) { $inside = $true; break }
        }
        if (-not $inside) { $kept.Add($folder) }
    }
    return @($kept)
}

function Invoke-OneDriveCleanup {
    Write-Log ("OneDrive step: looking for files not used for {0} days." -f $OneDriveDays)

    $folders = @(Get-OneDriveFolder)
    if ($folders.Count -eq 0) {
        Write-Log 'OneDrive is not set up for this person. Skipped.'
        return
    }

    $mySession = [System.Diagnostics.Process]::GetCurrentProcess().SessionId
    $oneDriveRunning = @(Get-Process -Name 'OneDrive' -ErrorAction SilentlyContinue | Where-Object { $_.SessionId -eq $mySession }).Count -gt 0
    if (-not $oneDriveRunning) {
        Write-Log 'OneDrive is not running for this person. Files are still marked, but the space is only freed once OneDrive is running.' 'WARN'
    }

    $attribExe = Join-Path $env:windir 'System32\attrib.exe'
    $cutoff    = (Get-Date).AddDays(-$OneDriveDays)
    $deadline  = (Get-Date).AddMinutes($MaxOneDriveMinutes)

    $freedCount = 0; [long]$freedBytes = 0
    $failed = 0; $recent = 0; $alreadyOnline = 0; $alwaysKeep = 0; $unreadableFolders = 0
    $outOfTime = $false
    $failReasons = @{}; $folderReasons = @{}

    foreach ($root in $folders) {
        if ($outOfTime) { break }

        # Walk the folder tree one folder at a time so a single unreadable folder does not stop the rest.
        $pending = New-Object System.Collections.Stack
        $pending.Push((New-Object System.IO.DirectoryInfo($root)))

        while ($pending.Count -gt 0 -and -not $outOfTime) {
            $folder = $pending.Pop()
            try {
                foreach ($entry in $folder.EnumerateFileSystemInfos()) {
                    if ((Get-Date) -gt $deadline) { $outOfTime = $true; break }

                    $attributes = [int]$entry.Attributes

                    if ($attributes -band $ATTR_DIRECTORY) {
                        # Do not follow shortcuts-to-folders (junctions and symbolic links). They can
                        # point outside OneDrive. Normal OneDrive folders are followed.
                        if (($attributes -band $ATTR_REPARSE) -and $entry.LinkType) { continue }
                        $pending.Push($entry)
                        continue
                    }

                    # Hidden and system files are left alone. attrib refuses to change hidden files anyway.
                    if ($attributes -band ($ATTR_SYSTEM -bor $ATTR_HIDDEN)) { continue }
                    if ($entry.Length -eq 0) { continue }
                    if (($attributes -band $ATTR_RECALL_DATA) -or ($attributes -band $ATTR_OFFLINE)) { $alreadyOnline++; continue }
                    if ($attributes -band $ATTR_PINNED) { $alwaysKeep++; continue }

                    # "Last used" = the newest of last opened, last changed, and created.
                    $lastUsed = $entry.LastAccessTime
                    if ($entry.LastWriteTime -gt $lastUsed) { $lastUsed = $entry.LastWriteTime }
                    if ($entry.CreationTime  -gt $lastUsed) { $lastUsed = $entry.CreationTime }
                    if ($lastUsed -ge $cutoff) { $recent++; continue }

                    # Same as "Free up space" in File Explorer. attrib prints text only when something goes wrong.
                    $output = & $attribExe '+U' '-P' $entry.FullName
                    if ($LASTEXITCODE -ne 0 -or $output) {
                        $failed++
                        # attrib answers "<reason> - <path>". Keep the reason only, never the file name.
                        $reason    = 'other'
                        $firstLine = [string](@($output)[0])
                        $dashAt    = $firstLine.IndexOf(' - ')
                        if ($dashAt -gt 0) { $reason = $firstLine.Substring(0, $dashAt).Trim() }
                        $failReasons[$reason] = 1 + [int]$failReasons[$reason]
                    }
                    else {
                        $freedCount++
                        $freedBytes += $entry.Length
                    }
                }
            }
            catch {
                $unreadableFolders++
                # Keep the kind of error only. The full message can contain a folder name.
                $problem = $_.Exception
                while ($problem.InnerException) { $problem = $problem.InnerException }
                $kind = $problem.GetType().Name
                $folderReasons[$kind] = 1 + [int]$folderReasons[$kind]
            }
        }
    }

    Write-Log ("OneDrive folders checked: {0}" -f $folders.Count)
    Write-Log ("OneDrive: {0} files set to online-only, about {1} to be freed." -f $freedCount, (Format-Size $freedBytes))
    Write-Log ("OneDrive: left alone: {0} used in the last {1} days, {2} already online-only, {3} marked 'Always keep on this device'." -f $recent, $OneDriveDays, $alreadyOnline, $alwaysKeep)
    if ($failed -gt 0) {
        $why = @($failReasons.Keys | Sort-Object | ForEach-Object { '{0} ({1})' -f $_, $failReasons[$_] }) -join ', '
        Write-Log ("OneDrive: {0} files could not be changed. Reason given by Windows: {1}" -f $failed, $why) 'WARN'
    }
    if ($unreadableFolders -gt 0) {
        $why = @($folderReasons.Keys | Sort-Object | ForEach-Object { '{0} ({1})' -f $_, $folderReasons[$_] }) -join ', '
        Write-Log ("OneDrive: {0} folders could not be read. Kind of error: {1}" -f $unreadableFolders, $why) 'WARN'
    }
    if ($outOfTime)               { Write-Log ("OneDrive: the {0}-minute time limit was reached. The remaining files are handled on the next run." -f $MaxOneDriveMinutes) 'WARN' }
}

function Get-OldRecycleBinItem {
    # Windows stores every Recycle Bin item as two files in <drive>:\$Recycle.Bin\<account ID>\ :
    #   $I...  a small record that holds the original size and the date the item was deleted
    #   $R...  the deleted file or folder itself
    param([datetime]$CutoffUtc)

    $sid   = [System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value
    # A plain ArrayList on purpose. Windows PowerShell 5.1 fails with "Argument types do not
    # match" when a List[object] that holds custom objects is wrapped in @().
    $items = New-Object System.Collections.ArrayList

    foreach ($drive in [System.IO.DriveInfo]::GetDrives()) {
        if ($drive.DriveType -ne [System.IO.DriveType]::Fixed -or -not $drive.IsReady) { continue }
        $binFolder = Join-Path $drive.RootDirectory.FullName ('$Recycle.Bin\' + $sid)
        if (-not [System.IO.Directory]::Exists($binFolder)) { continue }

        try { $records = [System.IO.Directory]::GetFiles($binFolder, '$I*') } catch { continue }

        foreach ($record in $records) {
            try {
                $stream = [System.IO.File]::Open($record, [System.IO.FileMode]::Open, [System.IO.FileAccess]::Read, [System.IO.FileShare]::ReadWrite)
                try {
                    $buffer = New-Object byte[] 24
                    $read   = $stream.Read($buffer, 0, 24)
                }
                finally { $stream.Dispose() }
                if ($read -lt 24) { continue }

                $version = [System.BitConverter]::ToInt64($buffer, 0)
                if ($version -ne 1 -and $version -ne 2) { continue }          # unknown layout: leave it alone

                $size       = [System.BitConverter]::ToInt64($buffer, 8)
                $deletedUtc = [datetime]::FromFileTimeUtc([System.BitConverter]::ToInt64($buffer, 16))

                if ($deletedUtc -lt $CutoffUtc) {
                    $fileName = [System.IO.Path]::GetFileName($record)
                    [void]$items.Add([pscustomobject]@{
                        RecordPath  = $record
                        ContentPath = Join-Path $binFolder ('$R' + $fileName.Substring(2))
                        Size        = [math]::Max([long]0, $size)
                    })
                }
            }
            catch { }
        }
    }
    return $items.ToArray()
}

function Remove-TreeSafely {
    # Deletes a file or a folder with everything in it. If it meets a shortcut-to-a-folder
    # (junction or symbolic link), it removes only the shortcut and never what it points to.
    param([string]$Path)

    $item       = Get-Item -LiteralPath $Path -Force -ErrorAction Stop
    $attributes = [int]$item.Attributes
    $isLink     = (($attributes -band $ATTR_REPARSE) -ne 0) -and [bool]$item.LinkType

    # Windows refuses to delete read-only items, so clear that marker first.
    if ($attributes -band 1) {
        $item.Attributes = [System.IO.FileAttributes]($attributes -band (-bnot 1))
    }

    if (-not $item.PSIsContainer) {
        [System.IO.File]::Delete($Path)
        return
    }

    if ($isLink) {
        [System.IO.Directory]::Delete($Path, $false)      # removes the shortcut only
        return
    }

    foreach ($child in [System.IO.Directory]::GetFileSystemEntries($Path)) {
        Remove-TreeSafely -Path $child
    }
    [System.IO.Directory]::Delete($Path, $false)
}

function Show-YesNoPrompt {
    # Shows a small window with two buttons. Returns 'Yes', 'No', or 'Timeout'.
    param([string]$Title, [string]$Message, [string]$YesText, [string]$NoText, [int]$TimeoutSeconds)

    Add-Type -AssemblyName System.Windows.Forms
    Add-Type -AssemblyName System.Drawing
    [System.Windows.Forms.Application]::EnableVisualStyles()

    $state = @{ Remaining = $TimeoutSeconds; TimedOut = $false }

    $form = New-Object System.Windows.Forms.Form
    $form.Text            = $Title
    $form.StartPosition   = [System.Windows.Forms.FormStartPosition]::CenterScreen
    $form.FormBorderStyle = [System.Windows.Forms.FormBorderStyle]::FixedDialog
    $form.MaximizeBox     = $false
    $form.MinimizeBox     = $false
    $form.TopMost         = $true
    $form.ShowInTaskbar   = $true
    $form.Font            = New-Object System.Drawing.Font('Segoe UI', 10)
    $form.ClientSize      = New-Object System.Drawing.Size(480, 262)

    $text = New-Object System.Windows.Forms.Label
    $text.Location = New-Object System.Drawing.Point(20, 18)
    $text.Size     = New-Object System.Drawing.Size(440, 150)
    $text.Text     = $Message
    $form.Controls.Add($text)

    $countdown = New-Object System.Windows.Forms.Label
    $countdown.Location  = New-Object System.Drawing.Point(20, 174)
    $countdown.Size      = New-Object System.Drawing.Size(440, 22)
    $countdown.ForeColor = [System.Drawing.Color]::DimGray
    $countdown.Text      = ("This message closes in {0} seconds. If it closes, nothing is deleted." -f $state.Remaining)
    $form.Controls.Add($countdown)

    $yesButton = New-Object System.Windows.Forms.Button
    $yesButton.Text         = $YesText
    $yesButton.Size         = New-Object System.Drawing.Size(160, 34)
    $yesButton.Location     = New-Object System.Drawing.Point(130, 212)
    $yesButton.DialogResult = [System.Windows.Forms.DialogResult]::Yes
    $form.Controls.Add($yesButton)

    $noButton = New-Object System.Windows.Forms.Button
    $noButton.Text         = $NoText
    $noButton.Size         = New-Object System.Drawing.Size(160, 34)
    $noButton.Location     = New-Object System.Drawing.Point(300, 212)
    $noButton.DialogResult = [System.Windows.Forms.DialogResult]::No
    $form.Controls.Add($noButton)

    # Pressing Enter or Esc means "no", so a stray key press never deletes anything.
    $form.AcceptButton = $noButton
    $form.CancelButton = $noButton

    $timer = New-Object System.Windows.Forms.Timer
    $timer.Interval = 1000
    $timer.Add_Tick({
        $state.Remaining = $state.Remaining - 1
        if ($state.Remaining -le 0) {
            $state.TimedOut = $true
            $timer.Stop()
            $form.Close()
        }
        else {
            $countdown.Text = ("This message closes in {0} seconds. If it closes, nothing is deleted." -f $state.Remaining)
        }
    })
    $form.Add_Shown({ $form.Activate(); $noButton.Focus() | Out-Null; $timer.Start() })

    try {
        $answer = $form.ShowDialog()
    }
    finally {
        $timer.Stop()
        $timer.Dispose()
        $form.Dispose()
    }

    if ($state.TimedOut) { return 'Timeout' }
    if ($answer -eq [System.Windows.Forms.DialogResult]::Yes) { return 'Yes' }
    return 'No'
}

function Invoke-RecycleBinCleanup {
    Write-Log ("Recycle Bin step: looking for items deleted more than {0} days ago." -f $RecycleBinDays)

    $oldItems = @(Get-OldRecycleBinItem -CutoffUtc ((Get-Date).ToUniversalTime().AddDays(-$RecycleBinDays)))
    if ($oldItems.Count -eq 0) {
        Write-Log ("Recycle Bin: nothing older than {0} days. The person was not asked." -f $RecycleBinDays)
        return
    }

    [long]$totalBytes = 0
    foreach ($oldItem in $oldItems) { $totalBytes += $oldItem.Size }
    Write-Log ("Recycle Bin: {0} items older than {1} days, about {2}. Asking the person." -f $oldItems.Count, $RecycleBinDays, (Format-Size $totalBytes))

    $message = ("Your Recycle Bin has {0} item(s) that were deleted more than {1} days ago. They use about {2}.`r`n`r`n" +
                "Do you want to remove them for good to free up disk space?`r`n`r`n" +
                "Items you deleted in the last {1} days are not touched.") -f $oldItems.Count, $RecycleBinDays, (Format-Size $totalBytes)

    $answer = Show-YesNoPrompt -Title 'Free up disk space' -Message $message `
                -YesText 'Delete old items' -NoText 'Keep them' -TimeoutSeconds $PromptTimeoutSeconds

    if ($answer -eq 'Timeout') {
        Write-Log ("Recycle Bin: no answer within {0} seconds. Skipped, nothing deleted." -f $PromptTimeoutSeconds)
        return
    }
    if ($answer -ne 'Yes') {
        Write-Log 'Recycle Bin: the person chose to keep the items. Skipped, nothing deleted.'
        return
    }

    $removed = 0; [long]$removedBytes = 0; $notRemoved = 0; $notRemovedReasons = @{}
    foreach ($oldItem in $oldItems) {
        try {
            if (Test-Path -LiteralPath $oldItem.ContentPath) {
                Remove-TreeSafely -Path $oldItem.ContentPath
            }
            # Remove the small record last, so an item that could not be deleted still shows in the Recycle Bin.
            [System.IO.File]::Delete($oldItem.RecordPath)
            $removed++
            $removedBytes += $oldItem.Size
        }
        catch {
            $notRemoved++
            # Keep the kind of error only. The full message can contain a file name.
            $problem = $_.Exception
            while ($problem.InnerException) { $problem = $problem.InnerException }
            $kind = $problem.GetType().Name
            $notRemovedReasons[$kind] = 1 + [int]$notRemovedReasons[$kind]
        }
    }

    # Tell Windows to redraw the Recycle Bin icon (full or empty).
    try {
        Add-Type -Namespace DiskCleanup -Name Shell -MemberDefinition '[DllImport("shell32.dll")] public static extern void SHUpdateRecycleBinIcon();'
        [DiskCleanup.Shell]::SHUpdateRecycleBinIcon()
    }
    catch { }

    Write-Log ("Recycle Bin: the person said yes. {0} items removed, about {1} freed." -f $removed, (Format-Size $removedBytes))
    if ($notRemoved -gt 0) {
        $why = @($notRemovedReasons.Keys | Sort-Object | ForEach-Object { '{0} ({1})' -f $_, $notRemovedReasons[$_] }) -join ', '
        Write-Log ("Recycle Bin: {0} items could not be removed and were left in place. Kind of error: {1}" -f $notRemoved, $why) 'WARN'
    }
}

# ----------------------------------------------------------------------------------------
# Main
# ----------------------------------------------------------------------------------------
try {
    $logFolder = Split-Path -Parent $ResultPath
    if (-not (Test-Path -LiteralPath $logFolder)) {
        New-Item -ItemType Directory -Path $logFolder -Force | Out-Null
    }
    [System.IO.File]::WriteAllText($ResultPath, '')            # start a fresh log for this run

    Write-Log ("Cleanup started for {0}\{1}." -f $env:USERDOMAIN, $env:USERNAME)

    try { Invoke-TempCleanup }
    catch {
        $script:HadError = $true
        Write-Log ("The temp folder step failed: {0}" -f $_.Exception.Message) 'ERROR'
    }

    if ($CleanBrowserCaches -eq 'Yes') {
        try { Invoke-BrowserCacheCleanup }
        catch {
            $script:HadError = $true
            Write-Log ("The browser cache step failed: {0}" -f $_.Exception.Message) 'ERROR'
        }
    }

    try { Invoke-OneDriveCleanup }
    catch {
        $script:HadError = $true
        Write-Log ("The OneDrive step failed: {0}" -f $_.Exception.Message) 'ERROR'
    }

    try { Invoke-RecycleBinCleanup }
    catch {
        $script:HadError = $true
        Write-Log ("The Recycle Bin step failed: {0}" -f $_.Exception.Message) 'ERROR'
    }
}
catch {
    $script:HadError = $true
    Write-Log ("Cleanup failed: {0}" -f $_.Exception.Message) 'ERROR'
}
finally {
    $status = 'ok'
    if ($script:HadError) { $status = 'error' }
    Write-Log ('DONE {0} status={1}' -f $RunId, $status)
}