windowsweep 1.3.2 → 1.4.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 +8 -8
- package/CHANGELOG.md +80 -0
- package/README.md +27 -28
- 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
package/lib/fs.ps1
CHANGED
|
@@ -61,14 +61,110 @@ function Get-ChildEntries {
|
|
|
61
61
|
}
|
|
62
62
|
|
|
63
63
|
function Get-NewestTimestampUtc {
|
|
64
|
-
<# .SYNOPSIS The most recent of write / access / creation time - the safe "last touched" estimate when access tracking is off.
|
|
65
|
-
|
|
64
|
+
<# .SYNOPSIS The most recent of write / access / creation time - the safe "last touched" estimate when access tracking is off.
|
|
65
|
+
-IgnoreAccess drops last-access (D82: used where Test-LastAccessLive found it carries no signal). #>
|
|
66
|
+
param([System.IO.FileSystemInfo] $Info, [switch] $IgnoreAccess)
|
|
66
67
|
$t = $Info.LastWriteTimeUtc
|
|
67
|
-
try { if ($Info.LastAccessTimeUtc -gt $t) { $t = $Info.LastAccessTimeUtc } } catch { $null = $_ }
|
|
68
|
+
if (-not $IgnoreAccess) { try { if ($Info.LastAccessTimeUtc -gt $t) { $t = $Info.LastAccessTimeUtc } } catch { $null = $_ } }
|
|
68
69
|
try { if ($Info.CreationTimeUtc -gt $t) { $t = $Info.CreationTimeUtc } } catch { $null = $_ }
|
|
69
70
|
return $t
|
|
70
71
|
}
|
|
71
72
|
|
|
73
|
+
# D82 (owner, 2026-11-02): last-access can be live - Windows updating it, or an indexer, backup, antivirus or disk
|
|
74
|
+
# scanner reading every file - and then every file looks touched today, so an idle gate deletes nothing (the P52:
|
|
75
|
+
# a 30-day developer run found 1.5 GB of ~37 GB). Each root is sampled once. When at least WS_ACCESS_SAMPLE_MIN
|
|
76
|
+
# files written or created a week or more ago were nearly all "read" within the last day, last-access says
|
|
77
|
+
# nothing there and idle age uses write/creation time for that root. A root too small to judge inherits a live
|
|
78
|
+
# verdict already reached on its volume; otherwise it keeps the conservative newest-of-three.
|
|
79
|
+
# The sample walks on until it has WS_ACCESS_SAMPLE_MAX old files or has seen WS_ACCESS_SCAN_CAP files: on the
|
|
80
|
+
# P52 (2026-11-03) %TEMP% held 79,202 files, its 14,041 old ones sat deep under node-compile-cache and all were
|
|
81
|
+
# read in one hour by a scanner, yet the first 5,000 entries held too few old files to judge - so %TEMP% inherited,
|
|
82
|
+
# and kept its old files whenever no other root had set the volume's verdict first in that run. A cache with
|
|
83
|
+
# plenty of old files still stops after 400 of them; only a root of mostly new files walks further.
|
|
84
|
+
$Script:WS_ACCESS_LIVE = @{}
|
|
85
|
+
$Script:WS_ACCESS_LIVE_VOLUME = @{}
|
|
86
|
+
$Script:WS_ACCESS_SAMPLE_MIN = 20
|
|
87
|
+
$Script:WS_ACCESS_SAMPLE_MAX = 400
|
|
88
|
+
$Script:WS_ACCESS_SCAN_CAP = 50000
|
|
89
|
+
$Script:WS_ACCESS_LIVE_RATIO = 0.9
|
|
90
|
+
|
|
91
|
+
function Test-AccessSampleLive {
|
|
92
|
+
<# .SYNOPSIS The D82 verdict for a counted sample: enough old files, and nearly all of them read in the last day. #>
|
|
93
|
+
param([int] $Old, [int] $FreshAccess)
|
|
94
|
+
if ($Old -lt $Script:WS_ACCESS_SAMPLE_MIN) { return $false }
|
|
95
|
+
return (($FreshAccess / [double]$Old) -ge $Script:WS_ACCESS_LIVE_RATIO)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function Get-VolumeKey {
|
|
99
|
+
<# .SYNOPSIS Lower-case volume root of a path ("c:\"), or '' when it cannot be resolved. #>
|
|
100
|
+
param([string] $Path)
|
|
101
|
+
$full = Get-FullPath $Path
|
|
102
|
+
if (-not $full) { return '' }
|
|
103
|
+
try { return ([IO.Path]::GetPathRoot($full)).ToLowerInvariant() } catch { return '' }
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function Get-AccessLiveVolumes {
|
|
107
|
+
<# .SYNOPSIS Volumes where a D82 sample found last-access live this run ("C:", "D:") - the --json summary names them. #>
|
|
108
|
+
return @($Script:WS_ACCESS_LIVE_VOLUME.Keys | ForEach-Object { ([string]$_).TrimEnd('\').ToUpperInvariant() } | Sort-Object)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function Reset-AccessVerdicts {
|
|
112
|
+
<# .SYNOPSIS Forget every D82 verdict (the self-test isolates its fixtures with this). #>
|
|
113
|
+
$Script:WS_ACCESS_LIVE = @{}
|
|
114
|
+
$Script:WS_ACCESS_LIVE_VOLUME = @{}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function Test-LastAccessLive {
|
|
118
|
+
<# .SYNOPSIS $true when last-access carries no signal under Root (D82). Judged once per root from a bounded,
|
|
119
|
+
breadth-first sample that never follows reparse points; the answer is cached for the run. #>
|
|
120
|
+
param([string] $Root)
|
|
121
|
+
$full = Get-FullPath $Root
|
|
122
|
+
if (-not $full) { return $false }
|
|
123
|
+
$key = $full.ToLowerInvariant()
|
|
124
|
+
if ($Script:WS_ACCESS_LIVE.ContainsKey($key)) { return [bool]$Script:WS_ACCESS_LIVE[$key] }
|
|
125
|
+
$now = [datetime]::UtcNow
|
|
126
|
+
$oldCut = $now.AddDays(-7); $freshCut = $now.AddDays(-1)
|
|
127
|
+
$old = 0; $fresh = 0; $seen = 0
|
|
128
|
+
$rootInfo = Get-ItemInfo $full
|
|
129
|
+
if ($rootInfo -is [IO.DirectoryInfo] -and -not (Test-ReparsePoint $rootInfo)) {
|
|
130
|
+
$queue = New-Object System.Collections.Generic.Queue[System.IO.DirectoryInfo]
|
|
131
|
+
$queue.Enqueue($rootInfo)
|
|
132
|
+
while ($queue.Count -gt 0 -and $old -lt $Script:WS_ACCESS_SAMPLE_MAX -and $seen -lt $Script:WS_ACCESS_SCAN_CAP) {
|
|
133
|
+
$dir = $queue.Dequeue()
|
|
134
|
+
$entries = $null
|
|
135
|
+
try { $entries = $dir.EnumerateFileSystemInfos() } catch { continue }
|
|
136
|
+
try {
|
|
137
|
+
foreach ($e in $entries) {
|
|
138
|
+
if (($e.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { continue }
|
|
139
|
+
if (($e.Attributes -band [IO.FileAttributes]::Directory) -ne 0) { $queue.Enqueue([IO.DirectoryInfo]$e); continue }
|
|
140
|
+
$seen++
|
|
141
|
+
if ((Get-NewestTimestampUtc $e -IgnoreAccess) -le $oldCut) {
|
|
142
|
+
$old++
|
|
143
|
+
if ($e.LastAccessTimeUtc -ge $freshCut) { $fresh++ }
|
|
144
|
+
}
|
|
145
|
+
if ($old -ge $Script:WS_ACCESS_SAMPLE_MAX -or $seen -ge $Script:WS_ACCESS_SCAN_CAP) { break }
|
|
146
|
+
}
|
|
147
|
+
} catch { $null = $_ }
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
if ($old -ge $Script:WS_ACCESS_SAMPLE_MIN) {
|
|
151
|
+
$live = Test-AccessSampleLive -Old $old -FreshAccess $fresh
|
|
152
|
+
$Script:WS_ACCESS_LIVE[$key] = $live
|
|
153
|
+
$vol = Get-VolumeKey $full
|
|
154
|
+
if ($live -and $vol -and -not $Script:WS_ACCESS_LIVE_VOLUME.ContainsKey($vol)) {
|
|
155
|
+
$Script:WS_ACCESS_LIVE_VOLUME[$vol] = $true
|
|
156
|
+
$msg = "last-access looks live on {0} ({1} of {2} files written a week or more ago were read in the last day) - idle age there uses write/creation time" -f $vol.TrimEnd('\').ToUpperInvariant(), $fresh, $old
|
|
157
|
+
Write-Note $msg
|
|
158
|
+
Write-LogLine "idle basis: $msg (first seen under $full)"
|
|
159
|
+
}
|
|
160
|
+
return $live
|
|
161
|
+
}
|
|
162
|
+
$vol = Get-VolumeKey $full
|
|
163
|
+
$inherit = [bool]($vol -and $Script:WS_ACCESS_LIVE_VOLUME.ContainsKey($vol))
|
|
164
|
+
$Script:WS_ACCESS_LIVE[$key] = $inherit
|
|
165
|
+
return $inherit
|
|
166
|
+
}
|
|
167
|
+
|
|
72
168
|
function Format-Utc8601 {
|
|
73
169
|
<# .SYNOPSIS ISO 8601 UTC for a timestamp, or $null when nothing was found (the MinValue sentinel).
|
|
74
170
|
Get-DirectoryStats returns MinValue for an empty or unreadable tree, and "0001-01-01" in a contract
|
|
@@ -79,17 +175,18 @@ function Format-Utc8601 {
|
|
|
79
175
|
}
|
|
80
176
|
|
|
81
177
|
function Get-DirectoryStats {
|
|
82
|
-
<# .SYNOPSIS Walk a tree without following reparse points. Returns Bytes, Files, Dirs, Links, Newest (UTC), Errors.
|
|
83
|
-
|
|
178
|
+
<# .SYNOPSIS Walk a tree without following reparse points. Returns Bytes, Files, Dirs, Links, Newest (UTC), Errors.
|
|
179
|
+
-IgnoreAccess leaves last-access out of Newest (D82). #>
|
|
180
|
+
param([string] $Path, [switch] $IgnoreAccess)
|
|
84
181
|
$bytes = [long]0; $files = 0; $dirs = 0; $links = 0; $errors = 0
|
|
85
182
|
$newest = [datetime]::MinValue
|
|
86
183
|
$root = Get-ItemInfo $Path
|
|
87
184
|
if ($null -eq $root) { return [pscustomobject]@{ Bytes = 0; Files = 0; Dirs = 0; Links = 0; Newest = $newest; Errors = 0; Exists = $false } }
|
|
88
185
|
if ($root -is [IO.FileInfo]) {
|
|
89
|
-
return [pscustomobject]@{ Bytes = [long]$root.Length; Files = 1; Dirs = 0; Links = 0; Newest = (Get-NewestTimestampUtc $root); Errors = 0; Exists = $true }
|
|
186
|
+
return [pscustomobject]@{ Bytes = [long]$root.Length; Files = 1; Dirs = 0; Links = 0; Newest = (Get-NewestTimestampUtc $root -IgnoreAccess:$IgnoreAccess); Errors = 0; Exists = $true }
|
|
90
187
|
}
|
|
91
188
|
if (Test-ReparsePoint $root) {
|
|
92
|
-
return [pscustomobject]@{ Bytes = 0; Files = 0; Dirs = 0; Links = 1; Newest = (Get-NewestTimestampUtc $root); Errors = 0; Exists = $true }
|
|
189
|
+
return [pscustomobject]@{ Bytes = 0; Files = 0; Dirs = 0; Links = 1; Newest = (Get-NewestTimestampUtc $root -IgnoreAccess:$IgnoreAccess); Errors = 0; Exists = $true }
|
|
93
190
|
}
|
|
94
191
|
$stack = New-Object System.Collections.Generic.Stack[System.IO.DirectoryInfo]
|
|
95
192
|
$stack.Push($root)
|
|
@@ -104,13 +201,13 @@ function Get-DirectoryStats {
|
|
|
104
201
|
$files++
|
|
105
202
|
$bytes += [long]$e.Length
|
|
106
203
|
$t = $e.LastWriteTimeUtc
|
|
107
|
-
if ($e.LastAccessTimeUtc -gt $t) { $t = $e.LastAccessTimeUtc }
|
|
204
|
+
if (-not $IgnoreAccess -and $e.LastAccessTimeUtc -gt $t) { $t = $e.LastAccessTimeUtc }
|
|
108
205
|
if ($e.CreationTimeUtc -gt $t) { $t = $e.CreationTimeUtc }
|
|
109
206
|
if ($t -gt $newest) { $newest = $t }
|
|
110
207
|
}
|
|
111
208
|
} catch { $errors++ }
|
|
112
209
|
}
|
|
113
|
-
if ($newest -eq [datetime]::MinValue) { $newest = Get-NewestTimestampUtc $root }
|
|
210
|
+
if ($newest -eq [datetime]::MinValue) { $newest = Get-NewestTimestampUtc $root -IgnoreAccess:$IgnoreAccess }
|
|
114
211
|
return [pscustomobject]@{ Bytes = $bytes; Files = $files; Dirs = $dirs; Links = $links; Newest = $newest; Errors = $errors; Exists = $true }
|
|
115
212
|
}
|
|
116
213
|
|
|
@@ -134,14 +231,18 @@ function Get-DirectoryBytes {
|
|
|
134
231
|
}
|
|
135
232
|
|
|
136
233
|
function Get-IdleDays {
|
|
137
|
-
<# .SYNOPSIS Days since the path (or anything inside it) was last touched. 999999 when absent.
|
|
138
|
-
|
|
234
|
+
<# .SYNOPSIS Days since the path (or anything inside it) was last touched. 999999 when absent.
|
|
235
|
+
Last-access counts unless Test-LastAccessLive found it carries no signal here (D82). #>
|
|
236
|
+
# -WriteOnly (1.4.0): write/creation time only - for declared scratch roots, where a scanner's partial read
|
|
237
|
+
# (67-85% of a month-old test profile's files on the P52, 2026-11-03) must not count as use.
|
|
238
|
+
param([string] $Path, [switch] $WriteOnly)
|
|
139
239
|
$info = Get-ItemInfo $Path
|
|
140
240
|
if ($null -eq $info) { return 999999 }
|
|
241
|
+
$ignore = $WriteOnly -or (Test-LastAccessLive -Root $Path)
|
|
141
242
|
if ($info -is [IO.FileInfo] -or (Test-ReparsePoint $info)) {
|
|
142
|
-
return [int][math]::Floor(([datetime]::UtcNow - (Get-NewestTimestampUtc $info)).TotalDays)
|
|
243
|
+
return [int][math]::Floor(([datetime]::UtcNow - (Get-NewestTimestampUtc $info -IgnoreAccess:$ignore)).TotalDays)
|
|
143
244
|
}
|
|
144
|
-
$stats = Get-DirectoryStats $Path
|
|
245
|
+
$stats = Get-DirectoryStats $Path -IgnoreAccess:$ignore
|
|
145
246
|
return [int][math]::Floor(([datetime]::UtcNow - $stats.Newest).TotalDays)
|
|
146
247
|
}
|
|
147
248
|
|
|
@@ -156,6 +257,7 @@ function Get-StaleFiles {
|
|
|
156
257
|
if ($null -eq $rootInfo -or -not ($rootInfo -is [IO.DirectoryInfo]) -or (Test-ReparsePoint $rootInfo)) {
|
|
157
258
|
return [pscustomobject]@{ Files = $stale; Dirs = $visited }
|
|
158
259
|
}
|
|
260
|
+
$ignore = Test-LastAccessLive -Root $Root
|
|
159
261
|
$stack = New-Object System.Collections.Generic.Stack[System.IO.DirectoryInfo]
|
|
160
262
|
$stack.Push($rootInfo)
|
|
161
263
|
while ($stack.Count -gt 0) {
|
|
@@ -171,7 +273,7 @@ function Get-StaleFiles {
|
|
|
171
273
|
continue
|
|
172
274
|
}
|
|
173
275
|
$t = $e.LastWriteTimeUtc
|
|
174
|
-
if ($e.LastAccessTimeUtc -gt $t) { $t = $e.LastAccessTimeUtc }
|
|
276
|
+
if (-not $ignore -and $e.LastAccessTimeUtc -gt $t) { $t = $e.LastAccessTimeUtc }
|
|
175
277
|
if ($e.CreationTimeUtc -gt $t) { $t = $e.CreationTimeUtc }
|
|
176
278
|
if ($t -le $cutoff) { $stale.Add([pscustomobject]@{ Path = $e.FullName; Bytes = [long]$e.Length }) }
|
|
177
279
|
}
|
package/lib/safety.ps1
CHANGED
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
# safety.ps1 - the deletion chokepoint and its guards.
|
|
2
2
|
#
|
|
3
|
-
# Every
|
|
4
|
-
# 1.
|
|
5
|
-
# 2. refuses every protected subtree / pattern / basename
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
3
|
+
# Every path the tool deletes is first checked by Get-ProtectionReason, which:
|
|
4
|
+
# 1. refuses relative segments, UNC paths, drive roots and system roots;
|
|
5
|
+
# 2. refuses every protected subtree / pattern / basename - except an exception, an exact folder inside a
|
|
6
|
+
# protected subtree that a section may remove (declared below, or computed for the run: D79);
|
|
7
|
+
# 3. refuses the user's own --exclude-path / excludePaths entries, exceptions included.
|
|
8
|
+
# The deleting functions: Remove-PathSafe (one file, folder or link, strictly inside a declared -Within root),
|
|
9
|
+
# Send-ToRecycleBin, Remove-StaleFiles (each idle file after the same check) and Clear-DirectoryContents (each
|
|
10
|
+
# child through Remove-PathSafe); Remove-TreeInternal removes the children of a folder that passed the check.
|
|
11
|
+
# All of them honour --dry-run, remove reparse points as links without following them, and skip locked files.
|
|
12
|
+
# Tools that delete on their own (docker, cleanmgr, DISM, wevtutil, powercfg, sdkmanager, nvm) run through
|
|
13
|
+
# Invoke-External -Destructive, which --dry-run turns into a printed command. No flag lifts steps 1 and 2.
|
|
10
14
|
|
|
11
15
|
function Initialize-Safety {
|
|
12
16
|
<# .SYNOPSIS Build the protected-path tables from the environment. Called once at startup. #>
|
|
@@ -22,7 +26,10 @@ function Initialize-Safety {
|
|
|
22
26
|
|
|
23
27
|
$exact = New-Object System.Collections.Generic.HashSet[string]([StringComparer]::OrdinalIgnoreCase)
|
|
24
28
|
foreach ($d in [IO.DriveInfo]::GetDrives()) { $null = $exact.Add($d.Name.TrimEnd('\')); $null = $exact.Add($d.Name) }
|
|
25
|
-
|
|
29
|
+
# 1.4.0: the Common Files and ProgramData\Microsoft roots - section 26 reads InstallLocation from the registry,
|
|
30
|
+
# and an installer that registered one of these as its own folder must never get it offered as a leftover.
|
|
31
|
+
foreach ($p in @($SR, "$SR\System32", "$SR\SysWOW64", $PF, $PF86, $PD, "$SD\Users", "$SD\Users\Default", "$SD\Users\Public", $env:PUBLIC, $U, "$U\AppData", $A, $L, $LL,
|
|
32
|
+
"$PF\Common Files", "$PF86\Common Files", "$PD\Microsoft")) {
|
|
26
33
|
if (-not $p) { continue }
|
|
27
34
|
$fp = Get-FullPath $p
|
|
28
35
|
if ($fp) { $null = $exact.Add($fp) }
|
|
@@ -37,12 +44,19 @@ function Initialize-Safety {
|
|
|
37
44
|
"$A\npm", "$A\nvm", "$L\nvm", "$SD\nvm4w", "$L\Volta", "$L\fnm", "$L\fnm_multishells", "$L\node\corepack",
|
|
38
45
|
"$L\pnpm\global", "$U\.bun\bin", "$U\.bun\install\global", "$U\.deno\bin", "$U\.cargo\bin", "$U\.rustup", "$U\go\bin",
|
|
39
46
|
"$L\Programs", "$L\Microsoft\WindowsApps", "$L\Android\Sdk",
|
|
47
|
+
# The desktop app's own install and data folders (section 23 offered the data folder on the P52, 2026-11-02).
|
|
48
|
+
"$L\windowsweep", "$L\com.aoneahsan.windowsweep",
|
|
40
49
|
"$SR\Prefetch", "$SR\Installer", "$SR\WinSxS", "$SR\System32\config", "$SR\servicing", "$SR\Boot", "$SR\Fonts",
|
|
41
50
|
"$PD\Package Cache", "$PD\Microsoft\Windows\Start Menu",
|
|
42
51
|
"$SD\`$Recycle.Bin", "$SD\System Volume Information", "$SD\Recovery", "$SD\Boot", "$SD\EFI"
|
|
43
52
|
)
|
|
44
53
|
# Declared exceptions: paths inside a protected subtree that ARE regenerable caches.
|
|
45
54
|
$exceptions = @("$L\Android\Sdk\.temp", "$L\Android\Sdk\.downloadIntermediates")
|
|
55
|
+
# D79 (2026-11-02): computed for this run - exactly the superseded Codex release folders section 28 may remove.
|
|
56
|
+
# The current release and the newest older one are never listed, so the chokepoint still refuses them.
|
|
57
|
+
if (Get-Command Get-CodexReleaseExceptions -ErrorAction SilentlyContinue) {
|
|
58
|
+
try { $exceptions += @(Get-CodexReleaseExceptions) } catch { $null = $_ }
|
|
59
|
+
}
|
|
46
60
|
|
|
47
61
|
# Wildcards matched against the full path. Browser/editor state that must never be mistaken for cache.
|
|
48
62
|
$patterns = @(
|
|
@@ -125,18 +139,23 @@ function Get-ProtectionReason {
|
|
|
125
139
|
if ($pr.Exact.Contains($p)) { return "protected root: $p" }
|
|
126
140
|
$ic = [StringComparison]::OrdinalIgnoreCase
|
|
127
141
|
$pSlash = $p + '\'
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
142
|
+
# An exception lifts the protected lists for its exact folder, never the user's own exclusions below: an
|
|
143
|
+
# early return here used to let an exception path through even when the user had excluded it.
|
|
144
|
+
$excepted = $false
|
|
145
|
+
foreach ($e in $pr.ExceptionPrefixes) { if ($pSlash.StartsWith($e, $ic)) { $excepted = $true; break } }
|
|
146
|
+
if (-not $excepted) {
|
|
147
|
+
for ($i = 0; $i -lt $pr.SubtreePrefixes.Count; $i++) {
|
|
148
|
+
if ($pSlash.StartsWith($pr.SubtreePrefixes[$i], $ic)) { return "inside protected: $($pr.Subtrees[$i])" }
|
|
149
|
+
}
|
|
150
|
+
for ($i = 0; $i -lt $pr.PatternObjects.Count; $i++) {
|
|
151
|
+
if ($pr.PatternObjects[$i].IsMatch($p)) { return "matches protected pattern: $($pr.Patterns[$i])" }
|
|
152
|
+
}
|
|
153
|
+
$base = [IO.Path]::GetFileName($p)
|
|
154
|
+
foreach ($b in $pr.Basenames) { if ($base.Equals($b, $ic)) { return "protected file name: $b" } }
|
|
155
|
+
if ($Script:WS.Home -and -not $Script:WS.AllowOwnData) {
|
|
156
|
+
if (-not $pr.HomePrefix) { $pr.HomePrefix = (Get-FullPath $Script:WS.Home) + '\' }
|
|
157
|
+
if ($pSlash.StartsWith($pr.HomePrefix, $ic)) { return 'the tool''s own data directory' }
|
|
158
|
+
}
|
|
140
159
|
}
|
|
141
160
|
# A user exclusion is checked LAST, so a path that is both protected and excluded still reports the
|
|
142
161
|
# PROTECTED reason - the stronger statement, and the one no flag can lift. When nothing is excluded the
|
|
@@ -428,11 +447,17 @@ function Clear-DirectoryContents {
|
|
|
428
447
|
return $out
|
|
429
448
|
}
|
|
430
449
|
|
|
450
|
+
function Test-UsePermanentRemoval {
|
|
451
|
+
param([bool] $Permanent, [bool] $AlwaysRecycle)
|
|
452
|
+
return ($Permanent -and -not $AlwaysRecycle)
|
|
453
|
+
}
|
|
454
|
+
|
|
431
455
|
function Send-ToRecycleBin {
|
|
432
|
-
<# .SYNOPSIS Move a
|
|
433
|
-
param([Parameter(Mandatory = $true)][string] $Path, [Parameter(Mandatory = $true)][string] $Within
|
|
456
|
+
<# .SYNOPSIS Move a file/folder to the Recycle Bin; section 19 always keeps this undo path. #>
|
|
457
|
+
param([Parameter(Mandatory = $true)][string] $Path, [Parameter(Mandatory = $true)][string] $Within,
|
|
458
|
+
[switch] $AlwaysRecycle)
|
|
434
459
|
$ws = $Script:WS
|
|
435
|
-
if ($ws.Permanent) { return (Remove-PathSafe -Path $Path -Within $Within) }
|
|
460
|
+
if (Test-UsePermanentRemoval -Permanent $ws.Permanent -AlwaysRecycle $AlwaysRecycle) { return (Remove-PathSafe -Path $Path -Within $Within) }
|
|
436
461
|
$r = New-RemoveResult
|
|
437
462
|
$p = Get-FullPath $Path
|
|
438
463
|
$why = Get-ProtectionReason $p
|
package/lib/ui.ps1
CHANGED
|
@@ -96,14 +96,32 @@ function Write-Separator {
|
|
|
96
96
|
Write-UiLine ([string]::new([char]($Script:WS.Glyph.hline), 78)) 'DarkGray'
|
|
97
97
|
}
|
|
98
98
|
|
|
99
|
+
function Split-WrappedText {
|
|
100
|
+
<# .SYNOPSIS Word-wrap text into lines of at most -Width characters; a single longer word stands alone. #>
|
|
101
|
+
param([string] $Text, [int] $Width)
|
|
102
|
+
$lines = New-Object System.Collections.Generic.List[string]
|
|
103
|
+
$current = ''
|
|
104
|
+
foreach ($word in ($Text -split '\s+')) {
|
|
105
|
+
if (-not $word) { continue }
|
|
106
|
+
if (-not $current) { $current = $word; continue }
|
|
107
|
+
if (($current.Length + 1 + $word.Length) -le $Width) { $current = "$current $word" }
|
|
108
|
+
else { $lines.Add($current); $current = $word }
|
|
109
|
+
}
|
|
110
|
+
if ($current) { $lines.Add($current) }
|
|
111
|
+
return , $lines.ToArray()
|
|
112
|
+
}
|
|
113
|
+
|
|
99
114
|
function Write-Box {
|
|
100
|
-
<# .SYNOPSIS Double-lined title box with an optional dim subtitle. #>
|
|
115
|
+
<# .SYNOPSIS Double-lined title box with an optional dim subtitle, wrapped inside the 78-glyph rule. #>
|
|
101
116
|
param([string] $Title, [string] $Subtitle = '')
|
|
102
117
|
$line = [string]::new([char]($Script:WS.Glyph.dline), 78)
|
|
103
118
|
Write-UiLine '' 'Gray'
|
|
104
119
|
Write-UiLine $line 'Blue'
|
|
105
120
|
Write-UiLine (" $Title") 'Blue'
|
|
106
|
-
if ($Subtitle) {
|
|
121
|
+
if ($Subtitle) {
|
|
122
|
+
# The rule is 78 glyphs and the subtitle is indented by three, so each line holds 75.
|
|
123
|
+
foreach ($part in (Split-WrappedText -Text $Subtitle -Width 75)) { Write-UiLine (" $part") 'DarkGray' }
|
|
124
|
+
}
|
|
107
125
|
Write-UiLine $line 'Blue'
|
|
108
126
|
}
|
|
109
127
|
|
package/modules/android_avd.ps1
CHANGED
|
@@ -1,36 +1,187 @@
|
|
|
1
|
-
# android_avd.ps1 - section 4: Android emulators (AVDs). Slow to recreate, so per-AVD idle
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
1
|
+
# android_avd.ps1 - section 4: Android emulators (AVDs) and what they leave. Slow to recreate, so a per-AVD idle
|
|
2
|
+
# gate and never in --all. 1.4.0: every AVD root (also ANDROID_AVD_HOME), pointer .ini files whose AVD is gone
|
|
3
|
+
# (the P52's pointed into a drive that no longer existed, 2026-11-02) and system images no remaining AVD uses -
|
|
4
|
+
# removed with the SDK's own sdkmanager, the emulator too once no AVD is left. ASCII only.
|
|
5
|
+
|
|
6
|
+
function Get-AvdRoots {
|
|
7
|
+
<# .SYNOPSIS Every folder the emulator may keep AVDs in: the default, ANDROID_AVD_HOME, and the avd folder under
|
|
8
|
+
ANDROID_EMULATOR_HOME / ANDROID_SDK_HOME / ANDROID_PREFS_ROOT (process, user, machine) - existing ones, once
|
|
9
|
+
each. A missed root would make its AVDs' images look unused. #>
|
|
10
|
+
$default = Join-Path $Script:P.U '.android\avd'
|
|
11
|
+
$seen = New-Object System.Collections.Generic.HashSet[string]([StringComparer]::OrdinalIgnoreCase)
|
|
12
|
+
$cands = @($default)
|
|
13
|
+
foreach ($scope in 'Process', 'User', 'Machine') {
|
|
14
|
+
$cands += [Environment]::GetEnvironmentVariable('ANDROID_AVD_HOME', $scope)
|
|
15
|
+
$em = [Environment]::GetEnvironmentVariable('ANDROID_EMULATOR_HOME', $scope)
|
|
16
|
+
if ($em) { $cands += (Join-Path $em 'avd') }
|
|
17
|
+
foreach ($v in 'ANDROID_SDK_HOME', 'ANDROID_PREFS_ROOT') { $h = [Environment]::GetEnvironmentVariable($v, $scope); if ($h) { $cands += (Join-Path $h '.android\avd') } }
|
|
18
|
+
}
|
|
19
|
+
$out = @()
|
|
20
|
+
foreach ($c in $cands) {
|
|
21
|
+
if (-not $c) { continue }
|
|
22
|
+
$f = Get-FullPath $c
|
|
23
|
+
if (-not $f -or -not $seen.Add($f)) { continue }
|
|
24
|
+
if (Test-DirPresent $f) { $out += $f } elseif ($f -ne (Get-FullPath $default)) { Write-Note "an Android variable names the AVD folder $f, which does not exist" }
|
|
25
|
+
}
|
|
26
|
+
return $out
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function Read-KeyValueFile {
|
|
30
|
+
<# .SYNOPSIS The key=value lines of an AVD .ini or config.ini; empty when unreadable. #>
|
|
31
|
+
param([string] $Path)
|
|
32
|
+
$h = @{}
|
|
33
|
+
try { foreach ($line in [IO.File]::ReadAllLines($Path)) { $i = $line.IndexOf('='); if ($i -gt 0) { $h[$line.Substring(0, $i).Trim()] = $line.Substring($i + 1).Trim() } } } catch { $null = $_ }
|
|
34
|
+
return $h
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function Get-AvdInventory {
|
|
38
|
+
<# .SYNOPSIS AVD folders in the roots, AVDs stored elsewhere that a pointer .ini names (External: used for image
|
|
39
|
+
references, never pruned here), and pointer .ini files whose AVD folder is gone. #>
|
|
40
|
+
param([string[]] $Roots)
|
|
41
|
+
$avds = @(); $dangling = @()
|
|
42
|
+
$seen = New-Object System.Collections.Generic.HashSet[string]([StringComparer]::OrdinalIgnoreCase)
|
|
43
|
+
foreach ($root in $Roots) {
|
|
44
|
+
foreach ($e in (Get-ChildEntries $root)) {
|
|
45
|
+
if ($e -is [IO.DirectoryInfo] -and $e.Name -like '*.avd') {
|
|
46
|
+
$full = Remove-LongPrefix $e.FullName
|
|
47
|
+
if ($seen.Add($full)) { $avds += [pscustomobject]@{ Name = $e.Name; Path = $full; Root = $root; Ini = (Join-Path $root ($e.Name -replace '\.avd$', '.ini')); External = $false } }
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
foreach ($root in $Roots) {
|
|
52
|
+
foreach ($e in (Get-ChildEntries $root)) {
|
|
53
|
+
if (-not ($e -is [IO.FileInfo]) -or $e.Name -notlike '*.ini') { continue }
|
|
54
|
+
$full = Remove-LongPrefix $e.FullName
|
|
55
|
+
$target = [string](Read-KeyValueFile $full)['path']
|
|
56
|
+
if (-not $target) { $target = Join-Path $root ($e.Name -replace '\.ini$', '.avd') }
|
|
57
|
+
if (-not (Test-DirPresent $target)) { $dangling += [pscustomobject]@{ Path = $full; Root = $root; Target = $target }; continue }
|
|
58
|
+
$t = Get-FullPath $target
|
|
59
|
+
if ($seen.Add($t)) { $avds += [pscustomobject]@{ Name = (Split-Path -Leaf $t); Path = $t; Root = (Split-Path -Parent $t); Ini = $full; External = $true } }
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return [pscustomobject]@{ Avds = $avds; Dangling = $dangling }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function Get-AndroidSdkRoot {
|
|
66
|
+
foreach ($c in @($env:ANDROID_HOME, $env:ANDROID_SDK_ROOT, [Environment]::GetEnvironmentVariable('ANDROID_HOME', 'User'),
|
|
67
|
+
[Environment]::GetEnvironmentVariable('ANDROID_SDK_ROOT', 'User'), (Join-Path $Script:P.L 'Android\Sdk'))) {
|
|
68
|
+
if ($c -and (Test-DirPresent $c)) { return (Get-FullPath $c) }
|
|
69
|
+
}
|
|
70
|
+
return ''
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function Get-SystemImages {
|
|
74
|
+
<# .SYNOPSIS <sdk>\system-images\<api>\<tag>\<abi> folders. Broken = holds no image (an interrupted install left
|
|
75
|
+
only its .installer folder on the P52): sdkmanager does not know it, so it is only reported. #>
|
|
76
|
+
param([string] $Sdk)
|
|
77
|
+
$out = @()
|
|
78
|
+
foreach ($api in (Get-ChildEntries (Join-Path $Sdk 'system-images'))) {
|
|
79
|
+
if (-not ($api -is [IO.DirectoryInfo])) { continue }
|
|
80
|
+
foreach ($tag in (Get-ChildEntries (Remove-LongPrefix $api.FullName))) {
|
|
81
|
+
if (-not ($tag -is [IO.DirectoryInfo])) { continue }
|
|
82
|
+
foreach ($abi in (Get-ChildEntries (Remove-LongPrefix $tag.FullName))) {
|
|
83
|
+
if (-not ($abi -is [IO.DirectoryInfo])) { continue }
|
|
84
|
+
$p = Remove-LongPrefix $abi.FullName
|
|
85
|
+
$broken = -not ((Test-PathPresent (Join-Path $p 'source.properties')) -or (Test-PathPresent (Join-Path $p 'system.img')) -or (Test-PathPresent (Join-Path $p 'package.xml')))
|
|
86
|
+
$out += [pscustomobject]@{ Package = "system-images;$($api.Name);$($tag.Name);$($abi.Name)"; Path = $p; Rel = ("system-images\$($api.Name)\$($tag.Name)\$($abi.Name)").ToLowerInvariant(); Broken = $broken }
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return $out
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function Get-UnusedSdkPackages {
|
|
94
|
+
<# .SYNOPSIS Pure: the system images no remaining AVD's config.ini names, and the emulator once no AVD is left.
|
|
95
|
+
An AVD whose config names no image (unreadable, or made by hand) keeps every image. #>
|
|
96
|
+
param([object[]] $Images, [object[]] $Avds, [bool] $EmulatorPresent)
|
|
97
|
+
$used = New-Object System.Collections.Generic.HashSet[string]([StringComparer]::OrdinalIgnoreCase)
|
|
98
|
+
foreach ($a in @($Avds)) {
|
|
99
|
+
$kv = Read-KeyValueFile (Join-Path $a.Path 'config.ini')
|
|
100
|
+
$refs = @($kv.Keys | Where-Object { $_ -like 'image.sysdir.*' } | ForEach-Object { ([string]$kv[$_]).Replace('/', '\').Trim('\').ToLowerInvariant() })
|
|
101
|
+
if ($refs.Count -eq 0) { return [pscustomobject]@{ Packages = @(); Reason = "the AVD $($a.Name) names no system image - every image is kept" } }
|
|
102
|
+
foreach ($r in $refs) { $null = $used.Add($r) }
|
|
103
|
+
}
|
|
104
|
+
$drop = @($Images | Where-Object { -not $used.Contains($_.Rel) })
|
|
105
|
+
if ($EmulatorPresent -and @($Avds).Count -eq 0) { $drop += [pscustomobject]@{ Package = 'emulator'; Path = ''; Rel = 'emulator' } }
|
|
106
|
+
return [pscustomobject]@{ Packages = $drop; Reason = '' }
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function Get-SdkManagerPath {
|
|
110
|
+
param([string] $Sdk)
|
|
111
|
+
$c = @((Join-Path $Sdk 'cmdline-tools\latest\bin\sdkmanager.bat'))
|
|
112
|
+
$c += @(Get-ChildEntries (Join-Path $Sdk 'cmdline-tools') | Sort-Object Name -Descending | ForEach-Object { Join-Path (Remove-LongPrefix $_.FullName) 'bin\sdkmanager.bat' })
|
|
113
|
+
$c += (Join-Path $Sdk 'tools\bin\sdkmanager.bat')
|
|
114
|
+
foreach ($p in $c) { if (Test-PathPresent $p) { return $p } }
|
|
115
|
+
return ''
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function Remove-SdkPackages {
|
|
119
|
+
<# .SYNOPSIS Uninstall SDK packages with the SDK's own sdkmanager (no file is deleted by this tool). #>
|
|
120
|
+
param([string] $Sdk, [object[]] $Packages)
|
|
121
|
+
$tool = Get-SdkManagerPath $Sdk
|
|
122
|
+
if (-not $tool) { Write-Warn ('sdkmanager was not found; remove them in Android Studio > SDK Manager: ' + (($Packages | ForEach-Object { $_.Package }) -join ', ')); return }
|
|
123
|
+
$savedJava = $env:JAVA_HOME
|
|
124
|
+
$jbr = Join-Path $Script:P.PF 'Android\Android Studio\jbr'
|
|
125
|
+
if (-not $env:JAVA_HOME -and -not (Test-CommandPresent 'java') -and (Test-DirPresent $jbr)) { $env:JAVA_HOME = $jbr }
|
|
126
|
+
try {
|
|
127
|
+
foreach ($pkg in $Packages) {
|
|
128
|
+
$bytes = [long]0
|
|
129
|
+
if ($pkg.Path) { $bytes = [long](Get-DirectoryBytes $pkg.Path) } else { $bytes = [long](Get-DirectoryBytes (Join-Path $Sdk 'emulator')) }
|
|
130
|
+
$r = Invoke-External -FilePath $tool -ArgumentList @('--uninstall', $pkg.Package) -Destructive -Quiet -Label 'sdkmanager'
|
|
131
|
+
if ($Script:WS.DryRun) { Add-Freed $bytes; continue }
|
|
132
|
+
$left = $pkg.Path
|
|
133
|
+
if (-not $left) { $left = Join-Path $Sdk 'emulator' }
|
|
134
|
+
if (-not (Test-DirPresent $left)) { Add-Freed $bytes; Write-Ok ("{0} removed with sdkmanager ({1})" -f $pkg.Package, (Format-Bytes $bytes)) } else { Write-Warn "$($pkg.Package) is still installed (sdkmanager exit $($r.ExitCode))" }
|
|
135
|
+
}
|
|
136
|
+
} finally { $env:JAVA_HOME = $savedJava }
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function Get-Targets04 {
|
|
140
|
+
$t = @((New-Target 4 'Android AVDs (each .avd + .ini pair)' "$($Script:P.U)\.android\avd" -Mode units -Dev $true -Note "an AVD goes only when nothing inside it changed for $($Script:WS.Days)+ days"))
|
|
141
|
+
$sdk = Get-AndroidSdkRoot
|
|
142
|
+
if ($sdk) { $t += (New-Target 4 'Android system images no AVD uses' (Join-Path $sdk 'system-images') -Kind cmd -Dev $true -Note 'removed with sdkmanager; the emulator too once no AVD is left') }
|
|
143
|
+
return $t
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function Invoke-Section04 {
|
|
147
|
+
$ws = $Script:WS
|
|
148
|
+
Write-SectionIntro @(
|
|
149
|
+
'An emulator image is 5-15 GB and takes minutes to recreate. Each AVD is one unit: it is removed only when',
|
|
150
|
+
"every file inside it has been idle for $($ws.Days)+ days (booting an emulator updates its files). Pointer .ini",
|
|
151
|
+
'files whose AVD is gone, and system images no remaining AVD uses, go too - the images with the SDK''s sdkmanager.'
|
|
152
|
+
) -Dev $true
|
|
153
|
+
if (Test-ProcessRunning @('qemu-system-x86_64', 'emulator', 'emulator64-crash-service')) { Write-Warn 'an Android emulator is running - section skipped'; return }
|
|
154
|
+
$inv = Get-AvdInventory -Roots @(Get-AvdRoots)
|
|
155
|
+
$own = @($inv.Avds | Where-Object { -not $_.External })
|
|
156
|
+
foreach ($a in $own) { $a | Add-Member -NotePropertyName Idle -NotePropertyValue (Get-IdleDays $a.Path) -Force; $a | Add-Member -NotePropertyName Bytes -NotePropertyValue (Get-DirectoryBytes $a.Path) -Force }
|
|
157
|
+
if ($own.Count -gt 0) {
|
|
158
|
+
Write-UiLine (" {0,-36} {1,10} {2,8}" -f 'AVD', 'SIZE', 'IDLE') 'White'
|
|
159
|
+
foreach ($a in $own) { Write-UiLine (" {0,-36} {1,10} {2,7}d" -f $a.Name, (Format-Bytes $a.Bytes), $a.Idle) 'Gray' }
|
|
160
|
+
}
|
|
161
|
+
$stale = @($own | Where-Object { $_.Idle -ge $ws.Days -or $ws.PurgeAll })
|
|
162
|
+
foreach ($d in $inv.Dangling) { Write-UiLine (" pointer to a missing AVD: {0} (-> {1})" -f $d.Path, $d.Target) 'Gray' }
|
|
163
|
+
$sdk = Get-AndroidSdkRoot
|
|
164
|
+
$unused = [pscustomobject]@{ Packages = @(); Reason = '' }
|
|
165
|
+
if ($sdk) {
|
|
166
|
+
$remaining = @($inv.Avds | Where-Object { $stale -notcontains $_ })
|
|
167
|
+
$all = @(Get-SystemImages $sdk)
|
|
168
|
+
foreach ($b in @($all | Where-Object { $_.Broken })) { Write-UiLine (" an interrupted SDK install left {0} - remove it in Android Studio > SDK Manager, or delete the folder" -f $b.Path) 'Gray' }
|
|
169
|
+
$unused = Get-UnusedSdkPackages -Images @($all | Where-Object { -not $_.Broken }) -Avds $remaining -EmulatorPresent (Test-DirPresent (Join-Path $sdk 'emulator'))
|
|
170
|
+
if ($unused.Reason) { Write-Note $unused.Reason }
|
|
171
|
+
foreach ($p in $unused.Packages) { Write-UiLine (" SDK package no AVD uses: {0}" -f $p.Package) 'Gray' }
|
|
172
|
+
}
|
|
173
|
+
$n = $stale.Count + @($inv.Dangling).Count + @($unused.Packages).Count
|
|
174
|
+
if ($n -eq 0) { Write-Info "nothing to remove: every AVD was used within $($ws.Days) days, no pointer is dangling and every image is in use"; return }
|
|
175
|
+
if (-not (Confirm-Section ("Remove {0} idle AVD(s), {1} dangling pointer(s) and {2} unused SDK package(s)?" -f $stale.Count, @($inv.Dangling).Count, @($unused.Packages).Count) 'n')) { Write-Info 'skipped'; return }
|
|
176
|
+
foreach ($a in $stale) {
|
|
177
|
+
$r = Remove-PathSafe -Path $a.Path -Within $a.Root -Label $a.Name
|
|
178
|
+
if ($r.Removed) {
|
|
179
|
+
if (Test-PathPresent $a.Ini) { $null = Remove-PathSafe -Path $a.Ini -Within $a.Root -Label "$($a.Name) ini" }
|
|
180
|
+
if (-not $ws.DryRun) { Write-Ok ("removed AVD $($a.Name) ($($a.Idle)d idle, " + (Format-Bytes $r.Bytes) + ')') }
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
foreach ($d in $inv.Dangling) { $null = Send-ToRecycleBin -Path $d.Path -Within $d.Root }
|
|
184
|
+
if (@($unused.Packages).Count -eq 0) { return }
|
|
185
|
+
if (-not $ws.DryRun -and @($stale | Where-Object { Test-DirPresent $_.Path }).Count -gt 0) { Write-Warn 'an AVD could not be removed - its system image is kept; run section 4 again'; return }
|
|
186
|
+
Remove-SdkPackages -Sdk $sdk -Packages @($unused.Packages)
|
|
187
|
+
}
|