dsh-session-remover 1.0.0 → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +2 -1
- package/README.md +2 -1
- package/lib/client.js +18 -20
- package/lib/index.js +137 -50
- package/package.json +1 -1
package/README.en.md
CHANGED
|
@@ -43,7 +43,7 @@ dsh plugin --profile desktop add dsh-session-remover
|
|
|
43
43
|
|
|
44
44
|
## Notes
|
|
45
45
|
|
|
46
|
-
- **Deletion moves the data to the recycle bin; it does not erase it.** The move stays on the same volume, so no data is copied and nothing is left behind in `.dsh`. Before deleting, the plugin checks
|
|
46
|
+
- **Deletion moves the data to the recycle bin; it does not erase it.** The move stays on the same volume, so no data is copied and nothing is left behind in `.dsh`. Before deleting, the plugin checks two things on the volume that holds the session (Windows stores the recycle-bin setting per drive): whether the recycle bin has been disabled, and whether the volume has a usable recycle bin at all; if either check fails the plugin **refuses to delete** rather than falling back to permanent deletion. Afterwards each item is verified by original location and name; the verification reads the local recycle bin index directly (listing file names only, not reading each entry), so its cost is essentially independent of how many items the bin already holds.
|
|
47
47
|
- **Subagent sessions are removed together.** Subagent sessions are not shown in the sidebar or search, so they accumulate unless removed together. Forked conversations are independent and are **never** deleted along with their source.
|
|
48
48
|
- **Three records are cleaned:** the projection cache, the workspace registry, and the archived set. The projection cache is derived data that DSH rebuilds after a restore; the other two are cleared to avoid leaving traces locally, at the cost of a restored conversation being filed under "Ungrouped" in the sidebar.
|
|
49
49
|
- **Deleting the currently open conversation is supported.** Its running task is stopped and flushed to disk first, and no DSH restart is required.
|
|
@@ -51,6 +51,7 @@ dsh plugin --profile desktop add dsh-session-remover
|
|
|
51
51
|
## Known limitations
|
|
52
52
|
|
|
53
53
|
- **Windows only.** Moving to the recycle bin relies on the system script host `cscript.exe`, which ships with Windows; on other systems the plugin refuses to delete and reports an error rather than falling back to permanent deletion.
|
|
54
|
+
- **Deletion is refused when the volume has no recycle bin.** If the session directory sits on a network drive, a USB stick, or a volume with no usable recycle bin (such as FAT/exFAT), moving it to the recycle bin would degrade into permanent deletion, so the plugin always refuses. To proceed, move the session directory to a local disk, or delete any file on that volume once to initialise its recycle bin, and try again.
|
|
54
55
|
- **An emptied recycle bin cannot be restored.** The plugin only moves the files into the bin; whether to empty it is the user's decision. The bin also has a size cap, and Windows discards the oldest entries once the quota is exceeded.
|
|
55
56
|
- **A restored conversation may not be in its original workspace.** The plugin clears the workspace registry entry, so a restored conversation appears under "Ungrouped". To restore it to its original workspace, edit `~/.dsh/storages/workspace.json` by hand while DSH is stopped.
|
|
56
57
|
- **Attachments are not reclaimed.** DSH attachments are content-addressed, shared across conversations, and carry no reference counting, so the plugin cannot safely determine whether they can be reclaimed.
|
package/README.md
CHANGED
|
@@ -43,7 +43,7 @@ dsh plugin --profile desktop add dsh-session-remover
|
|
|
43
43
|
|
|
44
44
|
## 说明
|
|
45
45
|
|
|
46
|
-
- **删除为移入回收站,并非抹除**:同盘移动,不复制数据,`.dsh`
|
|
46
|
+
- **删除为移入回收站,并非抹除**:同盘移动,不复制数据,`.dsh` 内不留副本。删除前先按会话所在磁盘核对两件事:该磁盘是否关闭了回收站(Windows 该开关按磁盘逐个设置)、该磁盘是否有可用的回收站;任一不满足即**拒绝删除**,不会退化为永久删除。移入后另按「原位置 + 名字」逐项核对,确认确实进入回收站;核对直接读取本机回收站索引(只列文件名,不逐项读取),耗时基本不受回收站已存项数影响。
|
|
47
47
|
- **一并删除子代理会话**:子代理会话不显示于侧栏与搜索,未一并清理将持续累积。由 fork 产生的分叉会话属独立对话,**不被连带删除**。
|
|
48
48
|
- **清理三处记录**:投影缓存、工作区记账、归档集合。投影缓存为派生数据,还原后由 DSH 自动重建;后两处予以清理以避免在本地留存痕迹,代价是还原后的对话归入侧栏的「未分组」。
|
|
49
49
|
- **支持删除当前打开的对话**:删除前先停止其运行中的任务并落盘,无需重启 DSH。
|
|
@@ -51,6 +51,7 @@ dsh plugin --profile desktop add dsh-session-remover
|
|
|
51
51
|
## 已知限制
|
|
52
52
|
|
|
53
53
|
- **仅支持 Windows**。移入回收站依赖系统脚本宿主 `cscript.exe`(Windows 自带);在其他系统上插件拒绝删除并报错,不会退化为永久删除。
|
|
54
|
+
- **所在磁盘没有回收站时拒绝删除**。会话目录若位于网络盘、U 盘或未启用回收站的磁盘(如 FAT/exFAT),移入回收站会退化为永久删除,插件一律拒绝。处理办法:先把会话目录搬到本地磁盘,或在该磁盘上删除任意一个文件以启用回收站,再重试。
|
|
54
55
|
- **回收站清空后不可还原**。插件仅负责将文件移入回收站,是否清空由用户决定;回收站存在容量上限,超出配额时 Windows 将丢弃最早的项。
|
|
55
56
|
- **还原后可能不在原工作区**。插件清除了工作区记账,还原的对话将出现在「未分组」中。如需恢复至原工作区,须在 DSH 停止运行时手工编辑 `~/.dsh/storages/workspace.json`。
|
|
56
57
|
- **附件不予回收**。DSH 附件按内容寻址、跨会话共享,且无引用计数,插件无法安全判断其可否回收。
|
package/lib/client.js
CHANGED
|
@@ -94,6 +94,7 @@ window.__ModuleLoader__.load({
|
|
|
94
94
|
"err.E_RECYCLE_SPAWN_FAILED": "调用回收站失败:{reason}",
|
|
95
95
|
"err.E_RECYCLE_NO_RESULT": "回收站未返回结果,脚本未执行完",
|
|
96
96
|
"err.E_RECYCLE_DISABLED": "本机已关闭回收站(已勾选「不将文件移到回收站中,直接删除」),插件拒绝删除,以免数据无法还原",
|
|
97
|
+
"err.E_NO_RECYCLE_BIN": "磁盘 {volume} 上没有可用的回收站(网络盘、U 盘或该磁盘尚未启用回收站),插件拒绝删除,以免数据无法还原。可先把会话目录搬到本地磁盘,或在该磁盘上删除任意一个文件以启用回收站后再试",
|
|
97
98
|
"err.E_RECYCLE_UNRECOGNIZED": "无法识别回收站返回的结果",
|
|
98
99
|
"err.E_RECYCLE_LEFT": "共 {total} 个会话目录,已移入回收站 {moved} 个,仍有 {left} 个未能移入",
|
|
99
100
|
"err.E_RECYCLE_LEFT_REASON": "共 {total} 个会话目录,已移入回收站 {moved} 个,仍有 {left} 个未能移入:{reason}",
|
|
@@ -170,6 +171,7 @@ window.__ModuleLoader__.load({
|
|
|
170
171
|
"err.E_RECYCLE_SPAWN_FAILED": "Failed to invoke the recycle bin: {reason}",
|
|
171
172
|
"err.E_RECYCLE_NO_RESULT": "The recycle bin returned no result; the script did not finish",
|
|
172
173
|
"err.E_RECYCLE_DISABLED": "The recycle bin is disabled on this machine (\"Don't move files to the Recycle Bin. Remove files immediately when deleted\" is selected), so the plugin refuses to delete rather than leave the data unrestorable",
|
|
174
|
+
"err.E_NO_RECYCLE_BIN": "Volume {volume} has no usable recycle bin (a network drive, a USB stick, or a volume where the recycle bin has not been initialised), so the plugin refuses to delete rather than leave the data unrestorable. Move the session directory to a local disk, or delete any file on that volume once to initialise its recycle bin, and try again",
|
|
173
175
|
"err.E_RECYCLE_UNRECOGNIZED": "The result returned by the recycle bin could not be recognised",
|
|
174
176
|
"err.E_RECYCLE_LEFT": "Of {total} session directories, {moved} were moved to the recycle bin and {left} could not be moved",
|
|
175
177
|
"err.E_RECYCLE_LEFT_REASON": "Of {total} session directories, {moved} were moved to the recycle bin and {left} could not be moved: {reason}",
|
|
@@ -237,7 +239,7 @@ window.__ModuleLoader__.load({
|
|
|
237
239
|
|
|
238
240
|
function t(key, params) {
|
|
239
241
|
var table = DICT[i18n.active] || DICT.zh;
|
|
240
|
-
//
|
|
242
|
+
// 词表为普通对象,键可能与原型链上的属性同名(如 constructor),故一律按自有属性判定。
|
|
241
243
|
var text = Object.prototype.hasOwnProperty.call(table, key) ? table[key] : undefined;
|
|
242
244
|
if (text === undefined) {
|
|
243
245
|
text = Object.prototype.hasOwnProperty.call(DICT.zh, key) ? DICT.zh[key] : undefined;
|
|
@@ -257,11 +259,11 @@ window.__ModuleLoader__.load({
|
|
|
257
259
|
return Object.prototype.hasOwnProperty.call(table, key) || Object.prototype.hasOwnProperty.call(DICT.zh, key);
|
|
258
260
|
}
|
|
259
261
|
|
|
260
|
-
//
|
|
262
|
+
// 服务端的失败为「错误码 + 参数」;嵌套原因(reason)本身也可能为失败对象,故递归展开。
|
|
261
263
|
function renderFailure(value) {
|
|
262
264
|
if (value === null || value === undefined) return "";
|
|
263
265
|
if (typeof value === "string") {
|
|
264
|
-
//
|
|
266
|
+
// 参数中也可能直接嵌入错误码原文(而非对象),可识别时一并取词。
|
|
265
267
|
var bare = /^(E|L)_[A-Z0-9_]+$/.test(value) ? (value.indexOf("L_") === 0 ? "leftover." : "err.") + value : null;
|
|
266
268
|
return bare !== null && hasKey(bare) ? t(bare) : value;
|
|
267
269
|
}
|
|
@@ -281,7 +283,7 @@ window.__ModuleLoader__.load({
|
|
|
281
283
|
return "";
|
|
282
284
|
}
|
|
283
285
|
|
|
284
|
-
//
|
|
286
|
+
// 浮层挂载于自行创建的 React 根上(不经官方插槽),故自行订阅语言变化后重渲染。
|
|
285
287
|
function useI18nRevision() {
|
|
286
288
|
var state = react.useState(i18n.revision);
|
|
287
289
|
var setValue = state[1];
|
|
@@ -357,7 +359,7 @@ window.__ModuleLoader__.load({
|
|
|
357
359
|
window.alert(renderFailure(result) || t("error.http", { status: response.status }));
|
|
358
360
|
return;
|
|
359
361
|
}
|
|
360
|
-
// 残留(leftover
|
|
362
|
+
// 残留(leftover)与警告均须报出,仅报 warnings 将使「有记录未清理」静默消失。
|
|
361
363
|
var notes = collectNotes(result);
|
|
362
364
|
if (notes.length > 0) {
|
|
363
365
|
window.alert(t("error.leftover", { list: notes.map(renderFailure).join("\n") }));
|
|
@@ -368,7 +370,7 @@ window.__ModuleLoader__.load({
|
|
|
368
370
|
}
|
|
369
371
|
}
|
|
370
372
|
|
|
371
|
-
//
|
|
373
|
+
// 残留与警告合并为一串:两者均表示「目录已移走,但本地仍有数据未清理」。
|
|
372
374
|
function collectNotes(result) {
|
|
373
375
|
var notes = [];
|
|
374
376
|
if (result && Array.isArray(result.warnings)) notes = notes.concat(result.warnings);
|
|
@@ -428,7 +430,7 @@ window.__ModuleLoader__.load({
|
|
|
428
430
|
return value;
|
|
429
431
|
}
|
|
430
432
|
|
|
431
|
-
// origin === "subagent"
|
|
433
|
+
// origin === "subagent" 过滤是安全关键:遗漏会将子代理会话列给用户。
|
|
432
434
|
function selectBatchRows(listState, workspaceState, currentId) {
|
|
433
435
|
var rows = [];
|
|
434
436
|
if (!listState || !listState.byId) return rows;
|
|
@@ -486,7 +488,7 @@ window.__ModuleLoader__.load({
|
|
|
486
488
|
return undefined;
|
|
487
489
|
}
|
|
488
490
|
|
|
489
|
-
// 官方组件(Button/Checkbox/Modal
|
|
491
|
+
// 官方组件(Button/Checkbox/Modal)取不到时有朴素降级实现,键盘与语义仍可用。
|
|
490
492
|
function uiButton(props) {
|
|
491
493
|
if (Button) return react.createElement(Button, props);
|
|
492
494
|
return react.createElement("button", {
|
|
@@ -621,7 +623,7 @@ window.__ModuleLoader__.load({
|
|
|
621
623
|
setAcknowledged(false);
|
|
622
624
|
}
|
|
623
625
|
|
|
624
|
-
// 关闭闸门:删除进行中(busy
|
|
626
|
+
// 关闭闸门:删除进行中(busy)时不响应关闭,待其结束。
|
|
625
627
|
function requestClose() {
|
|
626
628
|
if (busy) return;
|
|
627
629
|
onClose();
|
|
@@ -741,9 +743,7 @@ window.__ModuleLoader__.load({
|
|
|
741
743
|
if (row.workspace) meta.push(react.createElement("span", { key: "ws" }, row.workspace));
|
|
742
744
|
if (row.fork) meta.push(react.createElement("span", { key: "fork", className: "dsh-session-remover-tag" }, t("tag.fork")));
|
|
743
745
|
if (row.current) meta.push(react.createElement("span", { key: "cur", className: "dsh-session-remover-tag" }, t("tag.current")));
|
|
744
|
-
// label
|
|
745
|
-
// 元素能正常渲染,只有这样才显示得出「标题 + 分支/当前打开」这一整行。
|
|
746
|
-
// 官方类型声明写的是 string,将来若给本插件加类型检查会在此报错,属预期差异。
|
|
746
|
+
// label 传元素:官方 Checkbox 运行时按 children 渲染,传字符串将无法显示这一整行。
|
|
747
747
|
var label = react.createElement("span", { className: "dsh-session-remover-rowmain" }, [
|
|
748
748
|
react.createElement("span", { key: "t", className: "dsh-session-remover-title" }, row.title),
|
|
749
749
|
meta.length > 0 ? react.createElement("span", { key: "m", className: "dsh-session-remover-meta" }, meta) : null
|
|
@@ -816,7 +816,7 @@ window.__ModuleLoader__.load({
|
|
|
816
816
|
});
|
|
817
817
|
}
|
|
818
818
|
|
|
819
|
-
//
|
|
819
|
+
// 本插件向官方侧栏插槽注册菜单项;批量浮层为自行创建的 React 根(官方无多选插槽)。
|
|
820
820
|
var host = { ctx: null, container: null, root: null, seq: 0 };
|
|
821
821
|
|
|
822
822
|
function ensureHost() {
|
|
@@ -866,9 +866,7 @@ window.__ModuleLoader__.load({
|
|
|
866
866
|
host.container = null;
|
|
867
867
|
}
|
|
868
868
|
|
|
869
|
-
//
|
|
870
|
-
// 万一没给 t,退回模块内的同一套词表。
|
|
871
|
-
// 官方 t 查不到词条时会把 key 原样返回,故必须把「等于 key」也当作没取到,否则菜单会露出裸码。
|
|
869
|
+
// 官方 t 查不到词条时会将 key 原样返回,故「等于 key」同样视为未取到,否则菜单将显示错误码原文。
|
|
872
870
|
function menuText(props, key) {
|
|
873
871
|
if (props && typeof props.t === "function") {
|
|
874
872
|
try {
|
|
@@ -949,8 +947,8 @@ window.__ModuleLoader__.load({
|
|
|
949
947
|
}
|
|
950
948
|
host.ctx = ctx;
|
|
951
949
|
|
|
952
|
-
// locale
|
|
953
|
-
//
|
|
950
|
+
// locale 服务软取:取不到则维持中文,不阻塞插件启用。
|
|
951
|
+
// 注册与订阅各自独立降级——订阅失败不应使已注册成功的词典一并失效。
|
|
954
952
|
var localeService = null;
|
|
955
953
|
try { localeService = ctx.get("locale") || ctx.locale || null; } catch (e) { localeService = null; }
|
|
956
954
|
var localeReady = false;
|
|
@@ -972,8 +970,8 @@ window.__ModuleLoader__.load({
|
|
|
972
970
|
}
|
|
973
971
|
}
|
|
974
972
|
}
|
|
975
|
-
//
|
|
976
|
-
// 恒带 locale
|
|
973
|
+
// 仅当 locale 服务确实就位时才声明 locale:官方在无 locale face 时渲染该条目会抛出异常,
|
|
974
|
+
// 恒带 locale 将使「软取、取不到即退回中文」的设计变成整条侧栏报错。
|
|
977
975
|
function entryOptions(id, order) {
|
|
978
976
|
return localeReady
|
|
979
977
|
? { name: SLOT, id: id, order: order, locale: NS }
|
package/lib/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// dsh-session-remover —— 宿主半(node 侧),浏览器半见同包 lib/client.js。
|
|
2
|
-
// POST /api/session.delete body { sessionId } ——
|
|
3
|
-
//
|
|
2
|
+
// POST /api/session.delete body { sessionId } —— 将该对话及其子代理后代移入系统回收站
|
|
3
|
+
// 本插件改动用户数据,故仅放行回环来源,非回环请求一律拒绝。
|
|
4
4
|
import { closeSync, existsSync, mkdtempSync, openSync, readFileSync, readdirSync, readSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
5
5
|
import { join, resolve } from "node:path";
|
|
6
6
|
import { homedir, tmpdir } from "node:os";
|
|
@@ -23,11 +23,11 @@ function json(res, status, value, extraHeaders) {
|
|
|
23
23
|
}
|
|
24
24
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
25
25
|
|
|
26
|
-
//
|
|
27
|
-
//
|
|
26
|
+
// 失败一律返回「错误码 + 参数」,由浏览器半按界面语言取词:服务端不产出面向界面的自然语言,
|
|
27
|
+
// 否则英文界面下将显示中文。params 的值可为字符串,也可为另一个失败对象,供嵌套原因递归展开。
|
|
28
28
|
const fail = (code, params, extra) => ({ ok: false, code, params: params || {}, ...(extra || {}) });
|
|
29
29
|
|
|
30
|
-
//
|
|
30
|
+
// 自注册路由位于官方 /api 信任栅栏之外,须自行补充判据。仅放行回环来源:官方第二道浏览器 cookie 鉴权依赖插件无法取得的签名密钥。0 = 放行;403 = 拒绝。
|
|
31
31
|
function isLoopbackHostname(hostname) {
|
|
32
32
|
if (hostname === "localhost" || hostname === "[::1]") return true;
|
|
33
33
|
const parts = String(hostname).split(".");
|
|
@@ -74,7 +74,7 @@ function isInside(root, target) {
|
|
|
74
74
|
const t = resolve(target).toLowerCase();
|
|
75
75
|
return t.startsWith(r + "\\") || t.startsWith(r + "/");
|
|
76
76
|
}
|
|
77
|
-
//
|
|
77
|
+
// 读磁盘的函数一律在读失败时返回 ok:false。将「读不出来」当作「磁盘上没有」会误判活动会话。
|
|
78
78
|
function findSessionDirs(sessionId) {
|
|
79
79
|
const root = sessionsRoot();
|
|
80
80
|
const variants = idVariants(sessionId);
|
|
@@ -98,7 +98,7 @@ function findSessionDirs(sessionId) {
|
|
|
98
98
|
}
|
|
99
99
|
|
|
100
100
|
const ZSTD_MAGIC = Buffer.from([0x28, 0xb5, 0x2f, 0xfd]);
|
|
101
|
-
//
|
|
101
|
+
// 复用同一块头缓冲:readSessionHeader 为同步函数,不会重入;数百个会话逐个分配将浪费上百 MB。
|
|
102
102
|
const HEAD_BUF = Buffer.allocUnsafe(HEAD_BYTES);
|
|
103
103
|
|
|
104
104
|
function sessionLogFiles(dir) {
|
|
@@ -199,7 +199,7 @@ function collectSessionTree(ctx, sessionId) {
|
|
|
199
199
|
const childrenOf = new Map();
|
|
200
200
|
for (const entry of byKey.values()) {
|
|
201
201
|
if (protectedKeys.has(entry.id)) continue;
|
|
202
|
-
//
|
|
202
|
+
// 仅收 origin === "subagent":由 fork 产生的分叉会话 header 中同样有 parentSession,但其属独立对话,不得连带删除。
|
|
203
203
|
if (entry.header?.origin !== "subagent") continue;
|
|
204
204
|
const parent = entry.header?.parentSession;
|
|
205
205
|
if (typeof parent !== "string" || parent.length === 0) continue;
|
|
@@ -232,8 +232,8 @@ function collectSessionTree(ctx, sessionId) {
|
|
|
232
232
|
return { ok: true, dirs };
|
|
233
233
|
}
|
|
234
234
|
|
|
235
|
-
//
|
|
236
|
-
//
|
|
235
|
+
// 移入回收站依赖系统脚本宿主 cscript:node 无回收站接口,PowerShell 每次启动开销过大。
|
|
236
|
+
// 结果写入文件而非 stdout——受限环境中无法取得管道输出,写入文件不受此限制。
|
|
237
237
|
function cscriptPath() {
|
|
238
238
|
const root = process.env.SystemRoot || "C:\\Windows";
|
|
239
239
|
for (const p of [join(root, "System32", "cscript.exe"), join(root, "SysWOW64", "cscript.exe")]) {
|
|
@@ -241,10 +241,9 @@ function cscriptPath() {
|
|
|
241
241
|
}
|
|
242
242
|
return "";
|
|
243
243
|
}
|
|
244
|
-
//
|
|
245
|
-
//
|
|
246
|
-
//
|
|
247
|
-
// 移完后按「原位置 + 名字」回回收站里点名核对,确认真的进去了,而不是被静默永久删掉。
|
|
244
|
+
// 回收站被关闭(NukeOnDelete=1)时 MoveHere 将直接永久删除,与「可还原」的承诺相反,故先行拒绝。
|
|
245
|
+
// 该开关按卷存储(回收站属性按驱动器逐个勾选),根键下通常没有此值,
|
|
246
|
+
// 故须先由盘符查出卷 GUID,再读该卷的值;仅读根键将使此闸门失效。
|
|
248
247
|
function recycleScript(paths, resultFile) {
|
|
249
248
|
return [
|
|
250
249
|
"var paths = " + JSON.stringify(paths) + ";",
|
|
@@ -253,7 +252,7 @@ function recycleScript(paths, resultFile) {
|
|
|
253
252
|
'var wsh = new ActiveXObject("WScript.Shell");',
|
|
254
253
|
"function write(line) { var o = fso.CreateTextFile(resultFile, true, false); o.WriteLine(line); o.Close(); }",
|
|
255
254
|
"function readNuke(key) { try { return wsh.RegRead(key); } catch (e) { return 0; } }",
|
|
256
|
-
//
|
|
255
|
+
// 反斜杠以 fromCharCode 拼接,避免多层转义出错。
|
|
257
256
|
"var BS = String.fromCharCode(92);",
|
|
258
257
|
'var ROOT_KEY = ["HKCU","Software","Microsoft","Windows","CurrentVersion","Explorer","BitBucket"].join(BS);',
|
|
259
258
|
// 盘符 → 卷 GUID:Win32_Volume 的 DeviceID 形如 \\?\Volume{GUID}\
|
|
@@ -272,7 +271,7 @@ function recycleScript(paths, resultFile) {
|
|
|
272
271
|
" } catch (err) {}",
|
|
273
272
|
" return \"\";",
|
|
274
273
|
"}",
|
|
275
|
-
// 卷级值读不到时退回根键值(旧版 Windows
|
|
274
|
+
// 卷级值读不到时退回根键值(旧版 Windows 将该开关置于根键)。
|
|
276
275
|
"function isNukeOn(p) {",
|
|
277
276
|
" var volKey = volumeKeyOf(p);",
|
|
278
277
|
" if (volKey !== \"\") {",
|
|
@@ -285,10 +284,8 @@ function recycleScript(paths, resultFile) {
|
|
|
285
284
|
"}",
|
|
286
285
|
'var sh = new ActiveXObject("Shell.Application");',
|
|
287
286
|
"var bin = sh.NameSpace(10);",
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
// 摘内存后落盘句柄的关闭是异步的,一次 MoveHere 可能撞上「目录仍被占用」而失败,
|
|
291
|
-
// 故在截止时间内反复重试,只对仍存在的路径再搬一次。
|
|
287
|
+
// 摘内存后落盘句柄的关闭是异步的,一次 MoveHere 可能因「目录仍被占用」而失败,
|
|
288
|
+
// 故在截止时间内反复重试,仅对仍存在的路径再次搬移。
|
|
292
289
|
"var deadline = (new Date()).getTime() + " + RECYCLE_TIMEOUT_MS + ";",
|
|
293
290
|
"var left = paths.length;",
|
|
294
291
|
"var lastErr = \"\";",
|
|
@@ -303,53 +300,143 @@ function recycleScript(paths, resultFile) {
|
|
|
303
300
|
" if ((new Date()).getTime() > deadline) break;",
|
|
304
301
|
" WScript.Sleep(200);",
|
|
305
302
|
"}",
|
|
306
|
-
|
|
307
|
-
"var matched = 0;",
|
|
308
|
-
"var e = new Enumerator(bin.Items());",
|
|
309
|
-
"for (; !e.atEnd(); e.moveNext()) {",
|
|
310
|
-
" try {",
|
|
311
|
-
" var it = e.item();",
|
|
312
|
-
" var nm = String(it.Name);",
|
|
313
|
-
" for (var k = 0; k < paths.length; k++) {",
|
|
314
|
-
" if (nm !== baseName(paths[k])) continue;",
|
|
315
|
-
' if (String(bin.GetDetailsOf(it, 1)).toLowerCase() === dirName(paths[k])) { matched++; break; }',
|
|
316
|
-
" }",
|
|
317
|
-
" } catch (err2) {}",
|
|
318
|
-
"}",
|
|
319
|
-
'write("RS|" + left + "|" + matched + "|" + lastErr.replace(/[\\r\\n|]/g, " "));',
|
|
303
|
+
'write("RS|" + left + "|" + lastErr.replace(/[\\r\\n|]/g, " "));',
|
|
320
304
|
].join("\r\n");
|
|
321
305
|
}
|
|
306
|
+
function recycleIndexSnapshot() {
|
|
307
|
+
const seen = new Set();
|
|
308
|
+
for (let code = 65; code <= 90; code++) {
|
|
309
|
+
const root = String.fromCharCode(code) + ":\\$Recycle.Bin";
|
|
310
|
+
let sids;
|
|
311
|
+
try { if (!statSync(root).isDirectory()) continue; sids = readdirSync(root, { withFileTypes: true }); } catch { continue; }
|
|
312
|
+
for (const sid of sids) {
|
|
313
|
+
if (!sid.isDirectory()) continue;
|
|
314
|
+
let names;
|
|
315
|
+
try { names = readdirSync(join(root, sid.name)); } catch { continue; }
|
|
316
|
+
for (const name of names) if (name.startsWith("$I")) seen.add(join(root, sid.name, name));
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
return seen;
|
|
320
|
+
}
|
|
321
|
+
// $I 为回收站索引文件,版本 2(Win10 起)布局:0-7 版本、8-15 原大小、16-23 删除时间、
|
|
322
|
+
// 24-27 路径字符数(含结尾 NUL)、28 起为 UTF-16LE 原始路径。
|
|
323
|
+
// 不可用 indexOf(0) 查找结尾:UTF-16LE 中 ASCII 字符为「XX 00」,将在每个字符的高字节处停止。
|
|
324
|
+
function recycleOriginalPath(file) {
|
|
325
|
+
let buf;
|
|
326
|
+
try { buf = readFileSync(file); } catch { return ""; }
|
|
327
|
+
if (buf.length < 28) return "";
|
|
328
|
+
if (buf.readUInt32LE(0) === 1) {
|
|
329
|
+
const end = buf.indexOf(0, 0);
|
|
330
|
+
return buf.subarray(0, end < 0 ? buf.length : end).toString("latin1");
|
|
331
|
+
}
|
|
332
|
+
const start = 28;
|
|
333
|
+
const chars = buf.readUInt32LE(24);
|
|
334
|
+
if (chars > 1 && start + (chars - 1) * 2 <= buf.length) {
|
|
335
|
+
return buf.subarray(start, start + (chars - 1) * 2).toString("utf16le");
|
|
336
|
+
}
|
|
337
|
+
// 长度字段不可信时,退回按 2 字节步进查找结尾 NUL。
|
|
338
|
+
for (let i = start; i + 1 < buf.length; i += 2) {
|
|
339
|
+
if (buf[i] === 0 && buf[i + 1] === 0) return buf.subarray(start, i).toString("utf16le");
|
|
340
|
+
}
|
|
341
|
+
return buf.subarray(start).toString("utf16le");
|
|
342
|
+
}
|
|
343
|
+
function parentDir(p) {
|
|
344
|
+
const parts = String(p).split(/[\\/]/).filter(Boolean);
|
|
345
|
+
parts.pop();
|
|
346
|
+
return parts.join("\\").toLowerCase();
|
|
347
|
+
}
|
|
348
|
+
// 核对是否确实进入回收站:仅认本次新增的 $I 项,按「原位置 + 名字」逐条比对。
|
|
349
|
+
// 采用差集而非全站按名字搜索:同名同路径的旧项可能早已存在于回收站中,将被误判为本次搬移成功。
|
|
350
|
+
function verifyRecycled(before, dirs) {
|
|
351
|
+
const want = dirs.map((d) => ({ name: dirName(d).toLowerCase(), parent: parentDir(d) }));
|
|
352
|
+
// 按目标目录去重计数:同一目录在窗口内出现两条新增 $I 时仅计一次,否则将顶替另一个未搬移成功的目录。
|
|
353
|
+
const hit = new Set();
|
|
354
|
+
let added = 0;
|
|
355
|
+
for (const file of recycleIndexSnapshot()) {
|
|
356
|
+
if (before.has(file)) continue;
|
|
357
|
+
added++;
|
|
358
|
+
const orig = recycleOriginalPath(file);
|
|
359
|
+
if (orig === "") continue;
|
|
360
|
+
const name = dirName(orig).toLowerCase();
|
|
361
|
+
const parent = parentDir(orig);
|
|
362
|
+
for (let i = 0; i < want.length; i++) {
|
|
363
|
+
if (!hit.has(i) && want[i].name === name && want[i].parent === parent) { hit.add(i); break; }
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
return { matched: hit.size, added };
|
|
367
|
+
}
|
|
368
|
+
// 判定整批确已进入回收站须同时满足两条:索引中新增的项命中每一个目标,且原路径已从磁盘消失。
|
|
369
|
+
function recycledAll(before, dirs) {
|
|
370
|
+
return verifyRecycled(before, dirs).matched === dirs.length && !dirs.some((d) => existsSync(d));
|
|
371
|
+
}
|
|
372
|
+
// cscript 无回应不等于搬移失败:先复核索引,全部命中即按成功收尾,否则将误报失败并留下残留记录。
|
|
373
|
+
function salvageMoved(before, dirs) {
|
|
374
|
+
return recycledAll(before, dirs) ? { ok: true, moved: dirs.length, salvaged: true } : null;
|
|
375
|
+
}
|
|
376
|
+
// 回收站按卷生效:网络共享与 FAT/exFAT 盘上 MoveHere 为永久删除,与「可还原」的承诺相反,故先行拒绝。
|
|
377
|
+
function volumeRootOf(dir) {
|
|
378
|
+
const m = /^([A-Za-z]):[\\/]/.exec(resolve(dir));
|
|
379
|
+
return m ? m[1].toUpperCase() + ":\\" : "";
|
|
380
|
+
}
|
|
381
|
+
function recycleBinUsable(dir) {
|
|
382
|
+
const root = volumeRootOf(dir);
|
|
383
|
+
if (root === "") return false;
|
|
384
|
+
try { return statSync(join(root, "$Recycle.Bin")).isDirectory(); } catch { return false; }
|
|
385
|
+
}
|
|
322
386
|
async function recycleBinMove(dirs) {
|
|
323
387
|
if (dirs.length === 0) return { ok: true, moved: 0 };
|
|
324
388
|
const exe = cscriptPath();
|
|
325
389
|
if (exe === "") return fail("E_CSCRIPT_MISSING");
|
|
326
390
|
const root = sessionsRoot();
|
|
327
391
|
if (dirs.some((d) => !isInside(root, d))) return fail("E_DIR_OUTSIDE_ROOT");
|
|
392
|
+
for (const d of dirs) {
|
|
393
|
+
if (!recycleBinUsable(d)) return fail("E_NO_RECYCLE_BIN", { volume: volumeRootOf(d) || d });
|
|
394
|
+
}
|
|
328
395
|
const work = mkdtempSync(join(tmpdir(), "dsh-session-remover-"));
|
|
329
396
|
const script = join(work, "recycle.js");
|
|
330
397
|
const resultFile = join(work, "result.txt");
|
|
331
398
|
try {
|
|
332
|
-
|
|
399
|
+
const before = recycleIndexSnapshot();
|
|
400
|
+
// cscript 按 BOM 判定编码,UTF-16LE 方能识别中文路径。
|
|
333
401
|
writeFileSync(script, "\uFEFF" + recycleScript(dirs, resultFile), "utf16le");
|
|
334
402
|
const r = spawnSync(exe, ["//nologo", "//E:JScript", script], {
|
|
335
403
|
stdio: "ignore", timeout: RECYCLE_TIMEOUT_MS + 30000, windowsHide: true,
|
|
336
404
|
});
|
|
337
|
-
if (r.error)
|
|
405
|
+
if (r.error) {
|
|
406
|
+
const salvaged = salvageMoved(before, dirs);
|
|
407
|
+
if (salvaged) return salvaged;
|
|
408
|
+
return fail("E_RECYCLE_SPAWN_FAILED", { reason: r.error.message });
|
|
409
|
+
}
|
|
338
410
|
let text = "";
|
|
339
|
-
try { text = readFileSync(resultFile, "utf8"); } catch {
|
|
411
|
+
try { text = readFileSync(resultFile, "utf8"); } catch {
|
|
412
|
+
const salvaged = salvageMoved(before, dirs);
|
|
413
|
+
if (salvaged) return salvaged;
|
|
414
|
+
return fail("E_RECYCLE_NO_RESULT");
|
|
415
|
+
}
|
|
340
416
|
if (text.startsWith("NUKE")) {
|
|
341
417
|
return fail("E_RECYCLE_DISABLED");
|
|
342
418
|
}
|
|
343
|
-
const m = text.match(/RS\|(\d+)
|
|
344
|
-
if (!m)
|
|
419
|
+
const m = text.match(/RS\|(\d+)\|?([^\r\n]*)/);
|
|
420
|
+
if (!m) {
|
|
421
|
+
// 结果文件存在但内容不完整(写入中途进程被终止):同样先复核再判失败。
|
|
422
|
+
const salvaged = salvageMoved(before, dirs);
|
|
423
|
+
if (salvaged) return salvaged;
|
|
424
|
+
return fail("E_RECYCLE_UNRECOGNIZED");
|
|
425
|
+
}
|
|
345
426
|
const left = Number(m[1]);
|
|
346
|
-
const
|
|
347
|
-
|
|
348
|
-
// 部分成功是可能发生的:搬移是逐项的,失败的项数要如实报出来,不能一律说「未删除任何记录」。
|
|
427
|
+
const lastErr = (m[2] || "").trim();
|
|
428
|
+
// 部分成功是可能发生的:搬移逐项进行,失败项数须如实报出。
|
|
349
429
|
if (left > 0) {
|
|
350
|
-
|
|
430
|
+
// 脚本按「原路径是否仍存在」统计 left,句柄释放存在延迟时会误判为仍存在,故再用索引复核一次。
|
|
431
|
+
const { matched } = verifyRecycled(before, dirs);
|
|
432
|
+
if (matched === dirs.length && !dirs.some((d) => existsSync(d))) {
|
|
433
|
+
return { ok: true, moved: dirs.length, salvaged: true };
|
|
434
|
+
}
|
|
435
|
+
const moved = Math.min(Math.max(dirs.length - left, matched), dirs.length - 1);
|
|
436
|
+
const params = { left: dirs.length - moved, total: dirs.length, moved };
|
|
351
437
|
return fail(lastErr === "" ? "E_RECYCLE_LEFT" : "E_RECYCLE_LEFT_REASON", lastErr === "" ? params : { ...params, reason: lastErr });
|
|
352
438
|
}
|
|
439
|
+
const { matched } = verifyRecycled(before, dirs);
|
|
353
440
|
if (matched < dirs.length) {
|
|
354
441
|
return fail("E_RECYCLE_MISMATCH", { matched, total: dirs.length });
|
|
355
442
|
}
|
|
@@ -368,7 +455,7 @@ async function stopAgent(ctx, sessionId) {
|
|
|
368
455
|
stopped = true;
|
|
369
456
|
try { agent.cancel?.({ kind: "disposed" }); } catch {}
|
|
370
457
|
try {
|
|
371
|
-
// Agent
|
|
458
|
+
// Agent 仅有 cancel/whenIdle,没有可自行调用的 scope;等待其静默即可,超时则结束等待。
|
|
372
459
|
if (typeof agent.whenIdle === "function") await Promise.race([agent.whenIdle(), sleep(AGENT_QUIESCE_MS)]);
|
|
373
460
|
else await sleep(200);
|
|
374
461
|
} catch {}
|
|
@@ -400,7 +487,7 @@ async function detachLive(ctx, sessionId) {
|
|
|
400
487
|
const store = sessions.store;
|
|
401
488
|
const entry = store && typeof store.get === "function" ? store.get(v) : undefined;
|
|
402
489
|
if (entry === undefined) continue;
|
|
403
|
-
// 首选官方 enter() 交出的 detach
|
|
490
|
+
// 首选官方 enter() 交出的 detach 闭包:它是唯一会连带摘除内部附件的途径。
|
|
404
491
|
if (typeof entry.detach === "function") { entry.detach(); detached = true; }
|
|
405
492
|
else if (typeof sessions.detachEntered === "function") { sessions.detachEntered(entry); detached = true; }
|
|
406
493
|
else if (typeof store.delete === "function") { store.delete(v); detached = true; }
|
|
@@ -449,7 +536,7 @@ async function removeFromArchiveSet(ctx, sessionId) {
|
|
|
449
536
|
const registry = ctx.get("workspaceRegistry");
|
|
450
537
|
if (!registry) return false;
|
|
451
538
|
const variants = idVariants(sessionId);
|
|
452
|
-
//
|
|
539
|
+
// 公开接口优先:不在归档集时静默返回。
|
|
453
540
|
if (typeof registry.unarchiveSession === "function") {
|
|
454
541
|
let removed = false;
|
|
455
542
|
try {
|
|
@@ -479,8 +566,8 @@ async function removeFromArchiveSet(ctx, sessionId) {
|
|
|
479
566
|
return removed;
|
|
480
567
|
}
|
|
481
568
|
|
|
482
|
-
//
|
|
483
|
-
// →
|
|
569
|
+
// 三段式:先摘内存(运行中的会话占用文件句柄,不摘除则无法移动)→ 再将整棵树一次性移入回收站
|
|
570
|
+
// → 最后清理记账。磁盘这一步失败即整体中止,后续记录一律不动,供用户重试。
|
|
484
571
|
async function deleteSessionCore(ctx, sessionId) {
|
|
485
572
|
const tree = collectSessionTree(ctx, sessionId);
|
|
486
573
|
const treeFailed = tree.ok ? null : tree;
|
|
@@ -539,7 +626,7 @@ function auditLeftover(ctx, sessionId, dirs) {
|
|
|
539
626
|
if (dirs.some((d) => existsSync(d))) leftover.push({ code: "L_SESSION_DIR" });
|
|
540
627
|
const sd = ctx.get("storageDomain");
|
|
541
628
|
if (sd && typeof sd.get === "function") {
|
|
542
|
-
//
|
|
629
|
+
// 仅审计 rc.2 中真实存在的存储域:message_feedback 已改为以会话日志为权威,不再是存储域。
|
|
543
630
|
for (const [domainName, label] of [["session_projcache", "L_PROJ_CACHE"]]) {
|
|
544
631
|
try {
|
|
545
632
|
const domain = sd.get(domainName);
|
|
@@ -581,7 +668,7 @@ function apply(ctx) {
|
|
|
581
668
|
try {
|
|
582
669
|
for await (const chunk of req) {
|
|
583
670
|
raw += chunk;
|
|
584
|
-
//
|
|
671
|
+
// 超限与「读失败」性质不同:前者为客户端请求过大,返回 413 便于理解。
|
|
585
672
|
if (raw.length > BODY_LIMIT) return json(res, 413, fail("E_BODY_TOO_LARGE"));
|
|
586
673
|
}
|
|
587
674
|
} catch {
|