ShutdownCleanup.ps1 - Setup and Explanation ============================================= SETUP: HOW TO INSTALL AND ACTIVATE THIS SCRIPT ------------------------------------------------- 1. Create a permanent folder for it, e.g.: C:\Scripts 2. Lock down permissions so normal users can read but not modify or delete the script (run this once, as Administrator, in PowerShell): icacls C:\Scripts /inheritance:r /grant:r "SYSTEM:(OI)(CI)F" "Administrators:(OI)(CI)F" "Users:(OI)(CI)RX" You can verify the result at any time with: icacls C:\Scripts 3. Copy ShutdownCleanup.ps1 into that folder, e.g.: C:\Scripts\ShutdownCleanup.ps1 4. Register it as a Group Policy shutdown script: - Open an elevated PowerShell and type: gpedit.msc - Go to: Computer Configuration -> Windows Settings -> Scripts (Startup/Shutdown) -> Shutdown - Double-click "Shutdown" to open its properties. - Use the "PowerShell Scripts" tab (not the older "Scripts" tab), if your Windows version has one - this ensures the script is run correctly as a PowerShell script rather than needing powershell.exe specified manually. - Click "Add...", then "Browse..." to select C:\Scripts\ShutdownCleanup.ps1 - Click OK / Apply to save. From this point on, the script runs automatically and silently every time this computer is shut down, as the SYSTEM account, before the shutdown completes. Nobody needs to do anything further. This script can also be run manually (as Administrator, in PowerShell) at any time, e.g. for testing. LINE-BY-LINE EXPLANATION ------------------------------------------------- Lines 32-40: log file, admin group, and the takeown confirmation letter $LogFile = Join-Path $PSScriptRoot "ShutdownCleanup.log" $AdminGroupName = ([System.Security.Principal.SecurityIdentifier]"S-1-5-32-544").Translate([System.Security.Principal.NTAccount]).Value $TakeownConfirm = if ((Get-UICulture).TwoLetterISOLanguageName -ieq "de") { "J" } else { "Y" } Line 32 builds the full path to the log file by combining $PSScriptRoot (the folder this script is saved in, e.g. C:\Scripts) with the filename "ShutdownCleanup.log". Result: C:\Scripts\ShutdownCleanup.log Line 34 looks up the actual name of the built-in "Administrators" group on this specific machine, using its well-known SID (S-1-5-32-544) rather than the English word "Administrators" - this works correctly regardless of whether Windows is set to English, German, or any other language, since the SID never changes but the displayed group name does. Line 40 deals with a real, tested quirk: the "takeown" command (used later to unlock folder permissions) asks for a Yes/No confirmation letter, but that letter is itself localized - "Y" on English Windows, "J" (for "Ja") on German Windows. This line detects the system's language and picks the correct letter automatically, so the script works on both without needing to be edited per machine. Lines 42-43: setting up logging try { Stop-Transcript -ErrorAction Stop | Out-Null } catch { } Start-Transcript -Path $LogFile -Append Line 42 is a safety measure: if a previous run of the script was interrupted before it finished cleanly (e.g. the computer was force- powered-off mid-shutdown), PowerShell can still think a transcript is "active" the next time the script runs, which would make Start- Transcript fail on line 43. This line proactively closes any such leftover transcript first. It either quietly cleans up a stale transcript, or quietly does nothing if there wasn't one - either way, it never causes a visible problem. Line 43 turns on transcript recording: from here on, everything the script would normally print to the console gets captured into the log file too. -Append means it adds to the end of the existing log file rather than erasing it, so the log accumulates a running history across every shutdown, rather than only showing the latest run. Lines 64-81: the Clear-Folder function function Clear-Folder { param([string]$Path) if (-not (Test-Path $Path)) { return } takeown /F $Path /R /D $TakeownConfirm | Out-Null icacls $Path /grant "${AdminGroupName}:(OI)(CI)M" /T /C | Out-Null Get-ChildItem -Path $Path -Force -ErrorAction SilentlyContinue | ForEach-Object { try { Remove-Item -Path $_.FullName -Recurse -Force -ErrorAction Stop $script:FilesDeleted++ } catch { $script:Errors++ } } } This defines a reusable function, Clear-Folder, that empties out a given folder completely (used for browser caches, the Windows Temp folder, and the Recycle Bin). Line 64: starts the function definition, giving it the name Clear-Folder. This is our own function, not a built-in PowerShell command - it's defined here and used repeatedly later in the script. Line 65: declares that the function takes one input, $Path - the folder to empty out. Line 67: safety check. If the given folder doesn't actually exist (e.g. a user doesn't have Chrome installed, so its cache folder was never created), "return" exits the function immediately rather than trying to work with a folder that isn't there. Why this check matters even though nobody reads the log live at shutdown: it isn't about hiding an error from a viewer. Without it, the following lines would likely throw an error on a nonexistent path, which could stop the ENTIRE script partway through the user loop - meaning every user after the one that triggered it never gets cleaned up that night. It also keeps the log itself useful: a missing Chrome cache folder for a user who doesn't use Chrome is completely normal, not a real problem, and letting that show as an "error" every single run for every such user would just be routine noise that makes it harder to spot a genuinely unusual problem later. There is no visible "else: do nothing" after this check, and none is needed - if the condition is false, PowerShell simply skips past the { } block entirely and continues with whatever comes after. An empty else{} would do exactly the same thing, just as extra, pointless lines. Lines 69-70: unlock permissions before touching anything. Even when running elevated as Administrator (as opposed to the SYSTEM account, which normally runs this script automatically at shutdown), Windows does not always guarantee access to a folder if its permissions or ownership are unusual - the same situation Windows Explorer handles by showing a "You don't have permission... Continue?" prompt. These two lines take ownership of the folder (and everything inside it, via /R) and then explicitly grant the Administrators group Modify rights (enough to read AND delete, not just read) - so the script behaves reliably whether it's triggered by SYSTEM at a real shutdown or run manually by an Administrator for testing. Line 72: lists everything directly inside that folder (files and subfolders alike) and processes each one. -Force includes hidden/ system items too. -ErrorAction SilentlyContinue means if listing the contents fails, it fails quietly. Lines 73-76 (the try block): for each item found, try to delete it. -Recurse means if it's a folder, delete everything inside it too (harmless if it's just a single file). If deletion succeeds, $script:FilesDeleted++ adds one to the running total (the "script:" part means "update the counter that lives at the main script level," not a separate local copy inside the function). Lines 77-79 (the catch block): if deleting that item failed (most likely because something still has it open/locked), this quietly counts it as an error and moves on - no message. A locked file today simply gets caught on the next run instead, so this isn't treated as something worth reporting in detail. Lines 83-92: accounts to skip $ExcludedUsers = @( "Administrator", "Guest", "Gast", "DefaultAccount", "WDAGUtilityAccount", "defaultuser0", "Public" ) Lists accounts that should never be touched by this automated cleanup - built-in Windows accounts by default. You can add more names here yourself (e.g. local admin accounts you don't want touched) - just add them as additional quoted entries, separated by commas. Each name must match the actual FOLDER NAME under C:\Users, not necessarily the display name shown at the login screen (these are usually the same, but can differ). Matching is case-insensitive, so exact capitalization doesn't matter. Note: this exclusion list only applies to the per-user cleanup (browser caches, Temp folder). The Recycle Bin cleanup near the end of the script (see below) covers ALL users, including any listed here, since it works on a shared system folder rather than going through individual user profiles. Lines 94-95: finding every user profile $UserFolders = Get-ChildItem "C:\Users" -Directory -ErrorAction SilentlyContinue | Where-Object { $_.Name -notin $ExcludedUsers } Get-ChildItem "C:\Users" -Directory lists everything directly inside C:\Users, but only the folders, not any stray files. Each user profile is a subfolder there (e.g. C:\Users\mueller). -ErrorAction SilentlyContinue: same "fail quietly" pattern as before. The | (pipe) symbol feeds that list of folders into the next command. Where-Object { $_.Name -notin $ExcludedUsers } filters the list down, keeping only folders whose name is NOT in the exclusion list above. $_ means "the current item being checked" as Where-Object looks at each folder one by one; $_.Name is that folder's name. The result - every real user's profile folder, with excluded accounts already removed - is stored in $UserFolders, ready for the foreach loop on line 97. Note: $UserFolders is not a text list like a CSV file. It's a collection of live PowerShell objects (one per folder found), each already carrying ready-to-use properties like .Name (e.g. "mueller") and .FullName (e.g. "C:\Users\mueller") - no text-parsing needed to get information out of it, unlike a CSV. Line 103: building a path $LocalAppData = Join-Path $UserFolder.FullName "AppData\Local" Join-Path glues two path pieces together correctly, handling the backslash between them so there's never a typo like a missing or doubled backslash. Example: if the loop is currently on user "mueller", then $UserFolder.FullName equals "C:\Users\mueller". Join-Path combines that with "AppData\Local", producing "C:\Users\mueller\AppData\Local" - stored in $LocalAppData. This chaining pattern (build a path, then join further pieces onto it) repeats throughout the script to construct each specific folder it needs to check - e.g. line 106 takes $LocalAppData and joins "Mozilla\Firefox\Profiles" onto it next. Line 107: checking a path exists before using it if (Test-Path $FirefoxProfilesPath) { Test-Path checks whether a given path actually exists on disk, and returns simply $true or $false. This checks whether the Firefox profiles folder built on line 106 actually exists for this user - because not every user necessarily has Firefox installed. If they don't, that folder was never created. Without this check, the following lines (which look inside that folder) would try to operate on a folder that isn't there, likely throwing an error and potentially interrupting the loop. As with line 67, there is no visible "else" here, and none is needed: if the folder doesn't exist, PowerShell simply skips past the { } block and moves on to check Chrome and Edge next - the correct behavior in that case really is just "do nothing," which is exactly what happens automatically without writing it out. Lines 130-142: the Recycle Bin (all users) Clear-Folder 'C:\$Recycle.Bin' This empties the Recycle Bin for every user on the machine, not just whoever happens to be logged in. It sits outside the per-user loop (which ends at line 128), because the Recycle Bin isn't stored inside each user's own profile folder - it's one shared system folder, C:\$Recycle.Bin, containing one subfolder per user (named by that user's SID). Since Clear-Folder already does exactly "empty out everything directly inside a given folder," it's reused here as-is, with no new logic needed - it deletes each user's SID subfolder inside C:\$Recycle.Bin, including the permission-unlock fix described above. The path is written in single quotes ('C:\$Recycle.Bin'), not double quotes. This matters: PowerShell treats a dollar sign inside double quotes as the start of a variable name, so "C:\$Recycle.Bin" would have been silently misread as "try to insert a variable called $Recycle here" (which doesn't exist, so it would have been quietly replaced with nothing) - producing the wrong path without any error or warning. Single quotes tell PowerShell to treat the text exactly as written, avoiding that trap. Important distinction from a normal "Empty Recycle Bin" action: emptying the Recycle Bin through Windows Explorer, or via PowerShell's own Clear-RecycleBin command, only ever affects the CURRENT user's own Recycle Bin - even when run as Administrator. This script reaches every user's Recycle Bin because it works directly on the underlying C:\$Recycle.Bin folder structure on disk, rather than going through either of those user-scoped mechanisms.