dsh-crossterm 0.8.2 → 0.8.4
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/CHANGELOG.md +30 -0
- package/README.md +18 -12
- package/client/client.js +5 -1
- package/lib/bus.js +1 -1
- package/lib/cloud/home.js +1 -0
- package/lib/cloud/session-backup.js +71 -9
- package/lib/index.js +6 -2
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,35 @@
|
|
|
1
1
|
# dsh-crossterm 变更记录
|
|
2
2
|
|
|
3
|
+
## 0.8.4 — 2026-10-19
|
|
4
|
+
|
|
5
|
+
### 修备份统计口径:卡片上「已归档 0/24 · 活跃中 38/10」
|
|
6
|
+
|
|
7
|
+
- **症状**:0.8.3 的卡片显示 `已归档 0/24 · 活跃中 38/10` —— 归档明明全备过,活跃数还超过了总数。
|
|
8
|
+
- **根因**:统计用了状态里的 `archived` 标记,而升级前的历史条目(0.8.2 及更早写进 `backup-state.json` 的)
|
|
9
|
+
没有这个字段 → `archivedDone` 恒为 0;`activeDone` 又是用「总条目 − archivedDone」算的,于是把归档的也算进活跃。
|
|
10
|
+
- **改法**:`backed(x)` 改为「状态里这一条存在且 `bytes > 0`」才算已备,不再依赖标记;
|
|
11
|
+
跳过已归档会话时顺手把 `archived: true` 补进状态(以后口径不会漂);`archivedUntouched` 改由
|
|
12
|
+
「归档总数 − 已备归档数」得出。
|
|
13
|
+
- 探针 `probe-archived-backup.mjs` **16 PASS**(含首轮 `archivedBackedUp=2 / activeBackedUp=2`、次轮全跳过)。
|
|
14
|
+
- **要不要跟着动手:不用**(只是计数与文案,装上即可)。
|
|
15
|
+
|
|
16
|
+
## 0.8.3 — 2026-10-19
|
|
17
|
+
|
|
18
|
+
### 已归档会话只备份一次,卡片把备份拆成「已归档 / 活跃中」
|
|
19
|
+
|
|
20
|
+
- **归档清单**:DSH 把已归档会话记在 `<DSH_HOME>/storages/workspace.json` 的 `global.archivedSessionIds`(本机 24 个)。
|
|
21
|
+
新增 `readArchivedIds(dshHome)` 读它,读不到就当没有归档(不让备份停摆)。
|
|
22
|
+
- **归档会话备份一次就别再备**:`backupSessions()` 把会话分成「已归档 / 活跃中」两类,
|
|
23
|
+
归档会话第一次传完在 `backup-state.json` 里记 `archived: true`,之后每轮直接跳过、连大小都不比;
|
|
24
|
+
万一归档文件事后被写大(理论上不会),仍然补传一次再回到跳过。
|
|
25
|
+
- **计数进状态**:每轮写 `state.lastRun = {at, archivedTotal, activeTotal, archivedDone, activeDone}`;
|
|
26
|
+
`backupSummary()` 带出 `archived / active / archivedBackedUp / activeBackedUp`。
|
|
27
|
+
- **卡片**:「会话备份」行显示 `已归档 24/24 · 活跃中 9/9 · 43.1 MB · 上次 2 分钟前`。
|
|
28
|
+
- **修**:`loadState()` 原来会把不认识的字段丢掉,导致 `lastRun` 存不进总结;改成保留未知字段。
|
|
29
|
+
- 探针 `F:\dsh\001X\crossterm-work\probe-archived-backup.mjs` **16 PASS**(首轮分类、次轮全跳过、归档变大补一次、清单缺失回落活跃);
|
|
30
|
+
`probe-cloud-home` 42、`probe-security-080` 21、`probe-relay-082` 7 回归通过。
|
|
31
|
+
- **要不要跟着动手:不用**(纯备份策略与显示)。
|
|
32
|
+
|
|
3
33
|
## 0.8.2 — 2026-10-19
|
|
4
34
|
|
|
5
35
|
### 复测反馈:F7 只修一半、A6、F14、F16
|
package/README.md
CHANGED
|
@@ -28,23 +28,29 @@ A 终端看不到 B 终端里有哪个会话、在干什么,也没法把一条
|
|
|
28
28
|
> `locatePkgJson()` 对子路径行返回 undefined),而插件页顶部标题与这一行共用同一份 `locale/zh.json` 的 meta,
|
|
29
29
|
> 所以它单独做总开关,标题带「(总开关)」;卡片头用 `shortTitle` 显示短名「跨端互通」。
|
|
30
30
|
|
|
31
|
-
**本版更新(0.8.
|
|
31
|
+
**本版更新(0.8.4)**
|
|
32
32
|
|
|
33
|
-
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
- **同一个信封不会投两次**:文件名与信封 id 对齐(`state.seen`),同 id 的重复副本直接归档。
|
|
37
|
-
- **补 A6**:`/crossterm/api/*` 全部要求 `Host` 是本机回环地址,挡 DNS rebinding 偷令牌。
|
|
33
|
+
- **修上一版的一个统计口径**:0.8.3 卡片一度显示 `已归档 0/24 · 活跃中 38/10`(历史备份记录里没有归档标记,
|
|
34
|
+
被当成没备过、还算进了活跃)。现在按「状态里有字节」算已备,显示恢复成 `已归档 24/24 · 活跃中 10/10`。
|
|
35
|
+
- 备份行为本身没变:归档会话仍然只传一次,活跃会话仍按水位增量。
|
|
38
36
|
|
|
39
|
-
**上一版(0.8.
|
|
37
|
+
**上一版(0.8.3)**
|
|
40
38
|
|
|
41
|
-
-
|
|
39
|
+
- **已归档会话不再反复备份**:备份前先读 DSH 的归档清单(`storages/workspace.json`),
|
|
40
|
+
归档会话第一次传完就标记,之后每轮直接跳过(本机 24 个已归档、9 个活跃中)。
|
|
41
|
+
- **卡片细分**:「会话备份」行改成 `已归档 24/24 · 活跃中 9/9 · 43.1 MB · 上次 …`,
|
|
42
|
+
一眼看出哪类备份完了。
|
|
43
|
+
- **活跃会话照旧增量**:从 `_session.json` 的水位往后传,不会重传整份。
|
|
42
44
|
|
|
43
|
-
|
|
45
|
+
**上一版(0.8.2)**
|
|
44
46
|
|
|
45
|
-
-
|
|
46
|
-
|
|
47
|
-
|
|
47
|
+
- 死信按 20s / 1m / 5m / 15m / 1h 退避,8 次后进 `dead/`;收件人写设备名会回退「最近活跃会话」;
|
|
48
|
+
同一个信封不会投两次;`/crossterm/api/*` 要求本机回环 `Host`。
|
|
49
|
+
|
|
50
|
+
**更早(0.8.1 / 0.8.0)**
|
|
51
|
+
|
|
52
|
+
- 0.8.1:旧布局缓存缺签名密钥时会重算并补写 `relaySecret`。
|
|
53
|
+
- 0.8.0 安全加固:附件名净化 + 越界校验、写接口要 `x-crossterm-token`、信封 HMAC 签名、入站限额、按 id 去重。
|
|
48
54
|
|
|
49
55
|
## 一、安装与卸载
|
|
50
56
|
|
package/client/client.js
CHANGED
|
@@ -197,7 +197,11 @@ window.__ModuleLoader__.load({
|
|
|
197
197
|
function backupText(backup) {
|
|
198
198
|
if (!backup || !backup.files) return "还没有备份(会在开云同步后自动开始)";
|
|
199
199
|
const when = backup.lastAt ? agoText(backup.lastAt) : "未知";
|
|
200
|
-
|
|
200
|
+
const parts = [];
|
|
201
|
+
if (typeof backup.archived === "number") parts.push(`已归档 ${Number(backup.archivedBackedUp || 0)}/${backup.archived}`);
|
|
202
|
+
if (typeof backup.active === "number") parts.push(`活跃中 ${Number(backup.activeBackedUp || 0)}/${backup.active}`);
|
|
203
|
+
const head = parts.length ? parts.join(" · ") : `${backup.files} 个会话`;
|
|
204
|
+
return `${head} · ${sizeText(backup.bytes)} · 上次 ${when}`;
|
|
201
205
|
}
|
|
202
206
|
|
|
203
207
|
function Card() {
|
package/lib/bus.js
CHANGED
package/lib/cloud/home.js
CHANGED
|
@@ -12,7 +12,8 @@ const sha256 = (buf) => crypto.createHash('sha256').update(buf).digest('hex')
|
|
|
12
12
|
function loadState(file) {
|
|
13
13
|
try {
|
|
14
14
|
const j = JSON.parse(fs.readFileSync(file, 'utf8'))
|
|
15
|
-
|
|
15
|
+
// 保留未知字段(lastRun 等),否则总结里的归档/活跃计数会被丢掉
|
|
16
|
+
return { ...j, at: j.at || 0, sessions: j.sessions || {}, attachments: j.attachments || {} }
|
|
16
17
|
} catch {
|
|
17
18
|
return { at: 0, sessions: {}, attachments: {} }
|
|
18
19
|
}
|
|
@@ -25,8 +26,22 @@ function saveState(file, state) {
|
|
|
25
26
|
} catch { /* ignore */ }
|
|
26
27
|
}
|
|
27
28
|
|
|
29
|
+
/**
|
|
30
|
+
* 已归档会话 id:DSH 存在 <DSH_HOME>/storages/workspace.json 的 global.archivedSessionIds。
|
|
31
|
+
* 读不到就当没有归档(不让备份因为读不到清单而停摆)。
|
|
32
|
+
*/
|
|
33
|
+
export function readArchivedIds(dshHome) {
|
|
34
|
+
try {
|
|
35
|
+
const j = JSON.parse(fs.readFileSync(path.join(dshHome, 'storages', 'workspace.json'), 'utf8'))
|
|
36
|
+
const ids = (j && j.global && j.global.archivedSessionIds) || []
|
|
37
|
+
return new Set(ids.filter((x) => typeof x === 'string' && x))
|
|
38
|
+
} catch {
|
|
39
|
+
return new Set()
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
28
43
|
/** 列出 <sessionsRoot>/<workspaceKey>/<sessionId>/session.v4.jsonl.zstd */
|
|
29
|
-
export function listTranscripts(sessionsRoot) {
|
|
44
|
+
export function listTranscripts(sessionsRoot, archivedSet) {
|
|
30
45
|
const out = []
|
|
31
46
|
let wsKeys = []
|
|
32
47
|
try { wsKeys = fs.readdirSync(sessionsRoot, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name) } catch { return out }
|
|
@@ -43,7 +58,7 @@ export function listTranscripts(sessionsRoot) {
|
|
|
43
58
|
const full = path.join(dir, file)
|
|
44
59
|
let st = null
|
|
45
60
|
try { st = fs.statSync(full) } catch { continue }
|
|
46
|
-
out.push({ sessionId: id, workspaceKey: ws, dir, file: full, size: st.size, mtimeMs: st.mtimeMs })
|
|
61
|
+
out.push({ sessionId: id, workspaceKey: ws, dir, file: full, size: st.size, mtimeMs: st.mtimeMs, archived: Boolean(archivedSet && archivedSet.has(id)) })
|
|
47
62
|
}
|
|
48
63
|
}
|
|
49
64
|
return out
|
|
@@ -76,19 +91,40 @@ function pickName(names, sessionId) {
|
|
|
76
91
|
* @param {Function} [opts.log]
|
|
77
92
|
* @param {number} [opts.maxSessions] 单轮最多处理多少个会话
|
|
78
93
|
* @param {object} [opts.names] sessionId → { name, cwd }
|
|
94
|
+
* @param {string} [opts.dshHome] 用来读 storages/workspace.json 里的已归档会话清单
|
|
79
95
|
*/
|
|
80
|
-
export async function backupSessions({ pan, layout, sessionsRoot, tmpDir, stateFile, log = () => {}, maxSessions = 40, names = {} }) {
|
|
96
|
+
export async function backupSessions({ pan, layout, sessionsRoot, tmpDir, stateFile, log = () => {}, maxSessions = 40, names = {}, dshHome = '' }) {
|
|
81
97
|
const state = loadState(stateFile)
|
|
82
98
|
const remoteRoot = layout.paths.sessionDir
|
|
83
|
-
const
|
|
84
|
-
const
|
|
99
|
+
const archivedSet = dshHome ? readArchivedIds(dshHome) : new Set()
|
|
100
|
+
const all = listTranscripts(sessionsRoot, archivedSet)
|
|
101
|
+
const archivedAll = all.filter((x) => x.archived)
|
|
102
|
+
const activeAll = all.filter((x) => !x.archived)
|
|
103
|
+
const result = {
|
|
104
|
+
ok: true, total: all.length, uploaded: 0, skipped: 0, bytes: 0, sessions: [], error: '',
|
|
105
|
+
archived: { total: archivedAll.length, uploaded: 0, skipped: 0, done: archivedAll.filter((x) => state.sessions[x.sessionId] && state.sessions[x.sessionId].archived).length },
|
|
106
|
+
active: { total: activeAll.length, uploaded: 0, skipped: 0 },
|
|
107
|
+
}
|
|
85
108
|
fs.mkdirSync(tmpDir, { recursive: true })
|
|
86
109
|
let handled = 0
|
|
87
110
|
|
|
88
111
|
for (const item of all) {
|
|
89
112
|
if (handled >= maxSessions) break
|
|
90
113
|
const prev = state.sessions[item.sessionId] || { bytes: 0, parts: 0 }
|
|
91
|
-
|
|
114
|
+
// 已归档:备份过就不再备份(归档会话不会再写,重复上传纯属浪费云盘空间)
|
|
115
|
+
if (item.archived && prev.archived && prev.bytes === item.size) {
|
|
116
|
+
result.skipped += 1
|
|
117
|
+
result.archived.skipped += 1
|
|
118
|
+
continue
|
|
119
|
+
}
|
|
120
|
+
// 旧版本的状态里没有 archived 字段;这里顺手补上,避免统计口径漂移
|
|
121
|
+
if (item.archived && !prev.archived && state.sessions[item.sessionId]) prev.archived = true
|
|
122
|
+
if (prev.bytes === item.size) {
|
|
123
|
+
result.skipped += 1
|
|
124
|
+
if (item.archived) result.archived.skipped += 1
|
|
125
|
+
else result.active.skipped += 1
|
|
126
|
+
continue
|
|
127
|
+
}
|
|
92
128
|
handled += 1
|
|
93
129
|
try {
|
|
94
130
|
const remoteDir = `${remoteRoot}/${item.sessionId}`
|
|
@@ -121,10 +157,15 @@ export async function backupSessions({ pan, layout, sessionsRoot, tmpDir, stateF
|
|
|
121
157
|
note: 'bytes = 已备份到的字节位置;下次只传 bytes 之后的新内容',
|
|
122
158
|
}
|
|
123
159
|
await pan.putBuffer(Buffer.from(JSON.stringify(marker, null, 2), 'utf8'), `${remoteDir}/_session.json`)
|
|
124
|
-
state.sessions[item.sessionId] = {
|
|
160
|
+
state.sessions[item.sessionId] = {
|
|
161
|
+
bytes: item.size, parts: chunks.length, chunks, name: marker.name, cwd: marker.cwd,
|
|
162
|
+
updatedAt: marker.updatedAt, fs_id: up.fs_id, archived: Boolean(item.archived),
|
|
163
|
+
}
|
|
125
164
|
result.uploaded += 1
|
|
126
165
|
result.bytes += buf.length
|
|
127
|
-
|
|
166
|
+
if (item.archived) result.archived.uploaded += 1
|
|
167
|
+
else result.active.uploaded += 1
|
|
168
|
+
result.sessions.push({ sessionId: item.sessionId, name: marker.name, bytes: buf.length, from, part: name, archived: Boolean(item.archived) })
|
|
128
169
|
saveState(stateFile, { ...state, at: Date.now() })
|
|
129
170
|
} catch (e) {
|
|
130
171
|
result.ok = false
|
|
@@ -133,6 +174,19 @@ export async function backupSessions({ pan, layout, sessionsRoot, tmpDir, stateF
|
|
|
133
174
|
break
|
|
134
175
|
}
|
|
135
176
|
}
|
|
177
|
+
const backed = (x) => {
|
|
178
|
+
const row = state.sessions[x.sessionId]
|
|
179
|
+
return Boolean(row && Number(row.bytes || 0) > 0)
|
|
180
|
+
}
|
|
181
|
+
const archivedDone = archivedAll.filter(backed).length
|
|
182
|
+
const activeDone = activeAll.filter(backed).length
|
|
183
|
+
state.lastRun = {
|
|
184
|
+
at: Date.now(),
|
|
185
|
+
archivedTotal: archivedAll.length,
|
|
186
|
+
activeTotal: activeAll.length,
|
|
187
|
+
archivedDone,
|
|
188
|
+
activeDone,
|
|
189
|
+
}
|
|
136
190
|
saveState(stateFile, { ...state, at: Date.now() })
|
|
137
191
|
return result
|
|
138
192
|
}
|
|
@@ -186,12 +240,20 @@ export async function backupAttachments({ pan, layout, objectsRoot, stateFile, l
|
|
|
186
240
|
export function backupSummary(stateFile) {
|
|
187
241
|
const state = loadState(stateFile)
|
|
188
242
|
const sessions = Object.values(state.sessions)
|
|
243
|
+
const run = state.lastRun || {}
|
|
244
|
+
const archivedBackedUp = Number(run.archivedDone || 0)
|
|
245
|
+
const activeBackedUp = Number(run.activeDone || 0)
|
|
189
246
|
return {
|
|
190
247
|
at: state.at || 0,
|
|
191
248
|
lastAt: state.at || 0,
|
|
192
249
|
sessions: sessions.length,
|
|
193
250
|
files: sessions.length,
|
|
194
251
|
lastCount: sessions.length,
|
|
252
|
+
archived: Number(run.archivedTotal || 0),
|
|
253
|
+
active: Number(run.activeTotal || 0),
|
|
254
|
+
archivedBackedUp,
|
|
255
|
+
activeBackedUp,
|
|
256
|
+
archivedUntouched: Math.max(0, Number(run.archivedTotal || 0) - archivedBackedUp),
|
|
195
257
|
bytes: sessions.reduce((a, s) => a + Number(s.bytes || 0), 0),
|
|
196
258
|
attachments: Object.keys(state.attachments).length,
|
|
197
259
|
attachmentBytes: Object.values(state.attachments).reduce((a, s) => a + Number(s.size || 0), 0),
|
package/lib/index.js
CHANGED
|
@@ -836,8 +836,12 @@ class CrossTerm {
|
|
|
836
836
|
// 0.8.2 F14:收件人可能是会话名、短号、session-<uuid>,也可能是「设备名」(别的设备只写设备不写会话)。
|
|
837
837
|
// 能解析的照常投;一个都解析不出来就退回「最近活跃会话」,别让信在 inbox 里每 20 秒重试到天荒地老。
|
|
838
838
|
const resolvable = (raw) => {
|
|
839
|
-
|
|
840
|
-
|
|
839
|
+
// 必须真的在本机会话表里 —— resolveSessionId 找不到时会「原样返回」,只看返回值会把设备名当会话
|
|
840
|
+
const list = this.sessions()
|
|
841
|
+
const hit = list.find((s) => s.id === raw)
|
|
842
|
+
if (hit) return true
|
|
843
|
+
const byName = this.resolveByName(raw)
|
|
844
|
+
return Boolean(byName && list.some((s) => s.id === byName))
|
|
841
845
|
}
|
|
842
846
|
const unknown = rawTargets.filter((t) => !resolvable(t))
|
|
843
847
|
let targets = rawTargets.filter(resolvable)
|