dsh-mobile-plugin 0.1.82 → 0.1.85
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 +8 -5
- package/lib/client.js +43 -50
- package/lib/impl.js +2 -2
- package/lib/mirror/pusher.cjs +2 -0
- package/lib/mobile/archive-clean.js +15 -38
- package/lib/mobile/completion.js +58 -36
- package/lib/mobile/events.js +28 -5
- package/lib/mobile/plugins.js +22 -28
- package/lib/mobile/references.js +17 -30
- package/lib/mobile/session-cache.js +31 -24
- package/lib/mobile/session-send.js +31 -40
- package/lib/mobile/stream.js +42 -8
- package/lib/relay/tunnel.js +30 -9
- package/lib/session-cleanup.cjs +96 -17
- package/lib/shell/device-ota.js +81 -51
- package/lib/shell/manager.js +22 -3
- package/lib/shell/ota-package.js +237 -0
- package/lib/sidecar-facade-worker.cjs +65 -9
- package/package.json +1 -1
package/lib/shell/device-ota.js
CHANGED
|
@@ -1,13 +1,9 @@
|
|
|
1
|
-
/** 设备端临时插件 OTA:npm 保持正式来源,OTA
|
|
1
|
+
/** 设备端临时插件 OTA:npm 保持正式来源,OTA 只落在 profile 里(见 storageRoot)。 */
|
|
2
2
|
import fs from 'node:fs'
|
|
3
|
-
import os from 'node:os'
|
|
4
3
|
import path from 'node:path'
|
|
5
|
-
import { createRequire } from 'node:module'
|
|
6
4
|
import { dshHome } from '../shared/paths.js'
|
|
7
|
-
import { compareVersion } from './manager.js'
|
|
8
|
-
|
|
9
|
-
const require_ = createRequire(import.meta.url)
|
|
10
|
-
const zlib = require_('node:zlib')
|
|
5
|
+
import { compareVersion, generationModulesError } from './manager.js'
|
|
6
|
+
import { movePath, readManifest, sha256Hex, unpackTarGz, verifyManifest } from './ota-package.js'
|
|
11
7
|
/**
|
|
12
8
|
* OTA 包落在哪。
|
|
13
9
|
*
|
|
@@ -27,50 +23,55 @@ const PACKAGE = 'dsh-mobile-plugin'
|
|
|
27
23
|
const RELAY = 'https://dsh-hub.wnluo.com/api/desktop-plugins/ota'
|
|
28
24
|
const CHANNEL = 'stable'
|
|
29
25
|
const PLATFORM = process.platform
|
|
26
|
+
/**
|
|
27
|
+
* 平台回落的兜底标记。
|
|
28
|
+
*
|
|
29
|
+
* 这个插件是纯 JS,同一份包在 Linux/macOS/Windows 上都能跑;但中转是按平台分发的,以前
|
|
30
|
+
* 只发过 darwin 就意味着 Windows 机器永远查不到更新(`ok:false` 被当成「没有新版」)。
|
|
31
|
+
* 约定:自己平台没包时再问一次 `any`,发布方也可以直接只发一份 `any`。
|
|
32
|
+
*/
|
|
33
|
+
const ANY_PLATFORM = 'any'
|
|
30
34
|
function validVersion(value) { return typeof value === 'string' && /^\d+\.\d+\.\d+$/.test(value) }
|
|
31
35
|
function validPackageUrl(value) {
|
|
32
36
|
try { return new URL(value).protocol === 'https:' } catch { return false }
|
|
33
37
|
}
|
|
38
|
+
/**
|
|
39
|
+
* 列出盘上可用的 OTA 代。
|
|
40
|
+
*
|
|
41
|
+
* 两条容错都是必要的:**逐个条目自己 try**(一个残目录不能让整台机器的 OTA 全部不可见——
|
|
42
|
+
* 旧实现把整个 map 包在一个 try 里,任何一份半拷贝都会让 `availableOta()` 返回空,表现为
|
|
43
|
+
* 「更新装了但界面上永远没有新版」),以及**跳过点开头的目录**(`.staging-*` 是解包中途的
|
|
44
|
+
* 暂存,不能当成一代)。
|
|
45
|
+
*/
|
|
34
46
|
function availableOta() {
|
|
35
|
-
|
|
36
|
-
|
|
47
|
+
let items
|
|
48
|
+
try { items = fs.readdirSync(ROOT(), { withFileTypes: true }) } catch { return [] }
|
|
49
|
+
const found = []
|
|
50
|
+
for (const item of items) {
|
|
51
|
+
if (!item.isDirectory() || item.name.startsWith('.')) continue
|
|
52
|
+
try {
|
|
37
53
|
const pkgDir = path.join(ROOT(), item.name, PACKAGE)
|
|
38
54
|
const pkg = JSON.parse(fs.readFileSync(path.join(pkgDir, 'package.json'), 'utf8'))
|
|
39
|
-
if (pkg.name !== PACKAGE || !validVersion(pkg.version)
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
55
|
+
if (pkg.name !== PACKAGE || !validVersion(pkg.version)) continue
|
|
56
|
+
if (!fs.existsSync(path.join(pkgDir, 'lib', 'impl.js'))) continue
|
|
57
|
+
found.push({ version: pkg.version, entry: path.join(pkgDir, 'lib', 'impl.js'), dir: pkgDir, pkgDir, source: 'ota' })
|
|
58
|
+
} catch { /* 半份包 / 残目录:只跳过它自己 */ }
|
|
59
|
+
}
|
|
60
|
+
return found.sort((a, b) => compareVersion(b.version, a.version))
|
|
61
|
+
}
|
|
62
|
+
/** 清掉上次解包中途退出留下的暂存目录(断电 / 进程被杀时的残骸)。 */
|
|
63
|
+
function sweepStaging() {
|
|
64
|
+
let items
|
|
65
|
+
try { items = fs.readdirSync(ROOT(), { withFileTypes: true }) } catch { return }
|
|
66
|
+
for (const item of items) {
|
|
67
|
+
if (!item.isDirectory() || !item.name.startsWith('.staging-')) continue
|
|
68
|
+
try { fs.rmSync(path.join(ROOT(), item.name), { recursive: true, force: true }) } catch { /* 下次再清 */ }
|
|
69
|
+
}
|
|
43
70
|
}
|
|
44
71
|
function clearSuperseded(version) {
|
|
45
72
|
if (!validVersion(version)) return
|
|
46
73
|
for (const release of availableOta()) if (compareVersion(version, release.version) >= 0) fs.rmSync(path.dirname(release.pkgDir), { recursive: true, force: true })
|
|
47
74
|
}
|
|
48
|
-
function unpackTarGz(buffer, target) {
|
|
49
|
-
const tar = zlib.gunzipSync(buffer)
|
|
50
|
-
let offset = 0
|
|
51
|
-
while (offset + 512 <= tar.length) {
|
|
52
|
-
const header = tar.subarray(offset, offset + 512)
|
|
53
|
-
if (header.every(byte => byte === 0)) break
|
|
54
|
-
const name = header.subarray(0, 100).toString().replace(/\0.*$/, '')
|
|
55
|
-
const prefix = header.subarray(345, 500).toString().replace(/\0.*$/, '')
|
|
56
|
-
const rel = path.posix.join(prefix, name)
|
|
57
|
-
const size = parseInt(header.subarray(124, 136).toString().replace(/\0.*$/, '').trim() || '0', 8)
|
|
58
|
-
const type = header[156]
|
|
59
|
-
const start = offset + 512
|
|
60
|
-
if (!rel.startsWith('package/') || rel.split('/').includes('..')) throw new Error('OTA 包含无效路径')
|
|
61
|
-
if (type === 0 || type === 48) {
|
|
62
|
-
const dest = path.join(target, PACKAGE, ...rel.split('/').slice(1))
|
|
63
|
-
fs.mkdirSync(path.dirname(dest), { recursive: true })
|
|
64
|
-
fs.writeFileSync(dest, tar.subarray(start, start + size))
|
|
65
|
-
} else if (type !== 53) throw new Error(`OTA 包包含不支持的 tar 项:${rel}`)
|
|
66
|
-
offset = start + Math.ceil(size / 512) * 512
|
|
67
|
-
}
|
|
68
|
-
const pkgDir = path.join(target, PACKAGE)
|
|
69
|
-
const pkg = JSON.parse(fs.readFileSync(path.join(pkgDir, 'package.json'), 'utf8'))
|
|
70
|
-
if (pkg.name !== PACKAGE || !validVersion(pkg.version) || !fs.existsSync(path.join(pkgDir, 'lib', 'impl.js'))) throw new Error('OTA 包结构不完整')
|
|
71
|
-
return pkg.version
|
|
72
|
-
}
|
|
73
|
-
|
|
74
75
|
export function createDeviceOta({ fetchImpl = fetch, installed = () => null, platform = PLATFORM, channel = CHANNEL, runtime = null } = {}) {
|
|
75
76
|
async function reconcileOfficial() {
|
|
76
77
|
const active = runtime?.status()
|
|
@@ -87,37 +88,66 @@ export function createDeviceOta({ fetchImpl = fetch, installed = () => null, pla
|
|
|
87
88
|
if (status?.source === 'installed') clearSuperseded(officialVersion)
|
|
88
89
|
return { switched: status?.source === 'installed', status }
|
|
89
90
|
}
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
const url = `${RELAY}/latest?platform=${encodeURIComponent(
|
|
91
|
+
/** 问一次中转「我该装哪个版本」;没有就返回 null(不抛)。 */
|
|
92
|
+
async function latestFor(wanted) {
|
|
93
|
+
const url = `${RELAY}/latest?platform=${encodeURIComponent(wanted)}&channel=${encodeURIComponent(channel)}`
|
|
93
94
|
const response = await fetchImpl(url, { headers: { accept: 'application/json' } })
|
|
94
95
|
if (!response.ok) throw new Error(`OTA relay 返回 HTTP ${response.status}`)
|
|
95
96
|
const body = await response.json()
|
|
96
|
-
|
|
97
|
-
|
|
97
|
+
if (body?.ok !== true) return null
|
|
98
|
+
const release = body.data
|
|
99
|
+
if (!release || !validVersion(release.version)) return null
|
|
100
|
+
// 回落来的包平台标记是 `any`;自己平台那份必须正好对上。
|
|
101
|
+
if (release.platform !== wanted || release.channel !== channel) return null
|
|
102
|
+
if (!validPackageUrl(release.packageUrl)) return null
|
|
103
|
+
return release
|
|
104
|
+
}
|
|
105
|
+
async function check() {
|
|
106
|
+
await reconcileOfficial()
|
|
107
|
+
const release = (await latestFor(platform)) ?? (platform === ANY_PLATFORM ? null : await latestFor(ANY_PLATFORM))
|
|
108
|
+
if (!release) throw new Error('OTA relay 没有提供匹配平台、stable channel、有效版本或 HTTPS 下载地址')
|
|
98
109
|
const installedVersion = installed()
|
|
99
110
|
const officialVersion = installedVersion
|
|
100
111
|
const ota = availableOta()[0] || null
|
|
101
112
|
const otaVersion = validVersion(release.version) ? release.version : null
|
|
102
113
|
const newest = [ota?.version, otaVersion].filter(validVersion).sort((a, b) => compareVersion(b, a))[0] || null
|
|
103
114
|
const absorbed = Boolean(validVersion(officialVersion) && newest && compareVersion(officialVersion, newest) >= 0 && runtime?.status()?.source !== 'ota')
|
|
104
|
-
return { available: Boolean(newest && !absorbed && compareVersion(newest, installedVersion || '0.0.0') > 0), version: newest, platform: release.platform || platform, channel: release.channel || channel, installedVersion, otaVersion: ota?.version ?? null, officialVersion, absorbed, packageUrl: release.packageUrl }
|
|
115
|
+
return { available: Boolean(newest && !absorbed && compareVersion(newest, installedVersion || '0.0.0') > 0), version: newest, platform: release.platform || platform, channel: release.channel || channel, installedVersion, otaVersion: ota?.version ?? null, officialVersion, absorbed, packageUrl: release.packageUrl, sha256: typeof release.sha256 === 'string' && /^[0-9a-f]{64}$/i.test(release.sha256) ? release.sha256.toLowerCase() : null }
|
|
105
116
|
}
|
|
106
117
|
async function apply() {
|
|
107
118
|
const info = await check()
|
|
108
119
|
if (info.otaVersion && compareVersion(info.otaVersion, info.version) === 0) return { installed: true, version: info.version, source: 'ota', alreadyDownloaded: true }
|
|
109
120
|
if (validVersion(info.officialVersion) && compareVersion(info.officialVersion, info.version) >= 0) return { installed: false, reason: 'npm-current-or-newer', version: info.officialVersion }
|
|
110
|
-
|
|
121
|
+
// 暂存目录**建在目标根里面**,不是系统临时目录:最后一步要 rename 到 `<root>/<version>`,
|
|
122
|
+
// 跨盘的 rename 在 Windows 上直接 EXDEV(系统临时目录常在 C:,而 profile 可能在别的盘)。
|
|
123
|
+
fs.mkdirSync(ROOT(), { recursive: true })
|
|
124
|
+
sweepStaging()
|
|
125
|
+
const staging = fs.mkdtempSync(path.join(ROOT(), '.staging-'))
|
|
111
126
|
try {
|
|
112
127
|
const response = await fetchImpl(info.packageUrl)
|
|
113
128
|
if (!response.ok) throw new Error(`OTA 下载失败:HTTP ${response.status}`)
|
|
114
|
-
const
|
|
129
|
+
const raw = Buffer.from(await response.arrayBuffer())
|
|
130
|
+
// 中转记了哈希就一定要对上:半个包、被中间层改过的包都在这里被挡住,而不是装上去再炸。
|
|
131
|
+
if (info.sha256 && sha256Hex(raw) !== info.sha256) {
|
|
132
|
+
throw new Error(`OTA 包校验失败:下载内容的 sha256 与中转记录不一致(预期 ${info.sha256},实际 ${sha256Hex(raw)})`)
|
|
133
|
+
}
|
|
134
|
+
unpackTarGz(raw, staging)
|
|
135
|
+
const pkgDir = path.join(staging, PACKAGE)
|
|
136
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(pkgDir, 'package.json'), 'utf8'))
|
|
137
|
+
const version = pkg && pkg.version
|
|
138
|
+
if (pkg.name !== PACKAGE || !validVersion(version)) throw new Error('OTA 包结构不完整:package.json 里的包名或版本不对')
|
|
115
139
|
if (version !== info.version) throw new Error(`OTA 版本不匹配:预期 ${info.version},实际 ${version}`)
|
|
140
|
+
if (!fs.existsSync(path.join(pkgDir, 'lib', 'impl.js'))) throw new Error('OTA 包结构不完整:缺 lib/impl.js')
|
|
141
|
+
// 有清单就逐项核对(缺文件 / 大小不符在**激活之前**失败);老包没有清单,退回上面那两条判据。
|
|
142
|
+
const manifest = readManifest(pkgDir)
|
|
143
|
+
if (manifest) {
|
|
144
|
+
if (manifest.version !== version) throw new Error(`OTA 包清单版本与 package.json 不一致(${manifest.version} ≠ ${version})`)
|
|
145
|
+
verifyManifest(pkgDir, manifest)
|
|
146
|
+
}
|
|
116
147
|
const releaseDir = path.join(ROOT(), version)
|
|
117
|
-
fs.mkdirSync(ROOT(), { recursive: true })
|
|
118
148
|
fs.rmSync(releaseDir, { recursive: true, force: true })
|
|
119
|
-
|
|
120
|
-
return { installed: true, version, source: 'ota' }
|
|
149
|
+
movePath(staging, releaseDir)
|
|
150
|
+
return { installed: true, version, source: 'ota', verified: Boolean(manifest) }
|
|
121
151
|
} catch (error) {
|
|
122
152
|
fs.rmSync(staging, { recursive: true, force: true })
|
|
123
153
|
throw error
|
|
@@ -127,7 +157,7 @@ export function createDeviceOta({ fetchImpl = fetch, installed = () => null, pla
|
|
|
127
157
|
const installedVersion = installed()
|
|
128
158
|
const active = runtime?.status() ?? null
|
|
129
159
|
const ota = availableOta()[0] || null
|
|
130
|
-
return { available: Boolean(ota && compareVersion(ota.version, installedVersion || '0.0.0') > 0), activeVersion: active?.version ?? installedVersion, otaVersion: ota?.version ?? null, officialVersion: installedVersion, absorbed: Boolean(installedVersion && ota && compareVersion(installedVersion, ota.version) >= 0 && active?.source !== 'ota'), channel }
|
|
160
|
+
return { available: Boolean(ota && compareVersion(ota.version, installedVersion || '0.0.0') > 0), activeVersion: active?.version ?? installedVersion, otaVersion: ota?.version ?? null, officialVersion: installedVersion, absorbed: Boolean(installedVersion && ota && compareVersion(installedVersion, ota.version) >= 0 && active?.source !== 'ota'), channel, dependencyLinkError: generationModulesError() }
|
|
131
161
|
}
|
|
132
162
|
async function activate() {
|
|
133
163
|
const outcome = await apply()
|
package/lib/shell/manager.js
CHANGED
|
@@ -33,6 +33,7 @@ import { createRequire } from 'node:module'
|
|
|
33
33
|
import path from 'node:path'
|
|
34
34
|
import { fileURLToPath, pathToFileURL } from 'node:url'
|
|
35
35
|
import { availableOta, configureOtaRoot } from './device-ota.js'
|
|
36
|
+
import { movePath } from './ota-package.js'
|
|
36
37
|
import { dshHome } from '../shared/paths.js'
|
|
37
38
|
|
|
38
39
|
/** CJS 模块缓存。实现树里 `lib/relay`、`lib/routes`、`lib/mobile` 都是 CJS。 */
|
|
@@ -251,7 +252,9 @@ function moveOtaRootIntoProfile() {
|
|
|
251
252
|
for (const entry of fs.readdirSync(legacy)) {
|
|
252
253
|
const target = path.join(next, entry)
|
|
253
254
|
if (fs.existsSync(target)) continue
|
|
254
|
-
|
|
255
|
+
// 跨盘也能搬:`<DSH_HOME>` 与 profile 在 Windows 上很可能不在同一个盘,
|
|
256
|
+
// 那时 rename 抛 EXDEV,整段迁移会静默失败、OTA 留在树外(依赖就解析不到了)。
|
|
257
|
+
movePath(path.join(legacy, entry), target)
|
|
255
258
|
}
|
|
256
259
|
fs.rmSync(legacy, { recursive: true, force: true })
|
|
257
260
|
} catch {
|
|
@@ -277,6 +280,18 @@ function moveOtaRootIntoProfile() {
|
|
|
277
280
|
/** 外壳一启动就把 OTA 落盘位置定下来(见 moveOtaRootIntoProfile 的注释)。 */
|
|
278
281
|
moveOtaRootIntoProfile()
|
|
279
282
|
|
|
283
|
+
/**
|
|
284
|
+
* 最近一次「给 OTA 代补依赖软链」的失败原因。
|
|
285
|
+
*
|
|
286
|
+
* 软链只是兜底(落在 profile 里的一代本来就能沿祖先目录解析到 profile 的依赖),可它
|
|
287
|
+
* **在 Windows 默认没有权限建**。旧实现把失败静默吞掉,于是现场只剩一句
|
|
288
|
+
* `Cannot find module`,看不出是缺依赖还是补链失败。记在这里,由状态接口如实报出来。
|
|
289
|
+
*/
|
|
290
|
+
let generationLinkError = null
|
|
291
|
+
export function generationModulesError() {
|
|
292
|
+
return generationLinkError
|
|
293
|
+
}
|
|
294
|
+
|
|
280
295
|
function ensureGenerationModules(generation) {
|
|
281
296
|
if (!generation || generation.source !== 'ota' || !generation.dir) return
|
|
282
297
|
// 只认真正的 `node_modules`:`findProfileNodeModules()` 找不到时会回落到 profile 根,
|
|
@@ -296,8 +311,12 @@ function ensureGenerationModules(generation) {
|
|
|
296
311
|
}
|
|
297
312
|
try {
|
|
298
313
|
fs.symlinkSync(target, at, 'dir')
|
|
299
|
-
|
|
300
|
-
|
|
314
|
+
generationLinkError = null
|
|
315
|
+
} catch (error) {
|
|
316
|
+
// Windows 默认没有建软链的权限(要开发者模式或管理员),这类失败**必须留痕**:
|
|
317
|
+
// 否则现场只有一句 `Cannot find module`,看不出是缺依赖还是补链被拒。
|
|
318
|
+
generationLinkError = `补依赖软链失败(${at} → ${target}):${error && typeof error === 'object' ? error.code || error.message : error}`
|
|
319
|
+
try { process.stderr.write(`[dsh-mobile-plugin] ${generationLinkError}\n`) } catch { /* ignore */ }
|
|
301
320
|
}
|
|
302
321
|
}
|
|
303
322
|
link(PROFILE_NODE_MODULES, path.join(versionDir, 'node_modules'))
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OTA 包(npm tarball)的读取、校验与落盘。
|
|
3
|
+
*
|
|
4
|
+
* 这一层存在的唯一理由是「换一台机器也照样能装」:以前解包是 `device-ota.js` 里的六十行
|
|
5
|
+
* 手写代码,只认普通文件和目录,遇到 PAX 扩展头(macOS 的 tar 默认会带)、GNU 长文件名、
|
|
6
|
+
* 长路径就直接抛「不支持的 tar 项」;反过来,包里少几个文件它又看不出来,于是装上去之后
|
|
7
|
+
* 才在某一代运行时报「找不到某个 JS 文件」。这里把两件事一起解决:
|
|
8
|
+
*
|
|
9
|
+
* 1. **解包要认得真实世界里的 tar**:ustar 的 prefix、PAX(`x` / `g`)、GNU 长名(`L` / `K`)
|
|
10
|
+
* 都要能读;软链接、硬链接、设备文件要**明确拒绝**并说清原因(不静默跳过,否则会装出
|
|
11
|
+
* 一个缺文件的代)。
|
|
12
|
+
* 2. **装完要能证明它完整**:包里带一份 `lib/ota-manifest.json`(发布脚本生成,列出版本与
|
|
13
|
+
* 每个文件的路径和字节数),解包后逐项核对。缺文件、大小不符都在**激活之前**失败,
|
|
14
|
+
* 错误里直接点名是哪个文件。
|
|
15
|
+
*
|
|
16
|
+
* 手写 tar 解析而不是引依赖:这是发布链路上的一段自包含代码(OTA 包要能在一台刚装好
|
|
17
|
+
* DSH 的机器上被解出来,不能要求它先装点别的),而 ustar 的头部格式是固定的 512 字节。
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import crypto from 'node:crypto'
|
|
21
|
+
import fs from 'node:fs'
|
|
22
|
+
import path from 'node:path'
|
|
23
|
+
import zlib from 'node:zlib'
|
|
24
|
+
|
|
25
|
+
/** 清单在包内的相对路径(相对插件包根)。 */
|
|
26
|
+
export const MANIFEST_PATH = 'lib/ota-manifest.json'
|
|
27
|
+
/** tar 里所有条目都必须在这个目录下,否则不是 npm 包。 */
|
|
28
|
+
const ARCHIVE_PREFIX = 'package'
|
|
29
|
+
|
|
30
|
+
const BLOCK = 512
|
|
31
|
+
/** 文件条目的类型标记。ustar 规范允许 `\0` 表示普通文件(老实现会写 0x00)。 */
|
|
32
|
+
const TYPE_FILE = new Set(['0', '\u0000'])
|
|
33
|
+
const TYPE_DIR = '5'
|
|
34
|
+
const TYPE_PAX = 'x'
|
|
35
|
+
const TYPE_PAX_GLOBAL = 'g'
|
|
36
|
+
const TYPE_GNU_LONG_NAME = 'L'
|
|
37
|
+
const TYPE_GNU_LONG_LINK = 'K'
|
|
38
|
+
|
|
39
|
+
function readString(buffer, start, length) {
|
|
40
|
+
const slice = buffer.subarray(start, start + length)
|
|
41
|
+
const end = slice.indexOf(0)
|
|
42
|
+
return (end === -1 ? slice : slice.subarray(0, end)).toString('utf8')
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function readOctal(buffer, start, length) {
|
|
46
|
+
const raw = readString(buffer, start, length).trim()
|
|
47
|
+
if (!raw) return 0
|
|
48
|
+
const value = Number.parseInt(raw, 8)
|
|
49
|
+
return Number.isFinite(value) ? value : 0
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* 把 tar 里的路径规范成安全的相对段数组。
|
|
54
|
+
*
|
|
55
|
+
* 反斜杠**当分隔符处理**:tar 用的是 posix 路径,但包里真出现一个 `a\..\..\x` 这样的名字时,
|
|
56
|
+
* 在 Windows 上它就是三个目录层级。统一按分隔符拆开再判 `..`,两个平台的结果才一致。
|
|
57
|
+
*/
|
|
58
|
+
function safeSegments(relPath) {
|
|
59
|
+
const normalized = String(relPath || '').replace(/\\/g, '/')
|
|
60
|
+
if (!normalized || normalized.includes('\u0000')) throw new Error('OTA 包包含空路径或非法字符')
|
|
61
|
+
if (normalized.startsWith('/') || /^[A-Za-z]:/.test(normalized)) throw new Error(`OTA 包含绝对路径:${relPath}`)
|
|
62
|
+
const parts = normalized.split('/').filter(part => part !== '' && part !== '.')
|
|
63
|
+
if (parts.includes('..')) throw new Error(`OTA 包含越界路径:${relPath}`)
|
|
64
|
+
return parts
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* PAX 扩展头里取 `path=`(其余键——size/mtime/uid 之类——对安装没有意义)。
|
|
69
|
+
*
|
|
70
|
+
* PAX 的每条记录是 `<十进制总长度> <键>=<值>\n`:长度写在前头,所以不能按空格切第一段
|
|
71
|
+
* 当键名。按长度逐条前进,才不会把值里的空格或换行切坏。
|
|
72
|
+
*/
|
|
73
|
+
function paxPathOf(data) {
|
|
74
|
+
const text = data.toString('utf8')
|
|
75
|
+
let offset = 0
|
|
76
|
+
let found = null
|
|
77
|
+
while (offset < text.length) {
|
|
78
|
+
const space = text.indexOf(' ', offset)
|
|
79
|
+
if (space <= offset) break
|
|
80
|
+
const length = Number.parseInt(text.slice(offset, space), 10)
|
|
81
|
+
if (!Number.isFinite(length) || length <= 0) break
|
|
82
|
+
const record = text.slice(space + 1, offset + length).replace(/\n$/, '')
|
|
83
|
+
const eq = record.indexOf('=')
|
|
84
|
+
if (eq > 0 && record.slice(0, eq) === 'path') found = record.slice(eq + 1)
|
|
85
|
+
offset += length
|
|
86
|
+
}
|
|
87
|
+
return found
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* 把一段 tgz/tar 解析成条目数组。
|
|
92
|
+
*
|
|
93
|
+
* @param {Buffer} input gzip 或裸 tar(按 gzip 魔数区分)
|
|
94
|
+
* @returns {Array<{path: string, type: 'file'|'dir', mode: number, data: Buffer|null}>}
|
|
95
|
+
*/
|
|
96
|
+
export function readEntries(input) {
|
|
97
|
+
const tar = input.length >= 2 && input[0] === 0x1f && input[1] === 0x8b ? zlib.gunzipSync(input) : input
|
|
98
|
+
const entries = []
|
|
99
|
+
let offset = 0
|
|
100
|
+
// PAX / GNU 长名是「下一个条目」的修饰头,读完那一条就清掉。
|
|
101
|
+
let pendingPath = null
|
|
102
|
+
while (offset + BLOCK <= tar.length) {
|
|
103
|
+
const header = tar.subarray(offset, offset + BLOCK)
|
|
104
|
+
if (header.every(byte => byte === 0)) break
|
|
105
|
+
const name = readString(header, 0, 100)
|
|
106
|
+
const prefix = readString(header, 345, 155)
|
|
107
|
+
const size = readOctal(header, 124, 12)
|
|
108
|
+
const mode = readOctal(header, 100, 8) || 0o644
|
|
109
|
+
const type = String.fromCharCode(header[156]) || '0'
|
|
110
|
+
const start = offset + BLOCK
|
|
111
|
+
const end = start + size
|
|
112
|
+
if (end > tar.length) {
|
|
113
|
+
throw new Error(`OTA 包被截断:条目 ${name || '(无名)'} 声明 ${size} 字节,包内不够`)
|
|
114
|
+
}
|
|
115
|
+
const data = tar.subarray(start, end)
|
|
116
|
+
offset = start + Math.ceil(size / BLOCK) * BLOCK
|
|
117
|
+
|
|
118
|
+
if (type === TYPE_PAX) {
|
|
119
|
+
pendingPath = paxPathOf(data) ?? pendingPath
|
|
120
|
+
continue
|
|
121
|
+
}
|
|
122
|
+
if (type === TYPE_PAX_GLOBAL) continue
|
|
123
|
+
if (type === TYPE_GNU_LONG_NAME) {
|
|
124
|
+
pendingPath = data.toString('utf8').replace(/\0.*$/, '')
|
|
125
|
+
continue
|
|
126
|
+
}
|
|
127
|
+
if (type === TYPE_GNU_LONG_LINK) continue
|
|
128
|
+
if (type === '1' || type === '2' || type === '3' || type === '4' || type === '6') {
|
|
129
|
+
// 软链接/硬链接/设备文件一律拒绝:npm 包里不会有,出现了要么是包坏了要么是有人在
|
|
130
|
+
// 拼接。静默跳过会让「这一代缺一个文件」在几个小时后才以别的方式暴露。
|
|
131
|
+
throw new Error(`OTA 包里有链接或设备条目(${name}),拒绝安装;打包请用 npm pack,不要用系统 tar`)
|
|
132
|
+
}
|
|
133
|
+
// PAX / GNU 长名已经把整条路径写全了(含 `package/` 前缀),有它就不再拼 prefix。
|
|
134
|
+
const fullPath = pendingPath ?? (prefix ? `${prefix}/${name}` : name)
|
|
135
|
+
pendingPath = null
|
|
136
|
+
const segments = safeSegments(fullPath)
|
|
137
|
+
if (segments[0] !== ARCHIVE_PREFIX) {
|
|
138
|
+
throw new Error(`OTA 包条目必须以 ${ARCHIVE_PREFIX}/ 打头:${fullPath}`)
|
|
139
|
+
}
|
|
140
|
+
const normalized = segments.join('/')
|
|
141
|
+
if (type === TYPE_DIR) entries.push({ path: normalized, type: 'dir', mode, data: null })
|
|
142
|
+
else if (TYPE_FILE.has(type)) entries.push({ path: normalized, type: 'file', mode, data })
|
|
143
|
+
else throw new Error(`OTA 包里有不支持的条目类型 '${type}'(${name})`)
|
|
144
|
+
}
|
|
145
|
+
if (!entries.length) throw new Error('OTA 包是空的:一个条目都没读出来')
|
|
146
|
+
return entries
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* 把包解到 `target/<packageName>`。
|
|
151
|
+
*
|
|
152
|
+
* 不解压到系统临时目录:见 `device-ota.js` 里对「暂存必须和目标同盘」的说明。
|
|
153
|
+
*
|
|
154
|
+
* @returns {{files: number}}
|
|
155
|
+
*/
|
|
156
|
+
export function unpackTarGz(input, target, { packageName = 'dsh-mobile-plugin' } = {}) {
|
|
157
|
+
const entries = readEntries(input)
|
|
158
|
+
let files = 0
|
|
159
|
+
for (const entry of entries) {
|
|
160
|
+
const rel = entry.path.split('/').slice(1)
|
|
161
|
+
if (!rel.length) continue
|
|
162
|
+
const dest = path.join(target, packageName, ...rel)
|
|
163
|
+
if (entry.type === 'dir') {
|
|
164
|
+
fs.mkdirSync(dest, { recursive: true })
|
|
165
|
+
continue
|
|
166
|
+
}
|
|
167
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true })
|
|
168
|
+
fs.writeFileSync(dest, entry.data)
|
|
169
|
+
// 可执行位只在 POSIX 上有意义;Windows 上 chmod 会变成只读标记,别去碰它。
|
|
170
|
+
if (process.platform !== 'win32' && (entry.mode & 0o111)) {
|
|
171
|
+
try { fs.chmodSync(dest, entry.mode & 0o777) } catch { /* 权限位丢了不影响 JS 加载 */ }
|
|
172
|
+
}
|
|
173
|
+
files += 1
|
|
174
|
+
}
|
|
175
|
+
return { files }
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* 读包内清单。没有清单(老版本打的包)返回 null——那时退回旧的「有 package.json 与
|
|
180
|
+
* impl.js 就算齐」判据,不让新代码把老包全判成坏的。
|
|
181
|
+
*/
|
|
182
|
+
export function readManifest(pkgDir) {
|
|
183
|
+
let raw
|
|
184
|
+
try { raw = JSON.parse(fs.readFileSync(path.join(pkgDir, MANIFEST_PATH), 'utf8')) } catch { return null }
|
|
185
|
+
if (!raw || typeof raw !== 'object' || typeof raw.version !== 'string' || !Array.isArray(raw.files)) return null
|
|
186
|
+
const files = raw.files.filter(item => item && typeof item.path === 'string' && item.path.length > 0)
|
|
187
|
+
if (files.length !== raw.files.length) return null
|
|
188
|
+
return { version: raw.version, platform: typeof raw.platform === 'string' ? raw.platform : null, files: files.map(item => ({ path: item.path, size: Number.isFinite(item.size) ? item.size : null })) }
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* 逐项核对清单。缺文件 / 大小不符立刻抛错,并把**具体是哪个文件**写进消息——
|
|
193
|
+
* 「找不到某个 JS 文件」这类模糊报错要在激活之前就变成可执行的结论。
|
|
194
|
+
*/
|
|
195
|
+
export function verifyManifest(pkgDir, manifest) {
|
|
196
|
+
if (!manifest) return 0
|
|
197
|
+
const problems = []
|
|
198
|
+
for (const item of manifest.files) {
|
|
199
|
+
const segments = safeSegments(item.path)
|
|
200
|
+
const abs = path.join(pkgDir, ...segments)
|
|
201
|
+
let stat
|
|
202
|
+
try { stat = fs.statSync(abs) } catch { problems.push(`缺文件 ${item.path}`) }
|
|
203
|
+
if (stat) {
|
|
204
|
+
if (!stat.isFile()) problems.push(`不是普通文件 ${item.path}`)
|
|
205
|
+
else if (item.size !== null && stat.size !== item.size) problems.push(`大小不符 ${item.path}(清单 ${item.size},实际 ${stat.size})`)
|
|
206
|
+
}
|
|
207
|
+
if (problems.length >= 5) break
|
|
208
|
+
}
|
|
209
|
+
if (problems.length) throw new Error(`OTA 包不完整,拒绝激活:${problems.join(';')}`)
|
|
210
|
+
return manifest.files.length
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* 移动/改名一个目录,跨盘也能成。
|
|
215
|
+
*
|
|
216
|
+
* `fs.renameSync` 在**同一文件系统**内是原子的,但 Windows 上系统临时目录与 DSH 数据目录
|
|
217
|
+
* 很可能不在一个盘,那时它抛 `EXDEV`;Windows 还可能在目标被占用时抛 `EPERM`/`EACCES`。
|
|
218
|
+
* 这条链上没有别的地方能兜住这类失败,所以在这里退化成本地复制再删源。
|
|
219
|
+
*/
|
|
220
|
+
export function movePath(src, dst) {
|
|
221
|
+
try {
|
|
222
|
+
fs.renameSync(src, dst)
|
|
223
|
+
return 'rename'
|
|
224
|
+
} catch (error) {
|
|
225
|
+
const code = error && typeof error === 'object' ? error.code : null
|
|
226
|
+
if (!['EXDEV', 'EPERM', 'EACCES', 'ENOTEMPTY', 'EEXIST'].includes(code)) throw error
|
|
227
|
+
}
|
|
228
|
+
fs.rmSync(dst, { recursive: true, force: true })
|
|
229
|
+
fs.cpSync(src, dst, { recursive: true })
|
|
230
|
+
fs.rmSync(src, { recursive: true, force: true })
|
|
231
|
+
return 'copy'
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** 下载内容的哈希。中转下发了 sha256 时用它把「拿到的包」和「发布的包」钉在一起。 */
|
|
235
|
+
export function sha256Hex(buffer) {
|
|
236
|
+
return crypto.createHash('sha256').update(buffer).digest('hex')
|
|
237
|
+
}
|
|
@@ -160,6 +160,41 @@ function snapshotRead(info) {
|
|
|
160
160
|
}
|
|
161
161
|
return body
|
|
162
162
|
}
|
|
163
|
+
/**
|
|
164
|
+
* 「这份配置里哪几项决定隧道要重建」。
|
|
165
|
+
*
|
|
166
|
+
* 只有决定**连到哪台、以谁的身份连**的字段在这里。配对码、名字、局域网候选都
|
|
167
|
+
* 不在此列——它们能在现有连接上重报一次注册信息就生效(见 reannounceRegistration)。
|
|
168
|
+
*
|
|
169
|
+
* 为什么要把这条显式写出来:以前 `configure()` 是无条件 `stopServices()` 把隧道和
|
|
170
|
+
* 局域网代理一起拆了重建,于是每次配置下发(哪怕一个字段没变)都是一次全断。真机
|
|
171
|
+
* 现场就是「切个网络,接下来几秒点什么都失败」,宿主日志里是一串「隧道关闭 + 局域网
|
|
172
|
+
* 代理开始监听」,跑几小时后内存堆到几个 G 把宿主撑爆。
|
|
173
|
+
*/
|
|
174
|
+
function tunnelSignature(c) {
|
|
175
|
+
return JSON.stringify([Boolean(c.enabled), c.relayUrl || '', c.instanceId || '', c.upstreamPort || 0, c.registerSecret || ''])
|
|
176
|
+
}
|
|
177
|
+
/** 「这份配置里哪几项决定局域网代理要重建」。 */
|
|
178
|
+
function lanSignature(c) {
|
|
179
|
+
return JSON.stringify([Boolean(c.enabled), Boolean(c.lanEnabled), c.lanPort || 0, c.upstreamPort || 0, c.instanceId || '', c.pairingCode || '', c.lanSecret || ''])
|
|
180
|
+
}
|
|
181
|
+
/** 「这份配置里哪几项决定网络监视器要不要重排」。 */
|
|
182
|
+
function monitorSignature(c) {
|
|
183
|
+
return JSON.stringify([Boolean(c.enabled), c.relayUrl || '', Boolean(c.lanEnabled)])
|
|
184
|
+
}
|
|
185
|
+
/** 当前应该上报给中转的局域网候选。 */
|
|
186
|
+
function currentLanUrls() { return config.lanEnabled ? lanUrls(lanUrlPort()) : [] }
|
|
187
|
+
/**
|
|
188
|
+
* 局域网候选变了:只重报注册信息,**不重建隧道**。
|
|
189
|
+
*
|
|
190
|
+
* 重建一次要把手机所有在途请求连同局域网监听一起拆掉,而这条路径(地址变化、实际
|
|
191
|
+
* 端口与首选端口不一致)本来就只是「名片上的地址要更新」。隧道还没连上时不用额外
|
|
192
|
+
* 做事:下一次 startTunnel 会按当前状态现算候选。
|
|
193
|
+
*/
|
|
194
|
+
function reannounceRegistration() {
|
|
195
|
+
if (!tunnel) return
|
|
196
|
+
tunnel.updateRegistration({ lanUrls: currentLanUrls() })
|
|
197
|
+
}
|
|
163
198
|
function stopNetworkMonitor() {
|
|
164
199
|
if (networkTimer) { clearInterval(networkTimer); networkTimer = null }
|
|
165
200
|
}
|
|
@@ -178,8 +213,7 @@ function startNetworkMonitor() {
|
|
|
178
213
|
const current = lanUrls(lanUrlPort())
|
|
179
214
|
if (JSON.stringify(current) === JSON.stringify(previous)) return
|
|
180
215
|
previous = current
|
|
181
|
-
|
|
182
|
-
startTunnel()
|
|
216
|
+
reannounceRegistration()
|
|
183
217
|
}, NETWORK_POLL_MS)
|
|
184
218
|
}
|
|
185
219
|
function stopLan() {
|
|
@@ -196,7 +230,7 @@ function startTunnel() {
|
|
|
196
230
|
relayUrl: core.tunnelWsUrl(config.relayUrl), instanceId: config.instanceId, pairingCode: config.pairingCode,
|
|
197
231
|
name: config.name, upstream: upstream(),
|
|
198
232
|
registerSecret: config.registerSecret,
|
|
199
|
-
lanUrls:
|
|
233
|
+
lanUrls: currentLanUrls(),
|
|
200
234
|
readSnapshot: snapshotRead,
|
|
201
235
|
// 实例身份守卫:只有还活着的那个隧道实例才许改状态。onClose / onPairRequest /
|
|
202
236
|
// start().catch 都有这道闸,只有 onRegistered 漏了——而它恰恰是最容易迟到的一个
|
|
@@ -251,9 +285,9 @@ function startLan() {
|
|
|
251
285
|
if (bound > 0) lanBoundPort = bound
|
|
252
286
|
publishState()
|
|
253
287
|
// 实际端口和首选端口不同时,中转侧那份 lanUrls(注册时随 metadata 上报的)
|
|
254
|
-
//
|
|
255
|
-
//
|
|
256
|
-
if (changed
|
|
288
|
+
// 带的就是首选端口,手机从公网拿到的局域网候选全是死地址。重报一次注册信息就
|
|
289
|
+
// 够了——重建隧道会把手机在途请求全掐掉,代价远大于这条名片更新。
|
|
290
|
+
if (changed) reannounceRegistration()
|
|
257
291
|
}).catch(error => { if (lan === current) { lanState = 'error'; lanError = error instanceof Error ? error.message : String(error); publishState() } })
|
|
258
292
|
}
|
|
259
293
|
async function configure(next) {
|
|
@@ -265,12 +299,34 @@ async function configure(next) {
|
|
|
265
299
|
if (typeof next.upstreamPort !== 'number' || !Number.isInteger(next.upstreamPort) || next.upstreamPort < 1 || next.upstreamPort > 65535) throw new Error('invalid upstreamPort')
|
|
266
300
|
if (next.lanPort !== undefined && (!Number.isInteger(next.lanPort) || next.lanPort < 1024 || next.lanPort > 65535)) throw new Error('invalid lanPort')
|
|
267
301
|
if (next.registerSecret !== undefined && (typeof next.registerSecret !== 'string' || next.registerSecret.length > 512)) throw new Error('invalid registerSecret')
|
|
268
|
-
|
|
302
|
+
// 只重建真正变了的那一半:
|
|
303
|
+
// - 决定隧道「连到哪台、以谁的身份连」的字段变了才重建隧道;
|
|
304
|
+
// - 决定局域网监听的字段变了才重建局域网代理;
|
|
305
|
+
// - 其余(配对码 / 名字 / 局域网候选)在现有连接上重报一次注册信息即可。
|
|
306
|
+
// 无条件全拆全建就是「每隔几分钟断一次 + 内存一直涨」的根因,别改回去。
|
|
307
|
+
const previous = config
|
|
308
|
+
const tunnelNeedsRebuild = tunnelSignature(previous) !== tunnelSignature(next)
|
|
309
|
+
const lanNeedsRebuild = lanSignature(previous) !== lanSignature(next)
|
|
310
|
+
const monitorNeedsRestart = monitorSignature(previous) !== monitorSignature(next)
|
|
269
311
|
config = { ...next }
|
|
270
|
-
if (
|
|
312
|
+
if (tunnelNeedsRebuild) {
|
|
313
|
+
stopTunnel()
|
|
271
314
|
startTunnel()
|
|
315
|
+
}
|
|
316
|
+
if (lanNeedsRebuild) {
|
|
317
|
+
stopLan()
|
|
318
|
+
startLan()
|
|
319
|
+
}
|
|
320
|
+
if (!tunnelNeedsRebuild && tunnel) {
|
|
321
|
+
const patch = {}
|
|
322
|
+
if (previous.pairingCode !== config.pairingCode) patch.pairingCode = config.pairingCode
|
|
323
|
+
if (previous.name !== config.name) patch.name = config.name
|
|
324
|
+
if (lanNeedsRebuild) patch.lanUrls = currentLanUrls()
|
|
325
|
+
if (Object.keys(patch).length) tunnel.updateRegistration(patch)
|
|
326
|
+
}
|
|
327
|
+
if (monitorNeedsRestart) {
|
|
328
|
+
stopNetworkMonitor()
|
|
272
329
|
startNetworkMonitor()
|
|
273
|
-
if (config.lanEnabled) startLan()
|
|
274
330
|
}
|
|
275
331
|
return status()
|
|
276
332
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-mobile-plugin",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.85",
|
|
4
4
|
"description": "Mobile API (/api/mobile/v1) and relay client for the DSH mobile app — desktop profile build (0.2.0-rc.2 compatible, peers resolved by the host)",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|