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/index.js CHANGED
@@ -1,164 +1,397 @@
1
- /**
2
- * dsh-desktop-link —— 宿主半区。
3
- *
4
- * 只做一件事:把两个地址交给浏览器半区。
5
- * 唯一的路由是一条只读 JSON:
6
- *
7
- * GET /api/desktop-link/info
8
- * → { ok, launchUrl, desktopUrl, source }
9
- *
10
- * launchUrl 点按钮时真正打开的地址(可以是中转的一次性链接)
11
- * desktopUrl 桌面版 GUI 的长期地址(书签用,也是 launchUrl 的兜底)
12
- *
13
- * desktopUrl 的来源优先级(命中即停):
14
- * 1. 本行 config.desktopUrl
15
- * 2. 环境变量 DSH_DESKTOP_URL
16
- * 3. $DSH_HOME/desktop-url.txt 第一行
17
- * 4. $DSH_HOME/remote-web-ui-registry/web.json 的 id → https://<id>.dsh-market.com
18
- *
19
- * launchUrl 额外支持 config.launchUrl / 环境变量 DSH_DESKTOP_LAUNCH_URL,
20
- * 用来指向 dsha-pair/bridge.py 的 /open.html(每次点都铸一枚新码)。
21
- * 没配 launchUrl 时,launchUrl 恒等于 desktopUrl。
22
- */
23
- import { existsSync, readFileSync } from 'node:fs'
24
- import { homedir } from 'node:os'
25
- import { join } from 'node:path'
26
-
27
- function dshHome() {
28
- const fromEnv = process.env.DSH_HOME
29
- if (typeof fromEnv === 'string' && fromEnv.trim()) return fromEnv.trim()
30
- return join(homedir(), '.dsh')
31
- }
32
-
33
- function urlish(value) {
34
- return typeof value === 'string' && /^https?:\/\/\S+$/i.test(value.trim()) ? value.trim() : ''
35
- }
36
-
37
- /**
38
- * Every URL in a hand-edited comment-friendly file under the DSH home.
39
- *
40
- * `desktop-url.txt` takes up to two lines, in this order:
41
- * line 1 = desktopUrl, the long-lived desktop origin (bookmark target)
42
- * line 2 = launchUrl, what a click actually opens (a minting endpoint)
43
- * A single line therefore means "open this one thing". `#` comments and blank
44
- * lines are skipped, and anything that is not http(s) is ignored rather than
45
- * returned half-parsed.
46
- * @param name - file name inside the DSH home.
47
- * @returns the URLs found, in file order.
48
- */
49
- function readUrlLines(name) {
50
- try {
51
- const file = join(dshHome(), name)
52
- if (!existsSync(file)) return []
53
- const found = []
54
- for (const raw of readFileSync(file, 'utf8').split(/\r?\n/)) {
55
- const line = raw.trim()
56
- if (!line || line.startsWith('#')) continue
57
- const value = urlish(line)
58
- if (value) found.push(value)
59
- }
60
- return found
61
- } catch {
62
- return []
63
- }
64
- }
65
-
66
- function readRelayOrigin() {
67
- try {
68
- const file = join(dshHome(), 'remote-web-ui-registry', 'web.json')
69
- if (!existsSync(file)) return ''
70
- const parsed = JSON.parse(readFileSync(file, 'utf8'))
71
- const id = typeof parsed?.id === 'string' ? parsed.id.trim() : ''
72
- return /^[A-Za-z0-9-]{8,}$/.test(id) ? `https://${id}.dsh-market.com` : ''
73
- } catch {
74
- return ''
75
- }
76
- }
77
-
78
- /**
79
- * Whether this instance has ever paired a device.
80
- *
81
- * The roster is `dsh-remote-web-ui`'s own persisted store
82
- * (`$DSH_HOME/remote-web-ui-devices.json`, 0600, its documented default). It is
83
- * read only for this boolean — no ids, no credentials — and a missing or
84
- * unreadable file simply means "nothing paired yet", which is the safe answer:
85
- * the button then mints a token instead of opening a URL that would 401.
86
- * @returns whether at least one device session exists on disk.
87
- */
88
- function hasPairedDevice() {
89
- try {
90
- const file = join(dshHome(), 'remote-web-ui-devices.json')
91
- if (!existsSync(file)) return false
92
- const parsed = JSON.parse(readFileSync(file, 'utf8'))
93
- const rows = Array.isArray(parsed) ? parsed : (parsed?.devices ?? [])
94
- return Array.isArray(rows) ? rows.length > 0 : Boolean(rows && Object.keys(rows).length)
95
- } catch {
96
- return false
97
- }
98
- }
99
-
100
- function resolveUrls(config) {
101
- const fileUrls = readUrlLines('desktop-url.txt')
102
- const chain = [
103
- ['config', () => urlish(config?.desktopUrl)],
104
- ['env', () => urlish(process.env.DSH_DESKTOP_URL)],
105
- ['desktop-url.txt', () => fileUrls[0] ?? ''],
106
- ['relay-registry', readRelayOrigin],
107
- ]
108
- let desktopUrl = ''
109
- let source = 'unset'
110
- for (const [label, read] of chain) {
111
- const value = read()
112
- if (value) { desktopUrl = value; source = label; break }
113
- }
114
- // launchUrl is what a click opens. In order: the row's config, the
115
- // environment, line 2 of desktop-url.txt (a local minting endpoint such as
116
- // dsha-pair/bridge.py's /open.html?k=…), then the desktop origin itself.
117
- const launchUrl =
118
- urlish(config?.launchUrl) ||
119
- urlish(process.env.DSH_DESKTOP_LAUNCH_URL) ||
120
- fileUrls[1] ||
121
- desktopUrl
122
- return { launchUrl, desktopUrl, source }
123
- }
124
-
125
- /** Host half: publish the desktop address. */
126
- export const inject = ['webServer']
127
-
128
- /**
129
- * Register the info route, the launch route, and the index global.
130
- *
131
- * The browser half reads `globalThis.__DSHA_DESKTOP_LINK__` synchronously, so
132
- * the button is usable on first paint. `paired` tells it whether this instance
133
- * ever paired a device: when it never has, the client mints a token itself
134
- * through `/api/pair/issue` (same-origin, loopback, so the pairing service
135
- * answers) instead of opening a URL that would hit the harness 401 fence.
136
- * @param ctx - host plugin context carrying webServer.
137
- * @param config - the row's config (`{ desktopUrl?, launchUrl? }`).
138
- */
139
- export function apply(ctx, config) {
140
- const disposeGlobal = ctx.on('webserver/index-inject', (table) => {
141
- table.push({ kind: 'global', name: '__DSHA_DESKTOP_LINK__', value: resolveUrls(config) })
142
- })
143
-
144
- const info = ctx.webServer.register({
145
- kind: 'exact',
146
- path: '/api/desktop-link/info',
147
- 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) })
151
- res.writeHead(200, {
152
- 'content-type': 'application/json; charset=utf-8',
153
- 'content-length': Buffer.byteLength(body),
154
- 'cache-control': 'no-store',
155
- })
156
- res.end(body)
157
- },
158
- })
159
-
160
- ctx.effect(() => () => {
161
- if (typeof disposeGlobal === 'function') disposeGlobal()
162
- if (typeof info === 'function') info()
163
- }, 'dsha-desktop-link: info route + index global')
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.0.0",
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": {