dsh-desktop-link 1.2.0 → 1.2.1
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/README.md +35 -2
- package/client.js +63 -11
- package/index.js +526 -397
- package/package.json +59 -59
package/README.md
CHANGED
|
@@ -97,9 +97,38 @@ https://registry.npmjs.org/dsh-desktop-link/-/dsh-desktop-link-1.2.0.tgz
|
|
|
97
97
|
└─────────────────────────────┘
|
|
98
98
|
```
|
|
99
99
|
|
|
100
|
-
保存会写进 `$DSH_HOME/desktop-url.txt`(先写临时文件再 rename
|
|
100
|
+
保存会写进 `$DSH_HOME/desktop-url.txt`(先写临时文件再 rename,半个文件不会被读走;
|
|
101
|
+
rename 被文件系统拒绝时退回直接覆写 —— Android 的 proot rootfs 会这样)。
|
|
101
102
|
两个框都清空 = 删掉该文件,退回自动探测。
|
|
102
103
|
|
|
104
|
+
这个面板按「绝不可能把页面搞挂」设计:不用 `<form>`(Android WebView 会把提交当成
|
|
105
|
+
一次顶层跳转交给 DSHA),按钮都是 `type="button"`,外层包了一个 error boundary ——
|
|
106
|
+
万一渲染抛错,只在这个位置显示一行说明,侧栏和会话都不受影响。
|
|
107
|
+
|
|
108
|
+
### 界面坏掉时的兜底(一个网址就能改)
|
|
109
|
+
|
|
110
|
+
不想点界面、或者界面在某个 WebView 里表现不正常,就直接访问:
|
|
111
|
+
|
|
112
|
+
```
|
|
113
|
+
http://127.0.0.1:<DSH 端口>/api/desktop-link/set?desktopUrl=https://xxx.dsh-market.com
|
|
114
|
+
http://127.0.0.1:<DSH 端口>/api/desktop-link/set?desktopUrl=https://xxx.dsh-market.com&launchUrl=https://…/open.html?k=…
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
返回一张「已保存」的小网页,写的还是同一个 `desktop-url.txt`。
|
|
118
|
+
这条路只接受同源请求(`Sec-Fetch-Site: cross-site` 与外站 `Origin` 一律 403),
|
|
119
|
+
地址栏直接输入(没有 Origin)是放行的。
|
|
120
|
+
|
|
121
|
+
### 在 DSHA 终端里一行搞定
|
|
122
|
+
|
|
123
|
+
DSHA 有终端,插件的 Host 半区是**每次请求重新读文件**的,所以写文件就等于改配置、
|
|
124
|
+
不用重启:
|
|
125
|
+
|
|
126
|
+
```sh
|
|
127
|
+
echo 'https://4217332722d23859.dsh-market.com' > ~/.dsh/desktop-url.txt
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
第一行长长期地址,第二行(可选)是点按钮时打开的地址。
|
|
131
|
+
|
|
103
132
|
### 用配置文件
|
|
104
133
|
|
|
105
134
|
插件自身不带默认地址 —— 它从下面这些地方按顺序找,命中即停:
|
|
@@ -154,8 +183,10 @@ DSH 的远程访问是要配对的。未配对设备直接打开桌面地址会
|
|
|
154
183
|
| GET | `/api/desktop-link/info` | `{"ok":true,"launchUrl":"…","desktopUrl":"…","source":"…"}` |
|
|
155
184
|
| POST | `/api/desktop-link/config` | body `{"desktopUrl":"…","launchUrl":"…"}` → 写入 `desktop-url.txt`,返回写入后的解析结果与 `path` |
|
|
156
185
|
|
|
186
|
+
| GET | `/api/desktop-link/set?desktopUrl=…&launchUrl=…` | 同上,但用查询参数、回一张 HTML 小页面;只要同源 |
|
|
187
|
+
|
|
157
188
|
POST 只接受 `http(s)` URL:传了非空值但格式不对会返回 `400` 并说明原因,不会静默清空。
|
|
158
|
-
请求体上限 8 KiB,非 `POST` 返回 `405
|
|
189
|
+
请求体上限 8 KiB,非 `POST` 返回 `405`;JSON 解析失败时会再试一次 `application/x-www-form-urlencoded`(部分 WebView 会这么发)。
|
|
159
190
|
|
|
160
191
|
每次请求重新解析地址,所以改配置文件不需要重启。
|
|
161
192
|
另外 Host 半区会在 `webserver/index-inject` 里注入 `globalThis.__DSHA_DESKTOP_LINK__`,让按钮首帧就能拿到地址;全局缺失时 Client 半区回退到上面那个接口。
|
|
@@ -189,6 +220,8 @@ locale/{zh,en}.json meta.title / meta.description
|
|
|
189
220
|
2. 侧栏折叠成 56px 轨道时只显示图标,展开后才显示「桌面版」文字。
|
|
190
221
|
3. 手机端「点击时铸码」依赖本机装了 `dsh-remote-web-ui`(本插件不声明这个依赖,装不装都能用,只是没有自动铸码)。
|
|
191
222
|
4. 需要 DSH `>= 0.2.1-rc.2`(`webserver/index-inject` 事件与 `sidebar.footer.action` 槽位)。
|
|
223
|
+
5. DSHA 把 DSH 锁在 `0.2.1-rc.2`,那一版**没有** `@deepseek-ai/schemastery`,所以手机上不会出现 DSH 自带的插件设置表单 —— 这正是本插件自带齿轮与 `/set` 路由的原因。
|
|
224
|
+
6. DSHA 的 profile 启动失败时会退到极简 recovery profile(只剩基础界面);如果界面突然"变回出厂样子",先看启动日志里 `[DSHA_STARTUP]` 那几行。
|
|
192
225
|
|
|
193
226
|
---
|
|
194
227
|
|
package/client.js
CHANGED
|
@@ -29,6 +29,7 @@ window.__ModuleLoader__.load({
|
|
|
29
29
|
'source.desktop-url.txt': '桌面地址文件',
|
|
30
30
|
'source.relay-registry': '固定中继',
|
|
31
31
|
'source.unset': '未配置',
|
|
32
|
+
'crash.fallback': '桌面地址面板出错了,界面其余部分不受影响。可以直接把地址写进 ~/.dsh/desktop-url.txt(第一行长期地址,第二行点按钮打开的地址)。',
|
|
32
33
|
}
|
|
33
34
|
const en = {
|
|
34
35
|
'action': 'Open desktop DSH',
|
|
@@ -52,6 +53,7 @@ window.__ModuleLoader__.load({
|
|
|
52
53
|
'source.desktop-url.txt': 'address file',
|
|
53
54
|
'source.relay-registry': 'fixed relay',
|
|
54
55
|
'source.unset': 'not configured',
|
|
56
|
+
'crash.fallback': 'The address panel failed; the rest of the UI is unaffected. Write the address into ~/.dsh/desktop-url.txt instead (line 1 the long-lived origin, line 2 what a click opens).',
|
|
55
57
|
}
|
|
56
58
|
|
|
57
59
|
/* 18×18 monitor with an out-arrow — same artwork as icon.svg, inlined so
|
|
@@ -239,6 +241,48 @@ window.__ModuleLoader__.load({
|
|
|
239
241
|
* @param read - selector from the store value to the slice worth rendering.
|
|
240
242
|
* @returns the current slice, re-rendering on change.
|
|
241
243
|
*/
|
|
244
|
+
/**
|
|
245
|
+
* Last line of defence: a render-time throw inside our own tree must not
|
|
246
|
+
* take the page down with it.
|
|
247
|
+
*
|
|
248
|
+
* DSHA runs this UI inside an Android WebView where a React error in a
|
|
249
|
+
* sidebar item unmounts the whole shell — the user sees a white page while
|
|
250
|
+
* the app is still alive. Nothing we render is worth that, so both sidebar
|
|
251
|
+
* items are wrapped: a failure shows one line of text and the address hint
|
|
252
|
+
* instead, and the rest of the GUI keeps working.
|
|
253
|
+
*/
|
|
254
|
+
class Boundary extends React.Component {
|
|
255
|
+
constructor(props) {
|
|
256
|
+
super(props)
|
|
257
|
+
this.state = { failed: false, message: '' }
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
static getDerivedStateFromError() {
|
|
261
|
+
return { failed: true }
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
componentDidCatch(error) {
|
|
265
|
+
try { console.error('dsha-desktop-link: render failed', error) } catch { /* noop */ }
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
render() {
|
|
269
|
+
if (!this.state.failed) return this.props.children
|
|
270
|
+
const t = (this.props && this.props.t) || ((key) => zh[key] || en[key] || key)
|
|
271
|
+
return h('div', {
|
|
272
|
+
role: 'alert',
|
|
273
|
+
style: {
|
|
274
|
+
padding: '8px 10px',
|
|
275
|
+
fontSize: 12,
|
|
276
|
+
lineHeight: 1.45,
|
|
277
|
+
borderRadius: 8,
|
|
278
|
+
background: 'var(--dsw-alias-interactive-bg-hover, rgba(127,127,127,.12))',
|
|
279
|
+
color: 'var(--dsw-alias-label-secondary, inherit)',
|
|
280
|
+
wordBreak: 'break-word',
|
|
281
|
+
},
|
|
282
|
+
}, t('crash.fallback'))
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
242
286
|
function useStore(source, read) {
|
|
243
287
|
const [, force] = React.useState(0)
|
|
244
288
|
React.useEffect(() => source.subscribe(() => force((n) => n + 1)), [source])
|
|
@@ -282,7 +326,7 @@ window.__ModuleLoader__.load({
|
|
|
282
326
|
mintPairUrl().then((url) => { window.location.assign(url || value.desktopUrl) })
|
|
283
327
|
}
|
|
284
328
|
|
|
285
|
-
return h('a', {
|
|
329
|
+
return h(Boundary, { t }, h('a', {
|
|
286
330
|
href: href || undefined,
|
|
287
331
|
target: '_blank',
|
|
288
332
|
rel: 'noopener noreferrer',
|
|
@@ -299,7 +343,7 @@ window.__ModuleLoader__.load({
|
|
|
299
343
|
? h('span', {
|
|
300
344
|
style: { marginLeft: 8, overflow: 'hidden', textOverflow: 'ellipsis', fontSize: 13 },
|
|
301
345
|
}, t('action.short'))
|
|
302
|
-
: null)
|
|
346
|
+
: null))
|
|
303
347
|
}
|
|
304
348
|
|
|
305
349
|
/**
|
|
@@ -312,7 +356,7 @@ window.__ModuleLoader__.load({
|
|
|
312
356
|
function DesktopLinkSettingsButton(props) {
|
|
313
357
|
const wide = props.wide !== false
|
|
314
358
|
const t = props.t || ((key) => zh[key] || en[key] || key)
|
|
315
|
-
return h(React.Fragment, null,
|
|
359
|
+
return h(Boundary, { t }, h(React.Fragment, null,
|
|
316
360
|
h('button', {
|
|
317
361
|
type: 'button',
|
|
318
362
|
style: baseButtonStyle(false),
|
|
@@ -322,7 +366,7 @@ window.__ModuleLoader__.load({
|
|
|
322
366
|
onMouseEnter: hoverIn,
|
|
323
367
|
onMouseLeave: hoverOut,
|
|
324
368
|
}, h(Gear)),
|
|
325
|
-
h(DesktopLinkSettingsDialog, { t }))
|
|
369
|
+
h(DesktopLinkSettingsDialog, { t })))
|
|
326
370
|
}
|
|
327
371
|
|
|
328
372
|
const overlayStyle = {
|
|
@@ -332,12 +376,18 @@ window.__ModuleLoader__.load({
|
|
|
332
376
|
display: 'flex',
|
|
333
377
|
alignItems: 'center',
|
|
334
378
|
justifyContent: 'center',
|
|
335
|
-
padding:
|
|
379
|
+
padding: '16px 16px calc(16px + env(safe-area-inset-bottom, 0px))',
|
|
336
380
|
background: 'rgba(0,0,0,.45)',
|
|
381
|
+
// A soft keyboard shrinks the WebView without moving a fixed overlay;
|
|
382
|
+
// letting the overlay itself scroll keeps the card reachable on a phone.
|
|
383
|
+
overflowY: 'auto',
|
|
384
|
+
overscrollBehavior: 'contain',
|
|
385
|
+
WebkitOverflowScrolling: 'touch',
|
|
337
386
|
}
|
|
338
387
|
const cardStyle = {
|
|
339
388
|
width: 'min(520px, 100%)',
|
|
340
389
|
maxHeight: 'calc(100vh - 32px)',
|
|
390
|
+
WebkitMaxHeight: 'calc(100vh - 32px)',
|
|
341
391
|
overflow: 'auto',
|
|
342
392
|
boxSizing: 'border-box',
|
|
343
393
|
padding: 20,
|
|
@@ -416,7 +466,7 @@ window.__ModuleLoader__.load({
|
|
|
416
466
|
if (!open) return null
|
|
417
467
|
|
|
418
468
|
const submit = (event) => {
|
|
419
|
-
event.preventDefault()
|
|
469
|
+
if (event && typeof event.preventDefault === 'function') event.preventDefault()
|
|
420
470
|
if (status === 'saving') return
|
|
421
471
|
setStatus('saving')
|
|
422
472
|
setError('')
|
|
@@ -521,23 +571,25 @@ window.__ModuleLoader__.load({
|
|
|
521
571
|
onClick: () => dialog.hide(),
|
|
522
572
|
}, fallback('form.cancel')),
|
|
523
573
|
h('button', {
|
|
524
|
-
type: '
|
|
574
|
+
type: 'button',
|
|
525
575
|
style: actionStyle(true),
|
|
526
576
|
disabled: status === 'saving',
|
|
527
577
|
onClick: submit,
|
|
528
578
|
}, status === 'saving' ? fallback('form.saving') : fallback('form.save'))))
|
|
529
579
|
|
|
530
|
-
|
|
580
|
+
// Deliberately not a <form>: an Android WebView treats a submission as a
|
|
581
|
+
// top-level navigation and hands it to DSHA, and a prevented submit still
|
|
582
|
+
// costs one navigation attempt. Two plain buttons have no such path.
|
|
583
|
+
return h(Boundary, { t: fallback }, h('div', {
|
|
531
584
|
role: 'dialog',
|
|
532
585
|
'aria-modal': 'true',
|
|
533
586
|
'aria-label': fallback('form.title'),
|
|
534
587
|
style: overlayStyle,
|
|
535
588
|
onClick: (event) => { if (event.target === event.currentTarget) dialog.hide() },
|
|
536
|
-
}, h('
|
|
537
|
-
onSubmit: submit,
|
|
589
|
+
}, h('div', {
|
|
538
590
|
style: cardStyle,
|
|
539
591
|
onClick: (event) => event.stopPropagation(),
|
|
540
|
-
}, body))
|
|
592
|
+
}, body)))
|
|
541
593
|
}
|
|
542
594
|
|
|
543
595
|
return {
|
package/index.js
CHANGED
|
@@ -1,397 +1,526 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* dsh-desktop-link —— 宿主半区。
|
|
3
|
-
*
|
|
4
|
-
* 只做一件事:把两个地址交给浏览器半区,并允许在界面上改这两个地址。
|
|
5
|
-
* 路由(均为同源 JSON):
|
|
6
|
-
*
|
|
7
|
-
* GET /api/desktop-link/info
|
|
8
|
-
* → { ok, launchUrl, desktopUrl, source }
|
|
9
|
-
*
|
|
10
|
-
* POST /api/desktop-link/config { desktopUrl?, launchUrl? }
|
|
11
|
-
* → { ok, path, launchUrl, desktopUrl, source };写入 $DSH_HOME/desktop-url.txt
|
|
12
|
-
*
|
|
13
|
-
* launchUrl 点按钮时真正打开的地址(可以是中转的一次性链接)
|
|
14
|
-
* desktopUrl 桌面版 GUI 的长期地址(书签用,也是 launchUrl 的兜底)
|
|
15
|
-
*
|
|
16
|
-
* desktopUrl 的来源优先级(命中即停):
|
|
17
|
-
* 1. 本行 config.desktopUrl
|
|
18
|
-
* 2. 环境变量 DSH_DESKTOP_URL
|
|
19
|
-
* 3. $DSH_HOME/desktop-url.txt 第一行
|
|
20
|
-
* 4. $DSH_HOME/remote-web-ui-registry/web.json 的 id → https://<id>.dsh-market.com
|
|
21
|
-
*
|
|
22
|
-
* launchUrl 额外支持 config.launchUrl / 环境变量 DSH_DESKTOP_LAUNCH_URL,
|
|
23
|
-
* 用来指向 dsha-pair/bridge.py 的 /open.html(每次点都铸一枚新码)。
|
|
24
|
-
* 没配 launchUrl 时,launchUrl 恒等于 desktopUrl。
|
|
25
|
-
*/
|
|
26
|
-
import { existsSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'
|
|
27
|
-
import { createRequire } from 'node:module'
|
|
28
|
-
import { homedir } from 'node:os'
|
|
29
|
-
import { dirname, join, resolve } from 'node:path'
|
|
30
|
-
import { fileURLToPath } from 'node:url'
|
|
31
|
-
|
|
32
|
-
/**
|
|
33
|
-
* Resolve `@deepseek-ai/schemastery`, which ships inside the harness
|
|
34
|
-
* installation rather than as a dependency of this package.
|
|
35
|
-
*
|
|
36
|
-
* A bare `import` here fails with ERR_MODULE_NOT_FOUND and takes the whole
|
|
37
|
-
* plugin down, so the module is located at runtime: first as a normal package
|
|
38
|
-
* import from this file, then by walking up from the running `dsh` binary (the
|
|
39
|
-
* harness's own node_modules), and finally by asking Node's global paths. The
|
|
40
|
-
* `Config` export is omitted entirely when none of those resolve — the plugin
|
|
41
|
-
* still works from config files, it just has no settings form.
|
|
42
|
-
* @returns the schemastery `z` function, or undefined.
|
|
43
|
-
*/
|
|
44
|
-
function loadSchemastery() {
|
|
45
|
-
const requireFrom = createRequire(import.meta.url)
|
|
46
|
-
const candidates = ['@deepseek-ai/schemastery']
|
|
47
|
-
|
|
48
|
-
// The harness entry point: .../node_modules/@deepseek-ai/dsh/lib/bin.js
|
|
49
|
-
const argv1 = process.argv[1]
|
|
50
|
-
if (typeof argv1 === 'string' && argv1) {
|
|
51
|
-
const dir = dirname(resolve(argv1))
|
|
52
|
-
candidates.push(join(dir, '..', 'node_modules', '@deepseek-ai', 'schemastery'))
|
|
53
|
-
candidates.push(join(dir, 'node_modules', '@deepseek-ai', 'schemastery'))
|
|
54
|
-
}
|
|
55
|
-
try {
|
|
56
|
-
for (const base of requireFrom.resolve.paths?.('@deepseek-ai/schemastery') ?? []) {
|
|
57
|
-
candidates.push(join(base, '@deepseek-ai', 'schemastery'))
|
|
58
|
-
}
|
|
59
|
-
} catch {
|
|
60
|
-
// resolve.paths is not on every Node build; the other candidates stand.
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
for (const candidate of candidates) {
|
|
64
|
-
try {
|
|
65
|
-
const mod = requireFrom(candidate)
|
|
66
|
-
const z = mod?.default ?? mod
|
|
67
|
-
if (typeof z === 'function' && typeof z.object === 'function') return z
|
|
68
|
-
} catch {
|
|
69
|
-
// try the next candidate
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
return undefined
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
const z = loadSchemastery()
|
|
76
|
-
|
|
77
|
-
/**
|
|
78
|
-
* Row config.
|
|
79
|
-
*
|
|
80
|
-
* Everything is optional. An install with no config falls through to the
|
|
81
|
-
* environment, then to `desktop-url.txt`, then to the relay origin that
|
|
82
|
-
* `dsh-remote-web-ui` publishes. The form exists so a phone — where there is no
|
|
83
|
-
* shell to write that file with — can set the address from the GUI.
|
|
84
|
-
*
|
|
85
|
-
* The loader validates Config through schemastery's own `meta`/`dict` shape, so
|
|
86
|
-
* a plain JSON Schema object is not interchangeable here (`undefined.validate`
|
|
87
|
-
* when it tries). When schemastery cannot be located the export is omitted: no
|
|
88
|
-
* settings form, but the row still activates.
|
|
89
|
-
*/
|
|
90
|
-
const configSchema = typeof z === 'function'
|
|
91
|
-
? z.object({
|
|
92
|
-
desktopUrl: z.string().description(
|
|
93
|
-
'桌面版 DSH 的长期地址(书签用),例如 https://xxx.dsh-market.com。'
|
|
94
|
-
+ '留空则依次读环境变量 DSH_DESKTOP_URL、$DSH_HOME/desktop-url.txt、'
|
|
95
|
-
+ 'dsh-remote-web-ui 的固定中继地址。'),
|
|
96
|
-
launchUrl: z.string().description(
|
|
97
|
-
'点按钮时真正打开的地址。留空则等于 desktopUrl。'
|
|
98
|
-
+ '填一个会临时铸配对码的端点(如 bridge.py 的 /open.html?k=…),'
|
|
99
|
-
+ '未配对过的手机也能一次进入。'),
|
|
100
|
-
})
|
|
101
|
-
: undefined
|
|
102
|
-
|
|
103
|
-
export { configSchema as Config }
|
|
104
|
-
|
|
105
|
-
function dshHome() {
|
|
106
|
-
const fromEnv = process.env.DSH_HOME
|
|
107
|
-
if (typeof fromEnv === 'string' && fromEnv.trim()) return fromEnv.trim()
|
|
108
|
-
return join(homedir(), '.dsh')
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
function urlish(value) {
|
|
112
|
-
return typeof value === 'string' && /^https?:\/\/\S+$/i.test(value.trim()) ? value.trim() : ''
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
/**
|
|
116
|
-
* Every URL in a hand-edited comment-friendly file under the DSH home.
|
|
117
|
-
*
|
|
118
|
-
* `desktop-url.txt` takes up to two lines, in this order:
|
|
119
|
-
* line 1 = desktopUrl, the long-lived desktop origin (bookmark target)
|
|
120
|
-
* line 2 = launchUrl, what a click actually opens (a minting endpoint)
|
|
121
|
-
* A single line therefore means "open this one thing". `#` comments and blank
|
|
122
|
-
* lines are skipped, and anything that is not http(s) is ignored rather than
|
|
123
|
-
* returned half-parsed.
|
|
124
|
-
* @param name - file name inside the DSH home.
|
|
125
|
-
* @returns the URLs found, in file order.
|
|
126
|
-
*/
|
|
127
|
-
function readUrlLines(name) {
|
|
128
|
-
try {
|
|
129
|
-
const file = join(dshHome(), name)
|
|
130
|
-
if (!existsSync(file)) return []
|
|
131
|
-
const found = []
|
|
132
|
-
for (const raw of readFileSync(file, 'utf8').split(/\r?\n/)) {
|
|
133
|
-
const line = raw.trim()
|
|
134
|
-
if (!line || line.startsWith('#')) continue
|
|
135
|
-
const value = urlish(line)
|
|
136
|
-
if (value) found.push(value)
|
|
137
|
-
}
|
|
138
|
-
return found
|
|
139
|
-
} catch {
|
|
140
|
-
return []
|
|
141
|
-
}
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
function readRelayOrigin() {
|
|
145
|
-
try {
|
|
146
|
-
const file = join(dshHome(), 'remote-web-ui-registry', 'web.json')
|
|
147
|
-
if (!existsSync(file)) return ''
|
|
148
|
-
const parsed = JSON.parse(readFileSync(file, 'utf8'))
|
|
149
|
-
const id = typeof parsed?.id === 'string' ? parsed.id.trim() : ''
|
|
150
|
-
return /^[A-Za-z0-9-]{8,}$/.test(id) ? `https://${id}.dsh-market.com` : ''
|
|
151
|
-
} catch {
|
|
152
|
-
return ''
|
|
153
|
-
}
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
/**
|
|
157
|
-
* Whether this instance has ever paired a device.
|
|
158
|
-
*
|
|
159
|
-
* The roster is `dsh-remote-web-ui`'s own persisted store
|
|
160
|
-
* (`$DSH_HOME/remote-web-ui-devices.json`, 0600, its documented default). It is
|
|
161
|
-
* read only for this boolean — no ids, no credentials — and a missing or
|
|
162
|
-
* unreadable file simply means "nothing paired yet", which is the safe answer:
|
|
163
|
-
* the button then mints a token instead of opening a URL that would 401.
|
|
164
|
-
* @returns whether at least one device session exists on disk.
|
|
165
|
-
*/
|
|
166
|
-
function hasPairedDevice() {
|
|
167
|
-
try {
|
|
168
|
-
const file = join(dshHome(), 'remote-web-ui-devices.json')
|
|
169
|
-
if (!existsSync(file)) return false
|
|
170
|
-
const parsed = JSON.parse(readFileSync(file, 'utf8'))
|
|
171
|
-
const rows = Array.isArray(parsed) ? parsed : (parsed?.devices ?? [])
|
|
172
|
-
return Array.isArray(rows) ? rows.length > 0 : Boolean(rows && Object.keys(rows).length)
|
|
173
|
-
} catch {
|
|
174
|
-
return false
|
|
175
|
-
}
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
function resolveUrls(config) {
|
|
179
|
-
const fileUrls = readUrlLines('desktop-url.txt')
|
|
180
|
-
const chain = [
|
|
181
|
-
['config', () => urlish(config?.desktopUrl)],
|
|
182
|
-
['env', () => urlish(process.env.DSH_DESKTOP_URL)],
|
|
183
|
-
['desktop-url.txt', () => fileUrls[0] ?? ''],
|
|
184
|
-
['relay-registry', readRelayOrigin],
|
|
185
|
-
]
|
|
186
|
-
let desktopUrl = ''
|
|
187
|
-
let source = 'unset'
|
|
188
|
-
for (const [label, read] of chain) {
|
|
189
|
-
const value = read()
|
|
190
|
-
if (value) { desktopUrl = value; source = label; break }
|
|
191
|
-
}
|
|
192
|
-
// launchUrl is what a click opens. In order: the row's config, the
|
|
193
|
-
// environment, line 2 of desktop-url.txt (a local minting endpoint such as
|
|
194
|
-
// dsha-pair/bridge.py's /open.html?k=…), then the desktop origin itself.
|
|
195
|
-
const launchUrl =
|
|
196
|
-
urlish(config?.launchUrl) ||
|
|
197
|
-
urlish(process.env.DSH_DESKTOP_LAUNCH_URL) ||
|
|
198
|
-
fileUrls[1] ||
|
|
199
|
-
desktopUrl
|
|
200
|
-
return { launchUrl, desktopUrl, source }
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
/**
|
|
204
|
-
* Nothing to do here — kept as the single place that maps a URL string to a
|
|
205
|
-
* file line, so the write path and the read path agree on what counts as a URL.
|
|
206
|
-
* @param value - candidate URL.
|
|
207
|
-
* @returns the trimmed URL, or an empty string when it is not http(s).
|
|
208
|
-
*/
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
*
|
|
218
|
-
*
|
|
219
|
-
*
|
|
220
|
-
*
|
|
221
|
-
*
|
|
222
|
-
*
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
if (
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
return
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
*
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
'
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
1
|
+
/**
|
|
2
|
+
* dsh-desktop-link —— 宿主半区。
|
|
3
|
+
*
|
|
4
|
+
* 只做一件事:把两个地址交给浏览器半区,并允许在界面上改这两个地址。
|
|
5
|
+
* 路由(均为同源 JSON):
|
|
6
|
+
*
|
|
7
|
+
* GET /api/desktop-link/info
|
|
8
|
+
* → { ok, launchUrl, desktopUrl, source }
|
|
9
|
+
*
|
|
10
|
+
* POST /api/desktop-link/config { desktopUrl?, launchUrl? }
|
|
11
|
+
* → { ok, path, launchUrl, desktopUrl, source };写入 $DSH_HOME/desktop-url.txt
|
|
12
|
+
*
|
|
13
|
+
* launchUrl 点按钮时真正打开的地址(可以是中转的一次性链接)
|
|
14
|
+
* desktopUrl 桌面版 GUI 的长期地址(书签用,也是 launchUrl 的兜底)
|
|
15
|
+
*
|
|
16
|
+
* desktopUrl 的来源优先级(命中即停):
|
|
17
|
+
* 1. 本行 config.desktopUrl
|
|
18
|
+
* 2. 环境变量 DSH_DESKTOP_URL
|
|
19
|
+
* 3. $DSH_HOME/desktop-url.txt 第一行
|
|
20
|
+
* 4. $DSH_HOME/remote-web-ui-registry/web.json 的 id → https://<id>.dsh-market.com
|
|
21
|
+
*
|
|
22
|
+
* launchUrl 额外支持 config.launchUrl / 环境变量 DSH_DESKTOP_LAUNCH_URL,
|
|
23
|
+
* 用来指向 dsha-pair/bridge.py 的 /open.html(每次点都铸一枚新码)。
|
|
24
|
+
* 没配 launchUrl 时,launchUrl 恒等于 desktopUrl。
|
|
25
|
+
*/
|
|
26
|
+
import { existsSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'
|
|
27
|
+
import { createRequire } from 'node:module'
|
|
28
|
+
import { homedir } from 'node:os'
|
|
29
|
+
import { dirname, join, resolve } from 'node:path'
|
|
30
|
+
import { fileURLToPath } from 'node:url'
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Resolve `@deepseek-ai/schemastery`, which ships inside the harness
|
|
34
|
+
* installation rather than as a dependency of this package.
|
|
35
|
+
*
|
|
36
|
+
* A bare `import` here fails with ERR_MODULE_NOT_FOUND and takes the whole
|
|
37
|
+
* plugin down, so the module is located at runtime: first as a normal package
|
|
38
|
+
* import from this file, then by walking up from the running `dsh` binary (the
|
|
39
|
+
* harness's own node_modules), and finally by asking Node's global paths. The
|
|
40
|
+
* `Config` export is omitted entirely when none of those resolve — the plugin
|
|
41
|
+
* still works from config files, it just has no settings form.
|
|
42
|
+
* @returns the schemastery `z` function, or undefined.
|
|
43
|
+
*/
|
|
44
|
+
function loadSchemastery() {
|
|
45
|
+
const requireFrom = createRequire(import.meta.url)
|
|
46
|
+
const candidates = ['@deepseek-ai/schemastery']
|
|
47
|
+
|
|
48
|
+
// The harness entry point: .../node_modules/@deepseek-ai/dsh/lib/bin.js
|
|
49
|
+
const argv1 = process.argv[1]
|
|
50
|
+
if (typeof argv1 === 'string' && argv1) {
|
|
51
|
+
const dir = dirname(resolve(argv1))
|
|
52
|
+
candidates.push(join(dir, '..', 'node_modules', '@deepseek-ai', 'schemastery'))
|
|
53
|
+
candidates.push(join(dir, 'node_modules', '@deepseek-ai', 'schemastery'))
|
|
54
|
+
}
|
|
55
|
+
try {
|
|
56
|
+
for (const base of requireFrom.resolve.paths?.('@deepseek-ai/schemastery') ?? []) {
|
|
57
|
+
candidates.push(join(base, '@deepseek-ai', 'schemastery'))
|
|
58
|
+
}
|
|
59
|
+
} catch {
|
|
60
|
+
// resolve.paths is not on every Node build; the other candidates stand.
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
for (const candidate of candidates) {
|
|
64
|
+
try {
|
|
65
|
+
const mod = requireFrom(candidate)
|
|
66
|
+
const z = mod?.default ?? mod
|
|
67
|
+
if (typeof z === 'function' && typeof z.object === 'function') return z
|
|
68
|
+
} catch {
|
|
69
|
+
// try the next candidate
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return undefined
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const z = loadSchemastery()
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Row config.
|
|
79
|
+
*
|
|
80
|
+
* Everything is optional. An install with no config falls through to the
|
|
81
|
+
* environment, then to `desktop-url.txt`, then to the relay origin that
|
|
82
|
+
* `dsh-remote-web-ui` publishes. The form exists so a phone — where there is no
|
|
83
|
+
* shell to write that file with — can set the address from the GUI.
|
|
84
|
+
*
|
|
85
|
+
* The loader validates Config through schemastery's own `meta`/`dict` shape, so
|
|
86
|
+
* a plain JSON Schema object is not interchangeable here (`undefined.validate`
|
|
87
|
+
* when it tries). When schemastery cannot be located the export is omitted: no
|
|
88
|
+
* settings form, but the row still activates.
|
|
89
|
+
*/
|
|
90
|
+
const configSchema = typeof z === 'function'
|
|
91
|
+
? z.object({
|
|
92
|
+
desktopUrl: z.string().description(
|
|
93
|
+
'桌面版 DSH 的长期地址(书签用),例如 https://xxx.dsh-market.com。'
|
|
94
|
+
+ '留空则依次读环境变量 DSH_DESKTOP_URL、$DSH_HOME/desktop-url.txt、'
|
|
95
|
+
+ 'dsh-remote-web-ui 的固定中继地址。'),
|
|
96
|
+
launchUrl: z.string().description(
|
|
97
|
+
'点按钮时真正打开的地址。留空则等于 desktopUrl。'
|
|
98
|
+
+ '填一个会临时铸配对码的端点(如 bridge.py 的 /open.html?k=…),'
|
|
99
|
+
+ '未配对过的手机也能一次进入。'),
|
|
100
|
+
})
|
|
101
|
+
: undefined
|
|
102
|
+
|
|
103
|
+
export { configSchema as Config }
|
|
104
|
+
|
|
105
|
+
function dshHome() {
|
|
106
|
+
const fromEnv = process.env.DSH_HOME
|
|
107
|
+
if (typeof fromEnv === 'string' && fromEnv.trim()) return fromEnv.trim()
|
|
108
|
+
return join(homedir(), '.dsh')
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function urlish(value) {
|
|
112
|
+
return typeof value === 'string' && /^https?:\/\/\S+$/i.test(value.trim()) ? value.trim() : ''
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Every URL in a hand-edited comment-friendly file under the DSH home.
|
|
117
|
+
*
|
|
118
|
+
* `desktop-url.txt` takes up to two lines, in this order:
|
|
119
|
+
* line 1 = desktopUrl, the long-lived desktop origin (bookmark target)
|
|
120
|
+
* line 2 = launchUrl, what a click actually opens (a minting endpoint)
|
|
121
|
+
* A single line therefore means "open this one thing". `#` comments and blank
|
|
122
|
+
* lines are skipped, and anything that is not http(s) is ignored rather than
|
|
123
|
+
* returned half-parsed.
|
|
124
|
+
* @param name - file name inside the DSH home.
|
|
125
|
+
* @returns the URLs found, in file order.
|
|
126
|
+
*/
|
|
127
|
+
function readUrlLines(name) {
|
|
128
|
+
try {
|
|
129
|
+
const file = join(dshHome(), name)
|
|
130
|
+
if (!existsSync(file)) return []
|
|
131
|
+
const found = []
|
|
132
|
+
for (const raw of readFileSync(file, 'utf8').split(/\r?\n/)) {
|
|
133
|
+
const line = raw.trim()
|
|
134
|
+
if (!line || line.startsWith('#')) continue
|
|
135
|
+
const value = urlish(line)
|
|
136
|
+
if (value) found.push(value)
|
|
137
|
+
}
|
|
138
|
+
return found
|
|
139
|
+
} catch {
|
|
140
|
+
return []
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function readRelayOrigin() {
|
|
145
|
+
try {
|
|
146
|
+
const file = join(dshHome(), 'remote-web-ui-registry', 'web.json')
|
|
147
|
+
if (!existsSync(file)) return ''
|
|
148
|
+
const parsed = JSON.parse(readFileSync(file, 'utf8'))
|
|
149
|
+
const id = typeof parsed?.id === 'string' ? parsed.id.trim() : ''
|
|
150
|
+
return /^[A-Za-z0-9-]{8,}$/.test(id) ? `https://${id}.dsh-market.com` : ''
|
|
151
|
+
} catch {
|
|
152
|
+
return ''
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Whether this instance has ever paired a device.
|
|
158
|
+
*
|
|
159
|
+
* The roster is `dsh-remote-web-ui`'s own persisted store
|
|
160
|
+
* (`$DSH_HOME/remote-web-ui-devices.json`, 0600, its documented default). It is
|
|
161
|
+
* read only for this boolean — no ids, no credentials — and a missing or
|
|
162
|
+
* unreadable file simply means "nothing paired yet", which is the safe answer:
|
|
163
|
+
* the button then mints a token instead of opening a URL that would 401.
|
|
164
|
+
* @returns whether at least one device session exists on disk.
|
|
165
|
+
*/
|
|
166
|
+
function hasPairedDevice() {
|
|
167
|
+
try {
|
|
168
|
+
const file = join(dshHome(), 'remote-web-ui-devices.json')
|
|
169
|
+
if (!existsSync(file)) return false
|
|
170
|
+
const parsed = JSON.parse(readFileSync(file, 'utf8'))
|
|
171
|
+
const rows = Array.isArray(parsed) ? parsed : (parsed?.devices ?? [])
|
|
172
|
+
return Array.isArray(rows) ? rows.length > 0 : Boolean(rows && Object.keys(rows).length)
|
|
173
|
+
} catch {
|
|
174
|
+
return false
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function resolveUrls(config) {
|
|
179
|
+
const fileUrls = readUrlLines('desktop-url.txt')
|
|
180
|
+
const chain = [
|
|
181
|
+
['config', () => urlish(config?.desktopUrl)],
|
|
182
|
+
['env', () => urlish(process.env.DSH_DESKTOP_URL)],
|
|
183
|
+
['desktop-url.txt', () => fileUrls[0] ?? ''],
|
|
184
|
+
['relay-registry', readRelayOrigin],
|
|
185
|
+
]
|
|
186
|
+
let desktopUrl = ''
|
|
187
|
+
let source = 'unset'
|
|
188
|
+
for (const [label, read] of chain) {
|
|
189
|
+
const value = read()
|
|
190
|
+
if (value) { desktopUrl = value; source = label; break }
|
|
191
|
+
}
|
|
192
|
+
// launchUrl is what a click opens. In order: the row's config, the
|
|
193
|
+
// environment, line 2 of desktop-url.txt (a local minting endpoint such as
|
|
194
|
+
// dsha-pair/bridge.py's /open.html?k=…), then the desktop origin itself.
|
|
195
|
+
const launchUrl =
|
|
196
|
+
urlish(config?.launchUrl) ||
|
|
197
|
+
urlish(process.env.DSH_DESKTOP_LAUNCH_URL) ||
|
|
198
|
+
fileUrls[1] ||
|
|
199
|
+
desktopUrl
|
|
200
|
+
return { launchUrl, desktopUrl, source }
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Nothing to do here — kept as the single place that maps a URL string to a
|
|
205
|
+
* file line, so the write path and the read path agree on what counts as a URL.
|
|
206
|
+
* @param value - candidate URL.
|
|
207
|
+
* @returns the trimmed URL, or an empty string when it is not http(s).
|
|
208
|
+
*/
|
|
209
|
+
/** Minimal HTML escaping for the fallback page. */
|
|
210
|
+
function escapeHtml(value) {
|
|
211
|
+
return String(value ?? '').replace(/[&<>"']/g, (ch) => (
|
|
212
|
+
ch === '&' ? '&' : ch === '<' ? '<' : ch === '>' ? '>' : ch === '"' ? '"' : '''
|
|
213
|
+
))
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Whether a request came from this origin.
|
|
218
|
+
*
|
|
219
|
+
* `/api/desktop-link/set` writes a URL on a bare GET, and registered routes
|
|
220
|
+
* sit outside the harness's own auth fence — so the check has to be here or any
|
|
221
|
+
* page on the internet could repoint the user's button with an image tag.
|
|
222
|
+
* A request typed into the address bar arrives with no Origin and no
|
|
223
|
+
* `Sec-Fetch-Site`; that is allowed on purpose, because it is the exact case
|
|
224
|
+
* the fallback page exists for.
|
|
225
|
+
* @param req - the incoming request.
|
|
226
|
+
* @returns whether the request is same-origin (or origin-less).
|
|
227
|
+
*/
|
|
228
|
+
function sameOrigin(req) {
|
|
229
|
+
try {
|
|
230
|
+
const site = String(req.headers['sec-fetch-site'] ?? '')
|
|
231
|
+
if (site === 'cross-site') return false
|
|
232
|
+
const origin = String(req.headers.origin ?? '')
|
|
233
|
+
if (!origin) return true
|
|
234
|
+
return new URL(origin).host === String(req.headers.host ?? '')
|
|
235
|
+
} catch {
|
|
236
|
+
return false
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Send the fallback page.
|
|
242
|
+
* @param res - the response.
|
|
243
|
+
* @param status - HTTP status.
|
|
244
|
+
* @param title - heading and document title.
|
|
245
|
+
* @param body - one paragraph of outcome text.
|
|
246
|
+
*/
|
|
247
|
+
function sendPage(res, status, title, body) {
|
|
248
|
+
const page = `<!doctype html><meta charset="utf-8">`
|
|
249
|
+
+ `<meta name="viewport" content="width=device-width,initial-scale=1">`
|
|
250
|
+
+ `<title>${escapeHtml(title)}</title>`
|
|
251
|
+
+ `<body style="font:15px/1.6 system-ui,-apple-system,sans-serif;padding:24px;max-width:720px;color:#222">`
|
|
252
|
+
+ `<h1 style="font-size:18px;margin:0 0 12px">${escapeHtml(title)}</h1>`
|
|
253
|
+
+ `<p style="margin:0 0 16px;word-break:break-all">${escapeHtml(body)}</p>`
|
|
254
|
+
+ `<p style="margin:0"><a href="/" style="color:#4d6bfe">← DSH</a></p></body>`
|
|
255
|
+
res.writeHead(status, {
|
|
256
|
+
'content-type': 'text/html; charset=utf-8',
|
|
257
|
+
'content-length': Buffer.byteLength(page),
|
|
258
|
+
'cache-control': 'no-store',
|
|
259
|
+
})
|
|
260
|
+
res.end(page)
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function cleanUrl(value) {
|
|
264
|
+
return urlish(value)
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Persist both addresses to `$DSH_HOME/desktop-url.txt`.
|
|
269
|
+
*
|
|
270
|
+
* Written to a sibling temp file and renamed into place: a half-written file
|
|
271
|
+
* would be read back by the next `/api/desktop-link/info` and silently point
|
|
272
|
+
* the button at a truncated URL. An empty pair removes the file entirely, which
|
|
273
|
+
* restores the relay-registry fallback instead of pinning a dead address.
|
|
274
|
+
* @param desktopUrl - the long-lived origin, or ''.
|
|
275
|
+
* @param launchUrl - what a click opens, or '' for "same as desktopUrl".
|
|
276
|
+
* @returns the file path written, or '' when the file was removed.
|
|
277
|
+
*/
|
|
278
|
+
function writeUrlFile(desktopUrl, launchUrl) {
|
|
279
|
+
const file = join(dshHome(), 'desktop-url.txt')
|
|
280
|
+
const lines = []
|
|
281
|
+
if (desktopUrl) lines.push(desktopUrl)
|
|
282
|
+
// Line 2 only carries meaning next to a line 1; a lone launchUrl is written
|
|
283
|
+
// as line 1 so a user who only knows the minting endpoint still gets one.
|
|
284
|
+
if (launchUrl && launchUrl !== desktopUrl) {
|
|
285
|
+
if (lines.length === 0) lines.push(launchUrl)
|
|
286
|
+
else lines.push(launchUrl)
|
|
287
|
+
}
|
|
288
|
+
if (lines.length === 0) {
|
|
289
|
+
try { rmSync(file, { force: true }) } catch { /* already gone */ }
|
|
290
|
+
return ''
|
|
291
|
+
}
|
|
292
|
+
const payload = `${lines.join('\n')}\n`
|
|
293
|
+
const tmp = `${file}.tmp`
|
|
294
|
+
writeFileSync(tmp, payload, { encoding: 'utf8', mode: 0o600 })
|
|
295
|
+
try {
|
|
296
|
+
renameSync(tmp, file)
|
|
297
|
+
} catch {
|
|
298
|
+
// Android's proot rootfs can refuse a rename across its overlay; a direct
|
|
299
|
+
// write is not atomic but it still leaves a complete file, which beats a
|
|
300
|
+
// settings form that can never save.
|
|
301
|
+
writeFileSync(file, payload, { encoding: 'utf8', mode: 0o600 })
|
|
302
|
+
try { rmSync(tmp, { force: true }) } catch { /* best effort */ }
|
|
303
|
+
}
|
|
304
|
+
return file
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Read a small JSON request body.
|
|
309
|
+
*
|
|
310
|
+
* Bounded on purpose: this route is reachable from any paired device, and an
|
|
311
|
+
* unbounded read would let one request hold the process's memory. The cap is
|
|
312
|
+
* far above the two URLs the form sends.
|
|
313
|
+
* @param req - the incoming request.
|
|
314
|
+
* @returns the parsed object, or null when the body is absent, oversized, or
|
|
315
|
+
* not JSON.
|
|
316
|
+
*/
|
|
317
|
+
function readJsonBody(req) {
|
|
318
|
+
return new Promise((resolveBody) => {
|
|
319
|
+
const chunks = []
|
|
320
|
+
let size = 0
|
|
321
|
+
let settled = false
|
|
322
|
+
const finish = (value) => {
|
|
323
|
+
if (settled) return
|
|
324
|
+
settled = true
|
|
325
|
+
resolveBody(value)
|
|
326
|
+
}
|
|
327
|
+
req.on('data', (chunk) => {
|
|
328
|
+
size += chunk.length
|
|
329
|
+
if (size > 8192) { finish(null); req.destroy(); return }
|
|
330
|
+
chunks.push(chunk)
|
|
331
|
+
})
|
|
332
|
+
req.on('end', () => {
|
|
333
|
+
if (!chunks.length) return finish({})
|
|
334
|
+
const text = Buffer.concat(chunks).toString('utf8')
|
|
335
|
+
try {
|
|
336
|
+
const parsed = JSON.parse(text)
|
|
337
|
+
return finish(parsed && typeof parsed === 'object' ? parsed : null)
|
|
338
|
+
} catch {
|
|
339
|
+
// Some WebViews send a form body even when asked for JSON.
|
|
340
|
+
}
|
|
341
|
+
try {
|
|
342
|
+
const form = new URLSearchParams(text)
|
|
343
|
+
const out = {}
|
|
344
|
+
for (const [key, value] of form) out[key] = value
|
|
345
|
+
return finish(Object.keys(out).length ? out : null)
|
|
346
|
+
} catch {
|
|
347
|
+
return finish(null)
|
|
348
|
+
}
|
|
349
|
+
})
|
|
350
|
+
req.on('error', () => finish(null))
|
|
351
|
+
})
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/** Host half: publish the desktop address. */
|
|
355
|
+
export const inject = ['webServer']
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Register the info route and the index global.
|
|
359
|
+
*
|
|
360
|
+
* The browser half reads `globalThis.__DSHA_DESKTOP_LINK__` synchronously, so
|
|
361
|
+
* the button is usable on first paint. `paired` tells it whether this instance
|
|
362
|
+
* ever paired a device: when it never has, the client mints a token itself
|
|
363
|
+
* through `/api/pair/issue` (same-origin, loopback, so the pairing service
|
|
364
|
+
* answers) instead of opening a URL that would hit the harness 401 fence.
|
|
365
|
+
*
|
|
366
|
+
* `config` is read per request rather than captured once: the settings form
|
|
367
|
+
* writes through the Host's config layer, and a request that arrived after such
|
|
368
|
+
* a write must see the new value without a restart.
|
|
369
|
+
* @param ctx - host plugin context carrying webServer.
|
|
370
|
+
* @param config - the row's live config (`{ desktopUrl?, launchUrl? }`).
|
|
371
|
+
*/
|
|
372
|
+
export function apply(ctx, config) {
|
|
373
|
+
/**
|
|
374
|
+
* A Cordis config value may be a getter-backed proxy that throws when a field
|
|
375
|
+
* is read outside the loader's own evaluation, so every read is guarded: a
|
|
376
|
+
* failing field is treated as absent, and the chain simply continues.
|
|
377
|
+
*/
|
|
378
|
+
const field = (name) => {
|
|
379
|
+
try {
|
|
380
|
+
const source = typeof config === 'function' ? config() : config
|
|
381
|
+
const value = source?.[name]
|
|
382
|
+
return typeof value === 'string' ? value : ''
|
|
383
|
+
} catch {
|
|
384
|
+
return ''
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
const live = () => resolveUrls({ desktopUrl: field('desktopUrl'), launchUrl: field('launchUrl') })
|
|
388
|
+
|
|
389
|
+
const disposeGlobal = ctx.on('webserver/index-inject', (table) => {
|
|
390
|
+
table.push({ kind: 'global', name: '__DSHA_DESKTOP_LINK__', value: live() })
|
|
391
|
+
})
|
|
392
|
+
|
|
393
|
+
const info = ctx.webServer.register({
|
|
394
|
+
kind: 'exact',
|
|
395
|
+
path: '/api/desktop-link/info',
|
|
396
|
+
handler(_req, res) {
|
|
397
|
+
// Re-read per request: editing desktop-url.txt (or saving the settings
|
|
398
|
+
// form) takes effect on the next click without restarting the profile.
|
|
399
|
+
// Never let a resolve failure answer with a bare 400 — the button reads
|
|
400
|
+
// this and a crash here looks exactly like "the plugin is gone".
|
|
401
|
+
let payload
|
|
402
|
+
try {
|
|
403
|
+
payload = { ok: true, ...live() }
|
|
404
|
+
} catch (error) {
|
|
405
|
+
payload = { ok: false, error: String(error?.message ?? error) }
|
|
406
|
+
}
|
|
407
|
+
const body = JSON.stringify(payload)
|
|
408
|
+
res.writeHead(200, {
|
|
409
|
+
'content-type': 'application/json; charset=utf-8',
|
|
410
|
+
'content-length': Buffer.byteLength(body),
|
|
411
|
+
'cache-control': 'no-store',
|
|
412
|
+
})
|
|
413
|
+
res.end(body)
|
|
414
|
+
},
|
|
415
|
+
})
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* Save the two addresses.
|
|
419
|
+
*
|
|
420
|
+
* This is the form a phone can actually reach: DSH renders a settings form
|
|
421
|
+
* only for entries it considers editable, and an installed bundle's own row
|
|
422
|
+
* is not always one of them. The route writes the same file the file-based
|
|
423
|
+
* chain reads, so both entry points converge on one source of truth.
|
|
424
|
+
*
|
|
425
|
+
* POST /api/desktop-link/config { desktopUrl?, launchUrl? }
|
|
426
|
+
*/
|
|
427
|
+
const save = ctx.webServer.register({
|
|
428
|
+
kind: 'exact',
|
|
429
|
+
path: '/api/desktop-link/config',
|
|
430
|
+
handler(req, res) {
|
|
431
|
+
const reply = (status, payload) => {
|
|
432
|
+
const body = JSON.stringify(payload)
|
|
433
|
+
res.writeHead(status, {
|
|
434
|
+
'content-type': 'application/json; charset=utf-8',
|
|
435
|
+
'content-length': Buffer.byteLength(body),
|
|
436
|
+
'cache-control': 'no-store',
|
|
437
|
+
})
|
|
438
|
+
res.end(body)
|
|
439
|
+
}
|
|
440
|
+
if (req.method !== 'POST') return reply(405, { ok: false, error: 'POST only' })
|
|
441
|
+
readJsonBody(req).catch(() => null).then((body) => {
|
|
442
|
+
if (!body) return reply(400, { ok: false, error: 'invalid JSON body' })
|
|
443
|
+
const desktopUrl = cleanUrl(body.desktopUrl)
|
|
444
|
+
const launchUrl = cleanUrl(body.launchUrl)
|
|
445
|
+
// A supplied non-empty value that fails validation is an error, not a
|
|
446
|
+
// silent "unset": the user typed something and deserves to know it was
|
|
447
|
+
// rejected instead of watching the button stay grey.
|
|
448
|
+
if (body.desktopUrl && !desktopUrl) {
|
|
449
|
+
return reply(400, { ok: false, error: 'desktopUrl must be an http(s) URL' })
|
|
450
|
+
}
|
|
451
|
+
if (body.launchUrl && !launchUrl) {
|
|
452
|
+
return reply(400, { ok: false, error: 'launchUrl must be an http(s) URL' })
|
|
453
|
+
}
|
|
454
|
+
try {
|
|
455
|
+
const path = writeUrlFile(desktopUrl, launchUrl)
|
|
456
|
+
return reply(200, { ok: true, path, ...live() })
|
|
457
|
+
} catch (error) {
|
|
458
|
+
return reply(500, { ok: false, error: String(error?.message ?? error) })
|
|
459
|
+
}
|
|
460
|
+
})
|
|
461
|
+
},
|
|
462
|
+
})
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* The no-dialog path.
|
|
466
|
+
*
|
|
467
|
+
* Everything the settings panel does, reachable as one URL, so a phone whose
|
|
468
|
+
* WebView misbehaves — or a user with only a terminal — can still set the
|
|
469
|
+
* address without touching the sidebar:
|
|
470
|
+
*
|
|
471
|
+
* GET /api/desktop-link/set?desktopUrl=https://xxx.dsh-market.com
|
|
472
|
+
*/
|
|
473
|
+
const setRoute = ctx.webServer.register({
|
|
474
|
+
kind: 'exact',
|
|
475
|
+
path: '/api/desktop-link/set',
|
|
476
|
+
handler(req, res) {
|
|
477
|
+
if (req.method !== 'GET') {
|
|
478
|
+
res.writeHead(405, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
|
|
479
|
+
res.end(JSON.stringify({ ok: false, error: 'GET only' }))
|
|
480
|
+
return
|
|
481
|
+
}
|
|
482
|
+
if (!sameOrigin(req)) {
|
|
483
|
+
sendPage(res, 403, '已拒绝', '这个地址只能从本机页面写入(跨站请求会被拒绝)。')
|
|
484
|
+
return
|
|
485
|
+
}
|
|
486
|
+
let query
|
|
487
|
+
try {
|
|
488
|
+
query = new URL(String(req.url ?? '/'), 'http://127.0.0.1').searchParams
|
|
489
|
+
} catch {
|
|
490
|
+
sendPage(res, 400, '参数无法解析', '请求地址无法解析,请重新打开一次。')
|
|
491
|
+
return
|
|
492
|
+
}
|
|
493
|
+
const rawDesktop = query.get('desktopUrl') ?? query.get('desktop') ?? ''
|
|
494
|
+
const rawLaunch = query.get('launchUrl') ?? query.get('launch') ?? ''
|
|
495
|
+
if (!rawDesktop && !rawLaunch) {
|
|
496
|
+
sendPage(res, 400, '缺少参数',
|
|
497
|
+
'用法:/api/desktop-link/set?desktopUrl=https://xxx.dsh-market.com'
|
|
498
|
+
+ '(可再加 &launchUrl=https://…/open.html?k=…)')
|
|
499
|
+
return
|
|
500
|
+
}
|
|
501
|
+
const desktopUrl = cleanUrl(rawDesktop)
|
|
502
|
+
const launchUrl = cleanUrl(rawLaunch)
|
|
503
|
+
if ((rawDesktop && !desktopUrl) || (rawLaunch && !launchUrl)) {
|
|
504
|
+
sendPage(res, 400, '地址不合法', '只接受 http(s) 地址。')
|
|
505
|
+
return
|
|
506
|
+
}
|
|
507
|
+
try {
|
|
508
|
+
const file = writeUrlFile(desktopUrl, launchUrl)
|
|
509
|
+
const now = live()
|
|
510
|
+
sendPage(res, 200, '已保存',
|
|
511
|
+
`${file ? `已写入 ${file}。` : '已清空地址文件。'}`
|
|
512
|
+
+ `桌面地址:${now.desktopUrl || '(未配置)'};点按钮打开:${now.launchUrl || '(未配置)'}。`
|
|
513
|
+
+ '回到 DSH 点一下按钮即可。')
|
|
514
|
+
} catch (error) {
|
|
515
|
+
sendPage(res, 500, '保存失败', `写入桌面地址文件时出错:${String(error?.message ?? error)}`)
|
|
516
|
+
}
|
|
517
|
+
},
|
|
518
|
+
})
|
|
519
|
+
|
|
520
|
+
ctx.effect(() => () => {
|
|
521
|
+
if (typeof disposeGlobal === 'function') disposeGlobal()
|
|
522
|
+
if (typeof info === 'function') info()
|
|
523
|
+
if (typeof save === 'function') save()
|
|
524
|
+
if (typeof setRoute === 'function') setRoute()
|
|
525
|
+
}, 'dsha-desktop-link: info + config routes + index global')
|
|
526
|
+
}
|
package/package.json
CHANGED
|
@@ -1,60 +1,60 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "dsh-desktop-link",
|
|
3
|
-
"version": "1.2.
|
|
4
|
-
"type": "module",
|
|
5
|
-
"icon": "./icon.svg",
|
|
6
|
-
"exports": {
|
|
7
|
-
".": "./index.js",
|
|
8
|
-
"./client": "./client.js",
|
|
9
|
-
"./locale/*.json": "./locale/*.json",
|
|
10
|
-
"./icon": "./icon.svg",
|
|
11
|
-
"./package.json": "./package.json"
|
|
12
|
-
},
|
|
13
|
-
"files": [
|
|
14
|
-
"index.js",
|
|
15
|
-
"client.js",
|
|
16
|
-
"cordis.patch.yml",
|
|
17
|
-
"icon.svg",
|
|
18
|
-
"locale",
|
|
19
|
-
"assets"
|
|
20
|
-
],
|
|
21
|
-
"dsh": {
|
|
22
|
-
"engines": {
|
|
23
|
-
"dsh": ">=0.2.1-rc.2"
|
|
24
|
-
},
|
|
25
|
-
"bundle": {
|
|
26
|
-
"patch": "./cordis.patch.yml"
|
|
27
|
-
},
|
|
28
|
-
"client": {
|
|
29
|
-
"platform": "web",
|
|
30
|
-
"immediately": true,
|
|
31
|
-
"inject": [
|
|
32
|
-
"@deepseek-ai/dsh-client-ui-sidebar"
|
|
33
|
-
]
|
|
34
|
-
}
|
|
35
|
-
},
|
|
36
|
-
"license": "MIT",
|
|
37
|
-
"author": "lzlrt",
|
|
38
|
-
"keywords": [
|
|
39
|
-
"dsh",
|
|
40
|
-
"dsh-plugin",
|
|
41
|
-
"deepseek",
|
|
42
|
-
"deepseek-harness",
|
|
43
|
-
"dsha",
|
|
44
|
-
"remote",
|
|
45
|
-
"sidebar",
|
|
46
|
-
"mobile"
|
|
47
|
-
],
|
|
48
|
-
"repository": {
|
|
49
|
-
"type": "git",
|
|
50
|
-
"url": "git+https://github.com/lzlrt/dsha-desktop-link.git"
|
|
51
|
-
},
|
|
52
|
-
"homepage": "https://github.com/lzlrt/dsha-desktop-link#readme",
|
|
53
|
-
"bugs": {
|
|
54
|
-
"url": "https://github.com/lzlrt/dsha-desktop-link/issues"
|
|
55
|
-
},
|
|
56
|
-
"description": "DSH sidebar button: open the desktop DSH Web GUI from DSHA on your phone, in one tap.",
|
|
57
|
-
"engines": {
|
|
58
|
-
"node": "^22.19.0 || >=24.0.0"
|
|
59
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "dsh-desktop-link",
|
|
3
|
+
"version": "1.2.1",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"icon": "./icon.svg",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": "./index.js",
|
|
8
|
+
"./client": "./client.js",
|
|
9
|
+
"./locale/*.json": "./locale/*.json",
|
|
10
|
+
"./icon": "./icon.svg",
|
|
11
|
+
"./package.json": "./package.json"
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"index.js",
|
|
15
|
+
"client.js",
|
|
16
|
+
"cordis.patch.yml",
|
|
17
|
+
"icon.svg",
|
|
18
|
+
"locale",
|
|
19
|
+
"assets"
|
|
20
|
+
],
|
|
21
|
+
"dsh": {
|
|
22
|
+
"engines": {
|
|
23
|
+
"dsh": ">=0.2.1-rc.2"
|
|
24
|
+
},
|
|
25
|
+
"bundle": {
|
|
26
|
+
"patch": "./cordis.patch.yml"
|
|
27
|
+
},
|
|
28
|
+
"client": {
|
|
29
|
+
"platform": "web",
|
|
30
|
+
"immediately": true,
|
|
31
|
+
"inject": [
|
|
32
|
+
"@deepseek-ai/dsh-client-ui-sidebar"
|
|
33
|
+
]
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
"license": "MIT",
|
|
37
|
+
"author": "lzlrt",
|
|
38
|
+
"keywords": [
|
|
39
|
+
"dsh",
|
|
40
|
+
"dsh-plugin",
|
|
41
|
+
"deepseek",
|
|
42
|
+
"deepseek-harness",
|
|
43
|
+
"dsha",
|
|
44
|
+
"remote",
|
|
45
|
+
"sidebar",
|
|
46
|
+
"mobile"
|
|
47
|
+
],
|
|
48
|
+
"repository": {
|
|
49
|
+
"type": "git",
|
|
50
|
+
"url": "git+https://github.com/lzlrt/dsha-desktop-link.git"
|
|
51
|
+
},
|
|
52
|
+
"homepage": "https://github.com/lzlrt/dsha-desktop-link#readme",
|
|
53
|
+
"bugs": {
|
|
54
|
+
"url": "https://github.com/lzlrt/dsha-desktop-link/issues"
|
|
55
|
+
},
|
|
56
|
+
"description": "DSH sidebar button: open the desktop DSH Web GUI from DSHA on your phone, in one tap.",
|
|
57
|
+
"engines": {
|
|
58
|
+
"node": "^22.19.0 || >=24.0.0"
|
|
59
|
+
}
|
|
60
60
|
}
|