@twinsearth/dsh-2xray 0.2.9 → 0.3.0

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 CHANGED
@@ -1,5 +1,33 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.0 — 2026-10-29
4
+
5
+ ### 更新中心
6
+
7
+ - 设置页「检测更新 / 立即更新 / 自动更新」:npm registry 检测、sha512 校验、tarball 原子替换、重启生效;自动更新默认关闭。
8
+ - Host 鉴权接口 `GET/POST /api/xray/update`,状态并入 `xray_status.update`。
9
+
10
+ ### 分流与内核
11
+
12
+ - 路由模式 global / cn-direct / whitelist,cn-direct 使用 `IPIfNonMatch` + geodata 自动复制。
13
+ - 黑名单最优先并并入 `noProxy`;白名单仅 whitelist 模式生效。
14
+ - 按平台内核包(`optionalDependencies`,已发布 win32-x64)+ 官方 zip 构建脚本 + CI workflow。
15
+ - macOS 发现路径、特权端口回落、探测 URL 修复。
16
+
17
+ ### 界面
18
+
19
+ - 三卡片面板、节点切换、日志默认展开、设置页 2Xray 标签页、VLESS 链接/本地二维码/屏幕扫码导入。
20
+
21
+ ## 0.2.10 — 2026-10-29
22
+
23
+ ### 更新
24
+
25
+ - 设置页新增「检测更新 / 立即更新 / 自动更新」。
26
+ - 检测:读取 npm registry 的 `latest` 并做 semver 比较。
27
+ - 立即更新:下载 tarball、校验 sha512(或 sha1)、解压并以 rename + 回滚方式替换插件目录;返回「重启 DSH 生效」。
28
+ - 自动更新:`updates.auto` 开启后,启动 5 秒检查并安装;默认关闭,不自动重启应用。
29
+ - 新增鉴权接口 `GET/POST /api/xray/update`,状态同时出现在 `xray_status.update`。
30
+
3
31
  ## 0.2.9 — 2026-10-29
4
32
 
5
33
  ### 内核分发
package/README.md CHANGED
@@ -334,6 +334,16 @@ v0.2.0 提供两个入口,互不替代:
334
334
 
335
335
  维护者用 `tools/build-core-packages.mjs` 从官方 zip 生成平台包;`.github/workflows/core-packages.yml` 会在具备 `NPM_TOKEN` secret 时自动下载、校验并发布各平台包。
336
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
+
337
347
  ## 路由模式与黑白名单
338
348
 
339
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/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.9",
3
+ "version": "0.3.0",
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",
@@ -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
+ })