@leiyi-works/dsh-cloud-sync 0.1.6 → 0.1.8

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/client.js CHANGED
@@ -65,6 +65,7 @@ window.__ModuleLoader__.load({
65
65
  lastSync: '上次同步',
66
66
  cloudSize: '云端占用',
67
67
  autoPlan: '自动同步',
68
+ transport: '传输通道',
68
69
  files: '个文件',
69
70
  never: '还没有',
70
71
  running: '正在同步',
@@ -90,6 +91,7 @@ window.__ModuleLoader__.load({
90
91
  lastSync: 'Last sync',
91
92
  cloudSize: 'Cloud usage',
92
93
  autoPlan: 'Auto sync',
94
+ transport: 'Transport',
93
95
  files: 'files',
94
96
  never: 'never',
95
97
  running: 'syncing',
@@ -254,6 +256,12 @@ window.__ModuleLoader__.load({
254
256
  ? t('off')
255
257
  : status?.config?.dailySync === true ? t('daily') : `${status.config.intervalMinutes} ${t('minutes')}`),
256
258
  h('span', { className: 'csyNote' }, status?.schedule?.nextPushAt === undefined ? '' : `${t('nextAt')} ${when(status.schedule.nextPushAt)}`)),
259
+ h('div', { className: 'csyCell' },
260
+ h('span', { className: 'csyCellName' }, t('transport')),
261
+ h('span', { className: 'csyCellValue' }, status?.transport?.mode === 'subprocess' ? 'subprocess' : 'child_process'),
262
+ h('span', { className: 'csyNote' }, status?.transport === undefined
263
+ ? ''
264
+ : `${status.transport.sshOk === true ? 'ssh ✓' : 'ssh ✗'} · ${status.transport.identitySource === 'guest' ? 'key(guest)' : status.transport.identityFile === '' ? 'key(default)' : status.transport.identityOk === true ? 'key ✓' : 'key ✗'}`)),
257
265
 
258
266
  Array.isArray(job?.log) && job.log.length > 0
259
267
  ? h('pre', { className: 'csyLog' }, job.log.slice(-12).join('\n'))
package/exec.js ADDED
@@ -0,0 +1,117 @@
1
+ import { spawn } from 'node:child_process'
2
+ import { EventEmitter } from 'node:events'
3
+
4
+ /**
5
+ * 命令执行层:优先走 DSH 的 `ctx.subprocess`(可移植层),
6
+ * 拿不到时退回 node:child_process。
7
+ *
8
+ * 为什么需要这一层:手机上(DSHM 鸿蒙外壳)应用沙箱不允许 Host 直接 fork/exec,
9
+ * `spawn('ssh')` 会 ENOENT;但 DSH 的 subprocess 服务可以被平台改派到自带的 guest 环境,
10
+ * 那里 ssh 可用。所以「能不能跑」取决于这里,而不是密钥或配置。
11
+ */
12
+
13
+ let provider = null
14
+
15
+ /** 由插件 apply() 调用,注入 ctx.subprocess(可能为 undefined)。 */
16
+ export function setSubprocessProvider(value) {
17
+ provider = value ?? null
18
+ }
19
+
20
+ export function hasSubprocessProvider() {
21
+ return provider !== null
22
+ }
23
+
24
+ /** 当前执行通道:subprocess(DSH 可移植层)/ child_process(Node 原生)。 */
25
+ export function transportMode() {
26
+ return provider === null ? 'child_process' : 'subprocess'
27
+ }
28
+
29
+ /** 在 provider 的执行世界里解析可执行文件路径;没有 provider 时原样返回。 */
30
+ export async function resolveCommand(command) {
31
+ if (provider === null) return command
32
+ try {
33
+ const resolved = await provider.resolveExecutable(command)
34
+ return typeof resolved === 'string' && resolved !== '' ? resolved : command
35
+ } catch {
36
+ return command
37
+ }
38
+ }
39
+
40
+ /** 伪造一个「启动失败」的 child,行为与 EventEmitter 版子进程一致。 */
41
+ function failedChild(error) {
42
+ const child = new EventEmitter()
43
+ child.stdin = new EventEmitter()
44
+ child.stdin.end = () => {}
45
+ child.stdin.write = () => true
46
+ child.stdout = new EventEmitter()
47
+ child.stderr = new EventEmitter()
48
+ child.kill = () => {}
49
+ setImmediate(() => {
50
+ child.emit('error', error instanceof Error ? error : new Error(String(error)))
51
+ child.emit('close', -1)
52
+ })
53
+ return child
54
+ }
55
+
56
+ /** 把 ctx.subprocess 的 handle 包成和 child_process 一样的接口。 */
57
+ class ManagedChild extends EventEmitter {
58
+ constructor(handle) {
59
+ super()
60
+ this.handle = handle
61
+ this.stdin = handle.stdin ?? null
62
+ this.stdout = handle.stdout ?? null
63
+ this.stderr = handle.stderr ?? null
64
+ this.killed = false
65
+ this.exitCode = null
66
+ handle.done.then(
67
+ (outcome) => {
68
+ this.exitCode = outcome && outcome.exitCode !== undefined ? outcome.exitCode : -1
69
+ this.emit('close', this.exitCode)
70
+ },
71
+ (error) => {
72
+ this.emit('error', error instanceof Error ? error : new Error(String(error)))
73
+ this.emit('close', -1)
74
+ },
75
+ )
76
+ // stdin 写失败不该冒泡成未捕获异常
77
+ if (this.stdin && typeof this.stdin.on === 'function') this.stdin.on('error', () => {})
78
+ }
79
+
80
+ kill() {
81
+ if (this.killed) return
82
+ this.killed = true
83
+ try {
84
+ this.handle.terminate()
85
+ } catch { /* 已经不在了 */ }
86
+ }
87
+ }
88
+
89
+ /**
90
+ * 启动一个受管子进程。
91
+ * @param {string[]} argv 完整命令行(argv[0] 为可执行文件)
92
+ * @param {{ cwd?: string, env?: Record<string,string>, graceMs?: number }} options
93
+ */
94
+ export function spawnManaged(argv, options = {}) {
95
+ const cwd = options.cwd ?? process.cwd()
96
+ const env = options.env ?? process.env
97
+ if (provider !== null) {
98
+ try {
99
+ const handle = provider.spawn({
100
+ argv,
101
+ cwd,
102
+ stdio: { stdin: 'pipe', stdout: 'pipe', stderr: 'pipe' },
103
+ graceMs: options.graceMs ?? 3000,
104
+ env,
105
+ })
106
+ return new ManagedChild(handle)
107
+ } catch (error) {
108
+ return failedChild(error)
109
+ }
110
+ }
111
+ return spawn(argv[0], argv.slice(1), {
112
+ windowsHide: true,
113
+ stdio: ['pipe', 'pipe', 'pipe'],
114
+ cwd,
115
+ env,
116
+ })
117
+ }
package/index.js CHANGED
@@ -31,6 +31,7 @@ import {
31
31
  readTombstones,
32
32
  defaultMachineId,
33
33
  isConfigured,
34
+ transportDiagnostics,
34
35
  } from './sync.js'
35
36
  import { setSubprocessProvider } from './exec.js'
36
37
 
@@ -347,6 +348,7 @@ async function buildStatus(config, runner) {
347
348
  },
348
349
  local: { ...local, deletedLocally },
349
350
  remote,
351
+ transport: await transportDiagnostics(config),
350
352
  schedule: {
351
353
  lastPushAt: lastPushAt || undefined,
352
354
  nextPushAt: config.autoSync ? nextPushAt : undefined,
@@ -394,7 +396,18 @@ function summarizeStatus(status) {
394
396
  const lastPush = status.last.push
395
397
  lines.push(`上次推送:${status.last.push !== undefined ? `${new Date(Number(status.schedule.lastPushAt) || 0).toLocaleString()}(上传 ${lastPush.uploaded} 个 / 删除 ${lastPush.deleted} 个)` : '还没有'}`)
396
398
  lines.push(`上次恢复:${status.last.pull !== undefined ? `${status.last.at}(${status.last.pull.restored} 个文件)` : '还没有'}`)
397
- if (status.last.error !== undefined) lines.push(`上次失败:${status.last.error.kind} — ${status.last.error.message}`)
399
+ if (status.last.error !== undefined) lines.push(`上次失败:${status.last.error.kind} — ${status.last.error.message}${status.last.error.permanent === true ? '(环境不可用,已停止自动重试)' : ''}`)
400
+ const transport = status.transport
401
+ if (transport !== undefined) {
402
+ const sshText = transport.sshOk === true ? `ssh 可用(${transport.sshResolved})` : `ssh 找不到(${transport.sshBinary})`
403
+ const keyText = transport.identitySource === 'guest'
404
+ ? `私钥 ${transport.identityFile}(guest 路径,未校验)`
405
+ : transport.identityFile === ''
406
+ ? '私钥:用系统默认密钥'
407
+ : `私钥 ${transport.identityOk ? '存在' : '不存在'}(${transport.identityFile})`
408
+ lines.push(`传输通道:${transport.mode === 'subprocess' ? 'DSH subprocess(可移植层)' : 'child_process(Node 原生)'};${sshText};${keyText}`)
409
+ if (transport.error !== '') lines.push(`传输诊断错误:${transport.error}`)
410
+ }
398
411
  if (status.config.autoSync) {
399
412
  lines.push(status.config.dailySync === true
400
413
  ? `自动同步:每天一次(${status.schedule.lastPushAt !== undefined && sameLocalDay(status.schedule.lastPushAt, Date.now()) ? `今天 ${new Date(status.schedule.lastPushAt).toLocaleTimeString()} 已同步` : '今天还没同步,下次检查时会补一轮'};下次 ${new Date(status.schedule.nextPushAt).toLocaleString()})`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leiyi-works/dsh-cloud-sync",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
4
4
  "type": "module",
5
5
  "title": "云端同步",
6
6
  "description": "云端同步:执行层走 DSH 可移植 subprocess(手机/沙箱也能跑)、ssh 兼容 dropbear、超时与停滞可配置、设备模式、永久错误停止重试;连接信息在 keyrule-secrets/ssh.json。",
@@ -23,6 +23,7 @@
23
23
  "files": [
24
24
  "index.js",
25
25
  "sync.js",
26
+ "exec.js",
26
27
  "client.js",
27
28
  "cordis.patch.yml",
28
29
  "locale/*.json",
package/sync.js CHANGED
@@ -25,7 +25,7 @@ import fs from 'node:fs'
25
25
  import fsp from 'node:fs/promises'
26
26
  import path from 'node:path'
27
27
  import os from 'node:os'
28
- import { spawnManaged } from './exec.js'
28
+ import { spawnManaged, transportMode, resolveCommand } from './exec.js'
29
29
 
30
30
  /* ── 配置 ───────────────────────────────────────────────────────────────── */
31
31
 
@@ -486,8 +486,7 @@ export function spawnSsh(config, remoteCommand) {
486
486
  return spawnManaged(argv)
487
487
  }
488
488
 
489
- /** 跑一条远程命令,收集输出;超时就杀掉,避免一个卡住的连接拖死整个任务。 */
490
- export async function runSsh(config, remoteCommand, options = {}) {
489
+ /** 跑一条远程命令,收集输出;超时就杀掉,避免一个卡住的连接拖死整个任务。 */export async function runSsh(config, remoteCommand, options = {}) {
491
490
  const child = spawnSsh(config, remoteCommand)
492
491
  const out = []
493
492
  const err = []
@@ -509,6 +508,57 @@ export async function runSsh(config, remoteCommand, options = {}) {
509
508
  }
510
509
  }
511
510
 
511
+ /**
512
+ * 传输诊断(面板/状态直接显示):当前走哪条执行通道、ssh 能否找到、私钥在哪。
513
+ * 手机/沙箱环境跑不通时,先看这里就知道卡在哪一步。
514
+ */
515
+ export async function transportDiagnostics(config) {
516
+ const mode = transportMode()
517
+ const binary = sshBinary(config)
518
+ const out = {
519
+ mode,
520
+ sshBinary: binary,
521
+ sshResolved: binary,
522
+ sshOk: false,
523
+ identityFile: '',
524
+ identityOk: true,
525
+ identitySource: 'default',
526
+ error: '',
527
+ }
528
+ try {
529
+ if (mode === 'subprocess') {
530
+ out.sshResolved = await resolveCommand(binary)
531
+ out.sshOk = true
532
+ } else {
533
+ out.sshResolved = binary
534
+ out.sshOk = path.isAbsolute(binary) ? fs.existsSync(binary) : true
535
+ }
536
+ } catch (error) {
537
+ out.error = error instanceof Error ? error.message : String(error)
538
+ }
539
+ try {
540
+ const guest = expandPath(config.identityFileGuest)
541
+ if (guest !== '') {
542
+ out.identityFile = guest
543
+ out.identitySource = 'guest'
544
+ out.identityOk = true
545
+ } else {
546
+ const host = expandPath(config.identityFile)
547
+ out.identityFile = host
548
+ if (host === '') {
549
+ out.identitySource = 'default'
550
+ out.identityOk = true
551
+ } else {
552
+ out.identitySource = 'host'
553
+ out.identityOk = fs.existsSync(host)
554
+ }
555
+ }
556
+ } catch (error) {
557
+ out.error = out.error || (error instanceof Error ? error.message : String(error))
558
+ }
559
+ return out
560
+ }
561
+
512
562
  /* ── tar 流(自带实现) ─────────────────────────────────────────────────── */
513
563
 
514
564
  const BLOCK = 512