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/README.md +230 -159
- package/assets/icon-180.png +0 -0
- package/assets/icon-96.png +0 -0
- package/client.js +473 -83
- package/index.js +374 -12
- package/package.json +59 -58
package/index.js
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* dsh-desktop-link —— 宿主半区。
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* 只做一件事:把两个地址交给浏览器半区,并允许在界面上改这两个地址。
|
|
5
|
+
* 路由(均为同源 JSON):
|
|
6
6
|
*
|
|
7
|
-
* GET
|
|
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 === '&' ? '&' : 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
|
+
|
|
125
354
|
/** Host half: publish the desktop address. */
|
|
126
355
|
export const inject = ['webServer']
|
|
127
356
|
|
|
128
357
|
/**
|
|
129
|
-
* Register the info route
|
|
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:
|
|
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
|
|
149
|
-
// click without restarting the profile.
|
|
150
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
"
|
|
30
|
-
"
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
"
|
|
37
|
-
"
|
|
38
|
-
|
|
39
|
-
"dsh
|
|
40
|
-
"
|
|
41
|
-
"deepseek
|
|
42
|
-
"
|
|
43
|
-
"
|
|
44
|
-
"
|
|
45
|
-
"
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
"
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
"
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
"
|
|
57
|
-
|
|
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
|
}
|