dsh-disk-cleaner 1.0.1 → 1.1.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 +51 -35
- package/lib/audit.js +121 -18
- package/lib/catalog.js +82 -0
- package/lib/client.js +114 -7
- package/lib/index.js +77 -5
- package/package.json +1 -1
- package/smoke.mjs +11 -4
package/README.md
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
<!-- badges -->
|
|
6
6
|
| | |
|
|
7
7
|
| --- | --- |
|
|
8
|
-
| **version** | `1.
|
|
8
|
+
| **version** | `1.1.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** —
|
|
19
|
+
1. **Clean** — fourteen known-safe locations (temp folders, package manager stores, browser caches, crash dumps, the Recycle Bin, leftover update installers). 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
|
|
30
|
+
| Scan | Measures all fourteen 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`) 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
|
|
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,13 @@ 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 |
|
|
61
63
|
|
|
62
64
|
`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
65
|
|
|
64
66
|
### Space assessment areas
|
|
65
67
|
|
|
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.
|
|
68
|
+
`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
69
|
|
|
68
70
|
### Plan steps
|
|
69
71
|
|
|
@@ -99,10 +101,12 @@ Panel state (drives, drive filter, scan rows, selection, notices, assessment) is
|
|
|
99
101
|
|
|
100
102
|
| Argument | Type | Notes |
|
|
101
103
|
| --- | --- | --- |
|
|
102
|
-
| `action` | `overview` \| `scan` \| `clean` \| `audit` | required |
|
|
104
|
+
| `action` | `overview` \| `scan` \| `clean` \| `audit` \| `reveal` | required |
|
|
103
105
|
| `ids` | string[] | catalog ids; required for `clean`, max 32 |
|
|
104
106
|
| `drive` | string | `C:` / `D:\` — limits the work to one volume |
|
|
105
107
|
| `confirm` | boolean | without it, `clean` is a dry run |
|
|
108
|
+
| `force` | boolean | `audit` re-measures instead of answering from the 10-minute cache |
|
|
109
|
+
| `area` / `scope` + `name` / `index` | | `reveal` names what to open; never a path |
|
|
106
110
|
|
|
107
111
|
`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
112
|
|
|
@@ -113,11 +117,12 @@ One route, `POST /api/disk-cleaner/`, used by the panel:
|
|
|
113
117
|
```jsonc
|
|
114
118
|
{"action":"overview"} // drives + target ids
|
|
115
119
|
{"action":"scan","drive":"C:"} // measured targets
|
|
116
|
-
{"action":"audit"}
|
|
120
|
+
{"action":"audit","force":true} // areas + largest apps + plan
|
|
121
|
+
{"action":"reveal","area":"downloads"} // opens it in Explorer
|
|
117
122
|
{"action":"clean","ids":["temp"],"confirm":true}
|
|
118
123
|
```
|
|
119
124
|
|
|
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.
|
|
125
|
+
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
126
|
|
|
122
127
|
### Safety rules
|
|
123
128
|
|
|
@@ -165,7 +170,7 @@ The bundle's own `cordis.patch.yml` inserts the plugin row; no profile patch edi
|
|
|
165
170
|
### Development
|
|
166
171
|
|
|
167
172
|
```bash
|
|
168
|
-
node smoke.mjs #
|
|
173
|
+
node smoke.mjs # 67 assertions, zero dependencies
|
|
169
174
|
node --check lib/client.js # and lib/index.js, lib/catalog.js, lib/audit.js, lib/plan.js
|
|
170
175
|
```
|
|
171
176
|
|
|
@@ -182,7 +187,9 @@ What the smoke test deliberately does **not** cover: anything that walks a real
|
|
|
182
187
|
| Client bundle executes, slots register and dispose | `node smoke.mjs` (real bundle in a fake DOM) |
|
|
183
188
|
| Settings reach the plugin page, the settings section and the panel | `node smoke.mjs` + `cordis_inspect_query` slot occupants |
|
|
184
189
|
| 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 |
|
|
190
|
+
| `overview` / `scan` / `clean` / `audit` / `reveal` round-trip | Real `POST /api/disk-cleaner/` against a live host |
|
|
191
|
+
| Re-run bypasses the cache, a plain call still uses it | `audit` twice: `cached: false`, then `true` |
|
|
192
|
+
| `reveal` refuses a path and a traversal | `name: "../.."`, and a request with neither area nor name → 400 |
|
|
186
193
|
| Real deletion keeps the target directory, honours the age limit | Temp directory seeded with old and new files |
|
|
187
194
|
| Dry run deletes nothing | `confirm` omitted, byte counts unchanged |
|
|
188
195
|
| Drive filter rejects unmounted and malformed volumes | `C:extra`, `Z:`, `abc` → 400 |
|
|
@@ -200,7 +207,8 @@ What the smoke test deliberately does **not** cover: anything that walks a real
|
|
|
200
207
|
### Known ceilings
|
|
201
208
|
|
|
202
209
|
- **Windows only.** Paths are built from the Windows layout; there is no POSIX branch.
|
|
203
|
-
- **First assessment takes
|
|
210
|
+
- **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.
|
|
211
|
+
- **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
212
|
- **No elevation.** The one Windows directory that refuses an unelevated write is reported, never attempted.
|
|
205
213
|
- **No uninstall, no move.** Steps the plugin cannot perform carry a command instead of a button.
|
|
206
214
|
- **Traversal caps** at 2,000,000 entries per walk, so an absurdly large tree is measured partially rather than hanging.
|
|
@@ -210,16 +218,16 @@ What the smoke test deliberately does **not** cover: anything that walks a real
|
|
|
210
218
|
```
|
|
211
219
|
├─ package.json dsh.bundle.patch + dsh.client { platform: "web" }
|
|
212
220
|
├─ cordis.patch.yml - insert: id: dsh-disk-cleaner
|
|
213
|
-
├─ lib/index.js (
|
|
214
|
-
├─ lib/catalog.js (
|
|
215
|
-
├─ lib/audit.js (
|
|
221
|
+
├─ lib/index.js (403) Host: HTTP route + disk_cleaner tool, same run() for both; reveal
|
|
222
|
+
├─ lib/catalog.js (500) The 14 targets, measurePath / purgePath / resolveTargets / drives
|
|
223
|
+
├─ lib/audit.js (365) Read-only assessment, 10-minute cache, areaPaths, exposes no deletion
|
|
216
224
|
├─ lib/plan.js (150) Pure: measurements → ordered steps
|
|
217
|
-
├─ lib/client.js (
|
|
225
|
+
├─ lib/client.js (1341) Client half: slots, panel, preferences, zh/en dictionaries, CSS
|
|
218
226
|
├─ locale/{en,zh}.json Display metadata (meta.title / meta.description)
|
|
219
227
|
├─ icon.svg Plugin icon
|
|
220
228
|
├─ README.md This file
|
|
221
229
|
├─ LICENSE MIT
|
|
222
|
-
└─ smoke.mjs
|
|
230
|
+
└─ smoke.mjs 67 assertions, zero dependencies
|
|
223
231
|
```
|
|
224
232
|
|
|
225
233
|
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 +239,7 @@ Client-half slots: `sidebar.panellist` (order 50, disposed in settings-only mode
|
|
|
231
239
|
|
|
232
240
|
**dsh-disk-cleaner** 是 [DeepSeek Harness](https://github.com/deepseek-ai)(DSH)的磁盘空间插件。它做两件不同的事,并且刻意分开:
|
|
233
241
|
|
|
234
|
-
1. **清理** ——
|
|
242
|
+
1. **清理** —— 十四个已知安全的位置(临时目录、包管理器存储、浏览器缓存、崩溃转储、回收站、更新安装包残留)。只有这些会被删除。
|
|
235
243
|
2. **评估** —— 所有本插件**不会**删的东西(已装程序、WSL 虚拟磁盘、Windows 组件库、聊天软件数据、你自己的文件夹)。只测量、只列出,绝不删除。
|
|
236
244
|
|
|
237
245
|
评估的最后是一份**处理方案**:同一批数字按「你要付出多少代价」排序,本插件做不到的步骤附上确切的 Windows 命令。
|
|
@@ -242,16 +250,17 @@ Client-half slots: `sidebar.panellist` (order 50, disposed in settings-only mode
|
|
|
242
250
|
|
|
243
251
|
| 功能 | 说明 |
|
|
244
252
|
| --- | --- |
|
|
245
|
-
| 扫描 |
|
|
253
|
+
| 扫描 | 并发测量全部十四个目标,显示各自占用 |
|
|
246
254
|
| 按盘过滤 | 已挂载卷的胶囊按钮;扫描与清理都可限定单盘,该盘上为空的项会隐藏 |
|
|
247
|
-
| 风险标注 | 每个目标都标 `安全` 或 `注意`,并给一行理由(`in-use` / `rebuild` / `elevation`),图例常驻,所以「注意」永远不是「说不清」 |
|
|
255
|
+
| 风险标注 | 每个目标都标 `安全` 或 `注意`,并给一行理由(`in-use` / `rebuild` / `elevation` / `pending-update`),图例常驻,所以「注意」永远不是「说不清」 |
|
|
248
256
|
| 保留期删除 | `temp` 与 `windows-temp` 只删一天前的文件;被运行中程序占住的自动跳过 |
|
|
249
|
-
| 空间评估 | 只读遍历磁盘,按 Windows 自身 / 已装软件 / 你自己的文件
|
|
257
|
+
| 空间评估 | 只读遍历磁盘,按 Windows 自身 / 已装软件 / 你自己的文件 分组,另列占用最大的十个应用数据目录。每一行都能打开所在目录——单击、右键或回车 |
|
|
258
|
+
| 重新评估真的重测 | 「重新评估」会重新测量;普通调用仍然走十分钟缓存 |
|
|
250
259
|
| 处理方案 | 九种可能步骤,从零风险到只能你决定;本插件能执行的带按钮,其余给可复制的命令 |
|
|
251
260
|
| 入口位置 | 侧边栏图标、设置页分区、或两者都要(默认)——在插件自己的配置卡或面板 ⚙ 里切换 |
|
|
252
261
|
| 行为设置 | 扫描后默认勾选什么、删除前是否确认 |
|
|
253
262
|
| 面板记忆 | 扫描结果、勾选、评估结果在切走再回来时都还在 |
|
|
254
|
-
| Agent 工具 | `disk_cleaner`
|
|
263
|
+
| Agent 工具 | `disk_cleaner` 把同样的五个动作以可读文本交给模型 |
|
|
255
264
|
| 双语 | 跟随宿主语言服务,zh / en 字典一起发布 |
|
|
256
265
|
|
|
257
266
|
### 清理目标
|
|
@@ -273,12 +282,13 @@ Client-half slots: `sidebar.panellist` (order 50, disposed in settings-only mode
|
|
|
273
282
|
| `windows-update` | `C:\Windows\SoftwareDistribution\Download` | 全清 | 注意 | 需要管理员权限 |
|
|
274
283
|
| `recycle-bin` | 各已挂载卷的 `$Recycle.Bin\S-1-5-*` | 全清 | 安全 | 就是回收站 |
|
|
275
284
|
| `inet-cache` | `%LOCALAPPDATA%\Microsoft\Windows\INetCache` | 全清 | 注意 | 会重建 |
|
|
285
|
+
| `app-updaters` | `%LOCALAPPDATA%` 与 `%APPDATA%` 下的 `*-updater` 目录——升级程序留下的安装包 | 保留期 3 天 | 注意 | 三天内的下载可能还是待安装的更新 |
|
|
276
286
|
|
|
277
287
|
保留期方式会跳过保留期内修改过的文件;全清方式清掉全部条目。两者都保留目标目录本身,并把锁定或无权限的条目跳过计数。
|
|
278
288
|
|
|
279
289
|
### 空间评估范围
|
|
280
290
|
|
|
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」指向的地方,设置页里根本找不到。
|
|
291
|
+
`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
292
|
|
|
283
293
|
### 处理方案步骤
|
|
284
294
|
|
|
@@ -314,10 +324,12 @@ Client-half slots: `sidebar.panellist` (order 50, disposed in settings-only mode
|
|
|
314
324
|
|
|
315
325
|
| 参数 | 类型 | 说明 |
|
|
316
326
|
| --- | --- | --- |
|
|
317
|
-
| `action` | `overview` \| `scan` \| `clean` \| `audit` | 必填 |
|
|
327
|
+
| `action` | `overview` \| `scan` \| `clean` \| `audit` \| `reveal` | 必填 |
|
|
318
328
|
| `ids` | string[] | catalog id;`clean` 必填,最多 32 个 |
|
|
319
329
|
| `drive` | string | `C:` / `D:\`,把操作限定在一块盘 |
|
|
320
330
|
| `confirm` | boolean | 不传时 `clean` 只做试运行 |
|
|
331
|
+
| `force` | boolean | `audit` 跳过 10 分钟缓存重新测量 |
|
|
332
|
+
| `area` / `scope` + `name` / `index` | | `reveal` 用它说明要打开什么,从不传路径 |
|
|
321
333
|
|
|
322
334
|
不带 `confirm: true` 的 `clean` 一个字节都不删,只报告本来能释放多少——这就是审批路径。`audit` 只读,并以文本返回处理方案,结尾固定为「把方案报告给用户,不要自己执行这些命令」。
|
|
323
335
|
|
|
@@ -328,11 +340,12 @@ Client-half slots: `sidebar.panellist` (order 50, disposed in settings-only mode
|
|
|
328
340
|
```jsonc
|
|
329
341
|
{"action":"overview"} // 磁盘容量 + 目标 id
|
|
330
342
|
{"action":"scan","drive":"C:"} // 测量后的目标
|
|
331
|
-
{"action":"audit"}
|
|
343
|
+
{"action":"audit","force":true} // 范围 + 最大应用目录 + 方案
|
|
344
|
+
{"action":"reveal","area":"downloads"} // 在资源管理器中打开
|
|
332
345
|
{"action":"clean","ids":["temp"],"confirm":true}
|
|
333
346
|
```
|
|
334
347
|
|
|
335
|
-
只接受 POST(否则 405),只接受同源(否则 403),body ≤4096 字节,id 最多 32 个,未知 id 或动作返回 400
|
|
348
|
+
只接受 POST(否则 405),只接受同源(否则 403),body ≤4096 字节,id 最多 32 个,未知 id 或动作返回 400。处理器永远收不到路径:`reveal` 只报一个评估范围(或 scope + name 表示的应用目录),宿主用当初测量它的那张表重建路径,再交给 `explorer.exe`。
|
|
336
349
|
|
|
337
350
|
### 安全规则
|
|
338
351
|
|
|
@@ -384,7 +397,7 @@ git clone https://github.com/stallenlu/dsh-disk-cleaner
|
|
|
384
397
|
### 开发
|
|
385
398
|
|
|
386
399
|
```bash
|
|
387
|
-
node smoke.mjs #
|
|
400
|
+
node smoke.mjs # 67 条断言,零依赖
|
|
388
401
|
node --check lib/client.js # 以及 lib/index.js、lib/catalog.js、lib/audit.js、lib/plan.js
|
|
389
402
|
```
|
|
390
403
|
|
|
@@ -401,7 +414,9 @@ node --check lib/client.js # 以及 lib/index.js、lib/catalog.js、li
|
|
|
401
414
|
| client bundle 能执行、slot 能注册与拆除 | `node smoke.mjs`(假 DOM 里跑真 bundle) |
|
|
402
415
|
| 设置能到达插件页、设置页、面板三处 | `node smoke.mjs` + `cordis_inspect_query` 查 slot 占用 |
|
|
403
416
|
| 路由与工具已注册 | `node smoke.mjs`(一次性 `ctx`) |
|
|
404
|
-
|
|
|
417
|
+
| 五个动作端到端 | 对真实宿主 `POST /api/disk-cleaner/` |
|
|
418
|
+
| 「重新评估」绕过缓存、普通调用仍走缓存 | 连调两次 `audit`:`cached` 先 false 后 true |
|
|
419
|
+
| `reveal` 拒绝路径与目录穿越 | `name: "../.."`,以及既无 area 又无 name → 400 |
|
|
405
420
|
| 真删保留目标目录、遵守保留期 | 在 Temp 里造一旧一新两个文件验证 |
|
|
406
421
|
| 试运行不删任何东西 | 不传 `confirm`,字节数不变 |
|
|
407
422
|
| 盘过滤拒绝未挂载与畸形卷名 | `C:extra`、`Z:`、`abc` → 400 |
|
|
@@ -419,7 +434,8 @@ node --check lib/client.js # 以及 lib/index.js、lib/catalog.js、li
|
|
|
419
434
|
### 已知上限
|
|
420
435
|
|
|
421
436
|
- **仅 Windows。** 路径按 Windows 目录布局构造,没有 POSIX 分支。
|
|
422
|
-
- **首次评估
|
|
437
|
+
- **首次评估 30–60 秒。** 命名范围与「最大应用目录」会遍历重叠的树。结果缓存 10 分钟,并在真删后失效,所以只有第一个调用者付这笔钱;面板的「重新评估」会传 `force`,每次都要重付。`lib/audit.js` 里有 `ponytail:` 注释记录「共享 Map」这个替代方案。
|
|
438
|
+
- **更新安装包按时间清理,不靠识别版本。** `*-updater` 目录靠名字认;三天内的下载一律不动,因为只有应用自己知道它装了没有。
|
|
423
439
|
- **不提权。** 那个拒绝普通权限写入的 Windows 目录只报告,不尝试删除。`C:\Windows\Temp` **不是**其中之一:默认 ACL 给 `BUILTIN\Users` 创建与删除权限,挡住删除的只是被服务占用的 EBUSY,而保留期已经处理了这一类。
|
|
424
440
|
- **不卸载、不迁移。** 本插件做不到的步骤给命令,不给按钮。
|
|
425
441
|
- **遍历上限**每棵树 2,000,000 条目,极端大的树会被部分测量,而不是卡死。
|
|
@@ -429,16 +445,16 @@ node --check lib/client.js # 以及 lib/index.js、lib/catalog.js、li
|
|
|
429
445
|
```
|
|
430
446
|
├─ package.json dsh.bundle.patch + dsh.client { platform: "web" }
|
|
431
447
|
├─ cordis.patch.yml - insert: id: dsh-disk-cleaner
|
|
432
|
-
├─ lib/index.js (
|
|
433
|
-
├─ lib/catalog.js (
|
|
434
|
-
├─ lib/audit.js (
|
|
448
|
+
├─ lib/index.js (403) Host:HTTP 路由 + disk_cleaner 工具,共用同一个 run(),含 reveal
|
|
449
|
+
├─ lib/catalog.js (500) 14 个目标,measurePath / purgePath / resolveTargets / drives
|
|
450
|
+
├─ lib/audit.js (365) 只读评估,10 分钟缓存,带 areaPaths,不暴露任何删除
|
|
435
451
|
├─ lib/plan.js (150) 纯函数:测量结果 → 有序步骤
|
|
436
|
-
├─ lib/client.js (
|
|
452
|
+
├─ lib/client.js (1341) Client 半面:slot、面板、偏好、zh/en 字典、CSS
|
|
437
453
|
├─ locale/{en,zh}.json 展示元数据(meta.title / meta.description)
|
|
438
454
|
├─ icon.svg 插件图标
|
|
439
455
|
├─ README.md 本文件
|
|
440
456
|
├─ LICENSE MIT
|
|
441
|
-
└─ smoke.mjs
|
|
457
|
+
└─ smoke.mjs 67 条断言,零依赖
|
|
442
458
|
```
|
|
443
459
|
|
|
444
460
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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
|
-
|
|
220
|
-
first ??= abs;
|
|
312
|
+
paths.push(abs);
|
|
221
313
|
}
|
|
222
314
|
|
|
223
|
-
if (
|
|
224
|
-
return {
|
|
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
|
|
254
|
-
|
|
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 [];
|
|
@@ -199,6 +272,15 @@ const CATALOG = [
|
|
|
199
272
|
roots: ['LOCALAPPDATA'],
|
|
200
273
|
paths: () => [path.join(env('LOCALAPPDATA') ?? '', 'Microsoft', 'Windows', 'INetCache')],
|
|
201
274
|
},
|
|
275
|
+
{
|
|
276
|
+
id: 'app-updaters',
|
|
277
|
+
kind: 'aged',
|
|
278
|
+
minAgeDays: 3,
|
|
279
|
+
risk: 'caution',
|
|
280
|
+
note: 'pending-update',
|
|
281
|
+
roots: ['LOCALAPPDATA', 'APPDATA'],
|
|
282
|
+
paths: () => appUpdaters(),
|
|
283
|
+
},
|
|
202
284
|
];
|
|
203
285
|
|
|
204
286
|
const byId = new Map(CATALOG.map((entry) => [entry.id, entry]));
|
package/lib/client.js
CHANGED
|
@@ -38,6 +38,7 @@ window.__ModuleLoader__.load({
|
|
|
38
38
|
'why_in-use': '只删 {n} 天前的文件,正在使用的会跳过',
|
|
39
39
|
why_rebuild: '缓存会自动重建,下次打开略慢',
|
|
40
40
|
why_elevation: '需要管理员权限;普通窗口下会被系统拒绝',
|
|
41
|
+
'why_pending-update': '只清 {n} 天前的下载,避免删掉还没装的更新',
|
|
41
42
|
pathCount: '{n} 处',
|
|
42
43
|
driveLabel: '清理范围',
|
|
43
44
|
allDrives: '全部磁盘',
|
|
@@ -57,6 +58,7 @@ window.__ModuleLoader__.load({
|
|
|
57
58
|
'target_inet-cache': '系统网络缓存',
|
|
58
59
|
'target_windows-update': 'Windows 更新缓存',
|
|
59
60
|
'target_recycle-bin': '回收站',
|
|
61
|
+
'target_app-updaters': '更新安装包残留',
|
|
60
62
|
auditTitle: '空间评估(本插件不会删除)',
|
|
61
63
|
auditHint: '统计缓存之外真正占空间的东西:已装软件、WSL 虚拟磁盘、Windows 组件库,以及你自己的文件。这些一律不由本插件删除,只列出供你判断。',
|
|
62
64
|
auditRun: '开始评估',
|
|
@@ -72,6 +74,32 @@ window.__ModuleLoader__.load({
|
|
|
72
74
|
auditScopeLocal: 'Local',
|
|
73
75
|
auditScopeRoaming: 'Roaming',
|
|
74
76
|
auditError: '评估失败',
|
|
77
|
+
auditOpen: '在资源管理器中打开',
|
|
78
|
+
auditOpened: '已打开',
|
|
79
|
+
area_wsl: 'WSL 虚拟磁盘',
|
|
80
|
+
area_wsl_why: 'WSL 的 ext4.vhdx 只增不减,删发行版会丢数据;回收只能压缩,见下面的处理方案。',
|
|
81
|
+
area_winsxs: 'Windows 组件库',
|
|
82
|
+
area_winsxs_why: '系统更新的旧版本备份。只能由 DISM 清理,手动删会损坏系统。',
|
|
83
|
+
'area_system-files': '休眠与页面文件',
|
|
84
|
+
'area_system-files_why': '由 Windows 管理系统文件,只能通过关闭休眠或调整虚拟内存来回收。',
|
|
85
|
+
area_programs: '用户安装的程序',
|
|
86
|
+
'area_program-files': '全机安装的程序',
|
|
87
|
+
area_programs_why: '装在你账户下的软件本体,删掉等同于卸载。',
|
|
88
|
+
'area_program-files_why': 'Program Files 里的软件本体,请走正常的卸载流程。',
|
|
89
|
+
'area_old-versions': '旧版本程序目录',
|
|
90
|
+
'area_old-versions_why': '安装器留了上一版供回滚。确认当前版本没问题后,可以自己删掉旧版。',
|
|
91
|
+
'area_update-payloads': '已下载的更新包',
|
|
92
|
+
'area_update-payloads_why': '安装完通常会留下的 setup/installer 文件。三天内的下载视为可能还没装,不动。',
|
|
93
|
+
area_downloads: '下载文件夹',
|
|
94
|
+
area_downloads_why: '你自己下载的文件,插件一律不碰。',
|
|
95
|
+
area_documents: '文档',
|
|
96
|
+
area_documents_why: '你自己的文档,插件一律不碰。',
|
|
97
|
+
area_pictures: '图片',
|
|
98
|
+
area_pictures_why: '你自己的图片,插件一律不碰。',
|
|
99
|
+
area_videos: '视频',
|
|
100
|
+
area_videos_why: '你自己的视频,插件一律不碰。',
|
|
101
|
+
area_desktop: '桌面',
|
|
102
|
+
area_desktop_why: '桌面上放着的文件,插件一律不碰。',
|
|
75
103
|
planTitle: '处理方案',
|
|
76
104
|
planHint: '按"收益 ÷ 代价"排序:先做零风险的部分,再做需要一次慎重操作的,最后是只能你自己决定的。',
|
|
77
105
|
planNow: '现在就能回收',
|
|
@@ -147,6 +175,7 @@ window.__ModuleLoader__.load({
|
|
|
147
175
|
'why_in-use': 'only files older than {n} day(s); files in use are skipped',
|
|
148
176
|
why_rebuild: 'rebuilt automatically; the next open is slower',
|
|
149
177
|
why_elevation: 'needs an elevated prompt; a normal window is refused by Windows',
|
|
178
|
+
'why_pending-update': 'only downloads older than {n} day(s), so a pending update survives',
|
|
150
179
|
pathCount: '{n} location(s)',
|
|
151
180
|
driveLabel: 'Limit to',
|
|
152
181
|
allDrives: 'All drives',
|
|
@@ -166,6 +195,7 @@ window.__ModuleLoader__.load({
|
|
|
166
195
|
'target_inet-cache': 'System internet cache',
|
|
167
196
|
'target_windows-update': 'Windows update cache',
|
|
168
197
|
'target_recycle-bin': 'Recycle Bin',
|
|
198
|
+
'target_app-updaters': 'Leftover update installers',
|
|
169
199
|
auditTitle: 'Space assessment (this plugin deletes none of it)',
|
|
170
200
|
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
201
|
auditRun: 'Run assessment',
|
|
@@ -181,6 +211,32 @@ window.__ModuleLoader__.load({
|
|
|
181
211
|
auditScopeLocal: 'Local',
|
|
182
212
|
auditScopeRoaming: 'Roaming',
|
|
183
213
|
auditError: 'Assessment failed',
|
|
214
|
+
auditOpen: 'Open in Explorer',
|
|
215
|
+
auditOpened: 'Opened',
|
|
216
|
+
area_wsl: 'WSL distros (ext4.vhdx)',
|
|
217
|
+
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.',
|
|
218
|
+
area_winsxs: 'Windows component store (WinSxS)',
|
|
219
|
+
area_winsxs_why: 'Superseded copies of system updates. Only DISM may clean these; deleting by hand damages Windows.',
|
|
220
|
+
'area_system-files': 'Hibernation and page files',
|
|
221
|
+
'area_system-files_why': 'Managed by Windows. Reclaimed only by turning hibernation off or shrinking virtual memory.',
|
|
222
|
+
area_programs: 'Installed programs (user scope)',
|
|
223
|
+
area_programs_why: 'The program itself, installed under your account. Deleting it is uninstalling it.',
|
|
224
|
+
'area_program-files': 'Installed programs (machine scope)',
|
|
225
|
+
'area_program-files_why': 'Program bodies under Program Files. Remove them through the normal uninstaller.',
|
|
226
|
+
'area_old-versions': 'Superseded app versions',
|
|
227
|
+
'area_old-versions_why': 'An installer kept the previous build for rollback. Once the current version is fine, delete the old one by hand.',
|
|
228
|
+
'area_update-payloads': 'Downloaded update packages',
|
|
229
|
+
'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.',
|
|
230
|
+
area_downloads: 'Downloads',
|
|
231
|
+
area_downloads_why: 'Your own downloads. This plugin never touches them.',
|
|
232
|
+
area_documents: 'Documents',
|
|
233
|
+
area_documents_why: 'Your own documents. This plugin never touches them.',
|
|
234
|
+
area_pictures: 'Pictures',
|
|
235
|
+
area_pictures_why: 'Your own pictures. This plugin never touches them.',
|
|
236
|
+
area_videos: 'Videos',
|
|
237
|
+
area_videos_why: 'Your own videos. This plugin never touches them.',
|
|
238
|
+
area_desktop: 'Desktop',
|
|
239
|
+
area_desktop_why: 'Files sitting on your desktop. This plugin never touches them.',
|
|
184
240
|
planTitle: 'Suggested plan',
|
|
185
241
|
planHint: 'Ordered by gain against cost: the zero-risk part first, then the one deliberate operation, then what only you can decide.',
|
|
186
242
|
planNow: 'Reclaimable now',
|
|
@@ -296,6 +352,9 @@ window.__ModuleLoader__.load({
|
|
|
296
352
|
.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
353
|
.dsc-audit-row { display: flex; align-items: baseline; gap: 10px; padding: 6px 8px; border-radius: 7px; }
|
|
298
354
|
.dsc-audit-row:hover { background: var(--dsw-alias-bg-layer-2); }
|
|
355
|
+
.dsc-openable { cursor: pointer; }
|
|
356
|
+
.dsc-openable:hover .dsc-audit-label { text-decoration: underline; text-decoration-color: var(--dsw-alias-border-l1); text-underline-offset: 3px; }
|
|
357
|
+
.dsc-openable:focus-visible { outline: 2px solid var(--dsw-alias-brand-primary); outline-offset: 1px; }
|
|
299
358
|
.dsc-audit-name { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
|
|
300
359
|
.dsc-audit-label { font-weight: 600; }
|
|
301
360
|
.dsc-audit-why { color: var(--dsw-alias-label-secondary); font-size: 11px; line-height: 16px; }
|
|
@@ -487,6 +546,17 @@ window.__ModuleLoader__.load({
|
|
|
487
546
|
return text.replace('{n}', String(row.minAgeDays || 1));
|
|
488
547
|
}
|
|
489
548
|
|
|
549
|
+
/**
|
|
550
|
+
* The host measures areas and sends an English label and reason; the dictionary carries
|
|
551
|
+
* a translation per area id and falls back to what the host sent when it has none, so a
|
|
552
|
+
* newly added area shows up in English instead of disappearing.
|
|
553
|
+
*/
|
|
554
|
+
function areaText(t, area, suffix) {
|
|
555
|
+
const key = `area_${area.id}${suffix}`;
|
|
556
|
+
const text = t(key);
|
|
557
|
+
return text === key ? (suffix === '' ? area.label : area.why) : text;
|
|
558
|
+
}
|
|
559
|
+
|
|
490
560
|
function DriveBar({ drive, t }) {
|
|
491
561
|
const ratio = drive.total > 0 ? drive.used / drive.total : 0;
|
|
492
562
|
const level = ratio >= 0.92 ? ' dsc-crit' : ratio >= 0.8 ? ' dsc-warn' : '';
|
|
@@ -865,12 +935,15 @@ window.__ModuleLoader__.load({
|
|
|
865
935
|
const [data, setData] = usePersistent('audit', null);
|
|
866
936
|
const [busy, setBusy] = useState(false);
|
|
867
937
|
const [error, setError] = usePersistent('auditError', '');
|
|
938
|
+
// Where the last click opened: 24px of feedback beats a silent no-op on Windows,
|
|
939
|
+
// which sometimes takes a second to raise the Explorer window.
|
|
940
|
+
const [opened, setOpened] = useState('');
|
|
868
941
|
|
|
869
|
-
const run = useCallback(async () => {
|
|
942
|
+
const run = useCallback(async (force) => {
|
|
870
943
|
setBusy(true);
|
|
871
944
|
setError('');
|
|
872
945
|
try {
|
|
873
|
-
const result = await call('audit');
|
|
946
|
+
const result = await call('audit', force === true ? { force: true } : {});
|
|
874
947
|
setData(result);
|
|
875
948
|
} catch (err) {
|
|
876
949
|
setError(`${t('auditError')}: ${err instanceof Error ? err.message : String(err)}`);
|
|
@@ -892,6 +965,39 @@ window.__ModuleLoader__.load({
|
|
|
892
965
|
const apps = data && Array.isArray(data.largestApps) ? data.largestApps : [];
|
|
893
966
|
const total = areas.reduce((sum, area) => sum + (area.bytes || 0), 0);
|
|
894
967
|
|
|
968
|
+
/**
|
|
969
|
+
* Open one measured location in Explorer. The host rebuilds the path from the area
|
|
970
|
+
* (or scope + name) it measured, so nothing here can hand it a path of our choosing.
|
|
971
|
+
*/
|
|
972
|
+
const revealAt = async (payload) => {
|
|
973
|
+
setError('');
|
|
974
|
+
try {
|
|
975
|
+
const result = await call('reveal', payload);
|
|
976
|
+
setOpened(result && result.revealed ? result.revealed : '');
|
|
977
|
+
} catch (err) {
|
|
978
|
+
setError(`${t('auditError')}: ${err instanceof Error ? err.message : String(err)}`);
|
|
979
|
+
}
|
|
980
|
+
};
|
|
981
|
+
|
|
982
|
+
/** A row that has somewhere to open gets click and right-click; one without stays plain. */
|
|
983
|
+
const openable = (payload) => {
|
|
984
|
+
const open = (event) => {
|
|
985
|
+
event.preventDefault();
|
|
986
|
+
revealAt(payload);
|
|
987
|
+
};
|
|
988
|
+
return {
|
|
989
|
+
className: 'dsc-audit-row dsc-openable',
|
|
990
|
+
role: 'button',
|
|
991
|
+
tabIndex: 0,
|
|
992
|
+
title: t('auditOpen'),
|
|
993
|
+
onClick: open,
|
|
994
|
+
onContextMenu: open,
|
|
995
|
+
onKeyDown: (event) => {
|
|
996
|
+
if (event.key === 'Enter' || event.key === ' ') open(event);
|
|
997
|
+
},
|
|
998
|
+
};
|
|
999
|
+
};
|
|
1000
|
+
|
|
895
1001
|
/** Run a plan step the plugin is allowed to run, then re-measure so the plan is honest. */
|
|
896
1002
|
const runStep = async (step) => {
|
|
897
1003
|
if (confirmFirst.get() === 'always' && !window.confirm(t('cleanConfirm'))) return;
|
|
@@ -924,12 +1030,13 @@ window.__ModuleLoader__.load({
|
|
|
924
1030
|
data && data.cached ? h('span', { className: 'dsc-total' }, t('auditCached')) : null,
|
|
925
1031
|
h(
|
|
926
1032
|
'button',
|
|
927
|
-
{ type: 'button', className: 'dsc-btn', onClick: run, disabled: busy },
|
|
1033
|
+
{ type: 'button', className: 'dsc-btn', onClick: () => run(true), disabled: busy },
|
|
928
1034
|
busy ? t('auditRunning') : data ? t('auditRescan') : t('auditRun'),
|
|
929
1035
|
),
|
|
930
1036
|
),
|
|
931
1037
|
h('p', { className: 'dsc-audit-hint' }, t('auditHint')),
|
|
932
1038
|
error ? h('div', { className: 'dsc-notice dsc-err' }, error) : null,
|
|
1039
|
+
opened ? h('div', { className: 'dsc-audit-hint' }, `${t('auditOpened')} ${opened}`) : null,
|
|
933
1040
|
busy && !data ? h('div', { className: 'dsc-empty' }, t('auditCold')) : null,
|
|
934
1041
|
data
|
|
935
1042
|
? [
|
|
@@ -948,12 +1055,12 @@ window.__ModuleLoader__.load({
|
|
|
948
1055
|
list.map((area) =>
|
|
949
1056
|
h(
|
|
950
1057
|
'div',
|
|
951
|
-
{
|
|
1058
|
+
{ key: area.id, ...openable({ area: area.id, index: 0 }) },
|
|
952
1059
|
h(
|
|
953
1060
|
'span',
|
|
954
1061
|
{ className: 'dsc-audit-name' },
|
|
955
|
-
h('span', { className: 'dsc-audit-label' }, area
|
|
956
|
-
h('span', { className: 'dsc-audit-why' }, area
|
|
1062
|
+
h('span', { className: 'dsc-audit-label' }, areaText(t, area, '')),
|
|
1063
|
+
h('span', { className: 'dsc-audit-why' }, areaText(t, area, '_why')),
|
|
957
1064
|
),
|
|
958
1065
|
h('span', { className: 'dsc-audit-size' }, formatBytes(area.bytes)),
|
|
959
1066
|
),
|
|
@@ -968,7 +1075,7 @@ window.__ModuleLoader__.load({
|
|
|
968
1075
|
apps.map((app) =>
|
|
969
1076
|
h(
|
|
970
1077
|
'div',
|
|
971
|
-
{
|
|
1078
|
+
{ key: `${app.scope}/${app.name}`, ...openable({ scope: app.scope, name: app.name }) },
|
|
972
1079
|
h(
|
|
973
1080
|
'span',
|
|
974
1081
|
{ 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
|
|
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');
|
|
@@ -234,11 +279,13 @@ export function apply(ctx) {
|
|
|
234
279
|
'WSL disk images, the Windows component store, chat-app data and the user\'s own folders — so the ' +
|
|
235
280
|
'answer covers the whole disk rather than only caches. audit is read-only: it never removes anything, ' +
|
|
236
281
|
'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.'
|
|
282
|
+
'for the ones this plugin cannot perform. Report that plan to the user; never run those commands yourself. ' +
|
|
283
|
+
'Action "reveal" opens one measured location in Explorer: pass an "area" id, or "scope" (local|roaming) ' +
|
|
284
|
+
'with "name" for one app folder. It takes no path.',
|
|
238
285
|
parameters: {
|
|
239
286
|
type: 'object',
|
|
240
287
|
properties: {
|
|
241
|
-
action: { type: 'string', enum: ['overview', 'scan', 'clean', 'audit'] },
|
|
288
|
+
action: { type: 'string', enum: ['overview', 'scan', 'clean', 'audit', 'reveal'] },
|
|
242
289
|
ids: {
|
|
243
290
|
type: 'array',
|
|
244
291
|
items: { type: 'string' },
|
|
@@ -254,6 +301,27 @@ export function apply(ctx) {
|
|
|
254
301
|
type: 'boolean',
|
|
255
302
|
description: 'Set true to actually delete. Without it, clean only reports a dry run.',
|
|
256
303
|
},
|
|
304
|
+
force: {
|
|
305
|
+
type: 'boolean',
|
|
306
|
+
description: 'With audit, re-measure instead of answering from the ten-minute cache.',
|
|
307
|
+
},
|
|
308
|
+
area: {
|
|
309
|
+
type: 'string',
|
|
310
|
+
description: 'With reveal, the assessment area id to open. Rejects an id the assessment did not report.',
|
|
311
|
+
},
|
|
312
|
+
scope: {
|
|
313
|
+
type: 'string',
|
|
314
|
+
enum: ['local', 'roaming'],
|
|
315
|
+
description: 'With reveal and name, which app-data root the folder is under.',
|
|
316
|
+
},
|
|
317
|
+
name: {
|
|
318
|
+
type: 'string',
|
|
319
|
+
description: 'With reveal and scope, one folder name from the largest-app list.',
|
|
320
|
+
},
|
|
321
|
+
index: {
|
|
322
|
+
type: 'number',
|
|
323
|
+
description: 'With reveal and area, which of its locations to open (default 0).',
|
|
324
|
+
},
|
|
257
325
|
},
|
|
258
326
|
required: ['action'],
|
|
259
327
|
additionalProperties: false,
|
|
@@ -295,8 +363,12 @@ export function apply(ctx) {
|
|
|
295
363
|
const tail = meaning === '' ? '' : `\n\nCaution codes:\n${meaning}`;
|
|
296
364
|
return `Reclaimable: ${formatBytes(total)}\n\n${lines.join('\n')}${tail}`;
|
|
297
365
|
}
|
|
366
|
+
if (action === 'reveal') {
|
|
367
|
+
const value = reveal(args);
|
|
368
|
+
return `Opened ${value.revealed} (${value.kind}).`;
|
|
369
|
+
}
|
|
298
370
|
if (action === 'audit') {
|
|
299
|
-
const value = await audit();
|
|
371
|
+
const value = await audit({ force: args.force === true });
|
|
300
372
|
const total = value.areas.reduce((sum, area) => sum + area.bytes, 0);
|
|
301
373
|
const lines = value.areas.map(
|
|
302
374
|
(area) => `${area.label}: ${formatBytes(area.bytes)} in ${area.files} file(s)\n why not automatic: ${area.why}`,
|
package/package.json
CHANGED
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
|
|
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
|
|
265
|
+
check('catalog has the documented 14 targets', ids.length === 14, `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'].every((k) => zhKeys.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]))];
|