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/client.js CHANGED
@@ -10,12 +10,48 @@ window.__ModuleLoader__.load({
10
10
  const zh = {
11
11
  'action': '打开桌面版 DSH',
12
12
  'action.short': '桌面版',
13
- 'hint.unset': '还没配桌面地址:把地址写进 ~/.dsh/desktop-url.txt(一行一个 URL)',
13
+ 'settings': '填桌面地址',
14
+ 'hint.unset': '还没配桌面地址:点右边的齿轮填一个,或把地址写进 ~/.dsh/desktop-url.txt',
15
+ 'form.title': '桌面版地址',
16
+ 'form.intro': '填一次就存在这台电脑上,手机和电脑都读同一份。',
17
+ 'form.desktop': '桌面版地址',
18
+ 'form.desktop.hint': '长期地址,例如 https://xxxx.dsh-market.com。留空则自动用固定中继。',
19
+ 'form.launch': '点按钮时打开的地址',
20
+ 'form.launch.hint': '留空就等于上面的地址。填中转的 /open.html 链接可以每次自动铸新配对码。',
21
+ 'form.save': '保存',
22
+ 'form.cancel': '取消',
23
+ 'form.saving': '保存中…',
24
+ 'form.saved': '已保存',
25
+ 'form.current': '当前生效',
26
+ 'form.close': '关闭',
27
+ 'source.config': '页面配置',
28
+ 'source.env': '环境变量',
29
+ 'source.desktop-url.txt': '桌面地址文件',
30
+ 'source.relay-registry': '固定中继',
31
+ 'source.unset': '未配置',
14
32
  }
15
33
  const en = {
16
34
  'action': 'Open desktop DSH',
17
35
  'action.short': 'Desktop',
18
- 'hint.unset': 'No desktop address yet: put one in ~/.dsh/desktop-url.txt (one URL per line)',
36
+ 'settings': 'Set desktop address',
37
+ 'hint.unset': 'No desktop address yet: click the gear to set one, or write it into ~/.dsh/desktop-url.txt',
38
+ 'form.title': 'Desktop address',
39
+ 'form.intro': 'Saved on this computer once; both the phone and the desktop read it.',
40
+ 'form.desktop': 'Desktop address',
41
+ 'form.desktop.hint': 'Long-lived origin, e.g. https://xxxx.dsh-market.com. Empty falls back to the fixed relay.',
42
+ 'form.launch': 'Address opened on click',
43
+ 'form.launch.hint': 'Empty means the same as above. Point it at a relay /open.html link to mint a fresh pair code each time.',
44
+ 'form.save': 'Save',
45
+ 'form.cancel': 'Cancel',
46
+ 'form.saving': 'Saving…',
47
+ 'form.saved': 'Saved',
48
+ 'form.current': 'In effect',
49
+ 'form.close': 'Close',
50
+ 'source.config': 'page config',
51
+ 'source.env': 'environment',
52
+ 'source.desktop-url.txt': 'address file',
53
+ 'source.relay-registry': 'fixed relay',
54
+ 'source.unset': 'not configured',
19
55
  }
20
56
 
21
57
  /* 18×18 monitor with an out-arrow — same artwork as icon.svg, inlined so
@@ -40,45 +76,87 @@ window.__ModuleLoader__.load({
40
76
  }))
41
77
  }
42
78
 
79
+ /* 18×18 gear, drawn at the same weight as Mark so the two sit together. */
80
+ function Gear() {
81
+ return h('svg', {
82
+ viewBox: '0 0 64 64', width: 16, height: 16, 'aria-hidden': true,
83
+ focusable: false, style: { display: 'block', flex: 'none' },
84
+ },
85
+ h('circle', {
86
+ cx: 32, cy: 32, r: 9, fill: 'none', stroke: 'currentColor', strokeWidth: 5,
87
+ }),
88
+ h('path', {
89
+ d: 'M32 6v10M32 48v10M6 32h10M48 32h10M13.6 13.6l7.1 7.1M43.3 43.3l7.1 7.1M50.4 13.6l-7.1 7.1M20.7 43.3l-7.1 7.1',
90
+ fill: 'none', stroke: 'currentColor', strokeWidth: 5, strokeLinecap: 'round',
91
+ }))
92
+ }
93
+
43
94
  /**
44
- * The addresses the host published so far. Both empty means the host half
45
- * resolved nothing — the button then renders disabled with a hint instead
46
- * of sending the browser to an address nobody configured.
95
+ * The live address pair, shared by both sidebar items.
96
+ *
97
+ * The two items are separate components mounted side by side, so the
98
+ * settings dialog's save has to reach the launcher without a page reload.
99
+ * A module-scope store with listeners is the smallest thing that does it —
100
+ * a window event would work too, but this keeps the shape typed in one
101
+ * place and lets a late subscriber read the current value synchronously.
47
102
  */
48
- function published() {
49
- const injected = window.__DSHA_DESKTOP_LINK__
50
- const desktopUrl = injected && typeof injected.desktopUrl === 'string' ? injected.desktopUrl : ''
51
- const launchUrl = injected && typeof injected.launchUrl === 'string' ? injected.launchUrl : ''
52
- return { desktopUrl, launchUrl }
53
- }
103
+ const store = (() => {
104
+ let value = { desktopUrl: '', launchUrl: '', source: 'unset', loaded: false }
105
+ const listeners = new Set()
106
+ const emit = () => { for (const fn of [...listeners]) fn(value) }
107
+ return {
108
+ get: () => value,
109
+ set(patch) {
110
+ const next = { ...value, ...patch }
111
+ if (next.desktopUrl === value.desktopUrl
112
+ && next.launchUrl === value.launchUrl
113
+ && next.source === value.source
114
+ && next.loaded === value.loaded) return
115
+ value = next
116
+ emit()
117
+ },
118
+ subscribe(fn) {
119
+ listeners.add(fn)
120
+ return () => { listeners.delete(fn) }
121
+ },
122
+ }
123
+ })()
54
124
 
55
125
  /**
56
- * Fill in from the info route when the index global is absent.
126
+ * Seed the store from the index global.
57
127
  *
58
- * The global comes from `webserver/index-inject`, so it only appears on an
59
- * index rendered after the Host half loaded; the route answers on the
60
- * running Host either way. Cached on the window so later renders and clicks
61
- * agree without another request.
62
- * @returns the published addresses, once known.
128
+ * The global is injected at index render time, so it can be absent on a
129
+ * page served before the Host half loaded; in that case the info route is
130
+ * asked once. Either way the result is pushed into the shared store so both
131
+ * sidebar items agree.
63
132
  */
64
- function hydrate() {
133
+ function seed() {
65
134
  const injected = window.__DSHA_DESKTOP_LINK__
66
- if (injected && injected.desktopUrl) return Promise.resolve(published())
135
+ if (injected && typeof injected === 'object') {
136
+ store.set({
137
+ desktopUrl: typeof injected.desktopUrl === 'string' ? injected.desktopUrl : '',
138
+ launchUrl: typeof injected.launchUrl === 'string' ? injected.launchUrl : '',
139
+ source: typeof injected.source === 'string' ? injected.source : 'unset',
140
+ loaded: true,
141
+ })
142
+ if (injected.desktopUrl) return Promise.resolve()
143
+ }
67
144
  return fetch('/api/desktop-link/info', {
68
145
  credentials: 'same-origin',
69
146
  headers: { accept: 'application/json' },
70
147
  }).then((res) => (res.ok ? res.json() : null))
71
148
  .then((body) => {
72
- if (!body || typeof body.desktopUrl !== 'string' || !body.desktopUrl) return published()
73
- const merged = {
74
- desktopUrl: body.desktopUrl,
149
+ if (!body || !body.ok) return
150
+ const next = {
151
+ desktopUrl: typeof body.desktopUrl === 'string' ? body.desktopUrl : '',
75
152
  launchUrl: typeof body.launchUrl === 'string' ? body.launchUrl : '',
76
- paired: Boolean(body.paired),
153
+ source: typeof body.source === 'string' ? body.source : 'unset',
154
+ loaded: true,
77
155
  }
78
- window.__DSHA_DESKTOP_LINK__ = merged
79
- return merged
156
+ window.__DSHA_DESKTOP_LINK__ = { ...(injected || {}), ...next }
157
+ store.set(next)
80
158
  })
81
- .catch(() => published())
159
+ .catch(() => { /* the button stays as-is; the gear still opens */ })
82
160
  }
83
161
 
84
162
  /** Whether this page runs on a handset (DSHA's WebView or a phone browser). */
@@ -87,6 +165,11 @@ window.__ModuleLoader__.load({
87
165
  return /Android|iPhone|iPad|iPod|HarmonyOS|Mobile/i.test(ua)
88
166
  }
89
167
 
168
+ /** The URL a click should carry, or '' when nothing is configured. */
169
+ function linkHref(value) {
170
+ return value.launchUrl || value.desktopUrl
171
+ }
172
+
90
173
  /**
91
174
  * Mint a one-shot pair URL from this instance's own pairing service.
92
175
  *
@@ -108,31 +191,62 @@ window.__ModuleLoader__.load({
108
191
  .catch(() => '')
109
192
  }
110
193
 
111
- /**
112
- * The URL the anchor carries, so middle-click and "copy link" stay
113
- * meaningful. A configured `launchUrl` wins, else the desktop origin.
114
- * @returns the absolute URL.
115
- */
116
- function linkHref() {
117
- const { desktopUrl, launchUrl } = published()
118
- return launchUrl || desktopUrl
194
+ /** The dialog's open state, shared so the launcher can open it too. */
195
+ const dialog = (() => {
196
+ let open = false
197
+ const listeners = new Set()
198
+ const emit = () => { for (const fn of [...listeners]) fn(open) }
199
+ return {
200
+ isOpen: () => open,
201
+ show() { if (!open) { open = true; emit() } },
202
+ hide() { if (open) { open = false; emit() } },
203
+ subscribe(fn) { listeners.add(fn); return () => { listeners.delete(fn) } },
204
+ }
205
+ })()
206
+
207
+ const baseButtonStyle = (wide) => ({
208
+ display: 'inline-flex',
209
+ alignItems: 'center',
210
+ justifyContent: wide ? 'flex-start' : 'center',
211
+ flex: 'none',
212
+ height: 28,
213
+ minWidth: 28,
214
+ padding: wide ? '0 8px' : 0,
215
+ boxSizing: 'border-box',
216
+ borderRadius: 'var(--dsw-radius-sm, 6px)',
217
+ color: 'var(--dsw-alias-label-secondary)',
218
+ background: 'transparent',
219
+ border: 'none',
220
+ cursor: 'pointer',
221
+ fontSize: 13,
222
+ fontFamily: 'inherit',
223
+ textDecoration: 'none',
224
+ whiteSpace: 'nowrap',
225
+ })
226
+
227
+ const hoverIn = (e) => {
228
+ e.currentTarget.style.background = 'var(--dsw-alias-interactive-bg-hover, rgba(127,127,127,.12))'
119
229
  }
230
+ const hoverOut = (e) => { e.currentTarget.style.background = 'transparent' }
120
231
 
121
232
  /**
122
- * Handset click: navigate this page to a freshly minted pair URL.
233
+ * Subscribe a component to one of the module-scope stores.
123
234
  *
124
- * A top-level navigation needs no user gesture, so this survives the await
125
- * that the popup-blocked `window.open` variant did not. The DSHA WebView
126
- * sees the navigation leave its own service and hands it to the system
127
- * browser, which is exactly the desired jump.
128
- * @param fallback - the URL to use when minting is unavailable.
235
+ * Deliberately not `useSyncExternalStore`: this plugin must run on whatever
236
+ * React the page shell ships, and the plain state+effect form works on every
237
+ * version that has hooks at all.
238
+ * @param source - object with `subscribe` and a getter.
239
+ * @param read - selector from the store value to the slice worth rendering.
240
+ * @returns the current slice, re-rendering on change.
129
241
  */
130
- function openOnHandset(fallback) {
131
- mintPairUrl().then((url) => { window.location.assign(url || fallback) })
242
+ function useStore(source, read) {
243
+ const [, force] = React.useState(0)
244
+ React.useEffect(() => source.subscribe(() => force((n) => n + 1)), [source])
245
+ return read()
132
246
  }
133
247
 
134
248
  /**
135
- * Sidebar-foot button: "open the desktop DSH".
249
+ * Row 1: "open the desktop DSH".
136
250
  *
137
251
  * Two paths, both deterministic:
138
252
  * - Handset: no second window exists, so a top-level navigation is the
@@ -140,67 +254,45 @@ window.__ModuleLoader__.load({
140
254
  * `window.open`) tolerates, and DSHA then hands it to the system browser.
141
255
  * - Desktop: a plain `target="_blank"` anchor opens a second tab and leaves
142
256
  * the current GUI alone. No script runs, so nothing can be blocked.
257
+ *
258
+ * With nothing configured the click opens the settings dialog instead of
259
+ * doing nothing — that is the only way a phone user can ever get an address
260
+ * in, since it has no shell to write the file with.
143
261
  */
144
262
  function DesktopLinkButton(props) {
145
263
  const wide = props.wide !== false
146
264
  const t = props.t || ((key) => zh[key] || en[key] || key)
147
- const [href, setHref] = React.useState(() => linkHref())
148
- React.useEffect(() => {
149
- let alive = true
150
- hydrate().then(() => { if (alive) setHref(linkHref()) })
151
- return () => { alive = false }
152
- }, [])
265
+ const value = useStore(store, () => store.get())
266
+ const href = linkHref(value)
267
+
268
+ React.useEffect(() => { seed() }, [])
153
269
 
154
270
  const onClick = (event) => {
155
- // Nothing configured: stay inert and let the title/hint explain why.
156
- if (!href) { event.preventDefault(); return }
271
+ if (!href) { event.preventDefault(); dialog.show(); return }
157
272
  // Let modifier-clicks and middle-clicks fall through to the anchor.
158
273
  if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return
159
274
  if (!isHandset()) return // desktop keeps the plain _blank anchor
160
275
  event.preventDefault()
161
276
  // A configured minting endpoint already hands out a fresh token, so
162
277
  // navigating there is enough; otherwise mint here.
163
- const { desktopUrl, launchUrl } = published()
164
- if (launchUrl && launchUrl !== desktopUrl) {
165
- window.location.assign(launchUrl)
278
+ if (value.launchUrl && value.launchUrl !== value.desktopUrl) {
279
+ window.location.assign(value.launchUrl)
166
280
  return
167
281
  }
168
- openOnHandset(desktopUrl)
169
- }
170
-
171
- const style = {
172
- display: 'inline-flex',
173
- alignItems: 'center',
174
- justifyContent: wide ? 'flex-start' : 'center',
175
- flex: 'none',
176
- height: 28,
177
- minWidth: 28,
178
- padding: wide ? '0 8px' : 0,
179
- boxSizing: 'border-box',
180
- borderRadius: 'var(--dsw-radius-sm, 6px)',
181
- color: 'var(--dsw-alias-label-secondary)',
182
- background: 'transparent',
183
- border: 'none',
184
- cursor: 'pointer',
185
- fontSize: 13,
186
- fontFamily: 'inherit',
187
- textDecoration: 'none',
188
- whiteSpace: 'nowrap',
282
+ mintPairUrl().then((url) => { window.location.assign(url || value.desktopUrl) })
189
283
  }
190
284
 
191
285
  return h('a', {
192
- href,
286
+ href: href || undefined,
193
287
  target: '_blank',
194
288
  rel: 'noopener noreferrer',
195
- style: { ...style, opacity: href ? 1 : 0.45 },
289
+ style: { ...baseButtonStyle(wide), opacity: href ? 1 : 0.45 },
196
290
  title: href || t('hint.unset'),
197
291
  'aria-label': t('action'),
198
292
  'aria-disabled': href ? undefined : 'true',
199
293
  onClick,
200
- onMouseEnter: (e) => {
201
- e.currentTarget.style.background = 'var(--dsw-alias-interactive-bg-hover, rgba(127,127,127,.12))'
202
- },
203
- onMouseLeave: (e) => { e.currentTarget.style.background = 'transparent' },
294
+ onMouseEnter: hoverIn,
295
+ onMouseLeave: hoverOut,
204
296
  },
205
297
  h(Mark),
206
298
  wide
@@ -210,6 +302,244 @@ window.__ModuleLoader__.load({
210
302
  : null)
211
303
  }
212
304
 
305
+ /**
306
+ * Row 2: the gear.
307
+ *
308
+ * A separate slot item rather than a modified click on the launcher: a
309
+ * long-press has no affordance on a phone, and a second small target next
310
+ * to the first is the one pattern that reads the same on both platforms.
311
+ */
312
+ function DesktopLinkSettingsButton(props) {
313
+ const wide = props.wide !== false
314
+ const t = props.t || ((key) => zh[key] || en[key] || key)
315
+ return h(React.Fragment, null,
316
+ h('button', {
317
+ type: 'button',
318
+ style: baseButtonStyle(false),
319
+ title: t('settings'),
320
+ 'aria-label': t('settings'),
321
+ onClick: () => dialog.show(),
322
+ onMouseEnter: hoverIn,
323
+ onMouseLeave: hoverOut,
324
+ }, h(Gear)),
325
+ h(DesktopLinkSettingsDialog, { t }))
326
+ }
327
+
328
+ const overlayStyle = {
329
+ position: 'fixed',
330
+ inset: 0,
331
+ zIndex: 2147483000,
332
+ display: 'flex',
333
+ alignItems: 'center',
334
+ justifyContent: 'center',
335
+ padding: 16,
336
+ background: 'rgba(0,0,0,.45)',
337
+ }
338
+ const cardStyle = {
339
+ width: 'min(520px, 100%)',
340
+ maxHeight: 'calc(100vh - 32px)',
341
+ overflow: 'auto',
342
+ boxSizing: 'border-box',
343
+ padding: 20,
344
+ borderRadius: 12,
345
+ background: 'var(--dsw-alias-bg-overlay, var(--dsw-alias-bg-base, #fff))',
346
+ color: 'var(--dsw-alias-label-primary, inherit)',
347
+ boxShadow: '0 12px 40px rgba(0,0,0,.28)',
348
+ fontFamily: 'inherit',
349
+ fontSize: 14,
350
+ lineHeight: 1.5,
351
+ }
352
+ const labelStyle = { display: 'block', fontSize: 13, fontWeight: 600, marginBottom: 6 }
353
+ const inputStyle = {
354
+ width: '100%',
355
+ boxSizing: 'border-box',
356
+ padding: '8px 10px',
357
+ borderRadius: 8,
358
+ border: '1px solid var(--dsw-alias-border-weak, rgba(127,127,127,.35))',
359
+ background: 'var(--dsw-alias-bg-base, transparent)',
360
+ color: 'inherit',
361
+ fontFamily: 'inherit',
362
+ fontSize: 14,
363
+ }
364
+ const hintStyle = { margin: '6px 0 0', fontSize: 12, opacity: .7, lineHeight: 1.45 }
365
+ const actionStyle = (primary) => ({
366
+ height: 34,
367
+ padding: '0 16px',
368
+ borderRadius: 8,
369
+ border: primary ? 'none' : '1px solid var(--dsw-alias-border-weak, rgba(127,127,127,.35))',
370
+ background: primary ? 'var(--dsw-alias-brand-primary, #4d6bfe)' : 'transparent',
371
+ color: primary ? '#fff' : 'inherit',
372
+ fontFamily: 'inherit',
373
+ fontSize: 14,
374
+ cursor: 'pointer',
375
+ })
376
+
377
+ /**
378
+ * The settings dialog.
379
+ *
380
+ * Mounted once, unconditionally, and driven by the shared open flag: a
381
+ * dialog rendered inside the button would unmount with it, and the sidebar
382
+ * row can re-render for unrelated reasons.
383
+ *
384
+ * Saving posts to the Host, which writes `desktop-url.txt` — the same file
385
+ * the file-based resolution chain reads — and answers with the resolved
386
+ * pair, so the form shows what actually took effect rather than what was
387
+ * typed.
388
+ */
389
+ function DesktopLinkSettingsDialog(props) {
390
+ const t = (props && props.t) || ((key) => zh[key] || en[key] || key)
391
+ const open = useStore(dialog, () => dialog.isOpen())
392
+ const value = useStore(store, () => store.get())
393
+ const [desktop, setDesktop] = React.useState('')
394
+ const [launch, setLaunch] = React.useState('')
395
+ const [status, setStatus] = React.useState('idle')
396
+ const [error, setError] = React.useState('')
397
+ const fallback = t
398
+
399
+ // Seed the fields from the live pair each time the dialog opens, so a
400
+ // second visit never shows a stale draft.
401
+ React.useEffect(() => {
402
+ if (!open) return
403
+ setDesktop(value.desktopUrl || '')
404
+ setLaunch(value.launchUrl && value.launchUrl !== value.desktopUrl ? value.launchUrl : '')
405
+ setStatus('idle')
406
+ setError('')
407
+ }, [open])
408
+
409
+ React.useEffect(() => {
410
+ if (!open) return
411
+ const onKey = (event) => { if (event.key === 'Escape') dialog.hide() }
412
+ window.addEventListener('keydown', onKey)
413
+ return () => window.removeEventListener('keydown', onKey)
414
+ }, [open])
415
+
416
+ if (!open) return null
417
+
418
+ const submit = (event) => {
419
+ event.preventDefault()
420
+ if (status === 'saving') return
421
+ setStatus('saving')
422
+ setError('')
423
+ fetch('/api/desktop-link/config', {
424
+ method: 'POST',
425
+ credentials: 'same-origin',
426
+ headers: { 'content-type': 'application/json', accept: 'application/json' },
427
+ body: JSON.stringify({ desktopUrl: desktop.trim(), launchUrl: launch.trim() }),
428
+ })
429
+ .then((res) => res.json().then((body) => ({ ok: res.ok, body })))
430
+ .then(({ ok, body }) => {
431
+ if (!ok || !body || body.ok !== true) {
432
+ setError((body && body.error) || 'save failed')
433
+ setStatus('idle')
434
+ return
435
+ }
436
+ const next = {
437
+ desktopUrl: typeof body.desktopUrl === 'string' ? body.desktopUrl : '',
438
+ launchUrl: typeof body.launchUrl === 'string' ? body.launchUrl : '',
439
+ source: typeof body.source === 'string' ? body.source : 'unset',
440
+ loaded: true,
441
+ }
442
+ window.__DSHA_DESKTOP_LINK__ = { ...(window.__DSHA_DESKTOP_LINK__ || {}), ...next }
443
+ store.set(next)
444
+ setStatus('saved')
445
+ })
446
+ .catch((err) => { setError(String(err && err.message ? err.message : err)); setStatus('idle') })
447
+ }
448
+
449
+ const sourceText = (fallback(`source.${value.source}`))
450
+ const body = []
451
+
452
+ body.push(h('h2', {
453
+ key: 'title',
454
+ style: { margin: '0 0 4px', fontSize: 17, fontWeight: 650 },
455
+ }, fallback('form.title')))
456
+ body.push(h('p', {
457
+ key: 'intro',
458
+ style: { margin: '0 0 16px', fontSize: 12, opacity: .72 },
459
+ }, fallback('form.intro')))
460
+
461
+ if (value.desktopUrl) {
462
+ body.push(h('p', {
463
+ key: 'current',
464
+ style: {
465
+ margin: '0 0 16px', padding: '8px 10px', borderRadius: 8, fontSize: 12,
466
+ background: 'var(--dsw-alias-interactive-bg-hover, rgba(127,127,127,.1))',
467
+ wordBreak: 'break-all',
468
+ },
469
+ }, `${fallback('form.current')}(${sourceText}):${value.launchUrl || value.desktopUrl}`))
470
+ }
471
+
472
+ body.push(h('div', { key: 'desktop', style: { marginBottom: 14 } },
473
+ h('label', { style: labelStyle, htmlFor: 'dsh-desktop-link-desktop' }, fallback('form.desktop')),
474
+ h('input', {
475
+ id: 'dsh-desktop-link-desktop',
476
+ type: 'url',
477
+ inputMode: 'url',
478
+ autoCapitalize: 'off',
479
+ autoCorrect: 'off',
480
+ spellCheck: false,
481
+ placeholder: 'https://xxxx.dsh-market.com',
482
+ value: desktop,
483
+ onChange: (e) => setDesktop(e.target.value),
484
+ style: inputStyle,
485
+ }),
486
+ h('p', { style: hintStyle }, fallback('form.desktop.hint'))))
487
+
488
+ body.push(h('div', { key: 'launch', style: { marginBottom: 14 } },
489
+ h('label', { style: labelStyle, htmlFor: 'dsh-desktop-link-launch' }, fallback('form.launch')),
490
+ h('input', {
491
+ id: 'dsh-desktop-link-launch',
492
+ type: 'url',
493
+ inputMode: 'url',
494
+ autoCapitalize: 'off',
495
+ autoCorrect: 'off',
496
+ spellCheck: false,
497
+ placeholder: 'https://…/open.html?k=…',
498
+ value: launch,
499
+ onChange: (e) => setLaunch(e.target.value),
500
+ style: inputStyle,
501
+ }),
502
+ h('p', { style: hintStyle }, fallback('form.launch.hint'))))
503
+
504
+ if (error) {
505
+ body.push(h('p', {
506
+ key: 'error',
507
+ style: { margin: '0 0 12px', fontSize: 12, color: 'var(--dsw-alias-danger, #d64545)' },
508
+ }, error))
509
+ }
510
+
511
+ body.push(h('div', {
512
+ key: 'actions',
513
+ style: { display: 'flex', gap: 8, justifyContent: 'flex-end', alignItems: 'center' },
514
+ },
515
+ status === 'saved'
516
+ ? h('span', { style: { marginRight: 'auto', fontSize: 12, opacity: .75 } }, fallback('form.saved'))
517
+ : null,
518
+ h('button', {
519
+ type: 'button',
520
+ style: actionStyle(false),
521
+ onClick: () => dialog.hide(),
522
+ }, fallback('form.cancel')),
523
+ h('button', {
524
+ type: 'submit',
525
+ style: actionStyle(true),
526
+ disabled: status === 'saving',
527
+ onClick: submit,
528
+ }, status === 'saving' ? fallback('form.saving') : fallback('form.save'))))
529
+
530
+ return h('div', {
531
+ role: 'dialog',
532
+ 'aria-modal': 'true',
533
+ 'aria-label': fallback('form.title'),
534
+ style: overlayStyle,
535
+ onClick: (event) => { if (event.target === event.currentTarget) dialog.hide() },
536
+ }, h('form', {
537
+ onSubmit: submit,
538
+ style: cardStyle,
539
+ onClick: (event) => event.stopPropagation(),
540
+ }, body))
541
+ }
542
+
213
543
  return {
214
544
  inject: ['slots', 'locale'],
215
545
  apply(ctx) {
@@ -226,6 +556,14 @@ window.__ModuleLoader__.load({
226
556
  label: () => ctx.locale.bind(NS)('action'),
227
557
  locale: NS,
228
558
  }, DesktopLinkButton))
559
+
560
+ ctx.slots.inject('sidebar.footer.action', () => ctx.slots.register({
561
+ name: 'sidebar.footer.action',
562
+ id: 'dsha-desktop-link-settings',
563
+ order: 11,
564
+ label: () => ctx.locale.bind(NS)('settings'),
565
+ locale: NS,
566
+ }, DesktopLinkSettingsButton))
229
567
  },
230
568
  }
231
569
  },