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 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.37'
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
- await fsp.writeFile(tmp, JSON.stringify(value, null, 2), 'utf8')
207
- await fsp.rename(tmp, file)
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 fsp.rename(tmp, file)
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
- fs.writeFileSync(tmp, `${head.join('\n')}\n${body}\n`, 'utf8')
304
- fs.renameSync(tmp, file)
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
+ }