dsh-crossterm 0.3.37 → 0.3.38

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -244,6 +244,19 @@ dsh plugin --profile desktop remove dsh-crossterm
244
244
 
245
245
  > 插件、npm 包、B 机安装包**都不携带任何凭证**;换机器要各自配自己的。
246
246
 
247
+ ### 看有哪些机器连着(0.3.38)
248
+
249
+ 插件页卡片展开后有「云端设备」一行,也可以直接 `GET /crossterm/api/devices`:
250
+
251
+ - 数据来自云端总线的 `presence/*.json`(每台机器 10 秒一次心跳),字段含名字、nodeId、版本、平台、会话数、入链;
252
+ - 最后心跳 3 分钟内的算「在线」,其余显示「N 分钟前」;
253
+ - 本机永远在列表里(标「(本机)」),取自本地 presence,不依赖云端往返;
254
+ - 没配云盘的机器只显示「未连接云盘」,不会假装有别的设备。
255
+
256
+ > 0.3.38 之前这里常常显示「云端还没有其他设备」:云端 `presence/` 里堆了 220 多个 `.tmp`
257
+ > 残骸,而百度网盘列目录一页只给 200 条 —— 真实 json 被挤到了第二页。现在列目录会翻页,
258
+ > 云端也不再上传 `.tmp`。
259
+
247
260
  ### 手动 / 自动注入开关(三层)
248
261
 
249
262
  | 层 | 开关 | 粒度 |
package/client/client.js CHANGED
@@ -131,11 +131,37 @@ window.__ModuleLoader__.load({
131
131
  );
132
132
  }
133
133
 
