# Cleanup-StaleProfiles.ps1
# Removes user profiles inactive for more than N days, using User Profile Service events
$DaysInactive = 25
$CutoffDate = (Get-Date).AddDays(-$DaysInactive)
$LogFile = 'C:\ProfileCleanup.log'
# Paths that should never be touched
$ExcludedPaths = @(
'C:\Windows\ServiceProfiles\NetworkService'
'C:\Windows\ServiceProfiles\LocalService'
'C:\Windows\system32\config\systemprofile'
'C:\Windows\SysWOW64\config\systemprofile'
)
# Usernames that should never be touched
$ExcludedUsers = @(
'Administrator'
'Default'
'Default User'
'Public'
'All Users'
'WDAGUtilityAccount'
)
# Ensure log directory exists
$logDir = Split-Path $LogFile -Parent
if (-not (Test-Path $logDir)) { New-Item -Path $logDir -ItemType Directory -Force | Out-Null }
function Write-Log {
param([string]$Message)
$ts = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
$line = "$ts $Message"
Write-Output $line
Add-Content -Path $LogFile -Value $line -Encoding UTF8
}
function Get-FreeSpaceGB {
$drive = Get-CimInstance -ClassName Win32_LogicalDisk -Filter "DeviceID='C:'"
[math]::Round($drive.FreeSpace / 1GB, 2)
}
$freeBefore = Get-FreeSpaceGB
Write-Log "=== Profile cleanup started (cutoff: $CutoffDate) ==="
Write-Log "Free space on C: before: $freeBefore GB"
# 1. Build last-activity table from User Profile Service events
$lastActivity = @{}
Get-WinEvent -FilterHashtable @{
LogName = 'Microsoft-Windows-User Profile Service/Operational'
Id = 1,2
} -ErrorAction SilentlyContinue | ForEach-Object {
$sid = $_.UserId.Value
if (-not $lastActivity.ContainsKey($sid) -or $lastActivity[$sid] -lt $_.TimeCreated) {
$lastActivity[$sid] = $_.TimeCreated
}
}
# 2. Currently logged-on users
$loggedOnUsers = @()
try {
$loggedOnUsers = quser 2>$null | Select-Object -Skip 1 | ForEach-Object {
($_.Trim() -split '\s+')[0].TrimStart('>')
}
} catch { }
# 3. Get candidate profiles
$profiles = Get-CimInstance -ClassName Win32_UserProfile | Where-Object {
-not $_.Special -and
-not $_.Loaded -and
$_.LocalPath -like 'C:\Users\*' -and
$ExcludedPaths -notcontains $_.LocalPath
}
# 4. Evaluate each profile
$removedCount = 0
foreach ($p in $profiles) {
$username = Split-Path $p.LocalPath -Leaf
$sid = $p.SID
if ($ExcludedUsers -contains $username) {
Write-Log "SKIP (excluded user): $($p.LocalPath)"
continue
}
if ($loggedOnUsers -contains $username) {
Write-Log "SKIP (logged on): $($p.LocalPath)"
continue
}
$lastSeen = $lastActivity[$sid]
if ($lastSeen) {
$source = 'UserProfileService event'
} else {
$lastSeen = $p.LastUseTime
$source = 'LastUseTime (no events)'
}
if (-not $lastSeen) {
Write-Log "SKIP (no activity data): $($p.LocalPath)"
continue
}
if ($lastSeen -lt $CutoffDate) {
try {
$p | Remove-CimInstance -Confirm:$false -ErrorAction Stop
Write-Log "REMOVED: $($p.LocalPath) | last activity $lastSeen ($source)"
$removedCount++
} catch {
Write-Log "FAILED: $($p.LocalPath) | $($_.Exception.Message)"
}
} else {
Write-Log "KEEP: $($p.LocalPath) | last activity $lastSeen ($source)"
}
}
$freeAfter = Get-FreeSpaceGB
$reclaimed = [math]::Round($freeAfter - $freeBefore, 2)
Write-Log "Free space on C: after: $freeAfter GB"
Write-Log "Profiles removed: $removedCount"
Write-Log "Space reclaimed: $reclaimed GB"
Write-Log "=== Profile cleanup finished ==="