@leiyi-works/dsh-cloud-sync 0.1.7 → 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.
- package/client.js +8 -0
- package/exec.js +45 -16
- package/index.js +14 -1
- package/package.json +2 -2
- package/sync.js +121 -6
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
CHANGED
|
@@ -21,6 +21,11 @@ export function hasSubprocessProvider() {
|
|
|
21
21
|
return provider !== null
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
/** 当前执行通道:subprocess(DSH 可移植层)/ child_process(Node 原生)。 */
|
|
25
|
+
export function transportMode() {
|
|
26
|
+
return provider === null ? 'child_process' : 'subprocess'
|
|
27
|
+
}
|
|
28
|
+
|
|
24
29
|
/** 在 provider 的执行世界里解析可执行文件路径;没有 provider 时原样返回。 */
|
|
25
30
|
export async function resolveCommand(command) {
|
|
26
31
|
if (provider === null) return command
|
|
@@ -81,32 +86,56 @@ class ManagedChild extends EventEmitter {
|
|
|
81
86
|
}
|
|
82
87
|
}
|
|
83
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
|
+
|
|
84
117
|
/**
|
|
85
118
|
* 启动一个受管子进程。
|
|
86
119
|
* @param {string[]} argv 完整命令行(argv[0] 为可执行文件)
|
|
87
|
-
* @param {{ cwd?: string, env?: Record<string,string>, graceMs?: number }} options
|
|
120
|
+
* @param {{ cwd?: string, env?: Record<string,string>, graceMs?: number, noWrap?: boolean }} options
|
|
88
121
|
*/
|
|
89
122
|
export function spawnManaged(argv, options = {}) {
|
|
90
|
-
const cwd = options.cwd ?? process.cwd()
|
|
91
|
-
const env = options.env ?? process.env
|
|
92
123
|
if (provider !== null) {
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
return new ManagedChild(handle)
|
|
102
|
-
} catch (error) {
|
|
103
|
-
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)
|
|
104
132
|
}
|
|
133
|
+
return spawnViaProvider(argv, options)
|
|
105
134
|
}
|
|
106
135
|
return spawn(argv[0], argv.slice(1), {
|
|
107
136
|
windowsHide: true,
|
|
108
137
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
109
|
-
cwd,
|
|
110
|
-
env,
|
|
138
|
+
cwd: options.cwd ?? process.cwd(),
|
|
139
|
+
env: options.env ?? process.env,
|
|
111
140
|
})
|
|
112
141
|
}
|
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,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@leiyi-works/dsh-cloud-sync",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.9",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"title": "云端同步",
|
|
6
|
-
"description": "
|
|
6
|
+
"description": "云端同步:subprocess 通道自动用 shell 包壳(HarmonyOS/DSHM 可跑)、设备模式默认值、dropbear 兼容、永久错误停止重试、传输诊断。",
|
|
7
7
|
"license": "MIT",
|
|
8
8
|
"author": "leiyi-works",
|
|
9
9
|
"keywords": [
|
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
|
|
|
@@ -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:
|
|
110
|
-
commandTimeoutMs:
|
|
111
|
-
stallMs:
|
|
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
|
}
|
|
@@ -486,8 +505,7 @@ export function spawnSsh(config, remoteCommand) {
|
|
|
486
505
|
return spawnManaged(argv)
|
|
487
506
|
}
|
|
488
507
|
|
|
489
|
-
/** 跑一条远程命令,收集输出;超时就杀掉,避免一个卡住的连接拖死整个任务。 */
|
|
490
|
-
export async function runSsh(config, remoteCommand, options = {}) {
|
|
508
|
+
/** 跑一条远程命令,收集输出;超时就杀掉,避免一个卡住的连接拖死整个任务。 */export async function runSsh(config, remoteCommand, options = {}) {
|
|
491
509
|
const child = spawnSsh(config, remoteCommand)
|
|
492
510
|
const out = []
|
|
493
511
|
const err = []
|
|
@@ -509,6 +527,103 @@ export async function runSsh(config, remoteCommand, options = {}) {
|
|
|
509
527
|
}
|
|
510
528
|
}
|
|
511
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
|
+
|
|
565
|
+
/**
|
|
566
|
+
* 传输诊断(面板/状态直接显示):当前走哪条执行通道、ssh 能否找到、私钥在哪。
|
|
567
|
+
* 手机/沙箱环境跑不通时,先看这里就知道卡在哪一步。
|
|
568
|
+
*/
|
|
569
|
+
export async function transportDiagnostics(config) {
|
|
570
|
+
const mode = transportMode()
|
|
571
|
+
const binary = sshBinary(config)
|
|
572
|
+
const out = {
|
|
573
|
+
mode,
|
|
574
|
+
sshBinary: binary,
|
|
575
|
+
sshResolved: binary,
|
|
576
|
+
sshOk: false,
|
|
577
|
+
sshVersion: '',
|
|
578
|
+
identityFile: '',
|
|
579
|
+
identityOk: true,
|
|
580
|
+
identitySource: 'default',
|
|
581
|
+
error: '',
|
|
582
|
+
}
|
|
583
|
+
try {
|
|
584
|
+
if (mode === 'subprocess') {
|
|
585
|
+
out.sshResolved = await resolveCommand(binary)
|
|
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
|
+
}
|
|
597
|
+
} else {
|
|
598
|
+
out.sshResolved = binary
|
|
599
|
+
out.sshOk = path.isAbsolute(binary) ? fs.existsSync(binary) : true
|
|
600
|
+
}
|
|
601
|
+
} catch (error) {
|
|
602
|
+
out.error = error instanceof Error ? error.message : String(error)
|
|
603
|
+
}
|
|
604
|
+
try {
|
|
605
|
+
const guest = expandPath(config.identityFileGuest)
|
|
606
|
+
if (guest !== '') {
|
|
607
|
+
out.identityFile = guest
|
|
608
|
+
out.identitySource = 'guest'
|
|
609
|
+
out.identityOk = true
|
|
610
|
+
} else {
|
|
611
|
+
const host = expandPath(config.identityFile)
|
|
612
|
+
out.identityFile = host
|
|
613
|
+
if (host === '') {
|
|
614
|
+
out.identitySource = 'default'
|
|
615
|
+
out.identityOk = true
|
|
616
|
+
} else {
|
|
617
|
+
out.identitySource = 'host'
|
|
618
|
+
out.identityOk = fs.existsSync(host)
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
} catch (error) {
|
|
622
|
+
out.error = out.error || (error instanceof Error ? error.message : String(error))
|
|
623
|
+
}
|
|
624
|
+
return out
|
|
625
|
+
}
|
|
626
|
+
|
|
512
627
|
/* ── tar 流(自带实现) ─────────────────────────────────────────────────── */
|
|
513
628
|
|
|
514
629
|
const BLOCK = 512
|