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,68 @@
|
|
|
1
|
+
# browser_ai.ps1 - section 29: the Chrome on-device AI model (OptGuideOnDeviceModel, ~4 GB on the P52, 2026-11-02).
|
|
2
|
+
# Deleting the folder alone is pointless - Chrome downloads it again. The GenAILocalFoundationalModelSettings
|
|
3
|
+
# policy (1 = do not download) stops that, and Chrome then removes the model itself on its next start (seen on the
|
|
4
|
+
# P52 the same day). Any Chrome policy makes Chrome show "Managed by your organization". ASCII only.
|
|
5
|
+
|
|
6
|
+
$Script:WS_BROWSERAI_POLICY_KEY = 'HKLM:\SOFTWARE\Policies\Google\Chrome'
|
|
7
|
+
$Script:WS_BROWSERAI_POLICY_VALUE = 'GenAILocalFoundationalModelSettings'
|
|
8
|
+
$Script:WS_BROWSERAI_UNDO = 'reg delete HKLM\SOFTWARE\Policies\Google\Chrome /v GenAILocalFoundationalModelSettings /f'
|
|
9
|
+
|
|
10
|
+
function Get-ChromeUserDataRoots {
|
|
11
|
+
<# .SYNOPSIS The User Data roots of every installed Chrome channel; all of them read the Google\Chrome policy. #>
|
|
12
|
+
$L = $Script:P.L
|
|
13
|
+
return @(@('Google\Chrome', 'Google\Chrome Beta', 'Google\Chrome Dev', 'Google\Chrome SxS') | ForEach-Object { Join-Path $L "$_\User Data" } | Where-Object { Test-DirPresent $_ })
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function Get-BrowserAiPolicyState {
|
|
17
|
+
<# .SYNOPSIS The policy's current value (1 = the model is not downloaded), or $null when it is not set. #>
|
|
18
|
+
param([string] $KeyPath = $Script:WS_BROWSERAI_POLICY_KEY)
|
|
19
|
+
try { $v = (Get-ItemProperty -LiteralPath $KeyPath -Name $Script:WS_BROWSERAI_POLICY_VALUE -ErrorAction Stop).($Script:WS_BROWSERAI_POLICY_VALUE); return [int]$v } catch { return $null }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function Set-BrowserAiPolicy {
|
|
23
|
+
<# .SYNOPSIS Set the policy to 1. --dry-run prints and writes nothing. -KeyPath lets the self-test use a
|
|
24
|
+
throwaway HKCU key instead of the machine policy. Returns 'set', 'would-set' or 'failed'. #>
|
|
25
|
+
param([string] $KeyPath = $Script:WS_BROWSERAI_POLICY_KEY)
|
|
26
|
+
if ($Script:WS.DryRun) { Write-DryRun "would set $KeyPath\$($Script:WS_BROWSERAI_POLICY_VALUE) = 1 (DWORD)"; return 'would-set' }
|
|
27
|
+
try {
|
|
28
|
+
if (-not (Test-Path -LiteralPath $KeyPath)) { New-Item -Path $KeyPath -Force -ErrorAction Stop | Out-Null }
|
|
29
|
+
New-ItemProperty -LiteralPath $KeyPath -Name $Script:WS_BROWSERAI_POLICY_VALUE -Value 1 -PropertyType DWord -Force -ErrorAction Stop | Out-Null
|
|
30
|
+
Write-LogLine "policy set: $KeyPath\$($Script:WS_BROWSERAI_POLICY_VALUE) = 1"
|
|
31
|
+
return 'set'
|
|
32
|
+
} catch {
|
|
33
|
+
Write-Err "could not set the Chrome policy: $($_.Exception.Message)"
|
|
34
|
+
return 'failed'
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function Get-Targets29 {
|
|
39
|
+
$t = @((New-Target 29 'Chrome policy GenAILocalFoundationalModelSettings = 1' $Script:WS_BROWSERAI_POLICY_KEY -Kind cmd -Note 'stops the download; Chrome then removes the model itself'))
|
|
40
|
+
foreach ($root in (Get-ChromeUserDataRoots)) {
|
|
41
|
+
$t += (New-Target 29 'Chrome on-device AI model' (Join-Path $root 'OptGuideOnDeviceModel') -Mode clear -Guard @('chrome') -Note 'removed now only while Chrome is closed')
|
|
42
|
+
}
|
|
43
|
+
return $t
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function Invoke-Section29 {
|
|
47
|
+
$ws = $Script:WS
|
|
48
|
+
Write-SectionIntro @(
|
|
49
|
+
'Chrome downloads an on-device AI model of several GB. Deleting it alone is pointless - Chrome fetches it again.',
|
|
50
|
+
'This sets the Chrome policy that stops the download; Chrome then removes the model on its next start.',
|
|
51
|
+
'Chrome will show "Managed by your organization" while the policy is set.'
|
|
52
|
+
)
|
|
53
|
+
$models = @(Get-ChromeUserDataRoots | ForEach-Object { Join-Path $_ 'OptGuideOnDeviceModel' } | Where-Object { Test-DirPresent $_ })
|
|
54
|
+
foreach ($m in $models) { Write-UiLine (" {0,10} {1}" -f (Format-Bytes (Get-DirectoryBytes $m)), $m) 'Gray' }
|
|
55
|
+
if ($models.Count -eq 0) { Write-Info 'no on-device AI model folder found' }
|
|
56
|
+
if ((Get-BrowserAiPolicyState) -eq 1) {
|
|
57
|
+
Write-Info 'the policy is already set - Chrome will not download the model again'
|
|
58
|
+
} else {
|
|
59
|
+
if (-not (Confirm-Section 'Set the Chrome policy that removes the on-device AI model?' 'n')) { Write-Info 'skipped'; return }
|
|
60
|
+
if ((Set-BrowserAiPolicy) -eq 'set') { Write-Ok 'Chrome policy set: the on-device AI model will not be downloaded again' }
|
|
61
|
+
}
|
|
62
|
+
foreach ($m in $models) {
|
|
63
|
+
if (Test-ProcessRunning @('chrome')) { Write-Note "Chrome is running - it removes $m itself on its next start"; continue }
|
|
64
|
+
$r = Remove-PathSafe -Path $m -Within (Split-Path -Parent $m) -Label 'Chrome on-device AI model'
|
|
65
|
+
if ($r.Removed -and -not $ws.DryRun) { Write-Ok ('Chrome on-device AI model removed (' + (Format-Bytes $r.Bytes) + ')') }
|
|
66
|
+
}
|
|
67
|
+
Write-Note "undo (Chrome downloads the model again): $Script:WS_BROWSERAI_UNDO"
|
|
68
|
+
}
|
package/modules/browsers.ps1
CHANGED
|
@@ -1,33 +1,101 @@
|
|
|
1
|
-
# browsers.ps1 - section 7: browser caches for every profile. Profile data is never touched.
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
(
|
|
15
|
-
|
|
16
|
-
(
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
$
|
|
33
|
-
|
|
1
|
+
# browsers.ps1 - section 7: browser caches for every profile. Profile data is never touched.
|
|
2
|
+
# P9: --browser-profile-roots / browserProfileRoots name folders that hold Chromium user-data directories of
|
|
3
|
+
# their own - automation and test profiles (the P52 kept 2.0 GB of cache in ~\.local\share\ahsan-automation\profiles,
|
|
4
|
+
# 2026-11-02). Only the declared cache folders inside them are cleared (D79), and a profile a running browser
|
|
5
|
+
# holds through --user-data-dir is skipped. ASCII only.
|
|
6
|
+
|
|
7
|
+
function Get-DeclaredProfileDirs {
|
|
8
|
+
<# .SYNOPSIS Chromium user-data directories (a folder holding "Local State") under the declared roots, up to
|
|
9
|
+
three levels deep, never through a junction. #>
|
|
10
|
+
param([string[]] $Roots = @($Script:WS.BrowserProfileRoots))
|
|
11
|
+
$out = @()
|
|
12
|
+
$seen = New-Object System.Collections.Generic.HashSet[string]([StringComparer]::OrdinalIgnoreCase)
|
|
13
|
+
foreach ($r in $Roots) {
|
|
14
|
+
if (-not $r -or -not (Test-DirPresent $r)) { continue }
|
|
15
|
+
$stack = New-Object System.Collections.Generic.Stack[object]
|
|
16
|
+
$stack.Push(@((Get-FullPath $r), 0))
|
|
17
|
+
$visited = 0
|
|
18
|
+
while ($stack.Count -gt 0 -and $visited -lt 5000) {
|
|
19
|
+
$item = $stack.Pop(); $dir = [string]$item[0]; $depth = [int]$item[1]; $visited++
|
|
20
|
+
if (Test-PathPresent (Join-Path $dir 'Local State')) { if ($seen.Add($dir)) { $out += $dir }; continue }
|
|
21
|
+
if ($depth -ge 3) { continue }
|
|
22
|
+
foreach ($e in (Get-ChildEntries $dir)) { if ($e -is [IO.DirectoryInfo] -and -not (Test-ReparsePoint $e)) { $stack.Push(@((Remove-LongPrefix $e.FullName), ($depth + 1))) } }
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return $out
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function Get-UserDataDirFromCommandLine {
|
|
29
|
+
<# .SYNOPSIS The --user-data-dir a browser command line names, as a full path, or ''. Handles
|
|
30
|
+
--user-data-dir=C:\a, --user-data-dir="C:\a b" and "--user-data-dir=C:\a b". #>
|
|
31
|
+
param([string] $CommandLine)
|
|
32
|
+
$t = [string]$CommandLine
|
|
33
|
+
$v = ''
|
|
34
|
+
if ($t -match '"--user-data-dir=([^"]+)"') { $v = $Matches[1] }
|
|
35
|
+
elseif ($t -match '--user-data-dir="([^"]+)"') { $v = $Matches[1] }
|
|
36
|
+
elseif ($t -match '--user-data-dir=([^\s"]+)') { $v = $Matches[1] }
|
|
37
|
+
if (-not $v) { return '' }
|
|
38
|
+
return (Get-FullPath $v.Trim())
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function Get-BusyUserDataDirs {
|
|
42
|
+
$set = New-Object System.Collections.Generic.HashSet[string]([StringComparer]::OrdinalIgnoreCase)
|
|
43
|
+
try {
|
|
44
|
+
foreach ($p in @(Get-CimInstance Win32_Process -Property CommandLine -ErrorAction Stop)) {
|
|
45
|
+
$d = Get-UserDataDirFromCommandLine ([string]$p.CommandLine)
|
|
46
|
+
if ($d) { $null = $set.Add($d) }
|
|
47
|
+
}
|
|
48
|
+
} catch { $null = $_ }
|
|
49
|
+
return , $set
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function Initialize-ProfileRootExceptions {
|
|
53
|
+
<# .SYNOPSIS Called once after the settings merge (D79): exceptions for exactly the cache folders of the user-data
|
|
54
|
+
directories under the declared roots - those folders and nothing above or beside them. #>
|
|
55
|
+
if (@($Script:WS.BrowserProfileRoots).Count -eq 0) { return }
|
|
56
|
+
$leaves = @()
|
|
57
|
+
foreach ($d in (Get-DeclaredProfileDirs)) { $leaves += @(Get-ChromiumCacheDirs $d) }
|
|
58
|
+
$n = Add-CacheLeafExceptions -Paths $leaves
|
|
59
|
+
if ($n -gt 0) { Write-LogLine "D79: $n cache folder(s) under the declared browser-profile roots may be cleared" }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function Get-Targets07 {
|
|
63
|
+
$P = $Script:P
|
|
64
|
+
$t = @(
|
|
65
|
+
(New-Target 7 'Google Chrome' "$($P.L)\Google\Chrome\User Data" -Kind chromium -Guard @('chrome') -Note 'cache folders of every profile')
|
|
66
|
+
(New-Target 7 'Google Chrome Beta' "$($P.L)\Google\Chrome Beta\User Data" -Kind chromium -Guard @('chrome'))
|
|
67
|
+
(New-Target 7 'Google Chrome Dev' "$($P.L)\Google\Chrome Dev\User Data" -Kind chromium -Guard @('chrome'))
|
|
68
|
+
(New-Target 7 'Google Chrome Canary' "$($P.L)\Google\Chrome SxS\User Data" -Kind chromium -Guard @('chrome'))
|
|
69
|
+
(New-Target 7 'Microsoft Edge' "$($P.L)\Microsoft\Edge\User Data" -Kind chromium -Guard @('msedge'))
|
|
70
|
+
(New-Target 7 'Brave' "$($P.L)\BraveSoftware\Brave-Browser\User Data" -Kind chromium -Guard @('brave'))
|
|
71
|
+
(New-Target 7 'Vivaldi' "$($P.L)\Vivaldi\User Data" -Kind chromium -Guard @('vivaldi'))
|
|
72
|
+
(New-Target 7 'Opera' "$($P.A)\Opera Software\Opera Stable" -Kind chromium -Guard @('opera'))
|
|
73
|
+
(New-Target 7 'Opera GX' "$($P.A)\Opera Software\Opera GX Stable" -Kind chromium -Guard @('opera'))
|
|
74
|
+
(New-Target 7 'Chromium' "$($P.L)\Chromium\User Data" -Kind chromium -Guard @('chrome', 'chromium'))
|
|
75
|
+
(New-Target 7 'Arc' "$($P.L)\Packages\TheBrowserCompany.Arc_*\LocalCache\Local\Arc\User Data" -Kind chromium -Guard @('Arc'))
|
|
76
|
+
(New-Target 7 'Firefox' "$($P.L)\Mozilla\Firefox\Profiles" -Kind firefox -Guard @('firefox') -Note 'cache2, startupCache, thumbnails, jumpListCache')
|
|
77
|
+
(New-Target 7 'LibreWolf' "$($P.L)\LibreWolf\Profiles" -Kind firefox -Guard @('librewolf'))
|
|
78
|
+
(New-Target 7 'Waterfox' "$($P.L)\Waterfox\Profiles" -Kind firefox -Guard @('waterfox'))
|
|
79
|
+
)
|
|
80
|
+
foreach ($d in (Get-DeclaredProfileDirs)) {
|
|
81
|
+
$t += (New-Target 7 "Declared browser profile ($(Split-Path -Leaf $d))" $d -Kind chromium -Note 'from --browser-profile-roots; skipped while a browser holds it')
|
|
82
|
+
}
|
|
83
|
+
return $t
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function Invoke-Section07 {
|
|
87
|
+
Write-SectionIntro @(
|
|
88
|
+
'Browser caches rebuild as you browse; the first page loads afterwards are a little slower. Cookies, logins,',
|
|
89
|
+
'history, bookmarks, extensions, Local Storage, IndexedDB and PWA CacheStorage are never touched.',
|
|
90
|
+
'A browser that is open right now is skipped - close it and run this section again.'
|
|
91
|
+
)
|
|
92
|
+
$declared = @(Get-DeclaredProfileDirs)
|
|
93
|
+
$busy = $null
|
|
94
|
+
if ($declared.Count -gt 0) { $busy = Get-BusyUserDataDirs }
|
|
95
|
+
$targets = @(Get-Targets07 | Where-Object {
|
|
96
|
+
if ($null -ne $busy -and $declared -contains $_.Path -and $busy.Contains($_.Path)) { Write-Warn "$($_.Label) - skipped: a running browser holds this profile"; $false } else { $true }
|
|
97
|
+
})
|
|
98
|
+
$null = Show-TargetSizes $targets
|
|
99
|
+
if (-not (Confirm-Section 'Clear browser caches now?')) { Write-Info 'skipped'; return }
|
|
100
|
+
$null = Invoke-TargetList $targets
|
|
101
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
# data_scanner.ps1 - section 19 (P9 data scanner): large or old personal files. Downloads by default; folders a
|
|
2
|
+
# person adds with --personal-roots / personalRoots (owner, 2026-11-03). Discovery is read-only and lists each file
|
|
3
|
+
# once with its reason (large: >= --large-file-mb at any age; old: idle --days at any size; both), its age and the
|
|
4
|
+
# timestamps that age came from (D82). Removal stays interactive: exact paths only, never by --yes, Recycle Bin by
|
|
5
|
+
# default, and every file is re-checked right before it goes - one that changed since the scan, or whose path now
|
|
6
|
+
# runs through a link, is refused. Personal paths never leave the machine. ASCII only.
|
|
7
|
+
|
|
8
|
+
$Script:WS_DATA_SCAN_MAX_FILES = 200000 # walked per root before the scan stops and says so
|
|
9
|
+
$Script:WS_DATA_SCAN_MAX_ROWS = 500 # offered at once, largest first
|
|
10
|
+
|
|
11
|
+
function Test-PersonalRoot {
|
|
12
|
+
<# .SYNOPSIS Why a folder may not be a personal-data root, or $null when it may. #>
|
|
13
|
+
param([string] $Path)
|
|
14
|
+
$f = Get-FullPath $Path
|
|
15
|
+
if (-not $f) { return 'unresolvable path' }
|
|
16
|
+
if ($f -match '^[A-Za-z]:\\?$') { return 'a drive root' }
|
|
17
|
+
$info = Get-ItemInfo $f
|
|
18
|
+
if (-not ($info -is [IO.DirectoryInfo])) { return 'not an existing folder' }
|
|
19
|
+
if (Test-ReparsePoint $info) { return 'a link - add the folder it points to instead' }
|
|
20
|
+
$P = $Script:P
|
|
21
|
+
if ($f.Equals($P.U, [StringComparison]::OrdinalIgnoreCase)) { return 'the profile folder itself' }
|
|
22
|
+
foreach ($sys in @($P.SR, $P.PF, $P.PF86, $P.PD, (Join-Path $P.U 'AppData'))) {
|
|
23
|
+
if ($sys -and (Test-PathWithin -Path $f -Within $sys)) { return "inside a system, program or app-data folder ($sys)" }
|
|
24
|
+
}
|
|
25
|
+
return (Get-ProtectionReason $f)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function Get-PersonalDataRoots {
|
|
29
|
+
<# .SYNOPSIS Downloads plus the accepted --personal-roots, once each; every root's verdict is kept for --json. #>
|
|
30
|
+
$ws = $Script:WS
|
|
31
|
+
$report = @(); $out = @()
|
|
32
|
+
$cands = @()
|
|
33
|
+
$dl = Join-Path $Script:P.U 'Downloads'
|
|
34
|
+
if (Test-DirPresent $dl) { $cands += (Get-FullPath $dl) }
|
|
35
|
+
$cands += @($ws.PersonalRoots)
|
|
36
|
+
foreach ($c in $cands) {
|
|
37
|
+
$f = Get-FullPath $c
|
|
38
|
+
if (-not $f -or $out -contains $f -or @($report | Where-Object { $_.path -eq $f }).Count -gt 0) { continue }
|
|
39
|
+
$why = Test-PersonalRoot $f
|
|
40
|
+
$report += [ordered]@{ path = $f; accepted = (-not $why); reason = [string]$why }
|
|
41
|
+
if ($why) { Write-Warn "not scanned: $f ($why)" } else { $out += $f }
|
|
42
|
+
}
|
|
43
|
+
$ws.PersonalRootsReport = $report
|
|
44
|
+
return $out
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function Find-PersonalFiles {
|
|
48
|
+
<# .SYNOPSIS Read-only: files under the roots that are large (>= MinBytes, any age) or old (idle >= Days, any
|
|
49
|
+
size), each once. Rows carry Selectable = $false and SkipReason when the chokepoint would refuse the file. #>
|
|
50
|
+
param([string[]] $Roots, [long] $MinBytes, [int] $Days, [int] $MaxFiles = $Script:WS_DATA_SCAN_MAX_FILES)
|
|
51
|
+
$rows = New-Object System.Collections.Generic.List[object]
|
|
52
|
+
$now = [datetime]::UtcNow
|
|
53
|
+
foreach ($root in $Roots) {
|
|
54
|
+
$ignore = Test-LastAccessLive -Root $root
|
|
55
|
+
$basis = 'write+access'
|
|
56
|
+
if ($ignore) { $basis = 'write' }
|
|
57
|
+
$files = @(Get-FilesUnder -Root $root -MaxDepth 6 -MaxFiles $MaxFiles)
|
|
58
|
+
if ($files.Count -ge $MaxFiles) { Write-Note "stopped after $MaxFiles files under $root - add a narrower folder for the rest" }
|
|
59
|
+
foreach ($f in $files) {
|
|
60
|
+
$len = [long]$f.Length
|
|
61
|
+
$t = Get-NewestTimestampUtc $f -IgnoreAccess:$ignore
|
|
62
|
+
$age = [int][math]::Floor(($now - $t).TotalDays)
|
|
63
|
+
$large = ($len -ge $MinBytes); $old = ($age -ge $Days)
|
|
64
|
+
if (-not ($large -or $old)) { continue }
|
|
65
|
+
$reason = 'old'
|
|
66
|
+
if ($large -and $old) { $reason = 'both' } elseif ($large) { $reason = 'large' }
|
|
67
|
+
$path = Remove-LongPrefix $f.FullName
|
|
68
|
+
$why = Get-ProtectionReason $path
|
|
69
|
+
$rows.Add([pscustomobject]@{ Path = $path; Root = $root; Bytes = $len; Idle = $age; Reason = $reason; Basis = $basis
|
|
70
|
+
Modified = $t; Written = $f.LastWriteTimeUtc; Selectable = (-not $why); SkipReason = [string]$why })
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return $rows.ToArray()
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function Test-PersonalFileUnchanged {
|
|
77
|
+
<# .SYNOPSIS Right before removal: why the file may no longer go - gone, now a link, changed since the scan, or
|
|
78
|
+
reached through a folder that became a link - or $null when it is exactly what the scan saw. #>
|
|
79
|
+
param([pscustomobject] $Row)
|
|
80
|
+
$info = Get-ItemInfo $Row.Path
|
|
81
|
+
if (-not ($info -is [IO.FileInfo])) { return 'gone, or no longer a file' }
|
|
82
|
+
if (Test-ReparsePoint $info) { return 'it became a link' }
|
|
83
|
+
if ([long]$info.Length -ne [long]$Row.Bytes -or $info.LastWriteTimeUtc -ne $Row.Written) { return 'changed since the scan - scan again' }
|
|
84
|
+
$root = Get-FullPath $Row.Root
|
|
85
|
+
$rootInfo = Get-ItemInfo $root
|
|
86
|
+
if (-not ($rootInfo -is [IO.DirectoryInfo]) -or (Test-ReparsePoint $rootInfo)) { return 'its folder is gone or became a link' }
|
|
87
|
+
$dir = $info.Directory
|
|
88
|
+
while ($null -ne $dir -and -not (Get-FullPath $dir.FullName).Equals($root, [StringComparison]::OrdinalIgnoreCase)) {
|
|
89
|
+
if (Test-ReparsePoint $dir) { return 'a folder on its path became a link' }
|
|
90
|
+
$dir = $dir.Parent
|
|
91
|
+
}
|
|
92
|
+
if ($null -eq $dir) { return 'no longer inside its folder' }
|
|
93
|
+
return $null
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function Show-DataScanTable {
|
|
97
|
+
param([object[]] $Rows)
|
|
98
|
+
Write-UiLine (" {0,3} {1,10} {2,6} {3,-5} {4}" -f '#', 'SIZE', 'AGE', 'WHY', 'FILE') 'White'
|
|
99
|
+
$i = 1
|
|
100
|
+
foreach ($r in $Rows) { Write-UiLine (" {0,3} {1,10} {2,5}d {3,-5} {4}" -f $i, (Format-Bytes $r.Bytes), $r.Idle, $r.Reason, $r.Path) 'Gray'; $i++ }
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function Get-Targets19 {
|
|
104
|
+
$t = @()
|
|
105
|
+
foreach ($r in @(Join-Path $Script:P.U 'Downloads') + @($Script:WS.PersonalRoots)) {
|
|
106
|
+
if ($r -and (Test-DirPresent $r)) { $t += (New-Target 19 "Files >= $($Script:WS.LargeFileMb) MB, or idle $($Script:WS.Days)+ days" (Get-FullPath $r) -Kind cmd -Note 'moved to the Recycle Bin after you pick them') }
|
|
107
|
+
}
|
|
108
|
+
return $t
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function Invoke-Section19 {
|
|
112
|
+
$ws = $Script:WS
|
|
113
|
+
Write-SectionIntro @(
|
|
114
|
+
"Files in Downloads and the folders you added that are large ($($ws.LargeFileMb) MB or more, any age) or old (untouched for",
|
|
115
|
+
"$($ws.Days)+ days, any size). Large and old describe the file, not whether you still need it. You pick what goes;",
|
|
116
|
+
'it lands in the Recycle Bin, and a file that changed since this list was made is refused.'
|
|
117
|
+
)
|
|
118
|
+
$roots = @(Get-PersonalDataRoots)
|
|
119
|
+
if ($roots.Count -eq 0) { Write-Info 'no folder to scan (no Downloads folder, and no accepted --personal-roots)'; return }
|
|
120
|
+
$all = @(Find-PersonalFiles -Roots $roots -MinBytes ([long]$ws.LargeFileMb * 1MB) -Days $ws.Days)
|
|
121
|
+
foreach ($k in @($all | Where-Object { -not $_.Selectable })) {
|
|
122
|
+
Write-Note ("kept: {0} ({1})" -f $k.Path, $k.SkipReason)
|
|
123
|
+
Add-JsonCandidate -Section 19 -Index 0 -Path $k.Path -Bytes $k.Bytes -IdleDays $k.Idle -Extra ([ordered]@{ reason = $k.Reason; age_basis = $k.Basis; modified = $k.Modified.ToString('o'); root = $k.Root; selectable = $false; skip_reason = $k.SkipReason })
|
|
124
|
+
}
|
|
125
|
+
$offer = @($all | Where-Object { $_.Selectable } | Sort-Object Bytes -Descending)
|
|
126
|
+
if ($offer.Count -gt $Script:WS_DATA_SCAN_MAX_ROWS) { Write-Note ("{0} more matching files are not listed (the smallest) - raise --large-file-mb or --days to see others" -f ($offer.Count - $Script:WS_DATA_SCAN_MAX_ROWS)); $offer = @($offer | Select-Object -First $Script:WS_DATA_SCAN_MAX_ROWS) }
|
|
127
|
+
if ($offer.Count -eq 0) { Write-Info 'no large or old file found'; return }
|
|
128
|
+
Show-DataScanTable $offer
|
|
129
|
+
$i = 1; $total = [long]0
|
|
130
|
+
foreach ($r in $offer) {
|
|
131
|
+
Add-JsonCandidate -Section 19 -Index $i -Path $r.Path -Bytes $r.Bytes -IdleDays $r.Idle -Extra ([ordered]@{ reason = $r.Reason; age_basis = $r.Basis; modified = $r.Modified.ToString('o'); root = $r.Root; selectable = $true })
|
|
132
|
+
$total += $r.Bytes; $i++
|
|
133
|
+
}
|
|
134
|
+
Write-Info ('total: ' + (Format-Bytes $total))
|
|
135
|
+
$dest = 'the Recycle Bin'
|
|
136
|
+
if ($ws.Permanent) { Write-Note '--permanent does not apply to personal files in section 19' }
|
|
137
|
+
Write-Note "selected files go to $dest"
|
|
138
|
+
$picks = @(Read-MultiSelect -Total $offer.Count -NoAutoYes -Candidates @($offer | ForEach-Object { $_.Path }))
|
|
139
|
+
if ($picks.Count -eq 0) { Write-Info 'nothing selected'; return }
|
|
140
|
+
if (-not $ws.DryRun) {
|
|
141
|
+
if (-not (Confirm-Ui -Prompt "Move the selected files to $dest ($($picks.Count) file(s))?" -Default 'n' -NoAutoYes -ScriptedOk)) { Write-Info 'skipped'; return }
|
|
142
|
+
}
|
|
143
|
+
foreach ($k in $picks) {
|
|
144
|
+
$row = $offer[$k - 1]
|
|
145
|
+
$changed = Test-PersonalFileUnchanged $row
|
|
146
|
+
if ($changed) { Write-Warn "kept $($row.Path) - $changed"; continue }
|
|
147
|
+
$null = Send-ToRecycleBin -Path $row.Path -Within $row.Root -AlwaysRecycle
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function Show-DataScan {
|
|
152
|
+
<# .SYNOPSIS Read-only listing of section 19 for --scan. #>
|
|
153
|
+
$ws = $Script:WS
|
|
154
|
+
Write-Section "Files >= $($ws.LargeFileMb) MB, or idle $($ws.Days)+ days, in Downloads and your added folders"
|
|
155
|
+
$roots = @(Get-PersonalDataRoots)
|
|
156
|
+
$rows = @(Find-PersonalFiles -Roots $roots -MinBytes ([long]$ws.LargeFileMb * 1MB) -Days $ws.Days | Where-Object { $_.Selectable } | Sort-Object Bytes -Descending)
|
|
157
|
+
if ($rows.Count -eq 0) { Write-Info 'none found' } else { Show-DataScanTable @($rows | Select-Object -First 30) }
|
|
158
|
+
}
|
|
@@ -58,11 +58,12 @@ function Get-GlobalPackages {
|
|
|
58
58
|
<# .SYNOPSIS Packages under one global root. Reads each package.json (top level only) - never walks a global tree. #>
|
|
59
59
|
param([pscustomobject] $RootRow)
|
|
60
60
|
$rows = @()
|
|
61
|
+
$ignore = Test-LastAccessLive -Root $RootRow.Root
|
|
61
62
|
if ($RootRow.Style -eq 'bin') {
|
|
62
63
|
foreach ($e in (Get-ChildEntries $RootRow.Root)) {
|
|
63
64
|
if ($e -is [IO.DirectoryInfo]) { continue }
|
|
64
65
|
if ($e.Extension -eq '.cmd') { continue }
|
|
65
|
-
$idle = [int][math]::Floor(([datetime]::UtcNow - (Get-NewestTimestampUtc $e)).TotalDays)
|
|
66
|
+
$idle = [int][math]::Floor(([datetime]::UtcNow - (Get-NewestTimestampUtc $e -IgnoreAccess:$ignore)).TotalDays)
|
|
66
67
|
$rows += [pscustomobject]@{ Manager = $RootRow.Manager; Name = [IO.Path]::GetFileNameWithoutExtension($e.Name)
|
|
67
68
|
Version = ''; Path = (Remove-LongPrefix $e.FullName); Bytes = [long]$e.Length; Idle = $idle
|
|
68
69
|
Bins = @([IO.Path]::GetFileNameWithoutExtension($e.Name)) }
|
|
@@ -89,7 +90,7 @@ function Get-GlobalPackages {
|
|
|
89
90
|
if ($manifest -and $manifest.version) { $version = [string]$manifest.version }
|
|
90
91
|
# The newest of the package's own top-level entries: a global node_modules is far too big to walk.
|
|
91
92
|
$newest = [datetime]::MinValue
|
|
92
|
-
foreach ($c in (Get-ChildEntries $d.Dir)) { $t = Get-NewestTimestampUtc $c; if ($t -gt $newest) { $newest = $t } }
|
|
93
|
+
foreach ($c in (Get-ChildEntries $d.Dir)) { $t = Get-NewestTimestampUtc $c -IgnoreAccess:$ignore; if ($t -gt $newest) { $newest = $t } }
|
|
93
94
|
if ($newest -eq [datetime]::MinValue) { $newest = [datetime]::UtcNow }
|
|
94
95
|
$rows += [pscustomobject]@{ Manager = $RootRow.Manager; Name = $d.Name; Version = $version; Path = $d.Dir
|
|
95
96
|
Bytes = [long](Get-DirectoryBytes $d.Dir); Idle = [int][math]::Floor(([datetime]::UtcNow - $newest).TotalDays)
|
|
@@ -107,6 +108,7 @@ function Get-ProjectPackageIndex {
|
|
|
107
108
|
$script = New-Object System.Text.StringBuilder
|
|
108
109
|
$seen = 0
|
|
109
110
|
foreach ($root in $Roots) {
|
|
111
|
+
$ignore = Test-LastAccessLive -Root $root
|
|
110
112
|
$stack = New-Object System.Collections.Generic.Stack[object]
|
|
111
113
|
$stack.Push(@($root, 0))
|
|
112
114
|
while ($stack.Count -gt 0 -and $seen -lt $MaxFiles) {
|
|
@@ -125,7 +127,7 @@ function Get-ProjectPackageIndex {
|
|
|
125
127
|
}
|
|
126
128
|
if ($e.Name -ne 'package.json') { continue }
|
|
127
129
|
$seen++
|
|
128
|
-
if ([int][math]::Floor(([datetime]::UtcNow - (Get-NewestTimestampUtc $e)).TotalDays) -ge $Days) { continue }
|
|
130
|
+
if ([int][math]::Floor(([datetime]::UtcNow - (Get-NewestTimestampUtc $e -IgnoreAccess:$ignore)).TotalDays) -ge $Days) { continue }
|
|
129
131
|
$m = $null
|
|
130
132
|
try { $m = @(Read-JsonFile (Remove-LongPrefix $e.FullName))[0] } catch { $m = $null }
|
|
131
133
|
if ($null -eq $m) { continue }
|
|
@@ -51,6 +51,65 @@ function Get-InstallLocationIdleDays {
|
|
|
51
51
|
return [int][math]::Floor(([datetime]::UtcNow - $newest).TotalDays)
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
+
function ConvertFrom-PrefetchName {
|
|
55
|
+
<# .SYNOPSIS "CHROME.EXE-1A2B3C4D.pf" -> "chrome.exe"; '' for anything else. #>
|
|
56
|
+
param([string] $Name)
|
|
57
|
+
if ($Name -match '^(.+\.exe)-[0-9A-Fa-f]{8}\.pf$') { return $Matches[1].ToLowerInvariant() }
|
|
58
|
+
return ''
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function Get-PrefetchLastRun {
|
|
62
|
+
<# .SYNOPSIS P9: program file name -> newest run (UTC) from Windows\Prefetch, which Windows rewrites on every
|
|
63
|
+
run. Readable only when elevated; empty otherwise or when prefetch is off. #>
|
|
64
|
+
$map = @{}
|
|
65
|
+
if (-not $Script:WS.IsAdmin) { return $map }
|
|
66
|
+
foreach ($e in (Get-ChildEntries (Join-Path $Script:P.SR 'Prefetch'))) {
|
|
67
|
+
if (-not ($e -is [IO.FileInfo])) { continue }
|
|
68
|
+
$exe = ConvertFrom-PrefetchName $e.Name
|
|
69
|
+
if ($exe -and (-not $map.ContainsKey($exe) -or $e.LastWriteTimeUtc -gt $map[$exe])) { $map[$exe] = $e.LastWriteTimeUtc }
|
|
70
|
+
}
|
|
71
|
+
return $map
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function Get-InstallLocationExeNames {
|
|
75
|
+
<# .SYNOPSIS Program file names (lower case) at depth <= 2 under an install folder, never through a junction. #>
|
|
76
|
+
param([string] $Location, [int] $MaxEntries = 4000)
|
|
77
|
+
$names = @()
|
|
78
|
+
if ([string]::IsNullOrWhiteSpace($Location)) { return $names }
|
|
79
|
+
$root = Get-FullPath ($Location.Trim().Trim('"').TrimEnd('\'))
|
|
80
|
+
if (-not $root -or -not (Test-DirPresent $root)) { return $names }
|
|
81
|
+
$stack = New-Object System.Collections.Generic.Stack[object]
|
|
82
|
+
$stack.Push(@($root, 0))
|
|
83
|
+
$seen = 0
|
|
84
|
+
while ($stack.Count -gt 0 -and $seen -lt $MaxEntries) {
|
|
85
|
+
$item = $stack.Pop()
|
|
86
|
+
foreach ($e in (Get-ChildEntries ([string]$item[0]))) {
|
|
87
|
+
$seen++
|
|
88
|
+
if (Test-ReparsePoint $e) { continue }
|
|
89
|
+
if ($e -is [IO.DirectoryInfo]) { if ([int]$item[1] -lt 2) { $stack.Push(@((Remove-LongPrefix $e.FullName), ([int]$item[1] + 1))) }; continue }
|
|
90
|
+
if ($e.Name -like '*.exe') { $names += $e.Name.ToLowerInvariant() }
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return $names
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
# Prefetch keys a run by file name only, so a name many programs ship says nothing about this one: Steam's
|
|
97
|
+
# uninstall.exe running made FileZilla (which also ships uninstall.exe) look "ran today" (P52, 2026-11-03).
|
|
98
|
+
$Script:WS_PREFETCH_GENERIC = '^(unins\d*|uninst.*|uninstall.*|update.*|updater.*|setup.*|install.*|crash.*|elevate.*|helper.*|maintenancetool|au_|launcher|bootstrap.*|vc_redist.*|notification_helper|squirrel)\.exe$'
|
|
99
|
+
|
|
100
|
+
function Get-LastRunDays {
|
|
101
|
+
<# .SYNOPSIS Pure: days since the newest Prefetch run of any of these program files - generic names (uninstall.exe,
|
|
102
|
+
update.exe, setup.exe, crash handlers) never count - or -1 when none ran. #>
|
|
103
|
+
param([string[]] $ExeNames, [hashtable] $Prefetch, [datetime] $NowUtc = [datetime]::UtcNow)
|
|
104
|
+
$best = [datetime]::MinValue
|
|
105
|
+
foreach ($n in @($ExeNames)) {
|
|
106
|
+
if ($n -match $Script:WS_PREFETCH_GENERIC) { continue }
|
|
107
|
+
if ($Prefetch.ContainsKey($n) -and $Prefetch[$n] -gt $best) { $best = $Prefetch[$n] }
|
|
108
|
+
}
|
|
109
|
+
if ($best -eq [datetime]::MinValue) { return -1 }
|
|
110
|
+
return [int][math]::Floor(($NowUtc - $best).TotalDays)
|
|
111
|
+
}
|
|
112
|
+
|
|
54
113
|
function Get-ProgramRemovalHint {
|
|
55
114
|
<# .SYNOPSIS The command that removes a program: winget when it knows it, otherwise its own UninstallString. #>
|
|
56
115
|
param([pscustomobject] $Row, [hashtable] $WingetMap)
|
|
@@ -86,11 +145,19 @@ function Invoke-Section24 {
|
|
|
86
145
|
if ($scored.Count -eq 0) {
|
|
87
146
|
Write-Info "no installed program has been untouched for $($ws.Days)+ days"
|
|
88
147
|
} else {
|
|
89
|
-
|
|
148
|
+
$prefetch = Get-PrefetchLastRun
|
|
149
|
+
if ($prefetch.Count -gt 0) { Write-Note 'RAN = days since Windows Prefetch last saw one of its program files run; "none" means no run is on record (Prefetch keeps about 1,024 programs)' }
|
|
150
|
+
else { Write-Note 'RAN needs an elevated run (Windows Prefetch): windowsweep --only 24 --elevate' }
|
|
151
|
+
Write-UiLine (" {0,-44} {1,-14} {2,10} {3,6} {4,6} {5}" -f 'PROGRAM', 'VERSION', 'SIZE', 'IDLE', 'RAN', 'PUBLISHER') 'White'
|
|
90
152
|
foreach ($s in ($scored | Sort-Object Bytes -Descending)) {
|
|
91
153
|
$size = 'unknown'
|
|
92
154
|
if ($s.Bytes -gt 0) { $size = Format-Bytes $s.Bytes }
|
|
93
|
-
$
|
|
155
|
+
$ran = '-'
|
|
156
|
+
if ($prefetch.Count -gt 0) {
|
|
157
|
+
$d = Get-LastRunDays -ExeNames @(Get-InstallLocationExeNames -Location $s.Row.InstallLocation) -Prefetch $prefetch
|
|
158
|
+
if ($d -ge 0) { $ran = "${d}d" } else { $ran = 'none' }
|
|
159
|
+
}
|
|
160
|
+
$line = " {0,-44} {1,-14} {2,10} {3,5}d {4,6} {5}" -f $s.Row.DisplayName, $s.Row.DisplayVersion, $size, $s.Idle, $ran, $s.Row.Publisher
|
|
94
161
|
Write-UiLine $line 'Gray'
|
|
95
162
|
$lines += $line.Trim()
|
|
96
163
|
$lines += (' remove with: ' + (Get-ProgramRemovalHint -Row $s.Row -WingetMap $winget))
|