dsh-desktop-link 1.0.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/index.js CHANGED
@@ -1,12 +1,15 @@
1
1
  /**
2
2
  * dsh-desktop-link —— 宿主半区。
3
3
  *
4
- * 只做一件事:把两个地址交给浏览器半区。
5
- * 唯一的路由是一条只读 JSON:
4
+ * 只做一件事:把两个地址交给浏览器半区,并允许在界面上改这两个地址。
5
+ * 路由(均为同源 JSON):
6
6
  *
7
- * GET /api/desktop-link/info
7
+ * GET /api/desktop-link/info
8
8
  * → { ok, launchUrl, desktopUrl, source }
9
9
  *
10
+ * POST /api/desktop-link/config { desktopUrl?, launchUrl? }
11
+ * → { ok, path, launchUrl, desktopUrl, source };写入 $DSH_HOME/desktop-url.txt
12
+ *
10
13
  * launchUrl 点按钮时真正打开的地址(可以是中转的一次性链接)
11
14
  * desktopUrl 桌面版 GUI 的长期地址(书签用,也是 launchUrl 的兜底)
12
15
  *
@@ -20,9 +23,84 @@
20
23
  * 用来指向 dsha-pair/bridge.py 的 /open.html(每次点都铸一枚新码)。
21
24
  * 没配 launchUrl 时,launchUrl 恒等于 desktopUrl。
22
25
  */
23
- import { existsSync, readFileSync } from 'node:fs'
26
+ import { existsSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'
27
+ import { createRequire } from 'node:module'
24
28
  import { homedir } from 'node:os'
25
- import { join } from 'node:path'
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 }
26
104
 
27
105
  function dshHome() {
28
106
  const fromEnv = process.env.DSH_HOME
@@ -122,32 +200,211 @@ function resolveUrls(config) {
122
200
  return { launchUrl, desktopUrl, source }
123
201
  }
124
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
+
125
354
  /** Host half: publish the desktop address. */
126
355
  export const inject = ['webServer']
127
356
 
128
357
  /**
129
- * Register the info route, the launch route, and the index global.
358
+ * Register the info route and the index global.
130
359
  *
131
360
  * The browser half reads `globalThis.__DSHA_DESKTOP_LINK__` synchronously, so
132
361
  * the button is usable on first paint. `paired` tells it whether this instance
133
362
  * ever paired a device: when it never has, the client mints a token itself
134
363
  * through `/api/pair/issue` (same-origin, loopback, so the pairing service
135
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.
136
369
  * @param ctx - host plugin context carrying webServer.
137
- * @param config - the row's config (`{ desktopUrl?, launchUrl? }`).
370
+ * @param config - the row's live config (`{ desktopUrl?, launchUrl? }`).
138
371
  */
139
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
+
140
389
  const disposeGlobal = ctx.on('webserver/index-inject', (table) => {
141
- table.push({ kind: 'global', name: '__DSHA_DESKTOP_LINK__', value: resolveUrls(config) })
390
+ table.push({ kind: 'global', name: '__DSHA_DESKTOP_LINK__', value: live() })
142
391
  })
143
392
 
144
393
  const info = ctx.webServer.register({
145
394
  kind: 'exact',
146
395
  path: '/api/desktop-link/info',
147
396
  handler(_req, res) {
148
- // Re-read per request: editing desktop-url.txt takes effect on the next
149
- // click without restarting the profile.
150
- const body = JSON.stringify({ ok: true, ...resolveUrls(config) })
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)
151
408
  res.writeHead(200, {
152
409
  'content-type': 'application/json; charset=utf-8',
153
410
  'content-length': Buffer.byteLength(body),
@@ -157,8 +414,113 @@ export function apply(ctx, config) {
157
414
  },
158
415
  })
159
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
+
160
520
  ctx.effect(() => () => {
161
521
  if (typeof disposeGlobal === 'function') disposeGlobal()
162
522
  if (typeof info === 'function') info()
163
- }, 'dsha-desktop-link: info route + index global')
523
+ if (typeof save === 'function') save()
524
+ if (typeof setRoute === 'function') setRoute()
525
+ }, 'dsha-desktop-link: info + config routes + index global')
164
526
  }
package/package.json CHANGED
@@ -1,59 +1,60 @@
1
- {
2
- "name": "dsh-desktop-link",
3
- "version": "1.0.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
- ],
20
- "dsh": {
21
- "engines": {
22
- "dsh": ">=0.2.1-rc.2"
23
- },
24
- "bundle": {
25
- "patch": "./cordis.patch.yml"
26
- },
27
- "client": {
28
- "platform": "web",
29
- "immediately": true,
30
- "inject": [
31
- "@deepseek-ai/dsh-client-ui-sidebar"
32
- ]
33
- }
34
- },
35
- "license": "MIT",
36
- "author": "lzlrt",
37
- "keywords": [
38
- "dsh",
39
- "dsh-plugin",
40
- "deepseek",
41
- "deepseek-harness",
42
- "dsha",
43
- "remote",
44
- "sidebar",
45
- "mobile"
46
- ],
47
- "repository": {
48
- "type": "git",
49
- "url": "git+https://github.com/lzlrt/dsha-desktop-link.git"
50
- },
51
- "homepage": "https://github.com/lzlrt/dsha-desktop-link#readme",
52
- "bugs": {
53
- "url": "https://github.com/lzlrt/dsha-desktop-link/issues"
54
- },
55
- "description": "DSH sidebar button: open the desktop DSH Web GUI from DSHA on your phone, in one tap.",
56
- "engines": {
57
- "node": "^22.19.0 || >=24.0.0"
58
- }
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
+ }
59
60
  }