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/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
- param([System.IO.FileSystemInfo] $Info)
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
- param([string] $Path)
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
- param([string] $Path)
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 (P9): 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 byte this tool removes passes through Remove-PathSafe (or Send-ToRecycleBin), which:
4
- # 1. resolves the path and refuses relative segments, UNC paths, drive roots and system roots;
5
- # 2. refuses every protected subtree / pattern / basename (Test-ProtectedPath);
6
- # 3. asserts the path lies strictly inside the target root the caller declared (-Within);
7
- # 4. short-circuits in --dry-run, printing what it would do and tallying an estimate;
8
- # 5. removes reparse points as links, never following them; skips locked files, never fails a run.
9
- # No flag bypasses steps 1-3.
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
- 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)) {
29
+ # P9: 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
- foreach ($e in $pr.ExceptionPrefixes) { if ($pSlash.StartsWith($e, $ic)) { return $null } }
129
- for ($i = 0; $i -lt $pr.SubtreePrefixes.Count; $i++) {
130
- if ($pSlash.StartsWith($pr.SubtreePrefixes[$i], $ic)) { return "inside protected: $($pr.Subtrees[$i])" }
131
- }
132
- for ($i = 0; $i -lt $pr.PatternObjects.Count; $i++) {
133
- if ($pr.PatternObjects[$i].IsMatch($p)) { return "matches protected pattern: $($pr.Patterns[$i])" }
134
- }
135
- $base = [IO.Path]::GetFileName($p)
136
- foreach ($b in $pr.Basenames) { if ($base.Equals($b, $ic)) { return "protected file name: $b" } }
137
- if ($Script:WS.Home -and -not $Script:WS.AllowOwnData) {
138
- if (-not $pr.HomePrefix) { $pr.HomePrefix = (Get-FullPath $Script:WS.Home) + '\' }
139
- if ($pSlash.StartsWith($pr.HomePrefix, $ic)) { return 'the tool''s own data directory' }
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 personal file/folder to the Recycle Bin (or delete when --permanent). Same guards as Remove-PathSafe. #>
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) { Write-UiLine (" $Subtitle") 'DarkGray' }
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
 
@@ -1,36 +1,187 @@
1
- # android_avd.ps1 - section 4: Android emulators (AVDs). Slow to recreate, so per-AVD idle gate and never in --all.
2
-
3
- function Get-Targets04 {
4
- return @((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"))
5
- }
6
-
7
- function Invoke-Section04 {
8
- $ws = $Script:WS
9
- $root = Join-Path $Script:P.U '.android\avd'
10
- Write-SectionIntro @(
11
- 'An emulator image is 5-15 GB and takes minutes to recreate. Each AVD is one unit: it is removed only when',
12
- "every file inside it has been idle for $($ws.Days)+ days (booting an emulator updates its files)."
13
- ) -Dev $true
14
- if (-not (Test-DirPresent $root)) { Write-Info 'no AVDs found'; return }
15
- if (Test-ProcessRunning @('qemu-system-x86_64', 'emulator', 'emulator64-crash-service')) { Write-Warn 'an Android emulator is running - section skipped'; return }
16
- $avds = @()
17
- foreach ($e in (Get-ChildEntries $root)) {
18
- if (-not ($e -is [IO.DirectoryInfo]) -or $e.Name -notlike '*.avd') { continue }
19
- $full = Remove-LongPrefix $e.FullName
20
- $avds += [pscustomobject]@{ Name = $e.Name; Path = $full; Ini = (Join-Path $root ($e.Name -replace '\.avd$', '.ini')); Idle = (Get-IdleDays $full); Bytes = (Get-DirectoryBytes $full) }
21
- }
22
- if ($avds.Count -eq 0) { Write-Info 'no AVDs found'; return }
23
- Write-UiLine (" {0,-36} {1,10} {2,8}" -f 'AVD', 'SIZE', 'IDLE') 'White'
24
- foreach ($a in $avds) { Write-UiLine (" {0,-36} {1,10} {2,7}d" -f $a.Name, (Format-Bytes $a.Bytes), $a.Idle) 'Gray' }
25
- $avds = @($avds)
26
- $stale = @($avds | Where-Object { $_.Idle -ge $ws.Days -or $ws.PurgeAll })
27
- if ($stale.Count -eq 0) { Write-Info "every AVD was used within the last $($ws.Days) days - nothing to prune"; return }
28
- if (-not (Confirm-Section "Remove $($stale.Count) idle AVD(s)?" 'n')) { Write-Info 'skipped'; return }
29
- foreach ($a in $stale) {
30
- $r = Remove-PathSafe -Path $a.Path -Within $root -Label $a.Name
31
- if ($r.Removed) {
32
- if (Test-PathPresent $a.Ini) { $null = Remove-PathSafe -Path $a.Ini -Within $root -Label "$($a.Name) ini" }
33
- if (-not $ws.DryRun) { Write-Ok ("removed AVD $($a.Name) ($($a.Idle)d idle, " + (Format-Bytes $r.Bytes) + ')') }
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. P9: 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
+ }