dsh-crossterm 0.3.37 → 0.3.39
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +42 -0
- package/client/client.js +118 -0
- package/lib/bus.js +67 -6
- package/lib/cloud/devices.js +95 -0
- package/lib/cloud/home.js +152 -0
- package/lib/cloud/layout.js +190 -0
- package/lib/cloud/pan.js +62 -6
- package/lib/cloud/relay.js +196 -0
- package/lib/cloud/session-backup.js +188 -0
- package/lib/cloud/sync.js +5 -1
- package/lib/index.js +136 -2
- package/lib/web.js +105 -0
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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
|
+
}
|