dsh-desktop-link 1.0.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +197 -159
- package/assets/icon-180.png +0 -0
- package/assets/icon-96.png +0 -0
- package/client.js +419 -81
- package/index.js +397 -164
- package/package.json +3 -2
package/index.js
CHANGED
|
@@ -1,164 +1,397 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* dsh-desktop-link —— 宿主半区。
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* GET
|
|
8
|
-
* → { ok, launchUrl, desktopUrl, source }
|
|
9
|
-
*
|
|
10
|
-
* launchUrl
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
* `
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
const
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-desktop-link",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"icon": "./icon.svg",
|
|
6
6
|
"exports": {
|
|
@@ -15,7 +15,8 @@
|
|
|
15
15
|
"client.js",
|
|
16
16
|
"cordis.patch.yml",
|
|
17
17
|
"icon.svg",
|
|
18
|
-
"locale"
|
|
18
|
+
"locale",
|
|
19
|
+
"assets"
|
|
19
20
|
],
|
|
20
21
|
"dsh": {
|
|
21
22
|
"engines": {
|