134
+ function agoText(ts) {
135
+ if (!ts) return "未知";
136
+ const mins = Math.round((Date.now() - ts) / 60000);
137
+ if (mins <= 0) return "刚刚";
138
+ if (mins < 60) return mins + " 分钟前";
139
+ const hours = Math.round(mins / 60);
140
+ if (hours < 24) return hours + " 小时前";
141
+ return Math.round(hours / 24) + " 天前";
142
+ }
143
+
144
+ function devicesText(status, devices) {
145
+ if (!(status && status.cloud && status.cloud.configured)) return "未连接云盘";
146
+ if (!devices || devices.loading) return "读取中…";
147
+ if (!devices.ok) return devices.message || "读取失败";
148
+ const list = devices.devices || [];
149
+ if (!list.length) return "云端还没有其他设备";
150
+ return list
151
+ .map((d) => {
152
+ const label = (d.name || d.nodeId || "未知设备") + (d.nodeId === devices.me ? "(本机)" : "");
153
+ const state = d.online ? "在线" : agoText(d.updatedAt);
154
+ return label + " · " + state + (d.version ? " · v" + d.version : "");
155
+ })
156
+ .join(";");
157
+ }
158
+
134
159
  function Card() {
135
160
  const [open, setOpen] = React.useState(false);
136
161
  const [status, setStatus] = React.useState(null);
137
162
  const [busy, setBusy] = React.useState("");
138
163
  const [msg, setMsg] = React.useState("");
164
+ const [devices, setDevices] = React.useState(null);
139
165
 
140
166
  const load = React.useCallback(() => {
141
167
  call("/crossterm/api/status")
@@ -150,6 +176,21 @@ window.__ModuleLoader__.load({
150
176
  load();
151
177
  }, [load]);
152
178
 
179
+ const loadDevices = React.useCallback(async () => {
180
+ setDevices({ loading: true, devices: [] });
181
+ try {
182
+ const data = await call("/crossterm/api/devices");
183
+ setDevices({ loading: false, ...(data || {}) });
184
+ } catch (error) {
185
+ setDevices({ loading: false, ok: false, devices: [], message: String((error && error.message) || error) });
186
+ }
187
+ }, []);
188
+
189
+ // 展开时自动读一次云端设备(收起不读,避免每次进页面都等网盘)
190
+ React.useEffect(() => {
191
+ if (open && !devices) void loadDevices();
192
+ }, [open, devices, loadDevices]);
193
+
153
194
  const onCheck = async () => {
154
195
  setBusy("check");
155
196
  setMsg("正在检查更新…");
@@ -265,6 +306,12 @@ window.__ModuleLoader__.load({
265
306
  label: "当前终端",
266
307
  value: status ? `${status.node}(${status.nodeId})· ${status.hostname}` : "",
267
308
  }),
309
+ h(
310
+ "div",
311
+ { style: S.grid },
312
+ h("div", { style: S.key }, "云端设备"),
313
+ h("div", { style: S.val }, devicesText(status, devices)),
314
+ ),
268
315
  h(
269
316
  "div",
270
317
  { style: S.grid },
@@ -318,6 +365,7 @@ window.__ModuleLoader__.load({
318
365
  { style: S.actions },
319
366
  h("button", { style: S.btn, onClick: onCheck, disabled: busy !== "" }, busy === "check" ? "检查中…" : "检查更新"),
320
367
  h("button", { style: S.btnPrimary, onClick: onUpdate, disabled: busy !== "" }, busy === "update" ? "更新中…" : "更新"),
368
+ h("button", { style: S.btn, onClick: () => void loadDevices(), disabled: Boolean(devices && devices.loading) }, devices && devices.loading ? "读取中…" : "刷新设备"),
321
369
  ),
322
370
  msg ? h("div", { style: S.msg }, msg) : null,
323
371
  ),
package/lib/bus.js CHANGED
@@ -22,7 +22,7 @@ import os from 'node:os'
22
22
  import path from 'node:path'
23
23
  import crypto from 'node:crypto'
24
24
 
25
- export const VERSION = '0.3.37'
25
+ export const VERSION = '0.3.38'
26
26
 
27
27
  /** DSH 的家目录:与宿主其它状态(sessions / profiles)同一个根,多终端天然共享 */
28
28
  export function dshHome() {
@@ -203,8 +203,9 @@ export function formatBytes(n) {
203
203
  export async function writeJsonAtomic(file, value) {
204
204
  await fsp.mkdir(path.dirname(file), { recursive: true })
205
205
  const tmp = `${file}.${process.pid}.${crypto.randomBytes(4).toString('hex')}.tmp`
206
- await fsp.writeFile(tmp, JSON.stringify(value, null, 2), 'utf8')
207
- await fsp.rename(tmp, file)
206
+ const data = JSON.stringify(value, null, 2)
207
+ await fsp.writeFile(tmp, data, 'utf8')
208
+ await renameWithRetry(tmp, file, data)
208
209
  }
209
210
 
210
211
  /** 原子写文本:同上,用于邮件正文 */
@@ -212,7 +213,53 @@ export async function writeFileAtomic(file, text) {
212
213
  await fsp.mkdir(path.dirname(file), { recursive: true })
213
214
  const tmp = `${file}.${process.pid}.${crypto.randomBytes(4).toString('hex')}.tmp`
214
215
  await fsp.writeFile(tmp, text, 'utf8')
215
- await fsp.rename(tmp, file)
216
+ await renameWithRetry(tmp, file, text)
217
+ }
218
+
219
+ // Windows 上目标文件被别的进程(读方、杀毒、同步盘)短暂占用时 rename 会 EPERM/EBUSY,
220
+ // 以前就这么把 .tmp 漏在原地:presence/ 攒了两百多个。这里重试几次,最后退化成直接覆盖,
221
+ // 无论成败都把 tmp 删掉,绝不留垃圾。
222
+ async function renameWithRetry(tmp, file, data) {
223
+ for (let attempt = 0; attempt < 5; attempt++) {
224
+ try {
225
+ await fsp.rename(tmp, file)
226
+ return
227
+ } catch (error) {
228
+ const code = error && error.code
229
+ if (!['EPERM', 'EBUSY', 'EACCES', 'ENOENT'].includes(code)) throw error
230
+ await new Promise((resolve) => setTimeout(resolve, 15 * (attempt + 1)))
231
+ }
232
+ }
233
+ await fsp.writeFile(file, data, 'utf8')
234
+ await fsp.rm(tmp, { force: true }).catch(() => {})
235
+ }
236
+
237
+ /** 清掉总线目录里超过 maxAgeMs 的 .tmp 残骸(原子写的漏网之鱼) */
238
+ export async function sweepTmp(root, maxAgeMs = 10 * 60 * 1000, dirs = ['presence', 'chains', 'links', 'inbox', 'mail']) {
239
+ const now = Date.now()
240
+ let removed = 0
241
+ for (const dir of dirs) {
242
+ const abs = path.join(root, dir)
243
+ let entries
244
+ try {
245
+ entries = await fsp.readdir(abs, { withFileTypes: true })
246
+ } catch {
247
+ continue
248
+ }
249
+ for (const entry of entries) {
250
+ if (!entry.isFile() || !entry.name.endsWith('.tmp')) continue
251
+ const target = path.join(abs, entry.name)
252
+ try {
253
+ const st = await fsp.stat(target)
254
+ if (now - st.mtimeMs < maxAgeMs) continue
255
+ await fsp.rm(target, { force: true })
256
+ removed += 1
257
+ } catch {
258
+ /* ignore */
259
+ }
260
+ }
261
+ }
262
+ return removed
216
263
  }
217
264
 
218
265
  export async function readJson(file) {
@@ -300,8 +347,14 @@ export class Bus {
300
347
  const file = this.mailFile(msg.id)
301
348
  fs.mkdirSync(path.dirname(file), { recursive: true })
302
349
  const tmp = `${file}.${process.pid}.${crypto.randomBytes(4).toString('hex')}.tmp`
303
- fs.writeFileSync(tmp, `${head.join('\n')}\n${body}\n`, 'utf8')
304
- fs.renameSync(tmp, file)
350
+ const text = `${head.join('\n')}\n${body}\n`
351
+ fs.writeFileSync(tmp, text, 'utf8')
352
+ try {
353
+ fs.renameSync(tmp, file)
354
+ } catch {
355
+ fs.writeFileSync(file, text, 'utf8')
356
+ try { fs.rmSync(tmp, { force: true }) } catch { /* ignore */ }
357
+ }
305
358
  return file
306
359
  }
307
360
 
@@ -0,0 +1,95 @@
1
+ import fsp from 'node:fs/promises'
2
+ import os from 'node:os'
3
+ import path from 'node:path'
4
+
5
+ const ONLINE_MS = 3 * 60 * 1000
6
+
7
+ function trimRoot(root) {
8
+ return String(root || '').replace(/\/+$/, '')
9
+ }
10
+
11
+ function summarize(data, now) {
12
+ const updatedAt = Number(data.updatedAt) || 0
13
+ return {
14
+ nodeId: String(data.nodeId || ''),
15
+ name: String(data.name || data.hostname || data.nodeId || ''),
16
+ hostname: String(data.hostname || ''),
17
+ version: String(data.version || ''),
18
+ platform: String(data.platform || ''),
19
+ pid: Number(data.pid) || 0,
20
+ sessions: Array.isArray(data.sessions) ? data.sessions.length : 0,
21
+ chains: Array.isArray(data.chains)
22
+ ? [...new Set(data.chains.map((c) => (typeof c === 'string' ? c : c && c.code)).filter(Boolean))]
23
+ : [],
24
+ updatedAt,
25
+ lastSeen: updatedAt ? new Date(updatedAt).toISOString() : '',
26
+ online: updatedAt > 0 && now - updatedAt < ONLINE_MS,
27
+ }
28
+ }
29
+
30
+ /**
31
+ * 云端设备列表:读 <cloudRoot>/presence/*.json(翻页列目录,跳过 .tmp 残骸),
32
+ * 逐个下载解析出 nodeId/名字/版本/最后心跳。
33
+ * 注意 cfg.cloudRoot 本身就是总线根(形如 /dsh云盘/crossterm/bus),不再拼一层 bus。
34
+ */
35
+ export async function listCloudDevices(pan, cloudRoot, opts = {}) {
36
+ const now = opts.now || Date.now()
37
+ const root = trimRoot(cloudRoot)
38
+ const dir = `${root}/presence`
39
+ const result = { ok: false, dir, devices: [], files: 0, tmpFiles: 0, error: '' }
40
+ if (!root) {
41
+ result.error = '还没配置云盘'
42
+ return result
43
+ }
44
+ let listing
45
+ try {
46
+ listing = await pan.lsAll(dir)
47
+ } catch (error) {
48
+ result.error = String((error && error.message) || error)
49
+ return result
50
+ }
51
+ if (!listing || listing.errno !== 0) {
52
+ // 4 = 目录不存在(还没同步过)
53
+ result.ok = true
54
+ result.error = listing && listing.errno === -9 ? '云端目录不存在' : ''
55
+ return result
56
+ }
57
+ const entries = listing.list || []
58
+ const jsonFiles = entries.filter((f) => f.isdir === 0 && f.server_filename.endsWith('.json'))
59
+ result.files = jsonFiles.length
60
+ result.tmpFiles = entries.filter((f) => f.isdir === 0 && f.server_filename.endsWith('.tmp')).length
61
+
62
+ const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'ct-devices-'))
63
+ try {
64
+ const byId = new Map()
65
+ for (const entry of jsonFiles) {
66
+ const name = entry.server_filename
67
+ const nodeId = name.slice(0, -'.json'.length)
68
+ const local = path.join(tmpDir, name)
69
+ try {
70
+ await pan.get(`${dir}/${name}`, local)
71
+ const data = JSON.parse(await fsp.readFile(local, 'utf8'))
72
+ if (!data || !data.nodeId) continue
73
+ byId.set(data.nodeId, summarize(data, now))
74
+ } catch {
75
+ /* 单个坏文件不影响整体 */
76
+ }
77
+ if (byId.size >= 50) break
78
+ }
79
+ result.devices = [...byId.values()].sort((a, b) => b.updatedAt - a.updatedAt)
80
+ result.ok = true
81
+ return result
82
+ } finally {
83
+ await fsp.rm(tmpDir, { recursive: true, force: true }).catch(() => {})
84
+ }
85
+ }
86
+
87
+ /** 本地自己的 presence(不依赖云端,用来兜底显示"我") */
88
+ export async function localDevice(presenceDir, nodeId, now = Date.now()) {
89
+ try {
90
+ const raw = await fsp.readFile(path.join(presenceDir, `${nodeId}.json`), 'utf8')
91
+ return summarize(JSON.parse(raw), now)
92
+ } catch {
93
+ return undefined
94
+ }
95
+ }
package/lib/cloud/pan.js CHANGED
@@ -73,9 +73,27 @@ 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 ls(dir = '/') {
76
+ async function lsPage(dir, start, limit) {
77
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=0&limit=200`)
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
+
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 }
79
97
  }
80
98
 
81
99
  async function put(localPath, remotePath) {
@@ -123,7 +141,7 @@ export function createPan({ appFile, authFile, log = () => {} }) {
123
141
  await ensureToken()
124
142
  const dir = path.posix.dirname(remotePath)
125
143
  const name = path.posix.basename(remotePath)
126
- const list = await ls(dir)
144
+ const list = await lsAll(dir)
127
145
  if (list.errno !== 0) throw new Error('列目录失败:' + JSON.stringify(list))
128
146
  const hit = (list.list || []).find((f) => f.server_filename === name && f.isdir === 0)
129
147
  if (!hit) throw new Error('远端没有该文件:' + remotePath)
@@ -148,5 +166,5 @@ export function createPan({ appFile, authFile, log = () => {} }) {
148
166
  )
149
167
  }
150
168
 
151
- return { uinfo, ls, put, get, rm, ensureToken }
169
+ return { uinfo, ls, lsAll, put, get, rm, ensureToken }
152
170
  }
package/lib/cloud/sync.js CHANGED
@@ -44,6 +44,9 @@ export function createCloud(opts) {
44
44
  if (!r.includes('/')) continue
45
45
  if (EXCLUDE_TOP.has(r)) continue
46
46
  }
47
+ // 原子写的临时文件(xxx.json.<pid>.<hex>.tmp)绝不能当数据同步,
48
+ // 以前漏过滤,云端 presence/ 里攒了两百多个残骸。
49
+ if (e.name.endsWith('.tmp')) continue
47
50
  let st
48
51
  try { st = fs.statSync(path.join(root, r)) } catch { continue }
49
52
  out.push({
@@ -62,10 +65,11 @@ export function createCloud(opts) {
62
65
  async function walkCloud() {
63
66
  const out = new Map()
64
67
  async function rec(dir, rel) {
65
- const j = await pan.ls(dir)
68
+ const j = await pan.lsAll(dir)
66
69
  if (j.errno !== 0) return
67
70
  for (const f of j.list || []) {
68
71
  if (EXCLUDE_DIRS.has(f.server_filename)) continue
72
+ if (f.server_filename.endsWith('.tmp')) continue
69
73
  const r = rel ? path.posix.join(rel, f.server_filename) : f.server_filename
70
74
  if (f.isdir === 1) { await rec(f.path, r); continue }
71
75
  out.set(r, { size: f.size, mtimeMs: (f.server_mtime || 0) * 1000, fs_id: f.fs_id })
package/lib/web.js CHANGED
@@ -5,6 +5,8 @@ import os from 'node:os'
5
5
  import path from 'node:path'
6
6
  import { spawn } from 'node:child_process'
7
7
  import { VERSION, readConfig, writeConfig } from './bus.js'
8
+ import { createPan } from './cloud/pan.js'
9
+ import { listCloudDevices, localDevice } from './cloud/devices.js'
8
10
  import { runSetup } from './cloud/setup.js'
9
11
 
10
12
  const PKG = 'dsh-crossterm'
@@ -277,6 +279,43 @@ export function registerWeb(ctx, term, log) {
277
279
  handler: (req, res) => json(res, 200, statusOf(term)),
278
280
  }),
279
281
  )
282
+ disposers.push(
283
+ webCtx.webServer.register({
284
+ kind: 'exact',
285
+ path: '/crossterm/api/devices',
286
+ handler: async (req, res) => {
287
+ const cfg = term.cfg || {}
288
+ const info = statusOf(term)
289
+ if (!info.cloud.configured) {
290
+ json(res, 200, {
291
+ ok: false,
292
+ configured: false,
293
+ devices: [],
294
+ message: '这台机器还没连接云盘,看不到其他设备',
295
+ })
296
+ return
297
+ }
298
+ try {
299
+ const pan = createPan({
300
+ appFile: cfg.baiduAppFile,
301
+ authFile: cfg.baiduAuthFile,
302
+ log: (m) => log('devices: ' + m),
303
+ })
304
+ const result = await listCloudDevices(pan, cfg.cloudRoot)
305
+ const me = await localDevice(path.join(cfg.busDir || '', 'presence'), info.nodeId || '')
306
+ if (me && !result.devices.some((d) => d.nodeId === me.nodeId)) result.devices.unshift(me)
307
+ json(res, 200, { ...result, configured: true, me: info.nodeId || '' })
308
+ } catch (error) {
309
+ json(res, 200, {
310
+ ok: false,
311
+ configured: true,
312
+ devices: [],
313
+ message: String((error && error.message) || error),
314
+ })
315
+ }
316
+ },
317
+ }),
318
+ )
280
319
  disposers.push(
281
320
  webCtx.webServer.register({
282
321
  kind: 'exact',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-crossterm",
3
- "version": "0.3.37",
3
+ "version": "0.3.38",
4
4
  "description": "DSH 跨终端互联:同一账号下的多个 DSH 终端/进程互发邮件与文件(主题内联、正文落盘,收件方自己读)",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",