dsh-disk-cleaner 1.6.5 → 1.6.7

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.6.5` |
8
+ | **version** | `1.6.7` |
9
9
  | **DSH** | `0.2.1-rc.5`(开发与实测版本;用到 `plugins.bundle.config`、`settings.section`) |
10
10
  | **license** | [MIT](LICENSE) |
11
11
  | **Node** | `>= 22`(仅运行冒烟测试需要) |
@@ -166,16 +166,16 @@ These are enforced in `lib/catalog.js` and must not be relaxed:
166
166
 
167
167
  Listed in the curated [awesome-dsh-plugin](https://github.com/awesome-dsh-plugin/awesome-dsh-plugin) catalog, which powers **dshmarket → Discover** ([PR #12970](https://github.com/awesome-dsh-plugin/awesome-dsh-plugin/pull/12970), merged): search **dsh-disk-cleaner** there and install it in one click. The direct installs below do the same thing by hand.
168
168
 
169
- Published on npm as [`dsh-disk-cleaner`](https://www.npmjs.com/package/dsh-disk-cleaner) — latest `1.6.5`:
169
+ Published on npm as [`dsh-disk-cleaner`](https://www.npmjs.com/package/dsh-disk-cleaner) — latest `1.6.7`:
170
170
 
171
171
  ```bash
172
172
  npm i dsh-disk-cleaner
173
173
  ```
174
174
 
175
- A prebuilt tarball is attached to the [v1.6.5 Release](https://github.com/stallenlu/dsh-disk-cleaner/releases/tag/v1.6.5); installing from it skips DSH's build-approval step:
175
+ A prebuilt tarball is attached to the [v1.6.7 Release](https://github.com/stallenlu/dsh-disk-cleaner/releases/tag/v1.6.7); installing from it skips DSH's build-approval step:
176
176
 
177
177
  ```bash
178
- dsh plugin --profile <profile> add https://github.com/stallenlu/dsh-disk-cleaner/releases/download/v1.6.5/dsh-disk-cleaner-1.6.5.tgz
178
+ dsh plugin --profile <profile> add https://github.com/stallenlu/dsh-disk-cleaner/releases/download/v1.6.7/dsh-disk-cleaner-1.6.7.tgz
179
179
  ```
180
180
 
181
181
  Then enable it in the profile — in `$DSH_PROFILE_DIR/package.json`:
@@ -183,7 +183,7 @@ Then enable it in the profile — in `$DSH_PROFILE_DIR/package.json`:
183
183
  ```json
