windowsweep 1.1.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/AI-INTEGRATION-GUIDE.md +15 -11
  2. package/CHANGELOG.md +56 -0
  3. package/LICENSE +21 -21
  4. package/README.md +70 -42
  5. package/VERSION +1 -1
  6. package/lib/actions.ps1 +212 -212
  7. package/lib/config.ps1 +108 -99
  8. package/lib/constants.ps1 +103 -93
  9. package/lib/fs.ps1 +189 -180
  10. package/lib/log.ps1 +140 -140
  11. package/lib/safety.ps1 +541 -493
  12. package/lib/scan.ps1 +109 -100
  13. package/lib/ui.ps1 +275 -275
  14. package/modules/android_avd.ps1 +36 -36
  15. package/modules/app_caches.ps1 +58 -58
  16. package/modules/browsers.ps1 +33 -33
  17. package/modules/build_tools.ps1 +35 -35
  18. package/modules/crash_trap.ps1 +89 -89
  19. package/modules/disk_usage.ps1 +49 -49
  20. package/modules/docker.ps1 +93 -93
  21. package/modules/editors.ps1 +150 -150
  22. package/modules/globals_audit.ps1 +236 -236
  23. package/modules/health.ps1 +101 -101
  24. package/modules/installed_programs.ps1 +118 -118
  25. package/modules/menu.ps1 +63 -63
  26. package/modules/notify.ps1 +60 -60
  27. package/modules/orphaned_appdata.ps1 +189 -189
  28. package/modules/personal.ps1 +126 -126
  29. package/modules/pkg_managers.ps1 +73 -73
  30. package/modules/projects.ps1 +160 -160
  31. package/modules/release_helpers.ps1 +438 -438
  32. package/modules/reports.ps1 +212 -212
  33. package/modules/runner.ps1 +242 -239
  34. package/modules/self_test_extra.ps1 +537 -360
  35. package/modules/startup_audit.ps1 +126 -126
  36. package/modules/system_admin.ps1 +220 -220
  37. package/modules/test_runners.ps1 +24 -24
  38. package/modules/walkthrough.ps1 +80 -79
  39. package/modules/windows_user.ps1 +118 -118
  40. package/package.json +2 -2
  41. package/windowsweep.ps1 +322 -316
