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