dsh-crossterm 0.3.37 → 0.3.39
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 +42 -0
- package/client/client.js +118 -0
- package/lib/bus.js +67 -6
- package/lib/cloud/devices.js +95 -0
- package/lib/cloud/home.js +152 -0
- package/lib/cloud/layout.js +190 -0
- package/lib/cloud/pan.js +62 -6
- package/lib/cloud/relay.js +196 -0
- package/lib/cloud/session-backup.js +188 -0
- package/lib/cloud/sync.js +5 -1
- package/lib/index.js +136 -2
- package/lib/web.js +105 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -244,6 +244,48 @@ dsh plugin --profile desktop remove dsh-crossterm
|
|
|
244
244
|
|
|
245
245
|
> 插件、npm 包、B 机安装包**都不携带任何凭证**;换机器要各自配自己的。
|
|
246
246
|
|
|
247
|
+
### 看有哪些机器连着(0.3.38)
|
|
248
|
+
|
|
249
|
+
插件页卡片展开后有「云端设备」一行,也可以直接 `GET /crossterm/api/devices`:
|
|
250
|
+
|
|
251
|
+
- 数据来自云端总线的 `presence/*.json`(每台机器 10 秒一次心跳),字段含名字、nodeId、版本、平台、会话数、入链;
|
|
252
|
+
- 最后心跳 3 分钟内的算「在线」,其余显示「N 分钟前」;
|
|
253
|
+
- 本机永远在列表里(标「(本机)」),取自本地 presence,不依赖云端往返;
|
|
254
|
+
- 没配云盘的机器只显示「未连接云盘」,不会假装有别的设备。
|
|
255
|
+
|
|
256
|
+
> 0.3.38 之前这里常常显示「云端还没有其他设备」:云端 `presence/` 里堆了 220 多个 `.tmp`
|
|
257
|
+
> 残骸,而百度网盘列目录一页只给 200 条 —— 真实 json 被挤到了第二页。现在列目录会翻页,
|
|
258
|
+
> 云端也不再上传 `.tmp`。
|
|
259
|
+
|
|
260
|
+
### 云端目录:账号 / 设备 / 共享(0.3.39)
|
|
261
|
+
|
|
262
|
+
打开云同步后,插件会在云盘的大文件夹(默认 `dsh`)下按账号和设备建目录,不用手动摆:
|
|
263
|
+
|
|
264
|
+
```
|
|
265
|
+
dsh/
|
|
266
|
+
<账号>/ ← _account.json:ownerDevice(第一个进云的设备 = 管理员)
|
|
267
|
+
<设备>/ ← _device.json:role(admin/member)、lastSeen、版本
|
|
268
|
+
sessions/<会话>/ part-0001.bin… full-<时间>.bin + _session.json(水位标注)
|
|
269
|
+
attachments/<sha256> 附件按内容寻址,天生去重
|
|
270
|
+
shared/ ← 云端设备共享文件夹:跨设备的信息、文件、邮件都走这里
|
|
271
|
+
<设备>/
|
|
272
|
+
inbox/ 收件箱(别的设备投给你的信)
|
|
273
|
+
done/ 收完归档
|
|
274
|
+
files/<mailId>/ 附件
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
- **账号**:默认取云盘账号名;想改名在 `config.json` 写 `cloudAccount`,或设环境变量 `DSH_ACCOUNT`。
|
|
278
|
+
- **角色**:账号下第一个进云的设备自动是 `admin`(看 `_account.json.ownerDevice`),之后的是 `member`。
|
|
279
|
+
- **收信(云端中转站)**:每 20 秒查一次自己的 `inbox/`,取信 → 存本机
|
|
280
|
+
`<总线目录>/cloud/relay/incoming/` → 按**收件人会话名**投递 → 归档到 `done/` 再从收件箱删。
|
|
281
|
+
- **会话备份**:每 5 分钟一轮,按字节增量(**不解压** transcript):首次整份 `full-*.bin`,
|
|
282
|
+
之后只传新增那段 `part-NNNN.bin`;`_session.json` 里的 `bytes` 就是「备份到哪了」的标注,
|
|
283
|
+
下次从标注往后继续。附件单轮 20 个、会话单轮 20 个,余量下一轮接着传。
|
|
284
|
+
- 卡片三行:**云端账号**(角色 + 设备号)、**云端中转站**(已收 / 失败 / 间隔)、
|
|
285
|
+
**会话备份**(会话数 · 体积 · 上次时间);按钮 **收信**、**备份会话** 可手动催一轮。
|
|
286
|
+
- 命令行等价物:`term_cloud action="home"`(看状态)、`action="layout"`(重建目录)、
|
|
287
|
+
`action="relay"`(收一轮)、`action="backup"`(备份一轮)。
|
|
288
|
+
|
|
247
289
|
### 手动 / 自动注入开关(三层)
|
|
248
290
|
|
|
249
291
|
| 层 | 开关 | 粒度 |
|
package/client/client.js
CHANGED
|
@@ -131,11 +131,51 @@ window.__ModuleLoader__.load({
|
|
|
131
131
|
);
|
|
132
132
|
}
|
|
133
133
|
|
|
134
|
+
function agoText(ts) {
|
|
135
|
+
if (!ts) return "未知";
|
|
136
|
+
const mins = Math.round((Date.now() - ts) / 60000);
|
|
137
|
+
if (mins <= 0) return "刚刚";
|
|
138
|
+
if (mins < 60) return mins + " 分钟前";
|
|
139
|
+
const hours = Math.round(mins / 60);
|
|
140
|
+
if (hours < 24) return hours + " 小时前";
|
|
141
|
+
return Math.round(hours / 24) + " 天前";
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function devicesText(status, devices) {
|
|
145
|
+
if (!(status && status.cloud && status.cloud.configured)) return "未连接云盘";
|
|
146
|
+
if (!devices || devices.loading) return "读取中…";
|
|
147
|
+
if (!devices.ok) return devices.message || "读取失败";
|
|
148
|
+
const list = devices.devices || [];
|
|
149
|
+
if (!list.length) return "云端还没有其他设备";
|
|
150
|
+
return list
|
|
151
|
+
.map((d) => {
|
|
152
|
+
const label = (d.name || d.nodeId || "未知设备") + (d.nodeId === devices.me ? "(本机)" : "");
|
|
153
|
+
const state = d.online ? "在线" : agoText(d.updatedAt);
|
|
154
|
+
return label + " · " + state + (d.version ? " · v" + d.version : "");
|
|
155
|
+
})
|
|
156
|
+
.join(";");
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function sizeText(bytes) {
|
|
160
|
+
const n = Number(bytes || 0);
|
|
161
|
+
if (n < 1024) return n + " B";
|
|
162
|
+
if (n < 1024 * 1024) return (n / 1024).toFixed(1) + " KB";
|
|
163
|
+
if (n < 1024 * 1024 * 1024) return (n / 1024 / 1024).toFixed(1) + " MB";
|
|
164
|
+
return (n / 1024 / 1024 / 1024).toFixed(2) + " GB";
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function backupText(backup) {
|
|
168
|
+
if (!backup || !backup.files) return "还没有备份(会在开云同步后自动开始)";
|
|
169
|
+
const when = backup.lastAt ? agoText(backup.lastAt) : "未知";
|
|
170
|
+
return `${backup.files} 个会话 · ${sizeText(backup.bytes)} · 上次 ${when}`;
|
|
171
|
+
}
|
|
172
|
+
|
|
134
173
|
function Card() {
|
|
135
174
|
const [open, setOpen] = React.useState(false);
|
|
136
175
|
const [status, setStatus] = React.useState(null);
|
|
137
176
|
const [busy, setBusy] = React.useState("");
|
|
138
177
|
const [msg, setMsg] = React.useState("");
|
|
178
|
+
const [devices, setDevices] = React.useState(null);
|
|
139
179
|
|
|
140
180
|
const load = React.useCallback(() => {
|
|
141
181
|
call("/crossterm/api/status")
|
|
@@ -150,6 +190,21 @@ window.__ModuleLoader__.load({
|
|
|
150
190
|
load();
|
|
151
191
|
}, [load]);
|
|
152
192
|
|
|
193
|
+
const loadDevices = React.useCallback(async () => {
|
|
194
|
+
setDevices({ loading: true, devices: [] });
|
|
195
|
+
try {
|
|
196
|
+
const data = await call("/crossterm/api/devices");
|
|
197
|
+
setDevices({ loading: false, ...(data || {}) });
|
|
198
|
+
} catch (error) {
|
|
199
|
+
setDevices({ loading: false, ok: false, devices: [], message: String((error && error.message) || error) });
|
|
200
|
+
}
|
|
201
|
+
}, []);
|
|
202
|
+
|
|
203
|
+
// 展开时自动读一次云端设备(收起不读,避免每次进页面都等网盘)
|
|
204
|
+
React.useEffect(() => {
|
|
205
|
+
if (open && !devices) void loadDevices();
|
|
206
|
+
}, [open, devices, loadDevices]);
|
|
207
|
+
|
|
153
208
|
const onCheck = async () => {
|
|
154
209
|
setBusy("check");
|
|
155
210
|
setMsg("正在检查更新…");
|
|
@@ -229,9 +284,48 @@ window.__ModuleLoader__.load({
|
|
|
229
284
|
}
|
|
230
285
|
};
|
|
231
286
|
|
|
287
|
+
const onRelayCloud = async () => {
|
|
288
|
+
setBusy("relay");
|
|
289
|
+
setMsg("正在检查云端收件箱…");
|
|
290
|
+
try {
|
|
291
|
+
const data = await call("/crossterm/api/home?action=relay");
|
|
292
|
+
const relay = (data && data.relay) || {};
|
|
293
|
+
setMsg(
|
|
294
|
+
data && data.ok
|
|
295
|
+
? `收信完成:拉到 ${relay.fetched || 0} 封,投递 ${relay.delivered || 0} 封${relay.failed ? ",失败 " + relay.failed : ""}`
|
|
296
|
+
: "收信失败:" + ((data && (data.message || relay.error)) || "未知错误"),
|
|
297
|
+
);
|
|
298
|
+
if (data && data.home && status) setStatus({ ...status, home: data.home });
|
|
299
|
+
} catch (error) {
|
|
300
|
+
setMsg("收信失败:" + String((error && error.message) || error));
|
|
301
|
+
} finally {
|
|
302
|
+
setBusy("");
|
|
303
|
+
}
|
|
304
|
+
};
|
|
305
|
+
|
|
306
|
+
const onBackupNow = async () => {
|
|
307
|
+
setBusy("backup");
|
|
308
|
+
setMsg("正在备份会话…");
|
|
309
|
+
try {
|
|
310
|
+
const data = await call("/crossterm/api/backup", "POST");
|
|
311
|
+
const b = (data && data.backup) || {};
|
|
312
|
+
setMsg(
|
|
313
|
+
data && data.ok
|
|
314
|
+
? `备份完成:会话 ${(b.sessions && b.sessions.uploaded) || 0} 个,附件 ${(b.attachments && b.attachments.uploaded) || 0} 个(${Math.round((b.ms || 0) / 1000)} 秒)`
|
|
315
|
+
: "备份失败:" + ((data && data.message) || "未知错误"),
|
|
316
|
+
);
|
|
317
|
+
if (data && data.home && status) setStatus({ ...status, home: data.home });
|
|
318
|
+
} catch (error) {
|
|
319
|
+
setMsg("备份失败:" + String((error && error.message) || error));
|
|
320
|
+
} finally {
|
|
321
|
+
setBusy("");
|
|
322
|
+
}
|
|
323
|
+
};
|
|
324
|
+
|
|
232
325
|
const version = status ? "v" + status.version : "";
|
|
233
326
|
const mode = status && status.install ? status.install.mode : "";
|
|
234
327
|
const cloud = (status && status.cloud) || {};
|
|
328
|
+
const home = (status && status.home) || null;
|
|
235
329
|
const subtitle = status
|
|
236
330
|
? `${status.node || "本机"} · ${status.chains && status.chains.length ? status.chains.join(" / ") : "未入链"}`
|
|
237
331
|
: "加载中…";
|
|
@@ -265,6 +359,12 @@ window.__ModuleLoader__.load({
|
|
|
265
359
|
label: "当前终端",
|
|
266
360
|
value: status ? `${status.node}(${status.nodeId})· ${status.hostname}` : "",
|
|
267
361
|
}),
|
|
362
|
+
h(
|
|
363
|
+
"div",
|
|
364
|
+
{ style: S.grid },
|
|
365
|
+
h("div", { style: S.key }, "云端设备"),
|
|
366
|
+
h("div", { style: S.val }, devicesText(status, devices)),
|
|
367
|
+
),
|
|
268
368
|
h(
|
|
269
369
|
"div",
|
|
270
370
|
{ style: S.grid },
|
|
@@ -313,11 +413,29 @@ window.__ModuleLoader__.load({
|
|
|
313
413
|
: null,
|
|
314
414
|
),
|
|
315
415
|
),
|
|
416
|
+
home && home.layout
|
|
417
|
+
? h(FriendlyRow, {
|
|
418
|
+
label: "云端账号",
|
|
419
|
+
value: `${home.layout.account}(${home.layout.role === "admin" ? "管理员" : "成员"})· 设备 ${home.layout.deviceId}`,
|
|
420
|
+
})
|
|
421
|
+
: null,
|
|
422
|
+
home && home.relay
|
|
423
|
+
? h(FriendlyRow, {
|
|
424
|
+
label: "云端中转站",
|
|
425
|
+
value: `已收 ${home.relay.processed || 0} 封,失败 ${home.relay.failed || 0} 封 · 每 ${Math.round((home.relay.intervalMs || 0) / 1000)} 秒查一次`,
|
|
426
|
+
})
|
|
427
|
+
: null,
|
|
428
|
+
home && home.backup
|
|
429
|
+
? h(FriendlyRow, { label: "会话备份", value: backupText(home.backup) })
|
|
430
|
+
: null,
|
|
316
431
|
h(
|
|
317
432
|
"div",
|
|
318
433
|
{ style: S.actions },
|
|
319
434
|
h("button", { style: S.btn, onClick: onCheck, disabled: busy !== "" }, busy === "check" ? "检查中…" : "检查更新"),
|
|
320
435
|
h("button", { style: S.btnPrimary, onClick: onUpdate, disabled: busy !== "" }, busy === "update" ? "更新中…" : "更新"),
|
|
436
|
+
h("button", { style: S.btn, onClick: () => void loadDevices(), disabled: Boolean(devices && devices.loading) }, devices && devices.loading ? "读取中…" : "刷新设备"),
|
|
437
|
+
h("button", { style: S.btn, onClick: onRelayCloud, disabled: busy !== "" }, busy === "relay" ? "收信中…" : "收信"),
|
|
438
|
+
h("button", { style: S.btn, onClick: onBackupNow, disabled: busy !== "" }, busy === "backup" ? "备份中…" : "备份会话"),
|
|
321
439
|
),
|
|
322
440
|
msg ? h("div", { style: S.msg }, msg) : null,
|
|
323
441
|
),
|
package/lib/bus.js
CHANGED
|
@@ -22,7 +22,7 @@ import os from 'node:os'
|
|
|
22
22
|
import path from 'node:path'
|
|
23
23
|
import crypto from 'node:crypto'
|
|
24
24
|
|
|
25
|
-
export const VERSION = '0.3.
|
|
25
|
+
export const VERSION = '0.3.39'
|
|
26
26
|
|
|
27
27
|
/** DSH 的家目录:与宿主其它状态(sessions / profiles)同一个根,多终端天然共享 */
|
|
28
28
|
export function dshHome() {
|
|
@@ -63,6 +63,14 @@ const DEFAULTS = {
|
|
|
63
63
|
cloudEnabled: false,
|
|
64
64
|
cloudRoot: '/dsh云盘/crossterm/bus',
|
|
65
65
|
cloudIntervalMs: 4000,
|
|
66
|
+
// 云端目录(0.3.39 起):<cloudHome>/<账号>/<设备>/ + <账号>/shared/<设备>/inbox
|
|
67
|
+
cloudHome: 'dsh',
|
|
68
|
+
cloudAccount: '',
|
|
69
|
+
relayIntervalMs: 20000,
|
|
70
|
+
backupIntervalMs: 300000,
|
|
71
|
+
backupSessionsPerRun: 20,
|
|
72
|
+
backupAttachmentsPerRun: 20,
|
|
73
|
+
attachmentBackupEnabled: true,
|
|
66
74
|
baiduAppFile: '',
|
|
67
75
|
baiduAuthFile: '',
|
|
68
76
|
cloudStateFile: '',
|
|
@@ -203,8 +211,9 @@ export function formatBytes(n) {
|
|
|
203
211
|
export async function writeJsonAtomic(file, value) {
|
|
204
212
|
await fsp.mkdir(path.dirname(file), { recursive: true })
|
|
205
213
|
const tmp = `${file}.${process.pid}.${crypto.randomBytes(4).toString('hex')}.tmp`
|
|
206
|
-
|
|
207
|
-
await fsp.
|
|
214
|
+
const data = JSON.stringify(value, null, 2)
|
|
215
|
+
await fsp.writeFile(tmp, data, 'utf8')
|
|
216
|
+
await renameWithRetry(tmp, file, data)
|
|
208
217
|
}
|
|
209
218
|
|
|
210
219
|
/** 原子写文本:同上,用于邮件正文 */
|
|
@@ -212,7 +221,53 @@ export async function writeFileAtomic(file, text) {
|
|
|
212
221
|
await fsp.mkdir(path.dirname(file), { recursive: true })
|
|
213
222
|
const tmp = `${file}.${process.pid}.${crypto.randomBytes(4).toString('hex')}.tmp`
|
|
214
223
|
await fsp.writeFile(tmp, text, 'utf8')
|
|
215
|
-
await
|
|
224
|
+
await renameWithRetry(tmp, file, text)
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Windows 上目标文件被别的进程(读方、杀毒、同步盘)短暂占用时 rename 会 EPERM/EBUSY,
|
|
228
|
+
// 以前就这么把 .tmp 漏在原地:presence/ 攒了两百多个。这里重试几次,最后退化成直接覆盖,
|
|
229
|
+
// 无论成败都把 tmp 删掉,绝不留垃圾。
|
|
230
|
+
async function renameWithRetry(tmp, file, data) {
|
|
231
|
+
for (let attempt = 0; attempt < 5; attempt++) {
|
|
232
|
+
try {
|
|
233
|
+
await fsp.rename(tmp, file)
|
|
234
|
+
return
|
|
235
|
+
} catch (error) {
|
|
236
|
+
const code = error && error.code
|
|
237
|
+
if (!['EPERM', 'EBUSY', 'EACCES', 'ENOENT'].includes(code)) throw error
|
|
238
|
+
await new Promise((resolve) => setTimeout(resolve, 15 * (attempt + 1)))
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
await fsp.writeFile(file, data, 'utf8')
|
|
242
|
+
await fsp.rm(tmp, { force: true }).catch(() => {})
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** 清掉总线目录里超过 maxAgeMs 的 .tmp 残骸(原子写的漏网之鱼) */
|
|
246
|
+
export async function sweepTmp(root, maxAgeMs = 10 * 60 * 1000, dirs = ['presence', 'chains', 'links', 'inbox', 'mail']) {
|
|
247
|
+
const now = Date.now()
|
|
248
|
+
let removed = 0
|
|
249
|
+
for (const dir of dirs) {
|
|
250
|
+
const abs = path.join(root, dir)
|
|
251
|
+
let entries
|
|
252
|
+
try {
|
|
253
|
+
entries = await fsp.readdir(abs, { withFileTypes: true })
|
|
254
|
+
} catch {
|
|
255
|
+
continue
|
|
256
|
+
}
|
|
257
|
+
for (const entry of entries) {
|
|
258
|
+
if (!entry.isFile() || !entry.name.endsWith('.tmp')) continue
|
|
259
|
+
const target = path.join(abs, entry.name)
|
|
260
|
+
try {
|
|
261
|
+
const st = await fsp.stat(target)
|
|
262
|
+
if (now - st.mtimeMs < maxAgeMs) continue
|
|
263
|
+
await fsp.rm(target, { force: true })
|
|
264
|
+
removed += 1
|
|
265
|
+
} catch {
|
|
266
|
+
/* ignore */
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
return removed
|
|
216
271
|
}
|
|
217
272
|
|
|
218
273
|
export async function readJson(file) {
|
|
@@ -300,8 +355,14 @@ export class Bus {
|
|
|
300
355
|
const file = this.mailFile(msg.id)
|
|
301
356
|
fs.mkdirSync(path.dirname(file), { recursive: true })
|
|
302
357
|
const tmp = `${file}.${process.pid}.${crypto.randomBytes(4).toString('hex')}.tmp`
|
|
303
|
-
|
|
304
|
-
fs.
|
|
358
|
+
const text = `${head.join('\n')}\n${body}\n`
|
|
359
|
+
fs.writeFileSync(tmp, text, 'utf8')
|
|
360
|
+
try {
|
|
361
|
+
fs.renameSync(tmp, file)
|
|
362
|
+
} catch {
|
|
363
|
+
fs.writeFileSync(file, text, 'utf8')
|
|
364
|
+
try { fs.rmSync(tmp, { force: true }) } catch { /* ignore */ }
|
|
365
|
+
}
|
|
305
366
|
return file
|
|
306
367
|
}
|
|
307
368
|
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import fsp from 'node:fs/promises'
|
|
2
|
+
import os from 'node:os'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
|
|
5
|
+
const ONLINE_MS = 3 * 60 * 1000
|
|
6
|
+
|
|
7
|
+
function trimRoot(root) {
|
|
8
|
+
return String(root || '').replace(/\/+$/, '')
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function summarize(data, now) {
|
|
12
|
+
const updatedAt = Number(data.updatedAt) || 0
|
|
13
|
+
return {
|
|
14
|
+
nodeId: String(data.nodeId || ''),
|
|
15
|
+
name: String(data.name || data.hostname || data.nodeId || ''),
|
|
16
|
+
hostname: String(data.hostname || ''),
|
|
17
|
+
version: String(data.version || ''),
|
|
18
|
+
platform: String(data.platform || ''),
|
|
19
|
+
pid: Number(data.pid) || 0,
|
|
20
|
+
sessions: Array.isArray(data.sessions) ? data.sessions.length : 0,
|
|
21
|
+
chains: Array.isArray(data.chains)
|
|
22
|
+
? [...new Set(data.chains.map((c) => (typeof c === 'string' ? c : c && c.code)).filter(Boolean))]
|
|
23
|
+
: [],
|
|
24
|
+
updatedAt,
|
|
25
|
+
lastSeen: updatedAt ? new Date(updatedAt).toISOString() : '',
|
|
26
|
+
online: updatedAt > 0 && now - updatedAt < ONLINE_MS,
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* 云端设备列表:读 <cloudRoot>/presence/*.json(翻页列目录,跳过 .tmp 残骸),
|
|
32
|
+
* 逐个下载解析出 nodeId/名字/版本/最后心跳。
|
|
33
|
+
* 注意 cfg.cloudRoot 本身就是总线根(形如 /dsh云盘/crossterm/bus),不再拼一层 bus。
|
|
34
|
+
*/
|
|
35
|
+
export async function listCloudDevices(pan, cloudRoot, opts = {}) {
|
|
36
|
+
const now = opts.now || Date.now()
|
|
37
|
+
const root = trimRoot(cloudRoot)
|
|
38
|
+
const dir = `${root}/presence`
|
|
39
|
+
const result = { ok: false, dir, devices: [], files: 0, tmpFiles: 0, error: '' }
|
|
40
|
+
if (!root) {
|
|
41
|
+
result.error = '还没配置云盘'
|
|
42
|
+
return result
|
|
43
|
+
}
|
|
44
|
+
let listing
|
|
45
|
+
try {
|
|
46
|
+
listing = await pan.lsAll(dir)
|
|
47
|
+
} catch (error) {
|
|
48
|
+
result.error = String((error && error.message) || error)
|
|
49
|
+
return result
|
|
50
|
+
}
|
|
51
|
+
if (!listing || listing.errno !== 0) {
|
|
52
|
+
// 4 = 目录不存在(还没同步过)
|
|
53
|
+
result.ok = true
|
|
54
|
+
result.error = listing && listing.errno === -9 ? '云端目录不存在' : ''
|
|
55
|
+
return result
|
|
56
|
+
}
|
|
57
|
+
const entries = listing.list || []
|
|
58
|
+
const jsonFiles = entries.filter((f) => f.isdir === 0 && f.server_filename.endsWith('.json'))
|
|
59
|
+
result.files = jsonFiles.length
|
|
60
|
+
result.tmpFiles = entries.filter((f) => f.isdir === 0 && f.server_filename.endsWith('.tmp')).length
|
|
61
|
+
|
|
62
|
+
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'ct-devices-'))
|
|
63
|
+
try {
|
|
64
|
+
const byId = new Map()
|
|
65
|
+
for (const entry of jsonFiles) {
|
|
66
|
+
const name = entry.server_filename
|
|
67
|
+
const nodeId = name.slice(0, -'.json'.length)
|
|
68
|
+
const local = path.join(tmpDir, name)
|
|
69
|
+
try {
|
|
70
|
+
await pan.get(`${dir}/${name}`, local)
|
|
71
|
+
const data = JSON.parse(await fsp.readFile(local, 'utf8'))
|
|
72
|
+
if (!data || !data.nodeId) continue
|
|
73
|
+
byId.set(data.nodeId, summarize(data, now))
|
|
74
|
+
} catch {
|
|
75
|
+
/* 单个坏文件不影响整体 */
|
|
76
|
+
}
|
|
77
|
+
if (byId.size >= 50) break
|
|
78
|
+
}
|
|
79
|
+
result.devices = [...byId.values()].sort((a, b) => b.updatedAt - a.updatedAt)
|
|
80
|
+
result.ok = true
|
|
81
|
+
return result
|
|
82
|
+
} finally {
|
|
83
|
+
await fsp.rm(tmpDir, { recursive: true, force: true }).catch(() => {})
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** 本地自己的 presence(不依赖云端,用来兜底显示"我") */
|
|
88
|
+
export async function localDevice(presenceDir, nodeId, now = Date.now()) {
|
|
89
|
+
try {
|
|
90
|
+
const raw = await fsp.readFile(path.join(presenceDir, `${nodeId}.json`), 'utf8')
|
|
91
|
+
return summarize(JSON.parse(raw), now)
|
|
92
|
+
} catch {
|
|
93
|
+
return undefined
|
|
94
|
+
}
|
|
95
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
// 云端目录编排(0.3.39):把「账号/设备/共享收件箱 + 中转站 + 会话备份」串起来
|
|
2
|
+
// start() 后按两个定时器跑:relayIntervalMs 收信投递、backupIntervalMs 会话增量备份
|
|
3
|
+
import fs from 'node:fs'
|
|
4
|
+
import path from 'node:path'
|
|
5
|
+
import { ensureLayout, readLayoutCache, DEFAULT_HOME } from './layout.js'
|
|
6
|
+
import { createRelay } from './relay.js'
|
|
7
|
+
import { backupSessions, backupAttachments, backupSummary } from './session-backup.js'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @param {object} opts
|
|
11
|
+
* @param {object} opts.cfg 插件配置(readConfig 结果)
|
|
12
|
+
* @param {object} opts.device { nodeId, name, version, platform, sessions }
|
|
13
|
+
* @param {object} opts.pan 云盘客户端
|
|
14
|
+
* @param {string} opts.busDir 总线根(<DSH_HOME>/crossterm)
|
|
15
|
+
* @param {Function} opts.deliver async ({ to, envelope, files }) => { ok, sessionId, reason }
|
|
16
|
+
* @param {Function} [opts.names] 返回 { <会话短号>: { name } },给 _session.json 补显示名
|
|
17
|
+
* @param {Function} [opts.log]
|
|
18
|
+
*/
|
|
19
|
+
export function createCloudHome({ cfg, device, pan, busDir, deliver, names, log = () => {} }) {
|
|
20
|
+
const home = path.dirname(busDir)
|
|
21
|
+
const root = path.join(busDir, 'cloud')
|
|
22
|
+
fs.mkdirSync(root, { recursive: true })
|
|
23
|
+
const cacheFile = path.join(root, 'layout.json')
|
|
24
|
+
const relayDir = path.join(root, 'relay')
|
|
25
|
+
const relayState = path.join(relayDir, 'state.json')
|
|
26
|
+
const backupState = path.join(root, 'backup-state.json')
|
|
27
|
+
const sessionsRoot = path.join(home, 'sessions')
|
|
28
|
+
const attachmentsRoot = path.join(home, 'attachments', 'v1', 'objects')
|
|
29
|
+
|
|
30
|
+
let timers = []
|
|
31
|
+
let started = false
|
|
32
|
+
let layoutBusy = null
|
|
33
|
+
let backupBusy = null
|
|
34
|
+
let relayTimer = 0
|
|
35
|
+
let backupTimer = 0
|
|
36
|
+
|
|
37
|
+
const relay = createRelay({
|
|
38
|
+
pan,
|
|
39
|
+
getLayout: () => getLayout(false),
|
|
40
|
+
localDir: relayDir,
|
|
41
|
+
stateFile: relayState,
|
|
42
|
+
deliver,
|
|
43
|
+
log: (line) => log('relay: ' + line),
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
async function getLayout(force = false) {
|
|
47
|
+
if (!force && layoutBusy) return layoutBusy
|
|
48
|
+
layoutBusy = (async () => {
|
|
49
|
+
try {
|
|
50
|
+
const info = await ensureLayout({
|
|
51
|
+
pan,
|
|
52
|
+
cfg,
|
|
53
|
+
device,
|
|
54
|
+
cacheFile,
|
|
55
|
+
force,
|
|
56
|
+
log: (line) => log('layout: ' + line),
|
|
57
|
+
})
|
|
58
|
+
return info
|
|
59
|
+
} catch (e) {
|
|
60
|
+
const msg = (e && e.message) || String(e)
|
|
61
|
+
log(`建云端目录失败:${msg}`)
|
|
62
|
+
const cached = readLayoutCache(cacheFile)
|
|
63
|
+
if (cached) return { ...cached, cached: true, error: msg }
|
|
64
|
+
return { error: msg }
|
|
65
|
+
} finally {
|
|
66
|
+
layoutBusy = null
|
|
67
|
+
}
|
|
68
|
+
})()
|
|
69
|
+
return layoutBusy
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function backupNow() {
|
|
73
|
+
if (backupBusy) return backupBusy
|
|
74
|
+
backupBusy = (async () => {
|
|
75
|
+
const layout = await getLayout(false)
|
|
76
|
+
if (!layout || !layout.paths) return { ok: false, error: layout?.error || '云端目录还没准备好' }
|
|
77
|
+
const namesMap = typeof names === 'function' ? names() || {} : {}
|
|
78
|
+
const sessionNames = {}
|
|
79
|
+
for (const [short, rec] of Object.entries(namesMap)) sessionNames[short] = { name: rec?.name || '', cwd: rec?.cwd || '' }
|
|
80
|
+
const started = Date.now()
|
|
81
|
+
const sessions = await backupSessions({
|
|
82
|
+
pan,
|
|
83
|
+
layout,
|
|
84
|
+
sessionsRoot,
|
|
85
|
+
tmpDir: path.join(root, 'tmp'),
|
|
86
|
+
stateFile: backupState,
|
|
87
|
+
log: (line) => log('backup: ' + line),
|
|
88
|
+
maxSessions: Number(cfg.backupSessionsPerRun || 20),
|
|
89
|
+
names: sessionNames,
|
|
90
|
+
})
|
|
91
|
+
let attachments = { ok: true, uploaded: 0, skipped: 0, pending: 0 }
|
|
92
|
+
if (cfg.attachmentBackupEnabled !== false) {
|
|
93
|
+
attachments = await backupAttachments({
|
|
94
|
+
pan,
|
|
95
|
+
layout,
|
|
96
|
+
objectsRoot: attachmentsRoot,
|
|
97
|
+
stateFile: backupState,
|
|
98
|
+
log: (line) => log('backup: ' + line),
|
|
99
|
+
maxFiles: Number(cfg.backupAttachmentsPerRun || 20),
|
|
100
|
+
})
|
|
101
|
+
}
|
|
102
|
+
const out = { ok: sessions.ok && attachments.ok, ms: Date.now() - started, sessions, attachments, summary: backupSummary(backupState) }
|
|
103
|
+
log(`会话备份:上传 ${sessions.uploaded} 个会话 / ${attachments.uploaded} 个附件(失败 ${sessions.error || attachments.error || '无'})`)
|
|
104
|
+
return out
|
|
105
|
+
})()
|
|
106
|
+
try { return await backupBusy } finally { backupBusy = null }
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function relayNow() {
|
|
110
|
+
return relay.poll()
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function send(opts) {
|
|
114
|
+
return relay.sendEnvelope(opts)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function status() {
|
|
118
|
+
const layout = readLayoutCache(cacheFile)
|
|
119
|
+
return {
|
|
120
|
+
home: `${cfg.cloudHome || DEFAULT_HOME}`,
|
|
121
|
+
layout: layout
|
|
122
|
+
? { account: layout.account, role: layout.role, deviceId: layout.deviceId, paths: layout.paths, ensuredAt: layout.ensuredAt }
|
|
123
|
+
: null,
|
|
124
|
+
relay: { ...relay.stats(), intervalMs: relayTimer },
|
|
125
|
+
backup: { ...backupSummary(backupState), intervalMs: backupTimer },
|
|
126
|
+
roots: { sessions: sessionsRoot, attachments: attachmentsRoot },
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function start() {
|
|
131
|
+
if (started) return
|
|
132
|
+
started = true
|
|
133
|
+
relayTimer = Number(cfg.relayIntervalMs || 20000)
|
|
134
|
+
backupTimer = Number(cfg.backupIntervalMs || 300000)
|
|
135
|
+
void getLayout(false).catch(() => {})
|
|
136
|
+
timers.push(setInterval(() => { void relayNow().catch(() => {}) }, relayTimer))
|
|
137
|
+
timers.push(setInterval(() => { void backupNow().catch(() => {}) }, backupTimer))
|
|
138
|
+
for (const t of timers) if (t && typeof t.unref === 'function') t.unref()
|
|
139
|
+
// 启动 8 秒后先跑一轮收信、30 秒后跑第一轮备份(别和宿主启动抢资源)
|
|
140
|
+
setTimeout(() => { void relayNow().catch(() => {}) }, 8000)
|
|
141
|
+
setTimeout(() => { void backupNow().catch(() => {}) }, 30000)
|
|
142
|
+
log(`云端目录就绪:relay ${relayTimer}ms / backup ${backupTimer}ms(根 ${cfg.cloudHome || DEFAULT_HOME})`)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function stop() {
|
|
146
|
+
started = false
|
|
147
|
+
for (const t of timers) clearInterval(t)
|
|
148
|
+
timers = []
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return { start, stop, status, getLayout, backupNow, relayNow, send, cacheFile, relayDir }
|
|
152
|
+
}
|