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

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.
Files changed (3) hide show
  1. package/exec.js +40 -16
  2. package/package.json +2 -2
  3. package/sync.js +69 -4
package/exec.js CHANGED
@@ -86,32 +86,56 @@ class ManagedChild extends EventEmitter {
86
86
  }
87
87
  }
88
88
 
89
+ /** 平台 VM 桥只接管这些 shell 作为 argv[0];其它程序需要包成 `shell -c 'exec …'`。 */
90
+ const SHELLS = new Set(['bash', 'sh', 'ash', 'dash', 'zsh', 'ksh'])
91
+
92
+ function baseName(value) {
93
+ return String(value).split(/[\\/]/).pop()
94
+ }
95
+
96
+ function quoteArg(value) {
97
+ return `'${String(value).replace(/'/g, `'\\''`)}'`
98
+ }
99
+
100
+ function spawnViaProvider(argv, options) {
101
+ const cwd = options.cwd ?? process.cwd()
102
+ const env = options.env ?? process.env
103
+ try {
104
+ const handle = provider.spawn({
105
+ argv,
106
+ cwd,
107
+ stdio: { stdin: 'pipe', stdout: 'pipe', stderr: 'pipe' },
108
+ graceMs: options.graceMs ?? 3000,
109
+ env,
110
+ })
111
+ return new ManagedChild(handle)
112
+ } catch (error) {
113
+ return failedChild(error)
114
+ }
115
+ }
116
+
89
117
  /**
90
118
  * 启动一个受管子进程。
91
119
  * @param {string[]} argv 完整命令行(argv[0] 为可执行文件)
92
- * @param {{ cwd?: string, env?: Record<string,string>, graceMs?: number }} options
120
+ * @param {{ cwd?: string, env?: Record<string,string>, graceMs?: number, noWrap?: boolean }} options
93
121
  */
94
122
  export function spawnManaged(argv, options = {}) {
95
- const cwd = options.cwd ?? process.cwd()
96
- const env = options.env ?? process.env
97
123
  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)
124
+ // 手机侧(DSHM)的 subprocess 实现只把 shell 命令转进 guest;
125
+ // 直接 spawn('ssh') 会被 fail-open 回原生 spawn → ENOENT。
126
+ // 所以非 shell 的 argv 一律包成 `bash -c 'exec …'`,让它走 guest。
127
+ const head = baseName(argv[0])
128
+ if (options.noWrap !== true && !SHELLS.has(head)) {
129
+ const shell = process.env.DSH_CLOUD_SYNC_SHELL || 'bash'
130
+ const line = `exec ${argv.map(quoteArg).join(' ')}`
131
+ return spawnViaProvider([shell, '-c', line], options)
109
132
  }
133
+ return spawnViaProvider(argv, options)
110
134
  }
111
135
  return spawn(argv[0], argv.slice(1), {
112
136
  windowsHide: true,
113
137
  stdio: ['pipe', 'pipe', 'pipe'],
114
- cwd,
115
- env,
138
+ cwd: options.cwd ?? process.cwd(),
139
+ env: options.env ?? process.env,
116
140
  })
117
141
  }
package/package.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "name": "@leiyi-works/dsh-cloud-sync",
3
- "version": "0.1.8",
3
+ "version": "0.1.9",
4
4
  "type": "module",
5
5
  "title": "云端同步",
6
- "description": "云端同步:执行层走 DSH 可移植 subprocess(手机/沙箱也能跑)、ssh 兼容 dropbear、超时与停滞可配置、设备模式、永久错误停止重试;连接信息在 keyrule-secrets/ssh.json。",
6
+ "description": "云端同步:subprocess 通道自动用 shell 包壳(HarmonyOS/DSHM 可跑)、设备模式默认值、dropbear 兼容、永久错误停止重试、传输诊断。",
7
7
  "license": "MIT",
8
8
  "author": "leiyi-works",
9
9
  "keywords": [
package/sync.js CHANGED
@@ -99,6 +99,10 @@ export function normalizeConfig(raw) {
99
99
  const bool = (key, deviceDefault) => (input[key] === undefined || input[key] === null
100
100
  ? (device ? deviceDefault : DEFAULTS[key])
101
101
  : flag(input[key], DEFAULTS[key]))
102
+ /** 数字项:显式配置优先;没配时用 deviceMode 的默认值。 */
103
+ const number = (key, deviceDefault, min) => (input[key] === undefined || input[key] === null
104
+ ? (device ? deviceDefault : DEFAULTS[key])
105
+ : num(input[key], DEFAULTS[key], min))
102
106
  const out = {
103
107
  host: text(pick('host'), DEFAULTS.host),
104
108
  port: num(pick('port'), DEFAULTS.port, 1),
@@ -106,9 +110,9 @@ export function normalizeConfig(raw) {
106
110
  identityFileGuest: text(pick('identityFileGuest'), DEFAULTS.identityFileGuest),
107
111
  sshBinary: text(pick('sshBinary'), DEFAULTS.sshBinary),
108
112
  sshCompat: text(pick('sshCompat'), DEFAULTS.sshCompat),
109
- connectTimeoutMs: num(pick('connectTimeoutMs'), DEFAULTS.connectTimeoutMs, 1000),
110
- commandTimeoutMs: num(pick('commandTimeoutMs'), DEFAULTS.commandTimeoutMs, 1000),
111
- stallMs: num(pick('stallMs'), DEFAULTS.stallMs, 5000),
113
+ connectTimeoutMs: number('connectTimeoutMs', 60000, 1000),
114
+ commandTimeoutMs: number('commandTimeoutMs', 240000, 1000),
115
+ stallMs: number('stallMs', 300000, 5000),
112
116
  deviceMode: device,
113
117
  connectionFile: text(pick('connectionFile'), DEFAULTS.connectionFile),
114
118
  remoteRoot: text(pick('remoteRoot'), DEFAULTS.remoteRoot).replace(/\/+$/, '') || DEFAULTS.remoteRoot,
@@ -443,11 +447,26 @@ function sshBinary(config) {
443
447
  * 那样只会得到 Permission denied,看不出真实原因)。
444
448
  * `identityFileGuest` 非空时优先用它——那是 guest/VM 世界里的路径,宿主侧无法校验。
445
449
  */
450
+ /** 宿主路径 → guest 路径(DSHM:应用 files 目录挂到 /mnt/share)。 */
451
+ const GUEST_PREFIX_MAP = [['/data/storage/el2/base/haps/phone/files', '/mnt/share']]
452
+
453
+ function toGuestPath(value) {
454
+ for (const [from, to] of GUEST_PREFIX_MAP) {
455
+ if (value.startsWith(from)) return to + value.slice(from.length)
456
+ }
457
+ return ''
458
+ }
459
+
446
460
  function identityPath(config) {
447
461
  const guest = expandPath(config.identityFileGuest)
448
462
  if (guest !== '') return guest
449
463
  const raw = expandPath(config.identityFile)
450
464
  if (raw === '') return ''
465
+ // 设备模式:宿主路径能映射到 guest 时优先用映射后的(宿主存在 ≠ guest 存在)
466
+ if (config.deviceMode === true) {
467
+ const mapped = toGuestPath(raw)
468
+ if (mapped !== '') return mapped
469
+ }
451
470
  if (!fs.existsSync(raw)) {
452
471
  throw new Error(`私钥不存在:${raw}(检查 ssh.json / identityFile;手机端要填 guest 内可见的路径,可用 identityFileGuest)`)
453
472
  }
@@ -508,6 +527,41 @@ export function spawnSsh(config, remoteCommand) {
508
527
  }
509
528
  }
510
529
 
530
+ /** ssh 可用性探测(带 10 分钟缓存;手机上一次 spawn 就要好几秒)。 */
531
+ let sshProbeCache = { key: '', at: 0, result: null }
532
+
533
+ function probeSshVersion(binary, timeoutMs = 10000) {
534
+ return new Promise((resolve) => {
535
+ let settled = false
536
+ const chunks = []
537
+ const finish = (ok, text) => {
538
+ if (settled) return
539
+ settled = true
540
+ clearTimeout(timer)
541
+ resolve({ ok, text: String(text ?? '').trim().split(/\r?\n/)[0] ?? '' })
542
+ }
543
+ let child
544
+ try {
545
+ child = spawnManaged([binary, '-V'])
546
+ } catch (error) {
547
+ resolve({ ok: false, text: error instanceof Error ? error.message : String(error) })
548
+ return
549
+ }
550
+ const timer = setTimeout(() => {
551
+ try { child.kill() } catch { /* 已退出 */ }
552
+ finish(false, 'timeout')
553
+ }, timeoutMs)
554
+ if (typeof timer.unref === 'function') timer.unref()
555
+ if (child.stdout) child.stdout.on('data', (chunk) => chunks.push(chunk))
556
+ if (child.stderr) child.stderr.on('data', (chunk) => chunks.push(chunk))
557
+ child.on('error', (error) => finish(false, error instanceof Error ? error.message : String(error)))
558
+ child.on('close', (code) => {
559
+ const text = Buffer.concat(chunks).toString('utf8')
560
+ finish(/OpenSSH|dropbear|SSH/i.test(text) || code === 0, text !== '' ? text : `exit ${code}`)
561
+ })
562
+ })
563
+ }
564
+
511
565
  /**
512
566
  * 传输诊断(面板/状态直接显示):当前走哪条执行通道、ssh 能否找到、私钥在哪。
513
567
  * 手机/沙箱环境跑不通时,先看这里就知道卡在哪一步。
@@ -520,6 +574,7 @@ export async function transportDiagnostics(config) {
520
574
  sshBinary: binary,
521
575
  sshResolved: binary,
522
576
  sshOk: false,
577
+ sshVersion: '',
523
578
  identityFile: '',
524
579
  identityOk: true,
525
580
  identitySource: 'default',
@@ -528,7 +583,17 @@ export async function transportDiagnostics(config) {
528
583
  try {
529
584
  if (mode === 'subprocess') {
530
585
  out.sshResolved = await resolveCommand(binary)
531
- out.sshOk = true
586
+ const now = Date.now()
587
+ const cached = sshProbeCache.result !== null && sshProbeCache.key === out.sshResolved && now - sshProbeCache.at < 10 * 60 * 1000
588
+ if (cached) {
589
+ out.sshOk = sshProbeCache.result.ok
590
+ out.sshVersion = sshProbeCache.result.text
591
+ } else {
592
+ const probed = await probeSshVersion(out.sshResolved, 10000)
593
+ sshProbeCache = { key: out.sshResolved, at: now, result: probed }
594
+ out.sshOk = probed.ok
595
+ out.sshVersion = probed.text
596
+ }
532
597
  } else {
533
598
  out.sshResolved = binary
534
599
  out.sshOk = path.isAbsolute(binary) ? fs.existsSync(binary) : true