@@ -1,60 +1,60 @@
1
- # notify.ps1 - --notify: one Windows notification when a run ends.
2
- #
3
- # Off by default. --quiet still notifies (that is the point of the flag when a Scheduled Task runs it).
4
- # Every path is wrapped: a notification that cannot be shown is not a failed run, so this NEVER touches the
5
- # exit code and NEVER writes to stdout - --json promises exactly one line there.
6
-
7
- function Get-RunNotificationText {
8
- <# .SYNOPSIS The two lines a notification carries. Pure, so the self-test can read it without a toast. #>
9
- $ws = $Script:WS
10
- $ran = @($ws.Report.steps | Where-Object { $_.status -in 'ran', 'dry-run' }).Count
11
- if ($ws.DryRun) {
12
- return [pscustomobject]@{ Title = "$Script:WS_NAME - dry run finished"; Body = ("about " + (Format-Bytes $ws.TotalEstimated) + " could be freed across $ran section(s)") }
13
- }
14
- return [pscustomobject]@{ Title = "$Script:WS_NAME finished"; Body = ("freed " + (Format-Bytes $ws.TotalFreed) + " across $ran section(s)") }
15
- }
16
-
17
- function Show-ToastNotification {
18
- <# .SYNOPSIS Windows PowerShell 5.1: a real toast through WinRT, under the PowerShell AppUserModelId. #>
19
- param([string] $Title, [string] $Body)
20
- $appId = '{1AC14E77-02E7-4E5D-B744-2EB1AE5198B7}\WindowsPowerShell\v1.0\powershell.exe'
21
- $null = [Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime]
22
- $null = [Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom.XmlDocument, ContentType = WindowsRuntime]
23
- $tpl = [Windows.UI.Notifications.ToastNotificationManager]::GetTemplateContent([Windows.UI.Notifications.ToastTemplateType]::ToastText02)
24
- $texts = $tpl.GetElementsByTagName('text')
25
- $null = $texts.Item(0).AppendChild($tpl.CreateTextNode($Title))
26
- $null = $texts.Item(1).AppendChild($tpl.CreateTextNode($Body))
27
- $toast = New-Object Windows.UI.Notifications.ToastNotification($tpl)
28
- [Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier($appId).Show($toast)
29
- }
30
-
31
- function Show-BalloonNotification {
32
- <# .SYNOPSIS PowerShell 7 has no WinRT projection by default: a tray balloon instead. #>
33
- param([string] $Title, [string] $Body)
34
- Add-Type -AssemblyName System.Windows.Forms -ErrorAction Stop
35
- Add-Type -AssemblyName System.Drawing -ErrorAction Stop
36
- $icon = New-Object System.Windows.Forms.NotifyIcon
37
- try {
38
- $icon.Icon = [System.Drawing.SystemIcons]::Information
39
- $icon.Visible = $true
40
- $icon.ShowBalloonTip(5000, $Title, $Body, [System.Windows.Forms.ToolTipIcon]::Info)
41
- # The balloon needs the tray icon to survive a moment; without this it is created and destroyed unseen.
42
- Start-Sleep -Milliseconds 600
43
- } finally {
44
- $icon.Visible = $false
45
- $icon.Dispose()
46
- }
47
- }
48
-
49
- function Send-RunNotification {
50
- <# .SYNOPSIS --notify: show the end-of-run notification, or say quietly in the log why it could not. #>
51
- if (-not $Script:WS.Notify) { return }
52
- $text = Get-RunNotificationText
53
- try {
54
- if ($PSVersionTable.PSEdition -eq 'Core') { Show-BalloonNotification -Title $text.Title -Body $text.Body }
55
- else { Show-ToastNotification -Title $text.Title -Body $text.Body }
56
- Write-LogLine "notification shown: $($text.Title) - $($text.Body)"
57
- } catch {
58
- Write-LogLine "notification could not be shown (this never affects the exit code): $($_.Exception.Message)"
59
- }
60
- }
1
+ # notify.ps1 - --notify: one Windows notification when a run ends.
2
+ #
3
+ # Off by default. --quiet still notifies (that is the point of the flag when a Scheduled Task runs it).
4
+ # Every path is wrapped: a notification that cannot be shown is not a failed run, so this NEVER touches the
5
+ # exit code and NEVER writes to stdout - --json promises exactly one line there.
6
+
7
+ function Get-RunNotificationText {
8
+ <# .SYNOPSIS The two lines a notification carries. Pure, so the self-test can read it without a toast. #>
9
+ $ws = $Script:WS
10
+ $ran = @($ws.Report.steps | Where-Object { $_.status -in 'ran', 'dry-run' }).Count
11
+ if ($ws.DryRun) {
12
+ return [pscustomobject]@{ Title = "$Script:WS_NAME - dry run finished"; Body = ("about " + (Format-Bytes $ws.TotalEstimated) + " could be freed across $ran section(s)") }
13
+ }
14
+ return [pscustomobject]@{ Title = "$Script:WS_NAME finished"; Body = ("freed " + (Format-Bytes $ws.TotalFreed) + " across $ran section(s)") }
15
+ }
16
+
17
+ function Show-ToastNotification {
18
+ <# .SYNOPSIS Windows PowerShell 5.1: a real toast through WinRT, under the PowerShell AppUserModelId. #>
19
+ param([string] $Title, [string] $Body)
20
+ $appId = '{1AC14E77-02E7-4E5D-B744-2EB1AE5198B7}\WindowsPowerShell\v1.0\powershell.exe'
21
+ $null = [Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime]
22
+ $null = [Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom.XmlDocument, ContentType = WindowsRuntime]
23
+ $tpl = [Windows.UI.Notifications.ToastNotificationManager]::GetTemplateContent([Windows.UI.Notifications.ToastTemplateType]::ToastText02)
24
+ $texts = $tpl.GetElementsByTagName('text')
25
+ $null = $texts.Item(0).AppendChild($tpl.CreateTextNode($Title))
26
+ $null = $texts.Item(1).AppendChild($tpl.CreateTextNode($Body))
27
+ $toast = New-Object Windows.UI.Notifications.ToastNotification($tpl)
28
+ [Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier($appId).Show($toast)
29
+ }
30
+
31
+ function Show-BalloonNotification {
32
+ <# .SYNOPSIS PowerShell 7 has no WinRT projection by default: a tray balloon instead. #>
33
+ param([string] $Title, [string] $Body)
34
+ Add-Type -AssemblyName System.Windows.Forms -ErrorAction Stop
35
+ Add-Type -AssemblyName System.Drawing -ErrorAction Stop
36
+ $icon = New-Object System.Windows.Forms.NotifyIcon
37
+ try {
38
+ $icon.Icon = [System.Drawing.SystemIcons]::Information
39
+ $icon.Visible = $true
40
+ $icon.ShowBalloonTip(5000, $Title, $Body, [System.Windows.Forms.ToolTipIcon]::Info)
41
+ # The balloon needs the tray icon to survive a moment; without this it is created and destroyed unseen.
42
+ Start-Sleep -Milliseconds 600
43
+ } finally {
44
+ $icon.Visible = $false
45
+ $icon.Dispose()
46
+ }
47
+ }
48
+
49
+ function Send-RunNotification {
50
+ <# .SYNOPSIS --notify: show the end-of-run notification, or say quietly in the log why it could not. #>
51
+ if (-not $Script:WS.Notify) { return }
52
+ $text = Get-RunNotificationText
53
+ try {
54
+ if ($PSVersionTable.PSEdition -eq 'Core') { Show-BalloonNotification -Title $text.Title -Body $text.Body }
55
+ else { Show-ToastNotification -Title $text.Title -Body $text.Body }
56
+ Write-LogLine "notification shown: $($text.Title) - $($text.Body)"
57
+ } catch {
58
+ Write-LogLine "notification could not be shown (this never affects the exit code): $($_.Exception.Message)"
59
+ }
60
+ }
@@ -1,189 +1,189 @@
1
- # orphaned_appdata.ps1 - section 23: folders directly under %APPDATA% and %LOCALAPPDATA% that belong to no
2
- # installed program. Interactive only; selected folders go to the Recycle Bin.
3
- #
4
- # The dangerous direction here is calling something orphaned when it is not, so every test errs the other
5
- # way: matching is generous, the exclusion list is long, and an EMPTY program index produces ZERO candidates
6
- # rather than "everything is orphaned".
7
-
8
- # Windows' own folders, shared infrastructure, and vendors whose data outlives an uninstall.
9
- $Script:WS_ORPHAN_EXCLUDE = @(
10
- 'Microsoft', 'Windows', 'WindowsPowerShell', 'PowerShell', 'Packages', 'Programs', 'Temp', 'Tmp', 'Comms',
11
- 'ConnectedDevicesPlatform', 'D3DSCache', 'Google', 'Mozilla', 'CrashDumps', 'VirtualStore', 'PeerDistRepub',
12
- 'Publishers', 'History', 'IconCache', 'ElevatedDiagnostics', 'Application Data', 'Local Settings',
13
- 'Deployment', 'assembly', 'GroupPolicy', 'Roaming', 'Local', 'LocalLow', 'Low', 'Diagnostics',
14
- 'PlaceholderTileLogoFolder', 'Desktop', 'Downloads', 'Documents', 'Start Menu', 'SendTo',
15
- 'Recent', 'NetHood', 'PrintHood', 'Templates', 'Cookies',
16
- # Platform folders that belong to Windows or .NET rather than to any one program: they look orphaned
17
- # because no uninstall entry names them, and one of them holds settings a .NET app wrote.
18
- 'IsolatedStorage', 'ToastNotificationManagerCompat', 'AppV', 'Package Cache', 'DBG', 'CLR_v4.0', 'CLR_v4.0_32'
19
- )
20
-
21
- function ConvertTo-MatchToken {
22
- <# .SYNOPSIS Lower-case, alphanumerics only - so "Slack Technologies, Inc." and "slack" compare equal. #>
23
- param([string] $Text)
24
- if ([string]::IsNullOrWhiteSpace($Text)) { return '' }
25
- return ($Text.ToLowerInvariant() -replace '[^a-z0-9]', '')
26
- }
27
-
28
- function Add-MatchTokens {
29
- <# .SYNOPSIS Add a display string to the token set: the whole thing, plus each word of 4+ characters. #>
30
- param([System.Collections.Generic.HashSet[string]] $Set, [string] $Text)
31
- $whole = ConvertTo-MatchToken $Text
32
- if ($whole.Length -ge 3) { $null = $Set.Add($whole) }
33
- foreach ($w in ($Text -split '[^A-Za-z0-9]+')) {
34
- $t = ConvertTo-MatchToken $w
35
- if ($t.Length -ge 4) { $null = $Set.Add($t) }
36
- }
37
- }
38
-
39
- function Get-InstalledProgramRows {
40
- <# .SYNOPSIS Raw uninstall-hive rows from all three registry views. Shared with section 24. #>
41
- $rows = @()
42
- $hives = @(
43
- 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall',
44
- 'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall',
45
- 'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall'
46
- )
47
- foreach ($h in $hives) {
48
- $keys = @()
49
- try { $keys = @(Get-ChildItem -LiteralPath $h -ErrorAction Stop) } catch { $keys = @() }
50
- foreach ($k in $keys) {
51
- $props = $null
52
- try { $props = Get-ItemProperty -LiteralPath $k.PSPath -ErrorAction Stop } catch { continue }
53
- if (-not $props.DisplayName) { continue }
54
- $rows += [pscustomobject]@{
55
- DisplayName = [string]$props.DisplayName; DisplayVersion = [string]$props.DisplayVersion
56
- Publisher = [string]$props.Publisher; InstallDate = [string]$props.InstallDate
57
- EstimatedSizeKb = [long]0 + $props.EstimatedSize; InstallLocation = [string]$props.InstallLocation
58
- UninstallString = [string]$props.UninstallString; SystemComponent = [int]0 + $props.SystemComponent
59
- }
60
- }
61
- }
62
- return $rows
63
- }
64
-
65
- function Get-InstalledProgramIndex {
66
- <# .SYNOPSIS Everything that says "a program owns this name": uninstall entries, program folders, Store
67
- package names and running processes. RegistryCount drives the fail-closed gate in section 23. #>
68
- $P = $Script:P
69
- $set = New-Object System.Collections.Generic.HashSet[string]([StringComparer]::Ordinal)
70
- $rows = @(Get-InstalledProgramRows)
71
- foreach ($r in $rows) {
72
- Add-MatchTokens -Set $set -Text $r.DisplayName
73
- Add-MatchTokens -Set $set -Text $r.Publisher
74
- if ($r.InstallLocation) { Add-MatchTokens -Set $set -Text (Split-Path -Leaf ($r.InstallLocation.TrimEnd('\'))) }
75
- }
76
- foreach ($dir in @($P.PF, $P.PF86, (Join-Path $P.L 'Programs'), (Join-Path $P.L 'Microsoft\WindowsApps'), (Join-Path $P.PD 'Microsoft\Windows\Start Menu\Programs'))) {
77
- if (-not $dir -or -not (Test-DirPresent $dir)) { continue }
78
- foreach ($e in (Get-ChildEntries $dir)) { Add-MatchTokens -Set $set -Text (($e.Name -split '_')[0]) }
79
- }
80
- foreach ($e in (Get-ChildEntries (Join-Path $P.L 'Packages'))) { Add-MatchTokens -Set $set -Text (($e.Name -split '_')[0]) }
81
- try { foreach ($proc in (Get-Process -ErrorAction SilentlyContinue)) { Add-MatchTokens -Set $set -Text $proc.ProcessName } } catch { $null = $_ }
82
- return [pscustomobject]@{ Tokens = $set; RegistryCount = $rows.Count; Rows = $rows }
83
- }
84
-
85
- function Get-OrphanExclusions {
86
- <# .SYNOPSIS Folder names section 23 never offers: the fixed list plus, derived at run time, the first
87
- segment under AppData of every target the tool already declares - so a vendor folder we clean can never
88
- also be offered for deletion, and the list cannot drift as sections are added. #>
89
- $P = $Script:P
90
- $set = New-Object System.Collections.Generic.HashSet[string]([StringComparer]::OrdinalIgnoreCase)
91
- foreach ($n in $Script:WS_ORPHAN_EXCLUDE) { $null = $set.Add($n) }
92
- $prefixes = @("$($P.A)\", "$($P.L)\")
93
- foreach ($t in (Get-AllTargets)) {
94
- $path = [string]$t.Path
95
- foreach ($pre in $prefixes) {
96
- if ($path.StartsWith($pre, [StringComparison]::OrdinalIgnoreCase)) {
97
- $seg = ($path.Substring($pre.Length) -split '\\')[0]
98
- if ($seg) { $null = $set.Add($seg) }
99
- }
100
- }
101
- }
102
- # ,$set - a bare `return $set` UNROLLS the HashSet into an Object[], whose .Contains is case-SENSITIVE,
103
- # so a folder named "slack" would slip past the "Slack" entry. The comma keeps the set a set.
104
- return , $set
105
- }
106
-
107
- function Test-OrphanFolderName {
108
- <# .SYNOPSIS True when NOTHING installed claims this folder name. Deliberately generous in the other
109
- direction: a token that merely starts with the name (or the reverse) counts as a claim. #>
110
- param([string] $Name, [pscustomobject] $Index)
111
- $n = ConvertTo-MatchToken $Name
112
- if ($n.Length -lt 3) { return $false }
113
- if ($Index.Tokens.Contains($n)) { return $false }
114
- foreach ($t in $Index.Tokens) {
115
- if ($t.Length -lt 4) { continue }
116
- if ($t.StartsWith($n) -or $n.StartsWith($t)) { return $false }
117
- }
118
- return $true
119
- }
120
-
121
- function Find-OrphanedAppData {
122
- <# .SYNOPSIS Top-level AppData folders idle N+ days that no installed program claims. Fails closed.
123
- -Index is for the self-test: it proves the fail-closed gate without touching the real registry. #>
124
- param([int] $Days, [pscustomobject] $Index = $null)
125
- $P = $Script:P
126
- $index = $Index
127
- if ($null -eq $index) { $index = Get-InstalledProgramIndex }
128
- if ($index.RegistryCount -eq 0) {
129
- Write-Warn 'no uninstall entries could be read: refusing to call anything orphaned (fail closed).'
130
- return @()
131
- }
132
- Write-Note "cross-checked against $($index.RegistryCount) installed-program entries"
133
- $exclude = Get-OrphanExclusions
134
- $out = @()
135
- foreach ($root in @($P.A, $P.L)) {
136
- if (-not (Test-DirPresent $root)) { continue }
137
- foreach ($e in (Get-ChildEntries $root)) {
138
- if (-not ($e -is [IO.DirectoryInfo]) -or (Test-ReparsePoint $e)) { continue }
139
- if ($exclude.Contains($e.Name)) { continue }
140
- $full = Remove-LongPrefix $e.FullName
141
- if (Get-ProtectionReason $full) { continue }
142
- if (-not (Test-OrphanFolderName -Name $e.Name -Index $index)) { continue }
143
- $idle = Get-IdleDays $full
144
- if ($idle -lt $Days) { continue }
145
- $out += [pscustomobject]@{ Name = $e.Name; Path = $full; Root = (Get-FullPath $root); Idle = $idle; Bytes = [long](Get-DirectoryBytes $full) }
146
- }
147
- }
148
- return @($out | Sort-Object Bytes -Descending | Select-Object -First 200)
149
- }
150
-
151
- function Get-Targets23 {
152
- <# .SYNOPSIS Informational rows: the candidates are found at run time, never declared as fixed paths. #>
153
- $P = $Script:P
154
- $t = @()
155
- foreach ($r in @($P.A, $P.L)) {
156
- if (Test-DirPresent $r) { $t += (New-Target 23 'AppData root scanned for orphans' (Get-FullPath $r) -Kind cmd -Note 'top-level folders only; you pick what goes, and it lands in the Recycle Bin') }
157
- }
158
- return $t
159
- }
160
-
161
- function Invoke-Section23 {
162
- $ws = $Script:WS
163
- Write-SectionIntro @(
164
- 'Uninstalling a program usually leaves its AppData folder behind. This lists top-level folders under',
165
- "Roaming and Local that no installed program, Store package or running process claims and that nothing",
166
- "has touched for $($ws.Days)+ days. You pick what goes; it lands in the Recycle Bin."
167
- )
168
- $rows = @(Find-OrphanedAppData -Days $ws.Days)
169
- if ($rows.Count -eq 0) { Write-Info 'no orphaned application data found'; return }
170
- Write-UiLine (" {0,3} {1,10} {2,6} {3}" -f '#', 'SIZE', 'IDLE', 'FOLDER') 'White'
171
- $i = 1; $total = [long]0
172
- foreach ($r in $rows) {
173
- Write-UiLine (" {0,3} {1,10} {2,5}d {3}" -f $i, (Format-Bytes $r.Bytes), $r.Idle, $r.Path) 'Gray'
174
- Add-JsonCandidate -Section 23 -Index $i -Path $r.Path -Bytes $r.Bytes -IdleDays $r.Idle
175
- $total += $r.Bytes; $i++
176
- }
177
- Write-Info ('total: ' + (Format-Bytes $total))
178
- Write-Note 'a folder here can still hold settings or a licence for a program you plan to reinstall - read the list.'
179
- $dest = 'the Recycle Bin'
180
- if ($ws.Permanent) { $dest = 'PERMANENT deletion (--permanent)' }
181
- Write-Note "selected folders go to $dest"
182
- $picks = @(Read-MultiSelect -Total $rows.Count -NoAutoYes -Candidates @($rows | ForEach-Object { $_.Path }))
183
- if ($picks.Count -eq 0) { Write-Info 'nothing selected'; return }
184
- if (-not $ws.DryRun) {
185
- # Orphaned app data never auto-confirms: --yes does not apply. An explicit --select / --select-file does.
186
- if (-not (Confirm-Ui -Prompt "Move $($picks.Count) folder(s) to $dest ?" -Default 'n' -NoAutoYes -ScriptedOk)) { Write-Info 'skipped'; return }
187
- }
188
- foreach ($k in $picks) { $null = Send-ToRecycleBin -Path $rows[$k - 1].Path -Within $rows[$k - 1].Root }
189
- }
1
+ # orphaned_appdata.ps1 - section 23: folders directly under %APPDATA% and %LOCALAPPDATA% that belong to no
2
+ # installed program. Interactive only; selected folders go to the Recycle Bin.
3
+ #
4
+ # The dangerous direction here is calling something orphaned when it is not, so every test errs the other
5
+ # way: matching is generous, the exclusion list is long, and an EMPTY program index produces ZERO candidates
6
+ # rather than "everything is orphaned".
7
+
8
+ # Windows' own folders, shared infrastructure, and vendors whose data outlives an uninstall.
9
+ $Script:WS_ORPHAN_EXCLUDE = @(
10
+ 'Microsoft', 'Windows', 'WindowsPowerShell', 'PowerShell', 'Packages', 'Programs', 'Temp', 'Tmp', 'Comms',
11
+ 'ConnectedDevicesPlatform', 'D3DSCache', 'Google', 'Mozilla', 'CrashDumps', 'VirtualStore', 'PeerDistRepub',
12
+ 'Publishers', 'History', 'IconCache', 'ElevatedDiagnostics', 'Application Data', 'Local Settings',
13
+ 'Deployment', 'assembly', 'GroupPolicy', 'Roaming', 'Local', 'LocalLow', 'Low', 'Diagnostics',
14
+ 'PlaceholderTileLogoFolder', 'Desktop', 'Downloads', 'Documents', 'Start Menu', 'SendTo',
15
+ 'Recent', 'NetHood', 'PrintHood', 'Templates', 'Cookies',
16
+ # Platform folders that belong to Windows or .NET rather than to any one program: they look orphaned
17
+ # because no uninstall entry names them, and one of them holds settings a .NET app wrote.
18
+ 'IsolatedStorage', 'ToastNotificationManagerCompat', 'AppV', 'Package Cache', 'DBG', 'CLR_v4.0', 'CLR_v4.0_32'
19
+ )
20
+
21
+ function ConvertTo-MatchToken {
22
+ <# .SYNOPSIS Lower-case, alphanumerics only - so "Slack Technologies, Inc." and "slack" compare equal. #>
23
+ param([string] $Text)
24
+ if ([string]::IsNullOrWhiteSpace($Text)) { return '' }
25
+ return ($Text.ToLowerInvariant() -replace '[^a-z0-9]', '')
26
+ }
27
+
28
+ function Add-MatchTokens {
29
+ <# .SYNOPSIS Add a display string to the token set: the whole thing, plus each word of 4+ characters. #>
30
+ param([System.Collections.Generic.HashSet[string]] $Set, [string] $Text)
31
+ $whole = ConvertTo-MatchToken $Text
32
+ if ($whole.Length -ge 3) { $null = $Set.Add($whole) }
33
+ foreach ($w in ($Text -split '[^A-Za-z0-9]+')) {
34
+ $t = ConvertTo-MatchToken $w
35
+ if ($t.Length -ge 4) { $null = $Set.Add($t) }
36
+ }
37
+ }
38
+
39
+ function Get-InstalledProgramRows {
40
+ <# .SYNOPSIS Raw uninstall-hive rows from all three registry views. Shared with section 24. #>
41
+ $rows = @()
42
+ $hives = @(
43
+ 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall',
44
+ 'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall',
45
+ 'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall'
46
+ )
47
+ foreach ($h in $hives) {
48
+ $keys = @()
49
+ try { $keys = @(Get-ChildItem -LiteralPath $h -ErrorAction Stop) } catch { $keys = @() }
50
+ foreach ($k in $keys) {
51
+ $props = $null
52
+ try { $props = Get-ItemProperty -LiteralPath $k.PSPath -ErrorAction Stop } catch { continue }
53
+ if (-not $props.DisplayName) { continue }
54
+ $rows += [pscustomobject]@{
55
+ DisplayName = [string]$props.DisplayName; DisplayVersion = [string]$props.DisplayVersion
56
+ Publisher = [string]$props.Publisher; InstallDate = [string]$props.InstallDate
57
+ EstimatedSizeKb = [long]0 + $props.EstimatedSize; InstallLocation = [string]$props.InstallLocation
58
+ UninstallString = [string]$props.UninstallString; SystemComponent = [int]0 + $props.SystemComponent
59
+ }
60
+ }
61
+ }
62
+ return $rows
63
+ }
64
+
65
+ function Get-InstalledProgramIndex {
66
+ <# .SYNOPSIS Everything that says "a program owns this name": uninstall entries, program folders, Store
67
+ package names and running processes. RegistryCount drives the fail-closed gate in section 23. #>
68
+ $P = $Script:P
69
+ $set = New-Object System.Collections.Generic.HashSet[string]([StringComparer]::Ordinal)
70
+ $rows = @(Get-InstalledProgramRows)
71
+ foreach ($r in $rows) {
72
+ Add-MatchTokens -Set $set -Text $r.DisplayName
73
+ Add-MatchTokens -Set $set -Text $r.Publisher
74
+ if ($r.InstallLocation) { Add-MatchTokens -Set $set -Text (Split-Path -Leaf ($r.InstallLocation.TrimEnd('\'))) }
75
+ }
76
+ foreach ($dir in @($P.PF, $P.PF86, (Join-Path $P.L 'Programs'), (Join-Path $P.L 'Microsoft\WindowsApps'), (Join-Path $P.PD 'Microsoft\Windows\Start Menu\Programs'))) {
77
+ if (-not $dir -or -not (Test-DirPresent $dir)) { continue }
78
+ foreach ($e in (Get-ChildEntries $dir)) { Add-MatchTokens -Set $set -Text (($e.Name -split '_')[0]) }
79
+ }
80
+ foreach ($e in (Get-ChildEntries (Join-Path $P.L 'Packages'))) { Add-MatchTokens -Set $set -Text (($e.Name -split '_')[0]) }
81
+ try { foreach ($proc in (Get-Process -ErrorAction SilentlyContinue)) { Add-MatchTokens -Set $set -Text $proc.ProcessName } } catch { $null = $_ }
82
+ return [pscustomobject]@{ Tokens = $set; RegistryCount = $rows.Count; Rows = $rows }
83
+ }
84
+
85
+ function Get-OrphanExclusions {
86
+ <# .SYNOPSIS Folder names section 23 never offers: the fixed list plus, derived at run time, the first
87
+ segment under AppData of every target the tool already declares - so a vendor folder we clean can never
88
+ also be offered for deletion, and the list cannot drift as sections are added. #>
89
+ $P = $Script:P
90
+ $set = New-Object System.Collections.Generic.HashSet[string]([StringComparer]::OrdinalIgnoreCase)
91
+ foreach ($n in $Script:WS_ORPHAN_EXCLUDE) { $null = $set.Add($n) }
92
+ $prefixes = @("$($P.A)\", "$($P.L)\")
93
+ foreach ($t in (Get-AllTargets)) {
94
+ $path = [string]$t.Path
95
+ foreach ($pre in $prefixes) {
96
+ if ($path.StartsWith($pre, [StringComparison]::OrdinalIgnoreCase)) {
97
+ $seg = ($path.Substring($pre.Length) -split '\\')[0]
98
+ if ($seg) { $null = $set.Add($seg) }
99
+ }
100
+ }
101
+ }
102
+ # ,$set - a bare `return $set` UNROLLS the HashSet into an Object[], whose .Contains is case-SENSITIVE,
103
+ # so a folder named "slack" would slip past the "Slack" entry. The comma keeps the set a set.
104
+ return , $set
105
+ }
106
+
107
+ function Test-OrphanFolderName {
108
+ <# .SYNOPSIS True when NOTHING installed claims this folder name. Deliberately generous in the other
109
+ direction: a token that merely starts with the name (or the reverse) counts as a claim. #>
110
+ param([string] $Name, [pscustomobject] $Index)
111
+ $n = ConvertTo-MatchToken $Name
112
+ if ($n.Length -lt 3) { return $false }
113
+ if ($Index.Tokens.Contains($n)) { return $false }
114
+ foreach ($t in $Index.Tokens) {
115
+ if ($t.Length -lt 4) { continue }
116
+ if ($t.StartsWith($n) -or $n.StartsWith($t)) { return $false }
117
+ }
118
+ return $true
119
+ }
120
+
121
+ function Find-OrphanedAppData {
122
+ <# .SYNOPSIS Top-level AppData folders idle N+ days that no installed program claims. Fails closed.
123
+ -Index is for the self-test: it proves the fail-closed gate without touching the real registry. #>
124
+ param([int] $Days, [pscustomobject] $Index = $null)
125
+ $P = $Script:P
126
+ $index = $Index
127
+ if ($null -eq $index) { $index = Get-InstalledProgramIndex }
128
+ if ($index.RegistryCount -eq 0) {
129
+ Write-Warn 'no uninstall entries could be read: refusing to call anything orphaned (fail closed).'
130
+ return @()
131
+ }
132
+ Write-Note "cross-checked against $($index.RegistryCount) installed-program entries"
133
+ $exclude = Get-OrphanExclusions
134
+ $out = @()
135
+ foreach ($root in @($P.A, $P.L)) {
136
+ if (-not (Test-DirPresent $root)) { continue }
137
+ foreach ($e in (Get-ChildEntries $root)) {
138
+ if (-not ($e -is [IO.DirectoryInfo]) -or (Test-ReparsePoint $e)) { continue }
139
+ if ($exclude.Contains($e.Name)) { continue }
140
+ $full = Remove-LongPrefix $e.FullName
141
+ if (Get-ProtectionReason $full) { continue }
142
+ if (-not (Test-OrphanFolderName -Name $e.Name -Index $index)) { continue }
143
+ $idle = Get-IdleDays $full
144
+ if ($idle -lt $Days) { continue }
145
+ $out += [pscustomobject]@{ Name = $e.Name; Path = $full; Root = (Get-FullPath $root); Idle = $idle; Bytes = [long](Get-DirectoryBytes $full) }
146
+ }
147
+ }
148
+ return @($out | Sort-Object Bytes -Descending | Select-Object -First 200)
149
+ }
150
+
151
+ function Get-Targets23 {
152
+ <# .SYNOPSIS Informational rows: the candidates are found at run time, never declared as fixed paths. #>
153
+ $P = $Script:P
154
+ $t = @()
155
+ foreach ($r in @($P.A, $P.L)) {
156
+ if (Test-DirPresent $r) { $t += (New-Target 23 'AppData root scanned for orphans' (Get-FullPath $r) -Kind cmd -Note 'top-level folders only; you pick what goes, and it lands in the Recycle Bin') }
157
+ }
158
+ return $t
159
+ }
160
+
161
+ function Invoke-Section23 {
162
+ $ws = $Script:WS
163
+ Write-SectionIntro @(
164
+ 'Uninstalling a program usually leaves its AppData folder behind. This lists top-level folders under',
165
+ "Roaming and Local that no installed program, Store package or running process claims and that nothing",
166
+ "has touched for $($ws.Days)+ days. You pick what goes; it lands in the Recycle Bin."
167
+ )
168
+ $rows = @(Find-OrphanedAppData -Days $ws.Days)
169
+ if ($rows.Count -eq 0) { Write-Info 'no orphaned application data found'; return }
170
+ Write-UiLine (" {0,3} {1,10} {2,6} {3}" -f '#', 'SIZE', 'IDLE', 'FOLDER') 'White'
171
+ $i = 1; $total = [long]0
172
+ foreach ($r in $rows) {
173
+ Write-UiLine (" {0,3} {1,10} {2,5}d {3}" -f $i, (Format-Bytes $r.Bytes), $r.Idle, $r.Path) 'Gray'
174
+ Add-JsonCandidate -Section 23 -Index $i -Path $r.Path -Bytes $r.Bytes -IdleDays $r.Idle
175
+ $total += $r.Bytes; $i++
176
+ }
177
+ Write-Info ('total: ' + (Format-Bytes $total))
178
+ Write-Note 'a folder here can still hold settings or a licence for a program you plan to reinstall - read the list.'
179
+ $dest = 'the Recycle Bin'
180
+ if ($ws.Permanent) { $dest = 'PERMANENT deletion (--permanent)' }
181
+ Write-Note "selected folders go to $dest"
182
+ $picks = @(Read-MultiSelect -Total $rows.Count -NoAutoYes -Candidates @($rows | ForEach-Object { $_.Path }))
183
+ if ($picks.Count -eq 0) { Write-Info 'nothing selected'; return }
184
+ if (-not $ws.DryRun) {
185
+ # Orphaned app data never auto-confirms: --yes does not apply. An explicit --select / --select-file does.
186
+ if (-not (Confirm-Ui -Prompt "Move $($picks.Count) folder(s) to $dest ?" -Default 'n' -NoAutoYes -ScriptedOk)) { Write-Info 'skipped'; return }
187
+ }
188
+ foreach ($k in $picks) { $null = Send-ToRecycleBin -Path $rows[$k - 1].Path -Within $rows[$k - 1].Root }
189
+ }