dsh-desktop-link 1.0.0 → 1.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +230 -159
- package/assets/icon-180.png +0 -0
- package/assets/icon-96.png +0 -0
- package/client.js +473 -83
- package/index.js +374 -12
- package/package.json +59 -58
package/client.js
CHANGED
|
@@ -10,12 +10,50 @@ window.__ModuleLoader__.load({
|
|
|
10
10
|
const zh = {
|
|
11
11
|
'action': '打开桌面版 DSH',
|
|
12
12
|
'action.short': '桌面版',
|
|
13
|
-
'
|
|
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
|
-
'
|
|
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
|
|
45
|
-
*
|
|
46
|
-
*
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
const
|
|
51
|
-
const
|
|
52
|
-
return {
|
|
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
|
-
*
|
|
128
|
+
* Seed the store from the index global.
|
|
57
129
|
*
|
|
58
|
-
* The global
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
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
|
|
135
|
+
function seed() {
|
|
65
136
|
const injected = window.__DSHA_DESKTOP_LINK__
|
|
66
|
-
if (injected && injected
|
|
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 ||
|
|
73
|
-
const
|
|
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
|
-
|
|
155
|
+
source: typeof body.source === 'string' ? body.source : 'unset',
|
|
156
|
+
loaded: true,
|
|
77
157
|
}
|
|
78
|
-
window.__DSHA_DESKTOP_LINK__ =
|
|
79
|
-
|
|
158
|
+
window.__DSHA_DESKTOP_LINK__ = { ...(injected || {}), ...next }
|
|
159
|
+
store.set(next)
|
|
80
160
|
})
|
|
81
|
-
.catch(() =>
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
125
|
-
*
|
|
126
|
-
*
|
|
127
|
-
*
|
|
128
|
-
*
|
|
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
|
-
|
|
131
|
-
|
|
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
|
-
*
|
|
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
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
-
|
|
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
|
-
|
|
164
|
-
|
|
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
|
-
|
|
326
|
+
mintPairUrl().then((url) => { window.location.assign(url || value.desktopUrl) })
|
|
169
327
|
}
|
|
170
328
|
|
|
171
|
-
|
|
172
|
-
|
|
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: { ...
|
|
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:
|
|
201
|
-
|
|
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
|
},
|