Find-OrphanedAppxFolders.ps1
|
<#
.SYNOPSIS Finds leftover folders in %LOCALAPPDATA%\Packages whose app is no longer installed for the current Windows user. .DESCRIPTION Compares every folder in %LOCALAPPDATA%\Packages against the Package Family Names registered for the current user (Get-AppxPackage). Unmatched folders are split into three groups: - Orphaned package folders: named like a package (Name_publisherid) but not currently registered. - Windows components: from the Windows publisher (_cw5n1h2txyewy) or windows_ie_ac_*. Listed so you can see them, but keep them. - Other folders: not named like a package at all. Listed separately for review. Folders matching an exclude pattern are left out of the results. When run as administrator, it also asks Windows about every user's packages: folders of apps registered to you in an unfinished state (staged or pending) are kept out of the results, and each folder gets notes such as "Provisioned" or "Installed for 1 other user". SAFETY: read-only. Nothing is deleted, uninstalled or modified. .PARAMETER NoPause Never pause at the end. The script only pauses when it opened its own window (a batch file or "Run with PowerShell"), not when run from an open PowerShell prompt. .PARAMETER ReportPath Custom location for the text report. Defaults to the user's Desktop (OneDrive-redirected Desktops are detected). .PARAMETER Csv Also save the results as a CSV file next to the text report. The CSV has an empty Delete column: type Yes on the rows to remove, then run Remove-OrphanedAppxFolders.ps1. .PARAMETER Html Also save a sortable HTML report next to the text report. .PARAMETER Open Open the HTML report in your browser when the scan finishes. .PARAMETER Exclude Folder names to leave out of the results. Wildcards allowed, e.g. -Exclude 'Contoso.*','*Teams*' .PARAMETER ExcludeFile A text file with one exclude pattern per line (# starts a comment). If not given, two files are read: WinPkgScan.exclude.txt next to this script, and your own %LOCALAPPDATA%\WinPkgScan\exclude.txt. .PARAMETER AdminChecks Auto (default): run the extra checks when running as administrator. On: always try them (a warning is shown if they fail). Off: never. .PARAMETER ProgressLines Print a "PROGRESS n/total folder" line for each folder. Used by the app to show a progress bar. .PARAMETER NoHistory Do not read or update the scan history. The history is kept in %LOCALAPPDATA%\WinPkgScan\history.json and is used to show which folders are new since the last scan and how much space was freed. .EXAMPLE .\Find-OrphanedAppxFolders.ps1 .EXAMPLE .\Find-OrphanedAppxFolders.ps1 -NoPause -Csv -ReportPath "C:\Temp\report.txt" .EXAMPLE .\Find-OrphanedAppxFolders.ps1 -Csv -Html -Open -Exclude '*Teams*' .NOTES Version: 1.8.0 #> [CmdletBinding()] param( [switch]$NoPause, [string]$ReportPath, [switch]$Csv, [switch]$Html, [switch]$Open, [string[]]$Exclude, [string]$ExcludeFile, [ValidateSet('Auto', 'On', 'Off')] [string]$AdminChecks = 'Auto', [switch]$NoHistory, [switch]$ProgressLines ) $ScriptVersion = "1.8.0" # Pause at the end only when this script opened its own console window # (a batch file or "Run with PowerShell"), so you can read the result before # it closes. Never from an open PowerShell prompt, the module, the app, or # with redirected input. $PauseAtEnd = $false if (-not $NoPause.IsPresent) { try { $PauseAtEnd = (-not [Console]::IsInputRedirected) -and ([Environment]::CommandLine.IndexOf([System.IO.Path]::GetFileName($PSCommandPath), [StringComparison]::OrdinalIgnoreCase) -ge 0) } catch { $PauseAtEnd = $false } } function Exit-Script { param([int]$Code = 0) if ($PauseAtEnd) { Write-Host "" Read-Host "Press Enter to exit" | Out-Null } exit $Code } function Write-ResultTable { param([object[]]$Items) # Out-String with a fixed width keeps the table visible # even when output is redirected to a file or log. $Items | Format-Table FolderName, @{Name = "Size (MB)"; Expression = { $_.SizeMB }}, @{Name = "Last Modified"; Expression = { $_.LastModified }}, New, Notes -AutoSize | Out-String -Width 250 | Write-Host } function Add-ReportSection { param( [System.Collections.Generic.List[string]]$Report, [string]$Title, [object[]]$Items, [string]$EmptyText ) $Report.Add("============================================================") $Report.Add(" $Title") $Report.Add("============================================================") $Report.Add("") if ($Items.Count -eq 0) { $Report.Add($EmptyText) $Report.Add("") return } foreach ($Item in $Items) { $Report.Add("Folder : $($Item.FolderName)") $Report.Add("Size : $($Item.SizeMB) MB") $Report.Add("Last Modified : $($Item.LastModified)") $Report.Add("Path : $($Item.FullPath)") $Report.Add("Status : $($Item.Status)") if ($Item.Notes) { $Report.Add("Notes : $($Item.Notes)") } if ($Item.New -eq 'Yes') { $Report.Add("New : yes, not found in the last scan") } $Report.Add("") } } function Get-ExcludePattern { # Patterns from -Exclude plus the exclude files (one per line, # = comment). param([string[]]$FromParameter, [string[]]$Files, [bool]$FileWasGiven) $patterns = New-Object System.Collections.Generic.List[string] foreach ($p in @($FromParameter)) { if (-not [string]::IsNullOrWhiteSpace($p)) { $patterns.Add($p.Trim()) } } foreach ($file in @($Files)) { if ($file -and (Test-Path -LiteralPath $file -PathType Leaf)) { foreach ($line in (Get-Content -LiteralPath $file)) { $t = $line.Trim() if ($t -and -not $t.StartsWith('#')) { $patterns.Add($t) } } } elseif ($FileWasGiven) { Write-Host "WARNING: Exclude file not found: $file" -ForegroundColor Yellow } } return , $patterns.ToArray() } function Get-MatchingPattern { param([string]$Name, [string[]]$Patterns) foreach ($p in $Patterns) { if ($Name -like $p) { return $p } } return $null } function Test-WindowsComponent { # Windows inbox components: publisher ID cw5n1h2txyewy, or the # Internet Explorer app containers (windows_ie_ac_*). param([string]$Name) $n = $Name.ToLowerInvariant() return $n.EndsWith('_cw5n1h2txyewy') -or $n -like 'windows_ie_ac_*' } function ConvertTo-HtmlText { param([string]$Text) return [System.Net.WebUtility]::HtmlEncode($Text) } function Write-HtmlReport { param( [string]$Path, [object[]]$Sections, [object[]]$Tiles, [string]$Subtitle ) $sb = New-Object System.Text.StringBuilder [void]$sb.Append(@' <!doctype html> <html lang="en"> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <title>WinPkgScan report</title> <style> :root { --bg:#f7f7f5; --card:#ffffff; --text:#1d1d1f; --muted:#6b6b70; --line:#e2e2e0; --accent:#2f6fde; --warn:#b26a00; --keep:#2e7d32; --head:#f0f0ee; --mark:#eaf1fd; } @media (prefers-color-scheme: dark) { :root { --bg:#161618; --card:#1f1f22; --text:#ececee; --muted:#9a9aa2; --line:#33333a; --accent:#6ea0ff; --warn:#f0a843; --keep:#7bc47f; --head:#26262a; --mark:#1d2a42; } } * { box-sizing:border-box; } body { margin:0; padding:24px 16px 96px; background:var(--bg); color:var(--text); font:14px/1.5 "Segoe UI", system-ui, sans-serif; } main { max-width:1100px; margin:0 auto; } h1 { font-size:22px; margin:0 0 4px; } .sub { color:var(--muted); margin:0 0 20px; } .tiles { display:grid; grid-template-columns:repeat(auto-fit,minmax(160px,1fr)); gap:12px; margin-bottom:20px; } .tile { background:var(--card); border:1px solid var(--line); border-radius:10px; padding:12px 14px; } .tile b { display:block; font-size:22px; } .tile span { color:var(--muted); font-size:12px; } .new { display:inline-block; margin-left:6px; padding:0 6px; border-radius:6px; font-size:11px; background:var(--accent); color:#fff; vertical-align:1px; } .note { background:var(--card); border:1px solid var(--line); border-left:4px solid var(--warn); border-radius:8px; padding:10px 14px; margin-bottom:20px; } .note ol { margin:6px 0 0; padding-left:20px; } input#filter { width:100%; max-width:360px; padding:8px 10px; border:1px solid var(--line); border-radius:8px; background:var(--card); color:var(--text); margin-bottom:16px; } section { margin-bottom:28px; } h2 { font-size:16px; margin:0 0 4px; } h2 small { color:var(--muted); font-weight:normal; } section p { color:var(--muted); margin:0 0 8px; } .wrap { overflow-x:auto; background:var(--card); border:1px solid var(--line); border-radius:10px; } table { border-collapse:collapse; width:100%; } th, td { text-align:left; padding:8px 12px; border-bottom:1px solid var(--line); white-space:nowrap; } th { background:var(--head); cursor:pointer; user-select:none; font-weight:600; } th.sel, td.sel { width:36px; padding-right:0; cursor:default; } th[data-dir="asc"]::after { content:" \25B2"; font-size:10px; } th[data-dir="desc"]::after { content:" \25BC"; font-size:10px; } td.num, th.num { text-align:right; } td.path { color:var(--muted); font-size:12px; } td.notes { color:var(--warn); font-size:12px; } tr.marked td { background:var(--mark); } tr:last-child td { border-bottom:none; } input[type=checkbox] { width:16px; height:16px; cursor:pointer; } .empty { padding:12px; color:var(--muted); } .k-orphan h2 { color:var(--accent); } .k-windows h2 { color:var(--keep); } .k-other h2 { color:var(--warn); } .bar { position:fixed; left:0; right:0; bottom:0; background:var(--card); border-top:1px solid var(--line); padding:10px 16px; box-shadow:0 -2px 8px rgba(0,0,0,.08); } .bar .in { max-width:1100px; margin:0 auto; display:flex; gap:12px; align-items:center; flex-wrap:wrap; } .bar b { white-space:nowrap; } .bar span { color:var(--muted); font-size:12px; flex:1 1 260px; } button { font:inherit; padding:8px 14px; border-radius:8px; border:1px solid var(--accent); background:var(--accent); color:#fff; cursor:pointer; } button:disabled { opacity:.45; cursor:default; } footer { color:var(--muted); font-size:12px; margin-top:32px; } @media (max-width: 600px) { .bar span { display:none; } .bar .in { justify-content:space-between; } } </style> </head> <body> <main> <h1>WinPkgScan report</h1> '@) [void]$sb.Append("<p class=`"sub`">$(ConvertTo-HtmlText $Subtitle)</p>`n<div class=`"tiles`">`n") foreach ($t in $Tiles) { [void]$sb.Append("<div class=`"tile`"><b>$(ConvertTo-HtmlText $t.Value)</b><span>$(ConvertTo-HtmlText $t.Label)</span></div>`n") } [void]$sb.Append(@' </div> <div class="note">Nothing was deleted or modified. To remove folders: <ol><li>Tick the orphaned folders to remove, then click <b>Save marked CSV</b>. It goes to your Downloads folder.</li> <li>Double-click <b>Run-Delete.bat</b>, or choose <b>Delete</b> in <b>WinPkgScan.bat</b>. It uses the newest marked file, re-checks every folder and moves it to the Recycle Bin.</li></ol></div> <input id="filter" type="search" placeholder="Filter by name or path" aria-label="Filter"> '@) foreach ($sec in $Sections) { $count = @($sec.Items).Count $markable = $sec.Kind -eq 'orphan' [void]$sb.Append("<section class=`"k-$($sec.Kind)`">`n<h2>$(ConvertTo-HtmlText $sec.Title) <small>($count, $($sec.TotalMB) MB)</small></h2>`n<p>$(ConvertTo-HtmlText $sec.Note)</p>`n<div class=`"wrap`">`n") if ($count -eq 0) { [void]$sb.Append("<div class=`"empty`">None found.</div>`n") } else { [void]$sb.Append("<table class=`"sortable`"><thead><tr>") if ($markable) { [void]$sb.Append("<th class=`"sel`"><input type=`"checkbox`" class=`"all`" aria-label=`"Mark all shown`"></th>") } [void]$sb.Append("<th>Folder</th><th class=`"num`" data-type=`"num`">Size (MB)</th><th>Last modified</th><th>Notes</th><th>Path</th></tr></thead><tbody>`n") foreach ($i in $sec.Items) { $size = ([double]$i.SizeMB).ToString('0.00', [System.Globalization.CultureInfo]::InvariantCulture) $attr = '' if ($markable) { $attr = " data-cat=`"$(ConvertTo-HtmlText $i.Category)`" data-name=`"$(ConvertTo-HtmlText $i.FolderName)`"" + " data-size=`"$size`" data-date=`"$(ConvertTo-HtmlText $i.LastModified)`"" + " data-path=`"$(ConvertTo-HtmlText $i.FullPath)`" data-status=`"$(ConvertTo-HtmlText $i.Status)`"" + " data-notes=`"$(ConvertTo-HtmlText $i.Notes)`"" } [void]$sb.Append("<tr$attr>") if ($markable) { [void]$sb.Append("<td class=`"sel`"><input type=`"checkbox`" class=`"mark`" aria-label=`"Mark $(ConvertTo-HtmlText $i.FolderName)`"></td>") } $badge = if ($i.New -eq 'Yes') { ' <span class="new">New</span>' } else { '' } [void]$sb.Append("<td>$(ConvertTo-HtmlText $i.FolderName)$badge</td><td class=`"num`" data-v=`"$size`">$size</td><td>$(ConvertTo-HtmlText $i.LastModified)</td><td class=`"notes`">$(ConvertTo-HtmlText $i.Notes)</td><td class=`"path`">$(ConvertTo-HtmlText $i.FullPath)</td></tr>`n") } [void]$sb.Append("</tbody></table>`n") } [void]$sb.Append("</div>`n</section>`n") } [void]$sb.Append("<footer>WinPkgScan v$ScriptVersion</footer>`n</main>`n") [void]$sb.Append(@' <div class="bar"><div class="in"> <b id="count">0 marked (0.00 MB)</b> <span>Marked folders are checked again before anything is removed, and go to the Recycle Bin.</span> <button id="save" type="button" disabled>Save marked CSV</button> </div></div> <script> function sortable() { document.querySelectorAll('table.sortable th').forEach(function (th) { if (th.classList.contains('sel')) { return; } th.addEventListener('click', function () { var table = th.closest('table'), body = table.tBodies[0]; var col = Array.prototype.indexOf.call(th.parentNode.children, th); var asc = th.getAttribute('data-dir') !== 'asc'; table.querySelectorAll('th').forEach(function (h) { h.removeAttribute('data-dir'); }); th.setAttribute('data-dir', asc ? 'asc' : 'desc'); var num = th.getAttribute('data-type') === 'num'; Array.prototype.slice.call(body.rows).sort(function (a, b) { var x = a.cells[col].getAttribute('data-v') || a.cells[col].textContent; var y = b.cells[col].getAttribute('data-v') || b.cells[col].textContent; var r = num ? parseFloat(x) - parseFloat(y) : x.localeCompare(y); return asc ? r : -r; }).forEach(function (row) { body.appendChild(row); }); }); }); } function markedRows() { return Array.prototype.slice.call(document.querySelectorAll('input.mark:checked')).map(function (c) { return c.closest('tr'); }); } function update() { var rows = markedRows(), mb = 0; rows.forEach(function (r) { mb += parseFloat(r.getAttribute('data-size')) || 0; }); document.querySelectorAll('input.mark').forEach(function (c) { c.closest('tr').classList.toggle('marked', c.checked); }); document.getElementById('count').textContent = rows.length + ' marked (' + mb.toFixed(2) + ' MB)'; document.getElementById('save').disabled = rows.length === 0; } function csvField(v) { return '"' + String(v === null || v === undefined ? '' : v).replace(/"/g, '""') + '"'; } function saveCsv() { var head = ['Delete', 'Category', 'FolderName', 'SizeMB', 'LastModified', 'FullPath', 'Status', 'Notes']; var lines = [head.map(csvField).join(',')]; markedRows().forEach(function (r) { lines.push(['Yes', r.getAttribute('data-cat'), r.getAttribute('data-name'), r.getAttribute('data-size'), r.getAttribute('data-date'), r.getAttribute('data-path'), r.getAttribute('data-status'), r.getAttribute('data-notes')].map(csvField).join(',')); }); var blob = new Blob(['\ufeff' + lines.join('\r\n') + '\r\n'], { type: 'text/csv;charset=utf-8' }); var a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = 'WinPkgScan-marked.csv'; document.body.appendChild(a); a.click(); setTimeout(function () { URL.revokeObjectURL(a.href); a.remove(); }, 1000); } sortable(); document.querySelectorAll('input.mark').forEach(function (c) { c.addEventListener('change', update); }); document.querySelectorAll('input.all').forEach(function (all) { all.addEventListener('change', function () { all.closest('table').querySelectorAll('tbody tr').forEach(function (r) { if (!r.hidden) { r.querySelector('input.mark').checked = all.checked; } }); update(); }); }); document.getElementById('save').addEventListener('click', saveCsv); document.getElementById('filter').addEventListener('input', function (e) { var q = e.target.value.toLowerCase(); document.querySelectorAll('tbody tr').forEach(function (row) { row.hidden = q !== '' && row.textContent.toLowerCase().indexOf(q) === -1; }); }); update(); </script> </body> </html> '@) [System.IO.File]::WriteAllText($Path, $sb.ToString(), (New-Object System.Text.UTF8Encoding($false))) } function Test-IsAdministrator { try { $identity = [Security.Principal.WindowsIdentity]::GetCurrent() return ([Security.Principal.WindowsPrincipal]$identity).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) } catch { return $false } } function Get-CurrentUserSid { $sid = $null try { $sid = [Security.Principal.WindowsIdentity]::GetCurrent().User.Value } catch { $sid = $null } # Only used by the test suite on non-Windows systems, where there is no SID. if (-not $sid) { $sid = $env:WINPKGSCAN_TEST_SID } return $sid } function Get-RegisteredPackage { # All package types (main, framework, optional, resource, bundle), so no # registered package family is missed. Falls back if the filter is unsupported. try { return @(Get-AppxPackage -PackageTypeFilter All -ErrorAction Stop) } catch { return @(Get-AppxPackage -ErrorAction Stop) } } function Get-AdminPackageInfo { # Needs administrator rights. Returns: # ForMe: families Windows knows for this user in any state # (installed, staged, pending), even if not listed above # OtherUsers: family -> number of other user accounts that have it # Provisioned: families provisioned for new users param([string]$Sid) $forMe = New-Object 'System.Collections.Generic.HashSet[string]' ([StringComparer]::OrdinalIgnoreCase) $other = New-Object 'System.Collections.Generic.Dictionary[string,int]' ([StringComparer]::OrdinalIgnoreCase) $prov = New-Object 'System.Collections.Generic.HashSet[string]' ([StringComparer]::OrdinalIgnoreCase) try { $all = @(Get-AppxPackage -AllUsers -PackageTypeFilter All -ErrorAction Stop) } catch { $all = @(Get-AppxPackage -AllUsers -ErrorAction Stop) } foreach ($p in $all) { $family = "$($p.PackageFamilyName)" if (-not $family) { continue } $seen = New-Object 'System.Collections.Generic.HashSet[string]' foreach ($u in @($p.PackageUserInformation)) { if ($null -eq $u) { continue } # Works with live objects and with text from PowerShell 7's # Windows PowerShell compatibility layer. $text = if ($u.UserSecurityId) { if ($u.UserSecurityId.Sid) { "$($u.UserSecurityId.Sid)" } else { "$($u.UserSecurityId)" } } else { "$u" } $m = [regex]::Match($text, 'S-1-[0-9-]+') if (-not $m.Success) { continue } $userSid = $m.Value if ($Sid -and $userSid -eq $Sid) { [void]$forMe.Add($family) } elseif ($userSid.StartsWith('S-1-5-21-') -and $seen.Add($userSid)) { if ($other.ContainsKey($family)) { $other[$family] = $other[$family] + 1 } else { $other[$family] = 1 } } } } try { foreach ($p in @(Get-AppxProvisionedPackage -Online -ErrorAction Stop)) { if ($p.DisplayName -and $p.PublisherId) { [void]$prov.Add("$($p.DisplayName)_$($p.PublisherId)") } } } catch { Write-Verbose "Could not read provisioned packages: $($_.Exception.Message)" } [PSCustomObject]@{ ForMe = $forMe OtherUsers = $other Provisioned = $prov } } function Get-HistoryPath { return Join-Path (Join-Path $env:LOCALAPPDATA 'WinPkgScan') 'history.json' } function Read-History { # Scan history: past scans (with folder names), removals and restores. param([string]$Path) $history = [PSCustomObject]@{ Scans = @(); Removals = @(); Restores = @() } if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { return $history } try { $text = [System.IO.File]::ReadAllText($Path) if ($text.Trim()) { $data = $text | ConvertFrom-Json -ErrorAction Stop if ($data.Scans) { $history.Scans = @($data.Scans) } if ($data.Removals) { $history.Removals = @($data.Removals) } if ($data.Restores) { $history.Restores = @($data.Restores) } } } catch { Write-Host "WARNING: The scan history could not be read, so it starts again: $($_.Exception.Message)" -ForegroundColor Yellow try { Copy-Item -LiteralPath $Path -Destination "$Path.bad" -Force -ErrorAction Stop -WhatIf:$false -Confirm:$false } catch { Write-Verbose "Could not keep a copy of the unreadable history: $($_.Exception.Message)" } } return $history } function Save-History { param($History, [string]$Path) $dir = Split-Path -Path $Path -Parent if (-not (Test-Path -LiteralPath $dir)) { New-Item -ItemType Directory -Path $dir -Force -WhatIf:$false -Confirm:$false | Out-Null } $data = [ordered]@{ Version = 1 Scans = @(@($History.Scans) | Select-Object -Last 50) Removals = @(@($History.Removals) | Select-Object -Last 1000) Restores = @(@($History.Restores) | Select-Object -Last 1000) } $json = ConvertTo-Json -InputObject $data -Depth 6 [System.IO.File]::WriteAllText($Path, $json, (New-Object System.Text.UTF8Encoding($true))) } function Get-FreedMB { # Space freed with WinPkgScan: removals minus restores. param($History) $freed = 0.0 foreach ($r in @($History.Removals)) { if ($r) { $freed += [double]$r.FreedMB } } foreach ($r in @($History.Restores)) { if ($r) { $freed -= [double]$r.SizeMB } } if ($freed -lt 0) { $freed = 0 } return [math]::Round($freed, 2) } $PackagesPath = Join-Path $env:LOCALAPPDATA "Packages" # Package folders end in "_" + a 13-character publisher ID # (lowercase base32: digits and letters except i, l, o, u). $PackageNamePattern = '^.+_[0-9a-hjkmnp-tv-z]{13}$' # Resolve the actual Desktop, including OneDrive-redirected Desktops. if ([string]::IsNullOrWhiteSpace($ReportPath)) { $DesktopPath = [Environment]::GetFolderPath("Desktop") if ([string]::IsNullOrWhiteSpace($DesktopPath)) { $DesktopPath = Join-Path $env:USERPROFILE "Desktop" } $ReportPath = Join-Path $DesktopPath "Orphaned-Appx-Packages.txt" } $CsvPath = [System.IO.Path]::ChangeExtension($ReportPath, ".csv") $HtmlPath = [System.IO.Path]::ChangeExtension($ReportPath, ".html") # Exclude lists: the one next to the scripts, and your own one (the app's # Settings edit it). -ExcludeFile uses only the file given. if ([string]::IsNullOrWhiteSpace($ExcludeFile)) { $ExcludeFiles = @( (Join-Path $PSScriptRoot "WinPkgScan.exclude.txt") (Join-Path (Join-Path $env:LOCALAPPDATA 'WinPkgScan') 'exclude.txt') ) $ExcludeFileGiven = $false } else { $ExcludeFiles = @($ExcludeFile) $ExcludeFileGiven = $true } $ExcludePatterns = Get-ExcludePattern -FromParameter $Exclude -Files $ExcludeFiles -FileWasGiven $ExcludeFileGiven Write-Host "" Write-Host "=============================================" -ForegroundColor Cyan Write-Host " AppData\Local\Packages Orphan Scanner v$ScriptVersion" -ForegroundColor Cyan Write-Host "=============================================" -ForegroundColor Cyan Write-Host "" # ------------------------------------------------------------ # Check Packages directory # ------------------------------------------------------------ if (-not (Test-Path -LiteralPath $PackagesPath)) { Write-Host "ERROR: Packages directory was not found." -ForegroundColor Red Write-Host $PackagesPath Exit-Script 1 } Write-Host "Packages directory:" -ForegroundColor Gray Write-Host " $PackagesPath" Write-Host "" # ------------------------------------------------------------ # Get currently registered AppX packages # ------------------------------------------------------------ Write-Host "Reading installed AppX packages..." -ForegroundColor Yellow try { # PowerShell 7+ needs the Appx module loaded via Windows PowerShell. if ($PSVersionTable.PSVersion.Major -ge 7) { Import-Module Appx -UseWindowsPowerShell -ErrorAction Stop -WarningAction SilentlyContinue } $InstalledPackages = Get-RegisteredPackage } catch { Write-Host "ERROR: Could not read installed AppX packages." -ForegroundColor Red Write-Host $_.Exception.Message Write-Host "Scan aborted to avoid flagging every folder as orphaned." -ForegroundColor Red Exit-Script 1 } # Case-insensitive lookup of Package Family Names. $InstalledFamilyNames = New-Object 'System.Collections.Generic.HashSet[string]' ([StringComparer]::OrdinalIgnoreCase) foreach ($Package in $InstalledPackages) { if (-not [string]::IsNullOrWhiteSpace($Package.PackageFamilyName)) { [void]$InstalledFamilyNames.Add($Package.PackageFamilyName) } } # A normal Windows install always has registered packages. # Zero results means the query failed silently. if ($InstalledFamilyNames.Count -eq 0) { Write-Host "ERROR: No registered AppX packages were returned." -ForegroundColor Red Write-Host "Scan aborted to avoid flagging every folder as orphaned." -ForegroundColor Red Exit-Script 1 } Write-Host "Registered packages found: $($InstalledFamilyNames.Count)" # Extra checks that need administrator rights. $AdminInfo = $null $AdminStatus = "off (run as administrator for extra checks)" $UseAdmin = ($AdminChecks -eq 'On') -or ($AdminChecks -eq 'Auto' -and (Test-IsAdministrator)) if ($AdminChecks -eq 'Off') { $AdminStatus = "off" } if ($UseAdmin) { Write-Host "Running administrator checks..." -ForegroundColor Yellow try { $AdminInfo = Get-AdminPackageInfo -Sid (Get-CurrentUserSid) $AdminStatus = "on" } catch { $AdminStatus = "failed: $($_.Exception.Message)" Write-Host "WARNING: Administrator checks failed: $($_.Exception.Message)" -ForegroundColor Yellow } } Write-Host "Administrator checks: $AdminStatus" Write-Host "" # ------------------------------------------------------------ # Scan ONLY the Packages directory # ------------------------------------------------------------ Write-Host "Scanning package folders..." -ForegroundColor Yellow Write-Host "" $Folders = @( Get-ChildItem -LiteralPath $PackagesPath -Directory -Force -ErrorAction SilentlyContinue ) # Scan history: which folders were found last time. $History = $null $PrevScan = $null $PrevNames = New-Object 'System.Collections.Generic.HashSet[string]' ([StringComparer]::OrdinalIgnoreCase) if (-not $NoHistory) { $History = Read-History -Path (Get-HistoryPath) $PrevScan = @($History.Scans) | Select-Object -Last 1 if ($PrevScan) { foreach ($n in @($PrevScan.Folders)) { if ($n) { [void]$PrevNames.Add("$n") } } } } $Results = New-Object System.Collections.Generic.List[object] $Excluded = New-Object System.Collections.Generic.List[string] $KeptByAdminCheck = 0 $FolderNumber = 0 foreach ($Folder in $Folders) { $FolderNumber++ if ($ProgressLines) { Write-Host ("PROGRESS {0}/{1} {2}" -f $FolderNumber, $Folders.Count, $Folder.Name) } if ($InstalledFamilyNames.Contains($Folder.Name)) { continue } # Registered to this user in an unfinished state (staged, pending). if ($AdminInfo -and $AdminInfo.ForMe.Contains($Folder.Name)) { $KeptByAdminCheck++ continue } if (Get-MatchingPattern -Name $Folder.Name -Patterns $ExcludePatterns) { $Excluded.Add($Folder.Name) continue } # One pass: total size and newest write time. # Inaccessible files are skipped, so size may be understated. $SizeBytes = [long]0 $Newest = $Folder.LastWriteTime try { foreach ($File in (Get-ChildItem -LiteralPath $Folder.FullName -File -Recurse -Force -ErrorAction SilentlyContinue)) { $SizeBytes += $File.Length if ($File.LastWriteTime -gt $Newest) { $Newest = $File.LastWriteTime } } } catch { Write-Verbose "Could not fully read $($Folder.FullName): $($_.Exception.Message)" } $IsPackageNamed = $Folder.Name -cmatch $PackageNamePattern -or $Folder.Name.ToLowerInvariant() -cmatch $PackageNamePattern if (Test-WindowsComponent $Folder.Name) { $Category = "Windows component"; $Status = "WINDOWS COMPONENT (KEEP)" } elseif ($IsPackageNamed) { $Category = "Orphaned package"; $Status = "NOT CURRENTLY REGISTERED" } else { $Category = "Other folder"; $Status = "NOT A PACKAGE-NAMED FOLDER" } $NoteParts = @() if ($AdminInfo) { if ($AdminInfo.Provisioned.Contains($Folder.Name)) { $NoteParts += "Provisioned (Windows may reinstall it)" } if ($AdminInfo.OtherUsers.ContainsKey($Folder.Name)) { $n = $AdminInfo.OtherUsers[$Folder.Name] $NoteParts += "Installed for $n other user$(if ($n -ne 1) { 's' })" } } $Results.Add( [PSCustomObject]@{ Category = $Category FolderName = $Folder.Name SizeMB = [math]::Round(($SizeBytes / 1MB), 2) LastModified = $Newest.ToString("yyyy-MM-dd") FullPath = $Folder.FullName Status = $Status Notes = ($NoteParts -join '; ') New = if ($PrevScan -and -not $PrevNames.Contains($Folder.Name)) { 'Yes' } else { '' } } ) } $Orphans = @($Results | Where-Object { $_.Category -eq "Orphaned package" } | Sort-Object SizeMB -Descending) $WinParts = @($Results | Where-Object { $_.Category -eq "Windows component" } | Sort-Object SizeMB -Descending) $Others = @($Results | Where-Object { $_.Category -eq "Other folder" } | Sort-Object SizeMB -Descending) function Get-TotalMB { param([object[]]$Items) if ($Items.Count -eq 0) { return 0 } return [math]::Round((($Items | Measure-Object -Property SizeMB -Sum).Sum), 2) } $OrphanMB = Get-TotalMB $Orphans $WinMB = Get-TotalMB $WinParts $OtherMB = Get-TotalMB $Others $NewCount = @(@($Orphans) + @($Others) | Where-Object { $_.New -eq 'Yes' }).Count if ($History) { $NewText = if ($PrevScan) { "$NewCount (last scan $($PrevScan.Time))" } else { "first scan" } $FreedText = "$(Get-FreedMB $History) MB" } # ------------------------------------------------------------ # Console output # ------------------------------------------------------------ Write-Host "=============================================" -ForegroundColor Cyan Write-Host " Scan Results" -ForegroundColor Cyan Write-Host "=============================================" -ForegroundColor Cyan Write-Host "" Write-Host "Folders scanned: $($Folders.Count)" Write-Host "Registered packages: $($InstalledFamilyNames.Count)" Write-Host "Orphaned package folders: $($Orphans.Count) ($OrphanMB MB)" Write-Host "Windows components: $($WinParts.Count) ($WinMB MB)" Write-Host "Other folders: $($Others.Count) ($OtherMB MB)" Write-Host "Excluded: $($Excluded.Count)" if ($AdminInfo) { Write-Host "Kept by admin checks: $KeptByAdminCheck" } if ($History) { Write-Host "New since last scan: $NewText" Write-Host "Freed with WinPkgScan: $FreedText" } Write-Host "" if ($Orphans.Count -eq 0) { Write-Host "No orphaned package folders were found." -ForegroundColor Green } else { Write-Host "Orphaned package folders:" -ForegroundColor Yellow Write-ResultTable $Orphans } if ($WinParts.Count -gt 0) { Write-Host "Windows components (keep these):" -ForegroundColor Green Write-ResultTable $WinParts } if ($Others.Count -gt 0) { Write-Host "Other folders (not package-named, review manually):" -ForegroundColor Gray Write-ResultTable $Others } # ------------------------------------------------------------ # Generate report # ------------------------------------------------------------ $Report = New-Object System.Collections.Generic.List[string] $Report.Add("============================================================") $Report.Add(" AppData\Local\Packages - Orphaned Package Report") $Report.Add("============================================================") $Report.Add("") $Report.Add("Generated: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')") $Report.Add("Version: $ScriptVersion") $Report.Add("User: $env:USERNAME") $Report.Add("Computer: $env:COMPUTERNAME") $Report.Add("") $Report.Add("Packages directory:") $Report.Add($PackagesPath) $Report.Add("") $Report.Add("Folders scanned: $($Folders.Count)") $Report.Add("Registered packages: $($InstalledFamilyNames.Count)") $Report.Add("Orphaned package folders: $($Orphans.Count) ($OrphanMB MB)") $Report.Add("Windows components: $($WinParts.Count) ($WinMB MB)") $Report.Add("Other folders: $($Others.Count) ($OtherMB MB)") $Report.Add("Excluded: $($Excluded.Count)") $Report.Add("Administrator checks: $AdminStatus") if ($AdminInfo) { $Report.Add("Kept by admin checks: $KeptByAdminCheck") } if ($ExcludePatterns.Count -gt 0) { $Report.Add("Exclude patterns: $($ExcludePatterns -join ', ')") } if ($History) { $Report.Add("New since last scan: $NewText") $Report.Add("Freed with WinPkgScan: $FreedText") } $Report.Add("") $Report.Add("IMPORTANT:") $Report.Add("This is an identification report only.") $Report.Add("Nothing was deleted or modified.") $Report.Add("") $Report.Add("Orphaned package folders are named like an app package but") $Report.Add("are not registered for this Windows user. Windows components") $Report.Add("belong to Windows itself: keep them. Other folders are not") $Report.Add("named like a package and are often used by Windows.") $Report.Add("Verify each folder before deleting it.") $Report.Add("") Add-ReportSection -Report $Report -Title "Orphaned Package Folders" -Items $Orphans ` -EmptyText "No orphaned package folders were detected." Add-ReportSection -Report $Report -Title "Windows Components (keep)" -Items $WinParts ` -EmptyText "No Windows component folders were detected." Add-ReportSection -Report $Report -Title "Other Folders (not package-named)" -Items $Others ` -EmptyText "No other folders were detected." $Report.Add("============================================================") $Report.Add(" End of Report") $Report.Add("============================================================") try { $ReportDir = Split-Path -Path $ReportPath -Parent if ($ReportDir -and -not (Test-Path -LiteralPath $ReportDir)) { New-Item -ItemType Directory -Path $ReportDir -Force | Out-Null } $Report | Out-File -LiteralPath $ReportPath -Encoding UTF8 -Force -ErrorAction Stop Write-Host "" Write-Host "Report created:" -ForegroundColor Green Write-Host $ReportPath -ForegroundColor White if ($Csv) { $CsvRows = @(@($Orphans) + @($WinParts) + @($Others) | Select-Object @{ Name = 'Delete'; Expression = { '' } }, Category, FolderName, SizeMB, LastModified, FullPath, Status, Notes, New) if ($CsvRows.Count -gt 0) { $CsvRows | Export-Csv -LiteralPath $CsvPath -NoTypeInformation -Encoding UTF8 -Force -ErrorAction Stop } else { # Nothing found: still write the header row, so the file is a valid CSV. '"Delete","Category","FolderName","SizeMB","LastModified","FullPath","Status","Notes","New"' | Out-File -LiteralPath $CsvPath -Encoding UTF8 -Force -ErrorAction Stop } Write-Host "CSV created:" -ForegroundColor Green Write-Host $CsvPath -ForegroundColor White } if ($Html) { $tiles = @( [PSCustomObject]@{ Label = "Orphaned package folders"; Value = "$($Orphans.Count) ($OrphanMB MB)" } [PSCustomObject]@{ Label = "Windows components (keep)"; Value = "$($WinParts.Count)" } [PSCustomObject]@{ Label = "Other folders"; Value = "$($Others.Count)" } [PSCustomObject]@{ Label = "Excluded"; Value = "$($Excluded.Count)" } [PSCustomObject]@{ Label = "Registered packages"; Value = "$($InstalledFamilyNames.Count)" } ) if ($History) { $tiles += [PSCustomObject]@{ Label = "New since last scan"; Value = $(if ($PrevScan) { "$NewCount" } else { "First scan" }) } $tiles += [PSCustomObject]@{ Label = "Freed with WinPkgScan"; Value = $FreedText } } $sections = @( [PSCustomObject]@{ Kind = 'orphan'; Title = 'Orphaned package folders'; Items = $Orphans; TotalMB = $OrphanMB Note = 'Named like an app package but not registered for this user. Main cleanup candidates: check each one first.' } [PSCustomObject]@{ Kind = 'windows'; Title = 'Windows components'; Items = $WinParts; TotalMB = $WinMB Note = 'Belong to Windows itself. Keep these. The remover will not delete them.' } [PSCustomObject]@{ Kind = 'other'; Title = 'Other folders'; Items = $Others; TotalMB = $OtherMB Note = 'Not named like a package. Often used by Windows. Review manually.' } ) $who = @($env:USERNAME, $env:COMPUTERNAME) | Where-Object { $_ } $subtitle = (@(($who -join ' on '), (Get-Date -Format 'yyyy-MM-dd HH:mm'), $PackagesPath) | Where-Object { $_ }) -join ', ' Write-HtmlReport -Path $HtmlPath -Sections $sections -Tiles $tiles -Subtitle $subtitle Write-Host "HTML report created:" -ForegroundColor Green Write-Host $HtmlPath -ForegroundColor White if ($Open) { try { Invoke-Item -LiteralPath $HtmlPath -ErrorAction Stop } catch { Write-Host "Could not open the HTML report: $($_.Exception.Message)" -ForegroundColor Yellow } } } } catch { Write-Host "" Write-Host "ERROR: Could not write output file." -ForegroundColor Red Write-Host $_.Exception.Message Write-Host "" Write-Host "Nothing was deleted or modified." -ForegroundColor Green Exit-Script 1 } # Remember this scan, for "new since last scan" next time. if ($History) { try { $History.Scans = @(@($History.Scans) + [PSCustomObject]@{ Time = (Get-Date).ToString('yyyy-MM-dd HH:mm') Version = $ScriptVersion Orphans = $Orphans.Count OrphanMB = $OrphanMB Windows = $WinParts.Count Other = $Others.Count Excluded = $Excluded.Count Folders = @(@($Orphans) + @($WinParts) + @($Others) | ForEach-Object { $_.FolderName }) }) Save-History -History $History -Path (Get-HistoryPath) } catch { Write-Host "WARNING: Could not save the scan history: $($_.Exception.Message)" -ForegroundColor Yellow } } Write-Host "" Write-Host "Nothing was deleted or modified." -ForegroundColor Green Exit-Script 0 |