dsh-crossterm 0.3.38 → 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
@@ -257,6 +257,35 @@ dsh plugin --profile desktop remove dsh-crossterm
257
257
  > 残骸,而百度网盘列目录一页只给 200 条 —— 真实 json 被挤到了第二页。现在列目录会翻页,
258
258
  > 云端也不再上传 `.tmp`。
259
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
+
260
289
  ### 手动 / 自动注入开关(三层)
261
290
 
262
291
  | 层 | 开关 | 粒度 |
package/client/client.js CHANGED
@@ -156,6 +156,20 @@ window.__ModuleLoader__.load({
156
156
  .join(";");
157
157
  }
158
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
+
159
173
  function Card() {
160
174
  const [open, setOpen] = React.useState(false);
161
175
  const [status, setStatus] = React.useState(null);
@@ -270,9 +284,48 @@ window.__ModuleLoader__.load({
270
284
  }
271
285
  };
272
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
+
273
325
  const version = status ? "v" + status.version : "";
274
326
  const mode = status && status.install ? status.install.mode : "";
275
327
  const cloud = (status && status.cloud) || {};
328
+ const home = (status && status.home) || null;
276
329
  const subtitle = status
277
330
  ? `${status.node || "本机"} · ${status.chains && status.chains.length ? status.chains.join(" / ") : "未入链"}`
278
331
  : "加载中…";
@@ -360,12 +413,29 @@ window.__ModuleLoader__.load({
360
413
  : null,
361
414
  ),
362
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,
363
431
  h(
364
432
  "div",
365
433
  { style: S.actions },
366
434
  h("button", { style: S.btn, onClick: onCheck, disabled: busy !== "" }, busy === "check" ? "检查中…" : "检查更新"),
367
435
  h("button", { style: S.btnPrimary, onClick: onUpdate, disabled: busy !== "" }, busy === "update" ? "更新中…" : "更新"),
368
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" ? "备份中…" : "备份会话"),
369
439
  ),
370
440
  msg ? h("div", { style: S.msg }, msg) : null,
371
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.38'
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: '',
@@ -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
+ }
@@ -0,0 +1,190 @@
1
+ // 云端目录规则(0.3.39):
2
+ // <大文件夹>/<账号>/ 账号文件夹(第一个进入的设备 = 管理员)
3
+ // <大文件夹>/<账号>/<设备>/ 设备文件夹(会话备份、附件)
4
+ // <大文件夹>/<账号>/shared/ 云端设备共享文件夹(跨设备传输)
5
+ // <大文件夹>/<账号>/shared/<设备>/inbox/ 每台设备自己的收件箱
6
+ // <大文件夹>/<账号>/shared/<设备>/done/ 已投递归档
7
+ import fs from 'node:fs'
8
+ import path from 'node:path'
9
+
10
+ export const LAYOUT_VERSION = 1
11
+ export const DEFAULT_HOME = 'dsh'
12
+ export const ACCOUNT_FILE = '_account.json'
13
+ export const DEVICE_FILE = '_device.json'
14
+ export const INBOX_FILE = '_inbox.json'
15
+ const CACHE_MS = 10 * 60 * 1000
16
+
17
+ /** 目录名净化:去掉路径分隔符/控制字符/中文等非 ASCII(云盘接口对特殊字符不友好) */
18
+ export function sanitize(raw, fallback = 'default') {
19
+ const s = String(raw ?? '').trim()
20
+ if (!s) return fallback
21
+ const ascii = s.replace(/[^\x20-\x7e]/g, '').replace(/[\\/:*?"<>|]+/g, '-')
22
+ const out = ascii.replace(/\s+/g, '-').replace(/-+/g, '-').replace(/^[.-]+/, '').replace(/[.-]+$/, '').slice(0, 64)
23
+ return out || fallback
24
+ }
25
+
26
+ export function buildPaths({ home = DEFAULT_HOME, account = 'default', deviceId = 'device' } = {}) {
27
+ const base = '/' + String(home || DEFAULT_HOME).replace(/^\/+|\/+$/g, '')
28
+ const accountDir = `${base}/${account}`
29
+ const sharedDir = `${accountDir}/shared`
30
+ const deviceRoot = `${sharedDir}/${deviceId}`
31
+ const deviceDir = `${accountDir}/${deviceId}`
32
+ return {
33
+ base,
34
+ accountDir,
35
+ sharedDir,
36
+ deviceRoot,
37
+ deviceDir,
38
+ inboxDir: `${deviceRoot}/inbox`,
39
+ doneDir: `${deviceRoot}/done`,
40
+ sessionDir: `${deviceDir}/sessions`,
41
+ attachmentDir: `${deviceDir}/attachments`,
42
+ accountFile: `${accountDir}/${ACCOUNT_FILE}`,
43
+ deviceFile: `${deviceDir}/${DEVICE_FILE}`,
44
+ inboxFile: `${deviceRoot}/${INBOX_FILE}`,
45
+ }
46
+ }
47
+
48
+ /**
49
+ * 账号名:配置手填 > DSH_ACCOUNT 环境变量 > 云盘账号昵称 > uk > default
50
+ * (本机 .credentials.yaml 里拿不到 dsh 账号名,token 是不透明串,asar 也没有 account 服务)
51
+ */
52
+ export async function resolveAccount(pan, cfg = {}, log = () => {}) {
53
+ if (cfg.cloudAccount) return { account: sanitize(cfg.cloudAccount), source: 'config' }
54
+ if (process.env.DSH_ACCOUNT) return { account: sanitize(process.env.DSH_ACCOUNT), source: 'env' }
55
+ try {
56
+ const info = await pan.uinfo()
57
+ const name = info?.netdisk_name || info?.baidu_name || ''
58
+ if (name) return { account: sanitize(name), source: 'cloud', name, uk: info?.uk }
59
+ if (info?.uk) return { account: sanitize(String(info.uk)), source: 'cloud-uk', uk: info.uk }
60
+ } catch (e) {
61
+ log(`读云盘账号失败:${(e && e.message) || String(e)}`)
62
+ }
63
+ return { account: 'default', source: 'fallback' }
64
+ }
65
+
66
+ function readCache(file) {
67
+ if (!file) return null
68
+ try { return JSON.parse(fs.readFileSync(file, 'utf8')) } catch { return null }
69
+ }
70
+
71
+ function writeCache(file, value) {
72
+ if (!file) return
73
+ try {
74
+ fs.mkdirSync(path.dirname(file), { recursive: true })
75
+ fs.writeFileSync(file, JSON.stringify(value, null, 2), 'utf8')
76
+ } catch { /* ignore */ }
77
+ }
78
+
79
+ async function readJson(pan, remote, log) {
80
+ try {
81
+ const r = await pan.getBuffer(remote)
82
+ return JSON.parse(r.buffer.toString('utf8'))
83
+ } catch (e) {
84
+ const msg = (e && e.message) || String(e)
85
+ if (!/没有该文件/.test(msg)) log(`读 ${remote} 失败:${msg}`)
86
+ return null
87
+ }
88
+ }
89
+
90
+ const iso = (ts = Date.now()) => new Date(ts).toISOString()
91
+
92
+ /**
93
+ * 建立/校正云端目录结构,返回 { account, role, paths, created, accountInfo, deviceInfo }
94
+ * @param {{pan: object, cfg: object, device: {nodeId: string, name?: string, version?: string, platform?: string, sessions?: number}, cacheFile?: string, force?: boolean, log?: Function}} opts
95
+ */
96
+ export async function ensureLayout({ pan, cfg = {}, device = {}, cacheFile, force = false, log = () => {} }) {
97
+ const nodeId = sanitize(device.nodeId || 'device')
98
+ const cached = readCache(cacheFile)
99
+ if (!force && cached && cached.deviceId === nodeId && Date.now() - Number(cached.at || 0) < CACHE_MS && cached.paths) {
100
+ return { ...cached, cached: true, ensuredAt: cached.at }
101
+ }
102
+
103
+ const who = await resolveAccount(pan, cfg, log)
104
+ const paths = buildPaths({ home: cfg.cloudHome || DEFAULT_HOME, account: who.account, deviceId: nodeId })
105
+ const dirs = [
106
+ paths.base,
107
+ paths.accountDir,
108
+ paths.sharedDir,
109
+ paths.deviceDir,
110
+ paths.deviceRoot,
111
+ paths.inboxDir,
112
+ paths.doneDir,
113
+ paths.sessionDir,
114
+ paths.attachmentDir,
115
+ ]
116
+ const created = []
117
+ for (const dir of dirs) {
118
+ const r = await pan.mkdir(dir)
119
+ if (!r.ok) throw new Error(`建目录失败 ${dir}(errno=${r.errno})`)
120
+ if (r.created) created.push(dir)
121
+ }
122
+
123
+ // 角色:账号文件夹里第一个写下 _account.json 的设备是管理员,其余是成员
124
+ let accountInfo = await readJson(pan, paths.accountFile, log)
125
+ let role = 'admin'
126
+ if (!accountInfo) {
127
+ accountInfo = {
128
+ account: who.account,
129
+ accountSource: who.source,
130
+ cloudUk: who.uk || '',
131
+ ownerDevice: nodeId,
132
+ ownerName: device.name || nodeId,
133
+ role: 'admin',
134
+ version: LAYOUT_VERSION,
135
+ createdAt: iso(),
136
+ note: '账号云端目录;ownerDevice 是该账号云端的管理员设备,其余设备为成员',
137
+ }
138
+ await pan.putBuffer(Buffer.from(JSON.stringify(accountInfo, null, 2), 'utf8'), paths.accountFile)
139
+ log(`云端账号目录已创建,本机成为管理员:${paths.accountDir}`)
140
+ } else {
141
+ role = accountInfo.ownerDevice === nodeId ? 'admin' : 'member'
142
+ }
143
+
144
+ const oldDevice = (await readJson(pan, paths.deviceFile, () => {})) || {}
145
+ const deviceInfo = {
146
+ nodeId,
147
+ name: device.name || oldDevice.name || nodeId,
148
+ role,
149
+ version: device.version || '',
150
+ platform: device.platform || '',
151
+ sessions: Number(device.sessions || 0),
152
+ joinedAt: oldDevice.joinedAt || iso(),
153
+ lastSeen: iso(),
154
+ account: who.account,
155
+ }
156
+ await pan.putBuffer(Buffer.from(JSON.stringify(deviceInfo, null, 2), 'utf8'), paths.deviceFile)
157
+
158
+ const oldInbox = (await readJson(pan, paths.inboxFile, () => {})) || {}
159
+ const inboxInfo = {
160
+ nodeId,
161
+ name: deviceInfo.name,
162
+ version: deviceInfo.version,
163
+ role,
164
+ createdAt: oldInbox.createdAt || iso(),
165
+ updatedAt: iso(),
166
+ note: '本设备收件箱:别的设备发来的消息/文件先落在这里,本机的中转站定时取走',
167
+ }
168
+ await pan.putBuffer(Buffer.from(JSON.stringify(inboxInfo, null, 2), 'utf8'), paths.inboxFile)
169
+
170
+ const out = {
171
+ at: Date.now(),
172
+ ensuredAt: iso(),
173
+ deviceId: nodeId,
174
+ account: who.account,
175
+ accountSource: who.source,
176
+ role,
177
+ paths,
178
+ created,
179
+ accountInfo,
180
+ deviceInfo,
181
+ }
182
+ writeCache(cacheFile, out)
183
+ return out
184
+ }
185
+
186
+ /** 只读缓存,拿不到就返回 null(给 status 卡片用,不触网) */
187
+ export function readLayoutCache(cacheFile) {
188
+ const c = readCache(cacheFile)
189
+ return c && c.paths ? c : null
190
+ }
package/lib/cloud/pan.js CHANGED
@@ -1,4 +1,4 @@
1
- // 百度网盘最小客户端(插件内置版):uinfo / ls / put / get / rm,走 xpan openapi
1
+ // 百度网盘最小客户端(插件内置版):uinfo / ls / mkdir / put / get / rm,走 xpan openapi
2
2
  // 凭证文件由调用方给出:app 文件含 appKey/appSecret,auth 文件含 accessToken/refreshToken
3
3
  import fs from 'node:fs'
4
4
  import path from 'node:path'
@@ -96,9 +96,33 @@ export function createPan({ appFile, authFile, log = () => {} }) {
96
96
  return { errno: 0, list: all }
97
97
  }
98
98
 
99
- async function put(localPath, remotePath) {
99
+ // 建目录:xpan 的 create 带 isdir=1;已存在(31061 / -8)视为成功
100
+ async function mkdir(remotePath) {
101
+ await ensureToken()
102
+ const j = await api(
103
+ `https://pan.baidu.com/rest/2.0/xpan/file?method=create&access_token=${encodeURIComponent(tok)}`,
104
+ new URLSearchParams({ path: remotePath, isdir: '1', size: '0', block_list: '[]', rtype: '3' }),
105
+ )
106
+ if (j.errno === 0) return { ok: true, path: remotePath, created: true }
107
+ if (j.errno === 31061 || j.errno === -8) return { ok: true, path: remotePath, existed: true }
108
+ // 有些错误码(如父目录刚建好时的竞态)其实目录已存在,用列目录复核一次
109
+ const list = await lsAll(path.posix.dirname(remotePath))
110
+ const name = path.posix.basename(remotePath)
111
+ if (list.errno === 0 && (list.list || []).some((f) => f.server_filename === name && f.isdir === 1)) {
112
+ return { ok: true, path: remotePath, existed: true }
113
+ }
114
+ return { ok: false, path: remotePath, errno: j.errno, raw: j }
115
+ }
116
+
117
+ async function exists(remotePath) {
118
+ const list = await lsAll(path.posix.dirname(remotePath))
119
+ if (list.errno !== 0) return false
120
+ const name = path.posix.basename(remotePath)
121
+ return (list.list || []).some((f) => f.server_filename === name)
122
+ }
123
+
124
+ async function putBuffer(buf, remotePath) {
100
125
  await ensureToken()
101
- const buf = await readFile(localPath)
102
126
  const md5 = md5hex(buf)
103
127
  const size = buf.length
104
128
  const pre = await api(
@@ -137,6 +161,20 @@ export function createPan({ appFile, authFile, log = () => {} }) {
137
161
  return { path: remotePath, size, md5: upj.md5, fs_id: cr.fs_id }
138
162
  }
139
163
 
164
+ async function put(localPath, remotePath) {
165
+ const buf = await readFile(localPath)
166
+ return putBuffer(buf, remotePath)
167
+ }
168
+
169
+ /** 读回云端文件内容(小文件用,中转站/标注用) */
170
+ async function getBuffer(remotePath) {
171
+ const tmp = path.join(process.env.TEMP || '/tmp', `pan-${Date.now()}-${path.posix.basename(remotePath)}`)
172
+ const r = await get(remotePath, tmp)
173
+ const buf = fs.readFileSync(tmp)
174
+ try { fs.rmSync(tmp, { force: true }) } catch { /* ignore */ }
175
+ return { ...r, buffer: buf }
176
+ }
177
+
140
178
  async function get(remotePath, localPath) {
141
179
  await ensureToken()
142
180
  const dir = path.posix.dirname(remotePath)
@@ -166,5 +204,5 @@ export function createPan({ appFile, authFile, log = () => {} }) {
166
204
  )
167
205
  }
168
206
 
169
- return { uinfo, ls, lsAll, put, get, rm, ensureToken }
207
+ return { uinfo, ls, lsAll, mkdir, exists, put, putBuffer, get, getBuffer, rm, ensureToken }
170
208
  }
@@ -0,0 +1,196 @@
1
+ // 云端中转站(0.3.39):
2
+ // 收信侧:定时扫 <账号>/shared/<本机>/inbox/*.json → 下载附件到本地 → 按收件人(会话名)投递 → 云端归档到 done/
3
+ // 发信侧:sendEnvelope() 把信封写到目标设备的 inbox/,附件写到 files/<邮件id>/
4
+ // 信封格式(与老邮件兼容):
5
+ // { id, from: {nodeId,name}, to: '<会话名>' | ['会话名', …], text, files: [{name, size}], createdAt }
6
+ import fs from 'node:fs'
7
+ import path from 'node:path'
8
+ import crypto from 'node:crypto'
9
+
10
+ const sha256 = (buf) => crypto.createHash('sha256').update(buf).digest('hex')
11
+ const nowIso = () => new Date().toISOString()
12
+
13
+ function loadState(file) {
14
+ try {
15
+ const j = JSON.parse(fs.readFileSync(file, 'utf8'))
16
+ return { at: j.at || 0, processed: j.processed || {}, failed: j.failed || {}, sent: j.sent || [] }
17
+ } catch {
18
+ return { at: 0, processed: {}, failed: {}, sent: [] }
19
+ }
20
+ }
21
+
22
+ function saveState(file, state) {
23
+ try {
24
+ fs.mkdirSync(path.dirname(file), { recursive: true })
25
+ fs.writeFileSync(file, JSON.stringify(state, null, 2), 'utf8')
26
+ } catch { /* ignore */ }
27
+ }
28
+
29
+ const isTmp = (name) => /\.tmp$/.test(name) || name.startsWith('.')
30
+
31
+ function parseEnvelope(text) {
32
+ const j = JSON.parse(text)
33
+ const to = Array.isArray(j.to) ? j.to : j.to || j.toSession || j.recipient || ''
34
+ return {
35
+ id: String(j.id || j.mailId || ''),
36
+ from: j.from || {},
37
+ to,
38
+ text: String(j.text || j.body || ''),
39
+ subject: String(j.subject || ''),
40
+ files: Array.isArray(j.files) ? j.files : [],
41
+ createdAt: j.createdAt || Date.now(),
42
+ }
43
+ }
44
+
45
+ /**
46
+ * @param {object} opts
47
+ * @param {object} opts.pan
48
+ * @param {Function} opts.getLayout 返回 ensureLayout() 结果(异步)
49
+ * @param {string} opts.localDir 本地落地目录(收件箱缓存、附件)
50
+ * @param {string} opts.stateFile 本地已处理清单
51
+ * @param {Function} opts.deliver async ({ to, envelope, files }) => { ok, sessionId, reason }
52
+ * @param {Function} [opts.log]
53
+ */
54
+ export function createRelay({ pan, getLayout, localDir, stateFile, deliver, log = () => {} }) {
55
+ let running = false
56
+ const stats = { at: 0, scanned: 0, delivered: 0, failed: 0, pending: 0, sent: 0, lastError: '' }
57
+
58
+ async function downloadAttachment(layout, mailId, file, destDir) {
59
+ // 附件既可能在 files/<id>/ 下,也可能就在 inbox/ 同级(老信封)
60
+ const candidates = [
61
+ `${layout.paths.sharedDir}/${layout.deviceId}/files/${mailId}/${file.name}`,
62
+ `${layout.paths.inboxDir}/${file.name}`,
63
+ file.cloudPath || '',
64
+ ].filter(Boolean)
65
+ for (const remote of candidates) {
66
+ try {
67
+ const dst = path.join(destDir, file.name)
68
+ await pan.get(remote, dst)
69
+ return { name: file.name, local: dst, remote, bytes: fs.statSync(dst).size }
70
+ } catch { /* try next */ }
71
+ }
72
+ return { name: file.name, local: '', remote: candidates[0] || '', error: '下载失败' }
73
+ }
74
+
75
+ async function poll() {
76
+ if (running) return { ...stats, running: true }
77
+ running = true
78
+ const state = loadState(stateFile)
79
+ try {
80
+ const layout = await getLayout()
81
+ if (!layout || !layout.paths) return { ...stats, running: true, error: '云端目录还没准备好' }
82
+ const list = await pan.lsAll(layout.paths.inboxDir)
83
+ if (list.errno !== 0) {
84
+ stats.at = Date.now()
85
+ return { ...stats, error: `列收件箱失败 errno=${list.errno}` }
86
+ }
87
+ const files = (list.list || []).filter((f) => f.isdir === 0 && !isTmp(f.server_filename))
88
+ stats.scanned = files.length
89
+ stats.delivered = 0
90
+ stats.failed = 0
91
+ stats.pending = 0
92
+ const incomingDir = path.join(localDir, 'incoming')
93
+ fs.mkdirSync(incomingDir, { recursive: true })
94
+
95
+ for (const f of files) {
96
+ const name = f.server_filename
97
+ if (state.processed[name]) continue
98
+ const localJson = path.join(incomingDir, name)
99
+ let envelope
100
+ try {
101
+ await pan.get(`${layout.paths.inboxDir}/${name}`, localJson)
102
+ envelope = parseEnvelope(fs.readFileSync(localJson, 'utf8'))
103
+ } catch (e) {
104
+ stats.failed += 1
105
+ stats.lastError = `读取 ${name} 失败:${(e && e.message) || String(e)}`
106
+ log(stats.lastError)
107
+ continue
108
+ }
109
+ const mailId = envelope.id || path.basename(name, '.json')
110
+ const fileDir = path.join(localDir, 'files', mailId)
111
+ fs.mkdirSync(fileDir, { recursive: true })
112
+ const landed = []
113
+ for (const file of envelope.files) {
114
+ const got = await downloadAttachment(layout, mailId, file, fileDir)
115
+ landed.push(got)
116
+ }
117
+ let result
118
+ try {
119
+ result = await deliver({ to: envelope.to, envelope, files: landed })
120
+ } catch (e) {
121
+ result = { ok: false, reason: (e && e.message) || String(e) }
122
+ }
123
+ if (result && result.ok) {
124
+ stats.delivered += 1
125
+ state.processed[name] = { at: nowIso(), mailId, sessionId: result.sessionId || '', to: String(envelope.to) }
126
+ // 云端归档:先放 done/,再删收件箱里的原件
127
+ try {
128
+ await pan.mkdir(layout.paths.doneDir)
129
+ await pan.put(localJson, `${layout.paths.doneDir}/${name}`)
130
+ await pan.rm(`${layout.paths.inboxDir}/${name}`)
131
+ } catch (e) {
132
+ log(`归档 ${name} 失败:${(e && e.message) || String(e)}`)
133
+ }
134
+ log(`中转站投递 ${mailId} → ${result.sessionId || String(envelope.to)}`)
135
+ } else {
136
+ stats.pending += 1
137
+ const key = `${name}`
138
+ state.failed[key] = { at: nowIso(), reason: (result && result.reason) || '投递失败', tries: Number(state.failed?.[key]?.tries || 0) + 1 }
139
+ log(`中转站留件 ${name}:${(result && result.reason) || '投递失败'}`)
140
+ }
141
+ saveState(stateFile, { ...state, at: Date.now() })
142
+ }
143
+ stats.at = Date.now()
144
+ saveState(stateFile, { ...state, at: Date.now() })
145
+ return { ...stats }
146
+ } catch (e) {
147
+ stats.lastError = (e && e.message) || String(e)
148
+ log(`中转站巡检失败:${stats.lastError}`)
149
+ return { ...stats, error: stats.lastError }
150
+ } finally {
151
+ running = false
152
+ }
153
+ }
154
+
155
+ /** 发信:把信封写到目标设备的共享收件箱(同账号下的设备目录) */
156
+ async function sendEnvelope({ toDevice, toSession, text, files = [], from = {}, subject = '' }) {
157
+ const layout = await getLayout()
158
+ if (!layout || !layout.paths) return { ok: false, reason: '云端目录还没准备好' }
159
+ const shared = await pan.lsAll(layout.paths.sharedDir)
160
+ if (shared.errno !== 0) return { ok: false, reason: `列共享目录失败 errno=${shared.errno}` }
161
+ const devices = (shared.list || []).filter((f) => f.isdir === 1 && !isTmp(f.server_filename))
162
+ const wanted = String(toDevice || '').trim().toLowerCase()
163
+ const hit = devices.find((d) => d.server_filename.toLowerCase() === wanted)
164
+ if (!hit) return { ok: false, reason: `共享目录里没有这台设备:${toDevice}(现有:${devices.map((d) => d.server_filename).join('、') || '无'})` }
165
+ const target = hit.server_filename
166
+ const mailId = `m-${Date.now()}-${crypto.randomBytes(3).toString('hex')}`
167
+ const inboxDir = `${layout.paths.sharedDir}/${target}/inbox`
168
+ await pan.mkdir(inboxDir)
169
+ const envelope = {
170
+ id: mailId,
171
+ from,
172
+ to: toSession || '',
173
+ subject,
174
+ text: String(text || ''),
175
+ files: files.map((f) => ({ name: path.basename(f), size: fs.existsSync(f) ? fs.statSync(f).size : 0 })),
176
+ createdAt: Date.now(),
177
+ via: 'crossterm-relay',
178
+ }
179
+ if (files.length) {
180
+ const fileDir = `${layout.paths.sharedDir}/${target}/files/${mailId}`
181
+ await pan.mkdir(fileDir)
182
+ for (const f of files) await pan.put(f, `${fileDir}/${path.basename(f)}`)
183
+ }
184
+ const tmp = path.join(localDir, 'outgoing')
185
+ fs.mkdirSync(tmp, { recursive: true })
186
+ const localJson = path.join(tmp, `${mailId}.json`)
187
+ fs.writeFileSync(localJson, JSON.stringify(envelope, null, 2), 'utf8')
188
+ await pan.put(localJson, `${inboxDir}/${mailId}.json`)
189
+ const state = loadState(stateFile)
190
+ state.sent = (state.sent || []).concat([{ id: mailId, at: nowIso(), device: target, to: envelope.to, files: envelope.files.length }]).slice(-200)
191
+ saveState(stateFile, { ...state, at: Date.now() })
192
+ return { ok: true, mailId, device: target, to: envelope.to, files: envelope.files.length, path: `${inboxDir}/${mailId}.json` }
193
+ }
194
+
195
+ return { poll, sendEnvelope, stats: () => ({ ...stats }), localDir, stateFile, sha256 }
196
+ }
@@ -0,0 +1,188 @@
1
+ // 会话备份(0.3.39):把本机会话数据按「字节增量」备份进
2
+ // <账号>/<设备>/sessions/<会话>/part-NNNN.bin + _session.json(水位标注)
3
+ // <账号>/<设备>/attachments/<sha256>(附件内容寻址,天然去重)
4
+ // transcript 是**多帧 zstd**,解压要流式处理,所以这里备份原始字节、不解压;
5
+ // 下次备份从 _session.json 里记的 bytes 之后继续传(用户要求「备份完加标注」)。
6
+ import fs from 'node:fs'
7
+ import path from 'node:path'
8
+ import crypto from 'node:crypto'
9
+
10
+ const sha256 = (buf) => crypto.createHash('sha256').update(buf).digest('hex')
11
+
12
+ function loadState(file) {
13
+ try {
14
+ const j = JSON.parse(fs.readFileSync(file, 'utf8'))
15
+ return { at: j.at || 0, sessions: j.sessions || {}, attachments: j.attachments || {} }
16
+ } catch {
17
+ return { at: 0, sessions: {}, attachments: {} }
18
+ }
19
+ }
20
+
21
+ function saveState(file, state) {
22
+ try {
23
+ fs.mkdirSync(path.dirname(file), { recursive: true })
24
+ fs.writeFileSync(file, JSON.stringify(state, null, 2), 'utf8')
25
+ } catch { /* ignore */ }
26
+ }
27
+
28
+ /** 列出 <sessionsRoot>/<workspaceKey>/<sessionId>/session.v4.jsonl.zstd */
29
+ export function listTranscripts(sessionsRoot) {
30
+ const out = []
31
+ let wsKeys = []
32
+ try { wsKeys = fs.readdirSync(sessionsRoot, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name) } catch { return out }
33
+ for (const ws of wsKeys) {
34
+ const wsDir = path.join(sessionsRoot, ws)
35
+ let ids = []
36
+ try { ids = fs.readdirSync(wsDir, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name) } catch { continue }
37
+ for (const id of ids) {
38
+ const dir = path.join(wsDir, id)
39
+ let files = []
40
+ try { files = fs.readdirSync(dir) } catch { continue }
41
+ const file = files.find((f) => f === 'session.v4.jsonl.zstd') || files.find((f) => f.endsWith('.jsonl.zstd'))
42
+ if (!file) continue
43
+ const full = path.join(dir, file)
44
+ let st = null
45
+ try { st = fs.statSync(full) } catch { continue }
46
+ out.push({ sessionId: id, workspaceKey: ws, dir, file: full, size: st.size, mtimeMs: st.mtimeMs })
47
+ }
48
+ }
49
+ return out
50
+ }
51
+
52
+ function partName(n) {
53
+ return `part-${String(n).padStart(4, '0')}.bin`
54
+ }
55
+
56
+ /**
57
+ * 找会话显示名:names 的键可能是完整 sessionId,也可能是 8 位短号
58
+ * (Term.sessionNames() 用短号),两边都试一遍。
59
+ */
60
+ function pickName(names, sessionId) {
61
+ if (!names) return null
62
+ if (names[sessionId]) return names[sessionId]
63
+ const bare = String(sessionId).replace(/^session-/, '')
64
+ const short = bare.slice(0, 8)
65
+ return names[short] || names[bare] || null
66
+ }
67
+
68
+ /**
69
+ * 备份会话(增量)
70
+ * @param {object} opts
71
+ * @param {object} opts.pan 云盘客户端
72
+ * @param {object} opts.layout ensureLayout() 的返回值
73
+ * @param {string} opts.sessionsRoot 例如 C:\Users\x\.dsh\sessions
74
+ * @param {string} opts.tmpDir 上传中转目录(本地)
75
+ * @param {string} opts.stateFile 水位/去重状态文件(本地)
76
+ * @param {Function} [opts.log]
77
+ * @param {number} [opts.maxSessions] 单轮最多处理多少个会话
78
+ * @param {object} [opts.names] sessionId → { name, cwd }
79
+ */
80
+ export async function backupSessions({ pan, layout, sessionsRoot, tmpDir, stateFile, log = () => {}, maxSessions = 40, names = {} }) {
81
+ const state = loadState(stateFile)
82
+ const remoteRoot = layout.paths.sessionDir
83
+ const all = listTranscripts(sessionsRoot)
84
+ const result = { ok: true, total: all.length, uploaded: 0, skipped: 0, bytes: 0, sessions: [], error: '' }
85
+ fs.mkdirSync(tmpDir, { recursive: true })
86
+ let handled = 0
87
+
88
+ for (const item of all) {
89
+ if (handled >= maxSessions) break
90
+ const prev = state.sessions[item.sessionId] || { bytes: 0, parts: 0 }
91
+ if (prev.bytes === item.size) { result.skipped += 1; continue }
92
+ handled += 1
93
+ try {
94
+ const remoteDir = `${remoteRoot}/${item.sessionId}`
95
+ await pan.mkdir(remoteDir)
96
+ const grew = item.size > prev.bytes && prev.bytes > 0
97
+ let from = grew ? prev.bytes : 0
98
+ let partNo = grew ? Number(prev.parts || 1) + 1 : 1
99
+ const fd = fs.openSync(item.file, 'r')
100
+ const len = item.size - from
101
+ const buf = Buffer.alloc(len)
102
+ try { fs.readSync(fd, buf, 0, len, from) } finally { fs.closeSync(fd) }
103
+ const name = grew ? partName(partNo) : `full-${Date.now()}.bin`
104
+ const local = path.join(tmpDir, name)
105
+ fs.writeFileSync(local, buf)
106
+ const up = await pan.put(local, `${remoteDir}/${name}`)
107
+ try { fs.rmSync(local, { force: true }) } catch { /* ignore */ }
108
+ const chunks = grew ? (prev.chunks || []).concat([{ name, from, to: item.size, sha256: sha256(buf) }]) : [{ name, from: 0, to: item.size, sha256: sha256(buf) }]
109
+ const known = pickName(names, item.sessionId) || {}
110
+ const marker = {
111
+ sessionId: item.sessionId,
112
+ workspaceKey: item.workspaceKey,
113
+ name: known.name || prev.name || '',
114
+ cwd: known.cwd || prev.cwd || '',
115
+ bytes: item.size,
116
+ parts: chunks.length,
117
+ chunks,
118
+ size: item.size,
119
+ mtimeMs: item.mtimeMs,
120
+ updatedAt: new Date().toISOString(),
121
+ note: 'bytes = 已备份到的字节位置;下次只传 bytes 之后的新内容',
122
+ }
123
+ await pan.putBuffer(Buffer.from(JSON.stringify(marker, null, 2), 'utf8'), `${remoteDir}/_session.json`)
124
+ state.sessions[item.sessionId] = { bytes: item.size, parts: chunks.length, chunks, name: marker.name, cwd: marker.cwd, updatedAt: marker.updatedAt, fs_id: up.fs_id }
125
+ result.uploaded += 1
126
+ result.bytes += buf.length
127
+ result.sessions.push({ sessionId: item.sessionId, name: marker.name, bytes: buf.length, from, part: name })
128
+ saveState(stateFile, { ...state, at: Date.now() })
129
+ } catch (e) {
130
+ result.ok = false
131
+ result.error = `${item.sessionId}: ${(e && e.message) || String(e)}`
132
+ log(`会话备份失败 ${item.sessionId}:${result.error}`)
133
+ break
134
+ }
135
+ }
136
+ saveState(stateFile, { ...state, at: Date.now() })
137
+ return result
138
+ }
139
+
140
+ /**
141
+ * 备份附件(内容寻址,按 sha256 文件名去重;单轮有上限,剩下的下一轮继续)
142
+ */
143
+ export async function backupAttachments({ pan, layout, objectsRoot, stateFile, log = () => {}, maxFiles = 40 }) {
144
+ const state = loadState(stateFile)
145
+ const remoteDir = layout.paths.attachmentDir
146
+ const result = { ok: true, total: 0, uploaded: 0, skipped: 0, bytes: 0, pending: 0, error: '' }
147
+ let files = []
148
+ try { files = fs.readdirSync(objectsRoot, { withFileTypes: true }).filter((d) => d.isFile()).map((d) => d.name) } catch { return result }
149
+ result.total = files.length
150
+ const todo = files.filter((f) => !state.attachments[f])
151
+ result.skipped = files.length - todo.length
152
+ result.pending = Math.max(0, todo.length - maxFiles)
153
+ if (!todo.length) return result
154
+ await pan.mkdir(remoteDir)
155
+ for (const name of todo.slice(0, maxFiles)) {
156
+ const full = path.join(objectsRoot, name)
157
+ try {
158
+ const st = fs.statSync(full)
159
+ await pan.put(full, `${remoteDir}/${name}`)
160
+ state.attachments[name] = { size: st.size, at: new Date().toISOString() }
161
+ result.uploaded += 1
162
+ result.bytes += st.size
163
+ saveState(stateFile, { ...state, at: Date.now() })
164
+ } catch (e) {
165
+ result.ok = false
166
+ result.error = `${name}: ${(e && e.message) || String(e)}`
167
+ log(`附件备份失败 ${name}:${result.error}`)
168
+ break
169
+ }
170
+ }
171
+ saveState(stateFile, { ...state, at: Date.now() })
172
+ return result
173
+ }
174
+
175
+ export function backupSummary(stateFile) {
176
+ const state = loadState(stateFile)
177
+ const sessions = Object.values(state.sessions)
178
+ return {
179
+ at: state.at || 0,
180
+ lastAt: state.at || 0,
181
+ sessions: sessions.length,
182
+ files: sessions.length,
183
+ lastCount: sessions.length,
184
+ bytes: sessions.reduce((a, s) => a + Number(s.bytes || 0), 0),
185
+ attachments: Object.keys(state.attachments).length,
186
+ attachmentBytes: Object.values(state.attachments).reduce((a, s) => a + Number(s.size || 0), 0),
187
+ }
188
+ }
package/lib/index.js CHANGED
@@ -36,6 +36,7 @@ import {
36
36
  import { deriveNameFromFile, findTranscript } from './names.js'
37
37
  import { createPan } from './cloud/pan.js'
38
38
  import { createCloud } from './cloud/sync.js'
39
+ import { createCloudHome } from './cloud/home.js'
39
40
  import { runSetup } from './cloud/setup.js'
40
41
  import { registerWeb } from './web.js'
41
42
 
@@ -730,6 +731,69 @@ class CrossTerm {
730
731
  return { ok: true, sessionId, files: landed }
731
732
  }
732
733
 
734
+ // ── 云端中转站(0.3.39)────────────────────────────────────────────────
735
+
736
+ /** 按会话名(也接受短号/完整 id)找本终端里的会话 */
737
+ resolveByName(raw) {
738
+ const s = String(raw || '').trim()
739
+ if (!s) return ''
740
+ const lower = s.toLowerCase()
741
+ const list = this.sessions()
742
+ const exact = list.find((x) => [x.name, x.title, x.short, x.id].some((v) => String(v || '').trim().toLowerCase() === lower))
743
+ if (exact) return exact.id
744
+ const part = list.find((x) => [x.name, x.title].some((v) => String(v || '').trim().toLowerCase().includes(lower)))
745
+ if (part) return part.id
746
+ return this.resolveSessionId(s) || ''
747
+ }
748
+
749
+ /** 中转站投递:把云端收件箱里的一封信投进本机对应会话(按收件人会话名) */
750
+ async relayDeliver({ to, envelope = {}, files = [] }) {
751
+ const targets = (Array.isArray(to) ? to : String(to || '').split(/[,,;;\s]+/))
752
+ .map((s) => String(s || '').trim())
753
+ .filter(Boolean)
754
+ if (!targets.length) return { ok: false, reason: '信封里没有收件人(要填会话名)' }
755
+ const controller = this.sessionController()
756
+ if (!controller || typeof controller.prompt !== 'function') {
757
+ return { ok: false, reason: '本 Host 没有 sessionController.prompt,中转站只能留件' }
758
+ }
759
+ const from = envelope.from || {}
760
+ const lines = ['[云端中转站] 收到 1 封']
761
+ lines.push(`来自:${from.name || from.nodeId || '未知设备'}${from.nodeId ? ` (${from.nodeId})` : ''}`)
762
+ if (envelope.subject) lines.push(`主题:${envelope.subject}`)
763
+ if (envelope.text) lines.push('', String(envelope.text).trim())
764
+ const landed = files.filter((f) => f && f.local)
765
+ if (landed.length) {
766
+ lines.push('', '附件已保存到本机:')
767
+ for (const f of landed) lines.push(`- ${f.local}(${formatBytes(f.bytes || 0)})`)
768
+ }
769
+ const bad = files.filter((f) => !f || !f.local)
770
+ if (bad.length) lines.push('', `(有 ${bad.length} 个附件没下载下来:${bad.map((f) => (f && f.name) || '未知').join('、')})`)
771
+ lines.push('', `(这封信由云端中转站投递;收件人:${targets.join('、')}。回复用 term_send。)`)
772
+ const text = lines.join('\n')
773
+ const delivered = []
774
+ const failed = []
775
+ for (const target of targets) {
776
+ const sessionId = this.resolveByName(target)
777
+ if (!sessionId) { failed.push({ target, reason: '本机没有这个会话' }); continue }
778
+ try {
779
+ await this.promptSession(controller, { id: `relay-${Date.now()}-${shortId(sessionId)}` }, sessionId, text)
780
+ delivered.push(sessionId)
781
+ await this.note(`relay delivered -> ${this.labelOf(sessionId)}`)
782
+ } catch (e) {
783
+ failed.push({ target, reason: (e && e.message) || String(e) })
784
+ }
785
+ }
786
+ if (!delivered.length) return { ok: false, reason: `投递失败:${failed.map((f) => `${f.target}(${f.reason})`).join('、')}` }
787
+ return { ok: true, sessionId: delivered[0], delivered, failed }
788
+ }
789
+
790
+ /** 给云端备份用的「会话短号 → 显示名」表 */
791
+ sessionNames() {
792
+ const out = {}
793
+ for (const s of this.sessions()) out[s.short] = { name: s.name || '', cwd: s.cwd || '' }
794
+ return out
795
+ }
796
+
733
797
  // ── 会话链(一串特征码链接多个会话)────────────────────────────────────
734
798
 
735
799
  async loadLinks() {
@@ -1161,10 +1225,42 @@ class CrossTerm {
1161
1225
  })
1162
1226
  this.cloud.start()
1163
1227
  void this.note(`cloud sync 启动 → ${this.cfg.cloudRoot}(${this.cfg.cloudIntervalMs}ms)`)
1228
+ this.enableCloudHome(pan)
1164
1229
  return this.cloud
1165
1230
  }
1166
1231
 
1232
+ /** 云端目录(账号/设备/共享收件箱)+ 中转站 + 会话备份,见 lib/cloud/home.js */
1233
+ enableCloudHome(pan) {
1234
+ if (this.home) return this.home
1235
+ try {
1236
+ this.home = createCloudHome({
1237
+ cfg: this.cfg,
1238
+ device: {
1239
+ nodeId: this.nodeId,
1240
+ name: this.cfg.name,
1241
+ version: VERSION,
1242
+ platform: process.platform,
1243
+ sessions: this.sessions().length,
1244
+ },
1245
+ pan,
1246
+ busDir: this.bus.root,
1247
+ deliver: (opts) => this.relayDeliver(opts),
1248
+ names: () => this.sessionNames(),
1249
+ log: (m) => void this.note('cloud: ' + m),
1250
+ })
1251
+ this.home.start()
1252
+ } catch (e) {
1253
+ this.home = null
1254
+ void this.warn('云端目录启动失败:' + ((e && e.message) || String(e)))
1255
+ }
1256
+ return this.home
1257
+ }
1258
+
1167
1259
  disableCloud() {
1260
+ if (this.home) {
1261
+ this.home.stop()
1262
+ this.home = null
1263
+ }
1168
1264
  if (!this.cloud) return false
1169
1265
  this.cloud.stop()
1170
1266
  this.cloud = null
@@ -1196,6 +1292,7 @@ class CrossTerm {
1196
1292
  if (this.timer) clearInterval(this.timer)
1197
1293
  this.timer = undefined
1198
1294
  if (this.cloud) { this.cloud.stop(); this.cloud = null }
1295
+ if (this.home) { this.home.stop(); this.home = null }
1199
1296
  await this.bus.leave(this.nodeId).catch(() => {})
1200
1297
  await this.note('stopped')
1201
1298
  }
@@ -1617,14 +1714,33 @@ function buildTools(term, agent, part = 'all') {
1617
1714
 
1618
1715
  const cloud = defineTool({
1619
1716
  name: 'term_cloud',
1620
- description: '云总线同步:查看/开启/关闭本机总线与云端目录的镜像同步,或立刻同步一次;setup 可一键配置自己的云盘(弹出记事本填 AppKey/AppSecret → 浏览器授权 → 自动开启同步)。',
1717
+ description: '云总线同步:查看/开启/关闭本机总线与云端目录的镜像同步,或立刻同步一次;setup 可一键配置自己的云盘(弹出记事本填 AppKey/AppSecret → 浏览器授权 → 自动开启同步);home/layout/relay/backup 管理云端目录(账号/设备/共享收件箱/中转站/会话备份)。',
1621
1718
  parameters: {
1622
- action: { type: 'string', enum: ['status', 'start', 'stop', 'once', 'setup'], description: 'status=查看状态;start=开启(本进程内生效);stop=关闭;once=立刻同步一次;setup=一键配置自己的云盘' },
1719
+ action: { type: 'string', enum: ['status', 'start', 'stop', 'once', 'setup', 'home', 'layout', 'relay', 'backup'], description: 'status=查看状态;start=开启(本进程内生效);stop=关闭;once=立刻同步一次;setup=一键配置自己的云盘;home=云端目录/中转站/备份概况;layout=重建云端目录;relay=立刻收一轮云端收件箱;backup=立刻备份一轮会话' },
1623
1720
  code: { type: 'string', description: '仅 setup 用:授权回调 URL 里的 code(浏览器没自动回调时手动粘贴);不填则插件自己起本地回调等待' },
1624
1721
  },
1625
1722
  output: {
1626
1723
  schema: { type: 'json' },
1627
1724
  render: textRender((v) => {
1725
+ if (v && v.home) {
1726
+ const h = v.home
1727
+ const lines = []
1728
+ if (h.layout) {
1729
+ lines.push(`云端账号:${h.layout.account}(角色 ${h.layout.role === 'admin' ? '管理员' : '成员'},设备 ${h.layout.deviceId})`)
1730
+ const p = h.layout.paths || {}
1731
+ lines.push(`设备目录:${p.deviceDir || '(未建)'}`)
1732
+ lines.push(`我的收件箱:${p.inboxDir || '(未建)'}`)
1733
+ lines.push(`会话备份:${p.sessionDir || '(未建)'}(${h.backup?.files || 0} 个会话,已备份 ${formatBytes(h.backup?.bytes || 0)})`)
1734
+ } else {
1735
+ lines.push('云端目录:还没建好(可能没连云盘,或建目录失败)')
1736
+ }
1737
+ if (h.relay) lines.push(`中转站:已收 ${h.relay.processed || 0} 封,失败 ${h.relay.failed || 0} 封,每 ${Math.round((h.relay.intervalMs || 0) / 1000)} s 查一次`)
1738
+ if (h.backup?.lastAt) lines.push(`上次备份:${new Date(h.backup.lastAt).toISOString()}(${h.backup.lastCount || 0} 个会话)`)
1739
+ if (v.layout) lines.push(`目录动作:${v.layout.created?.length ? '新建 ' + v.layout.created.join('、') : '已存在'}`)
1740
+ if (v.relay) lines.push(`本轮收信:拉取 ${v.relay.fetched || 0} 封,投递 ${v.relay.delivered || 0},失败 ${v.relay.failed || 0}`)
1741
+ if (v.backup) lines.push(`本轮备份:会话 ${v.backup.sessions?.uploaded || 0},附件 ${v.backup.attachments?.uploaded || 0},耗时 ${v.backup.ms || 0} ms`)
1742
+ return lines.join('\n')
1743
+ }
1628
1744
  if (v && v.setup) {
1629
1745
  const s = v.setup
1630
1746
  const lines = []
@@ -1666,6 +1782,24 @@ function buildTools(term, agent, part = 'all') {
1666
1782
  term.enableCloud()
1667
1783
  return { ok: true, status: term.cloud ? term.cloud.status() : undefined, setup: ret }
1668
1784
  }
1785
+ else if (action === 'home' || action === 'layout' || action === 'relay' || action === 'backup') {
1786
+ if (!term.cloud) term.enableCloud()
1787
+ const h = term.home
1788
+ if (!h) return { ok: false, detail: '云同步没开(先 action="start",或在设置里开云同步)' }
1789
+ if (action === 'layout') {
1790
+ const layout = await h.getLayout(true)
1791
+ return { ok: !!(layout && layout.paths), home: h.status(), layout }
1792
+ }
1793
+ if (action === 'relay') {
1794
+ const relay = await h.relayNow()
1795
+ return { ok: relay.ok !== false, relay, home: h.status() }
1796
+ }
1797
+ if (action === 'backup') {
1798
+ const backup = await h.backupNow()
1799
+ return { ok: backup.ok !== false, backup, home: h.status() }
1800
+ }
1801
+ return { ok: true, home: h.status() }
1802
+ }
1669
1803
  else if (action === 'once') {
1670
1804
  const c = term.cloud || term.enableCloud()
1671
1805
  const result = await c.once()
package/lib/web.js CHANGED
@@ -258,6 +258,8 @@ function statusOf(term) {
258
258
  provider,
259
259
  },
260
260
  chains,
261
+ // 云端目录(0.3.39):账号/角色/设备目录/中转站/会话备份
262
+ home: term.home ? term.home.status() : null,
261
263
  }
262
264
  }
263
265
 
@@ -316,6 +318,70 @@ export function registerWeb(ctx, term, log) {
316
318
  },
317
319
  }),
318
320
  )
321
+ disposers.push(
322
+ webCtx.webServer.register({
323
+ kind: 'exact',
324
+ path: '/crossterm/api/home',
325
+ handler: async (req, res) => {
326
+ const cfg = term.cfg || {}
327
+ if (!statusOf(term).cloud.configured) {
328
+ json(res, 200, { ok: false, configured: false, message: '这台机器还没连接云盘:先「配置云盘」' })
329
+ return
330
+ }
331
+ try {
332
+ if (!term.home) term.enableCloud()
333
+ const home = term.home
334
+ if (!home) {
335
+ json(res, 200, { ok: false, configured: true, message: '云端目录还没启动(云同步可能被关掉了)' })
336
+ return
337
+ }
338
+ const url = String((req && req.url) || '')
339
+ const action = (() => {
340
+ try { return String(new URL(url, 'http://x').searchParams.get('action') || '') } catch { return '' }
341
+ })()
342
+ if (action === 'layout') {
343
+ const layout = await home.getLayout(true)
344
+ log(`home: 云端目录 ${layout && layout.paths ? layout.paths.deviceDir : '未建好'}`)
345
+ json(res, 200, { ok: !!(layout && layout.paths), home: home.status(), layout })
346
+ return
347
+ }
348
+ if (action === 'relay') {
349
+ const relay = await home.relayNow()
350
+ json(res, 200, { ok: relay.ok !== false, relay, home: home.status() })
351
+ return
352
+ }
353
+ if (action === 'backup') {
354
+ const backup = await home.backupNow()
355
+ json(res, 200, { ok: backup.ok !== false, backup, home: home.status() })
356
+ return
357
+ }
358
+ json(res, 200, { ok: true, configured: true, home: home.status(), cloudRoot: cfg.cloudRoot || '' })
359
+ } catch (error) {
360
+ json(res, 200, { ok: false, configured: true, message: String((error && error.message) || error) })
361
+ }
362
+ },
363
+ }),
364
+ )
365
+ disposers.push(
366
+ webCtx.webServer.register({
367
+ kind: 'exact',
368
+ path: '/crossterm/api/backup',
369
+ handler: async (req, res) => {
370
+ try {
371
+ if (!term.home) term.enableCloud()
372
+ if (!term.home) {
373
+ json(res, 200, { ok: false, message: '云端目录还没启动(先在卡片上打开云同步)' })
374
+ return
375
+ }
376
+ const result = await term.home.backupNow()
377
+ log(`backup: 会话 ${result.sessions ? result.sessions.uploaded : 0},附件 ${result.attachments ? result.attachments.uploaded : 0}`)
378
+ json(res, 200, { ok: result.ok !== false, backup: result, home: term.home.status() })
379
+ } catch (error) {
380
+ json(res, 200, { ok: false, message: String((error && error.message) || error) })
381
+ }
382
+ },
383
+ }),
384
+ )
319
385
  disposers.push(
320
386
  webCtx.webServer.register({
321
387
  kind: 'exact',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-crossterm",
3
- "version": "0.3.38",
3
+ "version": "0.3.39",
4
4
  "description": "DSH 跨终端互联:同一账号下的多个 DSH 终端/进程互发邮件与文件(主题内联、正文落盘,收件方自己读)",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",