@twinsearth/dsh-2xray 0.1.0 → 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 CHANGED
@@ -1,5 +1,24 @@
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
+
16
+ ## 0.1.1 — 2026-10-29
17
+
18
+ ### 修复
19
+
20
+ - 插件启用时报 `cannot get property "subprocess" without inject`:Cordis 会拦截未声明 `inject` 的服务属性读取。改为 `ctx.get('subprocess')` 反射查询,并把 launcher 改为首次 spawn 时惰性解析;服务晚挂载或缺失时分别使用服务 / `child_process` 兜底,不再阻断启用。
21
+
3
22
  ## 0.1.0 — 2026-10-29
4
23
 
5
24
  首个发布版本(npm:`@twinsearth/dsh-2xray`)。
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
@@ -1,5 +1,5 @@
1
1
  import { normalizeConfig } from './lib/config.js'
2
- import { createLauncher } from './lib/process.js'
2
+ import { createLazyLauncher } from './lib/process.js'
3
3
  import { RouteManager } from './lib/route.js'
4
4
  import { Supervisor } from './lib/supervisor.js'
5
5
  import { createLogger } from './lib/log.js'
@@ -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)
@@ -49,7 +71,7 @@ export function apply(ctx, rawConfig) {
49
71
  logger.info('使用旧字段 publicKey 配置 REALITY;xray 会同时读取 password,两者都写可兼容新旧版本')
50
72
  } }
51
73
 
52
- const launcher = createLauncher(ctx, { logger })
74
+ const launcher = createLazyLauncher(ctx, { logger })
53
75
  const route = new RouteManager({ logger })
54
76
  const control =
55
77
  config.control.enabled
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/process.js CHANGED
@@ -3,6 +3,28 @@ import { setTimeout as delay } from 'node:timers/promises'
3
3
 
4
4
  const COLLECT_BYTES = 64 * 1024
5
5
 
6
+ /**
7
+ * Resolve the subprocess service without tripping Cordis' inject guard.
8
+ *
9
+ * Reading `ctx.subprocess` directly throws
10
+ * `cannot get property "subprocess" without inject` on a fiber that did not
11
+ * declare it; `ctx.get(name)` is the reflective lookup and returns undefined
12
+ * instead. The service stays optional because a profile without
13
+ * `dsh-subprocess-local` still gets the `child_process` fallback.
14
+ */
15
+ function resolveSubprocess(ctx) {
16
+ if (!ctx) return undefined
17
+ try {
18
+ if (typeof ctx.get === 'function') {
19
+ const service = ctx.get('subprocess')
20
+ if (service) return service
21
+ }
22
+ } catch {
23
+ // inactive or guarded context: fall through to the fallback launcher
24
+ }
25
+ return undefined
26
+ }
27
+
6
28
  /**
7
29
  * A launcher hides whether the child runs through `ctx.subprocess` (Windows Job
8
30
  * Object, process-tree containment, DSH's environment scrub) or through a bare
@@ -16,7 +38,7 @@ const COLLECT_BYTES = 64 * 1024
16
38
  * pid?: number
17
39
  */
18
40
  export function createLauncher(ctx, { logger } = {}) {
19
- const service = ctx?.subprocess
41
+ const service = resolveSubprocess(ctx)
20
42
  if (service && typeof service.spawn === 'function' && typeof service.resolveExecutable === 'function') {
21
43
  return createServiceLauncher(service)
22
44
  }
@@ -24,6 +46,27 @@ export function createLauncher(ctx, { logger } = {}) {
24
46
  return createNodeLauncher()
25
47
  }
26
48
 
49
+ /**
50
+ * Resolve the launcher lazily at first spawn instead of at activation: the
51
+ * subprocess provider may mount after this plugin's fiber, and a strict
52
+ * `inject` dependency would stop the plugin from activating at all.
53
+ */
54
+ export function createLazyLauncher(ctx, options) {
55
+ let inner
56
+ const resolve = () => (inner ??= createLauncher(ctx, options))
57
+ return {
58
+ get kind() {
59
+ return resolve().kind
60
+ },
61
+ resolveExecutable(command, env, signal) {
62
+ return resolve().resolveExecutable(command, env, signal)
63
+ },
64
+ spawn(spec) {
65
+ return resolve().spawn(spec)
66
+ },
67
+ }
68
+ }
69
+
27
70
  function createServiceLauncher(service) {
28
71
  return {
29
72
  kind: 'ctx.subprocess',
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.1.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
  }
@@ -18,7 +18,7 @@ const check = (name, ok, detail = '') => checks.push({ name, ok, detail })
18
18
  const pkg = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8'))
19
19
 
20
20
  check('name is @twinsearth/dsh-2xray', pkg.name === '@twinsearth/dsh-2xray', pkg.name)
21
- check('version is 0.1.0', pkg.version === '0.1.0', pkg.version)
21
+ check('version is semver', /^\d+\.\d+\.\d+$/.test(pkg.version), pkg.version)
22
22
  check('publishable', pkg.private !== true, `private=${pkg.private}`)
23
23
  check('license MIT', pkg.license === 'MIT', String(pkg.license))
24
24
  check('type module', pkg.type === 'module', String(pkg.type))
@@ -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
+ })