windowsweep 1.0.1 → 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.
- package/AI-INTEGRATION-GUIDE.md +203 -0
- package/CHANGELOG.md +124 -0
- package/LICENSE +21 -21
- package/README.md +88 -49
- package/VERSION +1 -1
- package/lib/actions.ps1 +212 -209
- package/lib/config.ps1 +108 -99
- package/lib/constants.ps1 +103 -89
- package/lib/fs.ps1 +189 -180
- package/lib/log.ps1 +140 -140
- package/lib/safety.ps1 +541 -493
- package/lib/scan.ps1 +109 -94
- package/lib/ui.ps1 +275 -235
- package/modules/android_avd.ps1 +36 -36
- package/modules/app_caches.ps1 +58 -58
- package/modules/browsers.ps1 +33 -33
- package/modules/build_tools.ps1 +35 -35
- package/modules/crash_trap.ps1 +89 -89
- package/modules/disk_usage.ps1 +49 -49
- package/modules/docker.ps1 +93 -93
- package/modules/editors.ps1 +150 -150
- package/modules/globals_audit.ps1 +236 -0
- package/modules/health.ps1 +101 -101
- package/modules/installed_programs.ps1 +118 -0
- package/modules/menu.ps1 +63 -61
- package/modules/notify.ps1 +60 -0
- package/modules/orphaned_appdata.ps1 +189 -0
- package/modules/personal.ps1 +126 -124
- package/modules/pkg_managers.ps1 +73 -69
- package/modules/projects.ps1 +160 -143
- package/modules/release_helpers.ps1 +438 -434
- package/modules/reports.ps1 +212 -212
- package/modules/runner.ps1 +242 -190
- package/modules/self_test_extra.ps1 +537 -86
- package/modules/startup_audit.ps1 +126 -0
- package/modules/system_admin.ps1 +220 -220
- package/modules/test_runners.ps1 +24 -24
- package/modules/walkthrough.ps1 +80 -79
- package/modules/windows_user.ps1 +118 -112
- package/package.json +4 -3
- package/windowsweep.ps1 +322 -296
package/modules/menu.ps1
CHANGED
|
@@ -1,61 +1,63 @@
|
|
|
1
|
-
# menu.ps1 - jump-to menu: run one section at a time; toggle dry-run and auto-yes.
|
|
2
|
-
|
|
3
|
-
function Show-Menu {
|
|
4
|
-
$ws = $Script:WS
|
|
5
|
-
Write-UiLine '' 'Gray'
|
|
6
|
-
Write-Banner
|
|
7
|
-
$sys = New-Object IO.DriveInfo($Script:P.SD)
|
|
8
|
-
$dry = 'OFF'
|
|
9
|
-
if ($ws.DryRun) { $dry = 'ON' }
|
|
10
|
-
$yes = 'OFF'
|
|
11
|
-
if ($ws.Yes) { $yes = 'ON' }
|
|
12
|
-
$adm = 'no'
|
|
13
|
-
if ($ws.IsAdmin) { $adm = 'yes' }
|
|
14
|
-
Write-UiLine (" system drive free: {0} dry-run: {1} auto-yes: {2} elevated: {3}" -f (Format-Bytes $sys.AvailableFreeSpace), $dry, $yes, $adm) 'Cyan'
|
|
15
|
-
Write-UiLine '' 'Gray'
|
|
16
|
-
foreach ($s in $Script:WS_SECTIONS) {
|
|
17
|
-
$mark = ' '
|
|
18
|
-
if ($ws.Report.steps | Where-Object { $_.section -eq $s.Id -and $_.status -in 'ran', 'dry-run' }) { $mark = $ws.Glyph.ok }
|
|
19
|
-
$flags = ''
|
|
20
|
-
if ($s.Admin) { $flags += ' [admin]' }
|
|
21
|
-
if ($s.Batch -eq 'deep') { $flags += ' [deep]' }
|
|
22
|
-
if ($s.Batch -eq 'interactive') { $flags += ' [interactive]' }
|
|
23
|
-
$color = 'Gray'
|
|
24
|
-
if ($s.Admin -and -not $ws.IsAdmin) { $color = 'DarkGray' }
|
|
25
|
-
Write-UiLine (" {0} [{1,2}] {2}{3}" -f $mark, $s.Id, $s.Title, $flags) $color
|
|
26
|
-
}
|
|
27
|
-
Write-UiLine '' 'Gray'
|
|
28
|
-
Write-UiLine ' [A] run the safe batch [D] toggle dry-run [Y] toggle auto-yes [S] summary [Q] quit' 'White'
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
function Invoke-Menu {
|
|
32
|
-
$ws = $Script:WS
|
|
33
|
-
if (-not $ws.Interactive) {
|
|
34
|
-
Write-Err 'The menu needs an interactive console. For unattended runs use: windowsweep --all --yes'
|
|
35
|
-
$ws.ExitCode = $Script:WS_EXIT_USAGE
|
|
36
|
-
return
|
|
37
|
-
}
|
|
38
|
-
while ($true) {
|
|
39
|
-
Show-Menu
|
|
40
|
-
$
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
$
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
'^
|
|
54
|
-
'
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
}
|
|
1
|
+
# menu.ps1 - jump-to menu: run one section at a time; toggle dry-run and auto-yes.
|
|
2
|
+
|
|
3
|
+
function Show-Menu {
|
|
4
|
+
$ws = $Script:WS
|
|
5
|
+
Write-UiLine '' 'Gray'
|
|
6
|
+
Write-Banner
|
|
7
|
+
$sys = New-Object IO.DriveInfo($Script:P.SD)
|
|
8
|
+
$dry = 'OFF'
|
|
9
|
+
if ($ws.DryRun) { $dry = 'ON' }
|
|
10
|
+
$yes = 'OFF'
|
|
11
|
+
if ($ws.Yes) { $yes = 'ON' }
|
|
12
|
+
$adm = 'no'
|
|
13
|
+
if ($ws.IsAdmin) { $adm = 'yes' }
|
|
14
|
+
Write-UiLine (" system drive free: {0} dry-run: {1} auto-yes: {2} elevated: {3}" -f (Format-Bytes $sys.AvailableFreeSpace), $dry, $yes, $adm) 'Cyan'
|
|
15
|
+
Write-UiLine '' 'Gray'
|
|
16
|
+
foreach ($s in $Script:WS_SECTIONS) {
|
|
17
|
+
$mark = ' '
|
|
18
|
+
if ($ws.Report.steps | Where-Object { $_.section -eq $s.Id -and $_.status -in 'ran', 'dry-run' }) { $mark = $ws.Glyph.ok }
|
|
19
|
+
$flags = ''
|
|
20
|
+
if ($s.Admin) { $flags += ' [admin]' }
|
|
21
|
+
if ($s.Batch -eq 'deep') { $flags += ' [deep]' }
|
|
22
|
+
if ($s.Batch -eq 'interactive') { $flags += ' [interactive]' }
|
|
23
|
+
$color = 'Gray'
|
|
24
|
+
if ($s.Admin -and -not $ws.IsAdmin) { $color = 'DarkGray' }
|
|
25
|
+
Write-UiLine (" {0} [{1,2}] {2}{3}" -f $mark, $s.Id, $s.Title, $flags) $color
|
|
26
|
+
}
|
|
27
|
+
Write-UiLine '' 'Gray'
|
|
28
|
+
Write-UiLine ' [A] run the safe batch [D] toggle dry-run [Y] toggle auto-yes [S] summary [Q] quit' 'White'
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function Invoke-Menu {
|
|
32
|
+
$ws = $Script:WS
|
|
33
|
+
if (-not $ws.Interactive) {
|
|
34
|
+
Write-Err 'The menu needs an interactive console. For unattended runs use: windowsweep --all --yes'
|
|
35
|
+
$ws.ExitCode = $Script:WS_EXIT_USAGE
|
|
36
|
+
return
|
|
37
|
+
}
|
|
38
|
+
while ($true) {
|
|
39
|
+
Show-Menu
|
|
40
|
+
$lo = ($Script:WS_SECTIONS | Measure-Object Id -Minimum).Minimum
|
|
41
|
+
$hi = ($Script:WS_SECTIONS | Measure-Object Id -Maximum).Maximum
|
|
42
|
+
$choice = Read-Choice -Prompt " Select [$lo-$hi / A / D / Y / S / Q]: " -Default ''
|
|
43
|
+
if ($choice -eq '') { continue }
|
|
44
|
+
switch -Regex ($choice) {
|
|
45
|
+
'^q' { Show-SessionSummary; return }
|
|
46
|
+
'^a' {
|
|
47
|
+
$ids = @($Script:WS_SAFE_BATCH)
|
|
48
|
+
if ($ws.IsAdmin) { $ids += $Script:WS_SAFE_BATCH_ADMIN }
|
|
49
|
+
$ws.BatchMode = $true
|
|
50
|
+
try { foreach ($id in $ids) { Invoke-SectionById -Id $id } } finally { $ws.BatchMode = $false }
|
|
51
|
+
Wait-Enter
|
|
52
|
+
}
|
|
53
|
+
'^d' { $ws.DryRun = -not $ws.DryRun }
|
|
54
|
+
'^y' { $ws.Yes = -not $ws.Yes }
|
|
55
|
+
'^s' { Show-SessionSummary; Wait-Enter }
|
|
56
|
+
'^\d+$' {
|
|
57
|
+
$id = [int]$choice
|
|
58
|
+
if (Get-Section $id) { Invoke-SectionById -Id $id; Wait-Enter } else { Write-Warn "no section $id" }
|
|
59
|
+
}
|
|
60
|
+
default { Write-Warn "unrecognised choice: $choice" }
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -0,0 +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
|
+
}
|
|
@@ -0,0 +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
|
+
}
|
package/modules/personal.ps1
CHANGED
|
@@ -1,124 +1,126 @@
|
|
|
1
|
-
# personal.ps1 - sections 18 and 19: personal files in Downloads. Interactive only; the Recycle Bin is the destination.
|
|
2
|
-
|
|
3
|
-
$Script:WS_PARTIAL_PATTERNS = @('*.crdownload', '*.part', '*.partial', '*.fdmdownload', '*.opdownload', '*.!ut', '*.aria2', '*.download', '*.bc!', '*.tmp')
|
|
4
|
-
|
|
5
|
-
function Get-PersonalRoots {
|
|
6
|
-
$roots = @()
|
|
7
|
-
$d = Join-Path $Script:P.U 'Downloads'
|
|
8
|
-
if (Test-DirPresent $d) { $roots += (Get-FullPath $d) }
|
|
9
|
-
return $roots
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
function Get-Targets18 {
|
|
13
|
-
$t = @()
|
|
14
|
-
foreach ($r in (Get-PersonalRoots)) { $t += (New-Target 18 'Partial / orphan downloads' $r -Kind cmd -Note ($Script:WS_PARTIAL_PATTERNS -join ' ')) }
|
|
15
|
-
return $t
|
|
16
|
-
}
|
|
17
|
-
|
|
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
|
-
function Get-FilesUnder {
|
|
25
|
-
<# .SYNOPSIS Files under Root up to MaxDepth, never following links, never entering protected folders. #>
|
|
26
|
-
param([string] $Root, [int] $MaxDepth = 4)
|
|
27
|
-
$out = New-Object System.Collections.Generic.List[object]
|
|
28
|
-
$stack = New-Object System.Collections.Generic.Stack[object]
|
|
29
|
-
$stack.Push(@($Root, 0))
|
|
30
|
-
while ($stack.Count -gt 0) {
|
|
31
|
-
$item = $stack.Pop()
|
|
32
|
-
foreach ($e in (Get-ChildEntries ([string]$item[0]))) {
|
|
33
|
-
if (Test-ReparsePoint $e) { continue }
|
|
34
|
-
if ($e -is [IO.DirectoryInfo]) { if ([int]$item[1] -lt $MaxDepth) { $stack.Push(@((Remove-LongPrefix $e.FullName), [int]$item[1] + 1)) }; continue }
|
|
35
|
-
$out.Add($e)
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
return $out
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
function Get-PartialDownloads {
|
|
42
|
-
$list = @()
|
|
43
|
-
foreach ($root in (Get-PersonalRoots)) {
|
|
44
|
-
foreach ($f in (Get-FilesUnder -Root $root -MaxDepth 4)) {
|
|
45
|
-
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 }
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
return @($list | Sort-Object Bytes -Descending)
|
|
51
|
-
}
|
|
52
|
-
|
|
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
|
-
function Show-FileTable {
|
|
69
|
-
param([object[]] $Rows)
|
|
70
|
-
Write-UiLine (" {0,3} {1,10} {2,6} {3}" -f '#', 'SIZE', 'IDLE', 'FILE') 'White'
|
|
71
|
-
$i = 1
|
|
72
|
-
foreach ($r in $Rows) { Write-UiLine (" {0,3} {1,10} {2,5}d {3}" -f $i, (Format-Bytes $r.Bytes), $r.Idle, $r.Path) 'Gray'; $i++ }
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
function Invoke-PersonalPicker {
|
|
76
|
-
param([object[]] $Rows, [string] $Prompt)
|
|
77
|
-
$ws = $Script:WS
|
|
78
|
-
$Rows = @($Rows)
|
|
79
|
-
if ($Rows.Count -eq 0) { Write-Info 'none found'; return }
|
|
80
|
-
Show-FileTable $Rows
|
|
81
|
-
$total = [long]0
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
if (-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
$
|
|
123
|
-
|
|
124
|
-
|
|
1
|
+
# personal.ps1 - sections 18 and 19: personal files in Downloads. Interactive only; the Recycle Bin is the destination.
|
|
2
|
+
|
|
3
|
+
$Script:WS_PARTIAL_PATTERNS = @('*.crdownload', '*.part', '*.partial', '*.fdmdownload', '*.opdownload', '*.!ut', '*.aria2', '*.download', '*.bc!', '*.tmp')
|
|
4
|
+
|
|
5
|
+
function Get-PersonalRoots {
|
|
6
|
+
$roots = @()
|
|
7
|
+
$d = Join-Path $Script:P.U 'Downloads'
|
|
8
|
+
if (Test-DirPresent $d) { $roots += (Get-FullPath $d) }
|
|
9
|
+
return $roots
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function Get-Targets18 {
|
|
13
|
+
$t = @()
|
|
14
|
+
foreach ($r in (Get-PersonalRoots)) { $t += (New-Target 18 'Partial / orphan downloads' $r -Kind cmd -Note ($Script:WS_PARTIAL_PATTERNS -join ' ')) }
|
|
15
|
+
return $t
|
|
16
|
+
}
|
|
17
|
+
|
|
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
|
+
function Get-FilesUnder {
|
|
25
|
+
<# .SYNOPSIS Files under Root up to MaxDepth, never following links, never entering protected folders. #>
|
|
26
|
+
param([string] $Root, [int] $MaxDepth = 4)
|
|
27
|
+
$out = New-Object System.Collections.Generic.List[object]
|
|
28
|
+
$stack = New-Object System.Collections.Generic.Stack[object]
|
|
29
|
+
$stack.Push(@($Root, 0))
|
|
30
|
+
while ($stack.Count -gt 0) {
|
|
31
|
+
$item = $stack.Pop()
|
|
32
|
+
foreach ($e in (Get-ChildEntries ([string]$item[0]))) {
|
|
33
|
+
if (Test-ReparsePoint $e) { continue }
|
|
34
|
+
if ($e -is [IO.DirectoryInfo]) { if ([int]$item[1] -lt $MaxDepth) { $stack.Push(@((Remove-LongPrefix $e.FullName), [int]$item[1] + 1)) }; continue }
|
|
35
|
+
$out.Add($e)
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return $out
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function Get-PartialDownloads {
|
|
42
|
+
$list = @()
|
|
43
|
+
foreach ($root in (Get-PersonalRoots)) {
|
|
44
|
+
foreach ($f in (Get-FilesUnder -Root $root -MaxDepth 4)) {
|
|
45
|
+
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 }
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return @($list | Sort-Object Bytes -Descending)
|
|
51
|
+
}
|
|
52
|
+
|
|
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
|
+
function Show-FileTable {
|
|
69
|
+
param([object[]] $Rows)
|
|
70
|
+
Write-UiLine (" {0,3} {1,10} {2,6} {3}" -f '#', 'SIZE', 'IDLE', 'FILE') 'White'
|
|
71
|
+
$i = 1
|
|
72
|
+
foreach ($r in $Rows) { Write-UiLine (" {0,3} {1,10} {2,5}d {3}" -f $i, (Format-Bytes $r.Bytes), $r.Idle, $r.Path) 'Gray'; $i++ }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function Invoke-PersonalPicker {
|
|
76
|
+
param([object[]] $Rows, [string] $Prompt, [int] $Section = 0)
|
|
77
|
+
$ws = $Script:WS
|
|
78
|
+
$Rows = @($Rows)
|
|
79
|
+
if ($Rows.Count -eq 0) { Write-Info 'none found'; return }
|
|
80
|
+
Show-FileTable $Rows
|
|
81
|
+
$total = [long]0
|
|
82
|
+
$i = 1
|
|
83
|
+
foreach ($r in $Rows) { $total += $r.Bytes; Add-JsonCandidate -Section $Section -Index $i -Path $r.Path -Bytes $r.Bytes -IdleDays $r.Idle; $i++ }
|
|
84
|
+
Write-Info ("total: " + (Format-Bytes $total))
|
|
85
|
+
$dest = 'the Recycle Bin'
|
|
86
|
+
if ($ws.Permanent) { $dest = 'PERMANENT deletion (--permanent)' }
|
|
87
|
+
Write-Note "selected files go to $dest"
|
|
88
|
+
$picks = @(Read-MultiSelect -Total $Rows.Count -NoAutoYes -Candidates @($Rows | ForEach-Object { $_.Path }))
|
|
89
|
+
if ($picks.Count -eq 0) { Write-Info 'nothing selected'; return }
|
|
90
|
+
if (-not $ws.DryRun) {
|
|
91
|
+
# Personal files never auto-confirm: --yes does not apply here. An explicit --select / --select-file
|
|
92
|
+
# choice does, because it is a person naming these exact files in advance.
|
|
93
|
+
if (-not (Confirm-Ui -Prompt "$Prompt ($($picks.Count) file(s))?" -Default 'n' -NoAutoYes -ScriptedOk)) { Write-Info 'skipped'; return }
|
|
94
|
+
}
|
|
95
|
+
foreach ($k in $picks) { $null = Send-ToRecycleBin -Path $Rows[$k - 1].Path -Within $Rows[$k - 1].Root }
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function Invoke-Section18 {
|
|
99
|
+
Write-SectionIntro @(
|
|
100
|
+
'Half-finished downloads left behind by browsers and download managers (.crdownload, .part, .tmp, ...).',
|
|
101
|
+
'You pick what goes; it lands in the Recycle Bin. Desktop and cloud-synced folders are never scanned.'
|
|
102
|
+
)
|
|
103
|
+
if (@(Get-PersonalRoots).Count -eq 0) { Write-Info 'no Downloads folder found'; return }
|
|
104
|
+
Invoke-PersonalPicker -Rows @(Get-PartialDownloads) -Prompt 'Move the selected partial downloads to the Recycle Bin' -Section 18
|
|
105
|
+
}
|
|
106
|
+
|
|
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
|
+
function Show-PersonalScan {
|
|
118
|
+
<# .SYNOPSIS Read-only listing used by --scan. #>
|
|
119
|
+
Write-Box 'Personal files (read-only)' 'Sections 18 and 19 are interactive only - nothing here is deleted'
|
|
120
|
+
Write-Section 'Partial / orphan downloads'
|
|
121
|
+
$p = @(Get-PartialDownloads)
|
|
122
|
+
if ($p.Count -eq 0) { Write-Info 'none found' } else { Show-FileTable @($p | Select-Object -First 30) }
|
|
123
|
+
Write-Section "Files >= $($Script:WS.LargeFileMb) MB idle $($Script:WS.Days)+ days in Downloads"
|
|
124
|
+
$l = @(Get-LargeStaleFiles)
|
|
125
|
+
if ($l.Count -eq 0) { Write-Info 'none found' } else { Show-FileTable @($l | Select-Object -First 30) }
|
|
126
|
+
}
|