dsh-disk-cleaner 1.0.1 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -5,7 +5,7 @@
5
5
  <!-- badges -->
6
6
  | | |
7
7
  | --- | --- |
8
- | **version** | `1.0.0` |
8
+ | **version** | `1.2.0` |
9
9
  | **DSH** | `0.2.1-rc.5`(开发与实测版本;用到 `plugins.bundle.config`、`settings.section`) |
10
10
  | **license** | [MIT](LICENSE) |
11
11
  | **Node** | `>= 22`(仅运行冒烟测试需要) |
@@ -16,7 +16,7 @@
16
16
 
17
17
  **dsh-disk-cleaner** is a disk-space plugin for [DeepSeek Harness](https://github.com/deepseek-ai) (DSH). It does two different jobs, and keeps them apart on purpose:
18
18
 
19
- 1. **Clean** — thirteen known-safe cache locations (temp folders, package manager stores, browser caches, crash dumps, the Recycle Bin). Only these are ever deleted.
19
+ 1. **Clean** — nineteen known-safe locations (temp folders, package manager stores, browser caches, crash dumps, the Recycle Bin, leftover update installers, per-app caches and logs, superseded plugin versions and applied update packages). Only these are ever deleted.
20
20
  2. **Assess** — everything the plugin will *not* delete (installed programs, the WSL virtual disk, the Windows component store, chat-app data, your own folders). Measured and listed, never removed.
21
21
 
22
22
  The assessment ends with a **plan**: the same numbers ordered by what it costs you to act, with the exact Windows command for the steps the plugin cannot perform itself.
@@ -27,16 +27,17 @@ Both the panel and the agent reach the *same* operations: the cleaning logic liv
27
27
 
28
28
  | Feature | Description |
29
29
  | --- | --- |
30
- | Scan | Measures all thirteen targets concurrently and shows what each occupies |
30
+ | Scan | Measures all nineteen targets concurrently and shows what each occupies |
31
31
  | Per-drive filter | Chip row of mounted volumes; scanning or cleaning can be limited to one drive, and rows with nothing on that drive disappear |
32
- | Risk labels | Every target is `safe` or `caution`, with a one-line reason (`in-use` / `rebuild` / `elevation`) and a permanent legend, so "caution" never means "unspecified" |
32
+ | Risk labels | Every target is `safe` or `caution`, with a one-line reason (`in-use` / `rebuild` / `elevation` / `pending-update` / `rollback` / `superseded`) and a permanent legend, so "caution" never means "unspecified" |
33
33
  | Aged deletion | `temp` and `windows-temp` only remove files older than one day; files a running program still holds are skipped |
34
- | Space assessment | Walks the disk read-only and groups the result into Windows / installed software / your own files, plus the ten largest app-data folders |
34
+ | Space assessment | Walks the disk read-only and groups the result into Windows / installed software / your own files, plus the ten largest app-data folders. Any row opens its folder in Explorer — click, right click, or Enter |
35
+ | Honest re-run | Re-running the assessment re-measures; a plain call still answers from the ten-minute cache |
35
36
  | Plan | Nine possible steps, ordered from zero-cost to your-decision-only; the steps the plugin can run have a button, the rest have a copyable command |
36
37
  | Entry placement | Sidebar icon, settings section, or both (default) — switched from the plugin's own config card or the panel's ⚙ button |
37
38
  | Behaviour settings | What is preselected after a scan, and whether deleting asks first |
38
39
  | Panel memory | Scan results, the selection and the assessment survive switching to another panel and back |
39
- | Agent tool | `disk_cleaner` exposes the same four actions to the model, in readable prose |
40
+ | Agent tool | `disk_cleaner` exposes the same five actions to the model, in readable prose |
40
41
  | Bilingual | Follows the host locale service; zh and en dictionaries ship together |
41
42
 
42
43
  ### Cleanup targets
@@ -58,12 +59,18 @@ Every path is built from this table — a caller selects an **id**, never a path
58
59
  | `windows-update` | `C:\Windows\SoftwareDistribution\Download` | purge | caution | needs elevation |
59
60
  | `recycle-bin` | `$Recycle.Bin\S-1-5-*` on every mounted volume | purge | safe | it is the Recycle Bin |
60
61
  | `inet-cache` | `%LOCALAPPDATA%\Microsoft\Windows\INetCache` | purge | caution | rebuilt |
62
+ | `app-updaters` | `*-updater` folders under `%LOCALAPPDATA%` and `%APPDATA%` — the installers app updaters leave behind | aged, 3 days | caution | a download from the last three days may still be a pending update |
63
+ | `app-caches` | named cache folders inside `%APPDATA%`: the WPS CEF cache, FTNN `cef_cache`, and the `Cache` / `Code Cache` / `CachedData` / `GPUCache` of Qoder, Trae, the DSH desktop app, PersonalCenter, the WeChat devtools and QQ PC Manager | purge | caution | CEF and Chromium caches rebuild on next start |
64
+ | `app-logs` | log folders inside `%APPDATA%`: FTNN, WeChat, Qoder, Trae, DingTalk, PersonalCenter, Clash Verge | purge | safe | nothing reads an old log back at runtime |
65
+ | `app-temp` | scratch folders apps keep inside their own `%APPDATA%` folder instead of `%TEMP%`: `WorkDaddy\client-tmp`, `PersonalCenter\Temp` | aged, 2 days | caution | the owning app may be running and holding files |
66
+ | `plugin-old-versions` | every version but the newest of each family in the WPS plugin pool `kingsoft\wps\addons\pool\win-i386` | purge | caution | the manager loads the newest; the rest are rollback copies |
67
+ | `update-packages` | update packages already applied: FTNN `.spd` patches whose build number is below the newest `Program Files\FTNN\app\[version]`, and `WorkDaddy\update` installers below the installed version | purge | caution | decided by version comparison, not by age |
61
68
 
62
69
  `aged` targets skip anything modified within the retention window; `purge` targets remove every entry. Both keep the target directory itself and leave locked entries alone, counting them as skipped.
63
70
 
64
71
  ### Space assessment areas
65
72
 
66
- `wsl` (ext4.vhdx images), `winsxs`, `system-files` (hiberfil / pagefile / swapfile), `programs` (user scope), `program-files` (machine scope), `downloads`, `documents`, `pictures`, `videos`, `desktop`. An area that does not exist is dropped rather than reported as zero. The ten largest folders under `%LOCALAPPDATA%` and `%APPDATA%` (≥100 MB each) are listed separately, because "3.6 GB in Roaming\kingsoft" points somewhere a Settings page does not.
73
+ `wsl` (ext4.vhdx images), `winsxs`, `system-files` (hiberfil / pagefile / swapfile), `programs` (user scope), `program-files` (machine scope), `old-versions` (builds an installer kept for rollback), `update-payloads` (setup and installer files sitting in temp and app-data), `downloads`, `documents`, `pictures`, `videos`, `desktop`. An area that does not exist is dropped rather than reported as zero. The ten largest folders under `%LOCALAPPDATA%` and `%APPDATA%` (≥100 MB each) are listed separately, because "3.6 GB in Roaming\kingsoft" points somewhere a Settings page does not.
67
74
 
68
75
  ### Plan steps
69
76
 
@@ -99,10 +106,12 @@ Panel state (drives, drive filter, scan rows, selection, notices, assessment) is
99
106
 
100
107
  | Argument | Type | Notes |
101
108
  | --- | --- | --- |
102
- | `action` | `overview` \| `scan` \| `clean` \| `audit` | required |
109
+ | `action` | `overview` \| `scan` \| `clean` \| `audit` \| `reveal` | required |
103
110
  | `ids` | string[] | catalog ids; required for `clean`, max 32 |
104
111
  | `drive` | string | `C:` / `D:\` — limits the work to one volume |
105
112
  | `confirm` | boolean | without it, `clean` is a dry run |
113
+ | `force` | boolean | `audit` re-measures instead of answering from the 10-minute cache |
114
+ | `area` / `scope` + `name` / `index` | | `reveal` names what to open; never a path |
106
115
 
107
116
  `clean` without `confirm: true` deletes nothing and reports what it would free — that is the approval path. `audit` is read-only and returns the plan in prose, ending with "Report that plan to the user; never run those commands yourself."
108
117
 
@@ -113,11 +122,12 @@ One route, `POST /api/disk-cleaner/`, used by the panel:
113
122
  ```jsonc
114
123
  {"action":"overview"} // drives + target ids
115
124
  {"action":"scan","drive":"C:"} // measured targets
116
- {"action":"audit"} // areas + largest apps + plan
125
+ {"action":"audit","force":true} // areas + largest apps + plan
126
+ {"action":"reveal","area":"downloads"} // opens it in Explorer
117
127
  {"action":"clean","ids":["temp"],"confirm":true}
118
128
  ```
119
129
 
120
- POST only (405 otherwise), same-origin only (403 otherwise), body ≤4096 bytes, at most 32 ids, unknown ids or actions rejected with 400. Handlers never receive a path.
130
+ POST only (405 otherwise), same-origin only (403 otherwise), body ≤4096 bytes, at most 32 ids, unknown ids or actions rejected with 400. Handlers never receive a path: `reveal` names an assessment area (or an app folder by scope and name), the host rebuilds the path from the same table that measured it, and hands that to `explorer.exe`.
121
131
 
122
132
  ### Safety rules
123
133
 
@@ -165,7 +175,7 @@ The bundle's own `cordis.patch.yml` inserts the plugin row; no profile patch edi
165
175
  ### Development
166
176
 
167
177
  ```bash
168
- node smoke.mjs # 61 assertions, zero dependencies
178
+ node smoke.mjs # 67 assertions, zero dependencies
169
179
  node --check lib/client.js # and lib/index.js, lib/catalog.js, lib/audit.js, lib/plan.js
170
180
  ```
171
181
 
@@ -182,7 +192,9 @@ What the smoke test deliberately does **not** cover: anything that walks a real
182
192
  | Client bundle executes, slots register and dispose | `node smoke.mjs` (real bundle in a fake DOM) |
183
193
  | Settings reach the plugin page, the settings section and the panel | `node smoke.mjs` + `cordis_inspect_query` slot occupants |
184
194
  | Route and tool registered | `node smoke.mjs` (throwaway `ctx`) |
185
- | `overview` / `scan` / `clean` / `audit` round-trip | Real `POST /api/disk-cleaner/` against a live host |
195
+ | `overview` / `scan` / `clean` / `audit` / `reveal` round-trip | Real `POST /api/disk-cleaner/` against a live host |
196
+ | Re-run bypasses the cache, a plain call still uses it | `audit` twice: `cached: false`, then `true` |
197
+ | `reveal` refuses a path and a traversal | `name: "../.."`, and a request with neither area nor name → 400 |
186
198
  | Real deletion keeps the target directory, honours the age limit | Temp directory seeded with old and new files |
187
199
  | Dry run deletes nothing | `confirm` omitted, byte counts unchanged |
188
200
  | Drive filter rejects unmounted and malformed volumes | `C:extra`, `Z:`, `abc` → 400 |
@@ -200,7 +212,8 @@ What the smoke test deliberately does **not** cover: anything that walks a real
200
212
  ### Known ceilings
201
213
 
202
214
  - **Windows only.** Paths are built from the Windows layout; there is no POSIX branch.
203
- - **First assessment takes 40–60 s.** Named areas and the per-app folder list walk overlapping trees. A result is cached for 10 minutes and invalidated by a real deletion, so only the first caller pays. `ponytail:` comment in `lib/audit.js` records the shared-map alternative.
215
+ - **First assessment takes 30–60 s.** Named areas and the per-app folder list walk overlapping trees. A result is cached for 10 minutes and invalidated by a real deletion, so only the first caller pays; the panel's re-run button passes `force` and always pays it. `ponytail:` comment in `lib/audit.js` records the shared-map alternative.
216
+ - **Leftover installers are cleaned by age, not by knowledge.** `*-updater` folders are recognised by name; a download from the last three days is left alone because only the app knows whether it has been applied.
204
217
  - **No elevation.** The one Windows directory that refuses an unelevated write is reported, never attempted.
205
218
  - **No uninstall, no move.** Steps the plugin cannot perform carry a command instead of a button.
206
219
  - **Traversal caps** at 2,000,000 entries per walk, so an absurdly large tree is measured partially rather than hanging.
@@ -210,16 +223,16 @@ What the smoke test deliberately does **not** cover: anything that walks a real
210
223
  ```
211
224
  ├─ package.json dsh.bundle.patch + dsh.client { platform: "web" }
212
225
  ├─ cordis.patch.yml - insert: id: dsh-disk-cleaner
213
- ├─ lib/index.js (330) Host: HTTP route + disk_cleaner tool, same run() for both
214
- ├─ lib/catalog.js (413) The 13 targets, measurePath / purgePath / resolveTargets / drives
215
- ├─ lib/audit.js (262) Read-only assessment, 10-minute cache, exposes no deletion
226
+ ├─ lib/index.js (405) Host: HTTP route + disk_cleaner tool, same run() for both; reveal
227
+ ├─ lib/catalog.js (688) The 19 targets, measurePath / purgePath / resolveTargets / drives
228
+ ├─ lib/audit.js (365) Read-only assessment, 10-minute cache, areaPaths, exposes no deletion
216
229
  ├─ lib/plan.js (150) Pure: measurements → ordered steps
217
- ├─ lib/client.js (1234) Client half: slots, panel, preferences, zh/en dictionaries, CSS
230
+ ├─ lib/client.js (1355) Client half: slots, panel, preferences, zh/en dictionaries, CSS
218
231
  ├─ locale/{en,zh}.json Display metadata (meta.title / meta.description)
219
232
  ├─ icon.svg Plugin icon
220
233
  ├─ README.md This file
221
234
  ├─ LICENSE MIT
222
- └─ smoke.mjs 61 assertions, zero dependencies
235
+ └─ smoke.mjs 67 assertions, zero dependencies
223
236
  ```
224
237
 
225
238
  Client-half slots: `sidebar.panellist` (order 50, disposed in settings-only mode), `main` (keyed by the package name), `settings.section` (order 30, disposed in sidebar-only mode), `plugins.bundle.config` (keyed by the package name).
@@ -231,7 +244,7 @@ Client-half slots: `sidebar.panellist` (order 50, disposed in settings-only mode
231
244
 
232
245
  **dsh-disk-cleaner** 是 [DeepSeek Harness](https://github.com/deepseek-ai)(DSH)的磁盘空间插件。它做两件不同的事,并且刻意分开:
233
246
 
234
- 1. **清理** —— 十三个已知安全的缓存位置(临时目录、包管理器存储、浏览器缓存、崩溃转储、回收站)。只有这些会被删除。
247
+ 1. **清理** —— 十九个已知安全的位置(临时目录、包管理器存储、浏览器缓存、崩溃转储、回收站、更新安装包残留、各应用的缓存与日志、被淘汰的插件版本与已应用的更新包)。只有这些会被删除。
235
248
  2. **评估** —— 所有本插件**不会**删的东西(已装程序、WSL 虚拟磁盘、Windows 组件库、聊天软件数据、你自己的文件夹)。只测量、只列出,绝不删除。
236
249
 
237
250
  评估的最后是一份**处理方案**:同一批数字按「你要付出多少代价」排序,本插件做不到的步骤附上确切的 Windows 命令。
@@ -242,16 +255,17 @@ Client-half slots: `sidebar.panellist` (order 50, disposed in settings-only mode
242
255
 
243
256
  | 功能 | 说明 |
244
257
  | --- | --- |
245
- | 扫描 | 并发测量全部十三个目标,显示各自占用 |
258
+ | 扫描 | 并发测量全部十九个目标,显示各自占用 |
246
259
  | 按盘过滤 | 已挂载卷的胶囊按钮;扫描与清理都可限定单盘,该盘上为空的项会隐藏 |
247
- | 风险标注 | 每个目标都标 `安全` 或 `注意`,并给一行理由(`in-use` / `rebuild` / `elevation`),图例常驻,所以「注意」永远不是「说不清」 |
260
+ | 风险标注 | 每个目标都标 `安全` 或 `注意`,并给一行理由(`in-use` / `rebuild` / `elevation` / `pending-update` / `rollback` / `superseded`),图例常驻,所以「注意」永远不是「说不清」 |
248
261
  | 保留期删除 | `temp` 与 `windows-temp` 只删一天前的文件;被运行中程序占住的自动跳过 |
249
- | 空间评估 | 只读遍历磁盘,按 Windows 自身 / 已装软件 / 你自己的文件 分组,另列占用最大的十个应用数据目录 |
262
+ | 空间评估 | 只读遍历磁盘,按 Windows 自身 / 已装软件 / 你自己的文件 分组,另列占用最大的十个应用数据目录。每一行都能打开所在目录——单击、右键或回车 |
263
+ | 重新评估真的重测 | 「重新评估」会重新测量;普通调用仍然走十分钟缓存 |
250
264
  | 处理方案 | 九种可能步骤,从零风险到只能你决定;本插件能执行的带按钮,其余给可复制的命令 |
251
265
  | 入口位置 | 侧边栏图标、设置页分区、或两者都要(默认)——在插件自己的配置卡或面板 ⚙ 里切换 |
252
266
  | 行为设置 | 扫描后默认勾选什么、删除前是否确认 |
253
267
  | 面板记忆 | 扫描结果、勾选、评估结果在切走再回来时都还在 |
254
- | Agent 工具 | `disk_cleaner` 把同样的四个动作以可读文本交给模型 |
268
+ | Agent 工具 | `disk_cleaner` 把同样的五个动作以可读文本交给模型 |
255
269
  | 双语 | 跟随宿主语言服务,zh / en 字典一起发布 |
256
270
 
257
271
  ### 清理目标
@@ -273,12 +287,18 @@ Client-half slots: `sidebar.panellist` (order 50, disposed in settings-only mode
273
287
  | `windows-update` | `C:\Windows\SoftwareDistribution\Download` | 全清 | 注意 | 需要管理员权限 |
274
288
  | `recycle-bin` | 各已挂载卷的 `$Recycle.Bin\S-1-5-*` | 全清 | 安全 | 就是回收站 |
275
289
  | `inet-cache` | `%LOCALAPPDATA%\Microsoft\Windows\INetCache` | 全清 | 注意 | 会重建 |
290
+ | `app-updaters` | `%LOCALAPPDATA%` 与 `%APPDATA%` 下的 `*-updater` 目录——升级程序留下的安装包 | 保留期 3 天 | 注意 | 三天内的下载可能还是待安装的更新 |
291
+ | `app-caches` | `%APPDATA%` 里点名列出的缓存目录:WPS 的 CEF 缓存、FTNN 的 `cef_cache`,以及 Qoder、Trae、DSH 桌面端、PersonalCenter、微信开发者工具、腾讯电脑管家的 `Cache` / `Code Cache` / `CachedData` / `GPUCache` | 全清 | 注意 | CEF 与 Chromium 缓存会在下次启动时重建 |
292
+ | `app-logs` | `%APPDATA%` 里的日志目录:FTNN、微信、Qoder、Trae、钉钉、PersonalCenter、Clash Verge | 全清 | 安全 | 旧日志运行时不会被读回 |
293
+ | `app-temp` | 应用自己放在 `%APPDATA%` 里的临时目录(不走 `%TEMP%`):`WorkDaddy\client-tmp`、`PersonalCenter\Temp` | 保留期 2 天 | 注意 | 应用可能正在运行并占用其中的文件 |
294
+ | `plugin-old-versions` | WPS 插件池 `kingsoft\wps\addons\pool\win-i386` 里,每个插件族除最高版本以外的全部版本 | 全清 | 注意 | 管理器加载最高版本,其余是回滚副本 |
295
+ | `update-packages` | 已应用的更新包:FTNN 的 `.spd` 补丁(build 号低于 `Program Files\FTNN\app\[版本]` 中最新者),以及 `WorkDaddy\update` 里低于已装版本的安装包 | 全清 | 注意 | 按版本号比较判断,不按时间 |
276
296
 
277
297
  保留期方式会跳过保留期内修改过的文件;全清方式清掉全部条目。两者都保留目标目录本身,并把锁定或无权限的条目跳过计数。
278
298
 
279
299
  ### 空间评估范围
280
300
 
281
- `wsl`(ext4.vhdx 映像)、`winsxs`、`system-files`(hiberfil / pagefile / swapfile)、`programs`(用户级)、`program-files`(机器级)、`downloads`、`documents`、`pictures`、`videos`、`desktop`。不存在的范围直接略去,不报成 0。`%LOCALAPPDATA%` 与 `%APPDATA%` 下超过 100 MB 的十个最大目录另外列出——因为「Roaming\kingsoft 占 3.6 GB」指向的地方,设置页里根本找不到。
301
+ `wsl`(ext4.vhdx 映像)、`winsxs`、`system-files`(hiberfil / pagefile / swapfile)、`programs`(用户级)、`program-files`(机器级)、`old-versions`(安装器留给回滚的旧版本目录)、`update-payloads`(临时目录与应用数据里躺着的 setup / installer 文件)、`downloads`、`documents`、`pictures`、`videos`、`desktop`。不存在的范围直接略去,不报成 0。`%LOCALAPPDATA%` 与 `%APPDATA%` 下超过 100 MB 的十个最大目录另外列出——因为「Roaming\kingsoft 占 3.6 GB」指向的地方,设置页里根本找不到。
282
302
 
283
303
  ### 处理方案步骤
284
304
 
@@ -314,10 +334,12 @@ Client-half slots: `sidebar.panellist` (order 50, disposed in settings-only mode
314
334
 
315
335
  | 参数 | 类型 | 说明 |
316
336
  | --- | --- | --- |
317
- | `action` | `overview` \| `scan` \| `clean` \| `audit` | 必填 |
337
+ | `action` | `overview` \| `scan` \| `clean` \| `audit` \| `reveal` | 必填 |
318
338
  | `ids` | string[] | catalog id;`clean` 必填,最多 32 个 |
319
339
  | `drive` | string | `C:` / `D:\`,把操作限定在一块盘 |
320
340
  | `confirm` | boolean | 不传时 `clean` 只做试运行 |
341
+ | `force` | boolean | `audit` 跳过 10 分钟缓存重新测量 |
342
+ | `area` / `scope` + `name` / `index` | | `reveal` 用它说明要打开什么,从不传路径 |
321
343
 
322
344
  不带 `confirm: true` 的 `clean` 一个字节都不删,只报告本来能释放多少——这就是审批路径。`audit` 只读,并以文本返回处理方案,结尾固定为「把方案报告给用户,不要自己执行这些命令」。
323
345
 
@@ -328,11 +350,12 @@ Client-half slots: `sidebar.panellist` (order 50, disposed in settings-only mode
328
350
  ```jsonc
329
351
  {"action":"overview"} // 磁盘容量 + 目标 id
330
352
  {"action":"scan","drive":"C:"} // 测量后的目标
331
- {"action":"audit"} // 范围 + 最大应用目录 + 方案
353
+ {"action":"audit","force":true} // 范围 + 最大应用目录 + 方案
354
+ {"action":"reveal","area":"downloads"} // 在资源管理器中打开
332
355
  {"action":"clean","ids":["temp"],"confirm":true}
333
356
  ```
334
357
 
335
- 只接受 POST(否则 405),只接受同源(否则 403),body ≤4096 字节,id 最多 32 个,未知 id 或动作返回 400。处理器永远收不到路径。
358
+ 只接受 POST(否则 405),只接受同源(否则 403),body ≤4096 字节,id 最多 32 个,未知 id 或动作返回 400。处理器永远收不到路径:`reveal` 只报一个评估范围(或 scope + name 表示的应用目录),宿主用当初测量它的那张表重建路径,再交给 `explorer.exe`。
336
359
 
337
360
  ### 安全规则
338
361
 
@@ -384,7 +407,7 @@ git clone https://github.com/stallenlu/dsh-disk-cleaner
384
407
  ### 开发
385
408
 
386
409
  ```bash
387
- node smoke.mjs # 61 条断言,零依赖
410
+ node smoke.mjs # 67 条断言,零依赖
388
411
  node --check lib/client.js # 以及 lib/index.js、lib/catalog.js、lib/audit.js、lib/plan.js
389
412
  ```
390
413
 
@@ -401,7 +424,9 @@ node --check lib/client.js # 以及 lib/index.js、lib/catalog.js、li
401
424
  | client bundle 能执行、slot 能注册与拆除 | `node smoke.mjs`(假 DOM 里跑真 bundle) |
402
425
  | 设置能到达插件页、设置页、面板三处 | `node smoke.mjs` + `cordis_inspect_query` 查 slot 占用 |
403
426
  | 路由与工具已注册 | `node smoke.mjs`(一次性 `ctx`) |
404
- | 四个动作端到端 | 对真实宿主 `POST /api/disk-cleaner/` |
427
+ | 五个动作端到端 | 对真实宿主 `POST /api/disk-cleaner/` |
428
+ | 「重新评估」绕过缓存、普通调用仍走缓存 | 连调两次 `audit`:`cached` 先 false 后 true |
429
+ | `reveal` 拒绝路径与目录穿越 | `name: "../.."`,以及既无 area 又无 name → 400 |
405
430
  | 真删保留目标目录、遵守保留期 | 在 Temp 里造一旧一新两个文件验证 |
406
431
  | 试运行不删任何东西 | 不传 `confirm`,字节数不变 |
407
432
  | 盘过滤拒绝未挂载与畸形卷名 | `C:extra`、`Z:`、`abc` → 400 |
@@ -419,7 +444,8 @@ node --check lib/client.js # 以及 lib/index.js、lib/catalog.js、li
419
444
  ### 已知上限
420
445
 
421
446
  - **仅 Windows。** 路径按 Windows 目录布局构造,没有 POSIX 分支。
422
- - **首次评估 40–60 秒。** 命名范围与「最大应用目录」会遍历重叠的树。结果缓存 10 分钟,并在真删后失效,所以只有第一个调用者付这笔钱。`lib/audit.js` 里有 `ponytail:` 注释记录「共享 Map」这个替代方案。
447
+ - **首次评估 30–60 秒。** 命名范围与「最大应用目录」会遍历重叠的树。结果缓存 10 分钟,并在真删后失效,所以只有第一个调用者付这笔钱;面板的「重新评估」会传 `force`,每次都要重付。`lib/audit.js` 里有 `ponytail:` 注释记录「共享 Map」这个替代方案。
448
+ - **更新安装包按时间清理,不靠识别版本。** `*-updater` 目录靠名字认;三天内的下载一律不动,因为只有应用自己知道它装了没有。
423
449
  - **不提权。** 那个拒绝普通权限写入的 Windows 目录只报告,不尝试删除。`C:\Windows\Temp` **不是**其中之一:默认 ACL 给 `BUILTIN\Users` 创建与删除权限,挡住删除的只是被服务占用的 EBUSY,而保留期已经处理了这一类。
424
450
  - **不卸载、不迁移。** 本插件做不到的步骤给命令,不给按钮。
425
451
  - **遍历上限**每棵树 2,000,000 条目,极端大的树会被部分测量,而不是卡死。
@@ -429,16 +455,16 @@ node --check lib/client.js # 以及 lib/index.js、lib/catalog.js、li
429
455
  ```
430
456
  ├─ package.json dsh.bundle.patch + dsh.client { platform: "web" }
431
457
  ├─ cordis.patch.yml - insert: id: dsh-disk-cleaner
432
- ├─ lib/index.js (330) Host:HTTP 路由 + disk_cleaner 工具,共用同一个 run()
433
- ├─ lib/catalog.js (413) 13 个目标,measurePath / purgePath / resolveTargets / drives
434
- ├─ lib/audit.js (262) 只读评估,10 分钟缓存,不暴露任何删除
458
+ ├─ lib/index.js (405) Host:HTTP 路由 + disk_cleaner 工具,共用同一个 run(),含 reveal
459
+ ├─ lib/catalog.js (688) 19 个目标,measurePath / purgePath / resolveTargets / drives
460
+ ├─ lib/audit.js (365) 只读评估,10 分钟缓存,带 areaPaths,不暴露任何删除
435
461
  ├─ lib/plan.js (150) 纯函数:测量结果 → 有序步骤
436
- ├─ lib/client.js (1234) Client 半面:slot、面板、偏好、zh/en 字典、CSS
462
+ ├─ lib/client.js (1355) Client 半面:slot、面板、偏好、zh/en 字典、CSS
437
463
  ├─ locale/{en,zh}.json 展示元数据(meta.title / meta.description)
438
464
  ├─ icon.svg 插件图标
439
465
  ├─ README.md 本文件
440
466
  ├─ LICENSE MIT
441
- └─ smoke.mjs 61 条断言,零依赖
467
+ └─ smoke.mjs 67 条断言,零依赖
442
468
  ```
443
469
 
444
470
  Client 半面注册的 slot:`sidebar.panellist`(order 50,仅设置页模式下拆除)、`main`(以包名为 key)、`settings.section`(order 30,仅侧边栏模式下拆除)、`plugins.bundle.config`(以包名为 key)。
package/lib/audit.js CHANGED
@@ -13,12 +13,15 @@ import fs from 'node:fs';
13
13
  import os from 'node:os';
14
14
  import path from 'node:path';
15
15
 
16
- import { drives, measurePath, scan } from './catalog.js';
16
+ import { appVersionDirs, drives, measurePath, scan } from './catalog.js';
17
17
  import { buildPlan } from './plan.js';
18
18
 
19
19
  const { promises: fsp } = fs;
20
20
 
21
21
  const MB = 1024 * 1024;
22
+ /** A downloaded installer below this is noise; the multi-hundred-MB ones are the point. */
23
+ const PAYLOAD_FLOOR = 50 * MB;
24
+ const PAYLOAD_NAME = /(setup|installer|-upd-|\.msix$|\.nupkg$|\.msi$)/i;
22
25
 
23
26
  const env = (name) => {
24
27
  const value = process.env[name];
@@ -90,6 +93,20 @@ const AREAS = () => {
90
93
  why: 'Machine-wide applications. Uninstall through Settings.',
91
94
  paths: () => [path.join(systemDrive + path.sep, 'Program Files'), path.join(systemDrive + path.sep, 'Program Files (x86)')],
92
95
  },
96
+ {
97
+ id: 'old-versions',
98
+ group: 'app',
99
+ label: 'Superseded app versions',
100
+ why: 'An installer kept the previous build beside the current one for rollback. Safe to open and delete by hand; naming a version is the only way to be sure which one is live.',
101
+ paths: () => appVersionDirs(),
102
+ },
103
+ {
104
+ id: 'update-payloads',
105
+ group: 'app',
106
+ label: 'Downloaded update packages',
107
+ why: 'Installer executables an updater left in Temp or its own folder after applying. The "app-updaters" cleanup target covers the ones old enough to be finished; this line shows every one still on disk.',
108
+ files: () => updatePayloads(),
109
+ },
93
110
  {
94
111
  id: 'downloads',
95
112
  group: 'personal',
@@ -128,6 +145,53 @@ const AREAS = () => {
128
145
  ];
129
146
  };
130
147
 
148
+ /**
149
+ * Installer executables that updating software downloads and then leaves behind. They are
150
+ * found by name and size inside the handful of folders updates actually land in, so this
151
+ * stays a bounded directory read rather than another full-disk walk.
152
+ */
153
+ const updatePayloads = () => {
154
+ const out = [];
155
+ const roots = [path.join(env('LOCALAPPDATA') ?? '', 'Temp'), env('LOCALAPPDATA'), env('APPDATA')].filter(Boolean);
156
+ for (const root of roots) {
157
+ if (!isDir(root)) continue;
158
+ for (const entry of listDir(root)) {
159
+ if (entry.isSymbolicLink()) continue;
160
+ const abs = path.join(root, entry.name);
161
+ if (entry.isFile()) {
162
+ if (bigPayload(entry.name, abs)) out.push(abs);
163
+ continue;
164
+ }
165
+ // One level down: `Temp/<app>-update-x64/Setup.exe`, `Roaming/<app>/update/x.exe`.
166
+ for (const inner of listDir(abs)) {
167
+ if (!inner.isFile() || inner.isSymbolicLink()) continue;
168
+ const file = path.join(abs, inner.name);
169
+ if (bigPayload(inner.name, file)) out.push(file);
170
+ }
171
+ }
172
+ }
173
+ return out;
174
+ };
175
+
176
+ const bigPayload = (name, abs) => {
177
+ if (!PAYLOAD_NAME.test(name)) return false;
178
+ try {
179
+ const stat = fs.statSync(abs);
180
+ return stat.isFile() && stat.size >= PAYLOAD_FLOOR;
181
+ } catch {
182
+ return false;
183
+ }
184
+ };
185
+
186
+ /** `readdirSync` that answers an empty list instead of throwing on a denied directory. */
187
+ const listDir = (parent) => {
188
+ try {
189
+ return fs.readdirSync(parent, { withFileTypes: true });
190
+ } catch {
191
+ return [];
192
+ }
193
+ };
194
+
131
195
  /** WSL keeps one ext4.vhdx per distro, one level down under a GUID-named folder. */
132
196
  const virtualDisks = (root) => {
133
197
  if (!isDir(root)) return [];
@@ -180,9 +244,13 @@ export function invalidateAudit() {
180
244
  * on a full disk), so a result is reused for a short while: the panel and the agent ask
181
245
  * for the same picture within moments of each other, and re-walking every app folder for
182
246
  * the second caller buys nothing.
247
+ *
248
+ * `force` is what the panel's re-run button passes. Without it that button would answer
249
+ * from the cache and appear to do nothing, which is exactly the complaint it exists to fix.
183
250
  */
184
- export async function audit() {
185
- if (cached !== undefined && Date.now() - cachedAt < CACHE_TTL) return { ...cached, cached: true };
251
+ export async function audit(options = {}) {
252
+ const force = options.force === true;
253
+ if (!force && cached !== undefined && Date.now() - cachedAt < CACHE_TTL) return { ...cached, cached: true };
186
254
  const [areas, largestApps, reclaimable] = await Promise.all([
187
255
  Promise.all(AREAS().map(measureArea)),
188
256
  largestAppDirs(),
@@ -195,33 +263,67 @@ export async function audit() {
195
263
  return { ...cached, cached: false };
196
264
  }
197
265
 
198
- /** Measure one area, or return null when none of its locations exist. */
266
+ /**
267
+ * The concrete locations behind one area id, for opening in Explorer. Recomputed from the
268
+ * area definition rather than sent by the caller, so a reveal request names an area, never
269
+ * a path.
270
+ */
271
+ export function areaPaths(id) {
272
+ const area = AREAS().find((entry) => entry.id === id);
273
+ if (area === undefined) return [];
274
+ const out = [];
275
+ for (const abs of [...(area.files?.() ?? []), ...(area.paths?.() ?? [])]) {
276
+ try {
277
+ fs.statSync(abs);
278
+ out.push(abs);
279
+ } catch {
280
+ /* measured a moment ago, gone since, or simply not present */
281
+ }
282
+ }
283
+ return out;
284
+ }
285
+
286
+ /** Measure one area, or return null when none of its locations exist.
287
+ *
288
+ * `paths` is the concrete list the panel offers to open in Explorer, so a number in the
289
+ * assessment is one double-click away from the folder it came from. It is capped: an area
290
+ * like "installed programs" has hundreds of subfolders, and the panel only needs enough to
291
+ * get the user there.
292
+ */
293
+ const AREA_PATHS = 8;
294
+
199
295
  async function measureArea(area) {
200
296
  let bytes = 0;
201
297
  let files = 0;
202
- let present = 0;
203
- let first;
298
+ const paths = [];
204
299
 
205
300
  for (const file of area.files?.() ?? []) {
206
301
  const size = sizeOfFile(file);
207
- if (size > 0) {
208
- bytes += size;
209
- files += 1;
210
- present += 1;
211
- first ??= file;
212
- }
302
+ if (size <= 0) continue;
303
+ bytes += size;
304
+ files += 1;
305
+ paths.push(file);
213
306
  }
214
307
  for (const abs of area.paths?.() ?? []) {
215
308
  const found = isDir(abs) ? await measurePath(abs) : { bytes: sizeOfFile(abs), files: 1 };
216
309
  if (found.bytes <= 0 && found.files <= 0) continue;
217
310
  bytes += found.bytes;
218
311
  files += found.files;
219
- present += 1;
220
- first ??= abs;
312
+ paths.push(abs);
221
313
  }
222
314
 
223
- if (present === 0) return null;
224
- return { id: area.id, group: area.group, label: area.label, why: area.why, bytes, files, first };
315
+ if (paths.length === 0) return null;
316
+ return {
317
+ id: area.id,
318
+ group: area.group,
319
+ label: area.label,
320
+ why: area.why,
321
+ bytes,
322
+ files,
323
+ first: paths[0],
324
+ paths: paths.slice(0, AREA_PATHS),
325
+ pathCount: paths.length,
326
+ };
225
327
  }
226
328
 
227
329
  /**
@@ -250,8 +352,9 @@ async function largestAppDirs() {
250
352
  entries
251
353
  .filter((entry) => entry.isDirectory() && !entry.isSymbolicLink())
252
354
  .map(async (entry) => {
253
- const measured = await measurePath(path.join(root, entry.name));
254
- return { scope: label, name: entry.name, bytes: measured.bytes, files: measured.files };
355
+ const abs = path.join(root, entry.name);
356
+ const measured = await measurePath(abs);
357
+ return { scope: label, name: entry.name, bytes: measured.bytes, files: measured.files, first: abs };
255
358
  }),
256
359
  );
257
360
  for (const item of found) {
package/lib/catalog.js CHANGED
@@ -67,6 +67,79 @@ const recycleBins = () => {
67
67
  return out;
68
68
  };
69
69
 
70
+ /** `readdirSync` that answers an empty list instead of throwing on a denied directory. */
71
+ const listDir = (parent) => {
72
+ try {
73
+ return fs.readdirSync(parent, { withFileTypes: true });
74
+ } catch {
75
+ return [];
76
+ }
77
+ };
78
+
79
+ /** Compare `0.5.2` style folder names numerically, falling back to a plain string compare. */
80
+ const compareVersion = (a, b) => {
81
+ const pa = a.split(/[._-]/);
82
+ const pb = b.split(/[._-]/);
83
+ for (let i = 0; i < Math.max(pa.length, pb.length); i += 1) {
84
+ const na = Number(pa[i]);
85
+ const nb = Number(pb[i]);
86
+ if (Number.isFinite(na) && Number.isFinite(nb)) {
87
+ if (na !== nb) return na - nb;
88
+ continue;
89
+ }
90
+ const cmp = String(pa[i] ?? '').localeCompare(String(pb[i] ?? ''));
91
+ if (cmp !== 0) return cmp;
92
+ }
93
+ return 0;
94
+ };
95
+
96
+ /**
97
+ * Version folders a per-user install keeps beside itself, such as Qoder's
98
+ * `.qoder-versions/0.5.2`. The newest child stays — it is the version the installer last
99
+ * put there — and the rest are rollback copies. A holder with only one child is skipped,
100
+ * because then nothing distinguishes a rollback copy from the install itself.
101
+ */
102
+ export const appVersionDirs = () => {
103
+ const programs = path.join(env('LOCALAPPDATA') ?? '', 'Programs');
104
+ if (!isDir(programs)) return [];
105
+ const out = [];
106
+ for (const app of listDir(programs)) {
107
+ if (!app.isDirectory() || app.isSymbolicLink()) continue;
108
+ const appDir = path.join(programs, app.name);
109
+ for (const inner of listDir(appDir)) {
110
+ if (!inner.isDirectory() || inner.isSymbolicLink()) continue;
111
+ if (!/versions?$/i.test(inner.name) && !/_old$/i.test(inner.name)) continue;
112
+ const holder = path.join(appDir, inner.name);
113
+ const versions = listDir(holder)
114
+ .filter((entry) => entry.isDirectory() && !entry.isSymbolicLink())
115
+ .map((entry) => entry.name)
116
+ .sort(compareVersion);
117
+ if (versions.length < 2) continue;
118
+ for (const name of versions.slice(0, -1)) out.push(path.join(holder, name));
119
+ }
120
+ }
121
+ return out;
122
+ };
123
+
124
+ /**
125
+ * Update caches: an Electron-style updater keeps the installer it downloaded in
126
+ * `%LOCALAPPDATA%/<app>-updater` after applying it. The name pattern is the only signal a
127
+ * cleaner can use without knowing every vendor; the age limit keeps a download that has
128
+ * not been applied yet.
129
+ */
130
+ const appUpdaters = () => {
131
+ const out = [];
132
+ for (const root of [env('LOCALAPPDATA'), env('APPDATA')]) {
133
+ if (root === undefined || !isDir(root)) continue;
134
+ for (const entry of listDir(root)) {
135
+ if (!entry.isDirectory() || entry.isSymbolicLink()) continue;
136
+ if (!/-updater$/i.test(entry.name)) continue;
137
+ out.push(path.join(root, entry.name));
138
+ }
139
+ }
140
+ return out;
141
+ };
142
+
70
143
  /** Chromium-family caches live per browser profile, so they are discovered, not spelled out. */
71
144
  const browserCaches = (userData) => {
72
145
  if (!isDir(userData)) return [];
@@ -85,6 +158,122 @@ const browserCaches = (userData) => {
85
158
  return out;
86
159
  };
87
160
 
161
+ /**
162
+ * Per-app cache folders inside `%APPDATA%`. Every one is spelled out instead of discovered by
163
+ * a `*cache*` sweep: a folder is only safe to delete when somebody has looked at what it
164
+ * holds, and the same sweep that finds this WPS cache also finds WeChat's profile store and
165
+ * VS Code's web storage, which cost a re-login to rebuild.
166
+ */
167
+ const appCaches = () => {
168
+ const roaming = env('APPDATA') ?? '';
169
+ return [
170
+ path.join(roaming, 'kingsoft', 'wps', 'addons', 'data', 'win-i386', 'cef', '2', 'cache'),
171
+ path.join(roaming, 'FTNN', '2.0', 'Common', 'cef_cache'),
172
+ ...subdirs(path.join(roaming, 'com.qoder.app.stable'), ['Cache', 'Code Cache']),
173
+ ...subdirs(path.join(roaming, 'Trae CN'), ['Cache', 'CachedData']),
174
+ ...subdirs(path.join(roaming, 'Trae'), ['Cache', 'CachedData']),
175
+ ...subdirs(path.join(roaming, '@deepseek-ai', 'dsh-desktop'), ['Cache', 'Code Cache', 'GPUCache', 'GrShaderCache']),
176
+ ...subdirs(path.join(roaming, 'PersonalCenter'), ['Cache']),
177
+ ...subdirs(path.join(roaming, '微信开发者工具'), ['Cache']),
178
+ ...subdirs(path.join(roaming, '腾讯电脑管家-全局搜'), ['Cache']),
179
+ ...subdirs(path.join(roaming, 'appGallery'), ['Crashpad']),
180
+ ];
181
+ };
182
+
183
+ /** Log folders that only ever hold history: nothing reads an old log back at runtime. */
184
+ const appLogs = () => {
185
+ const roaming = env('APPDATA') ?? '';
186
+ return [
187
+ path.join(roaming, 'FTNN', '2.0', 'Logs'),
188
+ path.join(roaming, 'Tencent', 'xwechat', 'log'),
189
+ ...subdirs(path.join(roaming, 'com.qoder.app.stable'), ['logs']),
190
+ ...subdirs(path.join(roaming, 'Trae CN'), ['logs']),
191
+ ...subdirs(path.join(roaming, 'Trae'), ['logs']),
192
+ ...subdirs(path.join(roaming, 'DingTalk'), ['log']),
193
+ ...subdirs(path.join(roaming, 'PersonalCenter'), ['log']),
194
+ ...subdirs(path.join(roaming, 'io.github.clash-verge-rev.clash-verge-rev'), ['logs']),
195
+ ...subdirs(path.join(roaming, 'MAXHUBShare'), ['log']),
196
+ ];
197
+ };
198
+
199
+ /**
200
+ * Scratch directories an app keeps inside its own roaming folder rather than in `%TEMP%`.
201
+ * These belong to an app that may be running right now, so the target keeps a two-day floor
202
+ * instead of being purged outright.
203
+ */
204
+ const appTempDirs = () => [
205
+ path.join(env('APPDATA') ?? '', 'WorkDaddy', 'client-tmp'),
206
+ path.join(env('APPDATA') ?? '', 'PersonalCenter', 'Temp'),
207
+ ];
208
+
209
+ /**
210
+ * A plugin pool keeps every version it ever installed, because the manager picks one by name
211
+ * instead of replacing it in place. WPS alone had 347 folders across 131 families here. The
212
+ * highest version of each family stays — it is what the manager loads — and the rest are dead
213
+ * weight it never reads again. Families with a single member are skipped: nothing then proves
214
+ * which one is live.
215
+ */
216
+ const pooledOldVersions = (pool, pattern) => {
217
+ if (!isDir(pool)) return [];
218
+ const families = new Map();
219
+ for (const entry of listDir(pool)) {
220
+ if (entry.isSymbolicLink()) continue;
221
+ const match = pattern.exec(entry.name);
222
+ if (match === null) continue;
223
+ const [, base, version] = match;
224
+ if (!families.has(base)) families.set(base, []);
225
+ families.get(base).push({ name: entry.name, version });
226
+ }
227
+ const out = [];
228
+ for (const list of families.values()) {
229
+ if (list.length < 2) continue;
230
+ list.sort((a, b) => compareVersion(a.version, b.version));
231
+ for (const older of list.slice(0, -1)) out.push(path.join(pool, older.name));
232
+ }
233
+ return out;
234
+ };
235
+
236
+ const wpsPluginOldVersions = () =>
237
+ pooledOldVersions(path.join(env('APPDATA') ?? '', 'kingsoft', 'wps', 'addons', 'pool', 'win-i386'), /^(.+)_([0-9][0-9._]*)$/);
238
+
239
+ /** The version folders an installer left under its own `app` directory, such as FTNN's. */
240
+ const installedAppVersions = (appRoot) => {
241
+ if (!isDir(appRoot)) return [];
242
+ return listDir(appRoot)
243
+ .filter((entry) => entry.isDirectory() && !entry.isSymbolicLink())
244
+ .map((entry) => entry.name)
245
+ .filter((name) => /^[0-9][0-9._]*$/.test(name));
246
+ };
247
+
248
+ /**
249
+ * Update packages the vendor has already applied, decided by comparison rather than by age.
250
+ * FTNN names a patch `Download-<from>-<to>-<hash>.spd` with bare build numbers while it keeps
251
+ * full `16.35.27108` folders in Program Files, so the comparison is build number against the
252
+ * newest installed build. A patch at or above that build may still be pending, so it stays.
253
+ */
254
+ const ftnnAppliedPatches = () => {
255
+ const programFiles = env('ProgramFiles');
256
+ if (programFiles === undefined) return [];
257
+ const installed = installedAppVersions(path.join(programFiles, 'FTNN', 'app')).sort(compareVersion);
258
+ if (installed.length === 0) return [];
259
+ const newestBuild = Number(installed[installed.length - 1].split('.').pop());
260
+ if (!Number.isFinite(newestBuild)) return [];
261
+ const liveUpdate = path.join(env('APPDATA') ?? '', 'FTNN', '2.0', 'LiveUpdate', 'win_qtnn');
262
+ const out = [];
263
+ for (const entry of listDir(liveUpdate)) {
264
+ if (!entry.isFile() || entry.isSymbolicLink()) continue;
265
+ const match = /^Download-[0-9]+-([0-9]+)-[0-9a-f]+\.spd$/i.exec(entry.name);
266
+ if (match === null) continue;
267
+ if (Number(match[1]) < newestBuild) out.push(path.join(liveUpdate, entry.name));
268
+ }
269
+ return out;
270
+ };
271
+
272
+ const supersededUpdatePackages = () => [
273
+ ...ftnnAppliedPatches(),
274
+ ...pooledOldVersions(path.join(env('APPDATA') ?? '', 'WorkDaddy', 'update'), /^(.+)-([0-9][0-9._]*)(\.[A-Za-z0-9]+)?$/),
275
+ ];
276
+
88
277
  /**
89
278
  * `purge` targets drop every entry under the path. `aged` targets keep anything modified
90
279
  * within `minAgeDays`, because those files may belong to a process that is still running.
@@ -199,6 +388,55 @@ const CATALOG = [
199
388
  roots: ['LOCALAPPDATA'],
200
389
  paths: () => [path.join(env('LOCALAPPDATA') ?? '', 'Microsoft', 'Windows', 'INetCache')],
201
390
  },
391
+ {
392
+ id: 'app-updaters',
393
+ kind: 'aged',
394
+ minAgeDays: 3,
395
+ risk: 'caution',
396
+ note: 'pending-update',
397
+ roots: ['LOCALAPPDATA', 'APPDATA'],
398
+ paths: () => appUpdaters(),
399
+ },
400
+ {
401
+ id: 'app-caches',
402
+ kind: 'purge',
403
+ risk: 'caution',
404
+ note: 'rebuild',
405
+ roots: ['APPDATA'],
406
+ paths: () => appCaches(),
407
+ },
408
+ {
409
+ id: 'app-logs',
410
+ kind: 'purge',
411
+ risk: 'safe',
412
+ roots: ['APPDATA'],
413
+ paths: () => appLogs(),
414
+ },
415
+ {
416
+ id: 'app-temp',
417
+ kind: 'aged',
418
+ minAgeDays: 2,
419
+ risk: 'caution',
420
+ note: 'in-use',
421
+ roots: ['APPDATA'],
422
+ paths: () => appTempDirs(),
423
+ },
424
+ {
425
+ id: 'plugin-old-versions',
426
+ kind: 'purge',
427
+ risk: 'caution',
428
+ note: 'rollback',
429
+ roots: ['APPDATA'],
430
+ paths: () => wpsPluginOldVersions(),
431
+ },
432
+ {
433
+ id: 'update-packages',
434
+ kind: 'purge',
435
+ risk: 'caution',
436
+ note: 'superseded',
437
+ roots: ['APPDATA'],
438
+ paths: () => supersededUpdatePackages(),
439
+ },
202
440
  ];
203
441
 
204
442
  const byId = new Map(CATALOG.map((entry) => [entry.id, entry]));
@@ -227,6 +465,19 @@ const within = (abs, root) => {
227
465
  /** A volume such as `C:\`; anything else (including undefined) means every volume. */
228
466
  const onDrive = (abs, drive) => drive === undefined || path.parse(abs).root.toLowerCase() === drive.toLowerCase();
229
467
 
468
+ /**
469
+ * Some targets are single files (an already-applied update package) rather than directories,
470
+ * so every step below answers both. A file target is unlinked directly and never has its
471
+ * parent removed.
472
+ */
473
+ const isFile = (p) => {
474
+ try {
475
+ return fs.statSync(p).isFile();
476
+ } catch {
477
+ return false;
478
+ }
479
+ };
480
+
230
481
  /** Resolve catalog ids to the concrete directories that exist right now, optionally on one volume. */
231
482
  export function resolveTargets(only, drive) {
232
483
  const selected = only === undefined ? CATALOG : CATALOG.filter((entry) => only.includes(entry.id));
@@ -235,7 +486,7 @@ export function resolveTargets(only, drive) {
235
486
  for (const candidate of entry.paths()) {
236
487
  if (typeof candidate !== 'string' || candidate === '') continue;
237
488
  if (tooBroad(candidate)) continue;
238
- if (!isDir(candidate)) continue;
489
+ if (!isDir(candidate) && !isFile(candidate)) continue;
239
490
  if (!onDrive(candidate, drive)) continue;
240
491
  const roots = entry.roots.map((name) => env(name)).filter(Boolean);
241
492
  if (roots.length > 0 && !roots.some((root) => within(candidate, root))) continue;
@@ -256,6 +507,13 @@ export function resolveTargets(only, drive) {
256
507
  }
257
508
 
258
509
  export async function measurePath(abs) {
510
+ if (isFile(abs)) {
511
+ try {
512
+ return { bytes: fs.statSync(abs).size, files: 1 };
513
+ } catch {
514
+ return { bytes: 0, files: 0 };
515
+ }
516
+ }
259
517
  let bytes = 0;
260
518
  let files = 0;
261
519
  let seen = 0;
@@ -385,6 +643,18 @@ export async function clean(ids, options = {}) {
385
643
  failed += 1;
386
644
  continue;
387
645
  }
646
+ if (isFile(abs)) {
647
+ try {
648
+ const stat = await fsp.stat(abs);
649
+ if (cutoff !== undefined && stat.mtimeMs > cutoff) continue;
650
+ await fsp.unlink(abs);
651
+ bytes += stat.size;
652
+ removed += 1;
653
+ } catch {
654
+ failed += 1;
655
+ }
656
+ continue;
657
+ }
388
658
  const outcome = await purgePath(abs, cutoff);
389
659
  bytes += outcome.bytes;
390
660
  removed += outcome.removed;
package/lib/client.js CHANGED
@@ -38,6 +38,9 @@ window.__ModuleLoader__.load({
38
38
  'why_in-use': '只删 {n} 天前的文件,正在使用的会跳过',
39
39
  why_rebuild: '缓存会自动重建,下次打开略慢',
40
40
  why_elevation: '需要管理员权限;普通窗口下会被系统拒绝',
41
+ 'why_pending-update': '只清 {n} 天前的下载,避免删掉还没装的更新',
42
+ why_rollback: '每个插件/程序只留版本号最高的一份,其余是回滚副本',
43
+ why_superseded: '版本号不高于已装版本的更新包,装着的那份已经生效',
41
44
  pathCount: '{n} 处',
42
45
  driveLabel: '清理范围',
43
46
  allDrives: '全部磁盘',
@@ -57,6 +60,12 @@ window.__ModuleLoader__.load({
57
60
  'target_inet-cache': '系统网络缓存',
58
61
  'target_windows-update': 'Windows 更新缓存',
59
62
  'target_recycle-bin': '回收站',
63
+ 'target_app-updaters': '更新安装包残留',
64
+ 'target_app-caches': '各应用缓存',
65
+ 'target_app-logs': '各应用日志',
66
+ 'target_app-temp': '各应用临时目录',
67
+ 'target_plugin-old-versions': '插件旧版本',
68
+ 'target_update-packages': '已应用的更新包',
60
69
  auditTitle: '空间评估(本插件不会删除)',
61
70
  auditHint: '统计缓存之外真正占空间的东西:已装软件、WSL 虚拟磁盘、Windows 组件库,以及你自己的文件。这些一律不由本插件删除,只列出供你判断。',
62
71
  auditRun: '开始评估',
@@ -72,6 +81,32 @@ window.__ModuleLoader__.load({
72
81
  auditScopeLocal: 'Local',
73
82
  auditScopeRoaming: 'Roaming',
74
83
  auditError: '评估失败',
84
+ auditOpen: '在资源管理器中打开',
85
+ auditOpened: '已打开',
86
+ area_wsl: 'WSL 虚拟磁盘',
87
+ area_wsl_why: 'WSL 的 ext4.vhdx 只增不减,删发行版会丢数据;回收只能压缩,见下面的处理方案。',
88
+ area_winsxs: 'Windows 组件库',
89
+ area_winsxs_why: '系统更新的旧版本备份。只能由 DISM 清理,手动删会损坏系统。',
90
+ 'area_system-files': '休眠与页面文件',
91
+ 'area_system-files_why': '由 Windows 管理系统文件,只能通过关闭休眠或调整虚拟内存来回收。',
92
+ area_programs: '用户安装的程序',
93
+ 'area_program-files': '全机安装的程序',
94
+ area_programs_why: '装在你账户下的软件本体,删掉等同于卸载。',
95
+ 'area_program-files_why': 'Program Files 里的软件本体,请走正常的卸载流程。',
96
+ 'area_old-versions': '旧版本程序目录',
97
+ 'area_old-versions_why': '安装器留了上一版供回滚。确认当前版本没问题后,可以自己删掉旧版。',
98
+ 'area_update-payloads': '已下载的更新包',
99
+ 'area_update-payloads_why': '安装完通常会留下的 setup/installer 文件。三天内的下载视为可能还没装,不动。',
100
+ area_downloads: '下载文件夹',
101
+ area_downloads_why: '你自己下载的文件,插件一律不碰。',
102
+ area_documents: '文档',
103
+ area_documents_why: '你自己的文档,插件一律不碰。',
104
+ area_pictures: '图片',
105
+ area_pictures_why: '你自己的图片,插件一律不碰。',
106
+ area_videos: '视频',
107
+ area_videos_why: '你自己的视频,插件一律不碰。',
108
+ area_desktop: '桌面',
109
+ area_desktop_why: '桌面上放着的文件,插件一律不碰。',
75
110
  planTitle: '处理方案',
76
111
  planHint: '按"收益 ÷ 代价"排序:先做零风险的部分,再做需要一次慎重操作的,最后是只能你自己决定的。',
77
112
  planNow: '现在就能回收',
@@ -147,6 +182,9 @@ window.__ModuleLoader__.load({
147
182
  'why_in-use': 'only files older than {n} day(s); files in use are skipped',
148
183
  why_rebuild: 'rebuilt automatically; the next open is slower',
149
184
  why_elevation: 'needs an elevated prompt; a normal window is refused by Windows',
185
+ 'why_pending-update': 'only downloads older than {n} day(s), so a pending update survives',
186
+ why_rollback: 'only the highest version of each family stays; the rest are rollback copies',
187
+ why_superseded: 'the update package version is not above what is installed, so it is already applied',
150
188
  pathCount: '{n} location(s)',
151
189
  driveLabel: 'Limit to',
152
190
  allDrives: 'All drives',
@@ -166,6 +204,12 @@ window.__ModuleLoader__.load({
166
204
  'target_inet-cache': 'System internet cache',
167
205
  'target_windows-update': 'Windows update cache',
168
206
  'target_recycle-bin': 'Recycle Bin',
207
+ 'target_app-updaters': 'Leftover update installers',
208
+ 'target_app-caches': 'App caches',
209
+ 'target_app-logs': 'App logs',
210
+ 'target_app-temp': 'App scratch folders',
211
+ 'target_plugin-old-versions': 'Old plugin versions',
212
+ 'target_update-packages': 'Applied update packages',
169
213
  auditTitle: 'Space assessment (this plugin deletes none of it)',
170
214
  auditHint: 'What actually takes up room outside caches: installed programs, WSL disk images, the Windows component store, and your own files. None of it is removed here; it is listed so you can decide.',
171
215
  auditRun: 'Run assessment',
@@ -181,6 +225,32 @@ window.__ModuleLoader__.load({
181
225
  auditScopeLocal: 'Local',
182
226
  auditScopeRoaming: 'Roaming',
183
227
  auditError: 'Assessment failed',
228
+ auditOpen: 'Open in Explorer',
229
+ auditOpened: 'Opened',
230
+ area_wsl: 'WSL distros (ext4.vhdx)',
231
+ area_wsl_why: 'The WSL ext4.vhdx only grows. Deleting a distro loses data; compacting is the only way to reclaim it, see the plan below.',
232
+ area_winsxs: 'Windows component store (WinSxS)',
233
+ area_winsxs_why: 'Superseded copies of system updates. Only DISM may clean these; deleting by hand damages Windows.',
234
+ 'area_system-files': 'Hibernation and page files',
235
+ 'area_system-files_why': 'Managed by Windows. Reclaimed only by turning hibernation off or shrinking virtual memory.',
236
+ area_programs: 'Installed programs (user scope)',
237
+ area_programs_why: 'The program itself, installed under your account. Deleting it is uninstalling it.',
238
+ 'area_program-files': 'Installed programs (machine scope)',
239
+ 'area_program-files_why': 'Program bodies under Program Files. Remove them through the normal uninstaller.',
240
+ 'area_old-versions': 'Superseded app versions',
241
+ 'area_old-versions_why': 'An installer kept the previous build for rollback. Once the current version is fine, delete the old one by hand.',
242
+ 'area_update-payloads': 'Downloaded update packages',
243
+ 'area_update-payloads_why': 'Setup/installer files left behind after an update. Usually safe to delete; anything from the last three days may still be pending.',
244
+ area_downloads: 'Downloads',
245
+ area_downloads_why: 'Your own downloads. This plugin never touches them.',
246
+ area_documents: 'Documents',
247
+ area_documents_why: 'Your own documents. This plugin never touches them.',
248
+ area_pictures: 'Pictures',
249
+ area_pictures_why: 'Your own pictures. This plugin never touches them.',
250
+ area_videos: 'Videos',
251
+ area_videos_why: 'Your own videos. This plugin never touches them.',
252
+ area_desktop: 'Desktop',
253
+ area_desktop_why: 'Files sitting on your desktop. This plugin never touches them.',
184
254
  planTitle: 'Suggested plan',
185
255
  planHint: 'Ordered by gain against cost: the zero-risk part first, then the one deliberate operation, then what only you can decide.',
186
256
  planNow: 'Reclaimable now',
@@ -296,6 +366,9 @@ window.__ModuleLoader__.load({
296
366
  .dsc-audit-group-title { font-size: 11px; font-weight: 600; letter-spacing: .04em; text-transform: uppercase; color: var(--dsw-alias-label-secondary); margin-bottom: 4px; }
297
367
  .dsc-audit-row { display: flex; align-items: baseline; gap: 10px; padding: 6px 8px; border-radius: 7px; }
298
368
  .dsc-audit-row:hover { background: var(--dsw-alias-bg-layer-2); }
369
+ .dsc-openable { cursor: pointer; }
370
+ .dsc-openable:hover .dsc-audit-label { text-decoration: underline; text-decoration-color: var(--dsw-alias-border-l1); text-underline-offset: 3px; }
371
+ .dsc-openable:focus-visible { outline: 2px solid var(--dsw-alias-brand-primary); outline-offset: 1px; }
299
372
  .dsc-audit-name { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
300
373
  .dsc-audit-label { font-weight: 600; }
301
374
  .dsc-audit-why { color: var(--dsw-alias-label-secondary); font-size: 11px; line-height: 16px; }
@@ -487,6 +560,17 @@ window.__ModuleLoader__.load({
487
560
  return text.replace('{n}', String(row.minAgeDays || 1));
488
561
  }
489
562
 
563
+ /**
564
+ * The host measures areas and sends an English label and reason; the dictionary carries
565
+ * a translation per area id and falls back to what the host sent when it has none, so a
566
+ * newly added area shows up in English instead of disappearing.
567
+ */
568
+ function areaText(t, area, suffix) {
569
+ const key = `area_${area.id}${suffix}`;
570
+ const text = t(key);
571
+ return text === key ? (suffix === '' ? area.label : area.why) : text;
572
+ }
573
+
490
574
  function DriveBar({ drive, t }) {
491
575
  const ratio = drive.total > 0 ? drive.used / drive.total : 0;
492
576
  const level = ratio >= 0.92 ? ' dsc-crit' : ratio >= 0.8 ? ' dsc-warn' : '';
@@ -865,12 +949,15 @@ window.__ModuleLoader__.load({
865
949
  const [data, setData] = usePersistent('audit', null);
866
950
  const [busy, setBusy] = useState(false);
867
951
  const [error, setError] = usePersistent('auditError', '');
952
+ // Where the last click opened: 24px of feedback beats a silent no-op on Windows,
953
+ // which sometimes takes a second to raise the Explorer window.
954
+ const [opened, setOpened] = useState('');
868
955
 
869
- const run = useCallback(async () => {
956
+ const run = useCallback(async (force) => {
870
957
  setBusy(true);
871
958
  setError('');
872
959
  try {
873
- const result = await call('audit');
960
+ const result = await call('audit', force === true ? { force: true } : {});
874
961
  setData(result);
875
962
  } catch (err) {
876
963
  setError(`${t('auditError')}: ${err instanceof Error ? err.message : String(err)}`);
@@ -892,6 +979,39 @@ window.__ModuleLoader__.load({
892
979
  const apps = data && Array.isArray(data.largestApps) ? data.largestApps : [];
893
980
  const total = areas.reduce((sum, area) => sum + (area.bytes || 0), 0);
894
981
 
982
+ /**
983
+ * Open one measured location in Explorer. The host rebuilds the path from the area
984
+ * (or scope + name) it measured, so nothing here can hand it a path of our choosing.
985
+ */
986
+ const revealAt = async (payload) => {
987
+ setError('');
988
+ try {
989
+ const result = await call('reveal', payload);
990
+ setOpened(result && result.revealed ? result.revealed : '');
991
+ } catch (err) {
992
+ setError(`${t('auditError')}: ${err instanceof Error ? err.message : String(err)}`);
993
+ }
994
+ };
995
+
996
+ /** A row that has somewhere to open gets click and right-click; one without stays plain. */
997
+ const openable = (payload) => {
998
+ const open = (event) => {
999
+ event.preventDefault();
1000
+ revealAt(payload);
1001
+ };
1002
+ return {
1003
+ className: 'dsc-audit-row dsc-openable',
1004
+ role: 'button',
1005
+ tabIndex: 0,
1006
+ title: t('auditOpen'),
1007
+ onClick: open,
1008
+ onContextMenu: open,
1009
+ onKeyDown: (event) => {
1010
+ if (event.key === 'Enter' || event.key === ' ') open(event);
1011
+ },
1012
+ };
1013
+ };
1014
+
895
1015
  /** Run a plan step the plugin is allowed to run, then re-measure so the plan is honest. */
896
1016
  const runStep = async (step) => {
897
1017
  if (confirmFirst.get() === 'always' && !window.confirm(t('cleanConfirm'))) return;
@@ -924,12 +1044,13 @@ window.__ModuleLoader__.load({
924
1044
  data && data.cached ? h('span', { className: 'dsc-total' }, t('auditCached')) : null,
925
1045
  h(
926
1046
  'button',
927
- { type: 'button', className: 'dsc-btn', onClick: run, disabled: busy },
1047
+ { type: 'button', className: 'dsc-btn', onClick: () => run(true), disabled: busy },
928
1048
  busy ? t('auditRunning') : data ? t('auditRescan') : t('auditRun'),
929
1049
  ),
930
1050
  ),
931
1051
  h('p', { className: 'dsc-audit-hint' }, t('auditHint')),
932
1052
  error ? h('div', { className: 'dsc-notice dsc-err' }, error) : null,
1053
+ opened ? h('div', { className: 'dsc-audit-hint' }, `${t('auditOpened')} ${opened}`) : null,
933
1054
  busy && !data ? h('div', { className: 'dsc-empty' }, t('auditCold')) : null,
934
1055
  data
935
1056
  ? [
@@ -948,12 +1069,12 @@ window.__ModuleLoader__.load({
948
1069
  list.map((area) =>
949
1070
  h(
950
1071
  'div',
951
- { className: 'dsc-audit-row', key: area.id },
1072
+ { key: area.id, ...openable({ area: area.id, index: 0 }) },
952
1073
  h(
953
1074
  'span',
954
1075
  { className: 'dsc-audit-name' },
955
- h('span', { className: 'dsc-audit-label' }, area.label),
956
- h('span', { className: 'dsc-audit-why' }, area.why),
1076
+ h('span', { className: 'dsc-audit-label' }, areaText(t, area, '')),
1077
+ h('span', { className: 'dsc-audit-why' }, areaText(t, area, '_why')),
957
1078
  ),
958
1079
  h('span', { className: 'dsc-audit-size' }, formatBytes(area.bytes)),
959
1080
  ),
@@ -968,7 +1089,7 @@ window.__ModuleLoader__.load({
968
1089
  apps.map((app) =>
969
1090
  h(
970
1091
  'div',
971
- { className: 'dsc-audit-row', key: `${app.scope}/${app.name}` },
1092
+ { key: `${app.scope}/${app.name}`, ...openable({ scope: app.scope, name: app.name }) },
972
1093
  h(
973
1094
  'span',
974
1095
  { className: 'dsc-audit-name' },
package/lib/index.js CHANGED
@@ -5,7 +5,11 @@
5
5
  * cleaning logic exists once (in catalog.js) and is reached two ways: an HTTP route for
6
6
  * the panel and a tool for the agent.
7
7
  */
8
- import { audit, invalidateAudit } from './audit.js';
8
+ import fs from 'node:fs';
9
+ import path from 'node:path';
10
+ import { spawn } from 'node:child_process';
11
+
12
+ import { areaPaths, audit, invalidateAudit } from './audit.js';
9
13
  import { clean, drives, hasTarget, resolveTargets, scan } from './catalog.js';
10
14
 
11
15
  export const name = 'dsh-disk-cleaner';
@@ -104,6 +108,46 @@ const toPublic = (target) => ({
104
108
  pathCount: target.paths.length,
105
109
  });
106
110
 
111
+ /**
112
+ * Open a folder this plugin measured, in Explorer.
113
+ *
114
+ * The request never carries a path: it names an assessment area (or an app folder by
115
+ * scope and name) and the path is rebuilt here from the same code that measured it. A
116
+ * caller therefore cannot hand `explorer.exe` a string of its own choosing, which is the
117
+ * only thing standing between "open my Downloads folder" and "launch anything on disk".
118
+ */
119
+ function revealPath(payload) {
120
+ if (typeof payload.area === 'string' && payload.area !== '') {
121
+ const index = Number.isInteger(payload.index) ? payload.index : 0;
122
+ const paths = areaPaths(payload.area);
123
+ if (index < 0 || index >= paths.length) throw new Error(`no location ${index} in area: ${payload.area}`);
124
+ return paths[index];
125
+ }
126
+ if (typeof payload.name === 'string' && payload.name !== '') {
127
+ if (/[\\/]/.test(payload.name) || payload.name === '.' || payload.name === '..') {
128
+ throw new Error('name must be a single folder name');
129
+ }
130
+ const root = payload.scope === 'roaming' ? process.env.APPDATA : payload.scope === 'local' ? process.env.LOCALAPPDATA : undefined;
131
+ if (root === undefined || root === '') throw new Error(`unknown scope: ${String(payload.scope)}`);
132
+ return path.join(root, payload.name);
133
+ }
134
+ throw new Error('reveal needs an area or a scope and name');
135
+ }
136
+
137
+ function reveal(payload) {
138
+ const abs = revealPath(payload);
139
+ let stat;
140
+ try {
141
+ stat = fs.statSync(abs);
142
+ } catch {
143
+ throw new Error('that folder no longer exists');
144
+ }
145
+ // `/select,` highlights the item; a bare path would open its parent for a file.
146
+ const args = stat.isDirectory() ? [abs] : [`/select,${abs}`];
147
+ spawn('explorer.exe', args, { detached: true, stdio: 'ignore' }).unref();
148
+ return { revealed: abs, kind: stat.isDirectory() ? 'folder' : 'file' };
149
+ }
150
+
107
151
  const overview = () => ({ drives: drives(), targets: resolveTargets().map(toPublic) });
108
152
 
109
153
  async function run(action, payload) {
@@ -114,7 +158,8 @@ async function run(action, payload) {
114
158
  targets: measured.map((target) => ({ ...toPublic(target), bytes: target.bytes, files: target.files })),
115
159
  };
116
160
  }
117
- if (action === 'audit') return audit();
161
+ if (action === 'audit') return audit({ force: payload.force === true });
162
+ if (action === 'reveal') return reveal(payload);
118
163
  if (action === 'clean') {
119
164
  const ids = readIds(payload.ids);
120
165
  if (ids === undefined || ids.length === 0) throw new Error('clean needs at least one target id');
@@ -150,6 +195,8 @@ const NOTE_MEANING = {
150
195
  'in-use': 'a running program may hold some of these files; only files older than the age limit go',
151
196
  rebuild: 'the owner rebuilds these, so the next start or page load is slower',
152
197
  elevation: 'Windows refuses an unelevated delete here; run the plugin elevated to reach these bytes',
198
+ rollback: 'an older version the app keeps for rollback; what it runs now is the newest of that family',
199
+ superseded: 'an update package whose version is at or below what is already installed',
153
200
  };
154
201
 
155
202
  const formatBytes = (bytes) => {
@@ -234,11 +281,13 @@ export function apply(ctx) {
234
281
  'WSL disk images, the Windows component store, chat-app data and the user\'s own folders — so the ' +
235
282
  'answer covers the whole disk rather than only caches. audit is read-only: it never removes anything, ' +
236
283
  'and it also returns a suggested plan — the same numbers ordered into steps, with the exact command ' +
237
- 'for the ones this plugin cannot perform. Report that plan to the user; never run those commands yourself.',
284
+ 'for the ones this plugin cannot perform. Report that plan to the user; never run those commands yourself. ' +
285
+ 'Action "reveal" opens one measured location in Explorer: pass an "area" id, or "scope" (local|roaming) ' +
286
+ 'with "name" for one app folder. It takes no path.',
238
287
  parameters: {
239
288
  type: 'object',
240
289
  properties: {
241
- action: { type: 'string', enum: ['overview', 'scan', 'clean', 'audit'] },
290
+ action: { type: 'string', enum: ['overview', 'scan', 'clean', 'audit', 'reveal'] },
242
291
  ids: {
243
292
  type: 'array',
244
293
  items: { type: 'string' },
@@ -254,6 +303,27 @@ export function apply(ctx) {
254
303
  type: 'boolean',
255
304
  description: 'Set true to actually delete. Without it, clean only reports a dry run.',
256
305
  },
306
+ force: {
307
+ type: 'boolean',
308
+ description: 'With audit, re-measure instead of answering from the ten-minute cache.',
309
+ },
310
+ area: {
311
+ type: 'string',
312
+ description: 'With reveal, the assessment area id to open. Rejects an id the assessment did not report.',
313
+ },
314
+ scope: {
315
+ type: 'string',
316
+ enum: ['local', 'roaming'],
317
+ description: 'With reveal and name, which app-data root the folder is under.',
318
+ },
319
+ name: {
320
+ type: 'string',
321
+ description: 'With reveal and scope, one folder name from the largest-app list.',
322
+ },
323
+ index: {
324
+ type: 'number',
325
+ description: 'With reveal and area, which of its locations to open (default 0).',
326
+ },
257
327
  },
258
328
  required: ['action'],
259
329
  additionalProperties: false,
@@ -295,8 +365,12 @@ export function apply(ctx) {
295
365
  const tail = meaning === '' ? '' : `\n\nCaution codes:\n${meaning}`;
296
366
  return `Reclaimable: ${formatBytes(total)}\n\n${lines.join('\n')}${tail}`;
297
367
  }
368
+ if (action === 'reveal') {
369
+ const value = reveal(args);
370
+ return `Opened ${value.revealed} (${value.kind}).`;
371
+ }
298
372
  if (action === 'audit') {
299
- const value = await audit();
373
+ const value = await audit({ force: args.force === true });
300
374
  const total = value.areas.reduce((sum, area) => sum + area.bytes, 0);
301
375
  const lines = value.areas.map(
302
376
  (area) => `${area.label}: ${formatBytes(area.bytes)} in ${area.files} file(s)\n why not automatic: ${area.why}`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-disk-cleaner",
3
- "version": "1.0.1",
3
+ "version": "1.2.0",
4
4
  "type": "module",
5
5
  "description": "Disk Cleaner for DSH: scan known-safe cache locations, see what they occupy, and reclaim the space from a panel or from the agent.",
6
6
  "keywords": [
package/smoke.mjs CHANGED
@@ -210,6 +210,10 @@ check('confirm choice persisted', storage.get('dsc.confirm') === 'never');
210
210
  check('scan honours the preselect preference', source.includes('const want = defaultPick.get();'));
211
211
  check('clean honours the confirm preference', source.includes("confirmFirst.get() === 'always'"));
212
212
  check('the settings page reuses the same rows', /function SettingsOptions\(\{ t \}\)[\s\S]{0,400}h\(ConfigRows, \{ t \}\)/.test(source));
213
+ check('re-run asks the host to skip its cache', source.includes('onClick: () => run(true)') && source.includes("force === true ? { force: true } : {}"));
214
+ check('an assessment row opens its folder', source.includes("call('reveal'") && source.includes('dsc-audit-row dsc-openable'));
215
+ check('a row opens on click, right click and Enter', /const openable = \(payload\) =>[\s\S]{0,600}onClick: open[\s\S]{0,200}onContextMenu: open[\s\S]{0,300}'Enter'/.test(source));
216
+ check('an app folder is opened by scope and name', source.includes('openable({ scope: app.scope, name: app.name })'));
213
217
 
214
218
  {
215
219
  const button = find(live('main')[0].render(), (n) => n.type === 'button' && n.props && n.props.className === 'dsc-head-btn');
@@ -250,17 +254,20 @@ apply({
250
254
  check('route registered under the documented prefix', route?.kind === 'prefix' && route?.path === '/api/disk-cleaner/');
251
255
  check('tool registered', definition?.name === 'disk_cleaner');
252
256
  check(
253
- 'tool accepts the four documented actions',
254
- JSON.stringify(definition.parameters.properties.action.enum) === JSON.stringify(['overview', 'scan', 'clean', 'audit']),
257
+ 'tool accepts the five documented actions',
258
+ JSON.stringify(definition.parameters.properties.action.enum) === JSON.stringify(['overview', 'scan', 'clean', 'audit', 'reveal']),
255
259
  );
256
260
  check('tool needs no id for overview', JSON.stringify(definition.parameters.required) === JSON.stringify(['action']));
257
261
  check('tool description warns audit is read-only', /read-only: it never removes anything/.test(definition.description));
258
262
  check('every effect is labelled', applied.every((label) => typeof label === 'string' && label.startsWith('dsh-disk-cleaner:')));
259
263
 
260
264
  const ids = [...catalog.matchAll(/^\s{4}id: '([a-z-]+)',$/gm)].map((m) => m[1]);
261
- check('catalog has the documented 13 targets', ids.length === 13, `got ${ids.length}`);
265
+ check('catalog has the documented 19 targets', ids.length === 19, `got ${ids.length}`);
262
266
  check('every catalog id has a client label', ids.every((id) => zhKeys.includes(`target_${id}`) && enKeys.includes(`target_${id}`)));
263
- check('every note code has an explanation', ['why_safe', 'why_in-use', 'why_rebuild', 'why_elevation'].every((k) => zhKeys.includes(k)));
267
+ check('every note code has an explanation', ['why_safe', 'why_in-use', 'why_rebuild', 'why_elevation', 'why_pending-update', 'why_rollback', 'why_superseded'].every((k) => zhKeys.includes(k) && enKeys.includes(k)));
268
+ const areaIds = [...auditSrc.matchAll(/^\s{6}id: '([a-z-]+)',$/gm)].map((m) => m[1]);
269
+ check('every assessment area has a translated label and reason', areaIds.length > 0 && areaIds.every((id) => zhKeys.includes(`area_${id}`) && zhKeys.includes(`area_${id}_why`) && enKeys.includes(`area_${id}`) && enKeys.includes(`area_${id}_why`)), `areas: ${areaIds.join(',')}`);
270
+ check('a reveal request names an area, never a path', index.includes("action === 'reveal'") && auditSrc.includes('export function areaPaths') && !/revealPath[\s\S]{0,200}payload\.path/.test(index));
264
271
  check('no caller may pass a raw path', !/parameters[\s\S]{0,400}paths/.test(index) && catalog.includes('Callers select catalog ids'));
265
272
 
266
273
  const codes = [...new Set([...plan.matchAll(/code: '([a-z-]+)'/g)].map((m) => m[1]))];