@twinsearth/dsh-2xray 0.1.1 → 0.2.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 +13 -0
- package/README.md +18 -0
- package/index.js +22 -0
- package/lib/client.js +267 -0
- package/lib/config.js +17 -0
- package/lib/schema.js +85 -0
- package/package.json +11 -3
- package/test/client.test.js +56 -0
- package/tools/jsQR.js +10102 -0
- package/tools/scanner.html +195 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.2.0 — 2026-10-29
|
|
4
|
+
|
|
5
|
+
### 新增
|
|
6
|
+
|
|
7
|
+
- **插件设置页表单**:导出 Schemastery `Config`,在 DSH 插件页可直接编辑启用开关、vless 分享链接、xray 路径、本地端口、takeover、功能探测与控制面开关;保存后写入 profile patch,重启 DSH 生效。高级配置(`nodes`、`routing`、`noProxy` 等)由 Schemastery 原样保留。
|
|
8
|
+
- **左侧菜单「2Xray」**:插件标签下方新增黑色地球图标入口,主面板内置二维码图片识别、屏幕共享扫码与链接复制。
|
|
9
|
+
- **二维码导入工具** `tools/scanner.html`:浏览器打开后可粘贴/复制链接、选择二维码图片、或共享屏幕实时扫码;优先用 `BarcodeDetector`,不可用时回退到内置 jsQR。
|
|
10
|
+
|
|
11
|
+
### 说明
|
|
12
|
+
|
|
13
|
+
- `Config` 的字段全部为 volatile;volatile-only 变更不会重挂插件,插件会记录「重启 DSH 后生效」。
|
|
14
|
+
- 设置页内的原生扫码按钮仍待客户端半边,当前扫码入口是 `tools/scanner.html`(结果复制回设置页)。
|
|
15
|
+
|
|
3
16
|
## 0.1.1 — 2026-10-29
|
|
4
17
|
|
|
5
18
|
### 修复
|
package/README.md
CHANGED
|
@@ -177,6 +177,24 @@ bundle 自带的 `cordis.patch.yml` 默认 `enabled: false`,避免安装即报
|
|
|
177
177
|
- API 入站只绑 `127.0.0.1`,但**无认证**:本机任意进程可查统计或切节点。需要更强隔离时用随机端口,或关闭 `control.enabled` 只保留静态多出站。
|
|
178
178
|
- `stats` 计数器是**自进程启动以来的累计值**,不是速率;速率需要调用方自行差分。
|
|
179
179
|
|
|
180
|
+
## 界面入口:设置页 + 左侧 2Xray 面板
|
|
181
|
+
|
|
182
|
+
v0.2.0 提供两个入口,互不替代:
|
|
183
|
+
|
|
184
|
+
1. **插件设置页**(设置 → 插件 → Xray 代理):Schemastery 表单,可编辑
|
|
185
|
+
`启用`、`分享链接 (vless://)`、`xray 路径`、`本地端口`、`takeover`、`功能探测`、`控制面`。
|
|
186
|
+
保存后写入 profile patch,**重启 DSH 生效**。
|
|
187
|
+
2. **左侧菜单「2Xray」**(插件标签下方,黑色地球图标):内含
|
|
188
|
+
二维码图片识别、屏幕共享扫码、链接复制;识别后复制到设置页的「分享链接」保存。
|
|
189
|
+
|
|
190
|
+
```
|
|
191
|
+
设置 → 插件 → Xray 代理 ← 粘贴 / 保存 / 开关
|
|
192
|
+
左侧 2Xray(地球图标) ← 扫码 / 复制
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
> 浏览器没有 `BarcodeDetector` 时(部分 Windows Chrome),左侧面板会提示;
|
|
196
|
+
> 可改用插件目录里的 `tools/scanner.html`(内置 jsQR 兜底),再把链接粘回设置页。
|
|
197
|
+
|
|
180
198
|
## 配置导入:分享链接 / 二维码
|
|
181
199
|
|
|
182
200
|
三种入口最终都归约为同一段 `vless://` 文本,所以解析器只有一份([lib/share-link.js](lib/share-link.js)),UI 只负责把文本拿到手。
|
package/index.js
CHANGED
|
@@ -9,6 +9,19 @@ import { installTools } from './lib/tools.js'
|
|
|
9
9
|
|
|
10
10
|
export const name = 'dsh-2xray'
|
|
11
11
|
|
|
12
|
+
/**
|
|
13
|
+
* Settings schema, exported only when the harness copy of Schemastery is
|
|
14
|
+
* resolvable (it lives beside the plugin in the profile). Without it the plugin
|
|
15
|
+
* keeps working from YAML config; the Plugins page simply shows no form.
|
|
16
|
+
*/
|
|
17
|
+
let Config
|
|
18
|
+
try {
|
|
19
|
+
;({ Config } = await import('./lib/schema.js'))
|
|
20
|
+
} catch {
|
|
21
|
+
Config = undefined
|
|
22
|
+
}
|
|
23
|
+
export { Config }
|
|
24
|
+
|
|
12
25
|
/**
|
|
13
26
|
* Host half of dsh-2xray: it supervises one xray-core child process and
|
|
14
27
|
* points the harness' process-global proxy policy at the local inbound.
|
|
@@ -28,6 +41,15 @@ export function apply(ctx, rawConfig) {
|
|
|
28
41
|
|
|
29
42
|
for (const warning of warnings) logger.warn(`配置警告:${warning}`)
|
|
30
43
|
|
|
44
|
+
// Volatile-only settings writes do not remount the fiber; the value is in the
|
|
45
|
+
// profile patch and takes effect on the next start or plugin reload.
|
|
46
|
+
if (typeof ctx.on === 'function') {
|
|
47
|
+
ctx.on('loader/volatile-update', (paths) => {
|
|
48
|
+
const changed = Array.isArray(paths) ? paths.join(', ') : String(paths ?? '')
|
|
49
|
+
logger.info(`设置已保存(${changed});重启 DSH 后生效`)
|
|
50
|
+
})
|
|
51
|
+
}
|
|
52
|
+
|
|
31
53
|
let dataDir
|
|
32
54
|
try {
|
|
33
55
|
dataDir = resolveDataDir(config)
|
package/lib/client.js
ADDED
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
window.__ModuleLoader__.load({
|
|
2
|
+
id: '@twinsearth/dsh-2xray',
|
|
3
|
+
factory: (require) => {
|
|
4
|
+
const React = require('react')
|
|
5
|
+
const h = React.createElement
|
|
6
|
+
|
|
7
|
+
const PANEL_ID = 'xray'
|
|
8
|
+
const inject = ['slots', 'locale']
|
|
9
|
+
|
|
10
|
+
function GlobeIcon() {
|
|
11
|
+
return h(
|
|
12
|
+
'svg',
|
|
13
|
+
{
|
|
14
|
+
width: 20,
|
|
15
|
+
height: 20,
|
|
16
|
+
viewBox: '0 0 24 24',
|
|
17
|
+
fill: 'none',
|
|
18
|
+
stroke: 'currentColor',
|
|
19
|
+
strokeWidth: 1.7,
|
|
20
|
+
strokeLinecap: 'round',
|
|
21
|
+
strokeLinejoin: 'round',
|
|
22
|
+
'aria-hidden': 'true',
|
|
23
|
+
},
|
|
24
|
+
h('circle', { cx: 12, cy: 12, r: 9 }),
|
|
25
|
+
h('ellipse', { cx: 12, cy: 12, rx: 4, ry: 9 }),
|
|
26
|
+
h('path', { d: 'M3 12h18M4.5 7.5h15M4.5 16.5h15' }),
|
|
27
|
+
)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const styles = {
|
|
31
|
+
page: { maxWidth: 760, margin: '0 auto', padding: '28px 20px 48px', color: 'var(--dsh-text, #e2e8f0)' },
|
|
32
|
+
title: { fontSize: 20, fontWeight: 600, margin: '0 0 4px' },
|
|
33
|
+
hint: { color: 'var(--dsh-text-secondary, #94a3b8)', fontSize: 13, margin: '0 0 20px' },
|
|
34
|
+
card: { border: '1px solid var(--dsh-border, #334155)', borderRadius: 12, padding: 16, marginBottom: 16 },
|
|
35
|
+
h2: { fontSize: 15, margin: '0 0 10px' },
|
|
36
|
+
textarea: {
|
|
37
|
+
width: '100%',
|
|
38
|
+
boxSizing: 'border-box',
|
|
39
|
+
minHeight: 84,
|
|
40
|
+
padding: 10,
|
|
41
|
+
borderRadius: 8,
|
|
42
|
+
border: '1px solid var(--dsh-border, #334155)',
|
|
43
|
+
background: 'var(--dsh-surface, #0b1220)',
|
|
44
|
+
color: 'inherit',
|
|
45
|
+
font: '13px/1.5 ui-monospace, Consolas, monospace',
|
|
46
|
+
resize: 'vertical',
|
|
47
|
+
},
|
|
48
|
+
row: { display: 'flex', gap: 10, flexWrap: 'wrap', alignItems: 'center', marginTop: 10 },
|
|
49
|
+
button: {
|
|
50
|
+
padding: '8px 14px',
|
|
51
|
+
borderRadius: 8,
|
|
52
|
+
border: '1px solid var(--dsh-border, #334155)',
|
|
53
|
+
background: 'var(--dsh-surface-raised, #334155)',
|
|
54
|
+
color: 'inherit',
|
|
55
|
+
cursor: 'pointer',
|
|
56
|
+
fontSize: 14,
|
|
57
|
+
},
|
|
58
|
+
primary: { background: '#2563eb', borderColor: '#2563eb', color: '#fff' },
|
|
59
|
+
status: { fontSize: 13, marginTop: 10, minHeight: 20, color: 'var(--dsh-text-secondary, #94a3b8)' },
|
|
60
|
+
video: { width: '100%', maxHeight: 320, borderRadius: 8, background: '#000', marginTop: 12, display: 'none' },
|
|
61
|
+
steps: { margin: '10px 0 0', paddingLeft: 20, fontSize: 13, color: 'var(--dsh-text-secondary, #94a3b8)' },
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function XrayPage() {
|
|
65
|
+
const [text, setText] = React.useState('')
|
|
66
|
+
const [status, setStatus] = React.useState('')
|
|
67
|
+
const [statusKind, setStatusKind] = React.useState('')
|
|
68
|
+
const [scanning, setScanning] = React.useState(false)
|
|
69
|
+
const videoRef = React.useRef(null)
|
|
70
|
+
const streamRef = React.useRef(null)
|
|
71
|
+
const timerRef = React.useRef(null)
|
|
72
|
+
const detectorRef = React.useRef(null)
|
|
73
|
+
|
|
74
|
+
const say = (message, kind) => {
|
|
75
|
+
setStatus(message)
|
|
76
|
+
setStatusKind(kind || '')
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const detector = () => {
|
|
80
|
+
if (detectorRef.current !== null) return detectorRef.current
|
|
81
|
+
detectorRef.current = 'BarcodeDetector' in window ? new window.BarcodeDetector({ formats: ['qr_code'] }) : false
|
|
82
|
+
return detectorRef.current
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const decodeCanvas = async (canvas) => {
|
|
86
|
+
const det = detector()
|
|
87
|
+
if (!det) return null
|
|
88
|
+
const codes = await det.detect(canvas)
|
|
89
|
+
return codes && codes.length > 0 ? codes[0].rawValue : null
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const accept = (value) => {
|
|
93
|
+
const trimmed = String(value || '').trim()
|
|
94
|
+
if (!trimmed) return false
|
|
95
|
+
setText(trimmed)
|
|
96
|
+
say('已识别:' + trimmed.slice(0, 64) + (trimmed.length > 64 ? '…' : ''), 'ok')
|
|
97
|
+
return true
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const onFile = async (event) => {
|
|
101
|
+
const file = event.target.files && event.target.files[0]
|
|
102
|
+
if (!file) return
|
|
103
|
+
say('正在解析图片…')
|
|
104
|
+
try {
|
|
105
|
+
const bitmap = await createImageBitmap(file)
|
|
106
|
+
const scale = Math.min(1, 1600 / Math.max(bitmap.width, bitmap.height))
|
|
107
|
+
const canvas = document.createElement('canvas')
|
|
108
|
+
canvas.width = Math.max(1, Math.round(bitmap.width * scale))
|
|
109
|
+
canvas.height = Math.max(1, Math.round(bitmap.height * scale))
|
|
110
|
+
canvas.getContext('2d').drawImage(bitmap, 0, 0, canvas.width, canvas.height)
|
|
111
|
+
const value = await decodeCanvas(canvas)
|
|
112
|
+
if (!value) say('图片里没有识别到二维码;换一张更清晰/更大的图,或使用插件目录里的 tools/scanner.html(带 jsQR 兜底)。', 'error')
|
|
113
|
+
else accept(value)
|
|
114
|
+
} catch (error) {
|
|
115
|
+
say('解析失败:' + error.message, 'error')
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const stop = async () => {
|
|
120
|
+
if (timerRef.current) {
|
|
121
|
+
clearInterval(timerRef.current)
|
|
122
|
+
timerRef.current = null
|
|
123
|
+
}
|
|
124
|
+
if (streamRef.current) {
|
|
125
|
+
for (const track of streamRef.current.getTracks()) track.stop()
|
|
126
|
+
streamRef.current = null
|
|
127
|
+
}
|
|
128
|
+
if (videoRef.current) videoRef.current.srcObject = null
|
|
129
|
+
setScanning(false)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const tick = async () => {
|
|
133
|
+
const video = videoRef.current
|
|
134
|
+
if (!video || !streamRef.current) return
|
|
135
|
+
if (video.readyState < 2 || !video.videoWidth) return
|
|
136
|
+
const canvas = document.createElement('canvas')
|
|
137
|
+
canvas.width = video.videoWidth
|
|
138
|
+
canvas.height = video.videoHeight
|
|
139
|
+
canvas.getContext('2d').drawImage(video, 0, 0)
|
|
140
|
+
try {
|
|
141
|
+
const value = await decodeCanvas(canvas)
|
|
142
|
+
if (value) {
|
|
143
|
+
accept(value)
|
|
144
|
+
await stop()
|
|
145
|
+
say('已识别并停止扫描。', 'ok')
|
|
146
|
+
}
|
|
147
|
+
} catch {
|
|
148
|
+
// a failed frame is normal while the code is out of view
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const startScreen = async () => {
|
|
153
|
+
if (!navigator.mediaDevices || !navigator.mediaDevices.getDisplayMedia) {
|
|
154
|
+
say('当前浏览器不支持屏幕捕获;请改用「二维码图片」或 tools/scanner.html。', 'error')
|
|
155
|
+
return
|
|
156
|
+
}
|
|
157
|
+
if (!detector()) {
|
|
158
|
+
say('当前浏览器没有 BarcodeDetector;请使用插件目录里的 tools/scanner.html(内置 jsQR 兜底)。', 'error')
|
|
159
|
+
return
|
|
160
|
+
}
|
|
161
|
+
try {
|
|
162
|
+
const stream = await navigator.mediaDevices.getDisplayMedia({ video: { frameRate: 10 }, audio: false })
|
|
163
|
+
streamRef.current = stream
|
|
164
|
+
setScanning(true)
|
|
165
|
+
if (videoRef.current) {
|
|
166
|
+
videoRef.current.srcObject = stream
|
|
167
|
+
videoRef.current.style.display = 'block'
|
|
168
|
+
await videoRef.current.play()
|
|
169
|
+
}
|
|
170
|
+
stream.getVideoTracks()[0].addEventListener('ended', stop)
|
|
171
|
+
say('请把二维码放到共享屏幕上,正在识别…')
|
|
172
|
+
timerRef.current = setInterval(tick, 350)
|
|
173
|
+
} catch (error) {
|
|
174
|
+
say('屏幕共享未开始:' + error.message, 'error')
|
|
175
|
+
setScanning(false)
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
React.useEffect(() => () => {
|
|
180
|
+
if (timerRef.current) clearInterval(timerRef.current)
|
|
181
|
+
if (streamRef.current) for (const track of streamRef.current.getTracks()) track.stop()
|
|
182
|
+
}, [])
|
|
183
|
+
|
|
184
|
+
const copy = async () => {
|
|
185
|
+
if (!text.trim()) {
|
|
186
|
+
say('还没有链接可复制。', 'error')
|
|
187
|
+
return
|
|
188
|
+
}
|
|
189
|
+
try {
|
|
190
|
+
await navigator.clipboard.writeText(text.trim())
|
|
191
|
+
say('已复制。粘贴到「插件 → Xray 代理 → 分享链接」后保存,并重启 DSH。', 'ok')
|
|
192
|
+
} catch {
|
|
193
|
+
say('复制失败,请手动选中文本复制。', 'error')
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return h(
|
|
198
|
+
'div',
|
|
199
|
+
{ style: styles.page },
|
|
200
|
+
h('h1', { style: styles.title }, '2Xray'),
|
|
201
|
+
h('p', { style: styles.hint }, '扫描或粘贴 vless 分享链接,然后到「插件 → Xray 代理」里保存。'),
|
|
202
|
+
h(
|
|
203
|
+
'section',
|
|
204
|
+
{ style: styles.card },
|
|
205
|
+
h('h2', { style: styles.h2 }, '1. 二维码图片'),
|
|
206
|
+
h('input', { type: 'file', accept: 'image/*', onChange: onFile, disabled: scanning }),
|
|
207
|
+
h('div', { style: styles.status, 'aria-live': 'polite' }, status),
|
|
208
|
+
),
|
|
209
|
+
h(
|
|
210
|
+
'section',
|
|
211
|
+
{ style: styles.card },
|
|
212
|
+
h('h2', { style: styles.h2 }, '2. 扫描屏幕二维码'),
|
|
213
|
+
h(
|
|
214
|
+
'div',
|
|
215
|
+
{ style: styles.row },
|
|
216
|
+
h('button', { type: 'button', style: { ...styles.button, ...styles.primary }, onClick: startScreen, disabled: scanning }, scanning ? '扫描中…' : '开始扫描屏幕'),
|
|
217
|
+
h('button', { type: 'button', style: styles.button, onClick: stop, disabled: !scanning }, '停止'),
|
|
218
|
+
),
|
|
219
|
+
h('video', { ref: videoRef, playsInline: true, muted: true, style: styles.video }),
|
|
220
|
+
),
|
|
221
|
+
h(
|
|
222
|
+
'section',
|
|
223
|
+
{ style: styles.card },
|
|
224
|
+
h('h2', { style: styles.h2 }, '3. 分享链接'),
|
|
225
|
+
h('textarea', {
|
|
226
|
+
style: styles.textarea,
|
|
227
|
+
value: text,
|
|
228
|
+
placeholder: 'vless://uuid@host:443?security=reality&sni=...&pbk=...#备注',
|
|
229
|
+
onChange: (event) => setText(event.target.value),
|
|
230
|
+
}),
|
|
231
|
+
h(
|
|
232
|
+
'div',
|
|
233
|
+
{ style: styles.row },
|
|
234
|
+
h('button', { type: 'button', style: { ...styles.button, ...styles.primary }, onClick: copy }, '复制链接'),
|
|
235
|
+
h('span', { style: { ...styles.status, marginTop: 0, color: statusKind === 'error' ? '#f87171' : statusKind === 'ok' ? '#4ade80' : undefined } }, status),
|
|
236
|
+
),
|
|
237
|
+
h(
|
|
238
|
+
'ol',
|
|
239
|
+
{ style: styles.steps },
|
|
240
|
+
h('li', null, '复制上面的 vless:// 链接;'),
|
|
241
|
+
h('li', null, '打开 设置 → 插件 → Xray 代理,粘贴到「分享链接」并保存;'),
|
|
242
|
+
h('li', null, '重启 DSH,状态变为 ready 后即生效。'),
|
|
243
|
+
),
|
|
244
|
+
),
|
|
245
|
+
)
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function apply(ctx) {
|
|
249
|
+
ctx.effect(
|
|
250
|
+
() =>
|
|
251
|
+
ctx.slots.inject('main', () =>
|
|
252
|
+
ctx.slots.register({ name: 'main', key: PANEL_ID }, XrayPage),
|
|
253
|
+
),
|
|
254
|
+
'dsh-2xray: main panel',
|
|
255
|
+
)
|
|
256
|
+
ctx.effect(
|
|
257
|
+
() =>
|
|
258
|
+
ctx.slots.inject('sidebar.panellist', () =>
|
|
259
|
+
ctx.slots.register({ name: 'sidebar.panellist', id: PANEL_ID, order: 1, label: () => '2Xray' }, GlobeIcon),
|
|
260
|
+
),
|
|
261
|
+
'dsh-2xray: sidebar entry',
|
|
262
|
+
)
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
return { apply, inject, PANEL_ID }
|
|
266
|
+
},
|
|
267
|
+
})
|
package/lib/config.js
CHANGED
|
@@ -213,6 +213,22 @@ function validateEndpoint(endpoint, prefix, errors, warnings) {
|
|
|
213
213
|
}
|
|
214
214
|
}
|
|
215
215
|
|
|
216
|
+
/**
|
|
217
|
+
* Schemastery resolves volatile fields to `{ get }` references. Normalization
|
|
218
|
+
* must see their values, so unwrap them before merging defaults.
|
|
219
|
+
*/
|
|
220
|
+
function unwrapVolatile(value) {
|
|
221
|
+
if (value === null || typeof value !== 'object') return value
|
|
222
|
+
if (Array.isArray(value)) return value.map(unwrapVolatile)
|
|
223
|
+
const keys = Object.keys(value)
|
|
224
|
+
if (keys.length === 1 && keys[0] === 'get' && typeof value.get === 'function') {
|
|
225
|
+
return unwrapVolatile(value.get())
|
|
226
|
+
}
|
|
227
|
+
const out = {}
|
|
228
|
+
for (const key of keys) out[key] = unwrapVolatile(value[key])
|
|
229
|
+
return out
|
|
230
|
+
}
|
|
231
|
+
|
|
216
232
|
/**
|
|
217
233
|
* Normalize a raw `config:` block from `cordis.patch.yml` into the shape the
|
|
218
234
|
* rest of the plugin consumes.
|
|
@@ -221,6 +237,7 @@ function validateEndpoint(endpoint, prefix, errors, warnings) {
|
|
|
221
237
|
* @returns normalized config plus field-level validation results.
|
|
222
238
|
*/
|
|
223
239
|
export function normalizeConfig(raw) {
|
|
240
|
+
raw = unwrapVolatile(raw)
|
|
224
241
|
const errors = []
|
|
225
242
|
const warnings = []
|
|
226
243
|
const merged = merge(clone(DEFAULT_CONFIG), raw)
|
package/lib/schema.js
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Settings schema for the host half.
|
|
3
|
+
*
|
|
4
|
+
* Only the fields that should be editable from the Plugins page are declared:
|
|
5
|
+
* Schemastery preserves unknown keys, so `nodes`, `routing`, `noProxy` and the
|
|
6
|
+
* rest of the advanced YAML config pass through untouched.
|
|
7
|
+
*
|
|
8
|
+
* Every editable field is `volatile`: the settings page only writes volatile
|
|
9
|
+
* paths, and a volatile-only change does not remount the plugin fiber. The host
|
|
10
|
+
* logs a "restart required" note; the value lands in the profile patch and is
|
|
11
|
+
* applied on the next DSH start (or plugin reload).
|
|
12
|
+
*
|
|
13
|
+
* Schemastery is a harness package that lives beside the installed plugin; the
|
|
14
|
+
* bare import works there. The fallback keeps local checkouts and tests working
|
|
15
|
+
* by resolving the copy from the active profile.
|
|
16
|
+
*/
|
|
17
|
+
import { createRequire } from 'node:module'
|
|
18
|
+
import { join } from 'node:path'
|
|
19
|
+
import { pathToFileURL } from 'node:url'
|
|
20
|
+
|
|
21
|
+
async function loadSchema() {
|
|
22
|
+
try {
|
|
23
|
+
return (await import('@deepseek-ai/schemastery')).default
|
|
24
|
+
} catch {
|
|
25
|
+
// local checkout / test: borrow the profile's copy
|
|
26
|
+
}
|
|
27
|
+
const home = process.env.USERPROFILE ?? process.env.HOME
|
|
28
|
+
const anchors = home
|
|
29
|
+
? [
|
|
30
|
+
join(home, '.dsh', 'profiles', 'desktop', 'package.json'),
|
|
31
|
+
join(home, '.dsh', 'profiles', 'web', 'package.json'),
|
|
32
|
+
]
|
|
33
|
+
: []
|
|
34
|
+
for (const anchor of anchors) {
|
|
35
|
+
try {
|
|
36
|
+
const file = createRequire(anchor).resolve('@deepseek-ai/schemastery')
|
|
37
|
+
const module = await import(pathToFileURL(file).href)
|
|
38
|
+
return module.default ?? module
|
|
39
|
+
} catch {
|
|
40
|
+
// try the next profile
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
throw new Error('@deepseek-ai/schemastery is not resolvable')
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const Schema = await loadSchema()
|
|
47
|
+
|
|
48
|
+
export const Config = Schema.object({
|
|
49
|
+
enabled: Schema.boolean()
|
|
50
|
+
.default(false)
|
|
51
|
+
.description('启用 Xray 代理:生成配置、启动子进程并接管全局出口。')
|
|
52
|
+
.volatile(),
|
|
53
|
+
shareLink: Schema.string()
|
|
54
|
+
.default('')
|
|
55
|
+
.description('vless:// 分享链接(二维码里就是这段文本)。支持 security=reality/tls/none。')
|
|
56
|
+
.volatile(),
|
|
57
|
+
binary: Schema.object({
|
|
58
|
+
path: Schema.string()
|
|
59
|
+
.default('')
|
|
60
|
+
.description('xray 可执行文件绝对路径;留空按 插件 bin/ → 数据目录 → PATH 查找。')
|
|
61
|
+
.volatile(),
|
|
62
|
+
}),
|
|
63
|
+
inbound: Schema.object({
|
|
64
|
+
port: Schema.natural()
|
|
65
|
+
.default(20808)
|
|
66
|
+
.description('本地 HTTP 入站端口(DSH 与 web_fetch 都经它出网)。')
|
|
67
|
+
.volatile(),
|
|
68
|
+
}),
|
|
69
|
+
takeover: Schema.string()
|
|
70
|
+
.default('auto')
|
|
71
|
+
.description('auto:已有代理时不覆盖;force:强制接管;off:只启动 xray 不接管。')
|
|
72
|
+
.volatile(),
|
|
73
|
+
functionalProbe: Schema.object({
|
|
74
|
+
enabled: Schema.boolean()
|
|
75
|
+
.default(true)
|
|
76
|
+
.description('启动后经本地入站请求一次 204,验证链路;离线环境可关闭。')
|
|
77
|
+
.volatile(),
|
|
78
|
+
}),
|
|
79
|
+
control: Schema.object({
|
|
80
|
+
enabled: Schema.boolean()
|
|
81
|
+
.default(false)
|
|
82
|
+
.description('启用 gRPC 控制面(多节点切换与流量统计,需 xray 2024-09+)。')
|
|
83
|
+
.volatile(),
|
|
84
|
+
}),
|
|
85
|
+
})
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@twinsearth/dsh-2xray",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.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",
|
|
@@ -10,7 +10,8 @@
|
|
|
10
10
|
"./package.json": "./package.json",
|
|
11
11
|
"./cordis.patch.yml": "./cordis.patch.yml",
|
|
12
12
|
"./locale/*.json": "./locale/*.json",
|
|
13
|
-
"./icon": "./icon.svg"
|
|
13
|
+
"./icon": "./icon.svg",
|
|
14
|
+
"./client": "./lib/client.js"
|
|
14
15
|
},
|
|
15
16
|
"files": [
|
|
16
17
|
"index.js",
|
|
@@ -26,7 +27,8 @@
|
|
|
26
27
|
"docs/*.md",
|
|
27
28
|
"examples/*.json",
|
|
28
29
|
"scripts/*.mjs",
|
|
29
|
-
"test/*.js"
|
|
30
|
+
"test/*.js",
|
|
31
|
+
"tools/*"
|
|
30
32
|
],
|
|
31
33
|
"scripts": {
|
|
32
34
|
"test": "node --test --test-isolation=none test/*.test.js",
|
|
@@ -39,6 +41,12 @@
|
|
|
39
41
|
"dsh": {
|
|
40
42
|
"bundle": {
|
|
41
43
|
"patch": "./cordis.patch.yml"
|
|
44
|
+
},
|
|
45
|
+
"client": {
|
|
46
|
+
"inject": [
|
|
47
|
+
"@deepseek-ai/dsh-client-locale"
|
|
48
|
+
],
|
|
49
|
+
"platform": "web"
|
|
42
50
|
}
|
|
43
51
|
}
|
|
44
52
|
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import test from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
|
|
4
|
+
test('the client bundle registers a main panel and a sidebar entry', async () => {
|
|
5
|
+
let captured
|
|
6
|
+
globalThis.window = { __ModuleLoader__: { load(module) { captured = module } } }
|
|
7
|
+
try {
|
|
8
|
+
await import('../lib/client.js')
|
|
9
|
+
} finally {
|
|
10
|
+
delete globalThis.window
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
assert.equal(captured.id, '@twinsearth/dsh-2xray')
|
|
14
|
+
const createElement = (type, props, ...children) => ({ type, props, children })
|
|
15
|
+
const react = {
|
|
16
|
+
createElement,
|
|
17
|
+
useState: () => [undefined, () => {}],
|
|
18
|
+
useEffect: () => {},
|
|
19
|
+
useRef: () => ({ current: null }),
|
|
20
|
+
}
|
|
21
|
+
const mod = captured.factory((name) => {
|
|
22
|
+
if (name === 'react') return react
|
|
23
|
+
throw new Error('unexpected module ' + name)
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
assert.equal(typeof mod.apply, 'function')
|
|
27
|
+
assert.deepEqual(mod.inject, ['slots', 'locale'])
|
|
28
|
+
assert.equal(mod.PANEL_ID, 'xray')
|
|
29
|
+
|
|
30
|
+
const registrations = []
|
|
31
|
+
const ctx = {
|
|
32
|
+
effect(fn) {
|
|
33
|
+
fn()
|
|
34
|
+
},
|
|
35
|
+
slots: {
|
|
36
|
+
inject(_name, callback) {
|
|
37
|
+
callback()
|
|
38
|
+
},
|
|
39
|
+
register(definition, component) {
|
|
40
|
+
registrations.push({ definition, component })
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
}
|
|
44
|
+
mod.apply(ctx)
|
|
45
|
+
|
|
46
|
+
assert.deepEqual(
|
|
47
|
+
registrations.map((entry) => entry.definition.name),
|
|
48
|
+
['main', 'sidebar.panellist'],
|
|
49
|
+
)
|
|
50
|
+
assert.equal(registrations[0].definition.key, 'xray')
|
|
51
|
+
assert.equal(registrations[1].definition.id, 'xray')
|
|
52
|
+
assert.equal(registrations[1].definition.order, 1)
|
|
53
|
+
assert.equal(registrations[1].definition.label(), '2Xray')
|
|
54
|
+
const icon = registrations[1].component()
|
|
55
|
+
assert.equal(icon.type, 'svg')
|
|
56
|
+
})
|