184
184
  {
185
185
  "dependencies": {
186
- "dsh-disk-cleaner": "^1.6.5"
186
+ "dsh-disk-cleaner": "^1.6.7"
187
187
  },
188
188
  "dsh": {
189
189
  "profile": {
@@ -204,7 +204,7 @@ The bundle's own `cordis.patch.yml` inserts the plugin row; no profile patch edi
204
204
  ### Development
205
205
 
206
206
  ```bash
207
- node smoke.mjs # 98 assertions, zero dependencies
207
+ node smoke.mjs # 110 assertions, zero dependencies
208
208
  node --check lib/client.js # and lib/index.js, lib/catalog.js, lib/audit.js, lib/plan.js
209
209
  ```
210
210
 
@@ -258,22 +258,25 @@ What the smoke test deliberately does **not** cover: anything that walks a real
258
258
  ```
259
259
  ├─ package.json dsh.bundle.patch + dsh.client { platform: "web" }
260
260
  ├─ cordis.patch.yml - insert: id: dsh-disk-cleaner
261
- ├─ lib/index.js (434) Host: HTTP route + disk_cleaner tool, same run() for both; reveal
262
- ├─ lib/catalog.js (1222) The 32 targets, measurePath / purgePath / resolveTargets / drives
261
+ ├─ lib/index.js (506) Host: HTTP route + disk_cleaner tool, same run() for both; reveal
262
+ ├─ lib/catalog.js (1242) The 32 targets, measurePath / purgePath / resolveTargets / drives
263
263
  ├─ lib/audit.js (365) Read-only assessment, 10-minute cache, areaPaths, exposes no deletion
264
264
  ├─ lib/plan.js (150) Pure: measurements → ordered steps
265
- ├─ lib/client.js (1597) Client half: slots, panel, preferences, zh/en dictionaries, CSS
265
+ ├─ lib/history.js (71) Append-only run log in $DSH_HOME/dsh-disk-cleaner; never throws
266
+ ├─ lib/client.js (1808) Client half: slots, panel, preferences, zh/en dictionaries, CSS
266
267
  ├─ locale/{en,zh}.json Display metadata (meta.title / meta.description)
267
268
  ├─ icon.svg Plugin icon
268
269
  ├─ README.md This file
269
270
  ├─ LICENSE MIT
270
- └─ smoke.mjs 98 assertions, zero dependencies
271
+ └─ smoke.mjs 110 assertions, zero dependencies
271
272
  ```
272
273
 
273
274
  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).
274
275
 
275
276
  ### What's new
276
277
 
278
+ - **1.6.7** — Fixes a regression 1.6.6 shipped: refusing every request without an `Origin` header locked the plugin's own panel out of its API ("请求来源不被接受"), because the desktop window posts from `dsh-app://app`, not from `http://127.0.0.1:<port>`. The check now accepts the desktop scheme and a header the desktop forwarder stripped, but only when the request already arrived on a loopback address; a plain web page still cannot reach a loopback port without sending an `Origin`, so the door stays shut. No other behaviour changed.
279
+ - **1.6.6** — The results the plugin already computed are now shown. A **Dry run** button next to *Clean* asks the exact same question without `confirm`, so you see what a delete would free before anything goes; the result banner then survives the rescan that follows it (it used to be erased a line later). Plan steps name targets in words instead of printing `npm-cache, chrome-cache, pip-cache`, an empty plan says "nothing worth doing" instead of rendering a hole, and route errors arrive as a stable `code` the panel translates, so a Chinese user never reads `origin rejected` or `unknown target: x`. Every real clean now appends one line to `$DSH_HOME/dsh-disk-cleaner/history.jsonl`, and a **Clean history** panel reads it back with a *Copy as CSV* button. Under the hood: scan walks four targets at a time instead of all 32 at once, `SystemRoot` decides where the Windows targets live instead of a hardcoded `C:`, a request without an `Origin` header is refused unless it arrived on loopback (see 1.6.7), and a refused directory removal is counted separately from a legitimately full one.
277
280
  - **1.6.5** — Documentation only: the install section now points at the Release it names (the prebuilt-tarball link is labelled v1.6.5 and resolves to the v1.6.5 tag), and the catalog paragraph says the awesome-dsh-plugin entry is merged rather than pending review. No code change.
278
281
  - **1.6.4** — The panel now paints itself with [dsh-design-baseline](https://github.com/stallenlu/dsh-design-baseline): the stylesheet is inlined into the client bundle, the panel root carries `dsb-root`, and every colour resolves through a `--dsb-*` token instead of a host `--dsw-alias-*` name. That means it follows the host's light and dark themes (including `color-scheme`, so native `<select>` menus and scrollbars match), and colour is spent only on data that has state — the primary button and the active chip are now inverted neutrals rather than brand blue. No behaviour changes.
279
282
  - **1.6.3** — Safety and honesty rather than new features. The Recycle Bin is no longer filed under `safe`: emptying it cannot be undone, so it is never preselected, never picked by "select all", and carries its own `irreversible` note instead of the generic "rebuilt; next start is slower". The confirm dialog now names each target and the total instead of asking a generic "delete these?". A target that names no environment root is bounded by the volume it sits on rather than skipping the boundary check. `measure` and `purge` now count entries in the same unit, and a walk that hits the 2,000,000-entry cap says so (`truncated`) instead of claiming exact totals. A directory removal refused for a reason other than "not empty" is counted as `blocked` instead of being swallowed, and a clean that left files behind reports a warning rather than a green success. The orphan target is named for what it actually measures (the QQGuild folder).
@@ -437,16 +440,16 @@ Client-half slots: `sidebar.panellist` (order 50, disposed in settings-only mode
437
440
 
438
441
  已收录进精选目录 [awesome-dsh-plugin](https://github.com/awesome-dsh-plugin/awesome-dsh-plugin),它是 **dshmarket → 发现** 的数据源([PR #12970](https://github.com/awesome-dsh-plugin/awesome-dsh-plugin/pull/12970),已合并):在 dshmarket 里搜索 **dsh-disk-cleaner** 即可一键安装。下面的直接安装方式就是手动做同一件事。
439
442
 
440
- 已发布到 npm:[`dsh-disk-cleaner`](https://www.npmjs.com/package/dsh-disk-cleaner),最新版 `1.6.5`。
443
+ 已发布到 npm:[`dsh-disk-cleaner`](https://www.npmjs.com/package/dsh-disk-cleaner),最新版 `1.6.7`。
441
444
 
442
445
  ```bash
443
446
  npm i dsh-disk-cleaner
444
447
  ```
445
448
 
446
- [v1.6.5 Release](https://github.com/stallenlu/dsh-disk-cleaner/releases/tag/v1.6.5) 还附带一个预构建 tarball,从它安装可跳过 DSH 的构建授权步骤:
449
+ [v1.6.7 Release](https://github.com/stallenlu/dsh-disk-cleaner/releases/tag/v1.6.7) 还附带一个预构建 tarball,从它安装可跳过 DSH 的构建授权步骤:
447
450
 
448
451
  ```bash
449
- dsh plugin --profile <profile> add https://github.com/stallenlu/dsh-disk-cleaner/releases/download/v1.6.5/dsh-disk-cleaner-1.6.5.tgz
452
+ dsh plugin --profile <profile> add https://github.com/stallenlu/dsh-disk-cleaner/releases/download/v1.6.7/dsh-disk-cleaner-1.6.7.tgz
450
453
  ```
451
454
 
452
455
  然后在 profile 里启用 —— 在 `$DSH_PROFILE_DIR/package.json`:
@@ -454,7 +457,7 @@ dsh plugin --profile <profile> add https://github.com/stallenlu/dsh-disk-cleaner
454
457
  ```json
455
458
  {
456
459
  "dependencies": {
457
- "dsh-disk-cleaner": "^1.6.5"
460
+ "dsh-disk-cleaner": "^1.6.7"
458
461
  },
459
462
  "dsh": {
460
463
  "profile": {
@@ -475,7 +478,7 @@ bundle 自带的 `cordis.patch.yml` 会插入插件行,无需改 profile 的 p
475
478
  ### 开发
476
479
 
477
480
  ```bash
478
- node smoke.mjs # 98 条断言,零依赖
481
+ node smoke.mjs # 110 条断言,零依赖
479
482
  node --check lib/client.js # 以及 lib/index.js、lib/catalog.js、lib/audit.js、lib/plan.js
480
483
  ```
481
484
 
@@ -529,22 +532,25 @@ node --check lib/client.js # 以及 lib/index.js、lib/catalog.js、li
529
532
  ```
530
533
  ├─ package.json dsh.bundle.patch + dsh.client { platform: "web" }
531
534
  ├─ cordis.patch.yml - insert: id: dsh-disk-cleaner
532
- ├─ lib/index.js (434) Host:HTTP 路由 + disk_cleaner 工具,共用同一个 run(),含 reveal
533
- ├─ lib/catalog.js (1222) 32 个目标,measurePath / purgePath / resolveTargets / drives
535
+ ├─ lib/index.js (506) Host:HTTP 路由 + disk_cleaner 工具,共用同一个 run(),含 reveal
536
+ ├─ lib/catalog.js (1242) 32 个目标,measurePath / purgePath / resolveTargets / drives
534
537
  ├─ lib/audit.js (365) 只读评估,10 分钟缓存,带 areaPaths,不暴露任何删除
535
538
  ├─ lib/plan.js (150) 纯函数:测量结果 → 有序步骤
536
- ├─ lib/client.js (1597) Client 半面:slot、面板、偏好、zh/en 字典、CSS
539
+ ├─ lib/history.js (71) 只追加的运行日志,落在 $DSH_HOME/dsh-disk-cleaner;永不抛错
540
+ ├─ lib/client.js (1808) Client 半面:slot、面板、偏好、zh/en 字典、CSS
537
541
  ├─ locale/{en,zh}.json 展示元数据(meta.title / meta.description)
538
542
  ├─ icon.svg 插件图标
539
543
  ├─ README.md 本文件
540
544
  ├─ LICENSE MIT
541
- └─ smoke.mjs 98 条断言,零依赖
545
+ └─ smoke.mjs 110 条断言,零依赖
542
546
  ```
543
547
 
544
548
  Client 半面注册的 slot:`sidebar.panellist`(order 50,仅设置页模式下拆除)、`main`(以包名为 key)、`settings.section`(order 30,仅侧边栏模式下拆除)、`plugins.bundle.config`(以包名为 key)。
545
549
 
546
550
  ### 更新记录
547
551
 
552
+ - **1.6.7** — 修掉 1.6.6 带进来的一个回归:把「没有 Origin 头的请求」一律拒绝,连插件自己的面板都进不了接口(界面报「请求来源不被接受」)——桌面窗口发请求时 Origin 是 `dsh-app://app`,不是 `http://127.0.0.1:<端口>`。现在的判定接受桌面协议,也接受被桌面转发层剥掉的头,但前提是请求本身已经到达 loopback 地址;普通网页不发 Origin 就到不了 loopback 端口,所以门仍然是关的。其余行为没变。
553
+ - **1.6.6** — 把插件本来就算出来的结果真正显示出来。*清理* 旁边多了 **预演** 按钮:发同一个请求但不带 `confirm`,删之前就能看到会释放多少;清理结果横幅不再被紧随其后的重新扫描抹掉(以前下一行就被清空)。计划步骤改用目标名称,不再直出 `npm-cache, chrome-cache, pip-cache`;没有可做的事时说明「现在没有值得动手的地方」,而不是留一片空白;接口错误改带稳定 `code`,由面板翻成中文,不会再出现 `origin rejected` 或 `unknown target: x`。每次真正清理都会往 `$DSH_HOME/dsh-disk-cleaner/history.jsonl` 追加一行,新增的 **清理记录** 面板可读回并按 *复制为 CSV* 导出。底层:扫描从「32 个目标同时跑」改为每次 4 个,Windows 目标路径改由 `SystemRoot` 决定而不再硬编码 `C:`,缺少 `Origin` 头的请求会被拒绝(这项判定在 1.6.7 收紧到只放行 loopback,见下),目录删除被拒与非空目录被跳过分别计数。
548
554
  - **1.6.5** — 仅文档修正:安装段现在指向它所写的那个 Release(预构建 tarball 的链接标为 v1.6.5,且指向 v1.6.5 的 tag);目录段落也从「审核中」改为「已合并」。代码无变化。
549
555
  - **1.6.4** — 面板改用 [dsh-design-baseline](https://github.com/stallenlu/dsh-design-baseline) 上色:样式表内联进 client bundle,根节点带 `dsb-root`,所有颜色都走 `--dsb-*` 令牌而不是宿主的 `--dsw-alias-*` 名字。因此它跟随宿主的明暗主题(含 `color-scheme`,原生 `<select>` 弹层与滚动条也跟着变),并且颜色只花在有状态的数据上——主按钮与选中项改成中性反色,不再是品牌蓝。行为没有变化。
550
556
  - **1.6.3** — 改的是安全与诚实,不是新功能。回收站不再归入「安全」:清空不可恢复,所以它永不被预选、不被「全选」勾中,并单独带 `irreversible` 注意码,而不是笼统的「重建,下次启动慢一些」。确认框现在逐条列出目标名与合计字节,不再只问一句「删除选中缓存目录里的文件?」。没有声明根目录的目标,改为按所在卷做边界校验,而不是直接跳过检查。`measure` 与 `purge` 现在用同一单位计数,走查触到 200 万条上限时会如实标 `truncated`,不再声称精确总量。目录删除失败若原因不是「非空」会计入 `blocked`(此前被静默吞掉),清理后仍有残留的目标会报警告而不是绿色成功。orphan 目标按它实际测量的东西重新命名(QQ 频道目录)。
package/lib/catalog.js CHANGED
@@ -643,7 +643,9 @@ const CATALOG = [
643
643
  // the user to an elevated prompt that changed nothing and hid the bytes from the plan.
644
644
  note: 'in-use',
645
645
  roots: ['SystemRoot'],
646
- paths: () => ['C:\\Windows\\Temp'],
646
+ // SystemRoot, not a hardcoded C: — the boundary check above already trusted it,
647
+ // so a machine whose Windows sits on another volume must delete from the same place.
648
+ paths: () => [path.join(env('SystemRoot') ?? 'C:\\Windows', 'Temp')],
647
649
  },
648
650
  {
649
651
  id: 'npm-cache',
@@ -717,7 +719,7 @@ const CATALOG = [
717
719
  risk: 'caution',
718
720
  note: 'elevation',
719
721
  roots: ['SystemRoot'],
720
- paths: () => ['C:\\Windows\\SoftwareDistribution\\Download'],
722
+ paths: () => [path.join(env('SystemRoot') ?? 'C:\\Windows', 'SoftwareDistribution', 'Download')],
721
723
  },
722
724
  {
723
725
  id: 'recycle-bin',
@@ -1057,27 +1059,45 @@ export async function measurePath(abs, cutoff) {
1057
1059
  * folders hold. Reporting both is what tells the user "245 MB here, 0.2 MB past the limit"
1058
1060
  * instead of a small number that reads as "nothing found".
1059
1061
  */
1062
+ const measureTarget = async (target) => {
1063
+ const cutoff = target.minAgeDays > 0 ? Date.now() - target.minAgeDays * DAY : undefined;
1064
+ let bytes = 0;
1065
+ let files = 0;
1066
+ let totalBytes = 0;
1067
+ let totalFiles = 0;
1068
+ let truncated = false;
1069
+ for (const abs of target.paths) {
1070
+ const size = await measurePath(abs, cutoff);
1071
+ bytes += size.bytes;
1072
+ files += size.files;
1073
+ totalBytes += size.totalBytes;
1074
+ totalFiles += size.totalFiles;
1075
+ truncated = truncated || size.truncated === true;
1076
+ }
1077
+ return { ...target, bytes, files, totalBytes, totalFiles, truncated };
1078
+ };
1079
+
1080
+ /**
1081
+ * Measuring all 32 targets at once meant 32 concurrent directory walks, which turns a
1082
+ * scan on a spinning disk (or behind a scanner) into a stall. Four at a time keeps the
1083
+ * wall clock close and the disk queue short; results keep catalog order either way.
1084
+ */
1085
+ const SCAN_CONCURRENCY = 4;
1086
+
1060
1087
  export async function scan(only, drive) {
1061
1088
  const targets = resolveTargets(only, drive);
1062
- const measured = await Promise.all(
1063
- targets.map(async (target) => {
1064
- const cutoff = target.minAgeDays > 0 ? Date.now() - target.minAgeDays * DAY : undefined;
1065
- let bytes = 0;
1066
- let files = 0;
1067
- let totalBytes = 0;
1068
- let totalFiles = 0;
1069
- let truncated = false;
1070
- for (const abs of target.paths) {
1071
- const size = await measurePath(abs, cutoff);
1072
- bytes += size.bytes;
1073
- files += size.files;
1074
- totalBytes += size.totalBytes;
1075
- totalFiles += size.totalFiles;
1076
- truncated = truncated || size.truncated === true;
1077
- }
1078
- return { ...target, bytes, files, totalBytes, totalFiles, truncated };
1079
- }),
1080
- );
1089
+ const measured = new Array(targets.length);
1090
+ let next = 0;
1091
+ const worker = async () => {
1092
+ while (next < targets.length) {
1093
+ const index = next;
1094
+ next += 1;
1095
+ measured[index] = await measureTarget(targets[index]);
1096
+ }
1097
+ };
1098
+ const workers = [];
1099
+ for (let i = 0; i < Math.min(SCAN_CONCURRENCY, targets.length); i += 1) workers.push(worker());
1100
+ await Promise.all(workers);
1081
1101
  return measured;
1082
1102
  }
1083
1103
 
package/lib/client.js CHANGED
@@ -102,6 +102,32 @@ window.__ModuleLoader__.load({
102
102
  cleanConfirmList: '即将清理 {n} 项,合计 {bytes}:\n\n{list}\n\n回收站等不可恢复的项目会单独标出。',
103
103
  cleanConfirmIrreversible: '\n\n注意:其中「回收站」清空后无法恢复。',
104
104
  truncatedHint: '这个目录太大,只统计/清理了前 {n} 条,实际释放可能少于这个数。',
105
+ dryRunHint: '预演完成:没有删除任何文件。',
106
+ wouldFree: '可释放',
107
+ dryRunBtn: '预演',
108
+ dryRunBusy: '预演中…',
109
+ listSep: '、',
110
+ planEmpty: '现在没有值得动手的地方:缓存都还小,或者才清过。等它们长回来再看。',
111
+ historyTitle: '清理记录',
112
+ historyOpen: '查看清理记录',
113
+ historyLoading: '读取中…',
114
+ historyEmpty: '还没有清理记录。真正执行过一次清理后,这里会留下每一条。',
115
+ historyCopy: '复制为 CSV',
116
+ historyCopied: '已复制',
117
+ historyRun: '{time} · 释放 {bytes} · {files} 个文件',
118
+ historySourceAgent: '由 Agent 执行',
119
+ 'err_method-not-allowed': '这个接口只接受本地面板的请求。',
120
+ 'err_origin-rejected': '请求来源不被接受:请从面板里操作。',
121
+ 'err_body-too-large': '请求内容过大。',
122
+ 'err_body-invalid': '请求内容无法解析。',
123
+ 'err_bad-ids': '目标列表格式不对。',
124
+ 'err_too-many-ids': '一次选的目标太多了。',
125
+ 'err_unknown-target': '有不认识的目标名称,请重新扫描后再试。',
126
+ 'err_unknown-drive': '磁盘名称不认识。',
127
+ 'err_drive-not-mounted': '这个盘当前没有挂载。',
128
+ 'err_no-targets': '没有选中任何目标。',
129
+ 'err_unknown-action': '不支持的操作。',
130
+ 'err_internal': '插件内部出错了,详细信息见日志。',
105
131
  'target_temp': '用户临时文件',
106
132
  'target_windows-temp': 'Windows 临时文件',
107
133
  'target_npm-cache': 'npm 缓存',
@@ -278,6 +304,32 @@ window.__ModuleLoader__.load({
278
304
  cleanConfirmList: 'About to clean {n} target(s), {bytes} in total:\n\n{list}\n\nAnything irreversible is called out separately.',
279
305
  cleanConfirmIrreversible: '\n\nNote: emptying the Recycle Bin cannot be undone.',
280
306
  truncatedHint: 'This folder is too large: only the first {n} entries were measured or removed, so less may be freed.',
307
+ dryRunHint: 'Dry run finished: nothing was deleted.',
308
+ wouldFree: 'Could free',
309
+ dryRunBtn: 'Dry run',
310
+ dryRunBusy: 'Running…',
311
+ listSep: ', ',
312
+ planEmpty: 'Nothing worth doing right now: the caches are still small, or you just cleaned them. Come back when they grow.',
313
+ historyTitle: 'Clean history',
314
+ historyOpen: 'Show clean history',
315
+ historyLoading: 'Reading…',
316
+ historyEmpty: 'No runs recorded yet. Every real clean leaves one entry here.',
317
+ historyCopy: 'Copy as CSV',
318
+ historyCopied: 'Copied',
319
+ historyRun: '{time} · freed {bytes} · {files} file(s)',
320
+ historySourceAgent: 'run by the agent',
321
+ 'err_method-not-allowed': 'This route only accepts requests from the local panel.',
322
+ 'err_origin-rejected': 'Request origin rejected: use the panel.',
323
+ 'err_body-too-large': 'The request was too large.',
324
+ 'err_body-invalid': 'The request could not be parsed.',
325
+ 'err_bad-ids': 'The target list is malformed.',
326
+ 'err_too-many-ids': 'Too many targets in one request.',
327
+ 'err_unknown-target': 'Unknown target name; scan again and retry.',
328
+ 'err_unknown-drive': 'Unknown drive.',
329
+ 'err_drive-not-mounted': 'That drive is not mounted.',
330
+ 'err_no-targets': 'No target selected.',
331
+ 'err_unknown-action': 'Unsupported action.',
332
+ 'err_internal': 'The plugin hit an internal error; see the log.',
281
333
  'target_temp': 'User temp files',
282
334
  'target_windows-temp': 'Windows temp files',
283
335
  'target_npm-cache': 'npm cache',
@@ -545,6 +597,21 @@ window.__ModuleLoader__.load({
545
597
  return label === key ? id : label;
546
598
  }
547
599
 
600
+ /**
601
+ * The route answers with a stable `code` beside its English message. The panel shows the
602
+ * translated code and only falls back to the message when the code is unknown, so a
603
+ * Chinese user never reads "origin rejected" or "unknown target: x".
604
+ */
605
+ function describeError(t, err) {
606
+ const code = err && typeof err.code === 'string' ? err.code : '';
607
+ if (code !== '') {
608
+ const key = `err_${code}`;
609
+ const text = t(key);
610
+ if (text !== key) return text;
611
+ }
612
+ return err instanceof Error ? err.message : String(err);
613
+ }
614
+
548
615
  async function call(action, payload) {
549
616
  const response = await fetch(ROUTE, {
550
617
  method: 'POST',
@@ -558,7 +625,9 @@ window.__ModuleLoader__.load({
558
625
  throw new Error(`HTTP ${response.status}`);
559
626
  }
560
627
  if (!response.ok || !data || data.ok !== true) {
561
- throw new Error((data && data.error) || `HTTP ${response.status}`);
628
+ const error = new Error((data && data.error) || `HTTP ${response.status}`);
629
+ if (data && typeof data.code === 'string') error.code = data.code;
630
+ throw error;
562
631
  }
563
632
  return data;
564
633
  }
@@ -779,10 +848,15 @@ window.__ModuleLoader__.load({
779
848
  };
780
849
  }, [drives.length, setDrives]);
781
850
 
782
- const scan = useCallback(async () => {
851
+ /**
852
+ * `keepNotice` is what makes the clean result readable at all: a clean ends by
853
+ * rescanning, and a rescan used to clear the banner it had just set.
854
+ */
855
+ const scan = useCallback(async (options) => {
856
+ const keepNotice = options !== null && typeof options === 'object' && options.keepNotice === true;
783
857
  setBusy('scan');
784
858
  setError('');
785
- setNotice(null);
859
+ if (!keepNotice) setNotice(null);
786
860
  try {
787
861
  const data = await call('scan', drive === '' ? undefined : { drive });
788
862
  const targets = Array.isArray(data.targets) ? data.targets : [];
@@ -798,7 +872,7 @@ window.__ModuleLoader__.load({
798
872
  .map((row) => row.id);
799
873
  setPicked(new Set(preselect));
800
874
  } catch (err) {
801
- setError(`${t('scanError')}: ${err instanceof Error ? err.message : String(err)}`);
875
+ setError(`${t('scanError')}: ${describeError(t, err)}`);
802
876
  } finally {
803
877
  setBusy('');
804
878
  }
@@ -830,10 +904,16 @@ window.__ModuleLoader__.load({
830
904
  setPicked(new Set(eligible));
831
905
  };
832
906
 
833
- const runClean = async () => {
907
+ /**
908
+ * `dry` runs the exact same request without `confirm`, so the number the panel reports
909
+ * is the number the delete would achieve. It never asks for confirmation: it deletes
910
+ * nothing. A real run keeps asking.
911
+ */
912
+ const runClean = async (dry) => {
913
+ const isDry = dry === true;
834
914
  if (!rows || picked.size === 0) return;
835
915
  const chosen = shown.filter((row) => picked.has(row.id));
836
- if (confirmFirst.get() === 'always') {
916
+ if (!isDry && confirmFirst.get() === 'always') {
837
917
  // A confirm box that does not say what it deletes is not a confirmation. Name the
838
918
  // targets and the total, and call out anything that cannot be undone.
839
919
  const bytes = chosen.reduce((sum, row) => sum + (row.bytes || 0), 0);
@@ -851,12 +931,20 @@ window.__ModuleLoader__.load({
851
931
  setError('');
852
932
  setNotice(null);
853
933
  try {
854
- const data = await call('clean', drive === '' ? { ids, confirm: true } : { ids, drive, confirm: true });
934
+ const body = isDry ? { ids } : { ids, confirm: true };
935
+ const data = await call('clean', drive === '' ? body : { ...body, drive });
855
936
  const results = Array.isArray(data.results) ? data.results : [];
856
937
  const bytes = results.reduce((sum, item) => sum + (item.bytes || 0), 0);
857
938
  const files = results.reduce((sum, item) => sum + (item.removed || 0), 0);
858
939
  const failed = results.reduce((sum, item) => sum + (item.failed || 0), 0);
859
940
  const truncated = results.filter((item) => item.truncated === true).length > 0;
941
+ if (isDry) {
942
+ setNotice({
943
+ kind: 'ok',
944
+ text: `${t('dryRunHint')} ${t('wouldFree')} ${formatBytes(bytes)} · ${files} ${t('files')}`,
945
+ });
946
+ return;
947
+ }
860
948
  // A run that left files behind is not a success banner: an all-ok green line over
861
949
  // "12 skipped" reads as done when it is not.
862
950
  const kind = failed > 0 ? 'warn' : 'ok';
@@ -864,9 +952,9 @@ window.__ModuleLoader__.load({
864
952
  if (failed > 0) parts.push(`${t('failed')} ${failed} (${t('failedHint')})`);
865
953
  if (truncated) parts.push(t('truncatedHint').replace('{n}', String(results.length)));
866
954
  setNotice({ kind, text: parts.join(' · ') });
867
- await scan();
955
+ await scan({ keepNotice: true });
868
956
  } catch (err) {
869
- setError(`${t('cleanError')}: ${err instanceof Error ? err.message : String(err)}`);
957
+ setError(`${t('cleanError')}: ${describeError(t, err)}`);
870
958
  } finally {
871
959
  setBusy('');
872
960
  }
@@ -977,13 +1065,26 @@ window.__ModuleLoader__.load({
977
1065
  picked.size > 0 ? ` · ${t('selected')} ${formatBytes(pickedBytes)}` : '',
978
1066
  )
979
1067
  : null,
1068
+ rows
1069
+ ? h(
1070
+ 'button',
1071
+ {
1072
+ type: 'button',
1073
+ className: 'dsc-btn',
1074
+ onClick: () => runClean(true),
1075
+ disabled: busy !== '' || picked.size === 0,
1076
+ title: t('dryRunHint'),
1077
+ },
1078
+ busy === 'clean' ? t('dryRunBusy') : t('dryRunBtn'),
1079
+ )
1080
+ : null,
980
1081
  rows
981
1082
  ? h(
982
1083
  'button',
983
1084
  {
984
1085
  type: 'button',
985
1086
  className: 'dsc-btn dsc-btn-danger',
986
- onClick: runClean,
1087
+ onClick: () => runClean(false),
987
1088
  disabled: busy !== '' || picked.size === 0,
988
1089
  },
989
1090
  busy === 'clean' ? t('cleaning') : t('clean'),
@@ -1088,10 +1189,110 @@ window.__ModuleLoader__.load({
1088
1189
  ),
1089
1190
  ),
1090
1191
  h(Audit, { t }),
1192
+ h(History, { t }),
1091
1193
  ],
1092
1194
  );
1093
1195
  }
1094
1196
 
1197
+ /**
1198
+ * Every real clean appends one line to a JSONL file under $DSH_HOME. The panel reads it
1199
+ * back, so "what did I actually clean, and when" has an answer that outlives the tab —
1200
+ * and the CSV copy takes that answer out of the app.
1201
+ */
1202
+ function History({ t }) {
1203
+ const [runs, setRuns] = useState(null);
1204
+ const [busy, setBusy] = useState(false);
1205
+ const [error, setError] = useState('');
1206
+ const [copied, setCopied] = useState(false);
1207
+
1208
+ const load = async () => {
1209
+ setBusy(true);
1210
+ setError('');
1211
+ try {
1212
+ const data = await call('history');
1213
+ setRuns(Array.isArray(data.runs) ? data.runs : []);
1214
+ } catch (err) {
1215
+ setError(`${t('historyTitle')}: ${describeError(t, err)}`);
1216
+ } finally {
1217
+ setBusy(false);
1218
+ }
1219
+ };
1220
+
1221
+ /** Quote every cell: a target list contains commas, and Excel must not split it. */
1222
+ const copyCsv = async () => {
1223
+ const rows = [['when', 'bytes', 'files', 'failed', 'truncated', 'targets', 'source']];
1224
+ for (const run of runs || []) {
1225
+ rows.push([
1226
+ run.at || '',
1227
+ String(run.bytes || 0),
1228
+ String(run.files || 0),
1229
+ String(run.failed || 0),
1230
+ run.truncated === true ? 'yes' : 'no',
1231
+ (Array.isArray(run.ids) ? run.ids : []).join(' '),
1232
+ run.source === 'agent' ? 'agent' : 'panel',
1233
+ ]);
1234
+ }
1235
+ const csv = rows
1236
+ .map((row) => row.map((cell) => `"${String(cell).replace(/"/g, '""')}"`).join(','))
1237
+ .join('\r\n');
1238
+ try {
1239
+ await navigator.clipboard.writeText(csv);
1240
+ setCopied(true);
1241
+ } catch {
1242
+ setError(t('historyCopy'));
1243
+ }
1244
+ };
1245
+
1246
+ return h(
1247
+ 'section',
1248
+ { className: 'dsc-card' },
1249
+ h(
1250
+ 'div',
1251
+ { className: 'dsc-card-title' },
1252
+ t('historyTitle'),
1253
+ h('span', { className: 'dsc-spacer' }),
1254
+ runs && runs.length > 0
1255
+ ? h('button', { type: 'button', className: 'dsc-btn', onClick: copyCsv }, copied ? t('historyCopied') : t('historyCopy'))
1256
+ : null,
1257
+ h(
1258
+ 'button',
1259
+ { type: 'button', className: 'dsc-btn', onClick: load, disabled: busy },
1260
+ busy ? t('historyLoading') : t('historyOpen'),
1261
+ ),
1262
+ ),
1263
+ error ? h('div', { className: 'dsc-notice dsc-err' }, error) : null,
1264
+ runs === null
1265
+ ? null
1266
+ : runs.length === 0
1267
+ ? h('div', { className: 'dsc-empty' }, t('historyEmpty'))
1268
+ : runs.map((run, index) =>
1269
+ h(
1270
+ 'div',
1271
+ { className: 'dsc-audit-row', key: `${run.at || ''}/${index}` },
1272
+ h(
1273
+ 'span',
1274
+ { className: 'dsc-audit-name' },
1275
+ h(
1276
+ 'span',
1277
+ { className: 'dsc-audit-label' },
1278
+ t('historyRun')
1279
+ .replace('{time}', run.at ? new Date(run.at).toLocaleString() : '')
1280
+ .replace('{bytes}', formatBytes(run.bytes || 0))
1281
+ .replace('{files}', String(run.files || 0)),
1282
+ ),
1283
+ h(
1284
+ 'span',
1285
+ { className: 'dsc-audit-why' },
1286
+ run.source === 'agent'
1287
+ ? t('historySourceAgent')
1288
+ : (Array.isArray(run.ids) ? run.ids : []).map((id) => targetName(t, id)).join(t('listSep')),
1289
+ ),
1290
+ ),
1291
+ ),
1292
+ ),
1293
+ );
1294
+ }
1295
+
1095
1296
  /**
1096
1297
  * The plan turns the numbers into an order of operations. Steps that this plugin can
1097
1298
  * actually perform get a button; the rest show the exact command, because "compact
@@ -1100,7 +1301,17 @@ window.__ModuleLoader__.load({
1100
1301
  function Plan({ plan, t, onRan, busy }) {
1101
1302
  const [copied, setCopied] = useState('');
1102
1303
 
1103
- if (!plan || !Array.isArray(plan.steps) || plan.steps.length === 0) return null;
1304
+ if (!plan || !Array.isArray(plan.steps)) return null;
1305
+ // An empty plan is an answer — "there is nothing worth doing" — not a reason to
1306
+ // render a hole where the plan was.
1307
+ if (plan.steps.length === 0) {
1308
+ return h(
1309
+ 'div',
1310
+ { className: 'dsc-plan' },
1311
+ h('div', { className: 'dsc-audit-group-title' }, t('planTitle')),
1312
+ h('div', { className: 'dsc-plan-detail' }, t('planEmpty')),
1313
+ );
1314
+ }
1104
1315
 
1105
1316
  const copy = (code, command) => {
1106
1317
  const done = () => {
@@ -1125,7 +1336,7 @@ window.__ModuleLoader__.load({
1125
1336
  const detailKey = `plan_code_${step.code}_detail`;
1126
1337
  const detail = t(detailKey)
1127
1338
  .replace('{n}', String(step.count || 0))
1128
- .replace('{ids}', (step.ids || []).join(', '))
1339
+ .replace('{ids}', (step.ids || []).map((id) => targetName(t, id)).join(t('listSep')))
1129
1340
  .replace('{name}', step.name || '')
1130
1341
  .replace('{scope}', step.scope === 'local' ? t('planScopeLocal') : t('planScopeRoaming'))
1131
1342
  .replace('{folder}', step.folder || '')
package/lib/history.js ADDED
@@ -0,0 +1,71 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+
5
+ /**
6
+ * What each real run actually freed, appended as one JSON line per run.
7
+ *
8
+ * The panel is the only writer and it writes after the deletion has already happened, so a
9
+ * failed append must never turn a successful clean into an error: every function here answers
10
+ * with a value instead of throwing. Nothing else reads this file, which is why it is a plain
11
+ * JSONL log rather than a database.
12
+ */
13
+
14
+ const MAX_BYTES = 512 * 1024;
15
+
16
+ export function historyDir(env = process.env) {
17
+ const home =
18
+ typeof env.DSH_HOME === 'string' && env.DSH_HOME.trim() !== ''
19
+ ? env.DSH_HOME.trim()
20
+ : path.join(os.homedir(), '.dsh');
21
+ return path.join(home, 'dsh-disk-cleaner');
22
+ }
23
+
24
+ export function historyFile(env = process.env) {
25
+ return path.join(historyDir(env), 'history.jsonl');
26
+ }
27
+
28
+ const trim = (file) => {
29
+ try {
30
+ const stat = fs.statSync(file);
31
+ if (stat.size <= MAX_BYTES) return;
32
+ const lines = fs.readFileSync(file, 'utf8').split('\n').filter((line) => line.trim() !== '');
33
+ // Keep the newest half, which is what a reader ever asks about.
34
+ const kept = lines.slice(Math.floor(lines.length / 2));
35
+ fs.writeFileSync(file, `${kept.join('\n')}\n`, 'utf8');
36
+ } catch {
37
+ /* a log that cannot be trimmed is still a log */
38
+ }
39
+ };
40
+
41
+ export function appendRun(record, env = process.env) {
42
+ try {
43
+ const dir = historyDir(env);
44
+ fs.mkdirSync(dir, { recursive: true });
45
+ const file = historyFile(env);
46
+ fs.appendFileSync(file, `${JSON.stringify(record)}\n`, 'utf8');
47
+ trim(file);
48
+ return true;
49
+ } catch {
50
+ return false;
51
+ }
52
+ }
53
+
54
+ export function readRuns(limit = 50, env = process.env) {
55
+ try {
56
+ const text = fs.readFileSync(historyFile(env), 'utf8');
57
+ const lines = text.split('\n').filter((line) => line.trim() !== '');
58
+ const out = [];
59
+ for (const line of lines.slice(-limit)) {
60
+ try {
61
+ const parsed = JSON.parse(line);
62
+ if (parsed !== null && typeof parsed === 'object') out.push(parsed);
63
+ } catch {
64
+ /* a torn last line is not a reason to lose the rest */
65
+ }
66
+ }
67
+ return out.reverse();
68
+ } catch {
69
+ return [];
70
+ }
71
+ }
package/lib/index.js CHANGED
@@ -11,6 +11,7 @@ import { spawn } from 'node:child_process';
11
11
 
12
12
  import { areaPaths, audit, invalidateAudit } from './audit.js';
13
13
  import { clean, drives, hasTarget, resolveTargets, scan } from './catalog.js';
14
+ import { appendRun, readRuns } from './history.js';
14
15
 
15
16
  export const name = 'dsh-disk-cleaner';
16
17
 
@@ -20,6 +21,16 @@ const ROUTE_PREFIX = '/api/disk-cleaner/';
20
21
  const MAX_BODY = 4096;
21
22
  const MAX_IDS = 32;
22
23
 
24
+ /**
25
+ * Every rejection carries a code beside its message. The message is for logs and curl; the
26
+ * code is what the panel translates, so a Chinese user never reads "origin rejected".
27
+ */
28
+ const coded = (code, message) => {
29
+ const error = new Error(message);
30
+ error.code = code;
31
+ return error;
32
+ };
33
+
23
34
  const sendJson = (res, status, value) => {
24
35
  const body = JSON.stringify(value);
25
36
  res.writeHead(status, {
@@ -30,11 +41,42 @@ const sendJson = (res, status, value) => {
30
41
  res.end(body);
31
42
  };
32
43
 
33
- /** A same-origin page is the only caller that can reach a state-changing route. */
44
+ const LOOPBACK_HOSTS = new Set(['127.0.0.1', 'localhost', '[::1]', '::1']);
45
+
46
+ /**
47
+ * The desktop app renders the panel under its own privileged protocol (`dsh-app://app`), so a
48
+ * fetch from inside that window carries an Origin no `http://` comparison can ever match. A web
49
+ * page cannot forge this: Origin is set by the user agent, and only the harness serves that
50
+ * scheme. Everything is still gated on a loopback Host below.
51
+ */
52
+ const APP_SCHEME = 'dsh-app://';
53
+
54
+ /** `host` is `name` or `name:port`; the port is not ours to compare. */
55
+ function hostName(host) {
56
+ if (host.startsWith('[')) {
57
+ const end = host.indexOf(']');
58
+ return end === -1 ? host : host.slice(0, end + 1);
59
+ }
60
+ const colon = host.lastIndexOf(':');
61
+ return colon === -1 ? host : host.slice(0, colon);
62
+ }
63
+
64
+ /**
65
+ * Only the panel may drive a state-changing route.
66
+ *
67
+ * A missing Origin is allowed solely when the request already arrived on a loopback address: the
68
+ * desktop forwarder can strip the header, and refusing that locks the panel out of its own API.
69
+ * A web page cannot reach a loopback port without sending an Origin, so this does not open the
70
+ * door it looks like it opens. Any other absent-Origin caller must first reach loopback anyway.
71
+ */
34
72
  const sameOrigin = (req) => {
35
73
  const origin = req.headers.origin;
36
74
  const host = req.headers.host;
37
- return origin === undefined || host === undefined || origin === `http://${host}` || origin === `https://${host}`;
75
+ if (typeof host !== 'string' || host === '') return false;
76
+ const onLoopback = LOOPBACK_HOSTS.has(hostName(host));
77
+ if (typeof origin !== 'string' || origin === 'null') return onLoopback;
78
+ if (origin.startsWith(APP_SCHEME)) return onLoopback;
79
+ return origin === `http://${host}` || origin === `https://${host}`;
38
80
  };
39
81
 
40
82
  const readBody = (req) =>
@@ -74,10 +116,10 @@ const readBody = (req) =>
74
116
  /** Only catalog ids are accepted; an unknown or malformed id rejects the request. */
75
117
  const readIds = (value) => {
76
118
  if (value === undefined || value === null) return undefined;
77
- if (!Array.isArray(value)) throw new Error('ids must be an array');
78
- if (value.length > MAX_IDS) throw new Error('too many ids');
119
+ if (!Array.isArray(value)) throw coded('bad-ids', 'ids must be an array');
120
+ if (value.length > MAX_IDS) throw coded('too-many-ids', 'too many ids');
79
121
  for (const id of value) {
80
- if (typeof id !== 'string' || !hasTarget(id)) throw new Error(`unknown target: ${String(id)}`);
122
+ if (typeof id !== 'string' || !hasTarget(id)) throw coded('unknown-target', `unknown target: ${String(id)}`);
81
123
  }
82
124
  return value;
83
125
  };
@@ -91,10 +133,10 @@ const readDrive = (value) => {
91
133
  if (value === undefined || value === null || value === '') return undefined;
92
134
  const raw = typeof value === 'string' ? value.trim() : '';
93
135
  const match = /^([A-Za-z])(?::[\\/]?)?$/.exec(raw);
94
- if (match === null) throw new Error(`unknown drive: ${String(value)}`);
136
+ if (match === null) throw coded('unknown-drive', `unknown drive: ${String(value)}`);
95
137
  const letter = match[1].toUpperCase();
96
138
  const mounted = drives().find((drive) => drive.root[0] === letter);
97
- if (!mounted) throw new Error(`drive not mounted: ${letter}:`);
139
+ if (!mounted) throw coded('drive-not-mounted', `drive not mounted: ${letter}:`);
98
140
  return mounted.root;
99
141
  };
100
142
 
@@ -173,24 +215,38 @@ async function run(action, payload) {
173
215
  if (action === 'reveal') return reveal(payload);
174
216
  if (action === 'clean') {
175
217
  const ids = readIds(payload.ids);
176
- if (ids === undefined || ids.length === 0) throw new Error('clean needs at least one target id');
218
+ if (ids === undefined || ids.length === 0) throw coded('no-targets', 'clean needs at least one target id');
177
219
  const dryRun = payload.confirm !== true;
178
220
  const results = await clean(ids, { dryRun, drive: readDrive(payload.drive) });
179
221
  // A real deletion changes the numbers the assessment reported, so the cached plan is
180
222
  // no longer the truth. A dry run removed nothing and leaves it valid.
181
- if (!dryRun) invalidateAudit();
223
+ if (!dryRun) {
224
+ invalidateAudit();
225
+ // Written after the fact and never allowed to fail the request: the bytes are already
226
+ // gone by the time this runs, so a broken log must not read as a broken clean.
227
+ appendRun({
228
+ at: new Date().toISOString(),
229
+ bytes: results.reduce((sum, item) => sum + (item.bytes || 0), 0),
230
+ files: results.reduce((sum, item) => sum + (item.removed || 0), 0),
231
+ failed: results.reduce((sum, item) => sum + (item.failed || 0), 0),
232
+ truncated: results.some((item) => item.truncated === true),
233
+ ids: results.map((item) => item.id),
234
+ results,
235
+ });
236
+ }
182
237
  return { dryRun, results };
183
238
  }
184
- throw new Error(`unknown action: ${String(action)}`);
239
+ if (action === 'history') return { runs: readRuns(50) };
240
+ throw coded('unknown-action', `unknown action: ${String(action)}`);
185
241
  }
186
242
 
187
243
  const handle = async (req, res) => {
188
244
  if (req.method !== 'POST') {
189
- sendJson(res, 405, { ok: false, error: 'method not allowed' });
245
+ sendJson(res, 405, { ok: false, code: 'method-not-allowed', error: 'method not allowed' });
190
246
  return;
191
247
  }
192
248
  if (!sameOrigin(req)) {
193
- sendJson(res, 403, { ok: false, error: 'origin rejected' });
249
+ sendJson(res, 403, { ok: false, code: 'origin-rejected', error: 'origin rejected' });
194
250
  return;
195
251
  }
196
252
  try {
@@ -198,7 +254,11 @@ const handle = async (req, res) => {
198
254
  const action = typeof payload.action === 'string' ? payload.action : '';
199
255
  sendJson(res, 200, { ok: true, ...(await run(action, payload)) });
200
256
  } catch (error) {
201
- sendJson(res, 400, { ok: false, error: error instanceof Error ? error.message : String(error) });
257
+ sendJson(res, 400, {
258
+ ok: false,
259
+ code: typeof error?.code === 'string' ? error.code : 'internal',
260
+ error: error instanceof Error ? error.message : String(error),
261
+ });
202
262
  }
203
263
  };
204
264
 
@@ -413,10 +473,22 @@ export function apply(ctx) {
413
473
  }
414
474
  if (action === 'clean') {
415
475
  const ids = readIds(args.ids);
416
- if (ids === undefined || ids.length === 0) throw new Error('clean needs at least one target id');
476
+ if (ids === undefined || ids.length === 0) throw coded('no-targets', 'clean needs at least one target id');
417
477
  const dryRun = args.confirm !== true;
418
478
  const results = await clean(ids, { dryRun, drive: readDrive(args.drive) });
419
- if (!dryRun) invalidateAudit();
479
+ if (!dryRun) {
480
+ invalidateAudit();
481
+ appendRun({
482
+ at: new Date().toISOString(),
483
+ bytes: results.reduce((sum, item) => sum + (item.bytes || 0), 0),
484
+ files: results.reduce((sum, item) => sum + (item.removed || 0), 0),
485
+ failed: results.reduce((sum, item) => sum + (item.failed || 0), 0),
486
+ truncated: results.some((item) => item.truncated === true),
487
+ ids: results.map((item) => item.id),
488
+ source: 'agent',
489
+ results,
490
+ });
491
+ }
420
492
  const freed = results.reduce((sum, item) => sum + item.bytes, 0);
421
493
  const files = results.reduce((sum, item) => sum + item.removed, 0);
422
494
  const failed = results.reduce((sum, item) => sum + item.failed, 0);
@@ -426,7 +498,7 @@ export function apply(ctx) {
426
498
  : `Freed ${formatBytes(freed)} across ${files} file(s).`;
427
499
  return `${head}\n${detail}\nLocked or denied entries skipped: ${failed}`;
428
500
  }
429
- throw new Error(`unknown action: ${action}`);
501
+ throw coded('unknown-action', `unknown action: ${action}`);
430
502
  },
431
503
  }),
432
504
  'dsh-disk-cleaner: tool',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-disk-cleaner",
3
- "version": "1.6.5",
3
+ "version": "1.6.7",
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": [
@@ -23,6 +23,7 @@
23
23
  "lib/catalog.js",
24
24
  "lib/audit.js",
25
25
  "lib/plan.js",
26
+ "lib/history.js",
26
27
  "cordis.patch.yml",
27
28
  "locale/*.json",
28
29
  "icon.svg",
package/smoke.mjs CHANGED
@@ -229,6 +229,7 @@ const index = read('lib/index.js');
229
229
  const catalog = read('lib/catalog.js');
230
230
  const plan = read('lib/plan.js');
231
231
  const auditSrc = read('lib/audit.js');
232
+ const historySrc = read('lib/history.js');
232
233
 
233
234
  const { apply } = await import(pathToFileURL(path.join(here, 'lib/index.js')).href);
234
235
  check('host exports apply()', typeof apply === 'function');
@@ -264,7 +265,7 @@ check('tool needs no id for overview', JSON.stringify(definition.parameters.requ
264
265
  check('tool description warns audit is read-only', /read-only: it never removes anything/.test(definition.description));
265
266
  check('every effect is labelled', applied.every((label) => typeof label === 'string' && label.startsWith('dsh-disk-cleaner:')));
266
267
  check('tool output schema matches its string reply', definition.output.schema.type === 'string' && typeof (await definition.execute({ action: 'overview' })) === 'string');
267
- check('the read-only agent clean keeps the audit cache in sync', /if \(!dryRun\) invalidateAudit\(\);[\s\S]*const freed = results\.reduce/.test(index));
268
+ check('the read-only agent clean keeps the audit cache in sync', /if \(!dryRun\) \{[\s\S]*?invalidateAudit\(\);[\s\S]*?const freed = results\.reduce/.test(index));
268
269
  check('orphan target descriptions state the real 30-day rule, not four signals / two years',
269
270
  /30 days/.test(catalog) && /30 days/.test(index) && /QQGuild data folder/.test(source) &&
270
271
  !/four independent signals|over two years|no Start-menu shortcut|two years/.test(catalog + index + source));
@@ -412,7 +413,110 @@ check(
412
413
  source.includes('QQ') ,
413
414
  'it only ever measures the QQGuild folder',
414
415
  );
415
- check('the version badge and package agree', pkg.version === '1.6.5');
416
+ check('the version badge and package agree', pkg.version === '1.6.7');
417
+
418
+ check(
419
+ 'a dry run is one click away and never sends confirm',
420
+ /const runClean = async \(dry\) => \{/.test(source) &&
421
+ /const body = isDry \? \{ ids \} : \{ ids, confirm: true \};/.test(source) &&
422
+ /onClick: \(\) => runClean\(true\)/.test(source) &&
423
+ /onClick: \(\) => runClean\(false\)/.test(source),
424
+ 'the panel must be able to ask what a delete would free before doing it',
425
+ );
426
+
427
+ check(
428
+ 'a dry run never claims it freed anything',
429
+ /if \(isDry\) \{[\s\S]{0,320}t\('dryRunHint'\)[\s\S]{0,320}return;/.test(source) &&
430
+ /dryRunHint: '/.test(source),
431
+ 'the dry-run banner must say nothing was deleted',
432
+ );
433
+
434
+ check(
435
+ 'the notice a clean sets survives the rescan that follows it',
436
+ /await scan\(\{ keepNotice: true \}\);/.test(source) &&
437
+ /if \(!keepNotice\) setNotice\(null\);/.test(source),
438
+ 'rescanning used to erase the result the user just asked for',
439
+ );
440
+
441
+ check(
442
+ 'plan steps name targets in words, never catalog ids',
443
+ !source.includes("(step.ids || []).join(', ')") &&
444
+ source.includes('(step.ids || []).map((id) => targetName(t, id))'),
445
+ 'the panel must not print npm-cache, chrome-cache, pip-cache at the user',
446
+ );
447
+
448
+ check(
449
+ 'an empty plan says so instead of rendering nothing',
450
+ /if \(plan.steps.length === 0\)/.test(source) &&
451
+ /planEmpty: '/.test(source) &&
452
+ /t\('planEmpty'\)/.test(source),
453
+ 'nothing worth doing is a conclusion, not a blank',
454
+ );
455
+
456
+ check(
457
+ 'route errors carry a stable code the panel can translate',
458
+ /const coded = \(code, message\) => \{/.test(index) &&
459
+ /coded\('unknown-target'/.test(index) &&
460
+ /coded\('no-targets'/.test(index) &&
461
+ /code: typeof error\?\.code === 'string' \? error\.code : 'internal'/.test(index) &&
462
+ /function describeError\(t, err\) \{/.test(source) &&
463
+ /if \(data && typeof data\.code === 'string'\) error\.code = data\.code;/.test(source),
464
+ 'a Chinese panel must not print "origin rejected" or "unknown target: x"',
465
+ );
466
+
467
+ check(
468
+ 'every route error code has both a Chinese and an English sentence',
469
+ ['method-not-allowed', 'origin-rejected', 'bad-ids', 'too-many-ids', 'unknown-target', 'unknown-drive', 'drive-not-mounted', 'no-targets', 'unknown-action', 'internal'].every(
470
+ (code) => source.includes(`'err_${code}': `),
471
+ ),
472
+ 'an untranslated code falls back to the English message',
473
+ );
474
+
475
+ check(
476
+ 'a real clean writes one history line and the panel can read it back',
477
+ /import \{ appendRun, readRuns \} from '\.\/history\.js';/.test(index) &&
478
+ /if \(action === 'history'\) return \{ runs: readRuns\(50\) \};/.test(index) &&
479
+ /appendRun\(\{/.test(index) &&
480
+ /export function appendRun/.test(historySrc) &&
481
+ /export function readRuns/.test(historySrc) &&
482
+ /historyTitle: '/.test(source) &&
483
+ /function History\(\{ t \}\)/.test(source) &&
484
+ /h\(History, \{ t \}\)/.test(source),
485
+ 'history must survive the tab, and be exportable',
486
+ );
487
+
488
+ check(
489
+ 'history never throws into the clean path',
490
+ /try \{/.test(historySrc) &&
491
+ /catch/.test(historySrc) &&
492
+ !/throw /.test(historySrc),
493
+ 'the bytes are already gone; failing to log them must not look like a failed clean',
494
+ );
495
+
496
+ check(
497
+ 'scan bounds its own concurrency instead of walking every target at once',
498
+ /const SCAN_CONCURRENCY = \d+;/.test(catalog) &&
499
+ /const worker = async \(\) => \{/.test(catalog) &&
500
+ !/await Promise\.all\(targets\.map/.test(catalog),
501
+ '32 volumes at once stalls a spinning disk and the antivirus',
502
+ );
503
+
504
+ check(
505
+ 'a system target under SystemRoot is read from SystemRoot, not from C:',
506
+ (catalog.match(/env\('SystemRoot'\)/g) ?? []).length === 2 &&
507
+ !/C:\\+Windows\\+Temp/.test(catalog),
508
+ 'the boundary check already trusted SystemRoot; the paths have to agree',
509
+ );
510
+
511
+ check(
512
+ 'a request without an Origin is refused unless it arrived on loopback',
513
+ /const LOOPBACK_HOSTS = new Set\(/.test(index) &&
514
+ /const APP_SCHEME = 'dsh-app:\/\/';/.test(index) &&
515
+ /if \(typeof origin !== 'string' \|\| origin === 'null'\) return onLoopback;/.test(index) &&
516
+ /if \(origin\.startsWith\(APP_SCHEME\)\) return onLoopback;/.test(index) &&
517
+ !/if \(typeof origin !== 'string' \|\| typeof host !== 'string'\) return false;/.test(index),
518
+ 'the desktop shell posts from dsh-app://, and a stripped Origin can still be the panel itself',
519
+ );
416
520
 
417
521
  check(
418
522
  'the panel carries dsh-design-baseline (full mode: css inlined, root tagged, tokens only)',