@twinsearth/dsh-2xray 0.2.3 → 0.2.5
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 +19 -0
- package/lib/binary.js +81 -2
- package/lib/client.js +191 -41
- package/lib/xray-config.js +13 -1
- package/package.json +1 -1
- package/test/binary.test.js +28 -0
- package/test/client.test.js +56 -11
- package/test/xray-config.test.js +3 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,24 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.2.5 — 2026-10-29
|
|
4
|
+
|
|
5
|
+
### 修复
|
|
6
|
+
|
|
7
|
+
- **binary-missing 自动恢复**:未配置 `binary.path` 时,会按 `V2RAYN_HOME/V2RAYN_PATH`、`%LOCALAPPDATA%`、桌面/下载、Program Files、盘符根目录做有界搜索(只进入名字含 v2ray 的目录,3s 预算),识别 v2rayN 7.x 的 `bin/xray/xray.exe` 并直接借用。
|
|
8
|
+
- 找不到时错误信息仍列出显式候选与 PATH,便于诊断。
|
|
9
|
+
|
|
10
|
+
## 0.2.4 — 2026-10-29
|
|
11
|
+
|
|
12
|
+
### 设置页与导入
|
|
13
|
+
|
|
14
|
+
- **设置页新增「2Xray」标签页**(`settings.section`):VLESS 链接输入、本地二维码图片选择、屏幕扫码、启用/takeover/xray 路径/端口,保存后重启生效。
|
|
15
|
+
- 插件页卡片 `plugins.item` 的 id 改为 settings namespace(`xray-proxy`),并按页面真实契约使用 `{ state, mutate }`;写冲突(mutate=false)会显式报错,不再假装保存成功。
|
|
16
|
+
- 左侧「2Xray」面板保持三卡片(配置/实时信息/状态)。
|
|
17
|
+
|
|
18
|
+
### 兼容
|
|
19
|
+
|
|
20
|
+
- 路由直连规则从 `geoip:private` 改为显式私有网段(10/8、172.16/12、192.168/16、127/8、169.254/16、::1、fc00::/7、fe80::/10),不再依赖 xray 二进制旁的 `geoip.dat`——v2rayN 自带的 xray.exe 可 `-test` 通过。
|
|
21
|
+
|
|
3
22
|
## 0.2.3 — 2026-10-29
|
|
4
23
|
|
|
5
24
|
### 界面
|
package/lib/binary.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { accessSync, constants, existsSync } from 'node:fs'
|
|
1
|
+
import { accessSync, constants, existsSync, readdirSync } from 'node:fs'
|
|
2
2
|
import { delimiter, join } from 'node:path'
|
|
3
3
|
import { fileURLToPath } from 'node:url'
|
|
4
4
|
|
|
@@ -54,6 +54,8 @@ export async function resolveBinary({
|
|
|
54
54
|
launcher,
|
|
55
55
|
exists = isExecutable,
|
|
56
56
|
packageRoot = PACKAGE_ROOT,
|
|
57
|
+
discoveryRoots,
|
|
58
|
+
discoveryOptions = {},
|
|
57
59
|
}) {
|
|
58
60
|
const candidates = []
|
|
59
61
|
const explicit = config.binary.path?.trim()
|
|
@@ -90,14 +92,91 @@ export async function resolveBinary({
|
|
|
90
92
|
}
|
|
91
93
|
}
|
|
92
94
|
|
|
95
|
+
// v2rayN / v2rayN-core ship xray under their own directory (7.x uses
|
|
96
|
+
// bin/xray/xray.exe). Borrowing it avoids asking the user to copy a binary.
|
|
97
|
+
const discovered = discoverXray({ roots: discoveryRoots, ...discoveryOptions })
|
|
98
|
+
if (discovered.length > 0) {
|
|
99
|
+
return { path: discovered[0], source: 'discovered' }
|
|
100
|
+
}
|
|
101
|
+
|
|
93
102
|
throw new BinaryError(
|
|
94
103
|
`找不到 xray-core 可执行文件。已尝试:${candidates.map((candidate) => candidate.path).join(', ') || '(无显式路径)'},以及 PATH 中的 ${pathEntries.join(delimiter)}。` +
|
|
95
104
|
'请把 xray 放进插件自己的 bin/、数据目录的 bin/,或设置 config.binary.path。',
|
|
96
105
|
'binary-missing',
|
|
97
|
-
candidates.map((candidate) => candidate.path),
|
|
106
|
+
[...candidates.map((candidate) => candidate.path), ...discovered],
|
|
98
107
|
)
|
|
99
108
|
}
|
|
100
109
|
|
|
110
|
+
/** Common install roots for a borrowed xray binary (Windows-first). */
|
|
111
|
+
export function defaultDiscoveryRoots() {
|
|
112
|
+
const home = process.env.USERPROFILE ?? process.env.HOME ?? ''
|
|
113
|
+
const local = process.env.LOCALAPPDATA ?? ''
|
|
114
|
+
const roots = [
|
|
115
|
+
process.env.V2RAYN_HOME,
|
|
116
|
+
process.env.V2RAYN_PATH,
|
|
117
|
+
local,
|
|
118
|
+
home ? join(home, 'Desktop') : '',
|
|
119
|
+
home ? join(home, 'Downloads') : '',
|
|
120
|
+
'C:\\Program Files',
|
|
121
|
+
'C:\\Program Files (x86)',
|
|
122
|
+
'D:\\',
|
|
123
|
+
'E:\\',
|
|
124
|
+
'C:\\',
|
|
125
|
+
]
|
|
126
|
+
return [...new Set(roots.filter((root) => typeof root === 'string' && root.length > 0))]
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Bounded search for a v2rayN-style xray layout. Directory names must look
|
|
131
|
+
* like v2rayN, so the walk does not crawl unrelated trees; the time budget
|
|
132
|
+
* keeps a failed start from stalling activation.
|
|
133
|
+
*/
|
|
134
|
+
export function discoverXray({ roots = defaultDiscoveryRoots(), timeBudgetMs = 3000, max = 5 } = {}) {
|
|
135
|
+
const found = []
|
|
136
|
+
const deadline = Date.now() + timeBudgetMs
|
|
137
|
+
const skipped = /^(windows|programdata|recovery|\$recycle\.bin|system volume information|node_modules|\.git|appdata|temp)$/i
|
|
138
|
+
const layouts = [
|
|
139
|
+
['bin', 'xray', 'xray.exe'],
|
|
140
|
+
['bin', 'xray', 'xray'],
|
|
141
|
+
['bin', 'xray.exe'],
|
|
142
|
+
['bin', 'xray'],
|
|
143
|
+
['xray.exe'],
|
|
144
|
+
['xray'],
|
|
145
|
+
]
|
|
146
|
+
const check = (dir) => {
|
|
147
|
+
for (const parts of layouts) {
|
|
148
|
+
const candidate = join(dir, ...parts)
|
|
149
|
+
if (existsSync(candidate)) {
|
|
150
|
+
if (!found.includes(candidate)) found.push(candidate)
|
|
151
|
+
return
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
const visit = (dir, depth) => {
|
|
156
|
+
if (found.length >= max || Date.now() > deadline) return
|
|
157
|
+
let entries
|
|
158
|
+
try {
|
|
159
|
+
entries = readdirSync(dir, { withFileTypes: true })
|
|
160
|
+
} catch {
|
|
161
|
+
return
|
|
162
|
+
}
|
|
163
|
+
for (const entry of entries) {
|
|
164
|
+
if (found.length >= max || Date.now() > deadline) return
|
|
165
|
+
if (!entry.isDirectory()) continue
|
|
166
|
+
const name = entry.name
|
|
167
|
+
if (skipped.test(name)) continue
|
|
168
|
+
const full = join(dir, name)
|
|
169
|
+
if (/v2ray/i.test(name)) check(full)
|
|
170
|
+
if (depth < 3) visit(full, depth + 1)
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
for (const root of roots) {
|
|
174
|
+
if (found.length >= max || Date.now() > deadline) break
|
|
175
|
+
if (existsSync(root)) visit(root, 0)
|
|
176
|
+
}
|
|
177
|
+
return found
|
|
178
|
+
}
|
|
179
|
+
|
|
101
180
|
/**
|
|
102
181
|
* Run `xray version` and parse the version line. A failed probe (missing
|
|
103
182
|
* dynamic library, wrong architecture, antivirus block) is a startup failure,
|
package/lib/client.js
CHANGED
|
@@ -6,7 +6,7 @@ window.__ModuleLoader__.load({
|
|
|
6
6
|
|
|
7
7
|
const PANEL_ID = 'xray'
|
|
8
8
|
const NS = 'xray-proxy'
|
|
9
|
-
const inject = ['slots'
|
|
9
|
+
const inject = ['slots']
|
|
10
10
|
|
|
11
11
|
const C = {
|
|
12
12
|
page: { maxWidth: 980, margin: '0 auto', padding: '20px 24px 16px', color: 'var(--dsw-alias-label-primary, #0f1115)' },
|
|
@@ -260,76 +260,220 @@ window.__ModuleLoader__.load({
|
|
|
260
260
|
return h('div', null, h('label', { style: C.label }, label), children)
|
|
261
261
|
}
|
|
262
262
|
|
|
263
|
-
function
|
|
263
|
+
function controllerValue(controller) {
|
|
264
264
|
try {
|
|
265
|
-
const snapshot =
|
|
266
|
-
|
|
267
|
-
return candidate && typeof candidate === 'object' ? candidate : {}
|
|
265
|
+
const snapshot = controller?.getSnapshot?.()
|
|
266
|
+
return snapshot?.value && typeof snapshot.value === 'object' ? snapshot.value : {}
|
|
268
267
|
} catch {
|
|
269
268
|
return {}
|
|
270
269
|
}
|
|
271
270
|
}
|
|
272
271
|
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
272
|
+
function pageValue(pageForm) {
|
|
273
|
+
const value = pageForm?.state?.value
|
|
274
|
+
return value && typeof value === 'object' ? value : {}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
async function writeFields({ pageForm, controller }, fields) {
|
|
278
|
+
if (pageForm && typeof pageForm.mutate === 'function') {
|
|
279
|
+
const ops = fields.map(({ path, value }) => ({ op: 'set', path, value }))
|
|
280
|
+
const accepted = await pageForm.mutate(ops, pageForm.state?.revision)
|
|
281
|
+
if (accepted === false) throw new Error('写入被拒绝(revision 冲突)')
|
|
282
|
+
return
|
|
283
|
+
}
|
|
284
|
+
if (controller && typeof controller.mutate === 'function') {
|
|
285
|
+
for (const { path, value } of fields) {
|
|
286
|
+
if (path.length === 1 && typeof controller.set === 'function') {
|
|
287
|
+
const accepted = await controller.set(path[0], value)
|
|
288
|
+
if (accepted === false) throw new Error('写入被拒绝(revision 冲突)')
|
|
289
|
+
} else {
|
|
290
|
+
const revision = controller.getSnapshot?.()?.revision
|
|
291
|
+
const accepted = await controller.mutate([{ op: 'set', path, value }], revision)
|
|
292
|
+
if (accepted === false) throw new Error('写入被拒绝(revision 冲突)')
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
return
|
|
279
296
|
}
|
|
280
297
|
throw new Error('设置表单不可写')
|
|
281
298
|
}
|
|
282
299
|
|
|
283
|
-
function
|
|
284
|
-
|
|
300
|
+
async function decodeQrFromCanvas(canvas) {
|
|
301
|
+
if (!('BarcodeDetector' in window)) return null
|
|
302
|
+
const detector = new window.BarcodeDetector({ formats: ['qr_code'] })
|
|
303
|
+
const codes = await detector.detect(canvas)
|
|
304
|
+
return codes && codes.length > 0 ? codes[0].rawValue : null
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function XrayConfigForm({ controller, pageForm }) {
|
|
308
|
+
const value = controller ? controllerValue(controller) : pageValue(pageForm)
|
|
285
309
|
const [shareLink, setShareLink] = React.useState(value.shareLink ?? '')
|
|
286
310
|
const [binaryPath, setBinaryPath] = React.useState(value.binary?.path ?? '')
|
|
287
311
|
const [port, setPort] = React.useState(String(value.inbound?.port ?? 20808))
|
|
288
312
|
const [enabled, setEnabled] = React.useState(value.enabled === true)
|
|
289
313
|
const [takeover, setTakeover] = React.useState(value.takeover ?? 'auto')
|
|
290
314
|
const [message, setMessage] = React.useState('')
|
|
315
|
+
const [kind, setKind] = React.useState('')
|
|
291
316
|
const [busy, setBusy] = React.useState(false)
|
|
317
|
+
const [scanning, setScanning] = React.useState(false)
|
|
318
|
+
const videoRef = React.useRef(null)
|
|
319
|
+
const streamRef = React.useRef(null)
|
|
320
|
+
const timerRef = React.useRef(null)
|
|
321
|
+
|
|
322
|
+
const say = (text, tone) => {
|
|
323
|
+
setMessage(text)
|
|
324
|
+
setKind(tone || '')
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const applyDecoded = (text) => {
|
|
328
|
+
const trimmed = String(text || '').trim()
|
|
329
|
+
if (!trimmed) return
|
|
330
|
+
setShareLink(trimmed)
|
|
331
|
+
say('已识别二维码,确认后点保存。', 'ok')
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
const onFile = async (event) => {
|
|
335
|
+
const file = event.target.files && event.target.files[0]
|
|
336
|
+
if (!file) return
|
|
337
|
+
say('正在解析图片…')
|
|
338
|
+
try {
|
|
339
|
+
const bitmap = await createImageBitmap(file)
|
|
340
|
+
const scale = Math.min(1, 1600 / Math.max(bitmap.width, bitmap.height))
|
|
341
|
+
const canvas = document.createElement('canvas')
|
|
342
|
+
canvas.width = Math.max(1, Math.round(bitmap.width * scale))
|
|
343
|
+
canvas.height = Math.max(1, Math.round(bitmap.height * scale))
|
|
344
|
+
canvas.getContext('2d').drawImage(bitmap, 0, 0, canvas.width, canvas.height)
|
|
345
|
+
const decoded = await decodeQrFromCanvas(canvas)
|
|
346
|
+
if (!decoded) say('没有识别到二维码;当前浏览器不支持或图片不清晰,可改用插件目录 tools/scanner.html。', 'error')
|
|
347
|
+
else applyDecoded(decoded)
|
|
348
|
+
} catch (error) {
|
|
349
|
+
say('解析失败:' + (error.message || error), 'error')
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
const stopScan = () => {
|
|
354
|
+
if (timerRef.current) clearInterval(timerRef.current)
|
|
355
|
+
timerRef.current = null
|
|
356
|
+
if (streamRef.current) for (const track of streamRef.current.getTracks()) track.stop()
|
|
357
|
+
streamRef.current = null
|
|
358
|
+
if (videoRef.current) videoRef.current.srcObject = null
|
|
359
|
+
setScanning(false)
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
const tick = async () => {
|
|
363
|
+
const video = videoRef.current
|
|
364
|
+
if (!video || !streamRef.current || video.readyState < 2 || !video.videoWidth) return
|
|
365
|
+
const canvas = document.createElement('canvas')
|
|
366
|
+
canvas.width = video.videoWidth
|
|
367
|
+
canvas.height = video.videoHeight
|
|
368
|
+
canvas.getContext('2d').drawImage(video, 0, 0)
|
|
369
|
+
try {
|
|
370
|
+
const decoded = await decodeQrFromCanvas(canvas)
|
|
371
|
+
if (decoded) {
|
|
372
|
+
applyDecoded(decoded)
|
|
373
|
+
stopScan()
|
|
374
|
+
}
|
|
375
|
+
} catch {
|
|
376
|
+
// a frame without a readable code is normal
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
const startScan = async () => {
|
|
381
|
+
if (!navigator.mediaDevices || !navigator.mediaDevices.getDisplayMedia) {
|
|
382
|
+
say('当前浏览器不支持屏幕捕获;请使用「二维码图片」。', 'error')
|
|
383
|
+
return
|
|
384
|
+
}
|
|
385
|
+
if (!('BarcodeDetector' in window)) {
|
|
386
|
+
say('当前浏览器没有 BarcodeDetector;请使用插件目录 tools/scanner.html(内置 jsQR)。', 'error')
|
|
387
|
+
return
|
|
388
|
+
}
|
|
389
|
+
try {
|
|
390
|
+
const stream = await navigator.mediaDevices.getDisplayMedia({ video: { frameRate: 10 }, audio: false })
|
|
391
|
+
streamRef.current = stream
|
|
392
|
+
setScanning(true)
|
|
393
|
+
if (videoRef.current) {
|
|
394
|
+
videoRef.current.srcObject = stream
|
|
395
|
+
videoRef.current.style.display = 'block'
|
|
396
|
+
await videoRef.current.play()
|
|
397
|
+
}
|
|
398
|
+
stream.getVideoTracks()[0].addEventListener('ended', stopScan)
|
|
399
|
+
timerRef.current = setInterval(tick, 350)
|
|
400
|
+
say('请把二维码放到共享屏幕上…')
|
|
401
|
+
} catch (error) {
|
|
402
|
+
say('屏幕共享未开始:' + (error.message || error), 'error')
|
|
403
|
+
setScanning(false)
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
React.useEffect(() => () => stopScan(), [])
|
|
292
408
|
|
|
293
409
|
const save = async () => {
|
|
294
410
|
setBusy(true)
|
|
295
411
|
try {
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
412
|
+
const fields = [
|
|
413
|
+
{ path: ['shareLink'], value: shareLink.trim() },
|
|
414
|
+
{ path: ['enabled'], value: enabled },
|
|
415
|
+
{ path: ['takeover'], value: takeover },
|
|
416
|
+
]
|
|
417
|
+
if (binaryPath.trim()) fields.push({ path: ['binary', 'path'], value: binaryPath.trim() })
|
|
299
418
|
const numericPort = Number(port)
|
|
300
|
-
if (Number.isInteger(numericPort) && numericPort > 0)
|
|
301
|
-
|
|
302
|
-
|
|
419
|
+
if (Number.isInteger(numericPort) && numericPort > 0) fields.push({ path: ['inbound', 'port'], value: numericPort })
|
|
420
|
+
await writeFields({ controller, pageForm }, fields)
|
|
421
|
+
say('已保存。重启 DSH 后生效。', 'ok')
|
|
303
422
|
} catch (error) {
|
|
304
|
-
|
|
423
|
+
say('保存失败:' + (error.message || error), 'error')
|
|
305
424
|
} finally {
|
|
306
425
|
setBusy(false)
|
|
307
426
|
}
|
|
308
427
|
}
|
|
309
428
|
|
|
310
|
-
return h('div',
|
|
311
|
-
h(
|
|
312
|
-
h(Field, { label: 'VLESS 分享链接(粘贴或扫码)' },
|
|
429
|
+
return h('div', null,
|
|
430
|
+
h(Field, { label: '1. VLESS 分享链接(粘贴)' },
|
|
313
431
|
h('textarea', {
|
|
314
432
|
style: { ...C.input, minHeight: 64, fontFamily: 'ui-monospace, Consolas, monospace' },
|
|
315
433
|
value: shareLink,
|
|
316
434
|
placeholder: 'vless://uuid@host:443?security=reality&sni=...&pbk=...#备注',
|
|
317
435
|
onChange: (event) => setShareLink(event.target.value),
|
|
318
436
|
})),
|
|
437
|
+
h(Field, { label: '2. 本地二维码图片' },
|
|
438
|
+
h('input', { type: 'file', accept: 'image/*', onChange: onFile, disabled: scanning })),
|
|
439
|
+
h('div', { style: { display: 'flex', gap: 10, alignItems: 'center', marginTop: 10, flexWrap: 'wrap' } },
|
|
440
|
+
h('button', { type: 'button', style: C.btn, onClick: startScan, disabled: scanning }, scanning ? '扫描中…' : '扫描屏幕二维码'),
|
|
441
|
+
h('button', { type: 'button', style: C.btn, onClick: stopScan, disabled: !scanning }, '停止'),
|
|
442
|
+
h('span', { style: { ...C.muted, fontSize: 12 } }, '本地图片/屏幕扫码结果会填入上方链接')),
|
|
443
|
+
h('video', { ref: videoRef, playsInline: true, muted: true, style: { ...C.input, display: 'none', maxHeight: 240, background: '#000' } }),
|
|
319
444
|
h('div', { style: { display: 'flex', gap: 12, flexWrap: 'wrap' } },
|
|
320
|
-
h('label', { style: { fontSize: 12.5, display: 'flex', alignItems: 'center', gap: 6, marginTop:
|
|
445
|
+
h('label', { style: { fontSize: 12.5, display: 'flex', alignItems: 'center', gap: 6, marginTop: 10 } },
|
|
321
446
|
h('input', { type: 'checkbox', checked: enabled, onChange: (event) => setEnabled(event.target.checked) }), '启用代理'),
|
|
322
|
-
h('label', { style: { fontSize: 12.5, display: 'flex', alignItems: 'center', gap: 6, marginTop:
|
|
447
|
+
h('label', { style: { fontSize: 12.5, display: 'flex', alignItems: 'center', gap: 6, marginTop: 10 } }, 'takeover',
|
|
323
448
|
h('select', { value: takeover, onChange: (event) => setTakeover(event.target.value), style: { padding: '4px 8px', borderRadius: 6, background: 'transparent', color: 'inherit' } },
|
|
324
449
|
h('option', { value: 'auto' }, 'auto'), h('option', { value: 'force' }, 'force'), h('option', { value: 'off' }, 'off')))),
|
|
325
450
|
h(Field, { label: 'xray 可执行文件路径(留空自动查找)' },
|
|
326
|
-
h('input', { style: { ...C.input, fontFamily: 'ui-monospace, Consolas, monospace' }, value: binaryPath, placeholder: '例如 D:\\v2rayN\\xray.exe', onChange: (event) => setBinaryPath(event.target.value) })),
|
|
451
|
+
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) })),
|
|
327
452
|
h(Field, { label: '本地 HTTP 入站端口' },
|
|
328
453
|
h('input', { style: C.input, value: port, onChange: (event) => setPort(event.target.value) })),
|
|
329
454
|
h('div', { style: { display: 'flex', gap: 10, alignItems: 'center', marginTop: 12 } },
|
|
330
455
|
h('button', { type: 'button', style: C.primaryBtn, disabled: busy, onClick: save }, busy ? '保存中…' : '保存'),
|
|
331
|
-
h('span', { style: {
|
|
332
|
-
h('p', { style: { ...C.muted, fontSize: 12, marginTop: 10 } }, '
|
|
456
|
+
h('span', { style: { fontSize: 12, color: kind === 'error' ? '#c81e1e' : kind === 'ok' ? '#15803d' : undefined } }, message)),
|
|
457
|
+
h('p', { style: { ...C.muted, fontSize: 12, marginTop: 10 } }, '保存后重启 DSH;左侧「2Xray」面板显示节点、日志、延迟与出口 IP。'))
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
function XraySettingsCard({ view, form }) {
|
|
461
|
+
if (view === 'summary') {
|
|
462
|
+
return h('div', { style: { ...C.muted, fontSize: 12.5 } }, 'VLESS 链接 / 本地二维码导入 · 保存后重启 DSH 生效')
|
|
463
|
+
}
|
|
464
|
+
return h('div', { style: { ...C.card, marginTop: 8 } },
|
|
465
|
+
h('div', { style: C.cardHead }, h(GlobeIcon), '2Xray 连接'),
|
|
466
|
+
h(XrayConfigForm, { pageForm: form }))
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
function SettingsSection({ controller }) {
|
|
470
|
+
return h('div', { style: C.page },
|
|
471
|
+
h('div', { style: C.header },
|
|
472
|
+
h('span', { style: C.globe }, h(GlobeIcon, { size: 22 })),
|
|
473
|
+
h('div', null,
|
|
474
|
+
h('h1', { style: C.title }, '2Xray'),
|
|
475
|
+
h('p', { style: C.sub }, '导入 VLESS 链接或本地二维码,保存后重启 DSH 生效。'))),
|
|
476
|
+
h('section', { style: C.card }, h(XrayConfigForm, { controller })))
|
|
333
477
|
}
|
|
334
478
|
|
|
335
479
|
function apply(ctx) {
|
|
@@ -350,26 +494,32 @@ window.__ModuleLoader__.load({
|
|
|
350
494
|
if (typeof ctx.inject === 'function') {
|
|
351
495
|
ctx.inject(['configForms'], (child) => {
|
|
352
496
|
try {
|
|
497
|
+
// A dedicated Settings tab. It owns the ConfigFormController, so the
|
|
498
|
+
// full editor works even when the plugins page passes a narrow form.
|
|
499
|
+
const controller = child.configForms.get(NS)
|
|
500
|
+
child.effect(
|
|
501
|
+
() =>
|
|
502
|
+
child.slots.inject('settings.section', () =>
|
|
503
|
+
child.slots.register(
|
|
504
|
+
{ name: 'settings.section', id: NS, order: 30, label: () => '2Xray' },
|
|
505
|
+
() => h(SettingsSection, { controller }),
|
|
506
|
+
),
|
|
507
|
+
),
|
|
508
|
+
'dsh-2xray: settings section',
|
|
509
|
+
)
|
|
510
|
+
// The plugins page card: its id MUST equal the settings namespace,
|
|
511
|
+
// otherwise the page's formFor(id) returns undefined.
|
|
353
512
|
child.effect(
|
|
354
513
|
() =>
|
|
355
514
|
child.configForms.whileServed([NS], () =>
|
|
356
515
|
child.slots.inject('plugins.item', () =>
|
|
357
|
-
child.slots.register(
|
|
358
|
-
{
|
|
359
|
-
name: 'plugins.item',
|
|
360
|
-
id: PANEL_ID,
|
|
361
|
-
order: 40,
|
|
362
|
-
label: () => '2Xray 连接',
|
|
363
|
-
inject: () => ({ form: child.configForms.get(NS) }),
|
|
364
|
-
},
|
|
365
|
-
XraySettingsCard,
|
|
366
|
-
),
|
|
516
|
+
child.slots.register({ name: 'plugins.item', id: NS, order: 40, label: () => '2Xray' }, XraySettingsCard),
|
|
367
517
|
),
|
|
368
518
|
),
|
|
369
|
-
'dsh-2xray:
|
|
519
|
+
'dsh-2xray: plugins card',
|
|
370
520
|
)
|
|
371
|
-
} catch {
|
|
372
|
-
|
|
521
|
+
} catch (error) {
|
|
522
|
+
console.error('[dsh-2xray] client registration failed', error)
|
|
373
523
|
}
|
|
374
524
|
})
|
|
375
525
|
}
|
package/lib/xray-config.js
CHANGED
|
@@ -17,7 +17,19 @@
|
|
|
17
17
|
* `api` routing rule, and optionally the stats counters.
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
|
-
|
|
20
|
+
// Explicit RFC1918/loopback/link-local ranges instead of geoip:private: the
|
|
21
|
+
// latter needs geoip.dat beside the binary, and v2rayN's xray.exe keeps it one
|
|
22
|
+
// directory up, which fails -test with "failed to open geoip.dat".
|
|
23
|
+
const PRIVATE_IP_RULES = [
|
|
24
|
+
'10.0.0.0/8',
|
|
25
|
+
'172.16.0.0/12',
|
|
26
|
+
'192.168.0.0/16',
|
|
27
|
+
'127.0.0.0/8',
|
|
28
|
+
'169.254.0.0/16',
|
|
29
|
+
'::1/128',
|
|
30
|
+
'fc00::/7',
|
|
31
|
+
'fe80::/10',
|
|
32
|
+
]
|
|
21
33
|
const API_TAG = 'api'
|
|
22
34
|
|
|
23
35
|
function escapeRegExp(value) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@twinsearth/dsh-2xray",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.5",
|
|
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",
|
package/test/binary.test.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import test from 'node:test'
|
|
2
2
|
import assert from 'node:assert/strict'
|
|
3
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
|
4
|
+
import { tmpdir } from 'node:os'
|
|
3
5
|
import { join } from 'node:path'
|
|
4
6
|
import { BinaryError, resolveBinary } from '../lib/binary.js'
|
|
5
7
|
import { normalizeConfig } from '../lib/config.js'
|
|
@@ -89,6 +91,7 @@ test('a missing binary fails with every candidate listed', async () => {
|
|
|
89
91
|
packageRoot,
|
|
90
92
|
exists: () => false,
|
|
91
93
|
launcher: {},
|
|
94
|
+
discoveryRoots: [],
|
|
92
95
|
}),
|
|
93
96
|
(error) => {
|
|
94
97
|
assert.ok(error instanceof BinaryError)
|
|
@@ -101,3 +104,28 @@ test('a missing binary fails with every candidate listed', async () => {
|
|
|
101
104
|
process.env.PATH = previousPath
|
|
102
105
|
}
|
|
103
106
|
})
|
|
107
|
+
|
|
108
|
+
test('discovers a v2rayN layout when nothing else resolves', async () => {
|
|
109
|
+
const dir = mkdtempSync(join(tmpdir(), 'dsh-xray-discover-'))
|
|
110
|
+
const previousPath = process.env.PATH
|
|
111
|
+
try {
|
|
112
|
+
const layout = join(dir, 'v2rayN-windows-64', 'bin', 'xray')
|
|
113
|
+
mkdirSync(layout, { recursive: true })
|
|
114
|
+
writeFileSync(join(layout, 'xray.exe'), '')
|
|
115
|
+
process.env.PATH = ''
|
|
116
|
+
const found = await resolveBinary({
|
|
117
|
+
config: config(),
|
|
118
|
+
dataDir: join(dir, 'data'),
|
|
119
|
+
packageRoot: join(dir, 'plugin'),
|
|
120
|
+
exists: () => false,
|
|
121
|
+
launcher: {},
|
|
122
|
+
discoveryRoots: [dir],
|
|
123
|
+
discoveryOptions: { timeBudgetMs: 2000 },
|
|
124
|
+
})
|
|
125
|
+
assert.equal(found.source, 'discovered')
|
|
126
|
+
assert.ok(found.path.endsWith('xray.exe'))
|
|
127
|
+
} finally {
|
|
128
|
+
process.env.PATH = previousPath
|
|
129
|
+
rmSync(dir, { recursive: true, force: true })
|
|
130
|
+
}
|
|
131
|
+
})
|
package/test/client.test.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import test from 'node:test'
|
|
2
2
|
import assert from 'node:assert/strict'
|
|
3
3
|
|
|
4
|
-
test('the client bundle registers
|
|
4
|
+
test('the client bundle registers sidebar, main panel and settings entries', async () => {
|
|
5
5
|
let captured
|
|
6
6
|
globalThis.window = { __ModuleLoader__: { load(module) { captured = module } } }
|
|
7
7
|
try {
|
|
@@ -24,10 +24,11 @@ test('the client bundle registers a main panel and a sidebar entry', async () =>
|
|
|
24
24
|
})
|
|
25
25
|
|
|
26
26
|
assert.equal(typeof mod.apply, 'function')
|
|
27
|
-
assert.deepEqual(mod.inject, ['slots'
|
|
27
|
+
assert.deepEqual(mod.inject, ['slots'])
|
|
28
28
|
assert.equal(mod.PANEL_ID, 'xray')
|
|
29
|
+
assert.equal(mod.NS, 'xray-proxy')
|
|
29
30
|
|
|
30
|
-
const
|
|
31
|
+
const base = []
|
|
31
32
|
const ctx = {
|
|
32
33
|
effect(fn) {
|
|
33
34
|
fn()
|
|
@@ -37,20 +38,64 @@ test('the client bundle registers a main panel and a sidebar entry', async () =>
|
|
|
37
38
|
callback()
|
|
38
39
|
},
|
|
39
40
|
register(definition, component) {
|
|
40
|
-
|
|
41
|
+
base.push({ definition, component })
|
|
41
42
|
},
|
|
42
43
|
},
|
|
43
44
|
}
|
|
44
45
|
mod.apply(ctx)
|
|
45
46
|
|
|
46
47
|
assert.deepEqual(
|
|
47
|
-
|
|
48
|
+
base.map((entry) => entry.definition.name),
|
|
48
49
|
['sidebar.panellist', 'main'],
|
|
49
50
|
)
|
|
50
|
-
assert.equal(
|
|
51
|
-
assert.equal(
|
|
52
|
-
assert.equal(
|
|
53
|
-
|
|
54
|
-
assert.equal(
|
|
55
|
-
|
|
51
|
+
assert.equal(base[0].definition.id, 'xray')
|
|
52
|
+
assert.equal(base[0].definition.order, 1)
|
|
53
|
+
assert.equal(base[0].definition.label(), '2Xray')
|
|
54
|
+
assert.equal(base[0].component().type, 'svg')
|
|
55
|
+
assert.equal(base[1].definition.key, 'xray')
|
|
56
|
+
|
|
57
|
+
const settings = []
|
|
58
|
+
const controller = {
|
|
59
|
+
getSnapshot: () => ({ status: 'ready', value: { shareLink: '' }, revision: 0, writable: true }),
|
|
60
|
+
set: async () => true,
|
|
61
|
+
mutate: async () => true,
|
|
62
|
+
}
|
|
63
|
+
const child = {
|
|
64
|
+
effect(fn) {
|
|
65
|
+
fn()
|
|
66
|
+
},
|
|
67
|
+
slots: {
|
|
68
|
+
inject(_name, callback) {
|
|
69
|
+
callback()
|
|
70
|
+
},
|
|
71
|
+
register(definition, component) {
|
|
72
|
+
settings.push({ definition, component })
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
configForms: {
|
|
76
|
+
get: () => controller,
|
|
77
|
+
whileServed(namespaces, register) {
|
|
78
|
+
return register(new Set(namespaces))
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
}
|
|
82
|
+
mod.apply({
|
|
83
|
+
effect(fn) {
|
|
84
|
+
fn()
|
|
85
|
+
},
|
|
86
|
+
slots: { inject() {}, register() {} },
|
|
87
|
+
inject(services, callback) {
|
|
88
|
+
assert.deepEqual(services, ['configForms'])
|
|
89
|
+
callback(child)
|
|
90
|
+
},
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
assert.deepEqual(
|
|
94
|
+
settings.map((entry) => entry.definition.name),
|
|
95
|
+
['settings.section', 'plugins.item'],
|
|
96
|
+
)
|
|
97
|
+
// The plugins page looks the form up by the settings namespace, not the panel id.
|
|
98
|
+
assert.equal(settings[0].definition.id, 'xray-proxy')
|
|
99
|
+
assert.equal(settings[1].definition.id, 'xray-proxy')
|
|
100
|
+
assert.equal(settings[1].definition.label(), '2Xray')
|
|
56
101
|
})
|
package/test/xray-config.test.js
CHANGED
|
@@ -82,7 +82,9 @@ test('direct and block outbounds exist and the catch-all goes to proxy', () => {
|
|
|
82
82
|
test('private addresses and internal suffixes are routed direct', () => {
|
|
83
83
|
const built = buildXrayConfig(config({ root: { routing: { bypassDomains: ['lan', 'corp'] } } }))
|
|
84
84
|
const ipRule = built.routing.rules.find((rule) => rule.ip)
|
|
85
|
-
assert.
|
|
85
|
+
assert.ok(ipRule.ip.includes('10.0.0.0/8'))
|
|
86
|
+
assert.ok(ipRule.ip.includes('192.168.0.0/16'))
|
|
87
|
+
assert.equal(ipRule.ip.includes('geoip:private'), false)
|
|
86
88
|
const domainRule = built.routing.rules.find((rule) => rule.domain && rule.domain.some((entry) => entry.startsWith('domain:lan')))
|
|
87
89
|
assert.deepEqual(domainRule.domain, ['domain:lan', 'domain:corp'])
|
|
88
90
|
})
|