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.
Files changed (4) hide show
  1. package/README.md +35 -2
  2. package/client.js +63 -11
  3. package/index.js +526 -397
  4. 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: 16,
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: 'submit',
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
- return h('div', {
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('form', {
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
- function cleanUrl(value) {
210
- return urlish(value)
211
- }
212
-
213
- /**
214
- * Persist both addresses to `$DSH_HOME/desktop-url.txt`.
215
- *
216
- * Written to a sibling temp file and renamed into place: a half-written file
217
- * would be read back by the next `/api/desktop-link/info` and silently point
218
- * the button at a truncated URL. An empty pair removes the file entirely, which
219
- * restores the relay-registry fallback instead of pinning a dead address.
220
- * @param desktopUrl - the long-lived origin, or ''.
221
- * @param launchUrl - what a click opens, or '' for "same as desktopUrl".
222
- * @returns the file path written, or '' when the file was removed.
223
- */
224
- function writeUrlFile(desktopUrl, launchUrl) {
225
- const file = join(dshHome(), 'desktop-url.txt')
226
- const lines = []
227
- if (desktopUrl) lines.push(desktopUrl)
228
- // Line 2 only carries meaning next to a line 1; a lone launchUrl is written
229
- // as line 1 so a user who only knows the minting endpoint still gets one.
230
- if (launchUrl && launchUrl !== desktopUrl) {
231
- if (lines.length === 0) lines.push(launchUrl)
232
- else lines.push(launchUrl)
233
- }
234
- if (lines.length === 0) {
235
- try { rmSync(file, { force: true }) } catch { /* already gone */ }
236
- return ''
237
- }
238
- const tmp = `${file}.tmp`
239
- writeFileSync(tmp, `${lines.join('\n')}\n`, { encoding: 'utf8', mode: 0o600 })
240
- renameSync(tmp, file)
241
- return file
242
- }
243
-
244
- /**
245
- * Read a small JSON request body.
246
- *
247
- * Bounded on purpose: this route is reachable from any paired device, and an
248
- * unbounded read would let one request hold the process's memory. The cap is
249
- * far above the two URLs the form sends.
250
- * @param req - the incoming request.
251
- * @returns the parsed object, or null when the body is absent, oversized, or
252
- * not JSON.
253
- */
254
- function readJsonBody(req) {
255
- return new Promise((resolveBody) => {
256
- const chunks = []
257
- let size = 0
258
- let settled = false
259
- const finish = (value) => {
260
- if (settled) return
261
- settled = true
262
- resolveBody(value)
263
- }
264
- req.on('data', (chunk) => {
265
- size += chunk.length
266
- if (size > 8192) { finish(null); req.destroy(); return }
267
- chunks.push(chunk)
268
- })
269
- req.on('end', () => {
270
- if (!chunks.length) return finish({})
271
- try {
272
- const parsed = JSON.parse(Buffer.concat(chunks).toString('utf8'))
273
- finish(parsed && typeof parsed === 'object' ? parsed : null)
274
- } catch {
275
- finish(null)
276
- }
277
- })
278
- req.on('error', () => finish(null))
279
- })
280
- }
281
-
282
- /** Host half: publish the desktop address. */
283
- export const inject = ['webServer']
284
-
285
- /**
286
- * Register the info route and the index global.
287
- *
288
- * The browser half reads `globalThis.__DSHA_DESKTOP_LINK__` synchronously, so
289
- * the button is usable on first paint. `paired` tells it whether this instance
290
- * ever paired a device: when it never has, the client mints a token itself
291
- * through `/api/pair/issue` (same-origin, loopback, so the pairing service
292
- * answers) instead of opening a URL that would hit the harness 401 fence.
293
- *
294
- * `config` is read per request rather than captured once: the settings form
295
- * writes through the Host's config layer, and a request that arrived after such
296
- * a write must see the new value without a restart.
297
- * @param ctx - host plugin context carrying webServer.
298
- * @param config - the row's live config (`{ desktopUrl?, launchUrl? }`).
299
- */
300
- export function apply(ctx, config) {
301
- /**
302
- * A Cordis config value may be a getter-backed proxy that throws when a field
303
- * is read outside the loader's own evaluation, so every read is guarded: a
304
- * failing field is treated as absent, and the chain simply continues.
305
- */
306
- const field = (name) => {
307
- try {
308
- const source = typeof config === 'function' ? config() : config
309
- const value = source?.[name]
310
- return typeof value === 'string' ? value : ''
311
- } catch {
312
- return ''
313
- }
314
- }
315
- const live = () => resolveUrls({ desktopUrl: field('desktopUrl'), launchUrl: field('launchUrl') })
316
-
317
- const disposeGlobal = ctx.on('webserver/index-inject', (table) => {
318
- table.push({ kind: 'global', name: '__DSHA_DESKTOP_LINK__', value: live() })
319
- })
320
-
321
- const info = ctx.webServer.register({
322
- kind: 'exact',
323
- path: '/api/desktop-link/info',
324
- handler(_req, res) {
325
- // Re-read per request: editing desktop-url.txt (or saving the settings
326
- // form) takes effect on the next click without restarting the profile.
327
- // Never let a resolve failure answer with a bare 400 — the button reads
328
- // this and a crash here looks exactly like "the plugin is gone".
329
- let payload
330
- try {
331
- payload = { ok: true, ...live() }
332
- } catch (error) {
333
- payload = { ok: false, error: String(error?.message ?? error) }
334
- }
335
- const body = JSON.stringify(payload)
336
- res.writeHead(200, {
337
- 'content-type': 'application/json; charset=utf-8',
338
- 'content-length': Buffer.byteLength(body),
339
- 'cache-control': 'no-store',
340
- })
341
- res.end(body)
342
- },
343
- })
344
-
345
- /**
346
- * Save the two addresses.
347
- *
348
- * This is the form a phone can actually reach: DSH renders a settings form
349
- * only for entries it considers editable, and an installed bundle's own row
350
- * is not always one of them. The route writes the same file the file-based
351
- * chain reads, so both entry points converge on one source of truth.
352
- *
353
- * POST /api/desktop-link/config { desktopUrl?, launchUrl? }
354
- */
355
- const save = ctx.webServer.register({
356
- kind: 'exact',
357
- path: '/api/desktop-link/config',
358
- handler(req, res) {
359
- const reply = (status, payload) => {
360
- const body = JSON.stringify(payload)
361
- res.writeHead(status, {
362
- 'content-type': 'application/json; charset=utf-8',
363
- 'content-length': Buffer.byteLength(body),
364
- 'cache-control': 'no-store',
365
- })
366
- res.end(body)
367
- }
368
- if (req.method !== 'POST') return reply(405, { ok: false, error: 'POST only' })
369
- readJsonBody(req).then((body) => {
370
- if (!body) return reply(400, { ok: false, error: 'invalid JSON body' })
371
- const desktopUrl = cleanUrl(body.desktopUrl)
372
- const launchUrl = cleanUrl(body.launchUrl)
373
- // A supplied non-empty value that fails validation is an error, not a
374
- // silent "unset": the user typed something and deserves to know it was
375
- // rejected instead of watching the button stay grey.
376
- if (body.desktopUrl && !desktopUrl) {
377
- return reply(400, { ok: false, error: 'desktopUrl must be an http(s) URL' })
378
- }
379
- if (body.launchUrl && !launchUrl) {
380
- return reply(400, { ok: false, error: 'launchUrl must be an http(s) URL' })
381
- }
382
- try {
383
- const path = writeUrlFile(desktopUrl, launchUrl)
384
- return reply(200, { ok: true, path, ...live() })
385
- } catch (error) {
386
- return reply(500, { ok: false, error: String(error?.message ?? error) })
387
- }
388
- })
389
- },
390
- })
391
-
392
- ctx.effect(() => () => {
393
- if (typeof disposeGlobal === 'function') disposeGlobal()
394
- if (typeof info === 'function') info()
395
- if (typeof save === 'function') save()
396
- }, 'dsha-desktop-link: info + config routes + index global')
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 === '&' ? '&amp;' : ch === '<' ? '&lt;' : ch === '>' ? '&gt;' : ch === '"' ? '&quot;' : '&#39;'
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.0",
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
  }