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.
@@ -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'
@@ -73,14 +73,56 @@ export function createPan({ appFile, authFile, log = () => {} }) {
73
73
  return api(`https://pan.baidu.com/rest/2.0/xpan/nas?method=uinfo&access_token=${encodeURIComponent(tok)}`)
74
74
  }
75
75
 
76
+ async function lsPage(dir, start, limit) {
77
+ await ensureToken()
78
+ return api(`https://pan.baidu.com/rest/2.0/xpan/file?method=list&access_token=${encodeURIComponent(tok)}&dir=${encodeURIComponent(dir)}&order=time&start=${start}&limit=${limit}`)
79
+ }
80
+
76
81
  async function ls(dir = '/') {
82
+ return lsPage(dir, 0, 200)
83
+ }
84
+
85
+ // 一个目录超过 200 个文件时必须翻页,否则第 201 个之后永远看不见
86
+ // (presence/ 里被 .tmp 残骸占满时,真实 node json 就躲在第二页)。
87
+ async function lsAll(dir = '/', pageSize = 200) {
88
+ const all = []
89
+ for (let page = 0; page < 50; page += 1) {
90
+ const j = await lsPage(dir, page * pageSize, pageSize)
91
+ if (j.errno !== 0) return j
92
+ const list = j.list || []
93
+ all.push(...list)
94
+ if (list.length < pageSize) return { ...j, list: all, errno: 0 }
95
+ }
96
+ return { errno: 0, list: all }
97
+ }
98
+
99
+ // 建目录:xpan 的 create 带 isdir=1;已存在(31061 / -8)视为成功
100
+ async function mkdir(remotePath) {
77
101
  await ensureToken()
78
- return api(`https://pan.baidu.com/rest/2.0/xpan/file?method=list&access_token=${encodeURIComponent(tok)}&dir=${encodeURIComponent(dir)}&order=time&start=0&limit=200`)
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 }
79
115
  }
80
116
 
81
- async function put(localPath, remotePath) {
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) {
82
125
  await ensureToken()
83
- const buf = await readFile(localPath)
84
126
  const md5 = md5hex(buf)
85
127
  const size = buf.length
86
128
  const pre = await api(
@@ -119,11 +161,25 @@ export function createPan({ appFile, authFile, log = () => {} }) {
119
161
  return { path: remotePath, size, md5: upj.md5, fs_id: cr.fs_id }
120
162
  }
121
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
+
122
178
  async function get(remotePath, localPath) {
123
179
  await ensureToken()
124
180
  const dir = path.posix.dirname(remotePath)
125
181
  const name = path.posix.basename(remotePath)
126
- const list = await ls(dir)
182
+ const list = await lsAll(dir)
127
183
  if (list.errno !== 0) throw new Error('列目录失败:' + JSON.stringify(list))
128
184
  const hit = (list.list || []).find((f) => f.server_filename === name && f.isdir === 0)
129
185
  if (!hit) throw new Error('远端没有该文件:' + remotePath)
@@ -148,5 +204,5 @@ export function createPan({ appFile, authFile, log = () => {} }) {
148
204
  )
149
205
  }
150
206
 
151
- return { uinfo, ls, put, get, rm, ensureToken }
207
+ return { uinfo, ls, lsAll, mkdir, exists, put, putBuffer, get, getBuffer, rm, ensureToken }
152
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
+ }