@twinsearth/dsh-2xray 0.2.8 → 0.2.10
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/CHANGELOG.md +22 -0
- package/README.md +16 -0
- package/index.js +73 -0
- package/lib/binary.js +23 -1
- package/lib/client.js +65 -0
- package/lib/config.js +4 -0
- package/lib/schema.js +6 -0
- package/lib/update.js +144 -0
- package/package.json +4 -1
- package/test/binary.test.js +12 -1
- package/test/update.test.js +41 -0
- package/tools/build-core-packages.mjs +114 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,27 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.2.10 — 2026-10-29
|
|
4
|
+
|
|
5
|
+
### 更新
|
|
6
|
+
|
|
7
|
+
- 设置页新增「检测更新 / 立即更新 / 自动更新」。
|
|
8
|
+
- 检测:读取 npm registry 的 `latest` 并做 semver 比较。
|
|
9
|
+
- 立即更新:下载 tarball、校验 sha512(或 sha1)、解压并以 rename + 回滚方式替换插件目录;返回「重启 DSH 生效」。
|
|
10
|
+
- 自动更新:`updates.auto` 开启后,启动 5 秒检查并安装;默认关闭,不自动重启应用。
|
|
11
|
+
- 新增鉴权接口 `GET/POST /api/xray/update`,状态同时出现在 `xray_status.update`。
|
|
12
|
+
|
|
13
|
+
## 0.2.9 — 2026-10-29
|
|
14
|
+
|
|
15
|
+
### 内核分发
|
|
16
|
+
|
|
17
|
+
- 新增按平台拆分的可选内核包(`@twinsearth/dsh-2xray-core-<os>-<arch>`),主包通过 `optionalDependencies` 引用,npm 只装当前平台。
|
|
18
|
+
- `resolveBinary()` 新增 `core-package` 来源;未安装时继续回退到插件 `bin/`、数据目录、v2rayN 发现与 PATH。
|
|
19
|
+
- 新增 `tools/build-core-packages.mjs`(官方 zip → npm 包)与 `.github/workflows/core-packages.yml`(下载、`.dgst` 校验、发布;无 `NPM_TOKEN` 时只上传 artifact)。
|
|
20
|
+
|
|
21
|
+
### 验证
|
|
22
|
+
|
|
23
|
+
- 真机 xray 26.9.30 对 global / cn-direct / whitelist 三种配置执行 `run -test` 全部通过(含 geodata 与 IPIfNonMatch)。
|
|
24
|
+
|
|
3
25
|
## 0.2.8 — 2026-10-29
|
|
4
26
|
|
|
5
27
|
### 修复
|
package/README.md
CHANGED
|
@@ -328,6 +328,22 @@ v0.2.0 提供两个入口,互不替代:
|
|
|
328
328
|
| 状态一直 `degraded` + `route-install-failed` | 查日志里的模块解析诊断;必要时确认 DSH 安装目录结构 |
|
|
329
329
|
| `xray_switch_node` 报 `unknown service ... RoutingService` | xray 版本早于 2024-09;升级二进制,或关闭 `control.enabled` 只用 `activeNode` |
|
|
330
330
|
|
|
331
|
+
### 平台内核包
|
|
332
|
+
|
|
333
|
+
`@twinsearth/dsh-2xray` 通过 `optionalDependencies` 引用按平台拆分的核心包(例如 `@twinsearth/dsh-2xray-core-win32-x64`),npm 只安装当前平台那一个。本地没有可用内核包时,仍按 `插件 bin/ → 数据目录 → v2rayN 发现 → PATH` 回退,或用 `tools/install-xray-core.mjs` 安装。
|
|
334
|
+
|
|
335
|
+
维护者用 `tools/build-core-packages.mjs` 从官方 zip 生成平台包;`.github/workflows/core-packages.yml` 会在具备 `NPM_TOKEN` secret 时自动下载、校验并发布各平台包。
|
|
336
|
+
|
|
337
|
+
## 更新
|
|
338
|
+
|
|
339
|
+
设置页 2Xray 里有三个入口:
|
|
340
|
+
|
|
341
|
+
- **检测更新**:查询 npm 上的 `latest`,显示当前/最新版本。
|
|
342
|
+
- **立即更新**:下载并校验 npm tarball(sha512),原子替换插件目录;**重启 DSH 后生效**。
|
|
343
|
+
- **自动更新**:启动 5 秒后检查并安装新版本(默认关闭;同样需要重启)。
|
|
344
|
+
|
|
345
|
+
Host API:`GET/POST /api/xray/update`(走 `ctx.connection.fetch` 鉴权围栏)。
|
|
346
|
+
|
|
331
347
|
## 路由模式与黑白名单
|
|
332
348
|
|
|
333
349
|
- `global`(默认):全部流量走 2Xray。
|
package/index.js
CHANGED
|
@@ -8,6 +8,7 @@ import { ControlClient } from './lib/control/client.js'
|
|
|
8
8
|
import { installTools } from './lib/tools.js'
|
|
9
9
|
import { LogBuffer } from './lib/logbuf.js'
|
|
10
10
|
import { Monitor } from './lib/monitor.js'
|
|
11
|
+
import { checkForUpdate, installUpdate, ownVersion } from './lib/update.js'
|
|
11
12
|
|
|
12
13
|
export const name = 'dsh-2xray'
|
|
13
14
|
|
|
@@ -120,6 +121,29 @@ export function apply(ctx, rawConfig) {
|
|
|
120
121
|
logger,
|
|
121
122
|
})
|
|
122
123
|
|
|
124
|
+
const updateState = {
|
|
125
|
+
current: ownVersion(),
|
|
126
|
+
latest: null,
|
|
127
|
+
available: false,
|
|
128
|
+
checkedAt: null,
|
|
129
|
+
auto: config.updates?.auto === true,
|
|
130
|
+
restartRequired: false,
|
|
131
|
+
error: null,
|
|
132
|
+
}
|
|
133
|
+
let updateFlight = null
|
|
134
|
+
const checkUpdate = async () => {
|
|
135
|
+
if (updateFlight) return updateFlight
|
|
136
|
+
updateFlight = checkForUpdate({ current: updateState.current })
|
|
137
|
+
.then((info) => {
|
|
138
|
+
Object.assign(updateState, info, { auto: config.updates?.auto === true, error: info.error ?? null })
|
|
139
|
+
return info
|
|
140
|
+
})
|
|
141
|
+
.finally(() => {
|
|
142
|
+
updateFlight = null
|
|
143
|
+
})
|
|
144
|
+
return updateFlight
|
|
145
|
+
}
|
|
146
|
+
|
|
123
147
|
// Authenticated status/log feed for the browser panel. Optional service: a
|
|
124
148
|
// profile without `connection` keeps the proxy and the tools, it only loses
|
|
125
149
|
// the live panel data.
|
|
@@ -147,6 +171,7 @@ export function apply(ctx, rawConfig) {
|
|
|
147
171
|
proxied,
|
|
148
172
|
vpnName: node?.id ?? snapshot.activeNode ?? null,
|
|
149
173
|
probe: monitor.snapshot(),
|
|
174
|
+
update: updateState,
|
|
150
175
|
},
|
|
151
176
|
logs: page.items,
|
|
152
177
|
cursor: page.cursor,
|
|
@@ -176,12 +201,60 @@ export function apply(ctx, rawConfig) {
|
|
|
176
201
|
}),
|
|
177
202
|
'dsh-2xray: switch api',
|
|
178
203
|
)
|
|
204
|
+
child.effect(
|
|
205
|
+
() =>
|
|
206
|
+
child.connection.fetch.register({
|
|
207
|
+
path: '/api/xray/update',
|
|
208
|
+
methods: ['GET', 'POST'],
|
|
209
|
+
requestBody: 'buffered',
|
|
210
|
+
fetch: async (request) => {
|
|
211
|
+
try {
|
|
212
|
+
if (request.method === 'GET') {
|
|
213
|
+
const url = new URL(request.url)
|
|
214
|
+
const stale = !updateState.checkedAt || Date.now() - updateState.checkedAt > 600_000
|
|
215
|
+
if (stale || url.searchParams.get('refresh') === '1') await checkUpdate()
|
|
216
|
+
return Response.json({ ok: true, update: updateState })
|
|
217
|
+
}
|
|
218
|
+
const body = await request.json().catch(() => ({}))
|
|
219
|
+
if (body.action === 'install') {
|
|
220
|
+
const info = updateState.available ? updateState : await checkUpdate()
|
|
221
|
+
if (!info.available) return Response.json({ ok: false, error: '已是最新版本', update: updateState }, { status: 409 })
|
|
222
|
+
const result = await installUpdate(info, { logger })
|
|
223
|
+
updateState.restartRequired = result.ok === true
|
|
224
|
+
updateState.latest = info.latest
|
|
225
|
+
return Response.json({ ok: result.ok, update: updateState, result })
|
|
226
|
+
}
|
|
227
|
+
await checkUpdate()
|
|
228
|
+
return Response.json({ ok: true, update: updateState })
|
|
229
|
+
} catch (error) {
|
|
230
|
+
return Response.json({ ok: false, error: String(error?.message ?? error) }, { status: 400 })
|
|
231
|
+
}
|
|
232
|
+
},
|
|
233
|
+
}),
|
|
234
|
+
'dsh-2xray: update api',
|
|
235
|
+
)
|
|
179
236
|
} catch (error) {
|
|
180
237
|
logger.warn(`状态 API 注册失败:${String(error)}`)
|
|
181
238
|
}
|
|
182
239
|
})
|
|
183
240
|
}
|
|
184
241
|
|
|
242
|
+
// Opt-in auto-update: check once per start and install a newer tarball.
|
|
243
|
+
// Loading the new code still requires a DSH restart, so this never restarts
|
|
244
|
+
// the app behind the user's back.
|
|
245
|
+
if (config.updates?.auto) {
|
|
246
|
+
const timer = setTimeout(() => {
|
|
247
|
+
;(async () => {
|
|
248
|
+
const info = await checkUpdate()
|
|
249
|
+
if (!info.available) return
|
|
250
|
+
const result = await installUpdate(info, { logger })
|
|
251
|
+
updateState.restartRequired = result.ok === true
|
|
252
|
+
if (result.ok) logger.info(`自动更新完成:${info.latest};重启 DSH 后生效`)
|
|
253
|
+
})().catch((error) => logger.warn(`自动更新检查失败:${String(error)}`))
|
|
254
|
+
}, 5000)
|
|
255
|
+
timer.unref?.()
|
|
256
|
+
}
|
|
257
|
+
|
|
185
258
|
const dispose = ctx.effect(
|
|
186
259
|
() => async () => {
|
|
187
260
|
await supervisor.stop()
|
package/lib/binary.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { accessSync, constants, existsSync, readdirSync } from 'node:fs'
|
|
2
|
-
import {
|
|
2
|
+
import { createRequire } from 'node:module'
|
|
3
|
+
import { delimiter, dirname, join } from 'node:path'
|
|
3
4
|
import { fileURLToPath } from 'node:url'
|
|
4
5
|
|
|
5
6
|
/** Package root, so a bundle that ships its own `bin/xray(.exe)` is found first. */
|
|
@@ -29,6 +30,23 @@ function executableName() {
|
|
|
29
30
|
return process.platform === 'win32' ? 'xray.exe' : 'xray'
|
|
30
31
|
}
|
|
31
32
|
|
|
33
|
+
/**
|
|
34
|
+
* Platform cores are published as separate optional packages so the main
|
|
35
|
+
* package stays small. npm installs only the matching os/cpu one.
|
|
36
|
+
*/
|
|
37
|
+
export function optionalCorePaths({ platform = process.platform, arch = process.arch, resolvePackage } = {}) {
|
|
38
|
+
const name = `@twinsearth/dsh-2xray-core-${platform}-${arch}`
|
|
39
|
+
const resolver = resolvePackage ?? ((specifier) => createRequire(import.meta.url).resolve(specifier))
|
|
40
|
+
try {
|
|
41
|
+
const manifest = resolver(`${name}/package.json`)
|
|
42
|
+
const base = dirname(manifest)
|
|
43
|
+
const executable = platform === 'win32' ? 'xray.exe' : 'xray'
|
|
44
|
+
return [...new Set([join(base, 'bin', executable), join(base, 'bin', 'xray')])]
|
|
45
|
+
} catch {
|
|
46
|
+
return []
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
32
50
|
function isExecutable(file) {
|
|
33
51
|
try {
|
|
34
52
|
accessSync(file, constants.X_OK)
|
|
@@ -56,12 +74,16 @@ export async function resolveBinary({
|
|
|
56
74
|
packageRoot = PACKAGE_ROOT,
|
|
57
75
|
discoveryRoots,
|
|
58
76
|
discoveryOptions = {},
|
|
77
|
+
resolvePackage,
|
|
59
78
|
}) {
|
|
60
79
|
const candidates = []
|
|
61
80
|
const explicit = config.binary.path?.trim()
|
|
62
81
|
if (explicit) candidates.push({ path: explicit, source: 'config' })
|
|
63
82
|
const fromEnv = process.env.DSH_XRAY_BIN?.trim()
|
|
64
83
|
if (fromEnv) candidates.push({ path: fromEnv, source: 'env' })
|
|
84
|
+
for (const path of optionalCorePaths({ resolvePackage })) {
|
|
85
|
+
candidates.push({ path, source: 'core-package' })
|
|
86
|
+
}
|
|
65
87
|
candidates.push({ path: join(packageRoot, 'bin', executableName()), source: 'bundled' })
|
|
66
88
|
candidates.push({ path: join(packageRoot, 'bin', 'xray'), source: 'bundled' })
|
|
67
89
|
candidates.push({ path: join(dataDir, 'bin', executableName()), source: 'managed' })
|
package/lib/client.js
CHANGED
|
@@ -316,6 +316,9 @@ window.__ModuleLoader__.load({
|
|
|
316
316
|
const [routingMode, setRoutingMode] = React.useState(value.routing?.mode ?? 'global')
|
|
317
317
|
const [whitelist, setWhitelist] = React.useState((value.routing?.whitelist ?? []).join('\n'))
|
|
318
318
|
const [blacklist, setBlacklist] = React.useState((value.routing?.blacklist ?? []).join('\n'))
|
|
319
|
+
const [autoUpdate, setAutoUpdate] = React.useState(value.updates?.auto === true)
|
|
320
|
+
const [updateInfo, setUpdateInfo] = React.useState(null)
|
|
321
|
+
const [updateBusy, setUpdateBusy] = React.useState('')
|
|
319
322
|
const [message, setMessage] = React.useState('')
|
|
320
323
|
const [kind, setKind] = React.useState('')
|
|
321
324
|
const [busy, setBusy] = React.useState(false)
|
|
@@ -329,6 +332,47 @@ window.__ModuleLoader__.load({
|
|
|
329
332
|
setKind(tone || '')
|
|
330
333
|
}
|
|
331
334
|
|
|
335
|
+
const loadUpdate = async (refresh = false) => {
|
|
336
|
+
try {
|
|
337
|
+
const response = await fetch(new URL(`api/xray/update${refresh ? '?refresh=1' : ''}`, document.baseURI), {
|
|
338
|
+
credentials: 'same-origin',
|
|
339
|
+
headers: { accept: 'application/json' },
|
|
340
|
+
})
|
|
341
|
+
const data = await response.json()
|
|
342
|
+
if (data.update) setUpdateInfo(data.update)
|
|
343
|
+
return data
|
|
344
|
+
} catch (error) {
|
|
345
|
+
say('检查更新失败:' + (error.message || error), 'error')
|
|
346
|
+
return null
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
const installNow = async () => {
|
|
351
|
+
setUpdateBusy('install')
|
|
352
|
+
try {
|
|
353
|
+
const response = await fetch(new URL('api/xray/update', document.baseURI), {
|
|
354
|
+
method: 'POST',
|
|
355
|
+
credentials: 'same-origin',
|
|
356
|
+
headers: { 'content-type': 'application/json' },
|
|
357
|
+
body: JSON.stringify({ action: 'install' }),
|
|
358
|
+
})
|
|
359
|
+
const data = await response.json()
|
|
360
|
+
if (data.update) setUpdateInfo(data.update)
|
|
361
|
+
say(
|
|
362
|
+
data.ok ? `已下载 ${data.update?.latest ?? '新版本'},重启 DSH 后生效。` : '更新失败:' + (data.error || '未知错误'),
|
|
363
|
+
data.ok ? 'ok' : 'error',
|
|
364
|
+
)
|
|
365
|
+
} catch (error) {
|
|
366
|
+
say('更新失败:' + (error.message || error), 'error')
|
|
367
|
+
} finally {
|
|
368
|
+
setUpdateBusy('')
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
React.useEffect(() => {
|
|
373
|
+
loadUpdate(false)
|
|
374
|
+
}, [])
|
|
375
|
+
|
|
332
376
|
const applyDecoded = (text) => {
|
|
333
377
|
const trimmed = String(text || '').trim()
|
|
334
378
|
if (!trimmed) return
|
|
@@ -426,6 +470,7 @@ window.__ModuleLoader__.load({
|
|
|
426
470
|
{ path: ['routing', 'mode'], value: routingMode },
|
|
427
471
|
{ path: ['routing', 'whitelist'], value: splitList(whitelist) },
|
|
428
472
|
{ path: ['routing', 'blacklist'], value: splitList(blacklist) },
|
|
473
|
+
{ path: ['updates', 'auto'], value: autoUpdate },
|
|
429
474
|
]
|
|
430
475
|
if (binaryPath.trim()) fields.push({ path: ['binary', 'path'], value: binaryPath.trim() })
|
|
431
476
|
const numericPort = Number(port)
|
|
@@ -483,6 +528,26 @@ window.__ModuleLoader__.load({
|
|
|
483
528
|
placeholder: 'deepseek.com\napi.deepseek.com',
|
|
484
529
|
onChange: (event) => setBlacklist(event.target.value),
|
|
485
530
|
})),
|
|
531
|
+
h('div', { style: { ...C.cardHead, marginTop: 14 } }, '更新'),
|
|
532
|
+
h('div', { style: { display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'center' } },
|
|
533
|
+
h('button', {
|
|
534
|
+
type: 'button',
|
|
535
|
+
style: C.btn,
|
|
536
|
+
onClick: () => loadUpdate(true),
|
|
537
|
+
}, '检测更新'),
|
|
538
|
+
h('button', {
|
|
539
|
+
type: 'button',
|
|
540
|
+
style: C.primaryBtn,
|
|
541
|
+
disabled: updateBusy === 'install' || !updateInfo?.available,
|
|
542
|
+
onClick: installNow,
|
|
543
|
+
}, updateBusy === 'install' ? '更新中…' : '立即更新'),
|
|
544
|
+
h('label', { style: { fontSize: 12.5, display: 'flex', alignItems: 'center', gap: 6 } },
|
|
545
|
+
h('input', { type: 'checkbox', checked: autoUpdate, onChange: (event) => setAutoUpdate(event.target.checked) }), '自动更新')),
|
|
546
|
+
h('div', { style: { ...C.muted, fontSize: 12, marginTop: 6 } },
|
|
547
|
+
updateInfo
|
|
548
|
+
? `当前 ${updateInfo.current} · 最新 ${updateInfo.latest ?? '未知'}${updateInfo.available ? '(可更新)' : '(已是最新)'}` +
|
|
549
|
+
(updateInfo.restartRequired ? ' · 已下载,重启 DSH 生效' : '')
|
|
550
|
+
: '点击「检测更新」查询 npm 上的最新版本'),
|
|
486
551
|
h(Field, { label: 'xray 可执行文件路径(留空自动查找)' },
|
|
487
552
|
h('input', { style: { ...C.input, fontFamily: 'ui-monospace, Consolas, monospace' }, value: binaryPath, placeholder: '例如 D:\\v2rayN\\...\\bin\\xray\\xray.exe', onChange: (event) => setBinaryPath(event.target.value) })),
|
|
488
553
|
h(Field, { label: '本地 HTTP 入站端口' },
|
package/lib/config.js
CHANGED
|
@@ -69,6 +69,7 @@ export const DEFAULT_CONFIG = {
|
|
|
69
69
|
balancerTag: 'active',
|
|
70
70
|
timeoutMs: 3000,
|
|
71
71
|
},
|
|
72
|
+
updates: { auto: false },
|
|
72
73
|
binary: { path: '', dataDir: '' },
|
|
73
74
|
lifecycle: {
|
|
74
75
|
startTimeoutMs: 15000,
|
|
@@ -350,6 +351,9 @@ export function normalizeConfig(raw) {
|
|
|
350
351
|
merged.noProxy = [...new Set([...existing, ...merged.routing.blacklist])].join(',')
|
|
351
352
|
}
|
|
352
353
|
|
|
354
|
+
merged.updates = merged.updates ?? {}
|
|
355
|
+
merged.updates.auto = asBool(merged.updates.auto, false)
|
|
356
|
+
|
|
353
357
|
const control = merged.control
|
|
354
358
|
control.enabled = asBool(control.enabled, false)
|
|
355
359
|
control.host = asString(control.host, '127.0.0.1')
|
package/lib/schema.js
CHANGED
|
@@ -107,4 +107,10 @@ export const Config = Schema.object({
|
|
|
107
107
|
.description('强制直连的域名(优先于白名单与分流)。')
|
|
108
108
|
.volatile(),
|
|
109
109
|
}),
|
|
110
|
+
updates: Schema.object({
|
|
111
|
+
auto: Schema.boolean()
|
|
112
|
+
.default(false)
|
|
113
|
+
.description('启动时检查 npm 上的新版本并自动安装;重启 DSH 后生效。')
|
|
114
|
+
.volatile(),
|
|
115
|
+
}),
|
|
110
116
|
})
|
package/lib/update.js
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs'
|
|
3
|
+
import { tmpdir } from 'node:os'
|
|
4
|
+
import { dirname, join } from 'node:path'
|
|
5
|
+
import { spawnSync } from 'node:child_process'
|
|
6
|
+
import { fileURLToPath } from 'node:url'
|
|
7
|
+
|
|
8
|
+
const PACKAGE_ROOT = fileURLToPath(new URL('../', import.meta.url))
|
|
9
|
+
const PACKAGE_NAME = '@twinsearth/dsh-2xray'
|
|
10
|
+
const REGISTRY = 'https://registry.npmjs.org'
|
|
11
|
+
|
|
12
|
+
function readOwnVersion() {
|
|
13
|
+
try {
|
|
14
|
+
return JSON.parse(readFileSync(join(PACKAGE_ROOT, 'package.json'), 'utf8')).version
|
|
15
|
+
} catch {
|
|
16
|
+
return '0.0.0'
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Numeric semver comparison; a prerelease sorts before the release. */
|
|
21
|
+
export function compareVersions(left, right) {
|
|
22
|
+
const parse = (value) => {
|
|
23
|
+
const [core, pre] = String(value).replace(/^v/, '').split('-', 2)
|
|
24
|
+
const parts = core.split('.').map((part) => Number.parseInt(part, 10) || 0)
|
|
25
|
+
return { parts, pre: pre ?? '' }
|
|
26
|
+
}
|
|
27
|
+
const a = parse(left)
|
|
28
|
+
const b = parse(right)
|
|
29
|
+
for (let index = 0; index < 3; index++) {
|
|
30
|
+
if (a.parts[index] !== b.parts[index]) return a.parts[index] > b.parts[index] ? 1 : -1
|
|
31
|
+
}
|
|
32
|
+
if (a.pre === b.pre) return 0
|
|
33
|
+
if (a.pre === '') return 1
|
|
34
|
+
if (b.pre === '') return -1
|
|
35
|
+
return a.pre > b.pre ? 1 : -1
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function checkForUpdate({ current = readOwnVersion(), registry = REGISTRY, packageName = PACKAGE_NAME } = {}) {
|
|
39
|
+
return fetch(`${registry}/${encodeURIComponent(packageName)}/latest`, {
|
|
40
|
+
headers: { accept: 'application/json', 'cache-control': 'no-cache' },
|
|
41
|
+
signal: AbortSignal.timeout(15000),
|
|
42
|
+
})
|
|
43
|
+
.then(async (response) => {
|
|
44
|
+
if (!response.ok) throw new Error(`registry HTTP ${response.status}`)
|
|
45
|
+
const meta = await response.json()
|
|
46
|
+
const latest = String(meta.version ?? '')
|
|
47
|
+
if (!latest) throw new Error('registry response has no version')
|
|
48
|
+
return {
|
|
49
|
+
current,
|
|
50
|
+
latest,
|
|
51
|
+
available: compareVersions(latest, current) > 0,
|
|
52
|
+
checkedAt: Date.now(),
|
|
53
|
+
tarball: meta.dist?.tarball,
|
|
54
|
+
integrity: meta.dist?.integrity,
|
|
55
|
+
shasum: meta.dist?.shasum,
|
|
56
|
+
}
|
|
57
|
+
})
|
|
58
|
+
.catch((error) => ({ current, latest: null, available: false, error: String(error.message ?? error), checkedAt: Date.now() }))
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function verify(bytes, { integrity, shasum }) {
|
|
62
|
+
if (integrity?.startsWith('sha512-')) {
|
|
63
|
+
const actual = 'sha512-' + createHash('sha512').update(bytes).digest('base64')
|
|
64
|
+
return actual === integrity
|
|
65
|
+
}
|
|
66
|
+
if (shasum) {
|
|
67
|
+
return createHash('sha1').update(bytes).digest('hex') === shasum
|
|
68
|
+
}
|
|
69
|
+
return false
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function extractTarball(file, destination) {
|
|
73
|
+
const attempts = [
|
|
74
|
+
['tar', ['-xzf', file, '-C', destination]],
|
|
75
|
+
['tar', ['-xf', file, '-C', destination]],
|
|
76
|
+
]
|
|
77
|
+
for (const [command, argv] of attempts) {
|
|
78
|
+
if (spawnSync(command, argv, { stdio: 'ignore' }).status === 0) return true
|
|
79
|
+
}
|
|
80
|
+
return false
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function copyTree(source, target) {
|
|
84
|
+
mkdirSync(target, { recursive: true })
|
|
85
|
+
for (const entry of readdirSync(source, { withFileTypes: true })) {
|
|
86
|
+
const from = join(source, entry.name)
|
|
87
|
+
const to = join(target, entry.name)
|
|
88
|
+
if (entry.isDirectory()) copyTree(from, to)
|
|
89
|
+
else if (entry.isFile()) copyFileSync(from, to)
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Install a newer tarball over the running package directory. The swap is
|
|
95
|
+
* rename-based with a rollback copy, and the caller must restart DSH: the
|
|
96
|
+
* already-loaded modules keep running the old code.
|
|
97
|
+
*/
|
|
98
|
+
export async function installUpdate(info, { packageRoot = PACKAGE_ROOT, logger } = {}) {
|
|
99
|
+
if (!info?.available || !info.tarball) return { ok: false, error: 'no update available' }
|
|
100
|
+
const workspace = mkdtempSync(join(tmpdir(), 'dsh-2xray-update-'))
|
|
101
|
+
const target = packageRoot
|
|
102
|
+
const backup = `${target}.bak-${Date.now()}`
|
|
103
|
+
try {
|
|
104
|
+
const response = await fetch(info.tarball, { redirect: 'follow', signal: AbortSignal.timeout(180000) })
|
|
105
|
+
if (!response.ok) throw new Error(`tarball HTTP ${response.status}`)
|
|
106
|
+
const bytes = Buffer.from(await response.arrayBuffer())
|
|
107
|
+
if (!verify(bytes, info)) throw new Error('tarball integrity check failed')
|
|
108
|
+
const archive = join(workspace, 'package.tgz')
|
|
109
|
+
writeFileSync(archive, bytes)
|
|
110
|
+
const extracted = join(workspace, 'out')
|
|
111
|
+
mkdirSync(extracted, { recursive: true })
|
|
112
|
+
if (!extractTarball(archive, extracted)) throw new Error('could not extract tarball')
|
|
113
|
+
const packageDir = join(extracted, 'package')
|
|
114
|
+
if (!existsSync(join(packageDir, 'package.json'))) throw new Error('tarball has no package/ directory')
|
|
115
|
+
renameSync(target, backup)
|
|
116
|
+
try {
|
|
117
|
+
copyTree(packageDir, target)
|
|
118
|
+
} catch (error) {
|
|
119
|
+
rmSync(target, { recursive: true, force: true })
|
|
120
|
+
renameSync(backup, target)
|
|
121
|
+
throw error
|
|
122
|
+
}
|
|
123
|
+
rmSync(backup, { recursive: true, force: true })
|
|
124
|
+
logger?.info?.(`已更新到 ${info.latest},重启 DSH 后生效`)
|
|
125
|
+
return { ok: true, version: info.latest, restartRequired: true }
|
|
126
|
+
} catch (error) {
|
|
127
|
+
logger?.error?.(`更新失败:${String(error.message ?? error)}`)
|
|
128
|
+
return { ok: false, error: String(error.message ?? error) }
|
|
129
|
+
} finally {
|
|
130
|
+
rmSync(workspace, { recursive: true, force: true })
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function ownVersion() {
|
|
135
|
+
return readOwnVersion()
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function packageSize(path) {
|
|
139
|
+
try {
|
|
140
|
+
return statSync(path).size
|
|
141
|
+
} catch {
|
|
142
|
+
return 0
|
|
143
|
+
}
|
|
144
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@twinsearth/dsh-2xray",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.10",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "DSH-2Xray: supervises xray-core and routes DSH process-wide HTTP(S) traffic through its local inbound, with optional multi-node gRPC control plane",
|
|
6
6
|
"type": "module",
|
|
@@ -48,5 +48,8 @@
|
|
|
48
48
|
],
|
|
49
49
|
"platform": "web"
|
|
50
50
|
}
|
|
51
|
+
},
|
|
52
|
+
"optionalDependencies": {
|
|
53
|
+
"@twinsearth/dsh-2xray-core-win32-x64": "0.2.9"
|
|
51
54
|
}
|
|
52
55
|
}
|
package/test/binary.test.js
CHANGED
|
@@ -3,7 +3,7 @@ import assert from 'node:assert/strict'
|
|
|
3
3
|
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
|
4
4
|
import { tmpdir } from 'node:os'
|
|
5
5
|
import { join } from 'node:path'
|
|
6
|
-
import { BinaryError, resolveBinary } from '../lib/binary.js'
|
|
6
|
+
import { BinaryError, optionalCorePaths, resolveBinary } from '../lib/binary.js'
|
|
7
7
|
import { normalizeConfig } from '../lib/config.js'
|
|
8
8
|
|
|
9
9
|
const packageRoot = join('C:', 'plugins', 'dsh-2xray')
|
|
@@ -129,3 +129,14 @@ test('discovers a v2rayN layout when nothing else resolves', async () => {
|
|
|
129
129
|
rmSync(dir, { recursive: true, force: true })
|
|
130
130
|
}
|
|
131
131
|
})
|
|
132
|
+
|
|
133
|
+
test('optional platform core packages resolve on their own platform', () => {
|
|
134
|
+
const manifest = join('C:', 'profile', 'node_modules', '@twinsearth', 'dsh-2xray-core-darwin-arm64', 'package.json')
|
|
135
|
+
const paths = optionalCorePaths({
|
|
136
|
+
platform: 'darwin',
|
|
137
|
+
arch: 'arm64',
|
|
138
|
+
resolvePackage: () => manifest,
|
|
139
|
+
})
|
|
140
|
+
assert.deepEqual(paths, [join('C:', 'profile', 'node_modules', '@twinsearth', 'dsh-2xray-core-darwin-arm64', 'bin', 'xray')])
|
|
141
|
+
assert.deepEqual(optionalCorePaths({ platform: 'darwin', arch: 'arm64', resolvePackage: () => { throw new Error('missing') } }), [])
|
|
142
|
+
})
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import test from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { checkForUpdate, compareVersions } from '../lib/update.js'
|
|
4
|
+
|
|
5
|
+
test('compareVersions orders releases and prereleases', () => {
|
|
6
|
+
assert.equal(compareVersions('0.2.9', '0.2.8'), 1)
|
|
7
|
+
assert.equal(compareVersions('0.2.8', '0.2.9'), -1)
|
|
8
|
+
assert.equal(compareVersions('0.2.9', '0.2.9'), 0)
|
|
9
|
+
assert.equal(compareVersions('0.2.10', '0.2.9'), 1)
|
|
10
|
+
assert.equal(compareVersions('0.3.0-rc.1', '0.3.0'), -1)
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
test('checkForUpdate reads the registry latest and flags an update', async () => {
|
|
14
|
+
const original = globalThis.fetch
|
|
15
|
+
globalThis.fetch = async (url) => ({
|
|
16
|
+
ok: true,
|
|
17
|
+
async json() {
|
|
18
|
+
return { version: '9.9.9', dist: { tarball: 'https://example.test/pkg.tgz', integrity: 'sha512-abc', shasum: 'abc' } }
|
|
19
|
+
},
|
|
20
|
+
})
|
|
21
|
+
try {
|
|
22
|
+
const info = await checkForUpdate({ current: '0.2.9' })
|
|
23
|
+
assert.equal(info.latest, '9.9.9')
|
|
24
|
+
assert.equal(info.available, true)
|
|
25
|
+
assert.equal(info.tarball, 'https://example.test/pkg.tgz')
|
|
26
|
+
} finally {
|
|
27
|
+
globalThis.fetch = original
|
|
28
|
+
}
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
test('checkForUpdate fails closed on a registry error', async () => {
|
|
32
|
+
const original = globalThis.fetch
|
|
33
|
+
globalThis.fetch = async () => ({ ok: false, status: 503 })
|
|
34
|
+
try {
|
|
35
|
+
const info = await checkForUpdate({ current: '0.2.9' })
|
|
36
|
+
assert.equal(info.available, false)
|
|
37
|
+
assert.match(info.error, /503/)
|
|
38
|
+
} finally {
|
|
39
|
+
globalThis.fetch = original
|
|
40
|
+
}
|
|
41
|
+
})
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Turn official Xray-core zips into per-platform npm packages.
|
|
4
|
+
*
|
|
5
|
+
* npm installs only the package whose `os`/`cpu` matches, so the main package
|
|
6
|
+
* can depend on all of them without downloading five cores. CI can run this on
|
|
7
|
+
* each runner (where GitHub release downloads work); locally it also accepts a
|
|
8
|
+
* directory of already-downloaded zips.
|
|
9
|
+
*
|
|
10
|
+
* Usage:
|
|
11
|
+
* node tools/build-core-packages.mjs --zips ./vendor/zips --out ./dist-core --version 0.2.9
|
|
12
|
+
*
|
|
13
|
+
* Expected zip names (official release assets):
|
|
14
|
+
* Xray-windows-64.zip, Xray-windows-arm64-v8a.zip
|
|
15
|
+
* Xray-macos-64.zip, Xray-macos-arm64-v8a.zip
|
|
16
|
+
* Xray-linux-64.zip, Xray-linux-arm64-v8a.zip
|
|
17
|
+
*/
|
|
18
|
+
import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs'
|
|
19
|
+
import { tmpdir } from 'node:os'
|
|
20
|
+
import { join } from 'node:path'
|
|
21
|
+
import { spawnSync } from 'node:child_process'
|
|
22
|
+
|
|
23
|
+
const TARGETS = [
|
|
24
|
+
{ zip: 'Xray-windows-64.zip', pkg: 'win32-x64', os: 'win32', cpu: 'x64', exe: 'xray.exe' },
|
|
25
|
+
{ zip: 'Xray-windows-arm64-v8a.zip', pkg: 'win32-arm64', os: 'win32', cpu: 'arm64', exe: 'xray.exe' },
|
|
26
|
+
{ zip: 'Xray-macos-64.zip', pkg: 'darwin-x64', os: 'darwin', cpu: 'x64', exe: 'xray' },
|
|
27
|
+
{ zip: 'Xray-macos-arm64-v8a.zip', pkg: 'darwin-arm64', os: 'darwin', cpu: 'arm64', exe: 'xray' },
|
|
28
|
+
{ zip: 'Xray-linux-64.zip', pkg: 'linux-x64', os: 'linux', cpu: 'x64', exe: 'xray' },
|
|
29
|
+
{ zip: 'Xray-linux-arm64-v8a.zip', pkg: 'linux-arm64', os: 'linux', cpu: 'arm64', exe: 'xray' },
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
function args() {
|
|
33
|
+
const parsed = {}
|
|
34
|
+
for (let index = 2; index < process.argv.length; index += 2) {
|
|
35
|
+
const key = process.argv[index].replace(/^--/, '')
|
|
36
|
+
parsed[key] = process.argv[index + 1]
|
|
37
|
+
}
|
|
38
|
+
return {
|
|
39
|
+
zips: parsed.zips ?? './vendor/zips',
|
|
40
|
+
out: parsed.out ?? './dist-core',
|
|
41
|
+
version: parsed.version ?? JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version,
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function extract(zip, destination) {
|
|
46
|
+
const attempts =
|
|
47
|
+
process.platform === 'win32'
|
|
48
|
+
? [['tar', ['-xf', zip, '-C', destination]], ['powershell', ['-NoProfile', '-Command', `Expand-Archive -LiteralPath '${zip}' -DestinationPath '${destination}' -Force`]]]
|
|
49
|
+
: [['unzip', ['-o', zip, '-d', destination]], ['tar', ['-xf', zip, '-C', destination]]]
|
|
50
|
+
for (const [command, argv] of attempts) {
|
|
51
|
+
if (spawnSync(command, argv, { stdio: 'ignore' }).status === 0) return
|
|
52
|
+
}
|
|
53
|
+
throw new Error(`could not extract ${zip}`)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function findBinary(dir, name) {
|
|
57
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
58
|
+
const full = join(dir, entry.name)
|
|
59
|
+
if (entry.isFile() && entry.name === name) return full
|
|
60
|
+
if (entry.isDirectory()) {
|
|
61
|
+
const nested = findBinary(full, name)
|
|
62
|
+
if (nested) return nested
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return undefined
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const options = args()
|
|
69
|
+
mkdirSync(options.out, { recursive: true })
|
|
70
|
+
const built = []
|
|
71
|
+
for (const target of TARGETS) {
|
|
72
|
+
const zip = join(options.zips, target.zip)
|
|
73
|
+
if (!existsSync(zip)) {
|
|
74
|
+
console.log(`skip ${target.pkg}: ${target.zip} not present`)
|
|
75
|
+
continue
|
|
76
|
+
}
|
|
77
|
+
const workspace = mkdtempSync(join(tmpdir(), `dsh-core-${target.pkg}-`))
|
|
78
|
+
try {
|
|
79
|
+
extract(zip, workspace)
|
|
80
|
+
const binary = findBinary(workspace, target.exe)
|
|
81
|
+
if (!binary) throw new Error(`${target.exe} not found in ${target.zip}`)
|
|
82
|
+
const packageName = `@twinsearth/dsh-2xray-core-${target.pkg}`
|
|
83
|
+
const dir = join(options.out, `dsh-2xray-core-${target.pkg}`)
|
|
84
|
+
mkdirSync(join(dir, 'bin'), { recursive: true })
|
|
85
|
+
copyFileSync(binary, join(dir, 'bin', target.exe))
|
|
86
|
+
writeFileSync(
|
|
87
|
+
join(dir, 'package.json'),
|
|
88
|
+
JSON.stringify(
|
|
89
|
+
{
|
|
90
|
+
name: packageName,
|
|
91
|
+
version: options.version,
|
|
92
|
+
description: `Xray-core binary for ${target.pkg} (DSH-2Xray optional dependency)`,
|
|
93
|
+
license: 'MPL-2.0',
|
|
94
|
+
os: [target.os],
|
|
95
|
+
cpu: [target.cpu],
|
|
96
|
+
files: ['bin'],
|
|
97
|
+
},
|
|
98
|
+
null,
|
|
99
|
+
2,
|
|
100
|
+
) + '\n',
|
|
101
|
+
)
|
|
102
|
+
built.push({ name: packageName, dir, size: statSync(join(dir, 'bin', target.exe)).size })
|
|
103
|
+
} finally {
|
|
104
|
+
rmSync(workspace, { recursive: true, force: true })
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
for (const entry of built) {
|
|
109
|
+
console.log(`built ${entry.name} (${Math.round(entry.size / 1048576)} MB) -> ${entry.dir}`)
|
|
110
|
+
}
|
|
111
|
+
if (built.length === 0) {
|
|
112
|
+
console.log(`no zips found in ${options.zips}`)
|
|
113
|
+
process.exitCode = 1
|
|
114
|
+
}
|