windowsweep 1.3.2 → 1.4.1
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.
- package/AI-INTEGRATION-GUIDE.md +8 -8
- package/CHANGELOG.md +90 -0
- package/README.md +31 -30
- package/VERSION +1 -1
- package/lib/actions.ps1 +13 -0
- package/lib/config.ps1 +4 -0
- package/lib/constants.ps1 +12 -8
- package/lib/external.ps1 +13 -0
- package/lib/fs.ps1 +116 -14
- package/lib/safety.ps1 +48 -23
- package/lib/ui.ps1 +20 -2
- package/modules/android_avd.ps1 +187 -36
- package/modules/browser_ai.ps1 +68 -0
- package/modules/browsers.ps1 +101 -33
- package/modules/data_scanner.ps1 +158 -0
- package/modules/globals_audit.ps1 +5 -3
- package/modules/installed_programs.ps1 +69 -2
- package/modules/leftovers.ps1 +388 -0
- package/modules/orphaned_appdata.ps1 +47 -3
- package/modules/personal.ps1 +7 -37
- package/modules/projects.ps1 +2 -1
- package/modules/runner.ps1 +29 -5
- package/modules/self_test_1_4.ps1 +215 -0
- package/modules/self_test_1_4b.ps1 +418 -0
- package/modules/self_test_extra.ps1 +9 -0
- package/modules/system_admin.ps1 +17 -3
- package/modules/tool_releases.ps1 +197 -0
- package/modules/uninstall.ps1 +232 -0
- package/modules/windows_user.ps1 +46 -2
- package/package.json +2 -2
- package/windowsweep.ps1 +19 -1
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
# leftovers.ps1 - section 27: folders uninstalled programs left in Program Files, Common Files and ProgramData
|
|
2
|
+
# (D78), and the finder section 26 runs right after an uninstall. Section 23 owns the top level of AppData.
|
|
3
|
+
# A folder is offered only when no installed program's name claims it, nothing registered still points into it
|
|
4
|
+
# (an uninstall entry, a service or driver, an environment variable or PATH entry, a Run entry, a shortcut, a
|
|
5
|
+
# scheduled task or a running process) and - in section 27 - it has been idle --days. An unreadable registry
|
|
6
|
+
# offers nothing (fail closed). Picked folders go to the Recycle Bin; one inside a protected folder is only
|
|
7
|
+
# reported. Seen on the P52 (2026-11-02): AVAST Software and Malwarebytes Anti-Exploit folders years after
|
|
8
|
+
# their uninstall. ASCII only.
|
|
9
|
+
|
|
10
|
+
# Never offered: Windows, runtime, hardware-vendor and shared folders, whatever claims them or not.
|
|
11
|
+
$Script:WS_LEFTOVER_NEVER = @(
|
|
12
|
+
'Common Files', 'Internet Explorer', 'Microsoft', 'Microsoft.NET', 'Microsoft Office', 'Microsoft OneDrive', 'Microsoft SDKs',
|
|
13
|
+
'Microsoft SQL Server', 'Microsoft Update Health Tools', 'Microsoft Visual Studio', 'Microsoft Analysis Services', 'MSBuild',
|
|
14
|
+
'dotnet', 'Reference Assemblies', 'Uninstall Information', 'Windows Defender', 'Windows Defender Advanced Threat Protection',
|
|
15
|
+
'Windows Kits', 'Windows Mail', 'Windows Media Player', 'Windows Multimedia Platform', 'Windows NT', 'Windows Photo Viewer',
|
|
16
|
+
'Windows Portable Devices', 'Windows Security', 'Windows Sidebar', 'WindowsApps', 'WindowsPowerShell', 'ModifiableWindowsApps',
|
|
17
|
+
'PowerShell', 'Windows', 'WindowsHolographicDevices', 'USOPrivate', 'USOShared', 'SoftwareDistribution', 'ssh', 'Packages',
|
|
18
|
+
'Package Cache', 'regid.1991-06.com.microsoft', 'Comms', 'Application Data', 'Desktop', 'Documents', 'Start Menu',
|
|
19
|
+
'Templates', 'Favorites', 'DeviceSync', 'Temp', 'Tmp', 'IIS', 'IIS Express', 'Intel', 'NVIDIA', 'NVIDIA Corporation', 'AMD',
|
|
20
|
+
# Shared Microsoft components and install caches, and a fingerprint driver's data - offered on the P52 (2026-11-03)
|
|
21
|
+
'MSECache', 'MSOCache', 'DESIGNER', 'Microsoft Shared', 'System', 'Services', 'SpeechEngines', 'InstallShield',
|
|
22
|
+
'InstallShield Installation Information', 'Windows Live', 'Validity',
|
|
23
|
+
'ATI Technologies', 'Realtek', 'Synaptics', 'Lenovo', 'Dell', 'HP', 'Hewlett-Packard', 'Thunderbolt', 'Conexant', 'Dolby',
|
|
24
|
+
'Waves', 'Qualcomm', 'Broadcom', 'Killer Networking', 'Rivet Networks', 'Elantech', 'Alps', 'Bonjour', 'Apple', 'Google',
|
|
25
|
+
'Mozilla', 'Oracle', 'Java', 'Git', 'nodejs', 'Python', 'WSL', 'windowsweep', 'com.aoneahsan.windowsweep'
|
|
26
|
+
)
|
|
27
|
+
# Words too common to tie a folder to one program: never used as a program's key name.
|
|
28
|
+
$Script:WS_LEFTOVER_STOPWORDS = @('corporation', 'corp', 'software', 'systems', 'system', 'technologies', 'technology', 'limited',
|
|
29
|
+
'company', 'group', 'studio', 'studios', 'microsoft', 'windows', 'google', 'apps', 'tools', 'tool', 'program', 'programs', 'data',
|
|
30
|
+
'update', 'updater', 'setup', 'desktop', 'free', 'easy', 'best', 'simple', 'smart', 'super', 'quick', 'total', 'advanced',
|
|
31
|
+
'auto', 'mini', 'open', 'global', 'universal', 'portable', 'online', 'digital', 'power', 'master', 'magic', 'ultimate',
|
|
32
|
+
'express', 'visual', 'version', 'edition', 'professional', 'enterprise', 'community', 'client', 'server', 'service',
|
|
33
|
+
'services', 'helper', 'player', 'manager', 'runtime', 'driver', 'drivers', 'support', 'common', 'files', 'shared')
|
|
34
|
+
|
|
35
|
+
function Get-CommandPaths {
|
|
36
|
+
<# .SYNOPSIS The absolute paths a registered command names - its program and any absolute path among its
|
|
37
|
+
arguments - with environment variables expanded and the kernel forms (\SystemRoot\, \??\, system32\) resolved. #>
|
|
38
|
+
param([string] $Text)
|
|
39
|
+
$t = [Environment]::ExpandEnvironmentVariables(([string]$Text).Trim())
|
|
40
|
+
if (-not $t) { return @() }
|
|
41
|
+
$t = $t -replace '^\\\?\?\\', ''
|
|
42
|
+
if ($t -match '^\\SystemRoot\\(.*)$') { $t = Join-Path $Script:P.SR $Matches[1] }
|
|
43
|
+
elseif ($t -match '^(system32|syswow64)\\') { $t = Join-Path $Script:P.SR $t }
|
|
44
|
+
$c = Split-CommandLine $t
|
|
45
|
+
$out = @($c.FilePath)
|
|
46
|
+
foreach ($m in [regex]::Matches([string]$c.Arguments, '"([A-Za-z]:\\[^"]+)"|(?<![^\s,=])([A-Za-z]:\\[^\s",]+)')) {
|
|
47
|
+
$v = $m.Groups[1].Value
|
|
48
|
+
if (-not $v) { $v = $m.Groups[2].Value }
|
|
49
|
+
$out += $v
|
|
50
|
+
}
|
|
51
|
+
return @($out | Where-Object { $_ -match '^[A-Za-z]:\\' })
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function Get-ShortcutTargets {
|
|
55
|
+
<# .SYNOPSIS Targets of the Start Menu, desktop and pinned-taskbar shortcuts, for this user and all users. #>
|
|
56
|
+
$dirs = @([Environment]::GetFolderPath('StartMenu'), [Environment]::GetFolderPath('CommonStartMenu'),
|
|
57
|
+
[Environment]::GetFolderPath('Desktop'), [Environment]::GetFolderPath('CommonDesktopDirectory'),
|
|
58
|
+
(Join-Path $Script:P.A 'Microsoft\Internet Explorer\Quick Launch'))
|
|
59
|
+
$out = New-Object System.Collections.Generic.List[string]
|
|
60
|
+
$shell = $null
|
|
61
|
+
try { $shell = New-Object -ComObject WScript.Shell } catch { return @() }
|
|
62
|
+
try {
|
|
63
|
+
foreach ($d in $dirs) {
|
|
64
|
+
if (-not $d -or -not (Test-DirPresent $d)) { continue }
|
|
65
|
+
foreach ($f in @(Get-ChildItem -LiteralPath $d -Recurse -Filter '*.lnk' -File -ErrorAction SilentlyContinue | Select-Object -First 3000)) {
|
|
66
|
+
try { $target = [string]$shell.CreateShortcut($f.FullName).TargetPath; if ($target) { $out.Add($target) } } catch { $null = $_ }
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
} finally { try { $null = [Runtime.InteropServices.Marshal]::ReleaseComObject($shell) } catch { $null = $_ } }
|
|
70
|
+
return $out.ToArray()
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function Get-ComServerPaths {
|
|
74
|
+
<# .SYNOPSIS Files registered COM servers and type libraries load (machine 64- and 32-bit, and this user). Office
|
|
75
|
+
2007's add-in designer in Common Files\DESIGNER is registered only this way (P52, 2026-11-03). #>
|
|
76
|
+
$out = New-Object System.Collections.Generic.HashSet[string]([StringComparer]::OrdinalIgnoreCase)
|
|
77
|
+
foreach ($v in @(@('LocalMachine', 'Registry64'), @('LocalMachine', 'Registry32'), @('CurrentUser', 'Default'))) {
|
|
78
|
+
$base = $null
|
|
79
|
+
try { $base = [Microsoft.Win32.RegistryKey]::OpenBaseKey([Microsoft.Win32.RegistryHive]$v[0], [Microsoft.Win32.RegistryView]$v[1]) } catch { continue }
|
|
80
|
+
try {
|
|
81
|
+
$clsid = $base.OpenSubKey('SOFTWARE\Classes\CLSID')
|
|
82
|
+
if ($clsid) {
|
|
83
|
+
foreach ($name in $clsid.GetSubKeyNames()) {
|
|
84
|
+
foreach ($sub in 'InprocServer32', 'LocalServer32') {
|
|
85
|
+
$k = $null
|
|
86
|
+
try { $k = $clsid.OpenSubKey("$name\$sub") } catch { $k = $null }
|
|
87
|
+
if ($k) { $val = [string]$k.GetValue(''); $k.Close(); if ($val) { $null = $out.Add($val) } }
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
$clsid.Close()
|
|
91
|
+
}
|
|
92
|
+
$tl = $base.OpenSubKey('SOFTWARE\Classes\TypeLib')
|
|
93
|
+
if ($tl) {
|
|
94
|
+
foreach ($g in $tl.GetSubKeyNames()) {
|
|
95
|
+
$gk = $null
|
|
96
|
+
try { $gk = $tl.OpenSubKey($g) } catch { $gk = $null }
|
|
97
|
+
if (-not $gk) { continue }
|
|
98
|
+
foreach ($ver in $gk.GetSubKeyNames()) {
|
|
99
|
+
foreach ($lcid in @(try { $gk.OpenSubKey($ver).GetSubKeyNames() } catch { @() })) {
|
|
100
|
+
foreach ($plat in 'win32', 'win64') {
|
|
101
|
+
$pk = $null
|
|
102
|
+
try { $pk = $gk.OpenSubKey("$ver\$lcid\$plat") } catch { $pk = $null }
|
|
103
|
+
if ($pk) { $val = [string]$pk.GetValue(''); $pk.Close(); if ($val) { $null = $out.Add($val) } }
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
$gk.Close()
|
|
108
|
+
}
|
|
109
|
+
$tl.Close()
|
|
110
|
+
}
|
|
111
|
+
} catch { $null = $_ } finally { $base.Close() }
|
|
112
|
+
}
|
|
113
|
+
return @($out)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function Expand-ShortPath {
|
|
117
|
+
<# .SYNOPSIS C:\PROGRA~2\COMMON~1\x -> C:\Program Files (x86)\Common Files\x. COM registrations often store 8.3
|
|
118
|
+
names; a claim must compare with the long path a folder is listed by. Unchanged when it cannot resolve. #>
|
|
119
|
+
param([string] $Path)
|
|
120
|
+
if ($Path -notmatch '~') { return $Path }
|
|
121
|
+
$parts = $Path.Split('\')
|
|
122
|
+
$cur = $parts[0] + '\'
|
|
123
|
+
for ($i = 1; $i -lt $parts.Count; $i++) {
|
|
124
|
+
$seg = $parts[$i]
|
|
125
|
+
if (-not $seg) { continue }
|
|
126
|
+
if ($seg.Contains('~')) {
|
|
127
|
+
try { $hit = @(([IO.DirectoryInfo]$cur).GetFileSystemInfos($seg)); if ($hit.Count -eq 1) { $seg = $hit[0].Name } } catch { return $Path }
|
|
128
|
+
}
|
|
129
|
+
$cur = Join-Path $cur $seg
|
|
130
|
+
}
|
|
131
|
+
return $cur
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function Get-LeftoverClaims {
|
|
135
|
+
<# .SYNOPSIS What still claims a folder. Names: the installed-program index without the folders being judged.
|
|
136
|
+
Paths: existing files and folders an uninstall entry, a service or driver, an environment variable (PATH
|
|
137
|
+
included), a Run entry, a shortcut, a scheduled task or a running process names. #>
|
|
138
|
+
param([string[]] $IgnorePaths = @())
|
|
139
|
+
$index = Get-InstalledProgramIndex -IgnorePaths $IgnorePaths
|
|
140
|
+
$raw = New-Object System.Collections.Generic.List[string]
|
|
141
|
+
foreach ($r in $index.Rows) {
|
|
142
|
+
if ($r.InstallLocation) { $raw.Add(([string]$r.InstallLocation).Trim().Trim('"')) }
|
|
143
|
+
foreach ($s in @($r.UninstallString, $r.QuietUninstallString)) { if ($s) { foreach ($x in (Get-CommandPaths $s)) { $raw.Add($x) } } }
|
|
144
|
+
}
|
|
145
|
+
foreach ($cls in 'Win32_Service', 'Win32_SystemDriver') {
|
|
146
|
+
try { foreach ($s in @(Get-CimInstance $cls -Property PathName -ErrorAction Stop)) { foreach ($x in (Get-CommandPaths ([string]$s.PathName))) { $raw.Add($x) } } } catch { $null = $_ }
|
|
147
|
+
}
|
|
148
|
+
foreach ($scope in 'Machine', 'User') {
|
|
149
|
+
try {
|
|
150
|
+
$vars = [Environment]::GetEnvironmentVariables($scope)
|
|
151
|
+
foreach ($k in @($vars.Keys)) {
|
|
152
|
+
foreach ($part in ([string]$vars[$k] -split ';')) { $v = [Environment]::ExpandEnvironmentVariables($part.Trim().Trim('"')); if ($v -match '^[A-Za-z]:\\') { $raw.Add($v) } }
|
|
153
|
+
}
|
|
154
|
+
} catch { $null = $_ }
|
|
155
|
+
}
|
|
156
|
+
foreach ($key in @('HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Run', 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\RunOnce',
|
|
157
|
+
'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Run', 'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Run',
|
|
158
|
+
'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\RunOnce')) {
|
|
159
|
+
try {
|
|
160
|
+
$item = Get-ItemProperty -LiteralPath $key -ErrorAction Stop
|
|
161
|
+
foreach ($prop in $item.PSObject.Properties) { if ($prop.Name -notlike 'PS*') { foreach ($x in (Get-CommandPaths ([string]$prop.Value))) { $raw.Add($x) } } }
|
|
162
|
+
} catch { $null = $_ }
|
|
163
|
+
}
|
|
164
|
+
foreach ($x in (Get-ShortcutTargets)) { $raw.Add($x) }
|
|
165
|
+
foreach ($x in (Get-ComServerPaths)) { foreach ($y in (Get-CommandPaths $x)) { $raw.Add($y) } }
|
|
166
|
+
try {
|
|
167
|
+
foreach ($task in @(Get-ScheduledTask -ErrorAction Stop)) {
|
|
168
|
+
foreach ($a in @($task.Actions)) {
|
|
169
|
+
if ($a.Execute) { foreach ($x in (Get-CommandPaths ('"' + ([string]$a.Execute).Trim('"') + '" ' + [string]$a.Arguments))) { $raw.Add($x) } }
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
} catch { $null = $_ }
|
|
173
|
+
try {
|
|
174
|
+
foreach ($proc in @(Get-CimInstance Win32_Process -Property ExecutablePath, CommandLine -ErrorAction Stop)) {
|
|
175
|
+
if ($proc.ExecutablePath) { $raw.Add([string]$proc.ExecutablePath) }
|
|
176
|
+
if ($proc.CommandLine) { foreach ($x in (Get-CommandPaths ([string]$proc.CommandLine))) { $raw.Add($x) } }
|
|
177
|
+
}
|
|
178
|
+
} catch { $null = $_ }
|
|
179
|
+
$set = New-Object System.Collections.Generic.HashSet[string]([StringComparer]::OrdinalIgnoreCase)
|
|
180
|
+
foreach ($r in $raw) {
|
|
181
|
+
$f = ''
|
|
182
|
+
try { $f = Expand-ShortPath (Get-FullPath $r) } catch { $f = '' }
|
|
183
|
+
if ($f -and -not $set.Contains($f) -and (Test-Path -LiteralPath $f)) { $null = $set.Add($f) }
|
|
184
|
+
}
|
|
185
|
+
return [pscustomobject]@{ Tokens = $index.Tokens; RegistryCount = $index.RegistryCount; Rows = $index.Rows; Paths = @($set) }
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function Test-LeftoverClaimed {
|
|
189
|
+
<# .SYNOPSIS Why a folder still belongs to something, or $null when nothing claims it. #>
|
|
190
|
+
param([string] $Path, [pscustomobject] $Claims)
|
|
191
|
+
if (-not (Test-OrphanFolderName -Name (Split-Path -Leaf $Path) -Index $Claims)) { return 'its name belongs to an installed program' }
|
|
192
|
+
$prefix = $Path.TrimEnd('\') + '\'
|
|
193
|
+
foreach ($c in $Claims.Paths) {
|
|
194
|
+
if ($c.Equals($Path, [StringComparison]::OrdinalIgnoreCase) -or $c.StartsWith($prefix, [StringComparison]::OrdinalIgnoreCase)) { return "still in use: $c" }
|
|
195
|
+
}
|
|
196
|
+
return $null
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function Get-LeftoverRoots {
|
|
200
|
+
<# .SYNOPSIS Where uninstalled programs leave folders. -WithAppData adds Roaming and Local - for section 26's
|
|
201
|
+
pass right after an uninstall only; section 23 owns them otherwise. #>
|
|
202
|
+
param([switch] $WithAppData)
|
|
203
|
+
$P = $Script:P
|
|
204
|
+
$roots = @($P.PF, $P.PF86, (Join-Path $P.PF 'Common Files'), (Join-Path $P.PF86 'Common Files'), $P.PD, (Join-Path $P.L 'Programs'))
|
|
205
|
+
if ($WithAppData) { $roots += @($P.A, $P.L) }
|
|
206
|
+
$seen = New-Object System.Collections.Generic.HashSet[string]([StringComparer]::OrdinalIgnoreCase)
|
|
207
|
+
return @($roots | Where-Object { $_ -and (Test-DirPresent $_) -and $seen.Add($_) })
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function Get-ProgramKeyTokens {
|
|
211
|
+
<# .SYNOPSIS The names a program's leftover folders carry: the first word of its name and of its publisher, its
|
|
212
|
+
whole name and its install folder's name - 4+ characters, with a letter, never a common word. #>
|
|
213
|
+
param([pscustomobject] $Program)
|
|
214
|
+
$set = New-Object System.Collections.Generic.HashSet[string]([StringComparer]::Ordinal)
|
|
215
|
+
$texts = @()
|
|
216
|
+
foreach ($text in @($Program.DisplayName, $Program.Publisher)) {
|
|
217
|
+
$words = @(([string]$text) -split '[^A-Za-z0-9]+' | Where-Object { $_ })
|
|
218
|
+
if ($words.Count -gt 0) { $texts += $words[0] }
|
|
219
|
+
$texts += [string]$text
|
|
220
|
+
}
|
|
221
|
+
$loc = ([string]$Program.InstallLocation).Trim().Trim('"').TrimEnd('\')
|
|
222
|
+
if ($loc) { $texts += (Split-Path -Leaf $loc) }
|
|
223
|
+
foreach ($x in $texts) {
|
|
224
|
+
$t = ConvertTo-MatchToken $x
|
|
225
|
+
if ($t.Length -ge 4 -and $t -match '[a-z]' -and $Script:WS_LEFTOVER_STOPWORDS -notcontains $t) { $null = $set.Add($t) }
|
|
226
|
+
}
|
|
227
|
+
return , $set
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function Test-NameMatchesTokens {
|
|
231
|
+
<# .SYNOPSIS A folder named exactly like a key name or starting with one ("SteamLibrary" for "steam"); for a
|
|
232
|
+
reverse-DNS name, any segment after the first. #>
|
|
233
|
+
param([string] $Name, [System.Collections.Generic.HashSet[string]] $Tokens)
|
|
234
|
+
$names = @($Name)
|
|
235
|
+
if ($Name -match '^[A-Za-z0-9-]+(\.[A-Za-z0-9-]+){2,}$') { $names += @($Name.Split('.') | Select-Object -Skip 1) }
|
|
236
|
+
foreach ($nm in $names) {
|
|
237
|
+
$n = ConvertTo-MatchToken $nm
|
|
238
|
+
if ($n.Length -lt 4) { continue }
|
|
239
|
+
foreach ($t in $Tokens) { if ($n -eq $t -or $n.StartsWith($t)) { return $true } }
|
|
240
|
+
}
|
|
241
|
+
return $false
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function New-NeverSet {
|
|
245
|
+
$set = New-Object System.Collections.Generic.HashSet[string]([StringComparer]::OrdinalIgnoreCase)
|
|
246
|
+
foreach ($n in @($Script:WS_LEFTOVER_NEVER + $Script:WS_ORPHAN_EXCLUDE)) { $null = $set.Add($n) }
|
|
247
|
+
return , $set
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function Find-ProgramLeftovers {
|
|
251
|
+
<# .SYNOPSIS Section 26, after an uninstall: the removed programs' install folders, and folders in the leftover
|
|
252
|
+
roots and AppData named like them. Rows carry Reason = $null to offer, else why the folder stays. Fails
|
|
253
|
+
closed. -Roots and -Claims are for the self-test. #>
|
|
254
|
+
param([pscustomobject[]] $Programs, [string[]] $Roots = @(), [pscustomobject] $Claims = $null)
|
|
255
|
+
if ($Roots.Count -eq 0) { $Roots = @(Get-LeftoverRoots -WithAppData) }
|
|
256
|
+
$tokens = New-Object System.Collections.Generic.HashSet[string]([StringComparer]::Ordinal)
|
|
257
|
+
foreach ($prog in @($Programs)) { foreach ($t in (Get-ProgramKeyTokens $prog)) { $null = $tokens.Add($t) } }
|
|
258
|
+
$never = New-NeverSet
|
|
259
|
+
$cand = New-Object System.Collections.Generic.List[object]
|
|
260
|
+
$seen = New-Object System.Collections.Generic.HashSet[string]([StringComparer]::OrdinalIgnoreCase)
|
|
261
|
+
foreach ($prog in @($Programs)) {
|
|
262
|
+
$loc = ([string]$prog.InstallLocation).Trim().Trim('"').TrimEnd('\')
|
|
263
|
+
if (-not $loc -or -not (Test-DirPresent $loc)) { continue }
|
|
264
|
+
$full = Get-FullPath $loc
|
|
265
|
+
if ($seen.Add($full)) { $cand.Add([pscustomobject]@{ Path = $full; Root = (Split-Path -Parent $full); Program = $prog.DisplayName; Reason = $null; Idle = -1; Bytes = [long]0 }) }
|
|
266
|
+
}
|
|
267
|
+
foreach ($root in $Roots) {
|
|
268
|
+
if ($tokens.Count -eq 0 -or -not $root -or -not (Test-DirPresent $root)) { continue }
|
|
269
|
+
foreach ($e in (Get-ChildEntries $root)) {
|
|
270
|
+
if (-not ($e -is [IO.DirectoryInfo]) -or (Test-ReparsePoint $e) -or $never.Contains($e.Name)) { continue }
|
|
271
|
+
if (-not (Test-NameMatchesTokens -Name $e.Name -Tokens $tokens)) { continue }
|
|
272
|
+
$full = Remove-LongPrefix $e.FullName
|
|
273
|
+
if ($seen.Add($full)) { $cand.Add([pscustomobject]@{ Path = $full; Root = (Get-FullPath $root); Program = ''; Reason = $null; Idle = -1; Bytes = [long]0 }) }
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
if ($cand.Count -eq 0) { return @() }
|
|
277
|
+
if ($null -eq $Claims) { $Claims = Get-LeftoverClaims -IgnorePaths @($cand | ForEach-Object { $_.Path }) }
|
|
278
|
+
if ($Claims.RegistryCount -eq 0) { Write-Warn 'no uninstall entries could be read: no folder is offered (fail closed)'; return @() }
|
|
279
|
+
foreach ($c in $cand) {
|
|
280
|
+
$why = Get-ProtectionReason $c.Path
|
|
281
|
+
if (-not $why) { $why = Test-LeftoverClaimed -Path $c.Path -Claims $Claims }
|
|
282
|
+
$c.Reason = $why
|
|
283
|
+
# Sized only when offered: a registered install folder can be a protected root such as Program Files.
|
|
284
|
+
if (-not $why) { $c.Bytes = [long](Get-DirectoryBytes $c.Path) }
|
|
285
|
+
}
|
|
286
|
+
return $cand.ToArray() # @() around a List[object] throws "Argument types do not match" in PS 5.1
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function Find-MachineLeftovers {
|
|
290
|
+
<# .SYNOPSIS Section 27: top-level folders in the leftover roots that nothing claims, idle -Days or more. Rows
|
|
291
|
+
carry Reason = $null to offer, else why the folder is only reported. Fails closed. #>
|
|
292
|
+
param([int] $Days, [string[]] $Roots = @(), [pscustomobject] $Claims = $null)
|
|
293
|
+
if ($Roots.Count -eq 0) { $Roots = @(Get-LeftoverRoots) }
|
|
294
|
+
$never = New-NeverSet
|
|
295
|
+
$cand = New-Object System.Collections.Generic.List[object]
|
|
296
|
+
foreach ($root in $Roots) {
|
|
297
|
+
if (-not $root -or -not (Test-DirPresent $root)) { continue }
|
|
298
|
+
foreach ($e in (Get-ChildEntries $root)) {
|
|
299
|
+
if (-not ($e -is [IO.DirectoryInfo]) -or (Test-ReparsePoint $e) -or $never.Contains($e.Name)) { continue }
|
|
300
|
+
$cand.Add([pscustomobject]@{ Path = (Remove-LongPrefix $e.FullName); Root = (Get-FullPath $root); Program = ''; Reason = $null; Idle = 0; Bytes = [long]0 })
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
if ($cand.Count -eq 0) { return @() }
|
|
304
|
+
if ($null -eq $Claims) { $Claims = Get-LeftoverClaims -IgnorePaths @($cand | ForEach-Object { $_.Path }) }
|
|
305
|
+
if ($Claims.RegistryCount -eq 0) { Write-Warn 'no uninstall entries could be read: no folder is offered (fail closed)'; return @() }
|
|
306
|
+
Write-Note "cross-checked against $($Claims.RegistryCount) installed-program entries and $(@($Claims.Paths).Count) paths still in use"
|
|
307
|
+
$out = @()
|
|
308
|
+
foreach ($c in $cand) {
|
|
309
|
+
if (Test-LeftoverClaimed -Path $c.Path -Claims $Claims) { continue }
|
|
310
|
+
$c.Idle = Get-IdleDays $c.Path
|
|
311
|
+
if ($c.Idle -lt $Days) { continue }
|
|
312
|
+
$c.Reason = Get-ProtectionReason $c.Path
|
|
313
|
+
$c.Bytes = [long](Get-DirectoryBytes $c.Path)
|
|
314
|
+
$out += $c
|
|
315
|
+
}
|
|
316
|
+
return @($out | Sort-Object Bytes -Descending | Select-Object -First 200)
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function Invoke-LeftoverChoice {
|
|
320
|
+
<# .SYNOPSIS Report the rows that stay, list the rest, and let a person pick folders for the Recycle Bin.
|
|
321
|
+
-PathsOrConsoleOnly (section 26's second prompt): only --select-file paths or a console answer pick here - a
|
|
322
|
+
queued --select list belongs to a later prompt and is left for it. #>
|
|
323
|
+
param([object[]] $Rows, [int] $Section, [switch] $PathsOrConsoleOnly)
|
|
324
|
+
$ws = $Script:WS
|
|
325
|
+
foreach ($r in @($Rows | Where-Object { $_.Reason })) { Write-Note ("kept: {0} ({1})" -f $r.Path, $r.Reason) }
|
|
326
|
+
$offer = @($Rows | Where-Object { -not $_.Reason })
|
|
327
|
+
if ($offer.Count -eq 0) { Write-Info 'no leftover folder can be offered'; return }
|
|
328
|
+
Write-UiLine (" {0,3} {1,10} {2,6} {3}" -f '#', 'SIZE', 'IDLE', 'FOLDER') 'White'
|
|
329
|
+
$i = 1; $total = [long]0
|
|
330
|
+
foreach ($r in $offer) {
|
|
331
|
+
$idle = '-'
|
|
332
|
+
if ($r.Idle -ge 0) { $idle = "$($r.Idle)d" }
|
|
333
|
+
Write-UiLine (" {0,3} {1,10} {2,6} {3}" -f $i, (Format-Bytes $r.Bytes), $idle, $r.Path) 'Gray'
|
|
334
|
+
$label = ''
|
|
335
|
+
if ($Section -eq 26) { $label = 'leftover'; if ($r.Program) { $label = "leftover: $($r.Program)" } }
|
|
336
|
+
Add-JsonCandidate -Section $Section -Index $i -Path $r.Path -Bytes $r.Bytes -IdleDays $r.Idle -Label $label
|
|
337
|
+
$total += $r.Bytes; $i++
|
|
338
|
+
}
|
|
339
|
+
Write-Info ('total: ' + (Format-Bytes $total))
|
|
340
|
+
$dest = 'the Recycle Bin'
|
|
341
|
+
if ($ws.Permanent) { $dest = 'PERMANENT deletion (--permanent)' }
|
|
342
|
+
Write-Note "selected folders go to $dest"
|
|
343
|
+
$saved = $ws.SelectQueue
|
|
344
|
+
if ($PathsOrConsoleOnly) { $ws.SelectQueue = @() }
|
|
345
|
+
try { $picks = @(Read-MultiSelect -Total $offer.Count -NoAutoYes -Candidates @($offer | ForEach-Object { $_.Path })) } finally { if ($PathsOrConsoleOnly) { $ws.SelectQueue = $saved } }
|
|
346
|
+
if ($picks.Count -eq 0) { Write-Info 'nothing selected'; return }
|
|
347
|
+
if (-not $ws.DryRun) {
|
|
348
|
+
if (-not (Confirm-Ui -Prompt "Move $($picks.Count) folder(s) to $dest ?" -Default 'n' -NoAutoYes -ScriptedOk)) { Write-Info 'skipped'; return }
|
|
349
|
+
}
|
|
350
|
+
foreach ($k in $picks) { $null = Send-ToRecycleBin -Path $offer[$k - 1].Path -Within $offer[$k - 1].Root }
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function Get-DeadPathEntries {
|
|
354
|
+
<# .SYNOPSIS PATH entries and path-valued environment variables (machine and user) naming something that no
|
|
355
|
+
longer exists. Report only. #>
|
|
356
|
+
$out = @()
|
|
357
|
+
foreach ($scope in 'Machine', 'User') {
|
|
358
|
+
$vars = $null
|
|
359
|
+
try { $vars = [Environment]::GetEnvironmentVariables($scope) } catch { continue }
|
|
360
|
+
foreach ($k in @($vars.Keys | Sort-Object)) {
|
|
361
|
+
foreach ($part in @([string]$vars[$k] -split ';' | Where-Object { $_.Trim() })) {
|
|
362
|
+
$v = [Environment]::ExpandEnvironmentVariables($part.Trim().Trim('"'))
|
|
363
|
+
if ($v -match '^[A-Za-z]:\\' -and -not (Test-Path -LiteralPath $v)) { $out += [pscustomobject]@{ Scope = $scope; Name = [string]$k; Value = $v } }
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
return $out
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function Get-Targets27 {
|
|
371
|
+
$P = $Script:P
|
|
372
|
+
return @((New-Target 27 'Leftover program folders' "$($P.PF) | $($P.PF86) | Common Files | $($P.PD)" -Kind cmd -Note 'you pick; idle --days; nothing installed, registered or running uses them'))
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function Invoke-Section27 {
|
|
376
|
+
$ws = $Script:WS
|
|
377
|
+
Write-SectionIntro @(
|
|
378
|
+
'Uninstallers often leave folders in Program Files, Common Files and ProgramData. This lists the top-level',
|
|
379
|
+
'folders there that no installed program, service, driver, PATH entry, shortcut, task or running process uses',
|
|
380
|
+
"and that nothing has touched for $($ws.Days)+ days. You pick what goes; it lands in the Recycle Bin."
|
|
381
|
+
)
|
|
382
|
+
Invoke-LeftoverChoice -Rows @(Find-MachineLeftovers -Days $ws.Days) -Section 27
|
|
383
|
+
$dead = @(Get-DeadPathEntries)
|
|
384
|
+
if ($dead.Count -eq 0) { return }
|
|
385
|
+
Write-Section 'Environment entries naming something that no longer exists (report only)'
|
|
386
|
+
foreach ($d in $dead) { Write-UiLine (" {0,-7} {1,-24} {2}" -f $d.Scope, $d.Name, $d.Value) 'Gray' }
|
|
387
|
+
Write-Note 'remove them in System Properties > Environment Variables once the tool they belonged to is gone'
|
|
388
|
+
}
|
|
@@ -26,14 +26,36 @@ function ConvertTo-MatchToken {
|
|
|
26
26
|
}
|
|
27
27
|
|
|
28
28
|
function Add-MatchTokens {
|
|
29
|
-
<# .SYNOPSIS Add a display string to the token set: the whole thing,
|
|
29
|
+
<# .SYNOPSIS Add a display string to the token set: the whole thing, each word of 4+ characters, and - for a
|
|
30
|
+
name of three or more words - its initials ("Internet Download Manager" claims a folder named "IDM"). #>
|
|
30
31
|
param([System.Collections.Generic.HashSet[string]] $Set, [string] $Text)
|
|
31
32
|
$whole = ConvertTo-MatchToken $Text
|
|
32
33
|
if ($whole.Length -ge 3) { $null = $Set.Add($whole) }
|
|
33
|
-
|
|
34
|
+
$words = @($Text -split '[^A-Za-z0-9]+' | Where-Object { $_ })
|
|
35
|
+
foreach ($w in $words) {
|
|
34
36
|
$t = ConvertTo-MatchToken $w
|
|
35
37
|
if ($t.Length -ge 4) { $null = $Set.Add($t) }
|
|
36
38
|
}
|
|
39
|
+
if ($words.Count -ge 3) {
|
|
40
|
+
$initials = (($words | ForEach-Object { $_.Substring(0, 1) }) -join '').ToLowerInvariant()
|
|
41
|
+
if ($initials.Length -ge 3) { $null = $Set.Add($initials) }
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function Add-CommandLineTokens {
|
|
46
|
+
<# .SYNOPSIS Claim every path segment of a running command line: a tool started as "node ...\superpowers-chrome\..."
|
|
47
|
+
runs under a process named node, so its own name only ever appears here. #>
|
|
48
|
+
param([System.Collections.Generic.HashSet[string]] $Set, [string] $CommandLine)
|
|
49
|
+
if ([string]::IsNullOrWhiteSpace($CommandLine)) { return }
|
|
50
|
+
# Only the folders of PATH arguments count. A bare word claims nothing: a shell whose command line merely
|
|
51
|
+
# mentioned "vega-desktop-updater" made that orphan look claimed on the P52 (2026-11-02).
|
|
52
|
+
foreach ($arg in ($CommandLine -split '[\s"]+')) {
|
|
53
|
+
$a = $arg.Trim("'")
|
|
54
|
+
if ($a -notmatch '^[A-Za-z]:[\\/]' -and $a -notmatch '^[\\/]{2}' -and @($a -split '[\\/]').Count -lt 3) { continue }
|
|
55
|
+
foreach ($seg in ($a -split '[\\/=]+')) {
|
|
56
|
+
if ($seg.Length -ge 4 -and -not $seg.StartsWith('-')) { Add-MatchTokens -Set $Set -Text $seg }
|
|
57
|
+
}
|
|
58
|
+
}
|
|
37
59
|
}
|
|
38
60
|
|
|
39
61
|
function Get-InstalledProgramRows {
|
|
@@ -56,6 +78,10 @@ function Get-InstalledProgramRows {
|
|
|
56
78
|
Publisher = [string]$props.Publisher; InstallDate = [string]$props.InstallDate
|
|
57
79
|
EstimatedSizeKb = [long]0 + $props.EstimatedSize; InstallLocation = [string]$props.InstallLocation
|
|
58
80
|
UninstallString = [string]$props.UninstallString; SystemComponent = [int]0 + $props.SystemComponent
|
|
81
|
+
# Section 26 (P9) reads these; sections 23 and 24 ignore them.
|
|
82
|
+
KeyPath = [string]$k.Name; KeyName = [string]$k.PSChildName; Machine = ([string]$k.Name).StartsWith('HKEY_LOCAL_MACHINE', [StringComparison]::OrdinalIgnoreCase)
|
|
83
|
+
QuietUninstallString = [string]$props.QuietUninstallString; WindowsInstaller = [int]0 + $props.WindowsInstaller
|
|
84
|
+
ParentKeyName = [string]$props.ParentKeyName; ReleaseType = [string]$props.ReleaseType
|
|
59
85
|
}
|
|
60
86
|
}
|
|
61
87
|
}
|
|
@@ -65,7 +91,12 @@ function Get-InstalledProgramRows {
|
|
|
65
91
|
function Get-InstalledProgramIndex {
|
|
66
92
|
<# .SYNOPSIS Everything that says "a program owns this name": uninstall entries, program folders, Store
|
|
67
93
|
package names and running processes. RegistryCount drives the fail-closed gate in section 23. #>
|
|
94
|
+
# -IgnorePaths (P9, sections 26 and 27): folders being judged as leftovers, whose own names must not
|
|
95
|
+
# count as a claim on themselves.
|
|
96
|
+
param([string[]] $IgnorePaths = @())
|
|
68
97
|
$P = $Script:P
|
|
98
|
+
$ignore = New-Object System.Collections.Generic.HashSet[string]([StringComparer]::OrdinalIgnoreCase)
|
|
99
|
+
foreach ($x in $IgnorePaths) { if ($x) { $null = $ignore.Add((Get-FullPath $x)) } }
|
|
69
100
|
$set = New-Object System.Collections.Generic.HashSet[string]([StringComparer]::Ordinal)
|
|
70
101
|
$rows = @(Get-InstalledProgramRows)
|
|
71
102
|
foreach ($r in $rows) {
|
|
@@ -75,10 +106,14 @@ function Get-InstalledProgramIndex {
|
|
|
75
106
|
}
|
|
76
107
|
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
108
|
if (-not $dir -or -not (Test-DirPresent $dir)) { continue }
|
|
78
|
-
foreach ($e in (Get-ChildEntries $dir)) {
|
|
109
|
+
foreach ($e in (Get-ChildEntries $dir)) {
|
|
110
|
+
if ($ignore.Count -gt 0 -and $ignore.Contains((Remove-LongPrefix $e.FullName))) { continue }
|
|
111
|
+
Add-MatchTokens -Set $set -Text (($e.Name -split '_')[0])
|
|
112
|
+
}
|
|
79
113
|
}
|
|
80
114
|
foreach ($e in (Get-ChildEntries (Join-Path $P.L 'Packages'))) { Add-MatchTokens -Set $set -Text (($e.Name -split '_')[0]) }
|
|
81
115
|
try { foreach ($proc in (Get-Process -ErrorAction SilentlyContinue)) { Add-MatchTokens -Set $set -Text $proc.ProcessName } } catch { $null = $_ }
|
|
116
|
+
try { foreach ($proc in @(Get-CimInstance Win32_Process -ErrorAction Stop)) { Add-CommandLineTokens -Set $set -CommandLine ([string]$proc.CommandLine) } } catch { $null = $_ }
|
|
82
117
|
return [pscustomobject]@{ Tokens = $set; RegistryCount = $rows.Count; Rows = $rows }
|
|
83
118
|
}
|
|
84
119
|
|
|
@@ -114,6 +149,15 @@ function Test-OrphanFolderName {
|
|
|
114
149
|
foreach ($t in $Index.Tokens) {
|
|
115
150
|
if ($t.Length -lt 4) { continue }
|
|
116
151
|
if ($t.StartsWith($n) -or $n.StartsWith($t)) { return $false }
|
|
152
|
+
# A shared vendor stem claims it too: "DBeaverData" belongs to the "DBeaverCorp.DBeaverCE" Store app.
|
|
153
|
+
if ($n.Length -ge 6 -and $t.Length -ge 6 -and $n.Substring(0, 6) -eq $t.Substring(0, 6)) { return $false }
|
|
154
|
+
}
|
|
155
|
+
# A reverse-DNS app id ("com.vendor.app", as Tauri and Electron apps name their data folder) is claimed when
|
|
156
|
+
# any segment after the first is: "com.aoneahsan.windowsweep" belongs to the installed "windowsweep".
|
|
157
|
+
if ($Name -match '^[A-Za-z0-9-]+(\.[A-Za-z0-9-]+){2,}$') {
|
|
158
|
+
foreach ($seg in @($Name.Split('.') | Select-Object -Skip 1)) {
|
|
159
|
+
if ((ConvertTo-MatchToken $seg).Length -ge 4 -and -not (Test-OrphanFolderName -Name $seg -Index $Index)) { return $false }
|
|
160
|
+
}
|
|
117
161
|
}
|
|
118
162
|
return $true
|
|
119
163
|
}
|
package/modules/personal.ps1
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
# personal.ps1 -
|
|
1
|
+
# personal.ps1 - section 18 (partial downloads in Downloads) and the file walk section 19 shares (data_scanner.ps1).
|
|
2
|
+
# Interactive only; the Recycle Bin is the destination.
|
|
2
3
|
|
|
3
4
|
$Script:WS_PARTIAL_PATTERNS = @('*.crdownload', '*.part', '*.partial', '*.fdmdownload', '*.opdownload', '*.!ut', '*.aria2', '*.download', '*.bc!', '*.tmp')
|
|
4
5
|
|
|
@@ -15,15 +16,9 @@ function Get-Targets18 {
|
|
|
15
16
|
return $t
|
|
16
17
|
}
|
|
17
18
|
|
|
18
|
-
function Get-Targets19 {
|
|
19
|
-
$t = @()
|
|
20
|
-
foreach ($r in (Get-PersonalRoots)) { $t += (New-Target 19 "Files >= $($Script:WS.LargeFileMb) MB idle $($Script:WS.Days)+ days" $r -Kind cmd -Note 'moved to the Recycle Bin after you pick them') }
|
|
21
|
-
return $t
|
|
22
|
-
}
|
|
23
|
-
|
|
24
19
|
function Get-FilesUnder {
|
|
25
20
|
<# .SYNOPSIS Files under Root up to MaxDepth, never following links, never entering protected folders. #>
|
|
26
|
-
param([string] $Root, [int] $MaxDepth = 4)
|
|
21
|
+
param([string] $Root, [int] $MaxDepth = 4, [int] $MaxFiles = 0)
|
|
27
22
|
$out = New-Object System.Collections.Generic.List[object]
|
|
28
23
|
$stack = New-Object System.Collections.Generic.Stack[object]
|
|
29
24
|
$stack.Push(@($Root, 0))
|
|
@@ -33,6 +28,7 @@ function Get-FilesUnder {
|
|
|
33
28
|
if (Test-ReparsePoint $e) { continue }
|
|
34
29
|
if ($e -is [IO.DirectoryInfo]) { if ([int]$item[1] -lt $MaxDepth) { $stack.Push(@((Remove-LongPrefix $e.FullName), [int]$item[1] + 1)) }; continue }
|
|
35
30
|
$out.Add($e)
|
|
31
|
+
if ($MaxFiles -gt 0 -and $out.Count -ge $MaxFiles) { return $out }
|
|
36
32
|
}
|
|
37
33
|
}
|
|
38
34
|
return $out
|
|
@@ -41,30 +37,16 @@ function Get-FilesUnder {
|
|
|
41
37
|
function Get-PartialDownloads {
|
|
42
38
|
$list = @()
|
|
43
39
|
foreach ($root in (Get-PersonalRoots)) {
|
|
40
|
+
$ignore = Test-LastAccessLive -Root $root
|
|
44
41
|
foreach ($f in (Get-FilesUnder -Root $root -MaxDepth 4)) {
|
|
45
42
|
foreach ($pat in $Script:WS_PARTIAL_PATTERNS) {
|
|
46
|
-
if ($f.Name -like $pat) { $list += [pscustomobject]@{ Path = (Remove-LongPrefix $f.FullName); Bytes = [long]$f.Length; Idle = [int][math]::Floor(([datetime]::UtcNow - (Get-NewestTimestampUtc $f)).TotalDays); Root = $root }; break }
|
|
43
|
+
if ($f.Name -like $pat) { $list += [pscustomobject]@{ Path = (Remove-LongPrefix $f.FullName); Bytes = [long]$f.Length; Idle = [int][math]::Floor(([datetime]::UtcNow - (Get-NewestTimestampUtc $f -IgnoreAccess:$ignore)).TotalDays); Root = $root }; break }
|
|
47
44
|
}
|
|
48
45
|
}
|
|
49
46
|
}
|
|
50
47
|
return @($list | Sort-Object Bytes -Descending)
|
|
51
48
|
}
|
|
52
49
|
|
|
53
|
-
function Get-LargeStaleFiles {
|
|
54
|
-
$ws = $Script:WS
|
|
55
|
-
$min = [long]$ws.LargeFileMb * 1MB
|
|
56
|
-
$list = @()
|
|
57
|
-
foreach ($root in (Get-PersonalRoots)) {
|
|
58
|
-
foreach ($f in (Get-FilesUnder -Root $root -MaxDepth 4)) {
|
|
59
|
-
if ([long]$f.Length -lt $min) { continue }
|
|
60
|
-
$idle = [int][math]::Floor(([datetime]::UtcNow - (Get-NewestTimestampUtc $f)).TotalDays)
|
|
61
|
-
if ($idle -lt $ws.Days) { continue }
|
|
62
|
-
$list += [pscustomobject]@{ Path = (Remove-LongPrefix $f.FullName); Bytes = [long]$f.Length; Idle = $idle; Root = $root }
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
return @($list | Sort-Object Bytes -Descending | Select-Object -First 100)
|
|
66
|
-
}
|
|
67
|
-
|
|
68
50
|
function Show-FileTable {
|
|
69
51
|
param([object[]] $Rows)
|
|
70
52
|
Write-UiLine (" {0,3} {1,10} {2,6} {3}" -f '#', 'SIZE', 'IDLE', 'FILE') 'White'
|
|
@@ -104,23 +86,11 @@ function Invoke-Section18 {
|
|
|
104
86
|
Invoke-PersonalPicker -Rows @(Get-PartialDownloads) -Prompt 'Move the selected partial downloads to the Recycle Bin' -Section 18
|
|
105
87
|
}
|
|
106
88
|
|
|
107
|
-
function Invoke-Section19 {
|
|
108
|
-
$ws = $Script:WS
|
|
109
|
-
Write-SectionIntro @(
|
|
110
|
-
"Files of $($ws.LargeFileMb) MB or more in Downloads that nothing has touched for $($ws.Days)+ days: old installers, ISOs,",
|
|
111
|
-
'archives you already extracted. You pick what goes; it lands in the Recycle Bin.'
|
|
112
|
-
)
|
|
113
|
-
if (@(Get-PersonalRoots).Count -eq 0) { Write-Info 'no Downloads folder found'; return }
|
|
114
|
-
Invoke-PersonalPicker -Rows @(Get-LargeStaleFiles) -Prompt 'Move the selected files to the Recycle Bin' -Section 19
|
|
115
|
-
}
|
|
116
|
-
|
|
117
89
|
function Show-PersonalScan {
|
|
118
90
|
<# .SYNOPSIS Read-only listing used by --scan. #>
|
|
119
91
|
Write-Box 'Personal files (read-only)' 'Sections 18 and 19 are interactive only - nothing here is deleted'
|
|
120
92
|
Write-Section 'Partial / orphan downloads'
|
|
121
93
|
$p = @(Get-PartialDownloads)
|
|
122
94
|
if ($p.Count -eq 0) { Write-Info 'none found' } else { Show-FileTable @($p | Select-Object -First 30) }
|
|
123
|
-
|
|
124
|
-
$l = @(Get-LargeStaleFiles)
|
|
125
|
-
if ($l.Count -eq 0) { Write-Info 'none found' } else { Show-FileTable @($l | Select-Object -First 30) }
|
|
95
|
+
Show-DataScan
|
|
126
96
|
}
|
package/modules/projects.ps1
CHANGED
|
@@ -63,6 +63,7 @@ function Get-ProjectActivityDays {
|
|
|
63
63
|
<# .SYNOPSIS Days since any SOURCE file in the project changed (artefact dirs and .git excluded). #>
|
|
64
64
|
param([string] $ProjectDir)
|
|
65
65
|
$newest = [datetime]::MinValue
|
|
66
|
+
$ignore = Test-LastAccessLive -Root $ProjectDir
|
|
66
67
|
$stack = New-Object System.Collections.Generic.Stack[string]
|
|
67
68
|
$stack.Push($ProjectDir)
|
|
68
69
|
$seen = 0
|
|
@@ -75,7 +76,7 @@ function Get-ProjectActivityDays {
|
|
|
75
76
|
if ($Script:WS_ARTEFACT_DIRS -contains $e.Name -or $Script:WS_NEVER_ENTER -contains $e.Name -or (Test-MarkedArtefactDir -Dir $dir -Name $e.Name)) { continue }
|
|
76
77
|
$stack.Push((Remove-LongPrefix $e.FullName)); continue
|
|
77
78
|
}
|
|
78
|
-
$t = Get-NewestTimestampUtc $e
|
|
79
|
+
$t = Get-NewestTimestampUtc $e -IgnoreAccess:$ignore
|
|
79
80
|
if ($t -gt $newest) { $newest = $t }
|
|
80
81
|
}
|
|
81
82
|
}
|
package/modules/runner.ps1
CHANGED
|
@@ -60,9 +60,15 @@ function Write-MachineProgress {
|
|
|
60
60
|
|
|
61
61
|
function Add-JsonCandidate {
|
|
62
62
|
<# .SYNOPSIS Record one item an interactive section offered, so --json callers can present the same list. #>
|
|
63
|
-
param([int] $Section, [int] $Index, [string] $Path, [long] $Bytes, [int] $IdleDays, [string] $Project = ''
|
|
63
|
+
param([int] $Section, [int] $Index, [string] $Path, [long] $Bytes, [int] $IdleDays, [string] $Project = '', [string] $Label = '',
|
|
64
|
+
[System.Collections.IDictionary] $Extra = $null)
|
|
64
65
|
if (-not $Script:WS.JsonMode) { return }
|
|
65
|
-
$
|
|
66
|
+
$c = [ordered]@{ section = $Section; index = $Index; path = $Path; bytes = $Bytes; idle_days = $IdleDays; project = $Project }
|
|
67
|
+
# P9: an optional display name - section 26's candidates are programs whose path is a registry key.
|
|
68
|
+
if ($Label) { $c.label = $Label }
|
|
69
|
+
# P9: section-specific fields added after the shared ones (section 19's data scanner: reason, age basis, ...).
|
|
70
|
+
if ($null -ne $Extra) { foreach ($k in $Extra.Keys) { $c[$k] = $Extra[$k] } }
|
|
71
|
+
$Script:WS.Candidates += $c
|
|
66
72
|
}
|
|
67
73
|
|
|
68
74
|
function Invoke-SectionById {
|
|
@@ -113,7 +119,9 @@ function Invoke-SectionById {
|
|
|
113
119
|
$status = 'ran'
|
|
114
120
|
if ($ws.DryRun) { $status = 'dry-run' }
|
|
115
121
|
try {
|
|
116
|
-
|
|
122
|
+
# A section speaks through the Write-* helpers; anything left on its output stream (a cmdlet's return value)
|
|
123
|
+
# would land on stdout ahead of the --json line, so it is discarded here for every section.
|
|
124
|
+
$null = & $sec.Fn
|
|
117
125
|
} catch {
|
|
118
126
|
$status = 'failed'
|
|
119
127
|
Write-Err "section $Id failed: $($_.Exception.Message)"
|
|
@@ -223,7 +231,12 @@ function Get-JsonSummary {
|
|
|
223
231
|
sections = @($ws.Report.steps | ForEach-Object { [ordered]@{ section = $_.section; status = $_.status; freed_bytes = $_.freed_bytes } })
|
|
224
232
|
# candidates and targets are ALWAYS present, empty when nothing was collected: a caller can rely on the shape.
|
|
225
233
|
candidates = @($ws.Candidates); targets = @($ws.ScanTargets)
|
|
226
|
-
refusals = @($ws.Refusals); excluded = @($ws.Excluded)
|
|
234
|
+
refusals = @($ws.Refusals); excluded = @($ws.Excluded)
|
|
235
|
+
# D82: the volumes where idle age used write/creation time because last-access was found live.
|
|
236
|
+
access_live_volumes = @(Get-AccessLiveVolumes)
|
|
237
|
+
# P9: the folders section 19 scanned or refused ({path, accepted, reason}); empty unless it ran.
|
|
238
|
+
personal_roots = @($ws.PersonalRootsReport)
|
|
239
|
+
log_file = $ws.LogFile; report_file = $ws.ReportFile
|
|
227
240
|
}
|
|
228
241
|
}
|
|
229
242
|
|
|
@@ -240,7 +253,18 @@ function Get-CatalogueJson {
|
|
|
240
253
|
}
|
|
241
254
|
}
|
|
242
255
|
|
|
256
|
+
function Save-ElevatedSummary {
|
|
257
|
+
<# .SYNOPSIS Let the waiting non-admin parent relay an elevated child's JSON to its caller. #>
|
|
258
|
+
param([string] $Json)
|
|
259
|
+
if ($Script:WS.ElevatedChild -and $Script:WS.ElevatedSummaryToken) {
|
|
260
|
+
$path = Join-Path $Script:WS.ReportsDir ("elevated-summary-$($Script:WS.ElevatedSummaryToken).json")
|
|
261
|
+
[IO.File]::WriteAllText($path, $Json, (New-Object System.Text.UTF8Encoding($false)))
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
243
265
|
function Write-JsonSummary {
|
|
244
266
|
<# .SYNOPSIS --json: the only line written to stdout. #>
|
|
245
|
-
|
|
267
|
+
$json = (Get-JsonSummary) | ConvertTo-Json -Depth 5 -Compress
|
|
268
|
+
Save-ElevatedSummary -Json $json
|
|
269
|
+
[Console]::Out.WriteLine($json)
|
|
246
270
|
}
|